From ced652911ba577b937c3d48d9db3ae942f776d29 Mon Sep 17 00:00:00 2001 From: maple Date: Tue, 2 Jun 2026 20:52:49 +0800 Subject: [PATCH 1/6] docs: add localization design --- .../specs/2026-06-02-localization-design.md | 285 ++++++++++++++++++ 1 file changed, 285 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-02-localization-design.md diff --git a/docs/superpowers/specs/2026-06-02-localization-design.md b/docs/superpowers/specs/2026-06-02-localization-design.md new file mode 100644 index 0000000..a25dcd2 --- /dev/null +++ b/docs/superpowers/specs/2026-06-02-localization-design.md @@ -0,0 +1,285 @@ +# Localization Design + +## Purpose + +Sub2API Status Bar should support users who prefer English or Simplified Chinese without making the macOS menu bar app feel heavier. The first localization slice will add an in-app language setting with Auto, English, and 简体中文 choices, then localize both visible UI and generated text that users copy or receive as local notifications. + +The project already has a partial foundation: + +- `AppLanguage` exists with `auto`, `zhHans`, and `en` cases. +- `AppConfig.language` is already persisted and can be seeded from `SUB2API_LANGUAGE`. +- The Settings language picker was intentionally removed earlier because localization was not wired. +- Most UI and generated report text is still hard-coded in English. + +This design turns the existing language field into a working preference instead of adding a parallel system. + +## Scope + +### In Scope + +- Add a working Settings language picker: Auto, English, 简体中文. +- Resolve `AppLanguage.auto` from the current system locale at render/generation time. +- Localize app-owned text in: + - login and connection views, + - monitor panel, + - dashboard cards, + - charts and quota views, + - Settings sections, + - update checking UI, + - button labels, help strings, accessibility labels, and menu bar tooltip text. +- Localize generated app text in: + - usage insights, + - local insight notifications, + - recovery suggestions, + - onboarding checklist, + - usage report copy, + - diagnostics copy, + - support bundle copy, + - social share summary and share card labels. +- Keep user and server data unchanged: emails, account names, URLs, model names, release names, server error messages, currency amounts, and raw metrics are not translated. +- Add focused tests for language resolution, config persistence, English compatibility, Simplified Chinese output, and token redaction in localized generated text. +- Update README, README.zh-CN, and CHANGELOG for the new language setting. + +### Out of Scope + +- Adding more languages beyond English and Simplified Chinese. +- Shipping external translator workflows, `.xliff` export, or App Store localization metadata. +- Translating backend-provided text or GitHub release content. +- Replacing all existing tests with locale snapshots. +- Changing the app's data model, network endpoints, update mechanism, or credential storage behavior. + +## Recommended Approach + +Use a code-owned localization layer in `Sub2APIStatusCore`, not Apple `.strings` or `.xcstrings` resources for this first PR. + +Reasons: + +- The app is a SwiftPM executable with a reusable core library, and many user-facing strings are generated in core types. +- The requested language can change at runtime from Settings; code-based lookup makes immediate refresh straightforward. +- Tests can assert localized output directly without an Xcode project or bundle-resource edge cases. +- The repo already started with `AppLanguage`, so the smallest coherent change is to complete that path. + +Apple resource localization remains a valid future migration if the project grows to many languages or needs translator tooling. + +## Architecture + +### Language Resolution + +Add a small resolver in core, for example: + +- `AppLanguage.resolved(preferredLanguages:) -> AppLanguage` +- `AppLanguage.currentResolved() -> AppLanguage` + +Rules: + +- `.en` resolves to `.en`. +- `.zhHans` resolves to `.zhHans`. +- `.auto` resolves to `.zhHans` when the preferred language starts with `zh`, `zh-Hans`, `zh-CN`, or another Simplified Chinese compatible identifier. +- `.auto` resolves to `.en` for everything else. + +The resolver should be deterministic and directly testable by passing language identifiers instead of depending only on the live machine locale. + +### Localization API + +Create a small code localization layer in `Sub2APIStatusCore`, for example: + +- `AppStrings` +- `LocalizedStringKey` is not used as the storage type because core-generated reports need plain `String`. +- `AppStrings(language: AppLanguage, preferredLanguages: [String] = Locale.preferredLanguages)` resolves the effective language once and exposes methods/properties that return `String`. + +The API should support two patterns: + +1. Static labels: + - settings title, + - button labels, + - section headings, + - update status labels. +2. Dynamic sentences: + - stale data label, + - quota remaining text, + - token surge insight detail, + - generated report lines. + +Avoid a giant untyped dictionary for dynamic strings. Prefer grouped computed properties and functions so call sites remain discoverable and compiler-checked. + +### UI Integration + +`MonitorViewModel` already publishes `config` and `settingsDraft`, so SwiftUI can refresh when `config.language` changes. + +Expected UI flow: + +1. User opens Settings. +2. User changes `Language` picker in General. +3. User clicks Save. +4. `saveSettings()` persists `settingsDraft.language`. +5. `config.language` updates. +6. `MonitorPanel`, Settings, menu bar title/tooltip, and generated copy actions use the new language on next render/action. + +For views, use a lightweight helper from the model or local computed value: + +- `let strings = AppStrings(language: model.config.language)` for committed settings. +- `let strings = AppStrings(language: model.settingsDraft.language)` inside editable Settings controls when the picker itself should update immediately while editing. + +The language picker belongs in `GeneralSettingsSection`, near other app-wide preferences. Its choices should be: + +- `Auto` +- `English` +- `简体中文` + +The display names may remain self-identifying so users can recover if they choose an unfamiliar language. + +### Core Generated Text Integration + +Core report and summary generators should accept a language or strings object rather than reaching into global UI state. + +Target shape: + +- `UsageReport.make(snapshot:config:now:language:)` +- `DiagnosticReport.make(snapshot:config:now:language:)` +- `SupportBundleReport.make(snapshot:config:now:language:)` +- `SocialShareSummary.make(snapshot:config:now:language:)` +- usage insight builders receive language through config or explicit parameters where they already receive thresholds/settings. + +The exact signatures can vary to keep diffs small, but the dependency direction should stay clear: + +- `Sub2APIStatusCore` does not import SwiftUI or AppKit. +- `Sub2APIStatusBar` passes `model.config.language` into core generators. +- Tests can call core generators with `.en` and `.zhHans` directly. + +### Notifications + +Local notifications should use the configured app language at the time the notification is created. If the user changes language later, already-delivered macOS notification text does not need to change. + +`InsightNotifier` should receive either `AppLanguage` or `AppStrings` through the existing notification path. Keep notification identifiers stable and language-independent. + +### Diagnostics And Support Bundle + +Diagnostics and support bundle text should be user-readable in the selected language, but token redaction and support safety are higher priority than translation. + +Rules: + +- Continue redacting access tokens, refresh tokens, passwords, and secret-looking values. +- Keep raw URLs, account email, app version, refresh interval, and menu bar metric values intact. +- It is acceptable to keep a few stable technical field names in English if changing them would make support parsing brittle. +- Tests must cover redaction for both English and Simplified Chinese output. + +### Social Share Card + +The share summary and image card should use localized labels and punchline text. Numeric values, model names, and dates stay formatted consistently with the existing app unless a focused formatter change is required. + +The card renderer should receive localized strings from `SocialShareSummary` rather than reimplementing language decisions inside drawing code. + +## File Plan + +Likely files to create: + +- `Sources/Sub2APIStatusCore/AppStrings.swift` + +Likely files to modify: + +- `Sources/Sub2APIStatusCore/AppConfig.swift` +- `Sources/Sub2APIStatusCore/Models.swift` +- `Sources/Sub2APIStatusCore/UsageInsights.swift` +- `Sources/Sub2APIStatusCore/InsightAlertPolicy.swift` +- `Sources/Sub2APIStatusCore/OnboardingChecklist.swift` +- `Sources/Sub2APIStatusCore/RecoverySuggestion.swift` +- `Sources/Sub2APIStatusCore/UsageReport.swift` +- `Sources/Sub2APIStatusCore/DiagnosticReport.swift` +- `Sources/Sub2APIStatusCore/SupportBundleReport.swift` +- `Sources/Sub2APIStatusCore/SocialShareSummary.swift` +- `Sources/Sub2APIStatusBar/Sub2APIStatusBarApp.swift` +- `Sources/Sub2APIStatusBar/MonitorViewModel.swift` +- `Sources/Sub2APIStatusBar/LoginViews.swift` +- `Sources/Sub2APIStatusBar/MonitorPanel.swift` +- `Sources/Sub2APIStatusBar/DashboardComponents.swift` +- `Sources/Sub2APIStatusBar/ChartViews.swift` +- `Sources/Sub2APIStatusBar/QuotaViews.swift` +- `Sources/Sub2APIStatusBar/SettingsView.swift` +- `Sources/Sub2APIStatusBar/SharedViews.swift` +- `Sources/Sub2APIStatusBar/SocialShareCard.swift` +- `Tests/Sub2APIStatusCoreTests/Sub2APIStatusCoreTests.swift` +- `README.md` +- `README.zh-CN.md` +- `CHANGELOG.md` + +The implementation plan may split these into smaller tasks to keep each commit reviewable. + +## Testing Strategy + +Run narrow tests first, then full checks: + +1. Add unit tests for `AppLanguage` resolution: + - `.en` stays English. + - `.zhHans` stays Simplified Chinese. + - `.auto` with `zh-Hans` resolves Simplified Chinese. + - `.auto` with `en-US` resolves English. +2. Add unit tests for config persistence: + - saved `language` round-trips. + - old config without `language` decodes as `.auto`. + - `SUB2API_LANGUAGE=zh-Hans` seeds defaults. +3. Add core output tests: + - English report retains existing important labels. + - Simplified Chinese report contains Chinese labels. + - diagnostics/support bundle redacts secrets in both languages. + - insight/recovery/checklist output changes with language. +4. Add UI-level compile verification through `swift build`. +5. Run: + - `swift test` + - `swift build` + +Manual verification for the final implementation: + +- Launch app with default Auto on an English system and confirm English UI. +- Set language to 简体中文, save, reopen panel, confirm visible UI changes. +- Copy Usage Report, Diagnostics, Support Bundle, and Share Card; confirm selected-language labels and redaction. +- Switch back to English and confirm UI/report labels return to English. + +## Documentation Updates + +`README.md` should mention the Language setting under Settings. `README.zh-CN.md` should describe Auto / English / 简体中文 in Chinese. `CHANGELOG.md` should add an unreleased or next-version entry consistent with the existing format. + +The PR body should call out: + +- language setting added, +- UI and generated text localized, +- no telemetry added, +- diagnostics remain token-redacted, +- checks run. + +## Risks And Mitigations + +### Risk: PR becomes too large + +Mitigation: keep the architecture simple, avoid Apple resource migration, and split implementation commits by localization infrastructure, UI/generated text wiring, and docs/tests. + +### Risk: English behavior regresses + +Mitigation: preserve existing English strings where practical and add focused tests for existing report/menu/status labels. + +### Risk: Generated support text becomes harder to parse + +Mitigation: translate human-facing wording but preserve raw values and any field names that are likely to be used for support triage. + +### Risk: Some text remains hard-coded + +Mitigation: run `rg` over Swift string-heavy UI patterns before final verification and explicitly document any intentionally untranslated strings. + +### Risk: Runtime language switching misses menu bar tooltip/title + +Mitigation: after saving settings, call the existing status item update path or trigger snapshot-change handling so menu bar text uses the new language immediately. + +## Acceptance Criteria + +- Settings exposes a working Language picker with Auto, English, and 简体中文. +- Selecting English produces English UI and generated app text. +- Selecting 简体中文 produces Simplified Chinese UI and generated app text. +- Auto follows system preferred language for English vs Simplified Chinese. +- Usage Report, Diagnostics, Support Bundle, Social Share text/card, insights, notifications, recovery suggestions, and onboarding checklist use the selected language. +- Server-provided/user data remains unmodified. +- Diagnostics and support bundle remain token-redacted. +- README, README.zh-CN, and CHANGELOG document the feature. +- `swift test` and `swift build` pass. + +## Approval Gate + +This spec is ready for implementation planning after review. Implementation should start with a detailed plan saved under `docs/superpowers/plans/` and should use small, verifiable commits. From ad7f38a2d902de41d046259b8cfe4d98afde80c7 Mon Sep 17 00:00:00 2001 From: maple Date: Tue, 2 Jun 2026 20:55:53 +0800 Subject: [PATCH 2/6] docs: add localization implementation plan --- .../plans/2026-06-02-localization.md | 216 ++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-02-localization.md diff --git a/docs/superpowers/plans/2026-06-02-localization.md b/docs/superpowers/plans/2026-06-02-localization.md new file mode 100644 index 0000000..2c87e7e --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-localization.md @@ -0,0 +1,216 @@ +# Localization Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a working Auto / English / Simplified Chinese language setting that localizes app UI and generated user-facing text. + +**Architecture:** Complete the existing `AppLanguage` path with a code-owned `AppStrings` localization layer in `Sub2APIStatusCore`. Pass the configured language from `MonitorViewModel` into SwiftUI views, status item updates, notifications, and generated report/share helpers. Keep server/user data unchanged and preserve support-safe token redaction. + +**Tech Stack:** Swift 6.1, SwiftPM executable/library targets, SwiftUI/AppKit, Swift Testing. + +--- + +## File Structure + +- Create `Sources/Sub2APIStatusCore/AppStrings.swift`: language resolution and grouped localized strings/functions used by both core and app targets. +- Modify `Sources/Sub2APIStatusCore/AppConfig.swift`: add resolver/display helpers for language setting. +- Modify core generators in `Sources/Sub2APIStatusCore/*.swift`: accept `AppLanguage` or `AppStrings` where user-facing text is generated. +- Modify app views in `Sources/Sub2APIStatusBar/*.swift`: replace hard-coded UI labels with `AppStrings` where practical and add the Settings picker. +- Modify `Tests/Sub2APIStatusCoreTests/Sub2APIStatusCoreTests.swift`: add focused localization tests and update existing English expectations if signatures change. +- Modify `README.md`, `README.zh-CN.md`, and `CHANGELOG.md`: document the language setting. + +## Task 1: Language Resolution And String Layer + +**Files:** +- Create: `Sources/Sub2APIStatusCore/AppStrings.swift` +- Modify: `Sources/Sub2APIStatusCore/AppConfig.swift` +- Test: `Tests/Sub2APIStatusCoreTests/Sub2APIStatusCoreTests.swift` + +- [ ] **Step 1: Write failing tests** + +Add tests that assert language resolution and basic labels: + +```swift +@Test func appLanguageResolvesAutoFromPreferredLanguages() { + #expect(AppLanguage.auto.resolved(preferredLanguages: ["zh-Hans-US"]) == .zhHans) + #expect(AppLanguage.auto.resolved(preferredLanguages: ["zh-CN"]) == .zhHans) + #expect(AppLanguage.auto.resolved(preferredLanguages: ["en-US"]) == .en) + #expect(AppLanguage.en.resolved(preferredLanguages: ["zh-Hans"]) == .en) + #expect(AppLanguage.zhHans.resolved(preferredLanguages: ["en-US"]) == .zhHans) +} + +@Test func appStringsReturnLocalizedStaticLabels() { + #expect(AppStrings(language: .en).settings == "Settings") + #expect(AppStrings(language: .zhHans).settings == "设置") + #expect(AppStrings(language: .en).languageDisplayName(.auto) == "Auto") + #expect(AppStrings(language: .zhHans).languageDisplayName(.auto) == "自动") +} +``` + +- [ ] **Step 2: Run red test** + +Run: `swift test --filter appLanguageResolvesAutoFromPreferredLanguages` +Expected: FAIL because `resolved(preferredLanguages:)` and/or `AppStrings` do not exist yet. + +- [ ] **Step 3: Implement language resolution and initial strings** + +Add `AppStrings` with enough labels to pass the tests plus common UI labels used in later tasks. Add `AppLanguage.resolved(preferredLanguages:)` and `resolved()` helpers in `AppConfig.swift`. + +- [ ] **Step 4: Run green tests** + +Run: `swift test --filter appLanguageResolvesAutoFromPreferredLanguages && swift test --filter appStringsReturnLocalizedStaticLabels` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add Sources/Sub2APIStatusCore/AppStrings.swift Sources/Sub2APIStatusCore/AppConfig.swift Tests/Sub2APIStatusCoreTests/Sub2APIStatusCoreTests.swift +git commit -m "feat: add localization string layer" +``` + +## Task 2: Localize Core Generated Text + +**Files:** +- Modify: `Sources/Sub2APIStatusCore/Models.swift` +- Modify: `Sources/Sub2APIStatusCore/UsageInsights.swift` +- Modify: `Sources/Sub2APIStatusCore/OnboardingChecklist.swift` +- Modify: `Sources/Sub2APIStatusCore/RecoverySuggestion.swift` +- Modify: `Sources/Sub2APIStatusCore/UsageReport.swift` +- Modify: `Sources/Sub2APIStatusCore/DiagnosticReport.swift` +- Modify: `Sources/Sub2APIStatusCore/SupportBundleReport.swift` +- Modify: `Sources/Sub2APIStatusCore/SocialShareSummary.swift` +- Test: `Tests/Sub2APIStatusCoreTests/Sub2APIStatusCoreTests.swift` + +- [ ] **Step 1: Write failing tests** + +Add tests for Chinese generated text and redaction: + +```swift +@Test func usageReportCanRenderSimplifiedChinese() { + let config = AppConfig(baseURL: "https://sub2api.example.com", language: .zhHans) + let report = UsageReport.make(snapshot: .sampleConnected, config: config, now: Date(timeIntervalSince1970: 1_700_000_000), language: .zhHans) + #expect(report.contains("状态")) + #expect(report.contains("账户")) +} + +@Test func diagnosticsRemainRedactedWhenLocalized() { + let config = AppConfig(baseURL: "https://sub2api.example.com", authToken: "secret-token", refreshToken: "refresh-secret", language: .zhHans) + let report = DiagnosticReport.make(snapshot: .sampleConnected, config: config, now: Date(timeIntervalSince1970: 1_700_000_000), language: .zhHans) + #expect(report.contains("[REDACTED]")) + #expect(!report.contains("secret-token")) + #expect(!report.contains("refresh-secret")) +} +``` + +If `.sampleConnected` is not available, use the existing test fixture factory already present in the test file. + +- [ ] **Step 2: Run red tests** + +Run: `swift test --filter usageReportCanRenderSimplifiedChinese` +Expected: FAIL because localized signatures/output do not exist yet. + +- [ ] **Step 3: Implement minimal core localization** + +Thread language through core report/share/checklist/recovery/insight functions. Preserve existing English output where practical. Keep backward-compatible overloads when many existing call sites/tests use old signatures. + +- [ ] **Step 4: Run core tests** + +Run: `swift test` +Expected: PASS or only failures from app-target compile changes not yet updated. Fix core test regressions before continuing. + +- [ ] **Step 5: Commit** + +```bash +git add Sources/Sub2APIStatusCore Tests/Sub2APIStatusCoreTests/Sub2APIStatusCoreTests.swift +git commit -m "feat: localize generated app text" +``` + +## Task 3: Localize App UI And Runtime Language Setting + +**Files:** +- Modify: `Sources/Sub2APIStatusBar/Sub2APIStatusBarApp.swift` +- Modify: `Sources/Sub2APIStatusBar/MonitorViewModel.swift` +- Modify: `Sources/Sub2APIStatusBar/LoginViews.swift` +- Modify: `Sources/Sub2APIStatusBar/MonitorPanel.swift` +- Modify: `Sources/Sub2APIStatusBar/DashboardComponents.swift` +- Modify: `Sources/Sub2APIStatusBar/ChartViews.swift` +- Modify: `Sources/Sub2APIStatusBar/QuotaViews.swift` +- Modify: `Sources/Sub2APIStatusBar/SettingsView.swift` +- Modify: `Sources/Sub2APIStatusBar/SharedViews.swift` +- Modify: `Sources/Sub2APIStatusBar/SocialShareCard.swift` + +- [ ] **Step 1: Add Settings language picker** + +In `GeneralSettingsSection`, add a `Language` row with `Picker` bound to `$model.settingsDraft.language` and choices from `AppLanguage.allCases` using localized display labels. + +- [ ] **Step 2: Pass language into generated copy actions and notifications** + +Update `MonitorViewModel` copy/report/share/notification calls to pass `config.language`. + +- [ ] **Step 3: Localize high-impact UI labels** + +Replace visible hard-coded labels in Settings, Login, MonitorPanel, dashboard/charts/quota, help strings, and app status tooltip with `AppStrings` values/functions. Do not translate user data or server data. + +- [ ] **Step 4: Build app target** + +Run: `swift build` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add Sources/Sub2APIStatusBar Sources/Sub2APIStatusCore +git commit -m "feat: add runtime language setting" +``` + +## Task 4: Documentation And Final Verification + +**Files:** +- Modify: `README.md` +- Modify: `README.zh-CN.md` +- Modify: `CHANGELOG.md` + +- [ ] **Step 1: Document language setting** + +Update both READMEs Settings sections to mention Auto / English / 简体中文. Add a CHANGELOG entry for app localization. + +- [ ] **Step 2: Search for missed obvious hard-coded UI strings** + +Run: + +```bash +rg -n 'Text\("|Label\("|Button\("|Toggle\("|\.help\("|accessibilityDescription: "|toolTip = "' Sources/Sub2APIStatusBar Sources/Sub2APIStatusCore +``` + +Review results. Convert missed app-owned text or document intentional exceptions in the final summary. + +- [ ] **Step 3: Full verification** + +Run: + +```bash +swift test +swift build +``` + +Expected: both PASS. + +- [ ] **Step 4: Re-read changed files** + +Run: + +```bash +git diff --stat upstream/main...HEAD +git diff --check +git status --short --branch +``` + +Expected: no whitespace errors; only intended files changed. + +- [ ] **Step 5: Commit docs/final fixes** + +```bash +git add README.md README.zh-CN.md CHANGELOG.md +# Include any final localization fixes found in Step 2. +git commit -m "docs: document language setting" +``` From fdf54c57d736f6c72e41604683879a8ef7161a56 Mon Sep 17 00:00:00 2001 From: maple Date: Tue, 2 Jun 2026 20:57:04 +0800 Subject: [PATCH 3/6] feat: add localization string layer --- Sources/Sub2APIStatusCore/AppConfig.swift | 16 +- Sources/Sub2APIStatusCore/AppStrings.swift | 140 ++++++++++++++++++ .../Sub2APIStatusCoreTests.swift | 15 ++ 3 files changed, 166 insertions(+), 5 deletions(-) create mode 100644 Sources/Sub2APIStatusCore/AppStrings.swift diff --git a/Sources/Sub2APIStatusCore/AppConfig.swift b/Sources/Sub2APIStatusCore/AppConfig.swift index b09b5c2..3acfed6 100644 --- a/Sources/Sub2APIStatusCore/AppConfig.swift +++ b/Sources/Sub2APIStatusCore/AppConfig.swift @@ -8,13 +8,19 @@ public enum AppLanguage: String, Codable, CaseIterable, Identifiable, Sendable { public var id: String { rawValue } public var displayName: String { + AppStrings(language: .en).languageDisplayName(self) + } + + public func resolved(preferredLanguages: [String] = Locale.preferredLanguages) -> AppLanguage { switch self { + case .en, .zhHans: + return self case .auto: - "Auto" - case .zhHans: - "简体中文" - case .en: - "English" + let preferred = preferredLanguages.first?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" + if preferred == "zh" || preferred.hasPrefix("zh-") { + return .zhHans + } + return .en } } diff --git a/Sources/Sub2APIStatusCore/AppStrings.swift b/Sources/Sub2APIStatusCore/AppStrings.swift new file mode 100644 index 0000000..d801103 --- /dev/null +++ b/Sources/Sub2APIStatusCore/AppStrings.swift @@ -0,0 +1,140 @@ +import Foundation + +public struct AppStrings: Sendable { + public let language: AppLanguage + + public init(language: AppLanguage, preferredLanguages: [String] = Locale.preferredLanguages) { + self.language = language.resolved(preferredLanguages: preferredLanguages) + } + + public var settings: String { text(en: "Settings", zhHans: "设置") } + public var cancel: String { text(en: "Cancel", zhHans: "取消") } + public var save: String { text(en: "Save", zhHans: "保存") } + public var refresh: String { text(en: "Refresh", zhHans: "刷新") } + public var quit: String { text(en: "Quit", zhHans: "退出") } + public var open: String { text(en: "Open", zhHans: "打开") } + public var post: String { text(en: "Post", zhHans: "发布") } + public var report: String { text(en: "Report", zhHans: "报告") } + public var details: String { text(en: "Details", zhHans: "详情") } + public var disconnected: String { text(en: "Disconnected", zhHans: "未连接") } + public var general: String { text(en: "General", zhHans: "通用") } + public var accounts: String { text(en: "Accounts", zhHans: "账户") } + public var alerts: String { text(en: "Alerts", zhHans: "提醒") } + public var insights: String { text(en: "Insights", zhHans: "洞察") } + public var diagnostics: String { text(en: "Diagnostics", zhHans: "诊断") } + public var updates: String { text(en: "Updates", zhHans: "更新") } + public var login: String { text(en: "Login", zhHans: "登录") } + public var languageSetting: String { text(en: "Language", zhHans: "语言") } + public var baseURL: String { text(en: "Base URL", zhHans: "基础 URL") } + public var menuBar: String { text(en: "Menu Bar", zhHans: "菜单栏") } + public var showText: String { text(en: "Show text", zhHans: "显示文字") } + public var metric: String { text(en: "Metric", zhHans: "指标") } + public var startup: String { text(en: "Startup", zhHans: "启动") } + public var launchAtLogin: String { text(en: "Launch at login", zhHans: "登录时启动") } + public var token: String { text(en: "Token", zhHans: "令牌") } + public var bearerToken: String { text(en: "Bearer Token", zhHans: "Bearer 令牌") } + public var notify: String { text(en: "Notify", zhHans: "通知") } + public var usageInsights: String { text(en: "Usage Insights", zhHans: "用量洞察") } + public var warning: String { text(en: "Warning", zhHans: "警告") } + public var errorOnly: String { text(en: "Error only", zhHans: "仅错误") } + public var cooldown: String { text(en: "Cooldown", zhHans: "冷却时间") } + public var today: String { text(en: "Today", zhHans: "今日") } + public var balance: String { text(en: "balance", zhHans: "余额") } + public var tokens: String { text(en: "tokens", zhHans: "令牌数") } + public var waitingForUsageData: String { text(en: "Waiting for usage data.", zhHans: "正在等待用量数据。") } + public var setBaseURLAndToken: String { text(en: "Set Base URL and token to start monitoring.", zhHans: "设置基础 URL 和令牌以开始监控。") } + public var shareCard: String { text(en: "Share Card", zhHans: "分享卡片") } + public var copyShareCard: String { text(en: "Copy Share Card", zhHans: "复制分享卡片") } + public var copyUsageReport: String { text(en: "Copy Usage Report", zhHans: "复制用量报告") } + public var copyDiagnostics: String { text(en: "Copy Diagnostics", zhHans: "复制诊断") } + public var copySupportBundle: String { text(en: "Copy Support Bundle", zhHans: "复制支持包") } + public var showConfig: String { text(en: "Show Config", zhHans: "显示配置") } + public var openServer: String { text(en: "Open Server", zhHans: "打开服务器") } + public var manualToken: String { text(en: "Manual token", zhHans: "手动令牌") } + public var saveToken: String { text(en: "Save Token", zhHans: "保存令牌") } + public var connectionChecklist: String { text(en: "Connection Checklist", zhHans: "连接检查清单") } + public var connectYourServer: String { text(en: "Connect your server", zhHans: "连接你的服务器") } + public var serverURL: String { text(en: "Server URL", zhHans: "服务器 URL") } + public var connecting: String { text(en: "Connecting...", zhHans: "正在连接…") } + public var loginAndSaveAccount: String { text(en: "Login and Save Account", zhHans: "登录并保存账户") } + public var removeCurrentAccount: String { text(en: "Remove Current Account", zhHans: "移除当前账户") } + public var checkNow: String { text(en: "Check Now", zhHans: "立即检查") } + public var openRelease: String { text(en: "Open Release", zhHans: "打开发布页") } + public var downloadLatestRelease: String { text(en: "Download the latest release from GitHub.", zhHans: "从 GitHub 下载最新版本。") } + public var githubReleaseCheck: String { text(en: "Checks GitHub Releases for newer versions.", zhHans: "检查 GitHub Releases 是否有新版本。") } + public var averageResponse: String { text(en: "Average Response", zhHans: "平均响应") } + public var averageTime: String { text(en: "Average time", zhHans: "平均耗时") } + public var status: String { text(en: "Status", zhHans: "状态") } + public var account: String { text(en: "Account", zhHans: "账户") } + public var generated: String { text(en: "Generated", zhHans: "生成时间") } + public var notConnected: String { text(en: "Not connected", zhHans: "未连接") } + public var staleData: String { text(en: "Stale Data", zhHans: "数据已过期") } + public var noData: String { text(en: "No Data", zhHans: "无数据") } + public var healthy: String { text(en: "Healthy", zhHans: "正常") } + public var needsAttention: String { text(en: "Needs Attention", zhHans: "需要关注") } + public var error: String { text(en: "Error", zhHans: "错误") } + + public func languageDisplayName(_ value: AppLanguage) -> String { + switch value { + case .auto: + text(en: "Auto", zhHans: "自动") + case .en: + "English" + case .zhHans: + "简体中文" + } + } + + public func menuBarMetricDisplayName(_ metric: MenuBarMetric) -> String { + switch metric { + case .automatic: + text(en: "Auto", zhHans: "自动") + case .spend: + text(en: "Spend", zhHans: "花费") + case .balance: + text(en: "Balance", zhHans: "余额") + case .quota: + text(en: "Quota", zhHans: "配额") + case .tokens: + text(en: "Tokens", zhHans: "令牌") + case .requests: + text(en: "Requests", zhHans: "请求") + } + } + + public func statusLabel(_ severity: MonitorSeverity) -> String { + switch severity { + case .healthy: + healthy + case .warning: + needsAttention + case .error: + error + } + } + + public func daysLeft(_ days: Int) -> String { + text(en: "\(days)d left", zhHans: "剩余 \(days) 天") + } + + public func quotaAccessibility(title: String, percent: String, remaining: String) -> String { + text(en: "\(title) quota \(percent), \(remaining)", zhHans: "\(title) 配额 \(percent),\(remaining)") + } + + public func sub2APIStatus(_ statusLabel: String) -> String { + "Sub2API \(statusLabel)" + } + + public func statusTooltip(statusLabel: String, todayCost: String, rpm: String) -> String { + text(en: "Sub2API \(statusLabel) - Today \(todayCost), RPM \(rpm)", zhHans: "Sub2API \(statusLabel) - 今日 \(todayCost),RPM \(rpm)") + } + + public func text(en: String, zhHans: String) -> String { + switch language { + case .zhHans: + zhHans + case .auto, .en: + en + } + } +} diff --git a/Tests/Sub2APIStatusCoreTests/Sub2APIStatusCoreTests.swift b/Tests/Sub2APIStatusCoreTests/Sub2APIStatusCoreTests.swift index 6100314..49e9709 100644 --- a/Tests/Sub2APIStatusCoreTests/Sub2APIStatusCoreTests.swift +++ b/Tests/Sub2APIStatusCoreTests/Sub2APIStatusCoreTests.swift @@ -30,6 +30,21 @@ import Testing #expect(config.showsMenuBarText == false) } +@Test func appLanguageResolvesAutoFromPreferredLanguages() { + #expect(AppLanguage.auto.resolved(preferredLanguages: ["zh-Hans-US"]) == .zhHans) + #expect(AppLanguage.auto.resolved(preferredLanguages: ["zh-CN"]) == .zhHans) + #expect(AppLanguage.auto.resolved(preferredLanguages: ["en-US"]) == .en) + #expect(AppLanguage.en.resolved(preferredLanguages: ["zh-Hans"]) == .en) + #expect(AppLanguage.zhHans.resolved(preferredLanguages: ["en-US"]) == .zhHans) +} + +@Test func appStringsReturnLocalizedStaticLabels() { + #expect(AppStrings(language: .en).settings == "Settings") + #expect(AppStrings(language: .zhHans).settings == "设置") + #expect(AppStrings(language: .en).languageDisplayName(.auto) == "Auto") + #expect(AppStrings(language: .zhHans).languageDisplayName(.auto) == "自动") +} + private func pngSize(_ data: Data) -> (width: Int, height: Int)? { guard data.count >= 24 else { return nil From 9a718fb63ddfc5f1a9cea7c92111d09559ad3464 Mon Sep 17 00:00:00 2001 From: maple Date: Tue, 2 Jun 2026 21:04:44 +0800 Subject: [PATCH 4/6] feat: localize generated app text --- .../Sub2APIStatusBar/InsightNotifier.swift | 6 +- Sources/Sub2APIStatusBar/LoginViews.swift | 3 +- Sources/Sub2APIStatusBar/MonitorPanel.swift | 3 +- .../Sub2APIStatusBar/MonitorViewModel.swift | 27 ++++-- .../Sub2APIStatusCore/DiagnosticReport.swift | 77 +++++++++------- .../InsightAlertPolicy.swift | 31 ++++--- Sources/Sub2APIStatusCore/Models.swift | 57 +++++++----- .../OnboardingChecklist.swift | 34 +++---- .../RecoverySuggestion.swift | 43 ++++----- .../SocialShareSummary.swift | 89 ++++++++++--------- .../SupportBundleReport.swift | 55 ++++++------ Sources/Sub2APIStatusCore/UsageInsights.swift | 78 ++++++++-------- Sources/Sub2APIStatusCore/UsageReport.swift | 80 +++++++++-------- .../Sub2APIStatusCoreTests.swift | 71 +++++++++++++++ 14 files changed, 393 insertions(+), 261 deletions(-) diff --git a/Sources/Sub2APIStatusBar/InsightNotifier.swift b/Sources/Sub2APIStatusBar/InsightNotifier.swift index 4359280..e19776c 100644 --- a/Sources/Sub2APIStatusBar/InsightNotifier.swift +++ b/Sources/Sub2APIStatusBar/InsightNotifier.swift @@ -27,14 +27,16 @@ final class InsightNotifier { _ snapshot: MonitorSnapshot, refreshIntervalSeconds: Double, settings: InsightAlertSettings, - now: Date = Date() + now: Date = Date(), + language: AppLanguage = .en ) { let policy = InsightAlertPolicy(settings: settings) guard let alert = policy.staleDataAlert( from: snapshot, refreshIntervalSeconds: refreshIntervalSeconds, lastAlertedAtByFingerprint: lastAlertedAtByFingerprint, - now: now + now: now, + language: language ) else { return } diff --git a/Sources/Sub2APIStatusBar/LoginViews.swift b/Sources/Sub2APIStatusBar/LoginViews.swift index 0229e62..684dffc 100644 --- a/Sources/Sub2APIStatusBar/LoginViews.swift +++ b/Sources/Sub2APIStatusBar/LoginViews.swift @@ -139,7 +139,8 @@ struct LoginPanel: View { private var checklist: OnboardingChecklist { OnboardingChecklist.make( form: formState, - manualToken: model.settingsDraft.authToken + manualToken: model.settingsDraft.authToken, + language: model.settingsDraft.language ) } diff --git a/Sources/Sub2APIStatusBar/MonitorPanel.swift b/Sources/Sub2APIStatusBar/MonitorPanel.swift index 02595f2..922641c 100644 --- a/Sources/Sub2APIStatusBar/MonitorPanel.swift +++ b/Sources/Sub2APIStatusBar/MonitorPanel.swift @@ -300,7 +300,8 @@ struct MonitorPanel: View { subscriptionSummary: model.snapshot.subscriptionSummary, trend: model.snapshot.trend, models: model.snapshot.modelDistribution, - thresholds: model.config.insightThresholds + thresholds: model.config.insightThresholds, + language: model.config.language ) } diff --git a/Sources/Sub2APIStatusBar/MonitorViewModel.swift b/Sources/Sub2APIStatusBar/MonitorViewModel.swift index 83bbf3f..416e74c 100644 --- a/Sources/Sub2APIStatusBar/MonitorViewModel.swift +++ b/Sources/Sub2APIStatusBar/MonitorViewModel.swift @@ -315,7 +315,8 @@ final class MonitorViewModel: ObservableObject { config: config, snapshot: snapshot, appVersion: currentAppVersion, - notificationAuthorization: notificationAuthorization + notificationAuthorization: notificationAuthorization, + language: config.language ) NSPasteboard.general.clearContents() NSPasteboard.general.setString(report, forType: .string) @@ -326,7 +327,8 @@ final class MonitorViewModel: ObservableObject { let report = SupportBundleReport.make( config: config, snapshot: snapshot, - appVersion: currentAppVersion + appVersion: currentAppVersion, + language: config.language ) NSPasteboard.general.clearContents() NSPasteboard.general.setString(report, forType: .string) @@ -337,7 +339,8 @@ final class MonitorViewModel: ObservableObject { let report = UsageReport.make( config: config, snapshot: snapshot, - now: now + now: now, + language: config.language ) NSPasteboard.general.clearContents() NSPasteboard.general.setString(report, forType: .string) @@ -348,7 +351,8 @@ final class MonitorViewModel: ObservableObject { let summary = SocialShareSummary.make( config: config, snapshot: snapshot, - now: now + now: now, + language: config.language ) let pasteboard = NSPasteboard.general pasteboard.clearContents() @@ -365,7 +369,8 @@ final class MonitorViewModel: ObservableObject { let summary = SocialShareSummary.make( config: config, snapshot: snapshot, - now: now + now: now, + language: config.language ) var components = URLComponents(string: "https://twitter.com/intent/tweet") components?.queryItems = [ @@ -406,7 +411,8 @@ final class MonitorViewModel: ObservableObject { RecoverySuggestion.make( message: settingsError, hasBaseURL: !settingsDraft.baseURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - hasToken: !settingsDraft.authToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + hasToken: !settingsDraft.authToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + language: settingsDraft.language ) } @@ -417,7 +423,8 @@ final class MonitorViewModel: ObservableObject { return RecoverySuggestion.make( message: snapshot.message, hasBaseURL: !config.baseURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - hasToken: !config.authToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + hasToken: !config.authToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + language: config.language ) } @@ -454,7 +461,8 @@ final class MonitorViewModel: ObservableObject { subscriptionSummary: snapshot.subscriptionSummary, trend: snapshot.trend, models: snapshot.modelDistribution, - thresholds: config.insightThresholds + thresholds: config.insightThresholds, + language: config.language ) insightNotifier.handle(insights: insights, settings: config.insightAlertSettings) } @@ -492,7 +500,8 @@ final class MonitorViewModel: ObservableObject { snapshot, refreshIntervalSeconds: config.refreshIntervalSeconds, settings: config.insightAlertSettings, - now: now + now: now, + language: config.language ) } diff --git a/Sources/Sub2APIStatusCore/DiagnosticReport.swift b/Sources/Sub2APIStatusCore/DiagnosticReport.swift index b3a0221..3db8648 100644 --- a/Sources/Sub2APIStatusCore/DiagnosticReport.swift +++ b/Sources/Sub2APIStatusCore/DiagnosticReport.swift @@ -6,51 +6,55 @@ public enum DiagnosticReport { snapshot: MonitorSnapshot, appVersion: String, notificationAuthorization: InsightNotificationAuthorization? = nil, - osVersion: String = ProcessInfo.processInfo.operatingSystemVersionString + osVersion: String = ProcessInfo.processInfo.operatingSystemVersionString, + language: AppLanguage? = nil ) -> String { let now = Date() + let strings = AppStrings(language: language ?? .en) + let reportLanguage = language ?? .en let isStale = snapshot.isStale(now: now, refreshIntervalSeconds: config.refreshIntervalSeconds) var lines = [ - "Sub2API Status Bar Diagnostics", - "Version: \(appVersion)", + strings.text(en: "Sub2API Status Bar Diagnostics", zhHans: "Sub2API Status Bar 诊断"), + "\(strings.text(en: "Version", zhHans: "版本")): \(appVersion)", "OS: \(osVersion)", - "Status: \(snapshot.statusLabel(now: now, refreshIntervalSeconds: config.refreshIntervalSeconds))", - "Connected: \(snapshot.connected ? "yes" : "no")", - "Data Freshness: \(isStale ? "stale" : "fresh")", - "Base URL: \(config.baseURL)", - "Refresh Interval: \(Int(config.refreshIntervalSeconds))s", - "Menu Bar Text: \(config.showsMenuBarText ? "shown" : "hidden")", - "Menu Bar Metric: \(config.menuBarMetric.rawValue)", - "Insight Alerts: \(config.insightAlertSettings.isEnabled ? "enabled" : "disabled")", - "Insight Alert Level: \(config.insightAlertSettings.minimumSeverity.rawValue)", - "Insight Alert Cooldown: \(Int(config.insightAlertSettings.cooldownMinutes))m", - "Monthly Budget: \(StatusFormatters.currency(config.insightThresholds.monthlyBudgetUSD))", - "Spend Surge Threshold: \(Int(config.insightThresholds.spendSurgeRatio * 100))%", - "Notification Permission: \(notificationAuthorization?.rawValue ?? "unknown")", - "Accounts: \(config.accounts.count)", - "Selected Account: \(config.selectedAccount?.displayName ?? "none")", - "Access Token: \(config.authToken.isEmpty ? "missing" : "present")", - "Refresh Token: \(config.refreshToken.isEmpty ? "missing" : "present")", + "\(strings.status): \(snapshot.statusLabel(now: now, refreshIntervalSeconds: config.refreshIntervalSeconds, language: reportLanguage))", + "\(strings.text(en: "Connected", zhHans: "连接状态")): \(yesNo(snapshot.connected, strings: strings))", + "\(strings.text(en: "Data Freshness", zhHans: "数据新鲜度")): \(isStale ? strings.text(en: "stale", zhHans: "过期") : strings.text(en: "fresh", zhHans: "新鲜"))", + "\(strings.baseURL): \(config.baseURL)", + "\(strings.text(en: "Refresh Interval", zhHans: "刷新间隔")): \(Int(config.refreshIntervalSeconds))s", + "\(strings.text(en: "Menu Bar Text", zhHans: "菜单栏文字")): \(config.showsMenuBarText ? strings.text(en: "shown", zhHans: "显示") : strings.text(en: "hidden", zhHans: "隐藏"))", + "\(strings.text(en: "Menu Bar Metric", zhHans: "菜单栏指标")): \(config.menuBarMetric.rawValue)", + "\(strings.text(en: "Language", zhHans: "语言")): \(config.language.rawValue)", + "\(strings.text(en: "Insight Alerts", zhHans: "洞察提醒")): \(config.insightAlertSettings.isEnabled ? strings.text(en: "enabled", zhHans: "已开启") : strings.text(en: "disabled", zhHans: "已关闭"))", + "\(strings.text(en: "Insight Alert Level", zhHans: "洞察提醒级别")): \(config.insightAlertSettings.minimumSeverity.rawValue)", + "\(strings.text(en: "Insight Alert Cooldown", zhHans: "洞察提醒冷却")): \(Int(config.insightAlertSettings.cooldownMinutes))m", + "\(strings.text(en: "Monthly Budget", zhHans: "月度预算")): \(StatusFormatters.currency(config.insightThresholds.monthlyBudgetUSD))", + "\(strings.text(en: "Spend Surge Threshold", zhHans: "花费激增阈值")): \(Int(config.insightThresholds.spendSurgeRatio * 100))%", + "\(strings.text(en: "Notification Permission", zhHans: "通知权限")): \(notificationAuthorization?.rawValue ?? strings.text(en: "unknown", zhHans: "未知"))", + "\(strings.accounts): \(config.accounts.count)", + "\(strings.text(en: "Selected Account", zhHans: "当前账户")): \(config.selectedAccount?.displayName ?? strings.text(en: "none", zhHans: "无"))", + "\(strings.text(en: "Access Token", zhHans: "访问令牌")): \(config.authToken.isEmpty ? strings.text(en: "missing", zhHans: "缺失") : strings.text(en: "present", zhHans: "存在"))", + "\(strings.text(en: "Refresh Token", zhHans: "刷新令牌")): \(config.refreshToken.isEmpty ? strings.text(en: "missing", zhHans: "缺失") : strings.text(en: "present", zhHans: "存在"))", ] if let stats = snapshot.stats { - lines.append("Today Requests: \(stats.todayRequests)") - lines.append("Today Cost: \(StatusFormatters.preciseCurrency(stats.todayActualCost))") - lines.append("Today Tokens: \(stats.todayTokens)") - lines.append("Today Cost per MTok: \(StatusFormatters.costPerMillionTokens(cost: stats.todayActualCost, tokens: stats.todayTokens))") - lines.append("Total Tokens: \(stats.totalTokens)") + lines.append("\(strings.text(en: "Today Requests", zhHans: "今日请求")): \(stats.todayRequests)") + lines.append("\(strings.text(en: "Today Cost", zhHans: "今日成本")): \(StatusFormatters.preciseCurrency(stats.todayActualCost))") + lines.append("\(strings.text(en: "Today Tokens", zhHans: "今日令牌")): \(stats.todayTokens)") + lines.append("\(strings.text(en: "Today Cost per MTok", zhHans: "今日每百万令牌成本")): \(StatusFormatters.costPerMillionTokens(cost: stats.todayActualCost, tokens: stats.todayTokens))") + lines.append("\(strings.text(en: "Total Tokens", zhHans: "总令牌")): \(stats.totalTokens)") lines.append("RPM: \(StatusFormatters.menuBarRate(stats.rpm))") lines.append("TPM: \(StatusFormatters.compactNumber(Int64(stats.tpm)))") } if let subscriptionSummary = snapshot.subscriptionSummary { - lines.append("Active Subscriptions: \(subscriptionSummary.activeCount)") - lines.append("Highest Quota Usage: \(StatusFormatters.percent(subscriptionSummary.highestProgress))") - lines.append("Expiring Soon: \(subscriptionSummary.expiringSoonCount)") + lines.append("\(strings.text(en: "Active Subscriptions", zhHans: "活跃订阅")): \(subscriptionSummary.activeCount)") + lines.append("\(strings.text(en: "Highest Quota Usage", zhHans: "最高配额用量")): \(StatusFormatters.percent(subscriptionSummary.highestProgress))") + lines.append("\(strings.text(en: "Expiring Soon", zhHans: "即将过期")): \(subscriptionSummary.expiringSoonCount)") } if let models = snapshot.modelDistribution { - lines.append("Visible Models: \(models.prefix(5).map(\.model).joined(separator: ", "))") + lines.append("\(strings.text(en: "Visible Models", zhHans: "可见模型")): \(models.prefix(5).map(\.model).joined(separator: ", "))") } if snapshot.connected { @@ -60,22 +64,27 @@ public enum DiagnosticReport { subscriptionSummary: snapshot.subscriptionSummary, trend: snapshot.trend, models: snapshot.modelDistribution, - thresholds: config.insightThresholds + thresholds: config.insightThresholds, + language: reportLanguage ) - lines.append("Usage Insight: \(insights.headline)") + lines.append("\(strings.text(en: "Usage Insight", zhHans: "用量洞察")): \(insights.headline)") for item in insights.items { - lines.append("Insight \(item.title): \(item.value) - \(item.detail)") + lines.append("\(strings.text(en: "Insight", zhHans: "洞察")) \(item.title): \(item.value) - \(item.detail)") } } if let lastUpdatedAt = snapshot.lastUpdatedAt { - lines.append("Last Updated: \(ISO8601DateFormatter().string(from: lastUpdatedAt))") + lines.append("\(strings.text(en: "Last Updated", zhHans: "上次更新")): \(ISO8601DateFormatter().string(from: lastUpdatedAt))") } if let message = snapshot.message, !message.isEmpty { - lines.append("Message: \(message)") + lines.append("\(strings.text(en: "Message", zhHans: "消息")): \(message)") } return lines.joined(separator: "\n") } + + private static func yesNo(_ value: Bool, strings: AppStrings) -> String { + strings.text(en: value ? "yes" : "no", zhHans: value ? "是" : "否") + } } diff --git a/Sources/Sub2APIStatusCore/InsightAlertPolicy.swift b/Sources/Sub2APIStatusCore/InsightAlertPolicy.swift index e5a6140..37c1902 100644 --- a/Sources/Sub2APIStatusCore/InsightAlertPolicy.swift +++ b/Sources/Sub2APIStatusCore/InsightAlertPolicy.swift @@ -30,7 +30,8 @@ public struct InsightAlertPolicy: Equatable, Sendable { public func nextAlert( from insights: UsageInsights, lastAlertedAtByFingerprint: [String: Date], - now: Date + now: Date, + language: AppLanguage = .en ) -> InsightAlert? { guard settings.isEnabled else { return nil @@ -59,8 +60,10 @@ public struct InsightAlertPolicy: Equatable, Sendable { from snapshot: MonitorSnapshot, refreshIntervalSeconds: Double, lastAlertedAtByFingerprint: [String: Date], - now: Date + now: Date, + language: AppLanguage = .en ) -> InsightAlert? { + let strings = AppStrings(language: language) guard settings.isEnabled, MonitorSeverity.warning.sortRank >= settings.minimumSeverity.sortRank, snapshot.isStale(now: now, refreshIntervalSeconds: refreshIntervalSeconds), @@ -76,8 +79,8 @@ public struct InsightAlertPolicy: Equatable, Sendable { return InsightAlert( fingerprint: fingerprint, - title: "Stale Data", - body: "Sub2API usage has not refreshed for \(StatusFormatters.duration(seconds: now.timeIntervalSince(lastUpdatedAt))). Check the server or retry refresh.", + title: strings.staleData, + body: strings.text(en: "Sub2API usage has not refreshed for \(StatusFormatters.duration(seconds: now.timeIntervalSince(lastUpdatedAt))). Check the server or retry refresh.", zhHans: "Sub2API 用量已 \(StatusFormatters.duration(seconds: now.timeIntervalSince(lastUpdatedAt))) 未刷新。请检查服务器或重试刷新。"), severity: .warning ) } @@ -111,12 +114,14 @@ public struct InsightNotificationPermissionSummary: Equatable, Sendable { public static func make( settings: InsightAlertSettings, - authorization: InsightNotificationAuthorization + authorization: InsightNotificationAuthorization, + language: AppLanguage = .en ) -> InsightNotificationPermissionSummary { + let strings = AppStrings(language: language) guard settings.isEnabled else { return InsightNotificationPermissionSummary( - title: "Alerts off", - detail: "Turn on insight alerts to receive local notifications.", + title: strings.text(en: "Alerts off", zhHans: "提醒已关闭"), + detail: strings.text(en: "Turn on insight alerts to receive local notifications.", zhHans: "开启洞察提醒以接收本地通知。"), action: nil ) } @@ -124,20 +129,20 @@ public struct InsightNotificationPermissionSummary: Equatable, Sendable { switch authorization { case .authorized: return InsightNotificationPermissionSummary( - title: "Notifications ready", - detail: "macOS alerts can be delivered for \(settings.minimumSeverity.rawValue) insights.", + title: strings.text(en: "Notifications ready", zhHans: "通知已就绪"), + detail: strings.text(en: "macOS alerts can be delivered for \(settings.minimumSeverity.rawValue) insights.", zhHans: "macOS 可发送 \(settings.minimumSeverity.rawValue) 级别洞察提醒。"), action: nil ) case .notDetermined: return InsightNotificationPermissionSummary( - title: "Permission needed", - detail: "Allow notifications so Sub2API can warn you when usage needs attention.", + title: strings.text(en: "Permission needed", zhHans: "需要通知权限"), + detail: strings.text(en: "Allow notifications so Sub2API can warn you when usage needs attention.", zhHans: "允许通知后,Sub2API 可在用量需要关注时提醒你。"), action: .requestPermission ) case .denied: return InsightNotificationPermissionSummary( - title: "Notifications blocked", - detail: "Open macOS notification settings and allow Sub2API alerts.", + title: strings.text(en: "Notifications blocked", zhHans: "通知被阻止"), + detail: strings.text(en: "Open macOS notification settings and allow Sub2API alerts.", zhHans: "打开 macOS 通知设置并允许 Sub2API 提醒。"), action: .openSystemSettings ) } diff --git a/Sources/Sub2APIStatusCore/Models.swift b/Sources/Sub2APIStatusCore/Models.swift index 311b523..1f9404c 100644 --- a/Sources/Sub2APIStatusCore/Models.swift +++ b/Sources/Sub2APIStatusCore/Models.swift @@ -1129,33 +1129,38 @@ public struct MonitorSnapshot: Equatable, Sendable { } public func statusLabel(now: Date, refreshIntervalSeconds: Double?) -> String { + statusLabel(now: now, refreshIntervalSeconds: refreshIntervalSeconds, language: .en) + } + + public func statusLabel(now: Date, refreshIntervalSeconds: Double?, language: AppLanguage) -> String { + let strings = AppStrings(language: language) if !connected { - return "Disconnected" + return strings.disconnected } if isStale(now: now, refreshIntervalSeconds: refreshIntervalSeconds) { - return "Stale Data" + return strings.staleData } if let subscriptionSummary { if subscriptionSummary.highestProgress >= 0.95 { - return "Near Limit" + return strings.text(en: "Near Limit", zhHans: "接近上限") } if subscriptionSummary.highestProgress >= 0.8 { - return "High Usage" + return strings.text(en: "High Usage", zhHans: "高用量") } if subscriptionSummary.expiringSoonCount > 0 { - return "Expiring Soon" + return strings.text(en: "Expiring Soon", zhHans: "即将过期") } } return switch severity(now: now, refreshIntervalSeconds: refreshIntervalSeconds) { case .healthy: - "OK" + strings.text(en: "OK", zhHans: "正常") case .warning: - "Warn" + strings.text(en: "Warn", zhHans: "警告") case .error: - "Error" + strings.error } } @@ -1172,47 +1177,57 @@ public struct MonitorSnapshot: Equatable, Sendable { now: Date, refreshIntervalSeconds: Double? ) -> String { + menuBarSummary(metric: metric, now: now, refreshIntervalSeconds: refreshIntervalSeconds, language: .en) + } + + public func menuBarSummary( + metric: MenuBarMetric, + now: Date, + refreshIntervalSeconds: Double?, + language: AppLanguage + ) -> String { + let strings = AppStrings(language: language) guard connected else { - return "Sub2API \(statusLabel(now: now, refreshIntervalSeconds: refreshIntervalSeconds))" + return "Sub2API \(statusLabel(now: now, refreshIntervalSeconds: refreshIntervalSeconds, language: language))" } - let prefix = isStale(now: now, refreshIntervalSeconds: refreshIntervalSeconds) ? "Stale · " : "" + let prefix = isStale(now: now, refreshIntervalSeconds: refreshIntervalSeconds) ? strings.text(en: "Stale · ", zhHans: "过期 · ") : "" if metric == .quota, let subscriptionSummary { - return "\(prefix)\(StatusFormatters.percent(subscriptionSummary.highestProgress)) quota · \(subscriptionSummary.activeCount) subs" + return strings.text(en: "\(prefix)\(StatusFormatters.percent(subscriptionSummary.highestProgress)) quota · \(subscriptionSummary.activeCount) subs", zhHans: "\(prefix)\(StatusFormatters.percent(subscriptionSummary.highestProgress)) 配额 · \(subscriptionSummary.activeCount) 订阅") } if let stats { switch metric { case .automatic: - return "\(prefix)\(StatusFormatters.currency(stats.todayActualCost)) · \(StatusFormatters.menuBarCount(stats.todayRequests)) req · \(StatusFormatters.menuBarRate(stats.rpm)) RPM" + return strings.text(en: "\(prefix)\(StatusFormatters.currency(stats.todayActualCost)) · \(StatusFormatters.menuBarCount(stats.todayRequests)) req · \(StatusFormatters.menuBarRate(stats.rpm)) RPM", zhHans: "\(prefix)\(StatusFormatters.currency(stats.todayActualCost)) · \(StatusFormatters.menuBarCount(stats.todayRequests)) 请求 · \(StatusFormatters.menuBarRate(stats.rpm)) RPM") case .spend: - return "\(prefix)\(StatusFormatters.currency(stats.todayActualCost)) · \(StatusFormatters.menuBarCount(stats.todayRequests)) req" + return strings.text(en: "\(prefix)\(StatusFormatters.currency(stats.todayActualCost)) · \(StatusFormatters.menuBarCount(stats.todayRequests)) req", zhHans: "\(prefix)\(StatusFormatters.currency(stats.todayActualCost)) · \(StatusFormatters.menuBarCount(stats.todayRequests)) 请求") case .balance: if let balance = currentUser?.balance { if stats.todayActualCost > 0 { return "\(prefix)\(StatusFormatters.currency(balance)) · \(String(format: "%.1fd", balance / stats.todayActualCost))" } - return "\(prefix)\(StatusFormatters.currency(balance)) · no spend" + return strings.text(en: "\(prefix)\(StatusFormatters.currency(balance)) · no spend", zhHans: "\(prefix)\(StatusFormatters.currency(balance)) · 无花费") } - return "\(prefix)\(StatusFormatters.currency(stats.todayActualCost)) · no balance" + return strings.text(en: "\(prefix)\(StatusFormatters.currency(stats.todayActualCost)) · no balance", zhHans: "\(prefix)\(StatusFormatters.currency(stats.todayActualCost)) · 无余额") case .quota: if let subscriptionSummary { - return "\(prefix)\(StatusFormatters.percent(subscriptionSummary.highestProgress)) quota · \(subscriptionSummary.activeCount) subs" + return strings.text(en: "\(prefix)\(StatusFormatters.percent(subscriptionSummary.highestProgress)) quota · \(subscriptionSummary.activeCount) subs", zhHans: "\(prefix)\(StatusFormatters.percent(subscriptionSummary.highestProgress)) 配额 · \(subscriptionSummary.activeCount) 订阅") } - return "\(prefix)\(StatusFormatters.currency(stats.todayActualCost)) · no quota" + return strings.text(en: "\(prefix)\(StatusFormatters.currency(stats.todayActualCost)) · no quota", zhHans: "\(prefix)\(StatusFormatters.currency(stats.todayActualCost)) · 无配额") case .tokens: - return "\(prefix)\(StatusFormatters.compactNumber(stats.todayTokens)) tok · \(StatusFormatters.compactNumber(Int64(stats.tpm))) TPM" + return strings.text(en: "\(prefix)\(StatusFormatters.compactNumber(stats.todayTokens)) tok · \(StatusFormatters.compactNumber(Int64(stats.tpm))) TPM", zhHans: "\(prefix)\(StatusFormatters.compactNumber(stats.todayTokens)) 令牌 · \(StatusFormatters.compactNumber(Int64(stats.tpm))) TPM") case .requests: - return "\(prefix)\(StatusFormatters.menuBarCount(stats.todayRequests)) req · \(StatusFormatters.menuBarRate(stats.rpm)) RPM" + return strings.text(en: "\(prefix)\(StatusFormatters.menuBarCount(stats.todayRequests)) req · \(StatusFormatters.menuBarRate(stats.rpm)) RPM", zhHans: "\(prefix)\(StatusFormatters.menuBarCount(stats.todayRequests)) 请求 · \(StatusFormatters.menuBarRate(stats.rpm)) RPM") } } if let subscriptionSummary { - return "\(prefix)\(subscriptionSummary.activeCount) subs · \(StatusFormatters.percent(subscriptionSummary.highestProgress)) peak" + return strings.text(en: "\(prefix)\(subscriptionSummary.activeCount) subs · \(StatusFormatters.percent(subscriptionSummary.highestProgress)) peak", zhHans: "\(prefix)\(subscriptionSummary.activeCount) 订阅 · 峰值 \(StatusFormatters.percent(subscriptionSummary.highestProgress))") } - return "Sub2API \(statusLabel(now: now, refreshIntervalSeconds: refreshIntervalSeconds))" + return "Sub2API \(statusLabel(now: now, refreshIntervalSeconds: refreshIntervalSeconds, language: language))" } public func isStale(now: Date, refreshIntervalSeconds: Double?) -> Bool { diff --git a/Sources/Sub2APIStatusCore/OnboardingChecklist.swift b/Sources/Sub2APIStatusCore/OnboardingChecklist.swift index 111a091..6c537d3 100644 --- a/Sources/Sub2APIStatusCore/OnboardingChecklist.swift +++ b/Sources/Sub2APIStatusCore/OnboardingChecklist.swift @@ -23,7 +23,8 @@ public struct OnboardingChecklist: Equatable, Sendable { self.summary = summary } - public static func make(form: LoginFormState, manualToken: String) -> OnboardingChecklist { + public static func make(form: LoginFormState, manualToken: String, language: AppLanguage = .en) -> OnboardingChecklist { + let strings = AppStrings(language: language) let hasURL = !form.baseURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty let hasAccount = !form.email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty let hasPassword = !form.password.isEmpty @@ -32,35 +33,35 @@ public struct OnboardingChecklist: Equatable, Sendable { let steps = [ OnboardingStep( id: "server-url", - title: "Server URL", - detail: "Root Sub2API address; /api/v1 is added automatically.", + title: strings.serverURL, + detail: strings.text(en: "Root Sub2API address; /api/v1 is added automatically.", zhHans: "Sub2API 根地址;/api/v1 会自动添加。"), isComplete: hasURL ), OnboardingStep( id: "account", - title: "Account", - detail: hasToken ? "Manual token can be saved without email login." : "Email used for Sub2API login.", + title: strings.account, + detail: hasToken ? strings.text(en: "Manual token can be saved without email login.", zhHans: "手动令牌无需邮箱登录即可保存。") : strings.text(en: "Email used for Sub2API login.", zhHans: "用于 Sub2API 登录的邮箱。"), isComplete: hasAccount || hasToken ), OnboardingStep( id: "credential", - title: "Password or token", - detail: "Login with a password, or paste a bearer token.", + title: strings.text(en: "Password or token", zhHans: "密码或令牌"), + detail: strings.text(en: "Login with a password, or paste a bearer token.", zhHans: "使用密码登录,或粘贴 bearer 令牌。"), isComplete: hasPassword || hasToken ), ] let summary: String if hasURL, hasToken { - summary = "Ready to save token." + summary = strings.text(en: "Ready to save token.", zhHans: "可以保存令牌。") } else if form.canSubmit { - summary = "Ready to login." + summary = strings.text(en: "Ready to login.", zhHans: "可以登录。") } else { let missing = steps .filter { !$0.isComplete } - .map(\.summaryName) - .joined(separator: ", ") - summary = "Add \(missing)." + .map { $0.summaryName(language: language) } + .joined(separator: strings.text(en: ", ", zhHans: "、")) + summary = strings.text(en: "Add \(missing).", zhHans: "请补充\(missing)。") } return OnboardingChecklist(steps: steps, summary: summary) @@ -68,12 +69,15 @@ public struct OnboardingChecklist: Equatable, Sendable { } private extension OnboardingStep { - var summaryName: String { + func summaryName(language: AppLanguage) -> String { + let strings = AppStrings(language: language) switch id { case "server-url": - "server URL" + return strings.text(en: "server URL", zhHans: "服务器 URL") + case "credential": + return strings.text(en: "password or token", zhHans: "密码或令牌") default: - title.lowercased() + return strings.text(en: title.lowercased(), zhHans: title) } } } diff --git a/Sources/Sub2APIStatusCore/RecoverySuggestion.swift b/Sources/Sub2APIStatusCore/RecoverySuggestion.swift index 362caaa..4b557e6 100644 --- a/Sources/Sub2APIStatusCore/RecoverySuggestion.swift +++ b/Sources/Sub2APIStatusCore/RecoverySuggestion.swift @@ -33,27 +33,28 @@ public struct RecoverySuggestion: Equatable, Sendable { self.actions = actions } - public static func make(message: String?, hasBaseURL: Bool, hasToken: Bool) -> RecoverySuggestion { + public static func make(message: String?, hasBaseURL: Bool, hasToken: Bool, language: AppLanguage = .en) -> RecoverySuggestion { + let strings = AppStrings(language: language) let normalized = (message ?? "").lowercased() if !hasBaseURL || normalized.contains("base url") || normalized.contains("url is invalid") { return RecoverySuggestion( - title: "Add your server URL", - detail: "Use the root Sub2API server address. The app adds /api/v1 automatically.", + title: strings.text(en: "Add your server URL", zhHans: "添加服务器 URL"), + detail: strings.text(en: "Use the root Sub2API server address. The app adds /api/v1 automatically.", zhHans: "使用 Sub2API 服务器根地址,App 会自动添加 /api/v1。"), actions: [ - RecoveryAction(kind: .enterURL, label: "Enter URL", systemImage: "link"), - RecoveryAction(kind: .openServer, label: "Open Server", systemImage: "safari"), + RecoveryAction(kind: .enterURL, label: strings.text(en: "Enter URL", zhHans: "输入 URL"), systemImage: "link"), + RecoveryAction(kind: .openServer, label: strings.openServer, systemImage: "safari"), ] ) } if normalized.contains("401") || normalized.contains("unauthorized") || normalized.contains("expired") { return RecoverySuggestion( - title: "Sign in again", - detail: "Your saved session is no longer accepted. Login again or replace the bearer token.", + title: strings.text(en: "Sign in again", zhHans: "重新登录"), + detail: strings.text(en: "Your saved session is no longer accepted. Login again or replace the bearer token.", zhHans: "已保存的会话不再被接受。请重新登录或替换 bearer 令牌。"), actions: [ - RecoveryAction(kind: .login, label: "Login", systemImage: "key.fill"), - RecoveryAction(kind: .replaceToken, label: "Replace Token", systemImage: "square.and.pencil"), + RecoveryAction(kind: .login, label: strings.login, systemImage: "key.fill"), + RecoveryAction(kind: .replaceToken, label: strings.text(en: "Replace Token", zhHans: "替换令牌"), systemImage: "square.and.pencil"), ] ) } @@ -64,32 +65,32 @@ public struct RecoverySuggestion: Equatable, Sendable { || normalized.contains("offline") || normalized.contains("cannot find host") { return RecoverySuggestion( - title: "Check server reachability", - detail: "Open the Sub2API server in your browser, then retry when it responds.", + title: strings.text(en: "Check server reachability", zhHans: "检查服务器可达性"), + detail: strings.text(en: "Open the Sub2API server in your browser, then retry when it responds.", zhHans: "在浏览器中打开 Sub2API 服务器,确认响应后重试。"), actions: [ - RecoveryAction(kind: .openServer, label: "Open Server", systemImage: "safari"), - RecoveryAction(kind: .retry, label: "Retry", systemImage: "arrow.clockwise"), + RecoveryAction(kind: .openServer, label: strings.openServer, systemImage: "safari"), + RecoveryAction(kind: .retry, label: strings.text(en: "Retry", zhHans: "重试"), systemImage: "arrow.clockwise"), ] ) } if !hasToken { return RecoverySuggestion( - title: "Connect an account", - detail: "Login with your Sub2API account or paste a bearer token to start monitoring.", + title: strings.text(en: "Connect an account", zhHans: "连接账户"), + detail: strings.text(en: "Login with your Sub2API account or paste a bearer token to start monitoring.", zhHans: "使用 Sub2API 账户登录,或粘贴 bearer 令牌以开始监控。"), actions: [ - RecoveryAction(kind: .login, label: "Login", systemImage: "key.fill"), - RecoveryAction(kind: .replaceToken, label: "Use Token", systemImage: "square.and.pencil"), + RecoveryAction(kind: .login, label: strings.login, systemImage: "key.fill"), + RecoveryAction(kind: .replaceToken, label: strings.text(en: "Use Token", zhHans: "使用令牌"), systemImage: "square.and.pencil"), ] ) } return RecoverySuggestion( - title: "Refresh or inspect settings", - detail: "Retry the request. If it still fails, check the server URL and token in Settings.", + title: strings.text(en: "Refresh or inspect settings", zhHans: "刷新或检查设置"), + detail: strings.text(en: "Retry the request. If it still fails, check the server URL and token in Settings.", zhHans: "重试请求。如果仍失败,请检查设置中的服务器 URL 和令牌。"), actions: [ - RecoveryAction(kind: .retry, label: "Retry", systemImage: "arrow.clockwise"), - RecoveryAction(kind: .openServer, label: "Open Server", systemImage: "safari"), + RecoveryAction(kind: .retry, label: strings.text(en: "Retry", zhHans: "重试"), systemImage: "arrow.clockwise"), + RecoveryAction(kind: .openServer, label: strings.openServer, systemImage: "safari"), ] ) } diff --git a/Sources/Sub2APIStatusCore/SocialShareSummary.swift b/Sources/Sub2APIStatusCore/SocialShareSummary.swift index 57e7ca0..bf476dc 100644 --- a/Sources/Sub2APIStatusCore/SocialShareSummary.swift +++ b/Sources/Sub2APIStatusCore/SocialShareSummary.swift @@ -22,12 +22,14 @@ public struct SocialShareSummary: Equatable, Sendable { public static func make( config: AppConfig, snapshot: MonitorSnapshot, - now: Date = Date() + now: Date = Date(), + language: AppLanguage? = nil ) -> SocialShareSummary { + let strings = AppStrings(language: language ?? .en) let stats = snapshot.stats - let topModel = topModelText(snapshot.modelDistribution) - let quota = topQuotaText(snapshot.subscriptionSummary) - let trend = trendText(snapshot.trend) + let topModel = topModelText(snapshot.modelDistribution, strings: strings) + let quota = topQuotaText(snapshot.subscriptionSummary, strings: strings) + let trend = trendText(snapshot.trend, strings: strings) let unitCost = StatusFormatters.costPerMillionTokens( cost: stats?.todayActualCost ?? 0, tokens: stats?.todayTokens ?? 0 @@ -39,31 +41,32 @@ public struct SocialShareSummary: Equatable, Sendable { let tokens = StatusFormatters.compactNumber(todayTokens) let spend = StatusFormatters.currency(stats?.todayActualCost ?? 0) let requests = StatusFormatters.menuBarCount(stats?.todayRequests ?? 0) - let persona = "Build Log" - let punchline = punchlineText(tokens: tokens, todayTokens: todayTokens) - let privacy = "No prompts. No keys." + let persona = strings.text(en: "Build Log", zhHans: "构建日志") + let punchline = punchlineText(tokens: tokens, todayTokens: todayTokens, strings: strings) + let privacy = strings.text(en: "No prompts. No keys.", zhHans: "不含提示词。不含密钥。") let badges = [ spend, - topModelBadgeText(snapshot.modelDistribution), - quotaBadgeText(snapshot.subscriptionSummary), + topModelBadgeText(snapshot.modelDistribution, strings: strings), + quotaBadgeText(snapshot.subscriptionSummary, strings: strings), ] let title = todayTokens > 0 - ? "\(tokens) AI tokens today" - : "My AI usage receipt" - let tagline = "A public-safe AI work counter." + ? strings.text(en: "\(tokens) AI tokens today", zhHans: "今日 \(tokens) AI 令牌") + : strings.text(en: "My AI usage receipt", zhHans: "我的 AI 用量小票") + let tagline = strings.text(en: "A public-safe AI work counter.", zhHans: "一张可公开分享的 AI 工作计数卡。") + let primaryLabel = strings.text(en: "AI tokens today", zhHans: "今日 AI 令牌") let lines = [ title, persona, punchline, - "\(spend) spend | \(requests) requests | \(unitCost)", - "Top model: \(topModel)", - "Quota: \(quota)", - "Trend: \(trend)", + strings.text(en: "\(spend) spend | \(requests) requests | \(unitCost)", zhHans: "花费 \(spend) | \(requests) 次请求 | \(unitCost)"), + strings.text(en: "Top model: \(topModel)", zhHans: "主要模型:\(topModel)"), + strings.text(en: "Quota: \(quota)", zhHans: "配额:\(quota)"), + strings.text(en: "Trend: \(trend)", zhHans: "趋势:\(trend)"), privacy, - "Made visible by Sub2API Status Bar.", - "#AIUsage #BuildInPublic", + strings.text(en: "Made visible by Sub2API Status Bar.", zhHans: "由 Sub2API Status Bar 可视化。"), + strings.text(en: "#AIUsage #BuildInPublic", zhHans: "#AI用量 #公开构建"), ] return SocialShareSummary( @@ -73,7 +76,7 @@ public struct SocialShareSummary: Equatable, Sendable { punchlineText: punchline, privacyText: privacy, primaryMetric: tokens, - primaryLabel: "AI tokens today", + primaryLabel: primaryLabel, spendText: spend, requestsText: requests, topModelText: topModel, @@ -87,53 +90,53 @@ public struct SocialShareSummary: Equatable, Sendable { ) } - private static func punchlineText(tokens: String, todayTokens: Int64) -> String { + private static func punchlineText(tokens _: String, todayTokens: Int64, strings: AppStrings) -> String { guard todayTokens > 0 else { - return "The dashboard is ready for the next run." + return strings.text(en: "The dashboard is ready for the next run.", zhHans: "仪表盘已准备好记录下一次运行。") } - return "The AI work counter for today." + return strings.text(en: "The AI work counter for today.", zhHans: "今日 AI 工作计数器。") } - private static func topModelText(_ models: [ModelUsageSummary]?) -> String { + private static func topModelText(_ models: [ModelUsageSummary]?, strings: AppStrings) -> String { guard let models, let top = models.max(by: { $0.actualCost < $1.actualCost }) else { - return "No model data yet" + return strings.text(en: "No model data yet", zhHans: "暂无模型数据") } let totalCost = models.map(\.actualCost).reduce(0, +) let costShare = totalCost > 0 ? top.actualCost / totalCost : 0 - return "\(top.model) (\(StatusFormatters.percent(costShare)) cost)" + return strings.text(en: "\(top.model) (\(StatusFormatters.percent(costShare)) cost)", zhHans: "\(top.model)(成本占比 \(StatusFormatters.percent(costShare)))") } - private static func topQuotaText(_ summary: SubscriptionSummary?) -> String { - guard let quota = topQuota(summary) else { - return "No quota pressure" + private static func topQuotaText(_ summary: SubscriptionSummary?, strings: AppStrings) -> String { + guard let quota = topQuota(summary, strings: strings) else { + return strings.text(en: "No quota pressure", zhHans: "暂无配额压力") } - let resetText = quota.resetInSeconds.map { ", resets in \(StatusFormatters.duration(seconds: $0))" } ?? "" + let resetText = quota.resetInSeconds.map { strings.text(en: ", resets in \(StatusFormatters.duration(seconds: $0))", zhHans: ",\(StatusFormatters.duration(seconds: $0)) 后重置") } ?? "" return "\(quota.name) \(StatusFormatters.percent(quota.progress))\(resetText)" } - private static func quotaBadgeText(_ summary: SubscriptionSummary?) -> String { - guard let quota = topQuota(summary) else { - return "no quota pressure" + private static func quotaBadgeText(_ summary: SubscriptionSummary?, strings: AppStrings) -> String { + guard let quota = topQuota(summary, strings: strings) else { + return strings.text(en: "no quota pressure", zhHans: "无配额压力") } - return "\(StatusFormatters.percent(quota.progress)) quota" + return strings.text(en: "\(StatusFormatters.percent(quota.progress)) quota", zhHans: "配额 \(StatusFormatters.percent(quota.progress))") } - private static func topModelBadgeText(_ models: [ModelUsageSummary]?) -> String { + private static func topModelBadgeText(_ models: [ModelUsageSummary]?, strings: AppStrings) -> String { guard let models, let top = models.max(by: { $0.actualCost < $1.actualCost }) else { - return "no model data" + return strings.text(en: "no model data", zhHans: "无模型数据") } return top.model } - private static func topQuota(_ summary: SubscriptionSummary?) -> (name: String, progress: Double, resetInSeconds: Double?)? { + private static func topQuota(_ summary: SubscriptionSummary?, strings: AppStrings) -> (name: String, progress: Double, resetInSeconds: Double?)? { summary?.subscriptions .flatMap { item in [ - (name: "\(item.groupName) daily", progress: item.dailyProgress, resetInSeconds: item.dailyResetInSeconds), - (name: "\(item.groupName) weekly", progress: item.weeklyProgress, resetInSeconds: item.weeklyResetInSeconds), - (name: "\(item.groupName) monthly", progress: item.monthlyProgress, resetInSeconds: item.monthlyResetInSeconds), + (name: strings.text(en: "\(item.groupName) daily", zhHans: "\(item.groupName) 日配额"), progress: item.dailyProgress, resetInSeconds: item.dailyResetInSeconds), + (name: strings.text(en: "\(item.groupName) weekly", zhHans: "\(item.groupName) 周配额"), progress: item.weeklyProgress, resetInSeconds: item.weeklyResetInSeconds), + (name: strings.text(en: "\(item.groupName) monthly", zhHans: "\(item.groupName) 月配额"), progress: item.monthlyProgress, resetInSeconds: item.monthlyResetInSeconds), ] } .compactMap { candidate -> (name: String, progress: Double, resetInSeconds: Double?)? in @@ -145,20 +148,20 @@ public struct SocialShareSummary: Equatable, Sendable { .max(by: { $0.progress < $1.progress }) } - private static func trendText(_ trend: [TrendDataPoint]?) -> String { + private static func trendText(_ trend: [TrendDataPoint]?, strings: AppStrings) -> String { guard let trend, trend.count > 1, let latest = trend.last else { - return "Collecting baseline" + return strings.text(en: "Collecting baseline", zhHans: "正在收集基线") } let previous = trend.dropLast() let average = previous.map(\.totalTokens).reduce(0, +) / Int64(max(previous.count, 1)) guard average > 0 else { - return "Collecting baseline" + return strings.text(en: "Collecting baseline", zhHans: "正在收集基线") } let delta = (Double(latest.totalTokens) - Double(average)) / Double(average) let sign = delta >= 0 ? "+" : "" - return "\(sign)\(percentText(abs(delta))) vs \(previous.count)-day avg" + return strings.text(en: "\(sign)\(percentText(abs(delta))) vs \(previous.count)-day avg", zhHans: "较 \(previous.count) 日均值 \(sign)\(percentText(abs(delta)))") } private static func skylineValues(_ trend: [TrendDataPoint]?, fallbackTokens: Int64) -> [Double] { diff --git a/Sources/Sub2APIStatusCore/SupportBundleReport.swift b/Sources/Sub2APIStatusCore/SupportBundleReport.swift index f38e479..f10dfff 100644 --- a/Sources/Sub2APIStatusCore/SupportBundleReport.swift +++ b/Sources/Sub2APIStatusCore/SupportBundleReport.swift @@ -6,62 +6,65 @@ public enum SupportBundleReport { snapshot: MonitorSnapshot, appVersion: String, osVersion: String = ProcessInfo.processInfo.operatingSystemVersionString, - installSource: String = "GitHub Release / Homebrew Cask draft / built from source" + installSource: String = "GitHub Release / Homebrew Cask draft / built from source", + language: AppLanguage? = nil ) -> String { + let strings = AppStrings(language: language ?? .en) let diagnostics = DiagnosticReport.make( config: config, snapshot: snapshot, appVersion: appVersion, - osVersion: osVersion + osVersion: osVersion, + language: language ?? .en ) let lines = [ - "# Sub2API Status Bar Support Bundle", + strings.text(en: "# Sub2API Status Bar Support Bundle", zhHans: "# Sub2API Status Bar 支持包"), "", - "Review this bundle for secrets before posting it to a GitHub issue.", + strings.text(en: "Review this bundle for secrets before posting it to a GitHub issue.", zhHans: "发布到 GitHub issue 前,请检查此支持包是否包含敏感信息。"), "", - "Do not attach `config.json`, access tokens, refresh tokens, passwords, private server logs, full Application Support directories, release archives, or crash dumps that have not been reviewed for secrets.", + strings.text(en: "Do not attach `config.json`, access tokens, refresh tokens, passwords, private server logs, full Application Support directories, release archives, or crash dumps that have not been reviewed for secrets.", zhHans: "不要附加未经敏感信息检查的 `config.json`、访问令牌、刷新令牌、密码、私有服务器日志、完整 Application Support 目录、发布压缩包或崩溃转储。"), "", - "## Diagnostics", + strings.text(en: "## Diagnostics", zhHans: "## 诊断"), "", "```text", diagnostics, "```", "", - "## Environment", + strings.text(en: "## Environment", zhHans: "## 环境"), "", - "- App version: \(appVersion)", - "- macOS version: \(osVersion)", - "- Sub2API server version or commit, if known:", - "- Install source: \(installSource)", - "- Release asset name, if applicable:", - "- Release asset SHA-256 verification result, if applicable:", + strings.text(en: "- App version: \(appVersion)", zhHans: "- App 版本:\(appVersion)"), + strings.text(en: "- macOS version: \(osVersion)", zhHans: "- macOS 版本:\(osVersion)"), + strings.text(en: "- Sub2API server version or commit, if known:", zhHans: "- Sub2API 服务器版本或提交(如果知道):"), + strings.text(en: "- Install source: \(installSource)", zhHans: "- 安装来源:\(installSource)"), + strings.text(en: "- Release asset name, if applicable:", zhHans: "- 发布资产名称(如适用):"), + strings.text(en: "- Release asset SHA-256 verification result, if applicable:", zhHans: "- 发布资产 SHA-256 校验结果(如适用):"), "", - "## Reproduction", + strings.text(en: "## Reproduction", zhHans: "## 复现步骤"), "", "1.", "2.", "3.", "", - "## Expected Behavior", + strings.text(en: "## Expected Behavior", zhHans: "## 预期行为"), "", - "What did you expect to happen?", + strings.text(en: "What did you expect to happen?", zhHans: "你预期会发生什么?"), "", - "## Actual Behavior", + strings.text(en: "## Actual Behavior", zhHans: "## 实际行为"), "", - "What happened instead?", + strings.text(en: "What happened instead?", zhHans: "实际发生了什么?"), "", - "## Recent Changes", + strings.text(en: "## Recent Changes", zhHans: "## 最近变更"), "", - "- Did login, manual token setup, refresh, update checking, or installation work before?", - "- Did this begin after changing Sub2API server version, app version, account, network, or macOS settings?", + strings.text(en: "- Did login, manual token setup, refresh, update checking, or installation work before?", zhHans: "- 之前登录、手动令牌设置、刷新、更新检查或安装是否正常?"), + strings.text(en: "- Did this begin after changing Sub2API server version, app version, account, network, or macOS settings?", zhHans: "- 这是否发生在更改 Sub2API 服务器版本、App 版本、账户、网络或 macOS 设置之后?"), "", - "## Local Checks", + strings.text(en: "## Local Checks", zhHans: "## 本地检查"), "", - "- [ ] Diagnostics report contains token presence only, not token values.", - "- [ ] No `config.json` content is included.", - "- [ ] No access token, refresh token, password, private server URL, or private log is included.", - "- [ ] Release archive checksums were verified before reporting an installation issue.", + strings.text(en: "- [ ] Diagnostics report contains token presence only, not token values.", zhHans: "- [ ] 诊断报告只包含令牌是否存在,不包含令牌值。"), + strings.text(en: "- [ ] No `config.json` content is included.", zhHans: "- [ ] 未包含 `config.json` 内容。"), + strings.text(en: "- [ ] No access token, refresh token, password, private server URL, or private log is included.", zhHans: "- [ ] 未包含访问令牌、刷新令牌、密码、私有服务器 URL 或私有日志。"), + strings.text(en: "- [ ] Release archive checksums were verified before reporting an installation issue.", zhHans: "- [ ] 报告安装问题前已验证发布压缩包校验和。"), ] return lines.joined(separator: "\n") diff --git a/Sources/Sub2APIStatusCore/UsageInsights.swift b/Sources/Sub2APIStatusCore/UsageInsights.swift index 2ee8221..ed4aa95 100644 --- a/Sources/Sub2APIStatusCore/UsageInsights.swift +++ b/Sources/Sub2APIStatusCore/UsageInsights.swift @@ -68,37 +68,39 @@ public struct UsageInsights: Equatable, Sendable { subscriptionSummary: SubscriptionSummary?, trend: [TrendDataPoint]?, models: [ModelUsageSummary]?, - thresholds: InsightThresholds = .defaults + thresholds: InsightThresholds = .defaults, + language: AppLanguage = .en ) -> UsageInsights { + let strings = AppStrings(language: language) var thresholds = thresholds thresholds.normalize() var items: [UsageInsightItem] = [] - if let quota = quotaInsight(subscriptionSummary, thresholds: thresholds) { + if let quota = quotaInsight(subscriptionSummary, thresholds: thresholds, strings: strings) { items.append(quota) } - if let balance = balanceInsight(currentUser: currentUser, stats: stats, thresholds: thresholds) { + if let balance = balanceInsight(currentUser: currentUser, stats: stats, thresholds: thresholds, strings: strings) { items.append(balance) } - if let budget = budgetInsight(stats: stats, trend: trend, thresholds: thresholds) { + if let budget = budgetInsight(stats: stats, trend: trend, thresholds: thresholds, strings: strings) { items.append(budget) } - if let spend = spendInsight(trend, thresholds: thresholds) { + if let spend = spendInsight(trend, thresholds: thresholds, strings: strings) { items.append(spend) } - if let trend = trendInsight(trend, thresholds: thresholds) { + if let trend = trendInsight(trend, thresholds: thresholds, strings: strings) { items.append(trend) } - if let modelMix = modelMixInsight(models, thresholds: thresholds) { + if let modelMix = modelMixInsight(models, thresholds: thresholds, strings: strings) { items.append(modelMix) } - if let performance = performanceInsight(stats, thresholds: thresholds) { + if let performance = performanceInsight(stats, thresholds: thresholds, strings: strings) { items.append(performance) } @@ -109,20 +111,20 @@ public struct UsageInsights: Equatable, Sendable { return lhs.kind.sortRank < rhs.kind.sortRank } - let headline = items.first?.detail ?? "Usage is steady." + let headline = items.first?.detail ?? strings.text(en: "Usage is steady.", zhHans: "用量保持稳定。") return UsageInsights(headline: headline, items: Array(items.prefix(5))) } - private static func quotaInsight(_ summary: SubscriptionSummary?, thresholds: InsightThresholds) -> UsageInsightItem? { + private static func quotaInsight(_ summary: SubscriptionSummary?, thresholds: InsightThresholds, strings: AppStrings) -> UsageInsightItem? { guard let summary else { return nil } let candidates = summary.subscriptions.flatMap { item in [ - (groupName: item.groupName, label: "daily", progress: item.dailyProgress, resetInSeconds: item.dailyResetInSeconds), - (groupName: item.groupName, label: "weekly", progress: item.weeklyProgress, resetInSeconds: item.weeklyResetInSeconds), - (groupName: item.groupName, label: "monthly", progress: item.monthlyProgress, resetInSeconds: item.monthlyResetInSeconds), + (groupName: item.groupName, label: strings.text(en: "daily", zhHans: "日"), progress: item.dailyProgress, resetInSeconds: item.dailyResetInSeconds), + (groupName: item.groupName, label: strings.text(en: "weekly", zhHans: "周"), progress: item.weeklyProgress, resetInSeconds: item.weeklyResetInSeconds), + (groupName: item.groupName, label: strings.text(en: "monthly", zhHans: "月"), progress: item.monthlyProgress, resetInSeconds: item.monthlyResetInSeconds), ] } @@ -145,18 +147,18 @@ public struct UsageInsights: Equatable, Sendable { .healthy } - let title = "\(peak.groupName) \(peak.label) quota" - let resetText = peak.resetInSeconds.map { " and resets in \(StatusFormatters.duration(seconds: $0))" } ?? "" + let title = strings.text(en: "\(peak.groupName) \(peak.label) quota", zhHans: "\(peak.groupName)\(peak.label)配额") + let resetText = peak.resetInSeconds.map { strings.text(en: " and resets in \(StatusFormatters.duration(seconds: $0))", zhHans: ",将在 \(StatusFormatters.duration(seconds: $0)) 后重置") } ?? "" return UsageInsightItem( kind: .quota, severity: severity, title: title, value: StatusFormatters.percent(peak.progress), - detail: "\(title) is at \(StatusFormatters.percent(peak.progress))\(resetText)." + detail: strings.text(en: "\(title) is at \(StatusFormatters.percent(peak.progress))\(resetText).", zhHans: "\(title) 已达到 \(StatusFormatters.percent(peak.progress))\(resetText)。") ) } - private static func balanceInsight(currentUser: CurrentUser?, stats: DashboardStats?, thresholds: InsightThresholds) -> UsageInsightItem? { + private static func balanceInsight(currentUser: CurrentUser?, stats: DashboardStats?, thresholds: InsightThresholds, strings: AppStrings) -> UsageInsightItem? { guard let balance = currentUser?.balance, balance > 0, let stats, stats.todayActualCost > 0 else { return nil } @@ -173,13 +175,13 @@ public struct UsageInsights: Equatable, Sendable { return UsageInsightItem( kind: .balance, severity: severity, - title: "Balance runway", + title: strings.text(en: "Balance runway", zhHans: "余额续航"), value: String(format: "%.1fd", days), - detail: "Balance covers about \(String(format: "%.1f", days)) days at today's spend." + detail: strings.text(en: "Balance covers about \(String(format: "%.1f", days)) days at today's spend.", zhHans: "按今日花费计算,余额约可支撑 \(String(format: "%.1f", days)) 天。") ) } - private static func budgetInsight(stats: DashboardStats?, trend: [TrendDataPoint]?, thresholds: InsightThresholds) -> UsageInsightItem? { + private static func budgetInsight(stats: DashboardStats?, trend: [TrendDataPoint]?, thresholds: InsightThresholds, strings: AppStrings) -> UsageInsightItem? { guard thresholds.monthlyBudgetUSD > 0 else { return nil } @@ -208,13 +210,13 @@ public struct UsageInsights: Equatable, Sendable { return UsageInsightItem( kind: .budget, severity: severity, - title: "Monthly budget", + title: strings.text(en: "Monthly budget", zhHans: "月度预算"), value: StatusFormatters.currency(projectedSpend), - detail: "Projected monthly spend is \(StatusFormatters.currency(projectedSpend)) against a \(StatusFormatters.currency(budget)) budget." + detail: strings.text(en: "Projected monthly spend is \(StatusFormatters.currency(projectedSpend)) against a \(StatusFormatters.currency(budget)) budget.", zhHans: "预计月花费为 \(StatusFormatters.currency(projectedSpend)),预算为 \(StatusFormatters.currency(budget))。") ) } - private static func trendInsight(_ trend: [TrendDataPoint]?, thresholds: InsightThresholds) -> UsageInsightItem? { + private static func trendInsight(_ trend: [TrendDataPoint]?, thresholds: InsightThresholds, strings: AppStrings) -> UsageInsightItem? { guard let trend, trend.count >= 4, let latest = trend.last else { return nil } @@ -231,9 +233,9 @@ public struct UsageInsights: Equatable, Sendable { return UsageInsightItem( kind: .trend, severity: .healthy, - title: "Token trend", - value: "steady", - detail: "Token usage is close to the recent average." + title: strings.text(en: "Token trend", zhHans: "令牌趋势"), + value: strings.text(en: "steady", zhHans: "稳定"), + detail: strings.text(en: "Token usage is close to the recent average.", zhHans: "令牌用量接近近期平均水平。") ) } @@ -242,15 +244,15 @@ public struct UsageInsights: Equatable, Sendable { return UsageInsightItem( kind: .trend, severity: isSpike ? .warning : .healthy, - title: isSpike ? "Token surge" : "Token dip", + title: isSpike ? strings.text(en: "Token surge", zhHans: "令牌激增") : strings.text(en: "Token dip", zhHans: "令牌下降"), value: StatusFormatters.percent(change), detail: isSpike - ? "Today's tokens are \(StatusFormatters.percent(change)) above the recent average." - : "Today's tokens are \(StatusFormatters.percent(change)) below the recent average." + ? strings.text(en: "Today's tokens are \(StatusFormatters.percent(change)) above the recent average.", zhHans: "今日令牌比近期平均高 \(StatusFormatters.percent(change))。") + : strings.text(en: "Today's tokens are \(StatusFormatters.percent(change)) below the recent average.", zhHans: "今日令牌比近期平均低 \(StatusFormatters.percent(change))。") ) } - private static func spendInsight(_ trend: [TrendDataPoint]?, thresholds: InsightThresholds) -> UsageInsightItem? { + private static func spendInsight(_ trend: [TrendDataPoint]?, thresholds: InsightThresholds, strings: AppStrings) -> UsageInsightItem? { guard let trend, trend.count >= 4, let latest = trend.last else { return nil } @@ -270,13 +272,13 @@ public struct UsageInsights: Equatable, Sendable { return UsageInsightItem( kind: .spend, severity: .warning, - title: "Spend surge", + title: strings.text(en: "Spend surge", zhHans: "花费激增"), value: StatusFormatters.percent(change), - detail: "Today's spend is \(StatusFormatters.percent(change)) above the recent average." + detail: strings.text(en: "Today's spend is \(StatusFormatters.percent(change)) above the recent average.", zhHans: "今日花费比近期平均高 \(StatusFormatters.percent(change))。") ) } - private static func modelMixInsight(_ models: [ModelUsageSummary]?, thresholds: InsightThresholds) -> UsageInsightItem? { + private static func modelMixInsight(_ models: [ModelUsageSummary]?, thresholds: InsightThresholds, strings: AppStrings) -> UsageInsightItem? { guard let models, models.count > 1 else { return nil } @@ -291,13 +293,13 @@ public struct UsageInsights: Equatable, Sendable { return UsageInsightItem( kind: .modelMix, severity: severity, - title: "Top model", + title: strings.text(en: "Top model", zhHans: "主要模型"), value: StatusFormatters.percent(share), - detail: "\(top.model) drives \(StatusFormatters.percent(share)) of model spend." + detail: strings.text(en: "\(top.model) drives \(StatusFormatters.percent(share)) of model spend.", zhHans: "\(top.model) 占模型花费的 \(StatusFormatters.percent(share))。") ) } - private static func performanceInsight(_ stats: DashboardStats?, thresholds: InsightThresholds) -> UsageInsightItem? { + private static func performanceInsight(_ stats: DashboardStats?, thresholds: InsightThresholds, strings: AppStrings) -> UsageInsightItem? { guard let stats else { return nil } @@ -306,9 +308,9 @@ public struct UsageInsights: Equatable, Sendable { return UsageInsightItem( kind: .performance, severity: .warning, - title: "Latency", + title: strings.text(en: "Latency", zhHans: "延迟"), value: String(format: "%.1fs", stats.averageDurationMs / 1_000), - detail: "Average response time is elevated." + detail: strings.text(en: "Average response time is elevated.", zhHans: "平均响应时间偏高。") ) } diff --git a/Sources/Sub2APIStatusCore/UsageReport.swift b/Sources/Sub2APIStatusCore/UsageReport.swift index 20fc855..2b9e90e 100644 --- a/Sources/Sub2APIStatusCore/UsageReport.swift +++ b/Sources/Sub2APIStatusCore/UsageReport.swift @@ -4,51 +4,56 @@ public enum UsageReport { public static func make( config: AppConfig, snapshot: MonitorSnapshot, - now: Date = Date() + now: Date = Date(), + language: AppLanguage? = nil ) -> String { + let strings = AppStrings(language: language ?? .en) var lines = [ - "Sub2API Usage Report", - "Generated: \(ISO8601DateFormatter().string(from: now))", - "Status: \(snapshot.statusLabel(now: now, refreshIntervalSeconds: config.refreshIntervalSeconds))", - "Account: \(accountText(snapshot.currentUser))", + strings.text(en: "Sub2API Usage Report", zhHans: "Sub2API 用量报告"), + "\(strings.generated): \(ISO8601DateFormatter().string(from: now))", + "\(strings.status): \(snapshot.statusLabel(now: now, refreshIntervalSeconds: config.refreshIntervalSeconds, language: language ?? .en))", + "\(strings.account): \(accountText(snapshot.currentUser, strings: strings))", ] if let lastUpdatedAt = snapshot.lastUpdatedAt { - lines.append("Last Updated: \(ISO8601DateFormatter().string(from: lastUpdatedAt))") + lines.append("\(strings.text(en: "Last Updated", zhHans: "上次更新")): \(ISO8601DateFormatter().string(from: lastUpdatedAt))") } if let balance = snapshot.currentUser?.balance { - lines.append("Balance: \(StatusFormatters.currency(balance))") + lines.append("\(strings.text(en: "Balance", zhHans: "余额")): \(StatusFormatters.currency(balance))") } if let stats = snapshot.stats { - lines.append("Today Spend: \(StatusFormatters.preciseCurrency(stats.todayActualCost))") - lines.append("Today Requests: \(StatusFormatters.menuBarCount(stats.todayRequests))") - lines.append("Today Tokens: \(StatusFormatters.compactNumber(stats.todayTokens))") - lines.append("Token Mix: \(tokenMix(stats))") - lines.append("Cost / MTok: \(StatusFormatters.costPerMillionTokens(cost: stats.todayActualCost, tokens: stats.todayTokens))") - lines.append("Throughput: \(StatusFormatters.menuBarRate(stats.rpm)) RPM / \(StatusFormatters.compactNumber(Int64(stats.tpm))) TPM") - lines.append("Average Response: \(latencyText(milliseconds: stats.averageDurationMs))") - lines.append("Lifetime Spend: \(StatusFormatters.preciseCurrency(stats.totalActualCost))") - lines.append("Lifetime Tokens: \(StatusFormatters.compactNumber(stats.totalTokens))") + lines.append("\(strings.text(en: "Today Spend", zhHans: "今日花费")): \(StatusFormatters.preciseCurrency(stats.todayActualCost))") + lines.append("\(strings.text(en: "Today Requests", zhHans: "今日请求")): \(StatusFormatters.menuBarCount(stats.todayRequests))") + lines.append("\(strings.text(en: "Today Tokens", zhHans: "今日令牌")): \(StatusFormatters.compactNumber(stats.todayTokens))") + lines.append("\(strings.text(en: "Token Mix", zhHans: "令牌构成")): \(tokenMix(stats, strings: strings))") + lines.append("\(strings.text(en: "Cost / MTok", zhHans: "每百万令牌成本")): \(StatusFormatters.costPerMillionTokens(cost: stats.todayActualCost, tokens: stats.todayTokens))") + lines.append("\(strings.text(en: "Throughput", zhHans: "吞吐量")): \(StatusFormatters.menuBarRate(stats.rpm)) RPM / \(StatusFormatters.compactNumber(Int64(stats.tpm))) TPM") + lines.append("\(strings.averageResponse): \(latencyText(milliseconds: stats.averageDurationMs))") + lines.append("\(strings.text(en: "Lifetime Spend", zhHans: "累计花费")): \(StatusFormatters.preciseCurrency(stats.totalActualCost))") + lines.append("\(strings.text(en: "Lifetime Tokens", zhHans: "累计令牌")): \(StatusFormatters.compactNumber(stats.totalTokens))") } if let subscriptionSummary = snapshot.subscriptionSummary { - lines.append("Subscriptions: \(subscriptionSummary.activeCount) active") - if let quota = topQuota(subscriptionSummary) { - let resetText = quota.resetInSeconds.map { ", resets in \(StatusFormatters.duration(seconds: $0))" } ?? "" + lines.append(strings.text(en: "Subscriptions: \(subscriptionSummary.activeCount) active", zhHans: "订阅:\(subscriptionSummary.activeCount) 个活跃")) + if let quota = topQuota(subscriptionSummary, strings: strings) { + let resetText = quota.resetInSeconds.map { strings.text(en: ", resets in \(StatusFormatters.duration(seconds: $0))", zhHans: ",\(StatusFormatters.duration(seconds: $0)) 后重置") } ?? "" lines.append("\(quota.name): \(StatusFormatters.percent(quota.progress))\(resetText)") } } if let latestTrend = snapshot.trend?.last { lines.append( - "Trend \(latestTrend.date): \(StatusFormatters.menuBarCount(latestTrend.requests)) requests, \(StatusFormatters.compactNumber(latestTrend.totalTokens)) tokens, \(StatusFormatters.preciseCurrency(latestTrend.actualCost))" + strings.text( + en: "Trend \(latestTrend.date): \(StatusFormatters.menuBarCount(latestTrend.requests)) requests, \(StatusFormatters.compactNumber(latestTrend.totalTokens)) tokens, \(StatusFormatters.preciseCurrency(latestTrend.actualCost))", + zhHans: "趋势 \(latestTrend.date):\(StatusFormatters.menuBarCount(latestTrend.requests)) 次请求,\(StatusFormatters.compactNumber(latestTrend.totalTokens)) 令牌,\(StatusFormatters.preciseCurrency(latestTrend.actualCost))" + ) ) } - if let modelText = topModelsText(snapshot.modelDistribution) { - lines.append("Top Models: \(modelText)") + if let modelText = topModelsText(snapshot.modelDistribution, strings: strings) { + lines.append("\(strings.text(en: "Top Models", zhHans: "主要模型")): \(modelText)") } if snapshot.connected { @@ -58,24 +63,25 @@ public enum UsageReport { subscriptionSummary: snapshot.subscriptionSummary, trend: snapshot.trend, models: snapshot.modelDistribution, - thresholds: config.insightThresholds + thresholds: config.insightThresholds, + language: language ?? .en ) - lines.append("Insight: \(insights.headline)") + lines.append("\(strings.text(en: "Insight", zhHans: "洞察")): \(insights.headline)") for item in insights.items { lines.append("- \(item.title): \(item.value) - \(item.detail)") } } if let message = snapshot.message, !message.isEmpty { - lines.append("Message: \(message)") + lines.append("\(strings.text(en: "Message", zhHans: "消息")): \(message)") } return lines.joined(separator: "\n") } - private static func accountText(_ user: CurrentUser?) -> String { + private static func accountText(_ user: CurrentUser?, strings: AppStrings) -> String { guard let user else { - return "Unknown" + return strings.text(en: "Unknown", zhHans: "未知") } let displayName = if let username = user.username, !username.isEmpty { @@ -90,11 +96,11 @@ public enum UsageReport { return "\(displayName) <\(user.email)>" } - private static func tokenMix(_ stats: DashboardStats) -> String { + private static func tokenMix(_ stats: DashboardStats, strings: AppStrings) -> String { [ - "Input \(StatusFormatters.compactNumber(stats.todayInputTokens))", - "Output \(StatusFormatters.compactNumber(stats.todayOutputTokens))", - "Cache Read \(StatusFormatters.compactNumber(stats.todayCacheReadTokens))", + strings.text(en: "Input \(StatusFormatters.compactNumber(stats.todayInputTokens))", zhHans: "输入 \(StatusFormatters.compactNumber(stats.todayInputTokens))"), + strings.text(en: "Output \(StatusFormatters.compactNumber(stats.todayOutputTokens))", zhHans: "输出 \(StatusFormatters.compactNumber(stats.todayOutputTokens))"), + strings.text(en: "Cache Read \(StatusFormatters.compactNumber(stats.todayCacheReadTokens))", zhHans: "缓存读取 \(StatusFormatters.compactNumber(stats.todayCacheReadTokens))"), ].joined(separator: " / ") } @@ -105,13 +111,13 @@ public enum UsageReport { return "\(Int(milliseconds))ms" } - private static func topQuota(_ summary: SubscriptionSummary) -> (name: String, progress: Double, resetInSeconds: Double?)? { + private static func topQuota(_ summary: SubscriptionSummary, strings: AppStrings) -> (name: String, progress: Double, resetInSeconds: Double?)? { summary.subscriptions .flatMap { item in [ - (name: "\(item.groupName) daily quota", progress: item.dailyProgress, resetInSeconds: item.dailyResetInSeconds), - (name: "\(item.groupName) weekly quota", progress: item.weeklyProgress, resetInSeconds: item.weeklyResetInSeconds), - (name: "\(item.groupName) monthly quota", progress: item.monthlyProgress, resetInSeconds: item.monthlyResetInSeconds), + (name: strings.text(en: "\(item.groupName) daily quota", zhHans: "\(item.groupName) 日配额"), progress: item.dailyProgress, resetInSeconds: item.dailyResetInSeconds), + (name: strings.text(en: "\(item.groupName) weekly quota", zhHans: "\(item.groupName) 周配额"), progress: item.weeklyProgress, resetInSeconds: item.weeklyResetInSeconds), + (name: strings.text(en: "\(item.groupName) monthly quota", zhHans: "\(item.groupName) 月配额"), progress: item.monthlyProgress, resetInSeconds: item.monthlyResetInSeconds), ] } .compactMap { candidate -> (name: String, progress: Double, resetInSeconds: Double?)? in @@ -123,14 +129,14 @@ public enum UsageReport { .max(by: { $0.progress < $1.progress }) } - private static func topModelsText(_ models: [ModelUsageSummary]?) -> String? { + private static func topModelsText(_ models: [ModelUsageSummary]?, strings: AppStrings) -> String? { guard let models, !models.isEmpty else { return nil } return models .prefix(3) - .map { "\($0.model) \(StatusFormatters.preciseCurrency($0.actualCost)) (\(StatusFormatters.compactNumber($0.totalTokens)) tokens)" } + .map { strings.text(en: "\($0.model) \(StatusFormatters.preciseCurrency($0.actualCost)) (\(StatusFormatters.compactNumber($0.totalTokens)) tokens)", zhHans: "\($0.model) \(StatusFormatters.preciseCurrency($0.actualCost))(\(StatusFormatters.compactNumber($0.totalTokens)) 令牌)") } .joined(separator: ", ") } } diff --git a/Tests/Sub2APIStatusCoreTests/Sub2APIStatusCoreTests.swift b/Tests/Sub2APIStatusCoreTests/Sub2APIStatusCoreTests.swift index 49e9709..92033cd 100644 --- a/Tests/Sub2APIStatusCoreTests/Sub2APIStatusCoreTests.swift +++ b/Tests/Sub2APIStatusCoreTests/Sub2APIStatusCoreTests.swift @@ -1558,6 +1558,77 @@ private func pngSize(_ data: Data) -> (width: Int, height: Int)? { #expect(summary.shareText.contains("Das") == false) } +@Test func generatedReportsCanRenderSimplifiedChineseWithoutSecrets() { + let config = AppConfig( + baseURL: "https://sub2api.example.com", + authToken: "secret-access-token", + refreshToken: "secret-refresh-token", + refreshIntervalSeconds: 30, + language: .zhHans + ) + let snapshot = MonitorSnapshot( + mode: .user, + connected: true, + currentUser: CurrentUser(id: 1, email: "das@example.com", username: "Das", role: "user", balance: 300, status: "active"), + stats: DashboardStats(todayRequests: 42, todayTokens: 123_456, todayInputTokens: 1_000, todayOutputTokens: 2_000, todayCacheReadTokens: 3_000, todayActualCost: 3.14, rpm: 2, tpm: 100), + trend: nil, + modelDistribution: nil, + realtime: nil, + accountHealth: nil, + subscriptionSummary: nil, + lastUpdatedAt: Date(timeIntervalSince1970: 1_000), + message: nil + ) + + let usage = UsageReport.make(config: config, snapshot: snapshot, now: Date(timeIntervalSince1970: 1_060), language: .zhHans) + let diagnostics = DiagnosticReport.make(config: config, snapshot: snapshot, appVersion: "0.1.11", osVersion: "macOS 15.0", language: .zhHans) + let support = SupportBundleReport.make(config: config, snapshot: snapshot, appVersion: "0.1.11", osVersion: "macOS 15.0", language: .zhHans) + let share = SocialShareSummary.make(config: config, snapshot: snapshot, now: Date(timeIntervalSince1970: 1_060), language: .zhHans) + + #expect(usage.contains("Sub2API 用量报告")) + #expect(usage.contains("状态")) + #expect(usage.contains("账户")) + #expect(diagnostics.contains("Sub2API Status Bar 诊断")) + #expect(diagnostics.contains("访问令牌: 存在") || diagnostics.contains("访问令牌:存在")) + #expect(support.contains("Sub2API Status Bar 支持包")) + #expect(support.contains("诊断报告只包含令牌是否存在")) + #expect(share.title.contains("AI 令牌")) + #expect(share.shareText.contains("不含提示词。不含密钥。")) + + for output in [usage, diagnostics, support, share.shareText] { + #expect(output.contains("secret-access-token") == false) + #expect(output.contains("secret-refresh-token") == false) + } +} + +@Test func recoveryChecklistAndInsightAlertsCanRenderSimplifiedChinese() { + let recovery = RecoverySuggestion.make(message: "401 unauthorized", hasBaseURL: true, hasToken: true, language: .zhHans) + let checklist = OnboardingChecklist.make( + form: LoginFormState(baseURL: "", email: "", password: ""), + manualToken: "", + language: .zhHans + ) + let alert = InsightAlertPolicy(settings: .defaults).staleDataAlert( + from: MonitorSnapshot( + mode: .user, + connected: true, + stats: DashboardStats(todayRequests: 1), + lastUpdatedAt: Date(timeIntervalSince1970: 0), + message: nil + ), + refreshIntervalSeconds: 5, + lastAlertedAtByFingerprint: [:], + now: Date(timeIntervalSince1970: 30), + language: .zhHans + ) + + #expect(recovery.title == "重新登录") + #expect(checklist.steps.map(\.title) == ["服务器 URL", "账户", "密码或令牌"]) + #expect(checklist.summary.contains("请补充")) + #expect(alert?.title == "数据已过期") + #expect(alert?.body.contains("未刷新") == true) +} + @Test func loginFormStateRequiresURLAccountAndPassword() { #expect(LoginFormState(baseURL: "", email: "a@example.com", password: "secret").canSubmit == false) #expect(LoginFormState(baseURL: "http://127.0.0.1:8080", email: "", password: "secret").canSubmit == false) From a6515da06c03c2d03bed0f624816d9609c7ba6d6 Mon Sep 17 00:00:00 2001 From: maple Date: Tue, 2 Jun 2026 21:08:08 +0800 Subject: [PATCH 5/6] feat: add runtime language setting --- Sources/Sub2APIStatusBar/ChartViews.swift | 6 +- .../DashboardComponents.swift | 8 +- Sources/Sub2APIStatusBar/LoginViews.swift | 33 ++++--- Sources/Sub2APIStatusBar/MonitorPanel.swift | 58 ++++++----- .../Sub2APIStatusBar/MonitorViewModel.swift | 10 +- Sources/Sub2APIStatusBar/QuotaViews.swift | 20 ++-- Sources/Sub2APIStatusBar/SettingsView.swift | 96 +++++++++++-------- .../Sub2APIStatusBarApp.swift | 9 +- 8 files changed, 142 insertions(+), 98 deletions(-) diff --git a/Sources/Sub2APIStatusBar/ChartViews.swift b/Sources/Sub2APIStatusBar/ChartViews.swift index 0185df2..d7e36fd 100644 --- a/Sources/Sub2APIStatusBar/ChartViews.swift +++ b/Sources/Sub2APIStatusBar/ChartViews.swift @@ -3,13 +3,16 @@ import Sub2APIStatusCore struct ModelDistributionView: View { let models: [ModelUsageSummary] + var language: AppLanguage = .en + + private var strings: AppStrings { AppStrings(language: language) } private var visibleModels: [ModelUsageDisplay] { ModelUsageDisplay.make(models) } var body: some View { - SectionBlock(title: "Model Distribution") { + SectionBlock(title: strings.text(en: "Model Distribution", zhHans: "模型分布")) { VStack(spacing: 0) { ForEach(visibleModels) { item in VStack(spacing: 8) { @@ -55,6 +58,7 @@ struct ModelDistributionView: View { struct UsageTrendSection: View { let state: UsageTrendDisplayState + var language: AppLanguage = .en @State private var selectedMode: UsageTrendMode = .tokens var body: some View { diff --git a/Sources/Sub2APIStatusBar/DashboardComponents.swift b/Sources/Sub2APIStatusBar/DashboardComponents.swift index 2cacddd..c179f9f 100644 --- a/Sources/Sub2APIStatusBar/DashboardComponents.swift +++ b/Sources/Sub2APIStatusBar/DashboardComponents.swift @@ -21,6 +21,7 @@ struct MetricItem: Identifiable { struct UserAccountCard: View { let user: CurrentUser + var language: AppLanguage = .en private var displayName: String { guard let username = user.username, !username.isEmpty else { @@ -52,7 +53,7 @@ struct UserAccountCard: View { if let status = user.status, !status.isEmpty { StatusPill( - text: status.capitalized, + text: status.lowercased() == "active" ? AppStrings(language: language).text(en: "Active", zhHans: "活跃") : status.capitalized, color: status.lowercased() == "active" ? .green : .secondary ) } @@ -127,11 +128,14 @@ private struct SafeSystemImage: View { struct UsageInsightsView: View { let insights: UsageInsights + var language: AppLanguage = .en + + private var strings: AppStrings { AppStrings(language: language) } var body: some View { VStack(alignment: .leading, spacing: 10) { HStack(alignment: .firstTextBaseline) { - Label("Usage Insights", systemImage: "sparkles") + Label(strings.usageInsights, systemImage: "sparkles") .font(.system(size: 14, weight: .semibold)) Spacer() Text(insights.headline) diff --git a/Sources/Sub2APIStatusBar/LoginViews.swift b/Sources/Sub2APIStatusBar/LoginViews.swift index 684dffc..b170148 100644 --- a/Sources/Sub2APIStatusBar/LoginViews.swift +++ b/Sources/Sub2APIStatusBar/LoginViews.swift @@ -5,6 +5,8 @@ struct LoginPanel: View { @ObservedObject var model: MonitorViewModel @FocusState private var focusedField: FocusedLoginField? + private var strings: AppStrings { AppStrings(language: model.settingsDraft.language) } + private var formState: LoginFormState { LoginFormState( baseURL: model.settingsDraft.baseURL, @@ -23,7 +25,7 @@ struct LoginPanel: View { VStack(alignment: .leading, spacing: 3) { Text("Sub2API") .font(.title2.bold()) - Text("Connect your server") + Text(strings.connectYourServer) .font(.callout) .foregroundStyle(.secondary) } @@ -33,23 +35,23 @@ struct LoginPanel: View { AccountListSection(model: model) } - OnboardingChecklistView(checklist: checklist) + OnboardingChecklistView(checklist: checklist, language: model.settingsDraft.language) VStack(alignment: .leading, spacing: 12) { - TextField("Server URL", text: $model.settingsDraft.baseURL) + TextField(strings.serverURL, text: $model.settingsDraft.baseURL) .textFieldStyle(.roundedBorder) .focused($focusedField, equals: .serverURL) - TextField("Account", text: $model.loginEmail) + TextField(strings.account, text: $model.loginEmail) .textFieldStyle(.roundedBorder) .focused($focusedField, equals: .email) - SecureField("Password", text: $model.loginPassword) + SecureField(strings.text(en: "Password", zhHans: "密码"), text: $model.loginPassword) .textFieldStyle(.roundedBorder) .focused($focusedField, equals: .password) HStack { - Text("Refresh") + Text(strings.refresh) .font(.callout) .foregroundStyle(.secondary) Slider(value: $model.settingsDraft.refreshIntervalSeconds, in: 5...300, step: 5) @@ -77,7 +79,7 @@ struct LoginPanel: View { } else { Image(systemName: "key.fill") } - Text(model.isLoggingIn ? "Connecting..." : "Login") + Text(model.isLoggingIn ? strings.connecting : strings.login) } .frame(maxWidth: .infinity) } @@ -88,9 +90,9 @@ struct LoginPanel: View { Divider() VStack(alignment: .leading, spacing: 8) { - Text("Manual token") + Text(strings.manualToken) .font(.headline) - SecureField("Bearer Token", text: $model.settingsDraft.authToken) + SecureField(strings.bearerToken, text: $model.settingsDraft.authToken) .textFieldStyle(.roundedBorder) .focused($focusedField, equals: .token) Button { @@ -102,7 +104,7 @@ struct LoginPanel: View { ) model.saveSettings() } label: { - Label("Save Token", systemImage: "square.and.arrow.down") + Label(strings.saveToken, systemImage: "square.and.arrow.down") } .disabled(model.settingsDraft.authToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } @@ -113,7 +115,7 @@ struct LoginPanel: View { Button { model.openURL(model.settingsDraft.baseURL) } label: { - Label("Open Server", systemImage: "safari") + Label(strings.openServer, systemImage: "safari") } .disabled(model.settingsDraft.baseURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) @@ -122,7 +124,7 @@ struct LoginPanel: View { Button { model.quit() } label: { - Label("Quit", systemImage: "power") + Label(strings.quit, systemImage: "power") } } .buttonStyle(.borderless) @@ -161,11 +163,14 @@ struct LoginPanel: View { struct OnboardingChecklistView: View { let checklist: OnboardingChecklist + var language: AppLanguage = .en + + private var strings: AppStrings { AppStrings(language: language) } var body: some View { VStack(alignment: .leading, spacing: 9) { HStack { - Text("Connection Checklist") + Text(strings.connectionChecklist) .font(.headline) Spacer() Text(checklist.summary) @@ -211,7 +216,7 @@ struct AccountListSection: View { var body: some View { VStack(alignment: .leading, spacing: 8) { - Text("Accounts") + Text(AppStrings(language: model.settingsDraft.language).accounts) .font(.headline) ForEach(model.config.accounts) { account in diff --git a/Sources/Sub2APIStatusBar/MonitorPanel.swift b/Sources/Sub2APIStatusBar/MonitorPanel.swift index 922641c..b56ec26 100644 --- a/Sources/Sub2APIStatusBar/MonitorPanel.swift +++ b/Sources/Sub2APIStatusBar/MonitorPanel.swift @@ -3,6 +3,8 @@ import Sub2APIStatusCore struct MonitorPanel: View { @ObservedObject var model: MonitorViewModel + + private var strings: AppStrings { AppStrings(language: model.config.language) } @State private var showingSettings = false var body: some View { @@ -28,18 +30,19 @@ struct MonitorPanel: View { stats: model.snapshot.stats, balanceText: balanceText, quotaText: quotaHeadline, + language: model.config.language, tokenBreakdown: tokenBreakdown ) shareToolbar SectionBlock(title: "Usage Trend") { - UsageTrendSection(state: UsageTrendDisplayState.make(points: model.snapshot.trend)) + UsageTrendSection(state: UsageTrendDisplayState.make(points: model.snapshot.trend), language: model.config.language) } } if let updateInfo = model.updateInfo, updateInfo.isUpdateAvailable { - UpdateAvailableBanner(info: updateInfo) { + UpdateAvailableBanner(info: updateInfo, language: model.config.language) { model.openLatestRelease() } } @@ -84,7 +87,7 @@ struct MonitorPanel: View { Text(activeAccountTitle) .font(.system(size: 15, weight: .semibold)) .lineLimit(1) - Text(model.snapshot.connected ? lastUpdatedText : "Disconnected") + Text(model.snapshot.connected ? lastUpdatedText : strings.disconnected) .font(.caption) .foregroundStyle(.secondary) } @@ -101,7 +104,7 @@ struct MonitorPanel: View { Image(systemName: model.isRefreshing ? "arrow.triangle.2.circlepath" : "arrow.clockwise") } .disabled(model.isRefreshing) - .help("Refresh") + .help(strings.refresh) Button { model.settingsDraft = model.config @@ -109,7 +112,7 @@ struct MonitorPanel: View { } label: { Image(systemName: "gearshape") } - .help("Settings") + .help(strings.settings) } .buttonStyle(.borderless) .padding(.horizontal, 18) @@ -125,11 +128,11 @@ struct MonitorPanel: View { private var statusSection: some View { VStack(alignment: .leading, spacing: 12) { if model.config.authToken.isEmpty { - Text("Set Base URL and token to start monitoring.") + Text(strings.setBaseURLAndToken) .font(.callout) .foregroundStyle(.secondary) } else if model.snapshot.connected { - UsageInsightsView(insights: usageInsights) + UsageInsightsView(insights: usageInsights, language: model.config.language) } } } @@ -140,19 +143,19 @@ struct MonitorPanel: View { Button { model.copySocialShareCard() } label: { - Label("Share Card", systemImage: "square.and.arrow.up") + Label(strings.shareCard, systemImage: "square.and.arrow.up") } Button { model.copyUsageReport() } label: { - Label("Report", systemImage: "doc.on.doc") + Label(strings.report, systemImage: "doc.on.doc") } Button { model.openSocialShareDraft() } label: { - Label("Post", systemImage: "paperplane") + Label(strings.post, systemImage: "paperplane") } Spacer() @@ -176,11 +179,11 @@ struct MonitorPanel: View { private var userSection: some View { VStack(alignment: .leading, spacing: 14) { if let user = model.snapshot.currentUser { - UserAccountCard(user: user) + UserAccountCard(user: user, language: model.config.language) } if let stats = model.snapshot.stats { - Text("Details") + Text(strings.details) .font(.system(size: 14, weight: .semibold)) .foregroundStyle(.primary) MetricGrid(items: [ @@ -209,14 +212,14 @@ struct MonitorPanel: View { SectionBlock(title: "Subscriptions") { VStack(spacing: 10) { ForEach(summary.subscriptions.prefix(5)) { item in - SubscriptionQuotaCard(item: item) + SubscriptionQuotaCard(item: item, language: model.config.language) } } } } if let models = model.snapshot.modelDistribution, !models.isEmpty { - ModelDistributionView(models: models) + ModelDistributionView(models: models, language: model.config.language) } } } @@ -226,7 +229,7 @@ struct MonitorPanel: View { Button { model.openDashboard() } label: { - Label("Open", systemImage: "safari") + Label(strings.open, systemImage: "safari") } .disabled(model.config.baseURL.isEmpty) @@ -235,7 +238,7 @@ struct MonitorPanel: View { Button { model.quit() } label: { - Label("Quit", systemImage: "power") + Label(strings.quit, systemImage: "power") } } .buttonStyle(.borderless) @@ -287,10 +290,10 @@ struct MonitorPanel: View { private var quotaHeadline: String { guard let summary = model.snapshot.subscriptionSummary else { - return "No quota data" + return strings.text(en: "No quota data", zhHans: "暂无配额数据") } let progress = StatusFormatters.percent(summary.highestProgress) - return "\(progress) peak" + return strings.text(en: "\(progress) peak", zhHans: "峰值 \(progress)") } private var usageInsights: UsageInsights { @@ -346,7 +349,7 @@ struct AccountSwitcher: View { Image(systemName: "person.2") } .menuStyle(.borderlessButton) - .help("Switch account") + .help(AppStrings(language: model.config.language).text(en: "Switch account", zhHans: "切换账户")) } } @@ -356,14 +359,17 @@ struct OverviewCard: View { let stats: DashboardStats? let balanceText: String let quotaText: String + let language: AppLanguage let tokenBreakdown: (Int64, Int64) -> String + private var strings: AppStrings { AppStrings(language: language) } + var body: some View { VStack(alignment: .leading, spacing: 18) { HStack(alignment: .top) { VStack(alignment: .leading, spacing: 4) { StatusPill(text: statusText, color: statusColor, systemImage: "circle.fill") - Text("Today") + Text(strings.today) .font(.system(size: 12, weight: .medium)) .foregroundStyle(.secondary) } @@ -373,7 +379,7 @@ struct OverviewCard: View { VStack(alignment: .trailing, spacing: 2) { Text(balanceText) .font(.system(size: 17, weight: .bold, design: .rounded).monospacedDigit()) - Text("balance") + Text(strings.balance) .font(.caption2) .foregroundStyle(.secondary) } @@ -386,15 +392,15 @@ struct OverviewCard: View { .foregroundStyle(.primary) .lineLimit(1) .minimumScaleFactor(0.68) - Text("tokens") + Text(strings.tokens) .font(.system(size: 16, weight: .semibold, design: .rounded)) .foregroundStyle(.secondary) } HStack(spacing: 10) { - OverviewStat(title: "Spend", value: StatusFormatters.preciseCurrency(stats.todayActualCost), color: .green) - OverviewStat(title: "Requests", value: StatusFormatters.menuBarCount(stats.todayRequests), color: .blue) - OverviewStat(title: "Quota", value: quotaText, color: .orange) + OverviewStat(title: strings.text(en: "Spend", zhHans: "花费"), value: StatusFormatters.preciseCurrency(stats.todayActualCost), color: .green) + OverviewStat(title: strings.text(en: "Requests", zhHans: "请求"), value: StatusFormatters.menuBarCount(stats.todayRequests), color: .blue) + OverviewStat(title: strings.text(en: "Quota", zhHans: "配额"), value: quotaText, color: .orange) } Text(tokenBreakdown(stats.todayInputTokens, stats.todayOutputTokens)) @@ -402,7 +408,7 @@ struct OverviewCard: View { .foregroundStyle(.secondary) .lineLimit(1) } else { - Text("Waiting for usage data.") + Text(strings.waitingForUsageData) .font(.callout) .foregroundStyle(.secondary) } diff --git a/Sources/Sub2APIStatusBar/MonitorViewModel.swift b/Sources/Sub2APIStatusBar/MonitorViewModel.swift index 416e74c..b78c5a8 100644 --- a/Sources/Sub2APIStatusBar/MonitorViewModel.swift +++ b/Sources/Sub2APIStatusBar/MonitorViewModel.swift @@ -320,7 +320,7 @@ final class MonitorViewModel: ObservableObject { ) NSPasteboard.general.clearContents() NSPasteboard.general.setString(report, forType: .string) - updateStatusMessage = "Diagnostics copied." + updateStatusMessage = AppStrings(language: config.language).text(en: "Diagnostics copied.", zhHans: "诊断已复制。") } func copySupportBundle() { @@ -332,7 +332,7 @@ final class MonitorViewModel: ObservableObject { ) NSPasteboard.general.clearContents() NSPasteboard.general.setString(report, forType: .string) - updateStatusMessage = "Support bundle copied." + updateStatusMessage = AppStrings(language: config.language).text(en: "Support bundle copied.", zhHans: "支持包已复制。") } func copyUsageReport() { @@ -344,7 +344,7 @@ final class MonitorViewModel: ObservableObject { ) NSPasteboard.general.clearContents() NSPasteboard.general.setString(report, forType: .string) - updateStatusMessage = "Usage report copied." + updateStatusMessage = AppStrings(language: config.language).text(en: "Usage report copied.", zhHans: "用量报告已复制。") } func copySocialShareCard() { @@ -358,10 +358,10 @@ final class MonitorViewModel: ObservableObject { pasteboard.clearContents() if let image = SocialShareCardRenderer.image(for: summary) { pasteboard.writeObjects([image, NSString(string: summary.shareText)]) - updateStatusMessage = "Share card copied." + updateStatusMessage = AppStrings(language: config.language).text(en: "Share card copied.", zhHans: "分享卡片已复制。") } else { pasteboard.setString(summary.shareText, forType: .string) - updateStatusMessage = "Share text copied." + updateStatusMessage = AppStrings(language: config.language).text(en: "Share text copied.", zhHans: "分享文本已复制。") } } diff --git a/Sources/Sub2APIStatusBar/QuotaViews.swift b/Sources/Sub2APIStatusBar/QuotaViews.swift index bd6c17d..f59d682 100644 --- a/Sources/Sub2APIStatusBar/QuotaViews.swift +++ b/Sources/Sub2APIStatusBar/QuotaViews.swift @@ -3,6 +3,9 @@ import Sub2APIStatusCore struct SubscriptionQuotaCard: View { let item: SubscriptionSummaryItem + var language: AppLanguage = .en + + private var strings: AppStrings { AppStrings(language: language) } var body: some View { VStack(alignment: .leading, spacing: 12) { @@ -14,7 +17,7 @@ struct SubscriptionQuotaCard: View { .font(.callout.weight(.semibold)) Spacer() StatusPill( - text: item.status == "active" ? "Active" : item.status, + text: item.status == "active" ? strings.text(en: "Active", zhHans: "活跃") : item.status, color: item.status == "active" ? .green : .secondary ) } @@ -22,7 +25,7 @@ struct SubscriptionQuotaCard: View { quotaSummary ForEach(quotaWindows, id: \.title) { window in - QuotaProgressRow(window: window) + QuotaProgressRow(window: window, language: language) } } } @@ -30,21 +33,21 @@ struct SubscriptionQuotaCard: View { private var quotaWindows: [QuotaWindowDisplay] { [ QuotaWindowDisplay( - title: "Daily", + title: strings.text(en: "Daily", zhHans: "每日"), used: item.dailyUsedUSD, limit: item.dailyLimitUSD, progress: item.dailyProgress, resetInSeconds: item.dailyResetInSeconds ), QuotaWindowDisplay( - title: "Weekly", + title: strings.text(en: "Weekly", zhHans: "每周"), used: item.weeklyUsedUSD, limit: item.weeklyLimitUSD, progress: item.weeklyProgress, resetInSeconds: item.weeklyResetInSeconds ), QuotaWindowDisplay( - title: "Monthly", + title: strings.text(en: "Monthly", zhHans: "每月"), used: item.monthlyUsedUSD, limit: item.monthlyLimitUSD, progress: item.monthlyProgress, @@ -65,7 +68,7 @@ struct SubscriptionQuotaCard: View { Spacer() if let days = item.daysRemaining { - Label("\(days)d left", systemImage: "calendar.badge.clock") + Label(strings.daysLeft(days), systemImage: "calendar.badge.clock") .foregroundStyle(days <= 3 ? .orange : .secondary) } } @@ -90,6 +93,9 @@ struct SubscriptionQuotaCard: View { struct QuotaProgressRow: View { let window: QuotaWindowDisplay + var language: AppLanguage = .en + + private var strings: AppStrings { AppStrings(language: language) } private var tint: Color { switch window.severity { @@ -133,7 +139,7 @@ struct QuotaProgressRow: View { .lineLimit(1) .minimumScaleFactor(0.75) .accessibilityElement(children: .ignore) - .accessibilityLabel("\(window.title) quota \(window.percentText), \(window.remainingText)") + .accessibilityLabel(strings.quotaAccessibility(title: window.title, percent: window.percentText, remaining: window.remainingText)) } } } diff --git a/Sources/Sub2APIStatusBar/SettingsView.swift b/Sources/Sub2APIStatusBar/SettingsView.swift index c01902b..2fea7a0 100644 --- a/Sources/Sub2APIStatusBar/SettingsView.swift +++ b/Sources/Sub2APIStatusBar/SettingsView.swift @@ -5,6 +5,8 @@ struct SettingsView: View { @ObservedObject var model: MonitorViewModel @Environment(\.dismiss) private var dismiss + private var strings: AppStrings { AppStrings(language: model.settingsDraft.language) } + var body: some View { VStack(spacing: 0) { header @@ -52,7 +54,7 @@ struct SettingsView: View { .frame(width: 34, height: 34) .background(Color.blue.opacity(0.13), in: RoundedRectangle(cornerRadius: 8)) - Text("Settings") + Text(strings.settings) .font(.title2.bold()) Spacer() @@ -70,10 +72,10 @@ struct SettingsView: View { private var footer: some View { HStack { Spacer() - Button("Cancel") { + Button(strings.cancel) { dismiss() } - Button("Save") { + Button(strings.save) { model.saveSettings() dismiss() } @@ -89,8 +91,8 @@ struct AccountSettingsSection: View { @ObservedObject var model: MonitorViewModel var body: some View { - SettingsSection(title: "Accounts") { - SettingsControlRow(title: "Active Account") { + SettingsSection(title: AppStrings(language: model.settingsDraft.language).accounts) { + SettingsControlRow(title: AppStrings(language: model.settingsDraft.language).text(en: "Active Account", zhHans: "当前账户")) { Picker("", selection: Binding( get: { model.config.selectedAccountID ?? "" }, set: { id in @@ -114,23 +116,36 @@ struct AccountSettingsSection: View { struct GeneralSettingsSection: View { @ObservedObject var model: MonitorViewModel + private var strings: AppStrings { AppStrings(language: model.settingsDraft.language) } + var body: some View { - SettingsSection(title: "General") { + SettingsSection(title: strings.general) { VStack(spacing: 8) { - SettingsControlRow(title: "Base URL") { + SettingsControlRow(title: strings.baseURL) { TextField("https://sub2api.example.com", text: $model.settingsDraft.baseURL) .textFieldStyle(.roundedBorder) .frame(maxWidth: .infinity) } - SettingsControlRow(title: "Menu Bar") { - Toggle("Show text", isOn: $model.settingsDraft.showsMenuBarText) + SettingsControlRow(title: strings.languageSetting) { + Picker("", selection: $model.settingsDraft.language) { + ForEach(AppLanguage.allCases) { language in + Text(strings.languageDisplayName(language)).tag(language) + } + } + .pickerStyle(.segmented) + .controlSize(.small) + .frame(maxWidth: 320, alignment: .leading) + } + + SettingsControlRow(title: strings.menuBar) { + Toggle(strings.showText, isOn: $model.settingsDraft.showsMenuBarText) } - SettingsControlRow(title: "Metric") { + SettingsControlRow(title: strings.metric) { Picker("", selection: $model.settingsDraft.menuBarMetric) { ForEach(MenuBarMetric.allCases) { metric in - Text(metric.displayName).tag(metric) + Text(strings.menuBarMetricDisplayName(metric)).tag(metric) } } .pickerStyle(.segmented) @@ -139,14 +154,14 @@ struct GeneralSettingsSection: View { .frame(maxWidth: .infinity) } - SettingsControlRow(title: "Startup") { - Toggle("Launch at login", isOn: Binding( + SettingsControlRow(title: strings.startup) { + Toggle(strings.launchAtLogin, isOn: Binding( get: { model.launchAtLoginEnabled }, set: { model.setLaunchAtLogin($0) } )) } - SettingsControlRow(title: "Refresh") { + SettingsControlRow(title: strings.refresh) { Slider(value: $model.settingsDraft.refreshIntervalSeconds, in: 5...300, step: 5) .frame(maxWidth: .infinity) Text("\(Int(model.settingsDraft.refreshIntervalSeconds))s") @@ -154,8 +169,8 @@ struct GeneralSettingsSection: View { .frame(width: 42, alignment: .trailing) } - SettingsControlRow(title: "Token") { - SecureField("Bearer Token", text: $model.settingsDraft.authToken) + SettingsControlRow(title: strings.token) { + SecureField(strings.bearerToken, text: $model.settingsDraft.authToken) .textFieldStyle(.roundedBorder) .frame(maxWidth: .infinity) } @@ -172,23 +187,23 @@ struct AlertSettingsSection: View { @ObservedObject var model: MonitorViewModel var body: some View { - SettingsSection(title: "Alerts") { + SettingsSection(title: AppStrings(language: model.settingsDraft.language).alerts) { VStack(spacing: 8) { - SettingsControlRow(title: "Notify") { - Toggle("Usage insights", isOn: $model.settingsDraft.insightAlertSettings.isEnabled) + SettingsControlRow(title: AppStrings(language: model.settingsDraft.language).notify) { + Toggle(AppStrings(language: model.settingsDraft.language).usageInsights, isOn: $model.settingsDraft.insightAlertSettings.isEnabled) } - SettingsControlRow(title: "Level") { + SettingsControlRow(title: AppStrings(language: model.settingsDraft.language).text(en: "Level", zhHans: "级别")) { Picker("", selection: $model.settingsDraft.insightAlertSettings.minimumSeverity) { - Text("Warning").tag(MonitorSeverity.warning) - Text("Error only").tag(MonitorSeverity.error) + Text(AppStrings(language: model.settingsDraft.language).warning).tag(MonitorSeverity.warning) + Text(AppStrings(language: model.settingsDraft.language).errorOnly).tag(MonitorSeverity.error) } .pickerStyle(.segmented) .controlSize(.small) .frame(maxWidth: 320, alignment: .leading) } - SettingsControlRow(title: "Quiet") { + SettingsControlRow(title: AppStrings(language: model.settingsDraft.language).text(en: "Quiet", zhHans: "静默")) { Slider(value: $model.settingsDraft.insightAlertSettings.cooldownMinutes, in: 5...360, step: 5) .frame(maxWidth: .infinity) Text("\(Int(model.settingsDraft.insightAlertSettings.cooldownMinutes))m") @@ -206,7 +221,7 @@ struct InsightSettingsSection: View { @ObservedObject var model: MonitorViewModel var body: some View { - SettingsSection(title: "Insights") { + SettingsSection(title: AppStrings(language: model.settingsDraft.language).insights) { VStack(spacing: 8) { ThresholdSliderRow( title: "Quota warn", @@ -430,7 +445,7 @@ struct LoginSettingsSection: View { let dismiss: DismissAction var body: some View { - SettingsSection(title: "Login") { + SettingsSection(title: AppStrings(language: model.settingsDraft.language).login) { TextField("Email", text: $model.loginEmail) .textFieldStyle(.roundedBorder) SecureField("Password", text: $model.loginPassword) @@ -438,7 +453,7 @@ struct LoginSettingsSection: View { Button { model.loginAndSave() } label: { - Label("Login and Save Account", systemImage: "person.crop.circle.badge.plus") + Label(AppStrings(language: model.settingsDraft.language).loginAndSaveAccount, systemImage: "person.crop.circle.badge.plus") } .disabled(!LoginFormState(baseURL: model.settingsDraft.baseURL, email: model.loginEmail, password: model.loginPassword).canSubmit || model.isLoggingIn) @@ -446,7 +461,7 @@ struct LoginSettingsSection: View { model.disconnect() dismiss() } label: { - Label("Remove Current Account", systemImage: "person.crop.circle.badge.xmark") + Label(AppStrings(language: model.settingsDraft.language).removeCurrentAccount, systemImage: "person.crop.circle.badge.xmark") } .disabled(model.config.selectedAccountID == nil && model.config.authToken.isEmpty && model.settingsDraft.authToken.isEmpty) } @@ -457,20 +472,20 @@ struct DiagnosticsSettingsSection: View { @ObservedObject var model: MonitorViewModel var body: some View { - SettingsSection(title: "Diagnostics") { + SettingsSection(title: AppStrings(language: model.settingsDraft.language).diagnostics) { VStack(alignment: .leading, spacing: 8) { HStack { Button { model.copySocialShareCard() } label: { - Label("Copy Share Card", systemImage: "square.and.arrow.up") + Label(AppStrings(language: model.settingsDraft.language).copyShareCard, systemImage: "square.and.arrow.up") } .disabled(!model.snapshot.connected) Button { model.copyUsageReport() } label: { - Label("Copy Usage Report", systemImage: "chart.line.text.clipboard") + Label(AppStrings(language: model.settingsDraft.language).copyUsageReport, systemImage: "chart.line.text.clipboard") } .disabled(!model.snapshot.connected) } @@ -479,19 +494,19 @@ struct DiagnosticsSettingsSection: View { Button { model.copyDiagnostics() } label: { - Label("Copy Diagnostics", systemImage: "doc.on.doc") + Label(AppStrings(language: model.settingsDraft.language).copyDiagnostics, systemImage: "doc.on.doc") } Button { model.copySupportBundle() } label: { - Label("Copy Support Bundle", systemImage: "doc.text") + Label(AppStrings(language: model.settingsDraft.language).copySupportBundle, systemImage: "doc.text") } Button { model.revealConfigFile() } label: { - Label("Show Config", systemImage: "folder") + Label(AppStrings(language: model.settingsDraft.language).showConfig, systemImage: "folder") } } } @@ -504,7 +519,7 @@ struct UpdateSettingsSection: View { @ObservedObject var model: MonitorViewModel var body: some View { - SettingsSection(title: "Updates") { + SettingsSection(title: AppStrings(language: model.settingsDraft.language).updates) { HStack { Spacer() if model.isCheckingForUpdates { @@ -531,7 +546,7 @@ struct UpdateSettingsSection: View { .font(.callout) .foregroundStyle(.secondary) } else { - Text("Checks GitHub Releases for newer versions.") + Text(AppStrings(language: model.settingsDraft.language).githubReleaseCheck) .font(.callout) .foregroundStyle(.secondary) } @@ -540,7 +555,7 @@ struct UpdateSettingsSection: View { Button { model.checkForUpdates() } label: { - Label("Check Now", systemImage: "arrow.clockwise") + Label(AppStrings(language: model.settingsDraft.language).checkNow, systemImage: "arrow.clockwise") } .disabled(model.isCheckingForUpdates) @@ -548,7 +563,7 @@ struct UpdateSettingsSection: View { Button { model.openLatestRelease() } label: { - Label("Open Release", systemImage: "safari") + Label(AppStrings(language: model.settingsDraft.language).openRelease, systemImage: "safari") } } } @@ -559,8 +574,11 @@ struct UpdateSettingsSection: View { struct UpdateAvailableBanner: View { let info: UpdateInfo + var language: AppLanguage = .en let openRelease: () -> Void + private var strings: AppStrings { AppStrings(language: language) } + var body: some View { HStack(spacing: 10) { Image(systemName: "arrow.down.circle.fill") @@ -569,7 +587,7 @@ struct UpdateAvailableBanner: View { VStack(alignment: .leading, spacing: 2) { Text(info.statusText) .font(.callout.weight(.semibold)) - Text("Download the latest release from GitHub.") + Text(strings.downloadLatestRelease) .font(.caption) .foregroundStyle(.secondary) } @@ -580,7 +598,7 @@ struct UpdateAvailableBanner: View { Image(systemName: "safari") } .buttonStyle(.borderless) - .help("Open release") + .help(strings.openRelease) } .padding(10) .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8)) diff --git a/Sources/Sub2APIStatusBar/Sub2APIStatusBarApp.swift b/Sources/Sub2APIStatusBar/Sub2APIStatusBarApp.swift index f991499..6b0db39 100644 --- a/Sources/Sub2APIStatusBar/Sub2APIStatusBarApp.swift +++ b/Sources/Sub2APIStatusBar/Sub2APIStatusBarApp.swift @@ -58,8 +58,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { return } + let strings = AppStrings(language: model.config.language) let severity = snapshot.severity(now: model.now, refreshIntervalSeconds: model.config.refreshIntervalSeconds) - let statusLabel = snapshot.statusLabel(now: model.now, refreshIntervalSeconds: model.config.refreshIntervalSeconds) + let statusLabel = snapshot.statusLabel(now: model.now, refreshIntervalSeconds: model.config.refreshIntervalSeconds, language: model.config.language) switch severity { case .healthy: @@ -72,13 +73,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate { button.image?.isTemplate = true button.imagePosition = .imageLeading button.title = snapshot.connected && model.config.showsMenuBarText - ? " \(snapshot.menuBarSummary(metric: model.config.menuBarMetric, now: model.now, refreshIntervalSeconds: model.config.refreshIntervalSeconds))" + ? " \(snapshot.menuBarSummary(metric: model.config.menuBarMetric, now: model.now, refreshIntervalSeconds: model.config.refreshIntervalSeconds, language: model.config.language))" : "" if let stats = snapshot.stats, snapshot.connected { - button.toolTip = "Sub2API \(statusLabel) - Today \(StatusFormatters.currency(stats.todayActualCost)), RPM \(String(format: "%.1f", stats.rpm))" + button.toolTip = strings.statusTooltip(statusLabel: statusLabel, todayCost: StatusFormatters.currency(stats.todayActualCost), rpm: String(format: "%.1f", stats.rpm)) } else { - button.toolTip = "Sub2API \(statusLabel)" + button.toolTip = strings.sub2APIStatus(statusLabel) } } } From d3aa3af1319d5ea24127a128fe2c87147ba52347 Mon Sep 17 00:00:00 2001 From: maple Date: Tue, 2 Jun 2026 21:09:01 +0800 Subject: [PATCH 6/6] docs: document language setting --- CHANGELOG.md | 3 +++ README.md | 2 ++ README.zh-CN.md | 2 ++ 3 files changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc7a8bc..b04e2ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- Added an in-app language setting with Auto, English, and Simplified Chinese options. +- Localized the main app UI, menu bar status text, local insight notifications, recovery guidance, usage reports, diagnostics, support bundles, and social share copy/card labels. + ## v0.1.11 - Added a Simplified Chinese README and language switch links between English and Chinese docs. diff --git a/README.md b/README.md index b7e9cf2..0631acf 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Sub2API Status Bar is a macOS menu bar companion for Sub2API users. It keeps dai ## Highlights - Native macOS menu bar app with a compact SwiftUI popover +- In-app language setting with Auto, English, and Simplified Chinese options - Usage Insights that turn balance, quotas, monthly budget, spend trend, usage trend, model concentration, and latency into prioritized signals - Local proactive alerts for warning/error insights, with severity and quiet-period controls - Stale-data guardrail so the menu bar and local alerts warn when the last successful refresh is too old @@ -81,6 +82,7 @@ To switch accounts or remove saved credentials, open Settings and choose **Disco Settings also includes: +- **Language** to use Auto, English, or Simplified Chinese for app UI, menu bar status, local alerts, reports, diagnostics, support bundles, and share cards - **Show text in menu bar** for a compact always-visible usage summary - **Metric** to choose whether the menu bar emphasizes Auto, Spend, Balance, Quota, Tokens, or Requests - **Notify on insights** to receive local macOS alerts when important usage signals cross the configured level diff --git a/README.zh-CN.md b/README.zh-CN.md index 27a4310..e9416ab 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -9,6 +9,7 @@ Sub2API Status Bar 是一款面向 Sub2API 用户的 macOS 菜单栏助手。它 ## 亮点 - 原生 macOS 菜单栏应用,带紧凑的 SwiftUI 弹窗 +- 应用内语言设置,支持 Auto、English 和简体中文 - Usage Insights 会把余额、额度、月度预算、花费趋势、用量趋势、模型成本集中度和延迟转成优先级信号 - 本地主动提醒,支持 warning/error 级别和静默间隔设置 - 数据过期保护:最近一次刷新太久时,菜单栏和本地提醒会提示数据已陈旧 @@ -81,6 +82,7 @@ swift run Sub2APIStatusBar Settings 还包含: +- **Language**:为应用 UI、菜单栏状态、本地提醒、报告、诊断、支持包和分享卡片选择 Auto、English 或简体中文 - **Show text in menu bar**:在菜单栏显示紧凑用量摘要 - **Metric**:选择菜单栏优先展示 Auto、Spend、Balance、Quota、Tokens 或 Requests - **Notify on insights**:重要用量信号达到设定级别时发送本地 macOS 通知