mirror of
https://github.com/jiayouzl/Bitcoin-Monitoring.git
synced 2025-11-25 03:15:24 +08:00
feat: Add support for custom cryptocurrency symbols and caching mechanism
- Implemented CustomCryptoSymbol model to handle user-defined symbols. - Enhanced PriceManager to manage custom symbols, including fetching and caching prices. - Updated PriceService to support fetching prices for custom symbols. - Modified CryptoSymbol to include methods for symbol validation and retrieval. - Redesigned PreferencesWindowView to allow users to add and delete custom symbols with validation. - Added caching logic for custom symbol prices to improve performance. - Updated error handling in PriceError to account for custom symbol scenarios.
This commit is contained in:
@@ -42,11 +42,31 @@ class MenuBarManager: NSObject, ObservableObject {
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// 监听币种配置变化
|
||||
// 监听默认币种配置变化
|
||||
appSettings.$selectedSymbol
|
||||
.sink { [weak self] newSymbol in
|
||||
guard let self = self else { return }
|
||||
self.priceManager.updateSymbol(newSymbol)
|
||||
if !self.appSettings.isUsingCustomSymbol() {
|
||||
self.priceManager.updateSymbol(newSymbol)
|
||||
self.updateMenuBarTitle(price: self.priceManager.currentPrice)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// 监听自定义币种配置变化
|
||||
appSettings.$customCryptoSymbol
|
||||
.sink { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
self.priceManager.updateCryptoSymbolSettings()
|
||||
self.updateMenuBarTitle(price: self.priceManager.currentPrice)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// 监听是否使用自定义币种的变化
|
||||
appSettings.$useCustomSymbol
|
||||
.sink { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
self.priceManager.updateCryptoSymbolSettings()
|
||||
self.updateMenuBarTitle(price: self.priceManager.currentPrice)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
@@ -116,8 +136,15 @@ class MenuBarManager: NSObject, ObservableObject {
|
||||
DispatchQueue.main.async {
|
||||
guard let button = self.statusItem?.button else { return }
|
||||
|
||||
let symbol = self.priceManager.selectedSymbol
|
||||
let symbolImage = self.symbolImage(for: symbol)
|
||||
// 获取当前活跃的币种信息
|
||||
let displayName = self.appSettings.getCurrentActiveDisplayName()
|
||||
let symbolImage: NSImage?
|
||||
|
||||
if self.appSettings.isUsingCustomSymbol() {
|
||||
symbolImage = self.customSymbolImage()
|
||||
} else {
|
||||
symbolImage = self.symbolImage(for: self.priceManager.selectedSymbol)
|
||||
}
|
||||
symbolImage?.size = NSSize(width: 16, height: 16)
|
||||
|
||||
// 设置图标
|
||||
@@ -126,14 +153,14 @@ class MenuBarManager: NSObject, ObservableObject {
|
||||
// 根据状态设置标题
|
||||
if price == 0.0 {
|
||||
if self.priceManager.isFetching {
|
||||
button.title = " \(symbol.displayName) 更新中..."
|
||||
button.title = " \(displayName) 更新中..."
|
||||
} else if self.priceManager.lastError != nil {
|
||||
button.title = " \(symbol.displayName) 错误"
|
||||
button.title = " \(displayName) 错误"
|
||||
} else {
|
||||
button.title = " \(symbol.displayName) 加载中..."
|
||||
button.title = " \(displayName) 加载中..."
|
||||
}
|
||||
} else {
|
||||
button.title = " \(symbol.displayName) $\(self.formatPriceWithCommas(price))"
|
||||
button.title = " \(displayName) $\(self.formatPriceWithCommas(price))"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -146,6 +173,11 @@ class MenuBarManager: NSObject, ObservableObject {
|
||||
return NSImage(systemSymbolName: "bitcoinsign.circle.fill", accessibilityDescription: "Crypto")
|
||||
}
|
||||
|
||||
// 获取自定义币种的图标(统一使用BTC图标)
|
||||
private func customSymbolImage() -> NSImage? {
|
||||
return NSImage(systemSymbolName: "bitcoinsign.circle.fill", accessibilityDescription: "自定义币种")
|
||||
}
|
||||
|
||||
// 格式化价格为千分位分隔形式
|
||||
private func formatPriceWithCommas(_ price: Double) -> String {
|
||||
let formatter = NumberFormatter()
|
||||
@@ -187,12 +219,13 @@ class MenuBarManager: NSObject, ObservableObject {
|
||||
let menu = NSMenu()
|
||||
|
||||
// 添加价格信息项(带币种图标和选中状态)
|
||||
// 我们将为每一个支持的币种添加一个菜单项,并在后台异步填充它们的价格
|
||||
// 首先添加所有默认币种
|
||||
var symbolMenuItems: [CryptoSymbol: NSMenuItem] = [:]
|
||||
let currentSymbol = priceManager.selectedSymbol
|
||||
let currentApiSymbol = appSettings.getCurrentActiveApiSymbol()
|
||||
|
||||
// 添加默认币种菜单项
|
||||
for symbol in CryptoSymbol.allCases {
|
||||
let isCurrent = (symbol == currentSymbol)
|
||||
let isCurrent = symbol.isCurrentSymbol(currentApiSymbol)
|
||||
let placeholderTitle = isCurrent ? "✓ \(symbol.displayName): 加载中..." : " \(symbol.displayName): 加载中..."
|
||||
let item = NSMenuItem(title: placeholderTitle, action: #selector(self.selectOrCopySymbol(_:)), keyEquivalent: "")
|
||||
item.target = self // 关键:必须设置target
|
||||
@@ -201,38 +234,79 @@ class MenuBarManager: NSObject, ObservableObject {
|
||||
item.image = icon
|
||||
}
|
||||
item.isEnabled = true // 立即启用菜单项,允许用户交互
|
||||
item.representedObject = ["symbol": symbol, "price": 0.0]
|
||||
item.representedObject = ["symbol": symbol, "price": 0.0, "isCustom": false]
|
||||
menu.addItem(item)
|
||||
symbolMenuItems[symbol] = item
|
||||
}
|
||||
|
||||
// 添加自定义币种菜单项(如果存在)- 显示在最后
|
||||
var customSymbolMenuItem: NSMenuItem?
|
||||
if let customSymbol = appSettings.customCryptoSymbol {
|
||||
let isCurrent = customSymbol.isCurrentSymbol(currentApiSymbol)
|
||||
let placeholderTitle = isCurrent ? "✓ \(customSymbol.displayName) (自定义): 加载中..." : " \(customSymbol.displayName) (自定义): 加载中..."
|
||||
let item = NSMenuItem(title: placeholderTitle, action: #selector(self.selectOrCopySymbol(_:)), keyEquivalent: "")
|
||||
item.target = self
|
||||
if let icon = customSymbolImage() {
|
||||
icon.size = NSSize(width: 16, height: 16)
|
||||
item.image = icon
|
||||
}
|
||||
item.isEnabled = true
|
||||
item.representedObject = ["customSymbol": customSymbol, "price": 0.0, "isCustom": true]
|
||||
menu.addItem(item)
|
||||
customSymbolMenuItem = item
|
||||
}
|
||||
|
||||
// 异步并发获取所有币种价格并更新对应的菜单项
|
||||
Task { @MainActor in
|
||||
let results = await self.priceManager.fetchAllPrices()
|
||||
let currentSymbolAfter = self.priceManager.selectedSymbol
|
||||
let currentSymbolAfter = self.appSettings.getCurrentActiveApiSymbol()
|
||||
|
||||
// 更新默认币种菜单项
|
||||
for symbol in CryptoSymbol.allCases {
|
||||
guard let (priceOpt, errorOpt) = results[symbol], let menuItem = symbolMenuItems[symbol] else { continue }
|
||||
let isCurrent = (symbol == currentSymbolAfter)
|
||||
let isCurrent = symbol.isCurrentSymbol(currentSymbolAfter)
|
||||
|
||||
if let price = priceOpt {
|
||||
let title = isCurrent ? "✓ \(symbol.displayName): $\(self.formatPriceWithCommas(price))" : " \(symbol.displayName): $\(self.formatPriceWithCommas(price))"
|
||||
menuItem.title = title
|
||||
menuItem.isEnabled = true // 启用菜单项,允许用户交互
|
||||
menuItem.target = self // 确保target正确设置
|
||||
menuItem.representedObject = ["symbol": symbol, "price": price]
|
||||
menuItem.representedObject = ["symbol": symbol, "price": price, "isCustom": false]
|
||||
} else if errorOpt != nil {
|
||||
let title = isCurrent ? "✓ \(symbol.displayName): 错误" : " \(symbol.displayName): 错误"
|
||||
menuItem.title = title
|
||||
// 已删除悬浮提示,避免网络错误时显示悬浮提示
|
||||
menuItem.isEnabled = false // 有错误时禁用交互
|
||||
menuItem.target = self // 确保target正确设置
|
||||
menuItem.representedObject = ["symbol": symbol, "price": 0.0, "isCustom": false]
|
||||
} else {
|
||||
let title = isCurrent ? "✓ \(symbol.displayName): 加载中..." : " \(symbol.displayName): 加载中..."
|
||||
menuItem.title = title
|
||||
menuItem.target = self // 确保target正确设置
|
||||
menuItem.representedObject = ["symbol": symbol, "price": 0.0, "isCustom": false]
|
||||
// 保持启用状态,允许用户交互
|
||||
}
|
||||
}
|
||||
|
||||
// 更新自定义币种菜单项
|
||||
if let customSymbol = self.appSettings.customCryptoSymbol,
|
||||
let menuItem = customSymbolMenuItem {
|
||||
let isCurrent = customSymbol.isCurrentSymbol(currentSymbolAfter)
|
||||
|
||||
if let price = await self.priceManager.fetchCustomSymbolPrice(forApiSymbol: customSymbol.apiSymbol) {
|
||||
let title = isCurrent ? "✓ \(customSymbol.displayName) (自定义): $\(self.formatPriceWithCommas(price))" : " \(customSymbol.displayName) (自定义): $\(self.formatPriceWithCommas(price))"
|
||||
menuItem.title = title
|
||||
menuItem.isEnabled = true
|
||||
menuItem.target = self
|
||||
menuItem.representedObject = ["customSymbol": customSymbol, "price": price, "isCustom": true]
|
||||
} else {
|
||||
let title = isCurrent ? "✓ \(customSymbol.displayName) (自定义): 错误" : " \(customSymbol.displayName) (自定义): 错误"
|
||||
menuItem.title = title
|
||||
menuItem.isEnabled = false
|
||||
menuItem.target = self
|
||||
menuItem.representedObject = ["customSymbol": customSymbol, "price": 0.0, "isCustom": true]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 添加使用提示
|
||||
@@ -358,8 +432,7 @@ class MenuBarManager: NSObject, ObservableObject {
|
||||
|
||||
// 选择币种或复制价格(支持Option键切换功能)
|
||||
@objc private func selectOrCopySymbol(_ sender: NSMenuItem) {
|
||||
guard let data = sender.representedObject as? [String: Any],
|
||||
let symbol = data["symbol"] as? CryptoSymbol else {
|
||||
guard let data = sender.representedObject as? [String: Any] else {
|
||||
print("❌ 无法获取菜单项数据")
|
||||
return
|
||||
}
|
||||
@@ -367,24 +440,48 @@ class MenuBarManager: NSObject, ObservableObject {
|
||||
// 检查是否按住了 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
|
||||
|
||||
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 price == 0.0 {
|
||||
Task { @MainActor in
|
||||
print("🔄 价格未加载,正在获取 \(symbol.displayName) 价格...")
|
||||
if let newPrice = await self.priceManager.fetchSinglePrice(for: symbol) {
|
||||
let priceString = self.formatPriceWithCommas(newPrice)
|
||||
print("🔄 价格未加载,正在获取 \(displayName) 价格...")
|
||||
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)
|
||||
}
|
||||
|
||||
if let priceToCopy = newPrice {
|
||||
let priceString = self.formatPriceWithCommas(priceToCopy)
|
||||
let pasteboard = NSPasteboard.general
|
||||
pasteboard.clearContents()
|
||||
pasteboard.setString("$\(priceString)", forType: .string)
|
||||
|
||||
print("✅ 已复制 \(symbol.displayName) 价格到剪贴板: $\(priceString)")
|
||||
print("✅ 已复制 \(displayName) 价格到剪贴板: $\(priceString)")
|
||||
} else {
|
||||
print("❌ 无法获取 \(symbol.displayName) 价格")
|
||||
print("❌ 无法获取 \(displayName) 价格")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -393,12 +490,28 @@ class MenuBarManager: NSObject, ObservableObject {
|
||||
pasteboard.clearContents()
|
||||
pasteboard.setString("$\(priceString)", forType: .string)
|
||||
|
||||
print("✅ 已复制 \(symbol.displayName) 价格到剪贴板: $\(priceString)")
|
||||
print("✅ 已复制 \(displayName) 价格到剪贴板: $\(priceString)")
|
||||
}
|
||||
} else {
|
||||
// 选择该币种
|
||||
appSettings.saveSelectedSymbol(symbol)
|
||||
print("✅ 币种已更新为: \(symbol.displayName)")
|
||||
if isCustom, let customSymbol = data["customSymbol"] as? CustomCryptoSymbol {
|
||||
// 选择自定义币种
|
||||
appSettings.saveCustomCryptoSymbol(customSymbol)
|
||||
print("✅ 已切换到自定义币种: \(customSymbol.displayName)")
|
||||
|
||||
// 立即更新价格管理器
|
||||
self.priceManager.updateCryptoSymbolSettings()
|
||||
self.updateMenuBarTitle(price: self.priceManager.currentPrice)
|
||||
} else if let symbol = data["symbol"] as? CryptoSymbol {
|
||||
// 选择默认币种
|
||||
appSettings.saveSelectedSymbol(symbol)
|
||||
|
||||
print("✅ 已切换到默认币种: \(symbol.displayName)")
|
||||
|
||||
// 立即更新价格管理器
|
||||
self.priceManager.updateCryptoSymbolSettings()
|
||||
self.updateMenuBarTitle(price: self.priceManager.currentPrice)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,13 @@ class AppSettings: ObservableObject {
|
||||
/// 是否开机自启动
|
||||
@Published var launchAtLogin: Bool = false
|
||||
|
||||
// MARK: - 自定义币种相关属性
|
||||
|
||||
/// 当前选中的自定义币种(如果有)
|
||||
@Published var customCryptoSymbol: CustomCryptoSymbol?
|
||||
/// 是否使用自定义币种
|
||||
@Published var useCustomSymbol: Bool = false
|
||||
|
||||
// MARK: - 代理设置相关属性
|
||||
|
||||
/// 是否启用代理
|
||||
@@ -43,6 +50,11 @@ class AppSettings: ObservableObject {
|
||||
private let selectedSymbolKey = "SelectedCryptoSymbol"
|
||||
private let launchAtLoginKey = "LaunchAtLogin"
|
||||
|
||||
// MARK: - 自定义币种配置键值
|
||||
|
||||
private let customSymbolKey = "CustomCryptoSymbol"
|
||||
private let useCustomSymbolKey = "UseCustomSymbol"
|
||||
|
||||
// MARK: - 代理配置键值
|
||||
|
||||
private let proxyEnabledKey = "ProxyEnabled"
|
||||
@@ -129,6 +141,23 @@ class AppSettings: ObservableObject {
|
||||
// 加载开机自启动设置
|
||||
launchAtLogin = defaults.bool(forKey: launchAtLoginKey)
|
||||
|
||||
// 加载自定义币种设置 - 总是先尝试读取数据
|
||||
if let customSymbolData = defaults.data(forKey: customSymbolKey),
|
||||
let customSymbol = try? JSONDecoder().decode(CustomCryptoSymbol.self, from: customSymbolData) {
|
||||
customCryptoSymbol = customSymbol
|
||||
// 根据保存的状态决定是否使用自定义币种
|
||||
useCustomSymbol = defaults.bool(forKey: useCustomSymbolKey)
|
||||
#if DEBUG
|
||||
print("🔧 [AppSettings] ✅ 已加载自定义币种: \(customSymbol.displayName),使用状态: \(useCustomSymbol)")
|
||||
#endif
|
||||
} else {
|
||||
customCryptoSymbol = nil
|
||||
useCustomSymbol = false
|
||||
#if DEBUG
|
||||
print("🔧 [AppSettings] ℹ️ 未找到自定义币种数据")
|
||||
#endif
|
||||
}
|
||||
|
||||
// 加载代理设置
|
||||
proxyEnabled = defaults.bool(forKey: proxyEnabledKey)
|
||||
proxyHost = defaults.string(forKey: proxyHostKey) ?? ""
|
||||
@@ -143,7 +172,8 @@ class AppSettings: ObservableObject {
|
||||
#if DEBUG
|
||||
let proxyInfo = proxyEnabled ? "\(proxyHost):\(proxyPort)" : "未启用"
|
||||
let authInfo = proxyEnabled && !proxyUsername.isEmpty ? " (认证: \(proxyUsername))" : ""
|
||||
print("🔧 [AppSettings] 配置加载完成 - 刷新间隔: \(refreshInterval.displayText), 币种: \(selectedSymbol.displayName), 开机自启动: \(launchAtLogin), 代理: \(proxyInfo)\(authInfo)")
|
||||
let customInfo = useCustomSymbol && customCryptoSymbol != nil ? " (自定义: \(customCryptoSymbol!.displayName))" : ""
|
||||
print("🔧 [AppSettings] 配置加载完成 - 刷新间隔: \(refreshInterval.displayText), 币种: \(getCurrentActiveDisplayName())\(customInfo), 开机自启动: \(launchAtLogin), 代理: \(proxyInfo)\(authInfo)")
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -161,6 +191,12 @@ class AppSettings: ObservableObject {
|
||||
saveRefreshInterval(.thirtySeconds)
|
||||
saveSelectedSymbol(.btc)
|
||||
|
||||
// 重置自定义币种设置
|
||||
useCustomSymbol = false
|
||||
customCryptoSymbol = nil
|
||||
defaults.set(false, forKey: useCustomSymbolKey)
|
||||
defaults.removeObject(forKey: customSymbolKey)
|
||||
|
||||
// 重置代理设置
|
||||
proxyEnabled = false
|
||||
proxyHost = ""
|
||||
@@ -174,7 +210,7 @@ class AppSettings: ObservableObject {
|
||||
defaults.set("", forKey: proxyPasswordKey)
|
||||
|
||||
#if DEBUG
|
||||
print("🔧 [AppSettings] 重置完成 - 刷新间隔: \(refreshInterval.displayText), 币种: \(selectedSymbol.displayName), 代理: 已重置")
|
||||
print("🔧 [AppSettings] 重置完成 - 刷新间隔: \(refreshInterval.displayText), 币种: \(selectedSymbol.displayName), 自定义币种: 已清除, 代理: 已重置")
|
||||
#endif
|
||||
|
||||
// 重置开机自启动设置
|
||||
@@ -196,6 +232,19 @@ class AppSettings: ObservableObject {
|
||||
/// - Parameter symbol: 要保存的币种
|
||||
func saveSelectedSymbol(_ symbol: CryptoSymbol) {
|
||||
selectedSymbol = symbol
|
||||
|
||||
// 如果当前正在使用自定义币种,只是切换使用状态,不删除数据
|
||||
if useCustomSymbol {
|
||||
useCustomSymbol = false
|
||||
defaults.set(false, forKey: useCustomSymbolKey)
|
||||
|
||||
#if DEBUG
|
||||
if let customSymbol = customCryptoSymbol {
|
||||
print("🔧 [AppSettings] ✅ 已切换到默认币种: \(symbol.displayName),自定义币种 \(customSymbol.displayName) 保留")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
print("🔧 [AppSettings] 保存币种配置: \(symbol.displayName) (\(symbol.rawValue))")
|
||||
#endif
|
||||
@@ -355,6 +404,87 @@ class AppSettings: ObservableObject {
|
||||
let actualStatus = SMAppService.mainApp.status
|
||||
return actualStatus == .enabled
|
||||
}
|
||||
|
||||
// MARK: - 自定义币种相关方法
|
||||
|
||||
/// 保存自定义币种设置
|
||||
/// - Parameter customSymbol: 要保存的自定义币种
|
||||
func saveCustomCryptoSymbol(_ customSymbol: CustomCryptoSymbol) {
|
||||
customCryptoSymbol = customSymbol
|
||||
useCustomSymbol = true
|
||||
|
||||
do {
|
||||
let data = try JSONEncoder().encode(customSymbol)
|
||||
defaults.set(data, forKey: customSymbolKey)
|
||||
defaults.set(true, forKey: useCustomSymbolKey)
|
||||
|
||||
#if DEBUG
|
||||
print("🔧 [AppSettings] ✅ 已保存自定义币种: \(customSymbol.displayName)")
|
||||
#endif
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("🔧 [AppSettings] ❌ 保存自定义币种失败: \(error.localizedDescription)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// 移除自定义币种
|
||||
func removeCustomCryptoSymbol() {
|
||||
customCryptoSymbol = nil
|
||||
useCustomSymbol = false
|
||||
defaults.removeObject(forKey: customSymbolKey)
|
||||
defaults.set(false, forKey: useCustomSymbolKey)
|
||||
|
||||
#if DEBUG
|
||||
print("🔧 [AppSettings] ✅ 已移除自定义币种")
|
||||
#endif
|
||||
}
|
||||
|
||||
/// 获取当前活跃的币种API符号
|
||||
/// - Returns: 当前活跃币种的API符号
|
||||
func getCurrentActiveApiSymbol() -> String {
|
||||
if useCustomSymbol, let customSymbol = customCryptoSymbol {
|
||||
return customSymbol.apiSymbol
|
||||
} else {
|
||||
return selectedSymbol.apiSymbol
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取当前活跃的币种显示名称
|
||||
/// - Returns: 当前活跃币种的显示名称
|
||||
func getCurrentActiveDisplayName() -> String {
|
||||
if useCustomSymbol, let customSymbol = customCryptoSymbol {
|
||||
return customSymbol.displayName
|
||||
} else {
|
||||
return selectedSymbol.displayName
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取当前活跃的币种图标
|
||||
/// - Returns: 当前活跃币种的图标名称
|
||||
func getCurrentActiveSystemImageName() -> String {
|
||||
if useCustomSymbol, let customSymbol = customCryptoSymbol {
|
||||
return customSymbol.systemImageName
|
||||
} else {
|
||||
return selectedSymbol.systemImageName
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取当前活跃的币种交易对显示名称
|
||||
/// - Returns: 当前活跃币种的交易对显示名称
|
||||
func getCurrentActivePairDisplayName() -> String {
|
||||
if useCustomSymbol, let customSymbol = customCryptoSymbol {
|
||||
return customSymbol.pairDisplayName
|
||||
} else {
|
||||
return selectedSymbol.pairDisplayName
|
||||
}
|
||||
}
|
||||
|
||||
/// 判断是否正在使用自定义币种
|
||||
/// - Returns: 是否正在使用自定义币种
|
||||
func isUsingCustomSymbol() -> Bool {
|
||||
return useCustomSymbol && customCryptoSymbol != nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - String Extension for Regex Matching
|
||||
|
||||
@@ -15,14 +15,29 @@ class PriceManager: ObservableObject {
|
||||
@Published var isFetching: Bool = false
|
||||
@Published var lastError: PriceError?
|
||||
@Published var selectedSymbol: CryptoSymbol
|
||||
|
||||
|
||||
// 自定义币种相关属性
|
||||
@Published var customCryptoSymbol: CustomCryptoSymbol?
|
||||
@Published var useCustomSymbol: Bool = false
|
||||
|
||||
private let priceService: PriceService
|
||||
private var timer: Timer?
|
||||
private var currentRefreshInterval: TimeInterval = RefreshInterval.thirtySeconds.rawValue // 当前刷新间隔
|
||||
private let appSettings: AppSettings
|
||||
|
||||
// 自定义币种价格缓存
|
||||
private var customSymbolPriceCache: [String: (price: Double, timestamp: Date)] = [:]
|
||||
private let cacheExpirationTime: TimeInterval = 30.0 // 缓存30秒
|
||||
|
||||
init(initialSymbol: CryptoSymbol = .btc, appSettings: AppSettings) {
|
||||
selectedSymbol = initialSymbol
|
||||
self.appSettings = appSettings
|
||||
self.priceService = PriceService(appSettings: appSettings)
|
||||
|
||||
// 初始化自定义币种状态
|
||||
self.customCryptoSymbol = appSettings.customCryptoSymbol
|
||||
self.useCustomSymbol = appSettings.useCustomSymbol
|
||||
|
||||
startPriceUpdates()
|
||||
}
|
||||
|
||||
@@ -83,11 +98,14 @@ class PriceManager: ObservableObject {
|
||||
private func fetchPrice() async {
|
||||
isFetching = true
|
||||
lastError = nil
|
||||
let activeSymbol = selectedSymbol
|
||||
|
||||
// 获取当前活跃的币种信息
|
||||
let activeApiSymbol = getCurrentActiveApiSymbol()
|
||||
let activeDisplayName = getCurrentDisplayName()
|
||||
var didUpdatePrice = false
|
||||
|
||||
#if DEBUG
|
||||
print("🔄 [Price Manager] 开始获取价格 | 币种: \(activeSymbol.displayName)")
|
||||
print("🔄 [Price Manager] 开始获取价格 | 币种: \(activeDisplayName)")
|
||||
#endif
|
||||
|
||||
defer {
|
||||
@@ -95,7 +113,7 @@ class PriceManager: ObservableObject {
|
||||
|
||||
#if DEBUG
|
||||
if let error = lastError {
|
||||
print("⚠️ [Price Manager] 价格获取流程结束,最终失败: \(error.localizedDescription) | 币种: \(activeSymbol.displayName)")
|
||||
print("⚠️ [Price Manager] 价格获取流程结束,最终失败: \(error.localizedDescription) | 币种: \(activeDisplayName)")
|
||||
} else if didUpdatePrice {
|
||||
print("✅ [Price Manager] 价格获取流程结束,成功")
|
||||
} else {
|
||||
@@ -109,15 +127,16 @@ class PriceManager: ObservableObject {
|
||||
|
||||
for attempt in 1...maxRetries {
|
||||
#if DEBUG
|
||||
print("📡 [Price Manager] 尝试获取价格 (第\(attempt)次) | 币种: \(activeSymbol.displayName)")
|
||||
print("📡 [Price Manager] 尝试获取价格 (第\(attempt)次) | 币种: \(activeDisplayName)")
|
||||
#endif
|
||||
|
||||
do {
|
||||
let price = try await priceService.fetchPrice(for: activeSymbol)
|
||||
let price = try await priceService.fetchPrice(forApiSymbol: activeApiSymbol)
|
||||
|
||||
guard activeSymbol == selectedSymbol else {
|
||||
// 检查币种是否已更改
|
||||
guard activeApiSymbol == getCurrentActiveApiSymbol() else {
|
||||
#if DEBUG
|
||||
print("ℹ️ [Price Manager] 币种已切换至 \(selectedSymbol.displayName),丢弃旧结果")
|
||||
print("ℹ️ [Price Manager] 币种已切换至 \(getCurrentDisplayName()),丢弃旧结果")
|
||||
#endif
|
||||
return
|
||||
}
|
||||
@@ -129,13 +148,13 @@ class PriceManager: ObservableObject {
|
||||
let formatter = DateFormatter()
|
||||
formatter.timeStyle = .medium
|
||||
let currentTime = formatter.string(from: Date())
|
||||
print("✅ [Price Manager] 价格更新成功: \(activeSymbol.displayName)/USDT $\(String(format: "%.4f", price)) | 时间: \(currentTime)")
|
||||
print("✅ [Price Manager] 价格更新成功: \(activeDisplayName)/USDT $\(String(format: "%.4f", price)) | 时间: \(currentTime)")
|
||||
#endif
|
||||
|
||||
break // 成功获取价格,退出重试循环
|
||||
} catch let error as PriceError {
|
||||
#if DEBUG
|
||||
print("❌ [Price Manager] 价格获取失败 (第\(attempt)次): \(error.localizedDescription) | 币种: \(activeSymbol.displayName)")
|
||||
print("❌ [Price Manager] 价格获取失败 (第\(attempt)次): \(error.localizedDescription) | 币种: \(activeDisplayName)")
|
||||
#endif
|
||||
|
||||
if attempt == maxRetries {
|
||||
@@ -146,7 +165,7 @@ class PriceManager: ObservableObject {
|
||||
}
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("❌ [Price Manager] 网络错误 (第\(attempt)次): \(error.localizedDescription) | 币种: \(activeSymbol.displayName)")
|
||||
print("❌ [Price Manager] 网络错误 (第\(attempt)次): \(error.localizedDescription) | 币种: \(activeDisplayName)")
|
||||
#endif
|
||||
|
||||
if attempt == maxRetries {
|
||||
@@ -160,19 +179,21 @@ class PriceManager: ObservableObject {
|
||||
|
||||
// 格式化价格显示
|
||||
var formattedPrice: String {
|
||||
let displayName = getCurrentDisplayName()
|
||||
|
||||
if isFetching {
|
||||
return "\(selectedSymbol.displayName): 更新中..."
|
||||
return "\(displayName): 更新中..."
|
||||
}
|
||||
|
||||
if lastError != nil {
|
||||
return "\(selectedSymbol.displayName): 错误"
|
||||
return "\(displayName): 错误"
|
||||
}
|
||||
|
||||
if currentPrice == 0.0 {
|
||||
return "\(selectedSymbol.displayName): 加载中..."
|
||||
return "\(displayName): 加载中..."
|
||||
}
|
||||
|
||||
return "\(selectedSymbol.displayName): $\(formatPriceWithCommas(currentPrice))"
|
||||
return "\(displayName): $\(formatPriceWithCommas(currentPrice))"
|
||||
}
|
||||
|
||||
// 获取详细错误信息
|
||||
@@ -284,4 +305,212 @@ class PriceManager: ObservableObject {
|
||||
func updateNetworkConfiguration() {
|
||||
priceService.updateNetworkConfiguration()
|
||||
}
|
||||
|
||||
// MARK: - 自定义币种支持方法
|
||||
|
||||
/// 获取当前活跃的币种API符号
|
||||
/// - Returns: 当前活跃币种的API符号
|
||||
private func getCurrentActiveApiSymbol() -> String {
|
||||
return appSettings.getCurrentActiveApiSymbol()
|
||||
}
|
||||
|
||||
/// 获取当前活跃的币种显示名称
|
||||
/// - Returns: 当前活跃币种的显示名称
|
||||
private func getCurrentDisplayName() -> String {
|
||||
return appSettings.getCurrentActiveDisplayName()
|
||||
}
|
||||
|
||||
/// 更新币种设置(当AppSettings中的自定义币种发生变化时调用)
|
||||
func updateCryptoSymbolSettings() {
|
||||
customCryptoSymbol = appSettings.customCryptoSymbol
|
||||
useCustomSymbol = appSettings.useCustomSymbol
|
||||
|
||||
// 重置价格状态,强制重新获取
|
||||
currentPrice = 0.0
|
||||
lastError = nil
|
||||
|
||||
#if DEBUG
|
||||
print("🔁 [Price Manager] 已更新币种设置,当前币种: \(getCurrentDisplayName())")
|
||||
#endif
|
||||
|
||||
// 立即获取新币种的价格
|
||||
Task {
|
||||
await fetchPrice()
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取所有支持的币种(包括默认币种和自定义币种)
|
||||
/// - Returns: 所有可用币种的API符号列表
|
||||
func getAllAvailableSymbols() -> [String] {
|
||||
var symbols = CryptoSymbol.allApiSymbols
|
||||
|
||||
if let customSymbol = appSettings.customCryptoSymbol {
|
||||
symbols.append(customSymbol.apiSymbol)
|
||||
}
|
||||
|
||||
return symbols
|
||||
}
|
||||
|
||||
/// 根据API符号获取对应的显示名称
|
||||
/// - Parameter apiSymbol: API符号
|
||||
/// - Returns: 显示名称,如果找不到则返回API符号本身
|
||||
func getDisplayName(forApiSymbol apiSymbol: String) -> String {
|
||||
// 首先检查是否是默认币种
|
||||
if let defaultSymbol = CryptoSymbol.fromApiSymbol(apiSymbol) {
|
||||
return defaultSymbol.displayName
|
||||
}
|
||||
|
||||
// 检查是否是自定义币种
|
||||
if let customSymbol = appSettings.customCryptoSymbol,
|
||||
customSymbol.apiSymbol == apiSymbol {
|
||||
return customSymbol.displayName
|
||||
}
|
||||
|
||||
// 如果都找不到,返回API符号的基础部分(去掉USDT)
|
||||
if apiSymbol.hasSuffix("USDT") {
|
||||
let baseSymbol = String(apiSymbol.dropLast(4))
|
||||
return baseSymbol
|
||||
}
|
||||
|
||||
return apiSymbol
|
||||
}
|
||||
|
||||
// MARK: - 价格缓存机制
|
||||
|
||||
/// 从缓存获取自定义币种价格
|
||||
/// - Parameter apiSymbol: API符号
|
||||
/// - Returns: 缓存的价格,如果已过期或不存在则返回nil
|
||||
private func getCachedPrice(forApiSymbol apiSymbol: String) -> Double? {
|
||||
guard let cachedData = customSymbolPriceCache[apiSymbol] else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 检查缓存是否过期
|
||||
let timeSinceCache = Date().timeIntervalSince(cachedData.timestamp)
|
||||
if timeSinceCache > cacheExpirationTime {
|
||||
// 缓存已过期,移除
|
||||
customSymbolPriceCache.removeValue(forKey: apiSymbol)
|
||||
#if DEBUG
|
||||
print("🗑️ [Price Manager] 缓存已过期,移除: \(apiSymbol)")
|
||||
#endif
|
||||
return nil
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
print("💾 [Price Manager] 使用缓存价格: \(apiSymbol) = $\(String(format: "%.4f", cachedData.price))")
|
||||
#endif
|
||||
return cachedData.price
|
||||
}
|
||||
|
||||
/// 缓存自定义币种价格
|
||||
/// - Parameters:
|
||||
/// - price: 价格值
|
||||
/// - apiSymbol: API符号
|
||||
private func cachePrice(_ price: Double, forApiSymbol apiSymbol: String) {
|
||||
customSymbolPriceCache[apiSymbol] = (price: price, timestamp: Date())
|
||||
#if DEBUG
|
||||
print("💾 [Price Manager] 已缓存价格: \(apiSymbol) = $\(String(format: "%.4f", price))")
|
||||
#endif
|
||||
|
||||
// 清理过期缓存
|
||||
cleanExpiredCache()
|
||||
}
|
||||
|
||||
/// 清理过期的缓存条目
|
||||
private func cleanExpiredCache() {
|
||||
let currentTime = Date()
|
||||
let expiredKeys = customSymbolPriceCache.compactMap { key, value in
|
||||
currentTime.timeIntervalSince(value.timestamp) > cacheExpirationTime ? key : nil
|
||||
}
|
||||
|
||||
for key in expiredKeys {
|
||||
customSymbolPriceCache.removeValue(forKey: key)
|
||||
}
|
||||
|
||||
if !expiredKeys.isEmpty {
|
||||
#if DEBUG
|
||||
print("🗑️ [Price Manager] 已清理 \(expiredKeys.count) 个过期缓存条目")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// 清空所有缓存
|
||||
private func clearAllCache() {
|
||||
customSymbolPriceCache.removeAll()
|
||||
#if DEBUG
|
||||
print("🗑️ [Price Manager] 已清空所有价格缓存")
|
||||
#endif
|
||||
}
|
||||
|
||||
/// 获取自定义币种价格(带缓存)
|
||||
/// - Parameter apiSymbol: API符号
|
||||
/// - Returns: 价格值
|
||||
func fetchCustomSymbolPrice(forApiSymbol apiSymbol: String) async -> Double? {
|
||||
// 首先尝试从缓存获取
|
||||
if let cachedPrice = getCachedPrice(forApiSymbol: apiSymbol) {
|
||||
return cachedPrice
|
||||
}
|
||||
|
||||
// 缓存未命中,从网络获取
|
||||
do {
|
||||
let price = try await priceService.fetchPrice(forApiSymbol: apiSymbol)
|
||||
cachePrice(price, forApiSymbol: apiSymbol)
|
||||
return price
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("❌ [Price Manager] 获取自定义币种价格失败: \(apiSymbol) - \(error.localizedDescription)")
|
||||
#endif
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// 批量获取多个币种的价格(带缓存优化)
|
||||
/// - Parameter apiSymbols: API符号数组
|
||||
/// - Returns: 价格字典
|
||||
func fetchMultiplePrices(forApiSymbols apiSymbols: [String]) async -> [String: Double] {
|
||||
var results = [String: Double]()
|
||||
var symbolsToFetch = [String]()
|
||||
|
||||
// 首先检查缓存
|
||||
for symbol in apiSymbols {
|
||||
if let cachedPrice = getCachedPrice(forApiSymbol: symbol) {
|
||||
results[symbol] = cachedPrice
|
||||
} else {
|
||||
symbolsToFetch.append(symbol)
|
||||
}
|
||||
}
|
||||
|
||||
// 批量获取未缓存的币种价格
|
||||
if !symbolsToFetch.isEmpty {
|
||||
await withTaskGroup(of: (String, Double?).self) { group in
|
||||
for symbol in symbolsToFetch {
|
||||
group.addTask { [weak self] in
|
||||
do {
|
||||
let price = try await self?.priceService.fetchPrice(forApiSymbol: symbol)
|
||||
if let price = price {
|
||||
return (symbol, price)
|
||||
} else {
|
||||
return (symbol, nil)
|
||||
}
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("❌ [Price Manager] 批量获取价格失败: \(symbol) - \(error.localizedDescription)")
|
||||
#endif
|
||||
return (symbol, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for await (symbol, price) in group {
|
||||
if let price = price {
|
||||
results[symbol] = price
|
||||
// 缓存获取到的价格
|
||||
cachePrice(price, forApiSymbol: symbol)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,6 +196,50 @@ class PriceService: NSObject, ObservableObject, URLSessionTaskDelegate {
|
||||
return price
|
||||
}
|
||||
|
||||
/// 获取指定API符号的价格(支持自定义币种)
|
||||
/// - Parameter apiSymbol: API符号(如 "ADAUSDT")
|
||||
/// - Returns: 价格值
|
||||
func fetchPrice(forApiSymbol apiSymbol: String) async throws -> Double {
|
||||
let urlString = "\(baseURL)?symbol=\(apiSymbol)"
|
||||
guard let url = URL(string: urlString) else {
|
||||
throw PriceError.invalidURL
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
print("📡 [PriceService] 请求API: \(urlString)")
|
||||
#endif
|
||||
|
||||
// 发送网络请求
|
||||
let (data, response) = try await session.data(from: url)
|
||||
|
||||
// 检查响应状态
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw PriceError.invalidResponse
|
||||
}
|
||||
|
||||
guard httpResponse.statusCode == 200 else {
|
||||
#if DEBUG
|
||||
print("❌ [PriceService] 服务器错误: \(httpResponse.statusCode) | API符号: \(apiSymbol)")
|
||||
#endif
|
||||
throw PriceError.serverError(httpResponse.statusCode)
|
||||
}
|
||||
|
||||
// 解析JSON数据
|
||||
let decoder = JSONDecoder()
|
||||
let priceResponse = try decoder.decode(TickerPriceResponse.self, from: data)
|
||||
|
||||
// 转换价格为Double类型
|
||||
guard let price = Double(priceResponse.price) else {
|
||||
throw PriceError.invalidPrice
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
print("✅ [PriceService] 价格获取成功: \(apiSymbol) = $\(String(format: "%.4f", price))")
|
||||
#endif
|
||||
|
||||
return price
|
||||
}
|
||||
|
||||
// MARK: - 代理配置相关方法
|
||||
|
||||
/**
|
||||
@@ -383,6 +427,8 @@ enum PriceError: Error, LocalizedError {
|
||||
case serverError(Int)
|
||||
case invalidPrice
|
||||
case networkError(Error)
|
||||
case symbolNotFound(String) // 自定义币种不存在
|
||||
case invalidSymbol(String) // 无效的币种符号
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
@@ -391,11 +437,19 @@ enum PriceError: Error, LocalizedError {
|
||||
case .invalidResponse:
|
||||
return "无效的响应"
|
||||
case .serverError(let code):
|
||||
return "服务器错误,状态码:\(code)"
|
||||
if code == 400 {
|
||||
return "币种符号不存在或无效"
|
||||
} else {
|
||||
return "服务器错误,状态码:\(code)"
|
||||
}
|
||||
case .invalidPrice:
|
||||
return "无效的价格数据"
|
||||
case .networkError(let error):
|
||||
return "网络错误:\(error.localizedDescription)"
|
||||
case .symbolNotFound(let symbol):
|
||||
return "未找到币种:\(symbol)"
|
||||
case .invalidSymbol(let symbol):
|
||||
return "无效的币种符号:\(symbol)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,4 +64,34 @@ enum CryptoSymbol: String, CaseIterable, Codable {
|
||||
func menuTitle(isCurrent: Bool) -> String {
|
||||
return isCurrent ? "✓ \(pairDisplayName)" : " \(pairDisplayName)"
|
||||
}
|
||||
|
||||
/// 判断是否为当前选中的币种
|
||||
/// - Parameter currentSymbol: 当前选中的币种字符串
|
||||
/// - Returns: 是否为当前选中币种
|
||||
func isCurrentSymbol(_ currentSymbol: String) -> Bool {
|
||||
return rawValue == currentSymbol || displayName.uppercased() == currentSymbol.uppercased()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Default Crypto Symbol Extensions
|
||||
|
||||
extension CryptoSymbol {
|
||||
/// 获取所有默认币种的API符号列表
|
||||
static var allApiSymbols: [String] {
|
||||
return allCases.map { $0.apiSymbol }
|
||||
}
|
||||
|
||||
/// 根据API符号查找对应的默认币种
|
||||
/// - Parameter apiSymbol: API符号
|
||||
/// - Returns: 对应的币种,如果不存在则返回nil
|
||||
static func fromApiSymbol(_ apiSymbol: String) -> CryptoSymbol? {
|
||||
return allCases.first { $0.apiSymbol == apiSymbol }
|
||||
}
|
||||
|
||||
/// 根据显示名称查找对应的默认币种
|
||||
/// - Parameter displayName: 显示名称
|
||||
/// - Returns: 对应的币种,如果不存在则返回nil
|
||||
static func fromDisplayName(_ displayName: String) -> CryptoSymbol? {
|
||||
return allCases.first { $0.displayName.uppercased() == displayName.uppercased() }
|
||||
}
|
||||
}
|
||||
|
||||
190
Bitcoin-Monitoring/Models/CustomCryptoSymbol.swift
Normal file
190
Bitcoin-Monitoring/Models/CustomCryptoSymbol.swift
Normal file
@@ -0,0 +1,190 @@
|
||||
//
|
||||
// CustomCryptoSymbol.swift
|
||||
// Bitcoin Monitoring
|
||||
//
|
||||
// Created by Claude on 2025/11/03.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 自定义加密货币数据模型
|
||||
/// 支持用户定义的3-5字符币种符号,使用统一的BTC图标
|
||||
struct CustomCryptoSymbol: Codable, Equatable, Hashable {
|
||||
/// 币种符号(如 ADA、DOGE、SHIB)
|
||||
let symbol: String
|
||||
|
||||
/// 创建自定义币种实例
|
||||
/// - Parameter symbol: 币种符号,3-5个大写字母
|
||||
init(symbol: String) throws {
|
||||
let trimmedSymbol = symbol.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
|
||||
|
||||
// 验证币种符号格式
|
||||
try CustomCryptoSymbol.validateSymbol(trimmedSymbol)
|
||||
|
||||
self.symbol = trimmedSymbol
|
||||
}
|
||||
|
||||
/// 从字符串创建自定义币种(可能失败)
|
||||
/// - Parameter rawValue: 原始字符串
|
||||
/// - Returns: 自定义币种实例,如果格式无效则返回nil
|
||||
static func fromString(_ rawValue: String) -> CustomCryptoSymbol? {
|
||||
do {
|
||||
return try CustomCryptoSymbol(symbol: rawValue)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// 验证币种符号格式
|
||||
/// - Parameter symbol: 待验证的币种符号
|
||||
/// - Throws: ValidationError 如果格式不符合要求
|
||||
private static func validateSymbol(_ symbol: String) throws {
|
||||
// 验证长度:3-5个字符
|
||||
guard symbol.count >= 3, symbol.count <= 5 else {
|
||||
throw ValidationError.invalidLength
|
||||
}
|
||||
|
||||
// 验证格式:只包含大写字母
|
||||
guard symbol.allSatisfy({ $0.isLetter && $0.isUppercase }) else {
|
||||
throw ValidationError.invalidFormat
|
||||
}
|
||||
|
||||
// 验证不与默认币种重复
|
||||
let defaultSymbols = CryptoSymbol.allCases.map { $0.displayName.uppercased() }
|
||||
guard !defaultSymbols.contains(symbol) else {
|
||||
throw ValidationError.duplicateWithDefault
|
||||
}
|
||||
}
|
||||
|
||||
/// 验证币种符号的有效性(不创建实例)
|
||||
/// - Parameter symbol: 待验证的币种符号
|
||||
/// - Returns: 验证结果和错误信息
|
||||
static func isValidSymbol(_ symbol: String) -> (isValid: Bool, errorMessage: String?) {
|
||||
let trimmedSymbol = symbol.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
// 检查长度
|
||||
guard trimmedSymbol.count >= 3, trimmedSymbol.count <= 5 else {
|
||||
return (false, "币种符号需要3-5个大写字母")
|
||||
}
|
||||
|
||||
// 检查格式
|
||||
guard trimmedSymbol.allSatisfy({ $0.isLetter }) else {
|
||||
return (false, "币种符号只能包含字母")
|
||||
}
|
||||
|
||||
// 转换为大写进行验证
|
||||
let uppercasedSymbol = trimmedSymbol.uppercased()
|
||||
|
||||
// 检查是否与默认币种重复
|
||||
let defaultSymbols = CryptoSymbol.allCases.map { $0.displayName.uppercased() }
|
||||
if defaultSymbols.contains(uppercasedSymbol) {
|
||||
return (false, "该币种已在默认列表中")
|
||||
}
|
||||
|
||||
return (true, nil)
|
||||
}
|
||||
|
||||
/// 自定义币种验证错误类型
|
||||
enum ValidationError: Error, LocalizedError {
|
||||
case invalidLength
|
||||
case invalidFormat
|
||||
case duplicateWithDefault
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidLength:
|
||||
return "币种符号需要3-5个大写字母"
|
||||
case .invalidFormat:
|
||||
return "币种符号只能包含大写字母"
|
||||
case .duplicateWithDefault:
|
||||
return "该币种已在默认列表中"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Display Properties
|
||||
|
||||
extension CustomCryptoSymbol {
|
||||
/// 用于展示的币种简称
|
||||
var displayName: String {
|
||||
return symbol
|
||||
}
|
||||
|
||||
/// 币安API使用的交易对符号
|
||||
var apiSymbol: String {
|
||||
return "\(symbol)USDT"
|
||||
}
|
||||
|
||||
/// 菜单中展示的交易对名称
|
||||
var pairDisplayName: String {
|
||||
return "\(symbol)/USDT"
|
||||
}
|
||||
|
||||
/// 对应的SF Symbols图标名称(统一使用BTC图标)
|
||||
var systemImageName: String {
|
||||
return "bitcoinsign.circle.fill"
|
||||
}
|
||||
|
||||
/// 菜单标题(带勾选标记和自定义标识)
|
||||
/// - Parameter isCurrent: 是否为当前选中币种
|
||||
/// - Returns: 菜单展示文本
|
||||
func menuTitle(isCurrent: Bool) -> String {
|
||||
let checkmark = isCurrent ? "✓" : " "
|
||||
return "\(checkmark) \(pairDisplayName) (自定义)"
|
||||
}
|
||||
|
||||
/// 判断是否为当前选中的币种
|
||||
/// - Parameter currentSymbol: 当前选中的币种字符串
|
||||
/// - Returns: 是否为当前选中币种
|
||||
func isCurrentSymbol(_ currentSymbol: String) -> Bool {
|
||||
return apiSymbol == currentSymbol || symbol.uppercased() == currentSymbol.uppercased()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Equatable and Hashable Implementation
|
||||
|
||||
extension CustomCryptoSymbol {
|
||||
/// 相等性比较(基于币种符号)
|
||||
static func == (lhs: CustomCryptoSymbol, rhs: CustomCryptoSymbol) -> Bool {
|
||||
return lhs.symbol.uppercased() == rhs.symbol.uppercased()
|
||||
}
|
||||
|
||||
/// 哈希值计算(基于币种符号)
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(symbol.uppercased())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CryptoRepresentable Protocol
|
||||
|
||||
/// 加密货币可表示协议
|
||||
/// 统一CryptoSymbol和CustomCryptoSymbol的接口
|
||||
protocol CryptoRepresentable {
|
||||
var displayName: String { get }
|
||||
var apiSymbol: String { get }
|
||||
var pairDisplayName: String { get }
|
||||
var systemImageName: String { get }
|
||||
|
||||
func menuTitle(isCurrent: Bool) -> String
|
||||
}
|
||||
|
||||
// MARK: - CryptoSymbol Protocol Conformance
|
||||
|
||||
extension CryptoSymbol: CryptoRepresentable {}
|
||||
|
||||
extension CustomCryptoSymbol: CryptoRepresentable {}
|
||||
|
||||
// MARK: - Type Identification
|
||||
|
||||
extension CryptoRepresentable {
|
||||
/// 是否为默认币种
|
||||
var isDefaultCrypto: Bool {
|
||||
return self is CryptoSymbol
|
||||
}
|
||||
|
||||
/// 是否为自定义币种
|
||||
var isCustomCrypto: Bool {
|
||||
return self is CustomCryptoSymbol
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,12 @@ struct PreferencesWindowView: View {
|
||||
// 保存状态
|
||||
@State private var isSaving = false
|
||||
|
||||
// 自定义币种相关状态
|
||||
@State private var customSymbolInput: String = ""
|
||||
@State private var isCustomSymbolValid: Bool = false
|
||||
@State private var customSymbolErrorMessage: String?
|
||||
@State private var showingCustomSymbolDeleteConfirmation: Bool = false
|
||||
|
||||
init(appSettings: AppSettings, onClose: @escaping () -> Void) {
|
||||
self.appSettings = appSettings
|
||||
self.onClose = onClose
|
||||
@@ -55,213 +61,374 @@ struct PreferencesWindowView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// 主要内容区域
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
// 刷新设置区域
|
||||
SettingsGroupView(title: "刷新设置", icon: "timer") {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("选择价格刷新间隔")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
ForEach(RefreshInterval.allCases, id: \.self) { interval in
|
||||
IntervalSelectionButton(
|
||||
interval: interval,
|
||||
isSelected: tempRefreshInterval == interval,
|
||||
onSelect: { tempRefreshInterval = interval }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 开机启动设置区域
|
||||
SettingsGroupView(title: "启动设置", icon: "power") {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("开机自动启动")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text("应用将在系统启动时自动运行")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Toggle("", isOn: $tempLaunchAtLogin)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.switch)
|
||||
.controlSize(.mini)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 代理设置区域
|
||||
SettingsGroupView(title: "代理设置", icon: "network") {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
// 代理开关
|
||||
HStack {
|
||||
Text("启用HTTP代理")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Toggle("", isOn: $tempProxyEnabled)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.switch)
|
||||
.controlSize(.mini)
|
||||
}
|
||||
|
||||
// 代理配置输入框 - 始终显示
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("代理服务器配置")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
// 服务器地址和端口
|
||||
HStack(spacing: 12) {
|
||||
// 服务器地址
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("服务器地址")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
TextField("ip or proxy.example.com", text: $tempProxyHost)
|
||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
.frame(maxWidth: .infinity)
|
||||
.disabled(!tempProxyEnabled)
|
||||
}
|
||||
|
||||
// 端口
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("端口")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
TextField("3128", text: $tempProxyPort)
|
||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
.frame(width: 80)
|
||||
.disabled(!tempProxyEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
// 认证配置
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("认证设置 (可选)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
// 用户名
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("用户名")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
TextField("user", text: $tempProxyUsername)
|
||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
.frame(maxWidth: .infinity)
|
||||
.disabled(!tempProxyEnabled)
|
||||
}
|
||||
|
||||
// 密码
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("密码")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
SecureField("password", text: $tempProxyPassword)
|
||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
.frame(maxWidth: .infinity)
|
||||
.disabled(!tempProxyEnabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 测试按钮
|
||||
HStack {
|
||||
Spacer()
|
||||
|
||||
Button(action: testProxyConnection) {
|
||||
HStack {
|
||||
if isTestingProxy {
|
||||
ProgressView()
|
||||
.scaleEffect(0.4)
|
||||
.frame(width: 8, height: 8)
|
||||
} else {
|
||||
Image(systemName: "network")
|
||||
.font(.system(size: 12))
|
||||
}
|
||||
Text(isTestingProxy ? "测试中..." : "测试连接")
|
||||
}
|
||||
.frame(minWidth: 80)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.disabled(!tempProxyEnabled || isTestingProxy || isSaving)
|
||||
}
|
||||
}
|
||||
.opacity(tempProxyEnabled ? 1.0 : 0.6) // 视觉反馈显示开关状态
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 20)
|
||||
mainContentView
|
||||
.frame(width: 480, height: 800)
|
||||
.alert("配置验证", isPresented: $showingValidationError) {
|
||||
Button("确定", role: .cancel) { }
|
||||
} message: {
|
||||
Text(validationErrorMessage)
|
||||
}
|
||||
.alert("代理测试结果", isPresented: $showingProxyTestResult) {
|
||||
Button("确定", role: .cancel) { }
|
||||
} message: {
|
||||
proxyTestAlertContent
|
||||
}
|
||||
.alert("删除自定义币种", isPresented: $showingCustomSymbolDeleteConfirmation) {
|
||||
Button("取消", role: .cancel) { }
|
||||
Button("删除", role: .destructive) {
|
||||
deleteCustomSymbol()
|
||||
}
|
||||
.padding(24)
|
||||
} message: {
|
||||
deleteCustomSymbolMessage
|
||||
}
|
||||
}
|
||||
|
||||
// 主要内容视图
|
||||
private var mainContentView: some View {
|
||||
VStack(spacing: 0) {
|
||||
ScrollView {
|
||||
settingsContentView
|
||||
.padding(24)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// 底部按钮区域
|
||||
bottomButtonsView
|
||||
}
|
||||
}
|
||||
|
||||
// 设置内容视图
|
||||
private var settingsContentView: some View {
|
||||
VStack(spacing: 24) {
|
||||
refreshSettingsView
|
||||
launchSettingsView
|
||||
proxySettingsView
|
||||
customCryptoSettingsView
|
||||
|
||||
Spacer(minLength: 20)
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新设置视图
|
||||
private var refreshSettingsView: some View {
|
||||
SettingsGroupView(title: "刷新设置", icon: "timer") {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("选择价格刷新间隔")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
ForEach(RefreshInterval.allCases, id: \.self) { interval in
|
||||
IntervalSelectionButton(
|
||||
interval: interval,
|
||||
isSelected: tempRefreshInterval == interval,
|
||||
onSelect: { tempRefreshInterval = interval }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 启动设置视图
|
||||
private var launchSettingsView: some View {
|
||||
SettingsGroupView(title: "启动设置", icon: "power") {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("开机自动启动")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text("应用将在系统启动时自动运行")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Toggle("", isOn: $tempLaunchAtLogin)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.switch)
|
||||
.controlSize(.mini)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 代理设置视图
|
||||
private var proxySettingsView: some View {
|
||||
SettingsGroupView(title: "代理设置", icon: "network") {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
proxyToggleView
|
||||
proxyConfigView
|
||||
}
|
||||
.opacity(tempProxyEnabled ? 1.0 : 0.6)
|
||||
}
|
||||
}
|
||||
|
||||
// 代理开关视图
|
||||
private var proxyToggleView: some View {
|
||||
HStack {
|
||||
Text("启用HTTP代理")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Toggle("", isOn: $tempProxyEnabled)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.switch)
|
||||
.controlSize(.mini)
|
||||
}
|
||||
}
|
||||
|
||||
// 代理配置视图
|
||||
private var proxyConfigView: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("代理服务器配置")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
proxyServerConfigView
|
||||
proxyAuthConfigView
|
||||
proxyTestButtonView
|
||||
}
|
||||
}
|
||||
|
||||
// 代理服务器配置视图
|
||||
private var proxyServerConfigView: some View {
|
||||
HStack(spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("服务器地址")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
TextField("ip or proxy.example.com", text: $tempProxyHost)
|
||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
.frame(maxWidth: .infinity)
|
||||
.disabled(!tempProxyEnabled)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("端口")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
TextField("3128", text: $tempProxyPort)
|
||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
.frame(width: 80)
|
||||
.disabled(!tempProxyEnabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 代理认证配置视图
|
||||
private var proxyAuthConfigView: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("认证设置 (可选)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("用户名")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
TextField("user", text: $tempProxyUsername)
|
||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
.frame(maxWidth: .infinity)
|
||||
.disabled(!tempProxyEnabled)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("密码")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
SecureField("password", text: $tempProxyPassword)
|
||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
.frame(maxWidth: .infinity)
|
||||
.disabled(!tempProxyEnabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 代理测试按钮视图
|
||||
private var proxyTestButtonView: some View {
|
||||
HStack {
|
||||
Spacer()
|
||||
|
||||
Button(action: testProxyConnection) {
|
||||
HStack {
|
||||
if isTestingProxy {
|
||||
ProgressView()
|
||||
.scaleEffect(0.4)
|
||||
.frame(width: 8, height: 8)
|
||||
} else {
|
||||
Image(systemName: "network")
|
||||
.font(.system(size: 12))
|
||||
}
|
||||
Text(isTestingProxy ? "测试中..." : "测试连接")
|
||||
}
|
||||
.frame(minWidth: 80)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.disabled(!tempProxyEnabled || isTestingProxy || isSaving)
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义币种设置视图
|
||||
private var customCryptoSettingsView: some View {
|
||||
SettingsGroupView(title: "自定义币种", icon: "plus.circle") {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if appSettings.isUsingCustomSymbol() {
|
||||
currentCustomSymbolView
|
||||
} else {
|
||||
addCustomSymbolView
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 当前自定义币种视图
|
||||
private var currentCustomSymbolView: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Image(systemName: appSettings.getCurrentActiveSystemImageName())
|
||||
.foregroundColor(.orange)
|
||||
.font(.system(size: 16))
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("当前自定义币种")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
|
||||
Text(appSettings.getCurrentActivePairDisplayName())
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// 取消按钮
|
||||
Button("取消") {
|
||||
onClose()
|
||||
Button("删除") {
|
||||
showingCustomSymbolDeleteConfirmation = true
|
||||
}
|
||||
.keyboardShortcut(.escape)
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 保存按钮
|
||||
Button(action: saveSettings) {
|
||||
HStack {
|
||||
if isSaving {
|
||||
ProgressView()
|
||||
.scaleEffect(0.4)
|
||||
.frame(width: 8, height: 8)
|
||||
}
|
||||
Text("保存")
|
||||
// 添加自定义币种视图
|
||||
private var addCustomSymbolView: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("添加自定义币种")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text("输入3-5个大写字母的币种符号(如 ADA、DOGE、SHIB)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
customSymbolInputView
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义币种输入视图
|
||||
private var customSymbolInputView: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("币种符号")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
TextField("例如: ADA", text: Binding(
|
||||
get: { customSymbolInput },
|
||||
set: { newValue in
|
||||
let filteredValue = newValue.filter { $0.isLetter }.uppercased()
|
||||
customSymbolInput = String(filteredValue.prefix(5))
|
||||
|
||||
let validation = CustomCryptoSymbol.isValidSymbol(customSymbolInput)
|
||||
isCustomSymbolValid = validation.isValid
|
||||
customSymbolErrorMessage = validation.errorMessage
|
||||
}
|
||||
.frame(minWidth: 80)
|
||||
))
|
||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
Button("添加") {
|
||||
addCustomSymbol()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(isSaving)
|
||||
.controlSize(.small)
|
||||
.disabled(!isCustomSymbolValid || isSaving)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 16)
|
||||
}
|
||||
.frame(width: 480, height: 700)
|
||||
.alert("配置验证", isPresented: $showingValidationError) {
|
||||
Button("确定", role: .cancel) { }
|
||||
} message: {
|
||||
Text(validationErrorMessage)
|
||||
}
|
||||
.alert("代理测试结果", isPresented: $showingProxyTestResult) {
|
||||
Button("确定", role: .cancel) { }
|
||||
} message: {
|
||||
HStack {
|
||||
Image(systemName: proxyTestSucceeded ? "checkmark.circle.fill" : "xmark.circle.fill")
|
||||
.foregroundColor(proxyTestSucceeded ? .green : .red)
|
||||
Text(proxyTestResultMessage)
|
||||
|
||||
if !isCustomSymbolValid && !customSymbolInput.isEmpty {
|
||||
Text(customSymbolErrorMessage ?? "输入格式不正确")
|
||||
.font(.caption)
|
||||
.foregroundColor(.red)
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
|
||||
if customSymbolInput.isEmpty {
|
||||
Text("输入币种符号后将自动验证")
|
||||
.font(.caption)
|
||||
.foregroundColor(.gray)
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 底部按钮视图
|
||||
private var bottomButtonsView: some View {
|
||||
HStack {
|
||||
Spacer()
|
||||
|
||||
Button("取消") {
|
||||
onClose()
|
||||
}
|
||||
.keyboardShortcut(.escape)
|
||||
|
||||
Button(action: saveSettings) {
|
||||
HStack {
|
||||
if isSaving {
|
||||
ProgressView()
|
||||
.scaleEffect(0.4)
|
||||
.frame(width: 8, height: 8)
|
||||
}
|
||||
Text("保存")
|
||||
}
|
||||
.frame(minWidth: 80)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(isSaving)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 16)
|
||||
}
|
||||
|
||||
// 代理测试警告内容
|
||||
private var proxyTestAlertContent: some View {
|
||||
HStack {
|
||||
Image(systemName: proxyTestSucceeded ? "checkmark.circle.fill" : "xmark.circle.fill")
|
||||
.foregroundColor(proxyTestSucceeded ? .green : .red)
|
||||
Text(proxyTestResultMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// 删除自定义币种确认消息
|
||||
private var deleteCustomSymbolMessage: Text {
|
||||
if let customSymbol = appSettings.customCryptoSymbol {
|
||||
return Text("确定要删除自定义币种 \"\(customSymbol.displayName)\" 吗?删除后将无法恢复。")
|
||||
} else {
|
||||
return Text("确定要删除自定义币种吗?删除后将无法恢复。")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -385,6 +552,40 @@ struct PreferencesWindowView: View {
|
||||
|
||||
return (true, nil)
|
||||
}
|
||||
|
||||
// MARK: - 自定义币种相关方法
|
||||
|
||||
/**
|
||||
* 添加自定义币种
|
||||
*/
|
||||
private func addCustomSymbol() {
|
||||
guard isCustomSymbolValid, !customSymbolInput.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let customSymbol = try CustomCryptoSymbol(symbol: customSymbolInput)
|
||||
appSettings.saveCustomCryptoSymbol(customSymbol)
|
||||
|
||||
// 清空输入状态
|
||||
customSymbolInput = ""
|
||||
isCustomSymbolValid = false
|
||||
customSymbolErrorMessage = nil
|
||||
|
||||
print("✅ [Preferences] 已添加自定义币种: \(customSymbol.displayName)")
|
||||
} catch {
|
||||
// 这种情况理论上不会发生,因为我们在onChange中已经验证了
|
||||
print("❌ [Preferences] 添加自定义币种失败: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除自定义币种
|
||||
*/
|
||||
private func deleteCustomSymbol() {
|
||||
appSettings.removeCustomCryptoSymbol()
|
||||
print("✅ [Preferences] 已删除自定义币种")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user