diff --git a/PanBar/Domain/Services/PortfolioService.swift b/PanBar/Domain/Services/PortfolioService.swift index 4d3773e..ee137a1 100644 --- a/PanBar/Domain/Services/PortfolioService.swift +++ b/PanBar/Domain/Services/PortfolioService.swift @@ -36,7 +36,9 @@ actor PortfolioService { if allSymbols.isEmpty { quotes = [:] } else { - quotes = (try? await provider.fetch(Array(allSymbols))) ?? [:] + let fetched = try await provider.fetch(Array(allSymbols)) + guard !fetched.isEmpty else { throw ProviderError.empty } + quotes = fetched } let converter = await fx.currentConverter() diff --git a/PanBar/Infrastructure/QuoteRefresher.swift b/PanBar/Infrastructure/QuoteRefresher.swift index d3bcc3a..483e55b 100644 --- a/PanBar/Infrastructure/QuoteRefresher.swift +++ b/PanBar/Infrastructure/QuoteRefresher.swift @@ -224,32 +224,33 @@ final class QuoteRefresher: ObservableObject { } private func tick() async { - await MainActor.run { self.isRefreshing = true } + guard !isRefreshing else { return } + isRefreshing = true + defer { isRefreshing = false } + do { let snap = try await service.computeSnapshot() - // 网络空响应(provider.fetch 失败 -> quotes 空)时,不要拿一个全 nil quote 的 - // snapshot 去覆盖磁盘 seed 的好数据;原样保留 cached snapshot,等下次 tick。 let hasFreshData = !snap.allQuotes.isEmpty - await MainActor.run { - if hasFreshData { - self.snapshot = snap - self.snapshotIsFromCache = false - self.quotes.merge(snap.allQuotes) { _, new in new } - self.lastUpdated = Date() - self.lastError = nil - self.alertEngine?.evaluate(quotes: self.quotes) + let isEmptyPortfolio = snap.positions.isEmpty && snap.allQuotes.isEmpty + if hasFreshData || isEmptyPortfolio { + snapshot = snap + snapshotIsFromCache = false + if isEmptyPortfolio { + quotes = [:] + } else { + quotes.merge(snap.allQuotes) { _, new in new } } + lastUpdated = Date() + lastError = nil + alertEngine?.evaluate(quotes: quotes) } // 拉到了就异步写盘,下次冷启动可以秒读 if hasFreshData { quoteCacheRepo?.upsertMany(snap.allQuotes) } } catch { - await MainActor.run { - self.lastError = String(describing: error) - } + lastError = String(describing: error) Log.quote.error("refresh failed: \(String(describing: error), privacy: .public)") } - await MainActor.run { self.isRefreshing = false } } } diff --git a/PanBar/Popover/Views/PopoverRoot.swift b/PanBar/Popover/Views/PopoverRoot.swift index 3c6ce4f..a76526f 100644 --- a/PanBar/Popover/Views/PopoverRoot.swift +++ b/PanBar/Popover/Views/PopoverRoot.swift @@ -228,9 +228,12 @@ struct PopoverRoot: View { } private var footerStatus: String { - if let err = refresher.lastError, !err.isEmpty { + if refresher.isOffline { return L("footer.offline", comment: "") } + if hasQuoteError { + return L("footer.quoteFailed", comment: "") + } // 还没拿到任何 fresh 数据,但有磁盘 seed 进来的旧数据 if refresher.snapshotIsFromCache { return L("footer.cached", comment: "") @@ -247,20 +250,29 @@ struct PopoverRoot: View { /// 文字统一用 .secondary 灰色保证两个模式下都清晰可读,状态用图标颜色区分: /// - 离线:橙色 wifi.slash + /// - 行情失败:橙色感叹号 /// - cached:橙色 clock(让用户知道在看的不是最新值,但不刺眼) /// - 正常:灰色刷新箭头 private var footerIcon: String { - if refresher.lastError != nil { return "wifi.slash" } + if refresher.isOffline { return "wifi.slash" } + if hasQuoteError { return "exclamationmark.triangle" } if refresher.snapshotIsFromCache { return "clock.arrow.circlepath" } return "arrow.clockwise" } private var footerIconColor: Color { - if refresher.lastError != nil { return .orange } + if refresher.isOffline || hasQuoteError { return .orange } if refresher.snapshotIsFromCache { return .orange } return .secondary } + private var hasQuoteError: Bool { + if let err = refresher.lastError { + return !err.isEmpty + } + return false + } + private func openSettings() { SettingsWindowController.shared.show() } diff --git a/PanBar/Resources/Localizable.xcstrings b/PanBar/Resources/Localizable.xcstrings index 2db8ffd..688566a 100644 --- a/PanBar/Resources/Localizable.xcstrings +++ b/PanBar/Resources/Localizable.xcstrings @@ -416,6 +416,12 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "离线" } } } }, + "footer.quoteFailed" : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Quote refresh failed" } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "行情刷新失败" } } + } + }, "holdings.empty" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "No holdings yet." } },