From 9a7e0cc1e09dbcd727d50985a3a6a683c72b3370 Mon Sep 17 00:00:00 2001 From: ysicing Date: Wed, 23 Sep 2026 22:28:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai-usage):=20=E4=BC=98=E5=8C=96=E9=A2=9D?= =?UTF-8?q?=E5=BA=A6=E4=B8=8E=E9=87=8D=E7=BD=AE=E8=BF=9B=E5=BA=A6=E5=B1=95?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/XStatsUI/Design/Tokens.swift | 1 + .../Sources/XStatsUI/Panel/AIUsagePage.swift | 24 ++++++++++++------- .../AIUsageTests/QuotaProviderTests.swift | 14 +++++++++++ ThirdPartyNotices.md | 3 +-- 4 files changed, 31 insertions(+), 11 deletions(-) diff --git a/Packages/XStatsKit/Sources/XStatsUI/Design/Tokens.swift b/Packages/XStatsKit/Sources/XStatsUI/Design/Tokens.swift index 95ee215..fdf153f 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Design/Tokens.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Design/Tokens.swift @@ -17,6 +17,7 @@ extension DS { static let success = Color.dynamic(light: 0x16A34A, dark: 0x22C55E) static let warning = Color.dynamic(light: 0xD97706, dark: 0xF59E0B) static let error = Color.dynamic(light: 0xDC2626, dark: 0xF87171) + static let critical = Color.dynamic(light: 0x991B1B, dark: 0xEF4444) // 文字 static let textPrimary = Color.dynamic(light: 0x111827, dark: 0xF3F4F6) diff --git a/Packages/XStatsKit/Sources/XStatsUI/Panel/AIUsagePage.swift b/Packages/XStatsKit/Sources/XStatsUI/Panel/AIUsagePage.swift index 3b7e30b..8362dd6 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Panel/AIUsagePage.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Panel/AIUsagePage.swift @@ -139,21 +139,19 @@ private struct LocalUsageContent: View { .foregroundStyle(DS.Palette.textPrimary) } .dsFont(.xs) - ProgressView(value: window.remainingPercent, total: 100) - .tint(DS.Palette.primary) + ProgressTrack(fraction: window.remainingPercent / 100, + color: progressTint(for: window.remainingPercent)) .accessibilityLabel("\(quotaTitle(window.kind)) \(tr("剩余")) \(Int(window.remainingPercent.rounded()))%") if let reset = window.resetsAt { Text(tr("重置:") + " " + reset.formatted(Date.FormatStyle(date: .abbreviated, time: .shortened, locale: L10n.locale))) .dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) TimelineView(.periodic(from: .now, by: 60)) { context in if let fraction = window.remainingTimeFraction(at: context.date) { - HStack(spacing: DS.Space.s2) { - Text(tr("距重置")) - .dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) - ProgressView(value: fraction, total: 1) - .tint(DS.Palette.textTertiary) - .accessibilityLabel(tr("距重置")) - } + // 时间条越接近重置越满、颜色越偏绿;额度条则随剩余量减少而变红。 + ProgressTrack(fraction: 1 - fraction, + color: progressTint(for: (1 - fraction) * 100)) + .accessibilityLabel(tr("重置:") + " " + reset.formatted(Date.FormatStyle(date: .abbreviated, time: .shortened, locale: L10n.locale))) + .accessibilityValue("\(Int(((1 - fraction) * 100).rounded()))%") } } } @@ -186,6 +184,14 @@ private struct LocalUsageContent: View { } } + private func progressTint(for percent: Double) -> Color { + if percent <= 10 { return DS.Palette.critical } + if percent <= 20 { return DS.Palette.error } + if percent < 40 { return DS.Palette.warning } + if percent < 80 { return DS.Palette.primary } + return DS.Palette.success + } + private func quotaFailureText(_ failure: AIQuotaFailure?) -> String { switch failure { case nil: tr("暂无额度数据") diff --git a/Packages/XStatsKit/Tests/AIUsageTests/QuotaProviderTests.swift b/Packages/XStatsKit/Tests/AIUsageTests/QuotaProviderTests.swift index 1b02071..b90d71a 100644 --- a/Packages/XStatsKit/Tests/AIUsageTests/QuotaProviderTests.swift +++ b/Packages/XStatsKit/Tests/AIUsageTests/QuotaProviderTests.swift @@ -57,6 +57,20 @@ private actor StubQuotaHTTPClient: QuotaHTTPClient { #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer test-token") } + @Test func codexShowsOnlyQuotaWindowsReturnedForEachPlan() throws { + let now = Date(timeIntervalSince1970: 1_799_000_000) + let plus = Data(#"{"plan_type":"plus","rate_limit":{"primary_window":{"used_percent":35,"limit_window_seconds":18000,"reset_after_seconds":3600},"secondary_window":null}}"#.utf8) + let pro = Data(#"{"plan_type":"pro","rate_limit":{"primary_window":{"used_percent":74,"limit_window_seconds":604800,"reset_after_seconds":86400},"secondary_window":null}}"#.utf8) + + let plusSnapshot = try CodexQuotaProvider.parse(plus, now: now) + #expect(plusSnapshot.window(.session)?.remainingPercent == 65) + #expect(plusSnapshot.window(.weekly) == nil) + + let proSnapshot = try CodexQuotaProvider.parse(pro, now: now) + #expect(proSnapshot.window(.session) == nil) + #expect(proSnapshot.window(.weekly)?.remainingPercent == 26) + } + @Test func claudeMapsFiveHourAndWeeklyBuckets() async throws { let http = StubQuotaHTTPClient(body: #"{"five_hour":{"utilization":22.5,"resets_at":"2026-09-23T10:00:00Z"},"seven_day":{"utilization":72,"resets_at":"2026-09-29T10:00:00Z"},"seven_day_sonnet":{"utilization":81,"resets_at":"2026-09-29T10:00:00Z"}}"#) let provider = ClaudeQuotaProvider(credentials: { "claude-test-token" }, http: http) diff --git a/ThirdPartyNotices.md b/ThirdPartyNotices.md index 396ad1b..f42aaf7 100644 --- a/ThirdPartyNotices.md +++ b/ThirdPartyNotices.md @@ -78,8 +78,7 @@ SOFTWARE. ## AI Provider 契约参考 -XStats 的 AI Provider 契约与状态设计参考了以下项目公开的 Provider 契约、测试样例与实现模式; -XStats 未引入这些项目的可执行程序、Sparkle 更新模块或浏览器扩展,也不读取任何登录凭据: +XStats 的 AI Provider 契约与状态设计参考了以下项目公开的 Provider 契约、测试样例与实现模式: - [burakgon/ai-usage-menubar](https://github.com/burakgon/ai-usage-menubar),MIT License,Copyright (c) 2026 Burak Gon - [robinebers/openusage](https://github.com/robinebers/openusage),MIT License,Copyright (c) 2026 Robin Ebers