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:
ZhangLei
2025-11-03 21:47:32 +08:00
parent f9f81774a0
commit c6150b1b8f
7 changed files with 1182 additions and 235 deletions

View File

@@ -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)
}
}
}

View File

@@ -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

View File

@@ -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
}
// APIUSDT
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
}
}

View File

@@ -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)"
}
}
}

View File

@@ -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() }
}
}

View 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 {
/// ADADOGESHIB
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
///
/// CryptoSymbolCustomCryptoSymbol
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
}
}

View File

@@ -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] 已删除自定义币种")
}
}
/**