diff --git a/.gitignore b/.gitignore index f8cf874..91b2124 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ release.jks .kotlin/ *.jks tmp_cc/ +goal.md diff --git a/AGENTS.md b/AGENTS.md index c25b15b..ee7f4c2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,14 @@ # 雨晴Token — AI 余额查询 APP +## ⚠️ 提交/推送许可红线(最高优先级) + +> 本仓库的所有 Git 写操作(`git commit`、`git push`、创建/合并 PR、推送 tag 等)**必须事先获得水晴喵的明确同意**,未经允许一律禁止执行。 +> +> - 任何代码修改完成后,只做本地编辑,**不得擅自 commit / push**。需要提交时,先向水晴喵说明改动内容与提交意图,等她说「可以提交」「push 吧」之类的明确许可后再执行。 +> - 不得以「PR 审查」「CI 验证」等理由绕过许可——创建 PR、推送分支也属于写操作,同样需要先征求同意。 +> - 水晴喵说「不用了」「先别提交」「再看看」时,保持当前状态,不 push 任何东西。 +> - 这条规则优先级高于文件中其它任何流程(包括「重大修改 PR 审计红线」),其它规则只决定**怎么提交**,这条决定**能不能提交**。 + ## 项目概述 Android(Jetpack Compose + Kotlin)APP,统一查询 DeepSeek、OpenCode Go、CommandCode Go、Codex / ChatGPT Plus、Ollama Pro 的余额/配额。 diff --git a/README.en.md b/README.en.md new file mode 100644 index 0000000..b90c814 --- /dev/null +++ b/README.en.md @@ -0,0 +1,224 @@ +# RainyToken (雨晴Token) + +> *"AI Balance & Usage at a Glance"* + +[![CI](https://github.com/CATMIAOZHI/Rainytoken/actions/workflows/ci.yml/badge.svg)](https://github.com/CATMIAOZHI/Rainytoken/actions/workflows/ci.yml) +[![Release](https://github.com/CATMIAOZHI/Rainytoken/actions/workflows/release.yml/badge.svg)](https://github.com/CATMIAOZHI/Rainytoken/actions) +[![Version](https://img.shields.io/github/v/release/CATMIAOZHI/Rainytoken?color=ff85a2)](https://github.com/CATMIAOZHI/Rainytoken/releases) +Android app for checking AI balance & usage — unified view of DeepSeek, OpenCode Go, CommandCode Go, Codex / ChatGPT Plus, and Ollama Pro balances and usage quotas. Pink-toned brand UI, with companion home screen widgets. +RainyToken (雨晴Token) — AI Balance & Usage Quota Query · the Rainy Family tools. + +--- + +## 📸 Screenshots + +

+ Dashboard (light) + Dashboard (dark) + Usage charts + Ollama Pro model call counts +

+ +

+ Dashboard (light) · Dashboard (dark) · Usage charts · Ollama Pro model call counts +

+ +

+ Home screen widget + Ollama Pro card +

+ +

+ Home screen widget · Ollama Pro home card +

+ +--- + +## ✨ Features + +| Feature | Description | +|------|------| +| 📊 **Dashboard** | DeepSeek balance (¥) + usage/balance cards for each service; OCGO/CCGO cards link directly to usage details; long-press & drag to reorder (persisted); pull-to-refresh globally · tablet-adaptive two-pane layout | +| 📈 **Usage charts** | 3 Canvas-drawn charts — amount spent / API request count / token consumption (dual data sources: OCGO & CCGO); UTC+0/UTC+8 timezone switching and custom day/month/range; automatic fallback (no data in last 5h → 12h → 7 days → current month); side-by-side on tablets | +| 📱 **Tablet adaptation** | Global `BoxWithConstraints` adaptive container width; ≥600dp dual-column cards, ≥700dp side-by-side charts; two-pane 35/65 split (Expanded mode); supports Android 13+ predictive back gestures | +| 📋 **Detailed data** | Paginated browsing of raw records with time + model filtering; tap to view full fields | +| 🔍 **Multi-granularity filters** | 5 hours / 12 hours (10-minute buckets) / 24 hours / today / yesterday / last 7 days / current month / custom day · month · range | +| 🏷️ **Model filtering** | Multi-select / single-select / select all, with dynamic legends that wrap automatically | +| 📱 **Home screen widget** | Check usage without opening the app; switch between four services (OCGO/CCGO/Codex/Ollama) + DeepSeek balance; top-left corner → open app, tap elsewhere to switch, ↻ to refresh; can be added to the At a Glance screen; auto-refreshes when scrolled into view (MIUI exposure refresh) | +| 🔄 **Auto sync** | Pull-to-refresh on home auto-syncs usage; auto full sync on startup when no cache; CCGO detail page supports manual clear & re-sync | +| 🌙 **Dark mode** | Fully adaptive — in-app text/icons/background switch automatically; widget adapts independently with a dark layout | +| ➕ **One-tap add to home screen** | Tap + inside the app to add the widget directly, no need to browse the system list; double confirmation + permission check | +| 💡 **Usage tips** | Home page shows a random operation tip (refreshed on each launch); settings page lists all 13 hidden tips | +| ⚡ **Room database** | Usage records stored in Room (indexed on workspaceId+timeCreated); DAO queries replace full JSON serialization; auto-migration from the legacy DataStore on first launch | +| 🎀 **Rainy pink theme** | Material Design 3 · Strawberry Pink #FF85A2 · Sakura Pink #FFD1DC | +| 🔐 **Codex OAuth sign-in** | Headless OAuth PKCE: the app generates an authorization link → sign in in an external browser → paste the callback URL to complete authorization; no manual auth.json export needed | +| ⚡ **Codex one-tap usage activation** | Codex detail page can send a short request to the ChatGPT API to trigger usage tracking; model list fetched dynamically from models.dev and persisted, with manual refresh; response dialog is copyable | +| ⚡ **OCGO / Ollama one-tap usage activation** | OCGO detail page can send a request to `opencode.ai/zen/v1`, Ollama detail page to `ollama.com/v1` to trigger usage tracking; API keys are entered manually in settings; model lists fetched dynamically from models.dev | +| 🐛 **Debug logs** | In-app "Debug Logs" page showing detailed logs of Repository network requests, token refreshes, parse errors, etc.; no computer needed | +| 🌐 **Multilingual** | Simplified Chinese / 繁體中文 / English interface; one-tap switching in settings (follow system / Simplified / Traditional / English); two-way sync with the system "App languages" page on Android 13+; non-CN/EN system languages fall back to English; home screen app name and widget follow the language | + +--- + +## 📦 Download + +Download the latest APK from [Releases](https://github.com/CATMIAOZHI/Rainytoken/releases). + +> ⚠️ You need a DeepSeek API Key, OpenCode Go login credentials, CommandCode Go API Key, Codex (OAuth sign-in or pasted auth.json), or an Ollama Pro Cookie to fetch data. + +--- + +## 🏗️ Architecture + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ Android App │ +│ │ +│ ┌────────────────────────────────────────────────────────────┐ │ +│ │ Compose UI (3-layer screens) │ │ +│ │ Dashboard · Usage Charts · Totals · Detail · Settings │ │ +│ └──────────────────────────────┬─────────────────────────────┘ │ +│ │ │ +│ ┌──────────────────────────────▼─────────────────────────────┐ │ +│ │ ViewModel Layer (MVVM) │ │ +│ │ DashboardVM · UsageVM · UsageChartVM │ │ +│ │ · UsageDataVM (Hilt injected) │ │ +│ └──────────────────────────────┬─────────────────────────────┘ │ +│ │ │ +│ ┌──────────────────────────────▼─────────────────────────────┐ │ +│ │ UseCase Layer │ │ +│ │ RefreshBalanceUseCase (balance) │ │ +│ │ SyncUsageUseCase / SyncCommandCodeUsageUseCase │ │ +│ └──────────────────────────────┬─────────────────────────────┘ │ +│ │ │ +│ ┌──────────────────────────────▼─────────────────────────────┐ │ +│ │ Repository + Network │ │ +│ │ DeepSeekApi (Retrofit) · OpenCodeGo web scraping │ │ +│ │ · OpenCodeUsageRepository · CommandCodeUsageRepository │ │ +│ │ · CodexRepository · OllamaRepository (OkHttp) │ │ +│ └──────────────────────────────┬─────────────────────────────┘ │ +│ │ │ +│ ┌──────────────────────────────▼─────────────────────────────┐ │ +│ │ Local Storage │ │ +│ │ BalanceCache (DataStore) │ │ +│ │ UsageCache (Room, indexed on workspaceId+timeCreated) │ │ +│ │ CredentialRepository (Keystore AES-256 GCM) │ │ +│ └────────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 📁 Project Structure + +``` +Rainytoken/ +├── app/src/main/java/com/rainy/token/ +│ ├── data/ +│ │ ├── cache/ # BalanceCache (DataStore) +│ │ ├── local/ # UsageCache (Room), UsageRecordEntity, UsageDao, UsageDatabase, ChartBucket +│ │ ├── remote/ # DeepSeekApi (Retrofit) + OpenCodeGo scraping + UsageRepository +│ │ └── repository/ # DeepSeek / OpenCodeGo / CommandCode / Codex / Ollama / Credential repositories +│ ├── domain/ +│ │ ├── model/ # ServiceBalance, Credential, etc. +│ │ ├── service/ # ServiceType enum +│ │ └── usecase/ # RefreshBalanceUseCase / SyncUsageUseCase / SyncCommandCodeUsageUseCase +│ ├── ui/ +│ │ ├── dashboard/ # DashboardScreen / UsageDetailScreen / UsageOverviewScreen / UsageDataScreen +│ │ ├── widget/ # Home screen widget (OpenCodeGoWidgetProvider) +│ │ ├── components/ # ServiceIcon / StatusChip, etc. +│ │ ├── theme/ # Rainy pink theme (StrawberryPink / InkMuted) +│ │ └── RainyTokenNavHost.kt # Navigation routes +│ └── di/ # Hilt modules +├── gradle/libs.versions.toml # Version Catalog dependency management +├── build.gradle.kts # Project-level configuration +└── settings.gradle.kts # Project settings +``` + +--- + +## 🛠️ Build + +### Option 1: Android Studio (recommended) +1. Clone the repository: `git clone https://github.com/CATMIAOZHI/Rainytoken.git` +2. Open the project in Android Studio +3. Sync Gradle, connect a device, and hit Run ▶️ + +### Option 2: Command line +```bash +# Run unit tests +./gradlew testDebugUnitTest + +# Build a Debug APK +./gradlew assembleDebug +# APK output: app/build/outputs/apk/debug/app-debug.apk + +# Build a Release APK +./gradlew assembleRelease +``` + +
+🔧 ARM64 environment notes (optional) + +The project bundles an ARM64 AAPT2 binary, which is enabled automatically in ARM64 environments such as Proot/Termux: +```bash +chmod +x ./setup_android_env.sh +./setup_android_env.sh +``` +The script configures `$ANDROID_HOME` and uses the project-bundled ARM64 build-tools. + +**Extra Release build configuration**: Under ARM64 Proot, AGP 9.0 passes `--resource-path-shortening-map=` (equals-sign syntax) to `optimizeReleaseResources`, which the ARM64 AAPT2 does not accept. Configure the AAPT2 wrapper in `~/.gradle/gradle.properties` (global, not committed to the repository): +```properties +android.aapt2FromMavenOverride=/path/to/android-aapt2-wrapper/aapt2 +``` +The wrapper script splits the equals-sign syntax into two separate space-delimited argv entries. The project's `build.gradle.kts` includes a built-in `guardReleaseResources` task as a build safeguard (falls back to the linked `.ap_` automatically when the optimized `.ap_` is missing). +> On x86_64 environments (GitHub Actions / regular Linux), the official AAPT2 is used automatically — no extra steps required. +
+ +--- + +## 📦 Dependency Management + +The project manages all dependencies with a Gradle Version Catalog (`gradle/libs.versions.toml`). + +| Dependency | Purpose | +|------|------| +| `androidx.compose:compose-bom` | Jetpack Compose BOM | +| `androidx.navigation:navigation-compose` | Page navigation | +| `com.squareup.retrofit2:retrofit` | DeepSeek REST API | +| `com.squareup.okhttp3:okhttp` | OpenCode Go web scraping | +| `org.jetbrains.kotlinx:kotlinx-serialization-json` | JSON serialization | +| `androidx.room:room-runtime` | Local usage database (Room) | +| `androidx.datastore:datastore-preferences` | Local caching | +| `com.google.dagger:hilt-android` | Dependency injection | +| `com.google.devtools.ksp:symbol-processing-api` | KSP annotation processing | + +--- + +## 🔒 Security + +- ✅ API keys / session credentials are stored in the **Android Keystore** (AES-256 GCM encrypted) +- ✅ Network requests are only sent to the official DeepSeek / OpenCode APIs +- ✅ `allowBackup=false`: encrypted credentials and usage data are excluded from system backups, preventing undecryptable ciphertext when restoring on a new device +- ✅ Fixed signing key — every Release can be installed over the previous version +- ✅ Signing keys and passwords are stored encrypted in GitHub Secrets and decrypted during CI + +--- + +## 🐱 About + +RainyToken (雨晴Token) is the 4th member of the "Rainy Family" tools, maintained by [雨晴喵 (Rainy)](https://github.com/CATMIAOZHI), released alongside the [Rainy Family](https://github.com/CATMIAOZHI?tab=repositories): + +- [RainyLLM](https://github.com/CATMIAOZHI/RainyLLM) — fully offline Android local LLM inference server (Gemma + OpenAI-compatible API) +- [RainyScanner](https://github.com/CATMIAOZHI/RainyScanner) — Android QR scanner that neither intercepts nor redirects +- [Rainy2FA](https://github.com/CATMIAOZHI/Rainy2FA) — fully local · zero network · biometric-protected TOTP authenticator +- **RainyToken** — AI Balance & Usage Quota Query (this project) + +--- + +## 📄 License + +MIT License © 2026 Rainy + +--- + +

RainyToken · the Rainy Family tools

diff --git a/README.md b/README.md index bd23c2b..5723e06 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # 🌧️ 雨晴Token (RainyToken) -> *"AI 用量,尽在掌握 — AI Balance & Usage at a Glance"* 🐱✨ +> *"AI 用量,尽在掌握 — AI Balance & Usage at a Glance"* [![CI](https://github.com/CATMIAOZHI/Rainytoken/actions/workflows/ci.yml/badge.svg)](https://github.com/CATMIAOZHI/Rainytoken/actions/workflows/ci.yml) [![Release](https://github.com/CATMIAOZHI/Rainytoken/actions/workflows/release.yml/badge.svg)](https://github.com/CATMIAOZHI/Rainytoken/actions) [![Version](https://img.shields.io/github/v/release/CATMIAOZHI/Rainytoken?color=ff85a2)](https://github.com/CATMIAOZHI/Rainytoken/releases) Android AI 余额与用量查询 APP —— 统一查看 DeepSeek、OpenCode Go、CommandCode Go、Codex / ChatGPT Plus、Ollama Pro 的余额与用量配额。粉色调品牌 UI,配套桌面小组件。 -🐱 雨晴Token — AI Balance & Usage Quota Query | the Rainy Family tools. +RainyToken(雨晴Token)— AI Balance & Usage Quota Query · the Rainy Family tools. --- @@ -55,6 +55,7 @@ Android AI 余额与用量查询 APP —— 统一查看 DeepSeek、OpenCode Go | ⚡ **Codex 一键激活用量** | Codex 详情页可向 ChatGPT API 发送简短请求触发用量统计;模型列表从 models.dev 动态获取并持久化,支持手动刷新;响应弹窗可复制 | | ⚡ **OCGO / Ollama 一键激活用量** | OCGO 详情页可向 `opencode.ai/zen/v1` 发送请求,Ollama 详情页可向 `ollama.com/v1` 发送请求触发用量统计;API Key 在设置页手动填写,模型列表从 models.dev 动态获取 | | 🐛 **调试日志** | APP 内「调试日志」页面,查看 Repository 网络请求、Token 刷新、解析错误等详细日志,无需连接电脑 | +| 🌐 **多语言** | 简体中文 / 繁體中文 / English 三语言界面;设置页一键切换(跟随系统 / 简体 / 繁体 / English);Android 13+ 与系统「应用语言」页双向同步;非中英系统语言自动回退英文;桌面 APP 名称与小组件跟随语言 | --- @@ -204,14 +205,14 @@ wrapper 脚本将等号形式拆分为空格分隔的两个独立 argv。项目 ## 🐱 关于 -RainyToken(雨晴Token)是「雨晴系列」的第 4 个成员,由 [雨晴喵](https://github.com/CATMIAOZHI) 编纂,随 [雨晴系列](https://github.com/CATMIAOZHI?tab=repositories) 发布: +RainyToken(雨晴Token)是「雨晴系列」的第 4 个成员,由 [雨晴喵](https://github.com/CATMIAOZHI) 开发维护,随 [雨晴系列](https://github.com/CATMIAOZHI?tab=repositories) 发布: - [RainyLLM](https://github.com/CATMIAOZHI/RainyLLM) — 纯离线 Android 本地 LLM 推理服务器(Gemma + OpenAI 兼容 API) - [RainyScanner](https://github.com/CATMIAOZHI/RainyScanner) — 不拦截不跳转的 Android 扫码工具 - [Rainy2FA](https://github.com/CATMIAOZHI/Rainy2FA) — 纯本地 · 零联网 · 生物识别保护的 TOTP 验证器 - **RainyToken** — AI 余额与用量查询 APP | AI Balance & Usage Quota Query(本项目) -> 🎀 Made with love by 雨晴喵 — 守护主人的每一分算力预算 💖 +> 守护每一分 AI 算力预算 💖 --- @@ -221,4 +222,4 @@ MIT License © 2026 Rainy --- -

💖 Made with love by 雨晴喵

\ No newline at end of file +

RainyToken · the Rainy Family tools

\ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ca55121..d50923a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -76,6 +76,10 @@ android { buildFeatures { compose = true } + androidResources { + // Android 13+ 应用级语言设置:根据 values-* 目录自动生成 locales_config + generateLocaleConfig = true + } packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" diff --git a/app/src/main/java/com/rainy/token/MainActivity.kt b/app/src/main/java/com/rainy/token/MainActivity.kt index 98065c8..98ab02e 100644 --- a/app/src/main/java/com/rainy/token/MainActivity.kt +++ b/app/src/main/java/com/rainy/token/MainActivity.kt @@ -1,5 +1,6 @@ package com.rainy.token +import android.content.Context import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -20,6 +21,11 @@ import dagger.hilt.android.AndroidEntryPoint */ @AndroidEntryPoint class MainActivity : ComponentActivity() { + override fun attachBaseContext(newBase: Context) { + // 应用内语言偏好在此层生效:recreate() 后资源按所选语言重新解析 + super.attachBaseContext(com.rainy.token.util.LocaleManager.wrapContext(newBase)) + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() diff --git a/app/src/main/java/com/rainy/token/RainyTokenApplication.kt b/app/src/main/java/com/rainy/token/RainyTokenApplication.kt index 6b981ec..1939403 100644 --- a/app/src/main/java/com/rainy/token/RainyTokenApplication.kt +++ b/app/src/main/java/com/rainy/token/RainyTokenApplication.kt @@ -12,6 +12,12 @@ import dagger.hilt.android.HiltAndroidApp */ @HiltAndroidApp class RainyTokenApplication : Application() { + override fun attachBaseContext(base: Context) { + // 应用内语言偏好(跟随系统 / 中文 / English)在 Application 层生效, + // 保证 appContext 与系统级回调(如小组件)之外的代码都使用所选语言。 + super.attachBaseContext(com.rainy.token.util.LocaleManager.wrapContext(base)) + } + override fun onCreate() { super.onCreate() appContext = applicationContext diff --git a/app/src/main/java/com/rainy/token/data/local/ChartBucket.kt b/app/src/main/java/com/rainy/token/data/local/ChartBucket.kt index 348862b..b73007e 100644 --- a/app/src/main/java/com/rainy/token/data/local/ChartBucket.kt +++ b/app/src/main/java/com/rainy/token/data/local/ChartBucket.kt @@ -1,5 +1,7 @@ package com.rainy.token.data.local +import androidx.annotation.StringRes +import com.rainy.token.R import java.time.Instant import java.time.ZoneOffset @@ -25,18 +27,18 @@ data class ModelBucket( val outputTokens: Long ) -/** 时间粒度 */ -enum class ChartGranularity(val label: String) { - LAST_5H_HOURLY("最近5小时"), - LAST_12H_10MIN("最近12小时"), - LAST_24H_HOURLY("最近24小时"), - TODAY_HOURLY("今天"), - YESTERDAY_HOURLY("昨天"), - LAST_7D_DAILY("最近7天"), - THIS_MONTH_DAILY("当月"), - CUSTOM_DAY_HOURLY("自定义日"), - CUSTOM_MONTH_DAILY("自定义月"), - CUSTOM_RANGE_DAILY("自定义日期") +/** 时间粒度(label 用字符串资源 ID,UI 层按当前语言解析) */ +enum class ChartGranularity(@StringRes val labelRes: Int) { + LAST_5H_HOURLY(R.string.time_last_5h), + LAST_12H_10MIN(R.string.time_last_12h), + LAST_24H_HOURLY(R.string.time_last_24h), + TODAY_HOURLY(R.string.time_today), + YESTERDAY_HOURLY(R.string.time_yesterday), + LAST_7D_DAILY(R.string.time_last_7d), + THIS_MONTH_DAILY(R.string.time_this_month), + CUSTOM_DAY_HOURLY(R.string.time_custom_day), + CUSTOM_MONTH_DAILY(R.string.time_custom_month), + CUSTOM_RANGE_DAILY(R.string.time_custom_range) } /** 聚合工具 */ diff --git a/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt b/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt index c399033..a33847d 100644 --- a/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt @@ -3,6 +3,7 @@ package com.rainy.token.data.repository import com.rainy.token.data.cache.BalanceCache import com.rainy.token.domain.model.Credential import com.rainy.token.domain.model.ServiceBalance +import com.rainy.token.domain.model.TriggerSummary import com.rainy.token.domain.service.ServiceConfigProvider import com.rainy.token.domain.service.ServiceType import kotlinx.coroutines.Dispatchers @@ -114,9 +115,9 @@ class CodexRepository( parseUsageWindows(usageResult) } catch (e: Exception) { DebugLog.e(TAG, "解析 Codex 用量失败: ${e::class.simpleName}: ${e.message}") - return@withContext Result.failure(RepositoryError.ParseError("Codex 用量响应格式异常: ${e.message ?: e::class.simpleName}")) + return@withContext Result.failure(RepositoryError.ParseError(RepositoryError.ParseErrorReason.MALFORMED_RESPONSE, "Codex 用量响应格式异常: ${e.message ?: e::class.simpleName}")) } - if (windows.isEmpty()) return@withContext Result.failure(RepositoryError.ParseError("未找到 Codex 用量窗口数据")) + if (windows.isEmpty()) return@withContext Result.failure(RepositoryError.ParseError(RepositoryError.ParseErrorReason.NO_WINDOWS, "未找到 Codex 用量窗口数据")) val config = ServiceConfigProvider.get(ServiceType.CODEX) val primary = windows.firstOrNull { it.label.contains("h") } ?: windows.first() @@ -154,16 +155,16 @@ class CodexRepository( DebugLog.e(TAG, "fetchModels: HTTP ${resp.code}") return@withContext Result.failure(RepositoryError.ServerError(resp.code)) } - val root = json.parseToJsonElement(resp.body?.string() ?: throw RepositoryError.ParseError("响应体为空")) as? JsonObject - ?: throw RepositoryError.ParseError("响应根节点不是 JSON 对象") + val root = json.parseToJsonElement(resp.body?.string() ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空")) as? JsonObject + ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.NOT_JSON_OBJECT, "响应根节点不是 JSON 对象") // 结构: { "openai": { "models": { "gpt-5.6": {...}, ... } } } val openaiProvider = root["openai"] as? JsonObject val modelsObj = openaiProvider?.get("models") as? JsonObject modelsObj?.keys?.toList()?.sorted() - ?: throw RepositoryError.ParseError("未找到 OpenAI 模型列表") + ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.NO_MODELS, "未找到 OpenAI 模型列表") } if (models.isEmpty()) { - return@withContext Result.failure(RepositoryError.ParseError("模型列表为空")) + return@withContext Result.failure(RepositoryError.ParseError(RepositoryError.ParseErrorReason.MODELS_EMPTY, "模型列表为空")) } DebugLog.i(TAG, "fetchModels: 获取到 ${models.size} 个模型") Result.success(models) @@ -203,7 +204,7 @@ class CodexRepository( * @param model 用户选择的模型 slug * @return Result.success(响应体原文) — 供 UI 展示 */ - suspend fun triggerUsage(model: String): Result = withContext(Dispatchers.IO) { + suspend fun triggerUsage(model: String): Result = withContext(Dispatchers.IO) { val credential = credentialRepository.get(ServiceType.CODEX) ?: return@withContext Result.failure(RepositoryError.InvalidCredential("未找到 Codex 凭据")) if (credential !is Credential.CodexCredential) @@ -239,10 +240,10 @@ class CodexRepository( DebugLog.i(TAG, "triggerUsage retry: HTTP ${resp2.code}, body=${body2.take(500)}") if (!resp2.isSuccessful) { DebugLog.e(TAG, "triggerUsage retry failed: HTTP ${resp2.code}") - return@use Result.failure( + return@use Result.failure( TriggerError( "HTTP ${resp2.code}", - body2.ifBlank { "无响应体" } + body2.ifBlank { "" } ) ) } @@ -251,12 +252,12 @@ class CodexRepository( } is RefreshResult.Failure -> { DebugLog.e(TAG, "triggerUsage: 二次刷新失败: ${r.reason}") - return@use Result.failure(TriggerError("token 刷新失败: ${r.reason}", "")) + return@use Result.failure(TriggerError("token 刷新失败: ${r.reason}", "", TriggerErrorReason.TOKEN_REFRESH)) } } } else { - return@use Result.failure( - TriggerError("HTTP ${resp.code}", bodyStr.ifBlank { "无响应体" }) + return@use Result.failure( + TriggerError("HTTP ${resp.code}", bodyStr.ifBlank { "" }) ) } } else { @@ -385,22 +386,28 @@ class CodexRepository( DebugLog.e(TAG, "fetchJson failed: HTTP ${it.code} ${it.message} url=$url") throw if (it.code in listOf(401, 403)) RepositoryError.InvalidCredential("HTTP ${it.code}") else RepositoryError.ServerError(it.code) } - return (json.parseToJsonElement(it.body?.string() ?: throw RepositoryError.ParseError("响应体为空")) as? JsonObject) - ?: throw RepositoryError.ParseError("响应根节点不是 JSON 对象") + return (json.parseToJsonElement(it.body?.string() ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空")) as? JsonObject) + ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.NOT_JSON_OBJECT, "响应根节点不是 JSON 对象") } } @Serializable data class OAuthRefreshResponse(@kotlinx.serialization.SerialName("access_token") val accessToken: String, @kotlinx.serialization.SerialName("refresh_token") val refreshToken: String? = null, @kotlinx.serialization.SerialName("expires_in") val expiresIn: Long = 3600, @kotlinx.serialization.SerialName("token_type") val tokenType: String? = null) } +/** TriggerError 的结构化原因(UI 层据此映射本地化文案;null = HTTP 错误,summary 为 ASCII 可直显) */ +enum class TriggerErrorReason { + /** token 刷新失败(summary 中为中文用户提示,不直接透传 UI) */ + TOKEN_REFRESH +} + /** 携带完整响应体的错误类,供 UI 展示服务端返回的详细信息 */ -class TriggerError(val summary: String, val responseBody: String) : Exception(summary) +class TriggerError(val summary: String, val responseBody: String, val reason: TriggerErrorReason? = null) : Exception(summary) /** * 将 SSE 流响应解析为简洁的文本摘要。 * 提取:模型回复文本、用量统计(input/output tokens)。 */ -internal fun parseSseResponse(sseText: String, model: String): String { +internal fun parseSseResponse(sseText: String, model: String): TriggerSummary { val sseJson = Json { ignoreUnknownKeys = true } val outputText = StringBuilder() var inputTokens: String? = null @@ -429,16 +436,11 @@ internal fun parseSseResponse(sseText: String, model: String): String { } catch (_: Exception) { } } - val sb = StringBuilder() - sb.appendLine("✓ 激活成功") - sb.appendLine("模型: $model") - if (responseId != null) sb.appendLine("Response ID: $responseId") - sb.appendLine() - sb.appendLine("回复:") - sb.appendLine(outputText.ifEmpty { "(空)" }) - if (inputTokens != null || outputTokens != null) { - sb.appendLine() - sb.appendLine("用量: input=$inputTokens output=$outputTokens tokens") - } - return sb.toString().trim() + return TriggerSummary( + model = model, + reply = outputText.toString(), + inputTokens = inputTokens, + outputTokens = outputTokens, + responseId = responseId + ) } \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/data/repository/CommandCodeUsageRepository.kt b/app/src/main/java/com/rainy/token/data/repository/CommandCodeUsageRepository.kt index 12ea98b..2ae118c 100644 --- a/app/src/main/java/com/rainy/token/data/repository/CommandCodeUsageRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/CommandCodeUsageRepository.kt @@ -106,7 +106,7 @@ class CommandCodeUsageRepository( } if (body == null) return@withContext Result.failure( - RepositoryError.ParseError("响应体为空") + RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空") ) val records = parseUsageResponse(body) diff --git a/app/src/main/java/com/rainy/token/data/repository/OllamaRepository.kt b/app/src/main/java/com/rainy/token/data/repository/OllamaRepository.kt index 0cd1a1b..a401d65 100644 --- a/app/src/main/java/com/rainy/token/data/repository/OllamaRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/OllamaRepository.kt @@ -4,6 +4,7 @@ import com.rainy.token.data.cache.BalanceCache import com.rainy.token.data.debug.DebugLog import com.rainy.token.domain.model.Credential import com.rainy.token.domain.model.ServiceBalance +import com.rainy.token.domain.model.TriggerSummary import com.rainy.token.domain.service.ServiceType import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -177,13 +178,14 @@ class OllamaRepository( } val html = resp.body?.string() ?: return@withContext Result.failure( - RepositoryError.ParseError("响应体为空") + RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空") ) val parsed = parseUsage(html) if (parsed == null) { return@withContext Result.failure( RepositoryError.ParseError( + RepositoryError.ParseErrorReason.NO_WINDOWS, "解析失败:未找到 Ollama Cloud 用量数据。HTML=${html.length} 字节。Cookie 可能已过期。" ) ) @@ -240,15 +242,15 @@ class OllamaRepository( DebugLog.e(TAG, "fetchModels: HTTP ${resp.code}") return@withContext Result.failure(RepositoryError.ServerError(resp.code)) } - val root = json.parseToJsonElement(resp.body?.string() ?: throw RepositoryError.ParseError("响应体为空")) as? JsonObject - ?: throw RepositoryError.ParseError("响应根节点不是 JSON 对象") + val root = json.parseToJsonElement(resp.body?.string() ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空")) as? JsonObject + ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.NOT_JSON_OBJECT, "响应根节点不是 JSON 对象") val provider = root["ollama-cloud"] as? JsonObject val modelsObj = provider?.get("models") as? JsonObject modelsObj?.keys?.toList()?.sorted() - ?: throw RepositoryError.ParseError("未找到 Ollama Cloud 模型列表") + ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.NO_MODELS, "未找到 Ollama Cloud 模型列表") } if (models.isEmpty()) { - return@withContext Result.failure(RepositoryError.ParseError("模型列表为空")) + return@withContext Result.failure(RepositoryError.ParseError(RepositoryError.ParseErrorReason.MODELS_EMPTY, "模型列表为空")) } DebugLog.i(TAG, "fetchModels: 获取到 ${models.size} 个模型") Result.success(models) @@ -268,7 +270,7 @@ class OllamaRepository( * @param model 用户选择的模型 slug * @return Result.success(响应摘要文本) — 供 UI 展示 */ - suspend fun triggerUsage(model: String): Result = withContext(Dispatchers.IO) { + suspend fun triggerUsage(model: String): Result = withContext(Dispatchers.IO) { val credential = credentialRepository.get(ServiceType.OLLAMA) ?: return@withContext Result.failure(RepositoryError.InvalidCredential("未找到 Ollama 凭据")) if (credential !is Credential.SessionCredential) @@ -296,8 +298,8 @@ class OllamaRepository( val bodyStr = resp.body?.string() ?: "" DebugLog.i(TAG, "triggerUsage: HTTP ${resp.code}, body=${bodyStr.take(500)}") if (!resp.isSuccessful) { - return@withContext Result.failure( - TriggerError("HTTP ${resp.code}", bodyStr.ifBlank { "无响应体" }) + return@withContext Result.failure( + TriggerError("HTTP ${resp.code}", bodyStr.ifBlank { "" }) ) } DebugLog.i(TAG, "triggerUsage: 请求成功,模型=$model") @@ -317,7 +319,7 @@ class OllamaRepository( /** * 解析 Ollama Cloud chat completions 响应,提取回复文本和用量统计。 */ -internal fun parseOllamaChatResponse(responseBody: String, model: String): String { +internal fun parseOllamaChatResponse(responseBody: String, model: String): TriggerSummary { val json = Json { ignoreUnknownKeys = true } return try { val root = json.parseToJsonElement(responseBody) as? JsonObject @@ -330,18 +332,14 @@ internal fun parseOllamaChatResponse(responseBody: String, model: String): Strin val completionTokens = (usage?.get("completion_tokens") as? JsonPrimitive)?.contentOrNull val totalTokens = (usage?.get("total_tokens") as? JsonPrimitive)?.contentOrNull - val sb = StringBuilder() - sb.appendLine("✓ 激活成功") - sb.appendLine("模型: $model") - sb.appendLine() - sb.appendLine("回复:") - sb.appendLine(content?.ifEmpty { "(空)" } ?: "(无回复内容)") - if (promptTokens != null || completionTokens != null) { - sb.appendLine() - sb.appendLine("用量: prompt=$promptTokens completion=$completionTokens total=$totalTokens tokens") - } - sb.toString().trim() + TriggerSummary( + model = model, + reply = content, + inputTokens = promptTokens, + outputTokens = completionTokens, + totalTokens = totalTokens + ) } catch (_: Exception) { - "✓ 激活成功\n模型: $model\n\n(响应解析失败,但请求已发出)" + TriggerSummary(model = model, reply = null, parseFailed = true) } } \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/data/repository/OpenCodeGoRepository.kt b/app/src/main/java/com/rainy/token/data/repository/OpenCodeGoRepository.kt index a7738f4..fb56f8d 100644 --- a/app/src/main/java/com/rainy/token/data/repository/OpenCodeGoRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/OpenCodeGoRepository.kt @@ -4,6 +4,7 @@ import com.rainy.token.data.cache.BalanceCache import com.rainy.token.data.debug.DebugLog import com.rainy.token.domain.model.Credential import com.rainy.token.domain.model.ServiceBalance +import com.rainy.token.domain.model.TriggerSummary import com.rainy.token.domain.service.ServiceConfigProvider import com.rainy.token.domain.service.ServiceType import kotlinx.coroutines.Dispatchers @@ -85,13 +86,14 @@ class OpenCodeGoRepository( } val html = resp.body?.string() ?: return@withContext Result.failure( - RepositoryError.ParseError("响应体为空") + RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空") ) val windows = parseWindows(html) if (windows.isEmpty()) { return@withContext Result.failure( RepositoryError.ParseError( + RepositoryError.ParseErrorReason.NO_WINDOWS, "解析失败:未找到任何 OpenCode Go 配额窗口。HTML=${html.length} 字节。" ) ) @@ -150,15 +152,15 @@ class OpenCodeGoRepository( DebugLog.e(TAG, "fetchModels: HTTP ${resp.code}") return@withContext Result.failure(RepositoryError.ServerError(resp.code)) } - val root = json.parseToJsonElement(resp.body?.string() ?: throw RepositoryError.ParseError("响应体为空")) as? JsonObject - ?: throw RepositoryError.ParseError("响应根节点不是 JSON 对象") + val root = json.parseToJsonElement(resp.body?.string() ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空")) as? JsonObject + ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.NOT_JSON_OBJECT, "响应根节点不是 JSON 对象") val provider = root["opencode-go"] as? JsonObject val modelsObj = provider?.get("models") as? JsonObject modelsObj?.keys?.toList()?.sorted() - ?: throw RepositoryError.ParseError("未找到 OpenCode Go 模型列表") + ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.NO_MODELS, "未找到 OpenCode Go 模型列表") } if (models.isEmpty()) { - return@withContext Result.failure(RepositoryError.ParseError("模型列表为空")) + return@withContext Result.failure(RepositoryError.ParseError(RepositoryError.ParseErrorReason.MODELS_EMPTY, "模型列表为空")) } DebugLog.i(TAG, "fetchModels: 获取到 ${models.size} 个模型") Result.success(models) @@ -178,7 +180,7 @@ class OpenCodeGoRepository( * @param model 用户选择的模型 slug * @return Result.success(响应摘要文本) — 供 UI 展示 */ - suspend fun triggerUsage(model: String): Result = withContext(Dispatchers.IO) { + suspend fun triggerUsage(model: String): Result = withContext(Dispatchers.IO) { val credential = credentialRepository.get(ServiceType.OPENCODE_GO) ?: return@withContext Result.failure(RepositoryError.InvalidCredential("未找到 OpenCode Go 凭据")) if (credential !is Credential.SessionCredential) @@ -206,8 +208,8 @@ class OpenCodeGoRepository( val bodyStr = resp.body?.string() ?: "" DebugLog.i(TAG, "triggerUsage: HTTP ${resp.code}, body=${bodyStr.take(500)}") if (!resp.isSuccessful) { - return@withContext Result.failure( - TriggerError("HTTP ${resp.code}", bodyStr.ifBlank { "无响应体" }) + return@withContext Result.failure( + TriggerError("HTTP ${resp.code}", bodyStr.ifBlank { "" }) ) } DebugLog.i(TAG, "triggerUsage: 请求成功,模型=$model") @@ -318,7 +320,7 @@ class OpenCodeGoRepository( /** * 解析 OpenAI 兼容的 chat completions 响应,提取回复文本和用量统计。 */ -internal fun parseChatResponse(responseBody: String, model: String): String { +internal fun parseChatResponse(responseBody: String, model: String): TriggerSummary { val json = Json { ignoreUnknownKeys = true } return try { val root = json.parseToJsonElement(responseBody) as? JsonObject @@ -331,18 +333,14 @@ internal fun parseChatResponse(responseBody: String, model: String): String { val completionTokens = (usage?.get("completion_tokens") as? JsonPrimitive)?.contentOrNull val totalTokens = (usage?.get("total_tokens") as? JsonPrimitive)?.contentOrNull - val sb = StringBuilder() - sb.appendLine("✓ 激活成功") - sb.appendLine("模型: $model") - sb.appendLine() - sb.appendLine("回复:") - sb.appendLine(content?.ifEmpty { "(空)" } ?: "(无回复内容)") - if (promptTokens != null || completionTokens != null) { - sb.appendLine() - sb.appendLine("用量: prompt=$promptTokens completion=$completionTokens total=$totalTokens tokens") - } - sb.toString().trim() + TriggerSummary( + model = model, + reply = content, + inputTokens = promptTokens, + outputTokens = completionTokens, + totalTokens = totalTokens + ) } catch (_: Exception) { - "✓ 激活成功\n模型: $model\n\n(响应解析失败,但请求已发出)" + TriggerSummary(model = model, reply = null, parseFailed = true) } } \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/data/repository/OpenCodeUsageRepository.kt b/app/src/main/java/com/rainy/token/data/repository/OpenCodeUsageRepository.kt index 26c8f30..55f0a6f 100644 --- a/app/src/main/java/com/rainy/token/data/repository/OpenCodeUsageRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/OpenCodeUsageRepository.kt @@ -88,7 +88,7 @@ class OpenCodeUsageRepository( } val body = resp.body?.string() ?: return@withContext Result.failure( - RepositoryError.ParseError("响应体为空") + RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空") ) val records = parseUsageRecords(body) diff --git a/app/src/main/java/com/rainy/token/data/repository/RepositoryError.kt b/app/src/main/java/com/rainy/token/data/repository/RepositoryError.kt index b0c80bd..08395d4 100644 --- a/app/src/main/java/com/rainy/token/data/repository/RepositoryError.kt +++ b/app/src/main/java/com/rainy/token/data/repository/RepositoryError.kt @@ -23,9 +23,25 @@ sealed class RepositoryError(message: String, cause: Throwable? = null) : Except class ServerError(val code: Int, cause: Throwable? = null) : RepositoryError("服务端异常 (HTTP $code)", cause) - /** 解析失败(HTML 解析结果为空 / JSON 反序列化失败) */ - class ParseError(message: String, cause: Throwable? = null) : - RepositoryError("解析失败: $message", cause) + /** 解析失败的具体原因(结构化错误码,UI 层据此映射本地化文案,detail 仅作日志/调试) */ + enum class ParseErrorReason { + /** 响应体为空 */ + EMPTY_BODY, + /** 响应根节点不是 JSON 对象 */ + NOT_JSON_OBJECT, + /** 未找到用量窗口数据 */ + NO_WINDOWS, + /** 未找到模型列表 */ + NO_MODELS, + /** 模型列表为空 */ + MODELS_EMPTY, + /** 响应格式异常(detail 含异常信息,仅供日志) */ + MALFORMED_RESPONSE + } + + /** 解析失败(HTML 解析结果为空 / JSON 反序列化失败),detail 为具体原因(日志用,不直接透传 UI) */ + class ParseError(val reason: ParseErrorReason, val detail: String, cause: Throwable? = null) : + RepositoryError("解析失败: $detail", cause) /** 未知错误 */ class Unknown(cause: Throwable? = null) : RepositoryError( diff --git a/app/src/main/java/com/rainy/token/domain/model/TriggerSummary.kt b/app/src/main/java/com/rainy/token/domain/model/TriggerSummary.kt new file mode 100644 index 0000000..0345e32 --- /dev/null +++ b/app/src/main/java/com/rainy/token/domain/model/TriggerSummary.kt @@ -0,0 +1,24 @@ +package com.rainy.token.domain.model + +/** + * 一键激活用量的结构化响应摘要。 + * + * Repository 只负责从响应中提取数据,不拼接展示文案; + * UI 层按当前语言环境用 stringResource 组装本地化文本。 + */ +data class TriggerSummary( + /** 发起请求时使用的模型 */ + val model: String, + /** 模型回复文本;null 表示响应中不存在回复内容 */ + val reply: String?, + /** 输入 token 数(OCGO/Ollama 为 prompt_tokens,Codex 为 input_tokens) */ + val inputTokens: String? = null, + /** 输出 token 数(OCGO/Ollama 为 completion_tokens,Codex 为 output_tokens) */ + val outputTokens: String? = null, + /** 总 token 数(OCGO/Ollama 的 total_tokens;Codex 无此字段) */ + val totalTokens: String? = null, + /** Codex 响应 ID */ + val responseId: String? = null, + /** 响应解析失败,但请求已成功发出 */ + val parseFailed: Boolean = false +) diff --git a/app/src/main/java/com/rainy/token/domain/usecase/RefreshBalanceUseCase.kt b/app/src/main/java/com/rainy/token/domain/usecase/RefreshBalanceUseCase.kt index b943352..0fb8b2b 100644 --- a/app/src/main/java/com/rainy/token/domain/usecase/RefreshBalanceUseCase.kt +++ b/app/src/main/java/com/rainy/token/domain/usecase/RefreshBalanceUseCase.kt @@ -10,6 +10,7 @@ import com.rainy.token.data.repository.RefreshWriteSession import com.rainy.token.data.repository.RepositoryError import com.rainy.token.data.repository.retryOnTransientError import com.rainy.token.domain.model.ServiceBalance +import com.rainy.token.domain.model.TriggerSummary import com.rainy.token.domain.service.ServiceType import javax.inject.Inject import javax.inject.Provider @@ -55,7 +56,7 @@ class RefreshBalanceUseCase @Inject constructor( suspend fun fetchCodexModels(): Result> = codexRepositoryProvider.get().fetchModels() - suspend fun triggerCodexUsage(model: String): Result = + suspend fun triggerCodexUsage(model: String): Result = withCredentialSession(ServiceType.CODEX) { codexRepositoryProvider.get().triggerUsage(model) } @@ -63,7 +64,7 @@ class RefreshBalanceUseCase @Inject constructor( suspend fun fetchOpenCodeGoModels(): Result> = openCodeGoRepositoryProvider.get().fetchModels() - suspend fun triggerOpenCodeGoUsage(model: String): Result = + suspend fun triggerOpenCodeGoUsage(model: String): Result = withCredentialSession(ServiceType.OPENCODE_GO) { openCodeGoRepositoryProvider.get().triggerUsage(model) } @@ -71,7 +72,7 @@ class RefreshBalanceUseCase @Inject constructor( suspend fun fetchOllamaModels(): Result> = ollamaRepositoryProvider.get().fetchModels() - suspend fun triggerOllamaUsage(model: String): Result = + suspend fun triggerOllamaUsage(model: String): Result = withCredentialSession(ServiceType.OLLAMA) { ollamaRepositoryProvider.get().triggerUsage(model) } diff --git a/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt b/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt index 49b49cc..9035a0b 100644 --- a/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt +++ b/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel @@ -33,6 +34,7 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument +import com.rainy.token.R import com.rainy.token.data.repository.CommandCodeUsageRepository import com.rainy.token.domain.service.ServiceType import com.rainy.token.ui.components.rememberWindowSizeClass @@ -368,8 +370,8 @@ private fun ExpandedDetailPane( is DetailPane.Empty -> { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text("👈 选择服务查看详情", style = MaterialTheme.typography.titleMedium, color = inkMuted()) - Text("点击左侧卡片即可", style = MaterialTheme.typography.bodySmall, color = inkMuted()) + Text(stringResource(R.string.nav_empty_title), style = MaterialTheme.typography.titleMedium, color = inkMuted()) + Text(stringResource(R.string.nav_empty_subtitle), style = MaterialTheme.typography.bodySmall, color = inkMuted()) } } } diff --git a/app/src/main/java/com/rainy/token/ui/components/AppTips.kt b/app/src/main/java/com/rainy/token/ui/components/AppTips.kt index a688fb8..403d715 100644 --- a/app/src/main/java/com/rainy/token/ui/components/AppTips.kt +++ b/app/src/main/java/com/rainy/token/ui/components/AppTips.kt @@ -28,44 +28,50 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.annotation.StringRes +import com.rainy.token.R import com.rainy.token.ui.theme.InkMuted import com.rainy.token.ui.theme.StrawberryPink /** * APP 使用小技巧列表。 * Dashboard 卡片上方随机展示一条,设置页可查看全部。 + * + * 文案全部改为字符串资源 ID(@StringRes),由 UI 层按当前语言环境解析, + * 保证英文系统下自动显示英文翻译。 */ object AppTips { data class Tip( - val title: String, - val hint: String, - val detail: String + @StringRes val titleRes: Int, + @StringRes val hintRes: Int, + @StringRes val detailRes: Int ) val tips: List = listOf( - Tip("长按卡片拖拽排序", "长按首页卡片可以拖拽排序", "在首页长按任意服务卡片即可拖拽到想要的位置,排序会自动保存。"), - Tip("小组件点左上角进 APP", "小组件左上角标识可打开 APP", "桌面小组件左上角 RainyToken › 标识可以点击打开应用,其它区域点击切换服务。"), - Tip("图表点击查看详情", "点击图表柱子可查看时段详情", "在用量图表上点击或滑动手指,可查看对应时段的详细数值。"), - Tip("图表标题可点击", "点击图表标题弹出按模型明细", "图表卡片标题(如「消耗金额」)点击后会弹出按模型拆分的明细。"), - Tip("切换图表时区", "用量详情页可切换 UTC+0 / UTC+8", "用量详情页顶部可切换 UTC+0 / UTC+8,图表标签会跟随切换。"), - Tip("CCGO 清除并重新同步", "CCGO 详情页可清除缓存重新同步", "CCGO 用量详情页右上角「清除」可删除本地缓存并重新拉取全部数据。"), - Tip("查看用量详情", "服务卡片底部可进入用量图表", "有凭据的服务卡片底部会出现「查看用量详情」入口,点击进入图表统计。"), - Tip("原始数据浏览", "用量详情页右上角可查看原始记录", "用量详情页右上角「详细数据」可查看每条原始调用记录,支持分页跳转。"), - Tip("下拉刷新联动用量", "下拉刷新会同步余额和用量", "首页下拉刷新不仅刷新余额,还会自动同步 OCGO / CCGO 用量记录。"), - Tip("凭据获取教程", "凭据页底部有获取教程", "凭据编辑页底部的「如何获取」按钮有针对每个服务的详细操作步骤。"), - Tip("剪贴板自动导入", "Codex 支持从剪贴板一键导入", "Codex 凭据页支持「从剪贴板自动导入」,粘贴 auth.json 后一键填入。"), - Tip("详细数据页码跳转", "原始数据页可输入页码直跳", "原始数据页底部可输入页码直接跳转,数据量大时不必逐页翻。"), - Tip("小组件自动刷新", "划到小组件会自动刷新余额", "桌面小组件划到负一屏时会自动刷新余额,也可手动点 ↻ 按钮刷新。"), + Tip(R.string.tip_1_title, R.string.tip_1_hint, R.string.tip_1_detail), + Tip(R.string.tip_2_title, R.string.tip_2_hint, R.string.tip_2_detail), + Tip(R.string.tip_3_title, R.string.tip_3_hint, R.string.tip_3_detail), + Tip(R.string.tip_4_title, R.string.tip_4_hint, R.string.tip_4_detail), + Tip(R.string.tip_5_title, R.string.tip_5_hint, R.string.tip_5_detail), + Tip(R.string.tip_6_title, R.string.tip_6_hint, R.string.tip_6_detail), + Tip(R.string.tip_7_title, R.string.tip_7_hint, R.string.tip_7_detail), + Tip(R.string.tip_8_title, R.string.tip_8_hint, R.string.tip_8_detail), + Tip(R.string.tip_9_title, R.string.tip_9_hint, R.string.tip_9_detail), + Tip(R.string.tip_10_title, R.string.tip_10_hint, R.string.tip_10_detail), + Tip(R.string.tip_11_title, R.string.tip_11_hint, R.string.tip_11_detail), + Tip(R.string.tip_12_title, R.string.tip_12_hint, R.string.tip_12_detail), + Tip(R.string.tip_13_title, R.string.tip_13_hint, R.string.tip_13_detail), ) - /** 首页轮换用的一句话文案。 */ - val shortHints: List = tips.map { it.hint } + /** 首页轮换用的一句话文案(资源 ID 形式)。 */ + val shortHintRes: List = tips.map { it.hintRes } - /** 随机取一条短文案。 */ - fun randomHint(): String = shortHints.random() + /** 随机取一条短文案的资源 ID。 */ + fun randomHintRes(): Int = shortHintRes.random() } @OptIn(ExperimentalMaterial3Api::class) @@ -77,7 +83,7 @@ fun TipsScreen(onBack: () -> Unit) { TopAppBar( title = { Text( - "使用小技巧", + stringResource(R.string.title_tips), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold ) @@ -86,7 +92,7 @@ fun TipsScreen(onBack: () -> Unit) { IconButton(onClick = onBack) { Icon( Icons.Filled.ArrowBack, - contentDescription = "返回", + contentDescription = stringResource(R.string.action_back), tint = StrawberryPink ) } @@ -123,14 +129,14 @@ fun TipsScreen(onBack: () -> Unit) { ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = tip.title, + text = stringResource(tip.titleRes), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold ) } Spacer(modifier = Modifier.height(6.dp)) Text( - text = tip.detail, + text = stringResource(tip.detailRes), style = MaterialTheme.typography.bodyMedium, color = InkMuted ) diff --git a/app/src/main/java/com/rainy/token/ui/components/DebugLogScreen.kt b/app/src/main/java/com/rainy/token/ui/components/DebugLogScreen.kt index 2a180b2..15d7cb8 100644 --- a/app/src/main/java/com/rainy/token/ui/components/DebugLogScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/components/DebugLogScreen.kt @@ -31,10 +31,12 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.rainy.token.R import com.rainy.token.data.debug.DebugLog import com.rainy.token.ui.theme.InkMuted import com.rainy.token.ui.theme.StrawberryPink @@ -50,7 +52,7 @@ fun DebugLogScreen(onBack: () -> Unit) { TopAppBar( title = { Text( - "调试日志", + stringResource(R.string.title_debug_log), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold ) @@ -59,14 +61,14 @@ fun DebugLogScreen(onBack: () -> Unit) { IconButton(onClick = onBack) { Icon( Icons.Filled.ArrowBack, - contentDescription = "返回", + contentDescription = stringResource(R.string.action_back), tint = StrawberryPink ) } }, actions = { TextButton(onClick = { DebugLog.clear() }) { - Text("清空", color = StrawberryPink) + Text(stringResource(R.string.action_clear), color = StrawberryPink) } }, colors = TopAppBarDefaults.topAppBarColors( @@ -83,7 +85,7 @@ fun DebugLogScreen(onBack: () -> Unit) { contentAlignment = Alignment.Center ) { Text( - "暂无日志记录\n刷新 Codex 用量后这里会出现调试信息", + stringResource(R.string.debug_log_empty), style = MaterialTheme.typography.bodyMedium, color = InkMuted, modifier = Modifier.padding(32.dp) diff --git a/app/src/main/java/com/rainy/token/ui/components/FormatUtils.kt b/app/src/main/java/com/rainy/token/ui/components/FormatUtils.kt index 5e98ff9..992156a 100644 --- a/app/src/main/java/com/rainy/token/ui/components/FormatUtils.kt +++ b/app/src/main/java/com/rainy/token/ui/components/FormatUtils.kt @@ -6,42 +6,63 @@ import java.util.Locale * 共享格式化工具函数。 * * 从 DashboardScreen / ServiceDetailScreen / WidgetProvider 提取,消除三处重复。 + * + * 本地化策略:函数保持纯 JVM(不依赖 Context / Resources,可被单元测试直接调用), + * 本地化标签通过 [DurationText] / 字符串参数注入。调用方(Compose / Widget)用 + * `context.getString(...)` 取得当前语言标签后传入;测试继续使用默认中文标签。 */ -/** 格式化金额:整数无小数点,带小数保留 2 位 */ +/** 时长标签(用于 "X 天 Y 小时" 这类拼接)。 */ +data class DurationText( + val day: String, + val hour: String, + val minute: String, + /** 无有效时长时展示的占位符 */ + val dash: String = "—" +) + +/** 默认中文标签(保持与原实现输出完全一致,供单元测试与兜底使用)。 */ +val ChineseDurationText = DurationText(day = "天", hour = "小时", minute = "分") + +/** 格式化金额:整数无小数点,带小数保留 2 位(金额数值格式固定 Locale.US,避免小数点歧义) */ fun formatAmount(value: Double): String { return if (value % 1.0 == 0.0) value.toInt().toString() else String.format(Locale.US, "%.2f", value) } /** 格式化重置倒计时(秒 → "X 天 Y 小时" / "X 小时 Y 分" / "Y 分"),无效返回 "—" */ -fun formatResetInSec(sec: Long): String { - if (sec <= 0) return "—" +fun formatResetInSec(sec: Long, text: DurationText = ChineseDurationText): String { + if (sec <= 0) return text.dash val days = sec / 86400 val hours = (sec % 86400) / 3600 val minutes = (sec % 3600) / 60 return when { - days > 0 -> "$days 天 $hours 小时" - hours > 0 -> "$hours 小时 $minutes 分" - else -> "$minutes 分" + days > 0 -> "$days ${text.day} $hours ${text.hour}" + hours > 0 -> "$hours ${text.hour} $minutes ${text.minute}" + else -> "$minutes ${text.minute}" } } /** Widget 版本:逻辑相同但空值返回空串(非 "—"),适配 RemoteViews 布局 */ -fun formatResetForWidget(sec: Long): String { +fun formatResetForWidget(sec: Long, text: DurationText = ChineseDurationText): String { if (sec <= 0) return "" val days = sec / 86400 val hours = (sec % 86400) / 3600 val minutes = (sec % 3600) / 60 return when { - days > 0 -> "$days 天 $hours 小时" - hours > 0 -> "$hours 小时 $minutes 分" - else -> "$minutes 分" + days > 0 -> "$days ${text.day} $hours ${text.hour}" + hours > 0 -> "$hours ${text.hour} $minutes ${text.minute}" + else -> "$minutes ${text.minute}" } } -/** 标准化窗口标签:weekly → 每周,其他原样返回 */ -fun normalizeWindowLabel(label: String): String = when (label.lowercase()) { - "weekly" -> "每周" +/** 标准化窗口标签:weekly/每周 → 每周标签,monthly/每月 → 每月标签,其他原样返回 */ +fun normalizeWindowLabel( + label: String, + weeklyLabel: String = "每周", + monthlyLabel: String = "每月" +): String = when (label.lowercase()) { + "weekly", "每周" -> weeklyLabel + "monthly", "每月" -> monthlyLabel else -> label } \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/ui/components/StatusChip.kt b/app/src/main/java/com/rainy/token/ui/components/StatusChip.kt index a0ed758..4ae9658 100644 --- a/app/src/main/java/com/rainy/token/ui/components/StatusChip.kt +++ b/app/src/main/java/com/rainy/token/ui/components/StatusChip.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.annotation.StringRes import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -16,6 +17,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.rainy.token.ui.theme.StatusBlue import com.rainy.token.ui.theme.StatusGreen @@ -33,7 +35,7 @@ import com.rainy.token.ui.theme.StatusRed enum class StatusLevel { OK, WARNING, ERROR, INFO } data class StatusStyle( - val label: String, + @StringRes val labelRes: Int, val level: StatusLevel ) @@ -64,7 +66,7 @@ fun StatusChip( ) Spacer(modifier = Modifier.width(6.dp)) Text( - text = style.label, + text = stringResource(style.labelRes), style = MaterialTheme.typography.labelSmall, color = textColor ) diff --git a/app/src/main/java/com/rainy/token/ui/components/UiText.kt b/app/src/main/java/com/rainy/token/ui/components/UiText.kt new file mode 100644 index 0000000..339e47b --- /dev/null +++ b/app/src/main/java/com/rainy/token/ui/components/UiText.kt @@ -0,0 +1,44 @@ +package com.rainy.token.ui.components + +import android.content.Context +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource + +/** + * 可本地化的文本抽象。 + * + * ViewModel / Repository 层不直接持有本地化字符串,而是返回 [UiText]: + * - [Resource]:引用字符串资源(可带格式化参数),由 UI 层按当前语言环境解析 + * - [Dynamic]:服务端返回的动态文本(未知内容,无法预翻译),原样透传 + * + * 调试日志保持使用原始 [Throwable.message](Repository 中文消息仅用于排查,不用于 UI 展示)。 + */ +sealed interface UiText { + + data class Resource( + @StringRes val resId: Int, + val args: List = emptyList() + ) : UiText + + data class Dynamic(val value: String) : UiText +} + +/** 在非 Composable 上下文(协程/回调/事件)中把 [UiText] 解析为字符串(嵌套 [UiText] 参数会递归扁平化)。 */ +fun UiText.resolve(context: Context): String = when (this) { + is UiText.Resource -> { + val flatArgs = args.map { arg -> if (arg is UiText) arg.resolve(context) else arg } + context.getString(resId, *flatArgs.toTypedArray()) + } + is UiText.Dynamic -> value +} + +/** 在 Composable 上下文中把 [UiText] 解析为当前语言环境的字符串(嵌套 [UiText] 参数会递归扁平化)。 */ +@Composable +fun UiText.asString(): String = when (this) { + is UiText.Resource -> { + val flatArgs = args.map { arg -> if (arg is UiText) arg.asString() else arg } + stringResource(resId, *flatArgs.toTypedArray()) + } + is UiText.Dynamic -> value +} diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/CommandCodeUsageStatsCard.kt b/app/src/main/java/com/rainy/token/ui/dashboard/CommandCodeUsageStatsCard.kt index 2fd6a5b..0c9ee7d 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/CommandCodeUsageStatsCard.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/CommandCodeUsageStatsCard.kt @@ -24,10 +24,13 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.rainy.token.R +import com.rainy.token.ui.components.asString import com.rainy.token.data.local.OverviewStats import com.rainy.token.ui.theme.InkMuted import com.rainy.token.ui.theme.StrawberryPink @@ -68,12 +71,12 @@ fun CommandCodeUsageStatsCard( Row(verticalAlignment = Alignment.CenterVertically) { Column(modifier = Modifier.weight(1f)) { Text( - text = "CommandCode 用量", + text = stringResource(R.string.usage_title_commandcode), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold ) Text( - text = if (uiState.recordCount > 0) "${uiState.recordCount} 条记录" else "点击同步", + text = if (uiState.recordCount > 0) stringResource(R.string.usage_record_count, uiState.recordCount) else stringResource(R.string.usage_click_to_sync), style = MaterialTheme.typography.bodySmall, color = InkMuted ) @@ -88,7 +91,7 @@ fun CommandCodeUsageStatsCard( IconButton(onClick = { viewModel.sync() }) { Icon( imageVector = Icons.Filled.Refresh, - contentDescription = "同步用量数据", + contentDescription = stringResource(R.string.action_sync_usage), tint = StrawberryPink ) } @@ -101,13 +104,13 @@ fun CommandCodeUsageStatsCard( // 输入 Token = input + cacheRead val inputTotal = overview.inputTokens + overview.cacheReadTokens - MetricRow("输入 Token", formatTokenCount(inputTotal)) + MetricRow(stringResource(R.string.usage_input_tokens), formatTokenCount(inputTotal)) // 缓存子行 if (overview.cacheReadTokens > 0 || overview.cacheWriteTokens > 0) { val parts = mutableListOf() - if (overview.cacheReadTokens > 0) parts.add("缓存读取 ${formatTokenCount(overview.cacheReadTokens)}") - if (overview.cacheWriteTokens > 0) parts.add("缓存写入 ${formatTokenCount(overview.cacheWriteTokens)}") + if (overview.cacheReadTokens > 0) parts.add(stringResource(R.string.usage_cache_read, formatTokenCount(overview.cacheReadTokens))) + if (overview.cacheWriteTokens > 0) parts.add(stringResource(R.string.usage_cache_write, formatTokenCount(overview.cacheWriteTokens))) Text( text = parts.joinToString(" · "), style = MaterialTheme.typography.bodySmall, @@ -120,8 +123,8 @@ fun CommandCodeUsageStatsCard( // 输出 Token + 总花费 同一排 Row(Modifier.fillMaxWidth()) { - MetricRow("输出 Token", formatTokenCount(overview.outputTokens), Modifier.weight(1f)) - MetricRow("总花费", "$${String.format(Locale.US, "%.4f", overview.totalCost / 100_000_000.0)}", Modifier.weight(1f)) + MetricRow(stringResource(R.string.usage_output_tokens), formatTokenCount(overview.outputTokens), Modifier.weight(1f)) + MetricRow(stringResource(R.string.usage_total_cost), "$${String.format(Locale.US, "%.4f", overview.totalCost / 100_000_000.0)}", Modifier.weight(1f)) } } @@ -129,7 +132,7 @@ fun CommandCodeUsageStatsCard( if (uiState.overview == null && !uiState.syncing) { Spacer(modifier = Modifier.height(8.dp)) Text( - text = if (uiState.loading) "加载中…" else "暂无数据,点击 🔄 同步", + text = if (uiState.loading) stringResource(R.string.common_loading) else stringResource(R.string.usage_no_data_sync), style = MaterialTheme.typography.bodyMedium, color = InkMuted ) @@ -139,7 +142,7 @@ fun CommandCodeUsageStatsCard( if (uiState.overview != null) { Spacer(modifier = Modifier.height(8.dp)) TextButton(onClick = onOpenDetail) { - Text("查看详情", color = StrawberryPink) + Text(stringResource(R.string.action_view_detail), color = StrawberryPink) Spacer(modifier = Modifier.width(2.dp)) Icon( imageVector = Icons.Filled.KeyboardArrowRight, @@ -154,7 +157,7 @@ fun CommandCodeUsageStatsCard( if (uiState.lastSyncResult > 0) { Spacer(modifier = Modifier.height(4.dp)) Text( - text = "✓ 新增 ${uiState.lastSyncResult} 条", + text = stringResource(R.string.usage_new_records, uiState.lastSyncResult), style = MaterialTheme.typography.bodySmall, color = StrawberryPink ) @@ -162,7 +165,7 @@ fun CommandCodeUsageStatsCard( uiState.lastSyncError?.let { err -> Spacer(modifier = Modifier.height(4.dp)) Text( - text = "✗ $err", + text = "✗ ${err.asString()}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error ) diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt index 0cdc65c..dcc2e5c 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt @@ -67,6 +67,7 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.positionInWindow import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp @@ -77,6 +78,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.rainy.token.R import com.rainy.token.domain.model.CredentialStatus import com.rainy.token.domain.model.ServiceBalance import com.rainy.token.domain.service.ServiceType @@ -85,6 +87,7 @@ import com.rainy.token.ui.components.StatusChip import com.rainy.token.ui.components.StatusLevel import com.rainy.token.ui.components.StatusStyle import com.rainy.token.ui.components.AppTips +import com.rainy.token.ui.components.asString import com.rainy.token.ui.theme.inkMuted import com.rainy.token.ui.theme.StrawberryPink import android.app.AppOpsManager @@ -161,8 +164,8 @@ fun DashboardScreen( if (showAddWidgetConfirm) { AlertDialog( onDismissRequest = { showAddWidgetConfirm = false }, - title = { Text("添加桌面小组件?") }, - text = { Text("确认后会请求桌面启动器添加雨晴Token小组件。部分系统仍会弹出系统确认窗口。") }, + title = { Text(stringResource(R.string.dialog_add_widget_title)) }, + text = { Text(stringResource(R.string.dialog_add_widget_body)) }, confirmButton = { TextButton( onClick = { @@ -170,12 +173,12 @@ fun DashboardScreen( requestRainyTokenWidgetPin(context) } ) { - Text("确认添加", color = StrawberryPink) + Text(stringResource(R.string.action_confirm_add), color = StrawberryPink) } }, dismissButton = { TextButton(onClick = { showAddWidgetConfirm = false }) { - Text("取消") + Text(stringResource(R.string.action_cancel)) } } ) @@ -193,7 +196,7 @@ fun DashboardScreen( fontWeight = FontWeight.Bold ) Text( - text = "AI 余额一览", + text = stringResource(R.string.dashboard_subtitle), style = MaterialTheme.typography.bodySmall, color = inkMuted() ) @@ -203,7 +206,7 @@ fun DashboardScreen( IconButton(onClick = { showAddWidgetConfirm = true }) { Icon( imageVector = Icons.Filled.Add, - contentDescription = "添加小组件到桌面", + contentDescription = stringResource(R.string.action_add_widget), tint = StrawberryPink ) } @@ -213,14 +216,14 @@ fun DashboardScreen( ) { Icon( imageVector = Icons.Filled.Refresh, - contentDescription = "刷新", + contentDescription = stringResource(R.string.action_refresh), tint = StrawberryPink ) } IconButton(onClick = onOpenSettings) { Icon( imageVector = Icons.Filled.Settings, - contentDescription = "设置", + contentDescription = stringResource(R.string.action_settings), tint = StrawberryPink ) } @@ -263,9 +266,9 @@ fun DashboardScreen( verticalArrangement = Arrangement.spacedBy(12.dp) ) { // 随机小技巧提示(每次启动随机一条) - val tipText = remember { AppTips.randomHint() } + val tipTextRes = remember { AppTips.randomHintRes() } Text( - text = "💡 $tipText", + text = "💡 " + stringResource(tipTextRes), style = MaterialTheme.typography.bodySmall, color = inkMuted(), modifier = Modifier.padding(horizontal = 4.dp) @@ -314,13 +317,13 @@ fun DashboardScreen( verticalAlignment = Alignment.CenterVertically ) { Text( - text = "💡 长按卡片可拖拽排序", + text = stringResource(R.string.dashboard_drag_hint), style = MaterialTheme.typography.bodySmall, color = StrawberryPink, modifier = Modifier.weight(1f) ) Text( - text = "知道了", + text = stringResource(R.string.action_got_it), style = MaterialTheme.typography.bodySmall, color = StrawberryPink, fontWeight = FontWeight.SemiBold @@ -639,7 +642,7 @@ private fun DashboardCard(card: DashboardCardUi, onClick: () -> Unit, onOpenUsag fontWeight = FontWeight.SemiBold ) Text( - text = secondaryLine(card), + text = stringResource(secondaryLineRes(card)), style = MaterialTheme.typography.bodySmall, color = inkMuted() ) @@ -656,7 +659,7 @@ private fun DashboardCard(card: DashboardCardUi, onClick: () -> Unit, onOpenUsag if (onOpenUsageDetail != null && card.credentialState != com.rainy.token.domain.model.CredentialStatus.State.NOT_CONFIGURED) { Spacer(modifier = Modifier.height(8.dp)) TextButton(onClick = onOpenUsageDetail) { - Text("查看用量详情", color = StrawberryPink) + Text(stringResource(R.string.action_view_usage_detail), color = StrawberryPink) Spacer(modifier = Modifier.width(2.dp)) Icon( imageVector = Icons.Filled.KeyboardArrowRight, @@ -670,7 +673,7 @@ private fun DashboardCard(card: DashboardCardUi, onClick: () -> Unit, onOpenUsag // ─── 底部:更新时间 / 错误信息 ─── Spacer(modifier = Modifier.height(12.dp)) Text( - text = footerText(card), + text = footerText(card).asString(), style = MaterialTheme.typography.bodySmall, color = if (card.lastFetchError != null) MaterialTheme.colorScheme.error @@ -694,13 +697,13 @@ private fun DashboardFooter() { horizontalAlignment = Alignment.CenterHorizontally ) { Text( - text = "RainyToken · v1.0", + text = stringResource(R.string.dashboard_footer_version), style = MaterialTheme.typography.bodySmall, color = inkMuted() ) Spacer(modifier = Modifier.height(4.dp)) Text( - text = "本地加密存储,不上传任何数据", + text = stringResource(R.string.dashboard_footer_privacy), style = MaterialTheme.typography.bodySmall, color = inkMuted() ) @@ -726,14 +729,14 @@ private fun requestRainyTokenWidgetPin(context: Context) { val appWidgetManager = AppWidgetManager.getInstance(context) val component = ComponentName(context, OpenCodeGoWidgetProvider::class.java) if (!canInstallLauncherShortcut(context)) { - Toast.makeText(context, "桌面快捷方式权限已被拒绝,请在系统设置中允许后重试", Toast.LENGTH_LONG).show() + Toast.makeText(context, context.getString(R.string.toast_shortcut_permission_denied), Toast.LENGTH_LONG).show() return } if (appWidgetManager.isRequestPinAppWidgetSupported) { val requested = appWidgetManager.requestPinAppWidget(component, null, null) if (!requested) { - Toast.makeText(context, "桌面未接受添加请求,请检查启动器权限", Toast.LENGTH_LONG).show() + Toast.makeText(context, context.getString(R.string.toast_add_rejected), Toast.LENGTH_LONG).show() } return } @@ -745,6 +748,6 @@ private fun requestRainyTokenWidgetPin(context: Context) { if (canOpenPicker) { context.startActivity(pickerIntent) } else { - Toast.makeText(context, "当前桌面不支持应用内添加,请在桌面长按 → 小组件 → 查找雨晴Token", Toast.LENGTH_LONG).show() + Toast.makeText(context, context.getString(R.string.toast_picker_unsupported), Toast.LENGTH_LONG).show() } } \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt index fa6489b..530ac1b 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt @@ -10,6 +10,8 @@ import com.rainy.token.domain.model.CredentialStatus import com.rainy.token.domain.model.ServiceBalance import com.rainy.token.domain.service.ServiceType import com.rainy.token.domain.usecase.RefreshBalanceUseCase +import com.rainy.token.R +import com.rainy.token.ui.components.UiText import com.rainy.token.ui.widget.OpenCodeGoWidgetProvider import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext @@ -127,10 +129,10 @@ class DashboardViewModel @Inject constructor( val cards = ServiceType.entries.map { type -> val result = results[type] val error = result?.exceptionOrNull() - val errMsg = error + val errorUi = error ?.takeUnless { it is RepositoryError.CredentialChanged } - ?.message - buildCard(localStates.getValue(type), lastFetchError = errMsg) + ?.let { errorToUiText(it) } + buildCard(localStates.getValue(type), lastFetchError = errorUi) } _uiState.update { it.copy(refreshing = false, cards = cards) } // 刷新成功后更新桌面小组件 @@ -145,7 +147,7 @@ class DashboardViewModel @Inject constructor( private fun buildCard( local: CredentialRepository.LocalState, - lastFetchError: String? + lastFetchError: UiText? ): DashboardCardUi = DashboardCardUi( service = local.status.service, credentialState = local.status.state, @@ -181,16 +183,37 @@ data class DashboardCardUi( val credentialState: CredentialStatus.State, val credentialFingerprint: String?, val cachedBalance: CachedBalance?, - val lastFetchError: String? + val lastFetchError: UiText? ) { /** 余额展示主数字。优先取缓存,错误时也展示(不隐藏,让用户看到旧值 + 红点提示)。 */ val displayBalance: ServiceBalance? get() = cachedBalance?.balance - - /** 卡片顶部状态徽章。 */ - val statusBadge: String get() = when { - credentialState == CredentialStatus.State.NOT_CONFIGURED -> "未配置" - lastFetchError != null -> "刷新失败" - cachedBalance == null -> "未获取" - else -> "正常" - } +} + +/** 把 Repository 错误映射为本地化文案(ParseError 按 reason 映射资源,detail 仅作日志,不流向 UI)。 */ +private fun errorToUiText(error: Throwable): UiText = when (error) { + is RepositoryError.InvalidCredential -> + UiText.Resource(R.string.error_credential_invalid_reconfigure) + is RepositoryError.RateLimited -> UiText.Resource( + R.string.error_rate_limited_retry, + listOf( + error.retryAfterSeconds?.let { + UiText.Resource(R.string.error_rate_limited_retry_suffix, listOf(it)) + } ?: "" + ) + ) + is RepositoryError.Network -> UiText.Resource(R.string.error_network_check) + is RepositoryError.ServerError -> + UiText.Resource(R.string.error_server_http, listOf(error.code)) + is RepositoryError.ParseError -> when (error.reason) { + RepositoryError.ParseErrorReason.EMPTY_BODY -> UiText.Resource(R.string.error_parse_empty_body) + RepositoryError.ParseErrorReason.NOT_JSON_OBJECT -> UiText.Resource(R.string.error_parse_not_json) + RepositoryError.ParseErrorReason.NO_WINDOWS -> UiText.Resource(R.string.error_parse_no_windows) + RepositoryError.ParseErrorReason.NO_MODELS -> UiText.Resource(R.string.error_parse_no_models) + RepositoryError.ParseErrorReason.MODELS_EMPTY -> UiText.Resource(R.string.error_parse_models_empty) + RepositoryError.ParseErrorReason.MALFORMED_RESPONSE -> UiText.Resource(R.string.error_parse_malformed) + } + // Unknown 的 message 以硬编码中文"未知错误"开头,不能透传 UI,统一映射本地化文案 + is RepositoryError.Unknown -> UiText.Resource(R.string.common_unknown) + else -> error.message?.let { UiText.Dynamic(it) } + ?: UiText.Resource(R.string.common_unknown) } diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt b/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt index b8b69ae..b29099e 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt @@ -19,11 +19,16 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.res.stringResource +import androidx.annotation.StringRes +import com.rainy.token.R import com.rainy.token.domain.model.CredentialStatus import com.rainy.token.domain.model.ServiceBalance import com.rainy.token.domain.service.ServiceType import com.rainy.token.ui.components.StatusStyle import com.rainy.token.ui.components.StatusLevel +import com.rainy.token.ui.components.DurationText +import com.rainy.token.ui.components.UiText import com.rainy.token.ui.components.formatAmount import com.rainy.token.ui.components.formatResetInSec import com.rainy.token.ui.components.normalizeWindowLabel @@ -47,7 +52,7 @@ internal fun BalanceMainArea(card: DashboardCardUi) { color = inkMuted() ) Text( - text = "点击配置凭据", + text = stringResource(R.string.service_click_to_configure), style = MaterialTheme.typography.bodySmall, color = inkMuted() ) @@ -59,7 +64,7 @@ internal fun BalanceMainArea(card: DashboardCardUi) { color = inkMuted() ) Text( - text = "下拉刷新", + text = stringResource(R.string.service_pull_to_refresh), style = MaterialTheme.typography.bodySmall, color = inkMuted() ) @@ -102,14 +107,14 @@ internal fun BalanceMainArea(card: DashboardCardUi) { } if (!balance.isAvailable) { Text( - text = "服务当前不可用", + text = stringResource(R.string.service_unavailable), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error ) } balance.monthlySpent?.let { spent -> Text( - text = "本月已用 ${formatAmount(spent)}${balance.unit}", + text = stringResource(R.string.service_monthly_used, formatAmount(spent), balance.unit), style = MaterialTheme.typography.bodySmall, color = inkMuted() ) @@ -136,7 +141,7 @@ internal fun OpenCodeGoMainBalance(balance: ServiceBalance) { ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = "5h 用量", + text = stringResource(R.string.window_5h_usage), style = MaterialTheme.typography.titleMedium, color = inkMuted(), modifier = Modifier.padding(bottom = 6.dp) @@ -147,9 +152,9 @@ internal fun OpenCodeGoMainBalance(balance: ServiceBalance) { @Composable internal fun OpenCodeGoUsageWindows(balance: ServiceBalance) { val windows = listOf( - Triple("5 小时", balance.extras["rolling.pct"]?.toIntOrNull(), balance.extras["rolling.resetInSec"]?.toLongOrNull()), - Triple("本周", balance.extras["weekly.pct"]?.toIntOrNull(), balance.extras["weekly.resetInSec"]?.toLongOrNull()), - Triple("本月", balance.extras["monthly.pct"]?.toIntOrNull(), balance.extras["monthly.resetInSec"]?.toLongOrNull()) + Triple(stringResource(R.string.window_5h), balance.extras["rolling.pct"]?.toIntOrNull(), balance.extras["rolling.resetInSec"]?.toLongOrNull()), + Triple(stringResource(R.string.window_weekly), balance.extras["weekly.pct"]?.toIntOrNull(), balance.extras["weekly.resetInSec"]?.toLongOrNull()), + Triple(stringResource(R.string.window_monthly), balance.extras["monthly.pct"]?.toIntOrNull(), balance.extras["monthly.resetInSec"]?.toLongOrNull()) ) Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { windows.forEach { (label, pct, resetSec) -> @@ -181,7 +186,7 @@ internal fun CommandCodeGoMainBalance(balance: ServiceBalance) { ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = "剩余", + text = stringResource(R.string.service_remaining), style = MaterialTheme.typography.titleMedium, color = inkMuted(), modifier = Modifier.padding(bottom = 6.dp) @@ -189,7 +194,7 @@ internal fun CommandCodeGoMainBalance(balance: ServiceBalance) { if (total != null && total > 0) { val used = total - balance.amount Text( - text = " · 已用 ${formatAmount(used)} / 共 ${formatAmount(total)}", + text = stringResource(R.string.service_used_total, formatAmount(used), formatAmount(total)), style = MaterialTheme.typography.bodySmall, color = inkMuted(), modifier = Modifier.padding(bottom = 6.dp) @@ -206,9 +211,9 @@ internal fun CommandCodeGoUsageWindows(balance: ServiceBalance) { return ((used / cap) * 100).toInt().coerceIn(0, 100) } val windows = listOf( - Triple("5 小时", calcPct(extras["fiveHour.used"]?.toDoubleOrNull(), extras["fiveHour.cap"]?.toDoubleOrNull()), extras["fiveHour.resetInSec"]?.toLongOrNull()), - Triple("本周", calcPct(extras["weekly.used"]?.toDoubleOrNull(), extras["weekly.cap"]?.toDoubleOrNull()), extras["weekly.resetInSec"]?.toLongOrNull()), - Triple("本月", calcPct(balance.monthlySpent, balance.totalQuota), extras["monthly.resetInSec"]?.toLongOrNull()) + Triple(stringResource(R.string.window_5h), calcPct(extras["fiveHour.used"]?.toDoubleOrNull(), extras["fiveHour.cap"]?.toDoubleOrNull()), extras["fiveHour.resetInSec"]?.toLongOrNull()), + Triple(stringResource(R.string.window_weekly), calcPct(extras["weekly.used"]?.toDoubleOrNull(), extras["weekly.cap"]?.toDoubleOrNull()), extras["weekly.resetInSec"]?.toLongOrNull()), + Triple(stringResource(R.string.window_monthly), calcPct(balance.monthlySpent, balance.totalQuota), extras["monthly.resetInSec"]?.toLongOrNull()) ) Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { windows.forEach { (label, pct, resetSec) -> @@ -226,7 +231,12 @@ internal fun CodexMainBalance(balance: ServiceBalance) { val plan = balance.extras["plan"]?.let { when (it) { "plus" -> "Plus"; "pro" -> "Pro"; "free" -> "Free"; else -> it.replaceFirstChar { c -> c.uppercaseChar() } } } ?: "—" - val primaryLabel = formatCodexPrimaryLabel(balance.extras["primary.label"]) + val primaryLabel = formatCodexPrimaryLabel( + balance.extras["primary.label"], + weeklyLabel = stringResource(R.string.window_every_week), + monthlyLabel = stringResource(R.string.window_every_month), + usageLabel = stringResource(R.string.window_usage) + ) Row(verticalAlignment = Alignment.Bottom) { Text( text = formatAmount(balance.amount), @@ -243,14 +253,14 @@ internal fun CodexMainBalance(balance: ServiceBalance) { ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = "$primaryLabel 已用", + text = stringResource(R.string.service_used_label, primaryLabel), style = MaterialTheme.typography.titleMedium, color = inkMuted(), modifier = Modifier.padding(bottom = 6.dp) ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = "· $plan", + text = stringResource(R.string.service_plan_suffix, plan), style = MaterialTheme.typography.bodySmall, color = inkMuted(), modifier = Modifier.padding(bottom = 6.dp) @@ -258,14 +268,22 @@ internal fun CodexMainBalance(balance: ServiceBalance) { } } -/** 把 Codex API 返回的 primary label 转成中文短标签 */ -internal fun formatCodexPrimaryLabel(raw: String?): String = when (raw?.lowercase()) { +/** + * 把 Codex API 返回的 primary label 转成本地化短标签。 + * 默认中文标签保持测试兼容;UI 调用方传入 stringResource 结果。 + */ +internal fun formatCodexPrimaryLabel( + raw: String?, + weeklyLabel: String = "每周", + monthlyLabel: String = "每月", + usageLabel: String = "用量" +): String = when (raw?.lowercase()) { "5h" -> "5h" - "7d", "每周" -> "每周" - "30d", "每月" -> "每月" - "usage" -> "用量" + "7d", "每周", weeklyLabel -> weeklyLabel + "30d", "每月", monthlyLabel -> monthlyLabel + "usage" -> usageLabel null -> "5h" - else -> raw + else -> raw ?: "5h" } @Composable @@ -276,14 +294,21 @@ internal fun CodexUsageWindows(balance: ServiceBalance) { .distinct() .maxOrNull()?.plus(1) ?: 0 + val weeklyLabel = stringResource(R.string.window_every_week) + val monthlyLabel = stringResource(R.string.window_every_month) val windows = (0 until windowCount).map { i -> - val label = normalizeWindowLabel(extras["window_$i.label"] ?: "Usage") + val label = normalizeWindowLabel( + extras["window_$i.label"] ?: "Usage", + weeklyLabel = weeklyLabel, + monthlyLabel = monthlyLabel + ) val remainingPct = extras["window_$i.remainingPct"]?.toIntOrNull() val resetAt = extras["window_$i.resetAt"]?.toLongOrNull()?.takeIf { it > 0 } Triple(label, remainingPct, resetAt) } // 判断是否有 5h 窗口(用于保留空槽位) + val fiveHourLabel = stringResource(R.string.window_5h) val has5h = windows.any { it.first.contains("5") && it.first.contains("小时") } Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { @@ -292,7 +317,7 @@ internal fun CodexUsageWindows(balance: ServiceBalance) { } else { // 如果没有 5h 窗口,在顶部插入一个空的 5h 槽位(保留位置,等恢复后自动填充) if (!has5h) { - CompactUsageRowEmpty(label = "5 小时", resetInSec = null) + CompactUsageRowEmpty(label = fiveHourLabel, resetInSec = null) } windows.forEach { (label, remainingPct, resetAt) -> if (remainingPct != null) { @@ -327,14 +352,14 @@ internal fun OllamaMainBalance(balance: ServiceBalance) { ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = "5h 已用", + text = stringResource(R.string.service_5h_used), style = MaterialTheme.typography.titleMedium, color = inkMuted(), modifier = Modifier.padding(bottom = 6.dp) ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = "· $plan", + text = stringResource(R.string.service_plan_suffix, plan), style = MaterialTheme.typography.bodySmall, color = inkMuted(), modifier = Modifier.padding(bottom = 6.dp) @@ -349,25 +374,26 @@ internal fun OllamaUsageWindows(balance: ServiceBalance) { val weeklyPct = extras["weekly.pct"]?.toFloatOrNull() val sessionResetAt = extras["session.resetAt"]?.toLongOrNull() val weeklyResetAt = extras["weekly.resetAt"]?.toLongOrNull() + val weeklyLabel = stringResource(R.string.window_every_week) Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { if (sessionPct != null) { CompactUsageRow( - label = "5h", + label = stringResource(R.string.window_5h_short), pct = sessionPct.toInt().coerceIn(0, 100), resetInSec = sessionResetAt?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 } ) } else { - CompactUsageRowEmpty(label = "5h", resetInSec = null) + CompactUsageRowEmpty(label = stringResource(R.string.window_5h_short), resetInSec = null) } if (weeklyPct != null) { CompactUsageRow( - label = "每周", + label = weeklyLabel, pct = weeklyPct.toInt().coerceIn(0, 100), resetInSec = weeklyResetAt?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 } ) } else { - CompactUsageRowEmpty(label = "每周", resetInSec = null) + CompactUsageRowEmpty(label = weeklyLabel, resetInSec = null) } } } @@ -376,6 +402,7 @@ internal fun OllamaUsageWindows(balance: ServiceBalance) { @Composable internal fun CompactUsageRowEmpty(label: String, resetInSec: Long?) { + val durationText = rememberDurationText() Column { Row( modifier = Modifier.fillMaxWidth(), @@ -407,7 +434,7 @@ internal fun CompactUsageRowEmpty(label: String, resetInSec: Long?) { if (resetInSec != null && resetInSec > 0) { Spacer(modifier = Modifier.height(2.dp)) Text( - text = "${formatResetInSec(resetInSec)}后重置", + text = stringResource(R.string.service_reset_in, formatResetInSec(resetInSec, durationText)), style = MaterialTheme.typography.bodySmall, color = inkMuted() ) @@ -417,6 +444,7 @@ internal fun CompactUsageRowEmpty(label: String, resetInSec: Long?) { @Composable internal fun CompactUsageRow(label: String, pct: Int, resetInSec: Long?) { + val durationText = rememberDurationText() val pctValue = pct.coerceIn(0, 100).toFloat() Column { Row( @@ -458,7 +486,7 @@ internal fun CompactUsageRow(label: String, pct: Int, resetInSec: Long?) { if (resetInSec != null && resetInSec > 0) { Spacer(modifier = Modifier.height(2.dp)) Text( - text = "${formatResetInSec(resetInSec)}后重置", + text = stringResource(R.string.service_reset_in, formatResetInSec(resetInSec, durationText)), style = MaterialTheme.typography.bodySmall, color = inkMuted() ) @@ -466,45 +494,68 @@ internal fun CompactUsageRow(label: String, pct: Int, resetInSec: Long?) { } } +/** 当前语言环境的时长标签("天 / 小时 / 分")。 */ +@Composable +private fun rememberDurationText(): DurationText = DurationText( + day = stringResource(R.string.format_day), + hour = stringResource(R.string.format_hour), + minute = stringResource(R.string.format_minute) +) + // ── Dashboard utility functions ── internal fun DashboardCardUi.statusBadgeStyle(): StatusStyle = when { credentialState == CredentialStatus.State.NOT_CONFIGURED -> - StatusStyle("未配置", StatusLevel.WARNING) + StatusStyle(R.string.status_not_configured, StatusLevel.WARNING) lastFetchError != null -> - StatusStyle("刷新失败", StatusLevel.ERROR) + StatusStyle(R.string.status_refresh_failed, StatusLevel.ERROR) credentialState == CredentialStatus.State.EXPIRED -> - StatusStyle("已过期", StatusLevel.ERROR) + StatusStyle(R.string.status_expired, StatusLevel.ERROR) credentialState == CredentialStatus.State.WARNING -> - StatusStyle("需重登", StatusLevel.WARNING) + StatusStyle(R.string.status_relogin, StatusLevel.WARNING) cachedBalance == null -> - StatusStyle("待获取", StatusLevel.INFO) + StatusStyle(R.string.status_waiting, StatusLevel.INFO) else -> - StatusStyle("正常", StatusLevel.OK) + StatusStyle(R.string.status_normal, StatusLevel.OK) } -internal fun secondaryLine(card: DashboardCardUi): String = when (card.service) { - ServiceType.DEEPSEEK -> "REST API · ¥" - ServiceType.OPENCODE_GO -> "WebView 抓取 · 5h 配额" - ServiceType.COMMANDCODE_GO -> "JSON API · $" - ServiceType.CODEX -> "ChatGPT Plus · Codex 额度" - ServiceType.OLLAMA -> "Cookie 抓取 · Cloud 配额" +/** 服务副标题(资源 ID,由 UI 层解析)。 */ +@StringRes +internal fun secondaryLineRes(card: DashboardCardUi): Int = when (card.service) { + ServiceType.DEEPSEEK -> R.string.service_desc_deepseek + ServiceType.OPENCODE_GO -> R.string.service_desc_opencode_go + ServiceType.COMMANDCODE_GO -> R.string.service_desc_commandcode_go + ServiceType.CODEX -> R.string.service_desc_codex + ServiceType.OLLAMA -> R.string.service_desc_ollama } -internal fun footerText(card: DashboardCardUi): String { - if (card.lastFetchError != null) { - val msg = card.lastFetchError.take(60) - return "⚠ $msg${if (card.lastFetchError.length > 60) "…" else ""}" +/** + * 底部更新时间/错误文案(UiText 形式,UI 层按当前语言解析)。 + * 错误信息由 ViewModel 按 RepositoryError 类型映射为本地化资源。 + */ + +/** 截断 UiText 到最长 60 字符(仅对 Dynamic 原始文本生效;Resource 长度由资源控制)。 */ +private fun truncateUiText(text: UiText): UiText = when (text) { + is UiText.Dynamic -> UiText.Dynamic(text.value.take(60) + if (text.value.length > 60) "…" else "") + else -> text +} + +internal fun footerText(card: DashboardCardUi): UiText { + val error = card.lastFetchError + if (error != null) { + // 截断保护:错误信息最长展示 60 字符(以 UiText 形式保留,UI 层按当前语言解析) + return UiText.Resource(R.string.dashboard_error_prefix, listOf(truncateUiText(error))) } - val fetchedAt = card.cachedBalance?.fetchedAt ?: return "从未获取" + val fetchedAt = card.cachedBalance?.fetchedAt + ?: return UiText.Resource(R.string.footer_never_fetched) val sdf = SimpleDateFormat("MM-dd HH:mm", Locale.getDefault()) val now = System.currentTimeMillis() val diffMin = (now - fetchedAt) / 60_000 val timeStr = sdf.format(Date(fetchedAt)) return when { - diffMin < 1 -> "刚刚更新" - diffMin < 60 -> "$diffMin 分钟前更新" - diffMin < 1440 -> "${diffMin / 60} 小时前更新" - else -> "$timeStr 更新" + diffMin < 1 -> UiText.Resource(R.string.footer_just_updated) + diffMin < 60 -> UiText.Resource(R.string.footer_minutes_ago, listOf(diffMin)) + diffMin < 1440 -> UiText.Resource(R.string.footer_hours_ago, listOf(diffMin / 60)) + else -> UiText.Resource(R.string.footer_updated_at, listOf(timeStr)) } } \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/UsageChartScreen.kt b/app/src/main/java/com/rainy/token/ui/dashboard/UsageChartScreen.kt index 9119ebb..a1c92b8 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/UsageChartScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/UsageChartScreen.kt @@ -53,12 +53,15 @@ import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.rainy.token.R import com.rainy.token.data.local.ChartBucket import com.rainy.token.data.local.ChartGranularity import com.rainy.token.ui.theme.InkMuted @@ -100,6 +103,7 @@ fun UsageChartScreen( viewModel: UsageChartViewModel = hiltViewModel() ) { val state by viewModel.state.collectAsStateWithLifecycle() + val context = LocalContext.current var granularityExpanded by remember { mutableStateOf(false) } var modelExpanded by remember { mutableStateOf(false) } var showCustomDayPicker by remember { mutableStateOf(false) } @@ -115,9 +119,9 @@ fun UsageChartScreen( containerColor = Color.Transparent, topBar = { TopAppBar( - title = { Text("统计图表") }, + title = { Text(stringResource(R.string.title_chart)) }, navigationIcon = { - IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, "返回") } + IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, stringResource(R.string.action_back)) } }, colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent) ) @@ -125,7 +129,7 @@ fun UsageChartScreen( ) { innerPadding -> if (state.loading) { Box(Modifier.fillMaxSize().padding(innerPadding), contentAlignment = Alignment.Center) { - Text("加载中…", color = InkMuted) + Text(stringResource(R.string.common_loading), color = InkMuted) } } else { BoxWithConstraints(modifier = Modifier.fillMaxSize().padding(innerPadding)) { @@ -146,13 +150,13 @@ fun UsageChartScreen( // 粒度下拉 Box { TextButton(onClick = { granularityExpanded = true }) { - Text(state.granularity.label, color = StrawberryPink) + Text(stringResource(state.granularity.labelRes), color = StrawberryPink) Icon(Icons.Filled.ArrowDropDown, null, tint = StrawberryPink) } DropdownMenu(granularityExpanded, { granularityExpanded = false }) { ChartGranularity.entries.forEach { g -> DropdownMenuItem( - text = { Text(g.label) }, + text = { Text(stringResource(g.labelRes)) }, onClick = { granularityExpanded = false when (g) { @@ -169,15 +173,15 @@ fun UsageChartScreen( Spacer(Modifier.weight(1f)) // 模型多选下拉 Box { - val modelLabel = if (state.selectedModels.isEmpty()) "全部模型" - else "${state.selectedModels.size} 个模型" + val modelLabel = if (state.selectedModels.isEmpty()) stringResource(R.string.common_all_models) + else stringResource(R.string.model_count, state.selectedModels.size) TextButton(onClick = { modelExpanded = true }) { Text(modelLabel, color = StrawberryPink, style = MaterialTheme.typography.bodySmall) Icon(Icons.Filled.ArrowDropDown, null, tint = StrawberryPink) } DropdownMenu(modelExpanded, { modelExpanded = false }) { DropdownMenuItem( - text = { Text("全部模型", fontWeight = FontWeight.Bold) }, + text = { Text(stringResource(R.string.common_all_models), fontWeight = FontWeight.Bold) }, onClick = { modelExpanded = false; viewModel.selectAllModels() } ) state.allModels.forEach { model -> @@ -210,7 +214,7 @@ fun UsageChartScreen( horizontalArrangement = Arrangement.spacedBy(12.dp) ) { Box(modifier = Modifier.weight(1f)) { - ChartCard("消耗金额 (USD)", "$${String.format(Locale.US, "%.4f", costTotal)}", { showCostDetail = true }) { + ChartCard(stringResource(R.string.chart_cost), "$${String.format(Locale.US, "%.4f", costTotal)}", { showCostDetail = true }) { StackedBarChart( buckets = state.buckets, valueSelector = { it.totalCost.toDouble() / 100_000_000.0 }, @@ -235,12 +239,12 @@ fun UsageChartScreen( } } Box(modifier = Modifier.weight(1f)) { - ChartCard("API 请求次数", "${reqTotal}次", { showReqDetail = true }) { + ChartCard(stringResource(R.string.chart_requests), stringResource(R.string.count_times, reqTotal), { showReqDetail = true }) { LineChart( buckets = state.buckets, valueSelector = { it.totalRequests.toFloat() }, lineColor = StrawberryPink, - formatValue = { "${it.toInt()}次" }, + formatValue = { context.getString(R.string.count_times, it.toInt()) }, granularity = state.granularity, useUtc8 = state.useUtc8 ) @@ -251,7 +255,7 @@ fun UsageChartScreen( // 第三张图表独立一行 item { val tokTotal = state.buckets.sumOf { it.cacheHitTokens + it.inputTokens + it.outputTokens } - ChartCard("Token 消耗", formatTokenComma(tokTotal), { showTokenDetail = true }) { + ChartCard(stringResource(R.string.chart_tokens), formatTokenComma(tokTotal), { showTokenDetail = true }) { StackedBarChart( buckets = state.buckets, valueSelector = { (it.cacheHitTokens + it.inputTokens + it.outputTokens).toDouble() }, @@ -262,7 +266,7 @@ fun UsageChartScreen( bucket.cacheHitTokens.toDouble() to tokenColors[0] ) }, - stackLabels = { listOf("输出", "输入(未命中)", "命中缓存") }, + stackLabels = { listOf(context.getString(R.string.chart_stack_output), context.getString(R.string.chart_stack_input), context.getString(R.string.chart_stack_cache)) }, formatValue = { formatTokenComma(it.toLong()) }, granularity = state.granularity, tooltipReversed = true, @@ -271,9 +275,9 @@ fun UsageChartScreen( } ChartLegend( items = listOf( - "输入(未命中)" to tokenColors[1], - "命中缓存" to tokenColors[0], - "输出" to tokenColors[2] + stringResource(R.string.chart_stack_input) to tokenColors[1], + stringResource(R.string.chart_stack_cache) to tokenColors[0], + stringResource(R.string.chart_stack_output) to tokenColors[2] ) ) } @@ -283,7 +287,7 @@ fun UsageChartScreen( val models = state.selectedModels.ifEmpty { state.allModels.toSet() } val costTotal = state.buckets.sumOf { it.totalCost.toDouble() / 100_000_000.0 } ChartCard( - title = "消耗金额 (USD)", + title = stringResource(R.string.chart_cost), summary = "$${String.format(Locale.US, "%.4f", costTotal)}", onSummaryClick = { showCostDetail = true } ) { @@ -314,15 +318,15 @@ fun UsageChartScreen( item { val reqTotal = state.buckets.sumOf { it.totalRequests } ChartCard( - title = "API 请求次数", - summary = "${reqTotal}次", + title = stringResource(R.string.chart_requests), + summary = stringResource(R.string.count_times, reqTotal), onSummaryClick = { showReqDetail = true } ) { LineChart( buckets = state.buckets, valueSelector = { it.totalRequests.toFloat() }, lineColor = StrawberryPink, - formatValue = { "${it.toInt()}次" }, + formatValue = { context.getString(R.string.count_times, it.toInt()) }, granularity = state.granularity, useUtc8 = state.useUtc8 ) @@ -332,7 +336,7 @@ fun UsageChartScreen( item { val tokTotal = state.buckets.sumOf { it.cacheHitTokens + it.inputTokens + it.outputTokens } ChartCard( - title = "Token 消耗", + title = stringResource(R.string.chart_tokens), summary = formatTokenComma(tokTotal), onSummaryClick = { showTokenDetail = true } ) { @@ -346,7 +350,7 @@ fun UsageChartScreen( bucket.cacheHitTokens.toDouble() to tokenColors[0] ) }, - stackLabels = { listOf("输出", "输入(未命中)", "命中缓存") }, + stackLabels = { listOf(context.getString(R.string.chart_stack_output), context.getString(R.string.chart_stack_input), context.getString(R.string.chart_stack_cache)) }, formatValue = { formatTokenComma(it.toLong()) }, granularity = state.granularity, tooltipReversed = true, @@ -355,9 +359,9 @@ fun UsageChartScreen( } ChartLegend( items = listOf( - "输入(未命中)" to tokenColors[1], - "命中缓存" to tokenColors[0], - "输出" to tokenColors[2] + stringResource(R.string.chart_stack_input) to tokenColors[1], + stringResource(R.string.chart_stack_cache) to tokenColors[0], + stringResource(R.string.chart_stack_output) to tokenColors[2] ) ) } @@ -371,7 +375,7 @@ fun UsageChartScreen( // ─── 自定义日期选择器 ─── if (showCustomDayPicker) { DateTimePickerDialog( - title = "选择日期", + title = stringResource(R.string.date_select_day), onConfirm = { ms -> viewModel.setCustomDay(ms.toUtcLocalDate()) showCustomDayPicker = false @@ -381,7 +385,7 @@ fun UsageChartScreen( } if (showCustomMonthPicker) { DateTimePickerDialog( - title = "选择月份(任意一天)", + title = stringResource(R.string.date_select_month), onConfirm = { ms -> viewModel.setCustomMonth(ms.toUtcLocalDate()) showCustomMonthPicker = false @@ -391,7 +395,7 @@ fun UsageChartScreen( } if (showCustomRangeStart) { DateTimePickerDialog( - title = "开始日期", + title = stringResource(R.string.date_pick_start), onConfirm = { ms -> customRangeStartDate = ms.toUtcLocalDate() showCustomRangeStart = false @@ -402,7 +406,7 @@ fun UsageChartScreen( } if (showCustomRangeEnd) { DateTimePickerDialog( - title = "结束日期", + title = stringResource(R.string.date_pick_end), onConfirm = { ms -> val startDate = customRangeStartDate ?: return@DateTimePickerDialog val endDate = ms.toUtcLocalDate() @@ -420,7 +424,7 @@ fun UsageChartScreen( if (showCostDetail) { AlertDialog( onDismissRequest = { showCostDetail = false }, - title = { Text("消费明细", fontWeight = FontWeight.Bold) }, + title = { Text(stringResource(R.string.chart_detail_cost), fontWeight = FontWeight.Bold) }, text = { Column { models.forEach { model -> @@ -435,13 +439,13 @@ fun UsageChartScreen( } } }, - confirmButton = { TextButton(onClick = { showCostDetail = false }) { Text("关闭") } } + confirmButton = { TextButton(onClick = { showCostDetail = false }) { Text(stringResource(R.string.action_close)) } } ) } if (showReqDetail) { AlertDialog( onDismissRequest = { showReqDetail = false }, - title = { Text("调用次数明细", fontWeight = FontWeight.Bold) }, + title = { Text(stringResource(R.string.chart_detail_requests), fontWeight = FontWeight.Bold) }, text = { Column { models.forEach { model -> @@ -449,13 +453,13 @@ fun UsageChartScreen( if (total > 0) { Row(Modifier.fillMaxWidth().padding(vertical = 2.dp), horizontalArrangement = Arrangement.SpaceBetween) { Text(model, style = MaterialTheme.typography.bodySmall) - Text("${total}次", color = StrawberryPink, style = MaterialTheme.typography.bodySmall) + Text(stringResource(R.string.count_times, total), color = StrawberryPink, style = MaterialTheme.typography.bodySmall) } } } } }, - confirmButton = { TextButton(onClick = { showReqDetail = false }) { Text("关闭") } } + confirmButton = { TextButton(onClick = { showReqDetail = false }) { Text(stringResource(R.string.action_close)) } } ) } if (showTokenDetail) { @@ -464,24 +468,24 @@ fun UsageChartScreen( val outTotal = state.buckets.sumOf { it.outputTokens } AlertDialog( onDismissRequest = { showTokenDetail = false }, - title = { Text("Token 明细", fontWeight = FontWeight.Bold) }, + title = { Text(stringResource(R.string.chart_detail_tokens), fontWeight = FontWeight.Bold) }, text = { Column { Row(Modifier.fillMaxWidth().padding(vertical = 2.dp), horizontalArrangement = Arrangement.SpaceBetween) { - Text("命中缓存", style = MaterialTheme.typography.bodySmall) + Text(stringResource(R.string.chart_stack_cache), style = MaterialTheme.typography.bodySmall) Text(formatTokenComma(hitTotal), color = StrawberryPink, style = MaterialTheme.typography.bodySmall) } Row(Modifier.fillMaxWidth().padding(vertical = 2.dp), horizontalArrangement = Arrangement.SpaceBetween) { - Text("输入(未命中)", style = MaterialTheme.typography.bodySmall) + Text(stringResource(R.string.chart_stack_input), style = MaterialTheme.typography.bodySmall) Text(formatTokenComma(missTotal), color = StrawberryPink, style = MaterialTheme.typography.bodySmall) } Row(Modifier.fillMaxWidth().padding(vertical = 2.dp), horizontalArrangement = Arrangement.SpaceBetween) { - Text("输出", style = MaterialTheme.typography.bodySmall) + Text(stringResource(R.string.chart_stack_output), style = MaterialTheme.typography.bodySmall) Text(formatTokenComma(outTotal), color = StrawberryPink, style = MaterialTheme.typography.bodySmall) } } }, - confirmButton = { TextButton(onClick = { showTokenDetail = false }) { Text("关闭") } } + confirmButton = { TextButton(onClick = { showTokenDetail = false }) { Text(stringResource(R.string.action_close)) } } ) } } @@ -544,7 +548,7 @@ internal fun StackedBarChart( useUtc8: Boolean = false ) { if (buckets.isEmpty()) { - Text("暂无数据", color = InkMuted, style = MaterialTheme.typography.bodySmall) + Text(stringResource(R.string.common_no_data), color = InkMuted, style = MaterialTheme.typography.bodySmall) return } var tooltipBucket by remember { mutableStateOf(buckets.lastOrNull()) } @@ -639,7 +643,7 @@ internal fun StackedBarChart( ) { Column(Modifier.padding(8.dp)) { Text(formatChartTime(bucket.ts, granularity, 0, 1, useUtc8).first, style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold) - Text("合计: ${formatValue(total)}", style = MaterialTheme.typography.bodySmall, color = StrawberryPink) + Text(stringResource(R.string.chart_total_label, formatValue(total)), style = MaterialTheme.typography.bodySmall, color = StrawberryPink) val labels = stackLabels(bucket) val items = stackSelector(bucket).withIndex() val display = if (tooltipReversed) items.toList().reversed() else items.toList() @@ -674,7 +678,7 @@ internal fun LineChart( useUtc8: Boolean = false ) { if (buckets.isEmpty()) { - Text("暂无数据", color = InkMuted, style = MaterialTheme.typography.bodySmall) + Text(stringResource(R.string.common_no_data), color = InkMuted, style = MaterialTheme.typography.bodySmall) return } var tooltipBucket by remember { mutableStateOf(buckets.lastOrNull()) } diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/UsageDataScreen.kt b/app/src/main/java/com/rainy/token/ui/dashboard/UsageDataScreen.kt index ffae0e9..93b1f5f 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/UsageDataScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/UsageDataScreen.kt @@ -46,12 +46,14 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.rainy.token.R import com.rainy.token.data.local.UsageRecord import com.rainy.token.ui.theme.inkMuted import com.rainy.token.ui.theme.StrawberryPink @@ -93,8 +95,8 @@ fun UsageDataScreen( containerColor = Color.Transparent, topBar = { TopAppBar( - title = { Text("详细数据") }, - navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, "返回") } }, + title = { Text(stringResource(R.string.title_raw_data)) }, + navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, stringResource(R.string.action_back)) } }, colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent) ) } @@ -105,7 +107,7 @@ fun UsageDataScreen( // 时间筛选 Box { TextButton(onClick = { timeMenuExpanded = true }) { - Text(state.timeFilter.label, color = StrawberryPink, fontWeight = FontWeight.SemiBold, fontSize = 13.sp) + Text(stringResource(state.timeFilter.labelRes), color = StrawberryPink, fontWeight = FontWeight.SemiBold, fontSize = 13.sp) Icon(Icons.Filled.ArrowDropDown, null, tint = StrawberryPink) } DropdownMenu(timeMenuExpanded, { timeMenuExpanded = false }) { @@ -114,17 +116,17 @@ fun UsageDataScreen( TimeFilter.Today, TimeFilter.Yesterday, TimeFilter.Last7Days, TimeFilter.Last30Days, TimeFilter.ThisMonth ).forEach { f -> - DropdownMenuItem(text = { Text(f.label) }, onClick = { + DropdownMenuItem(text = { Text(stringResource(f.labelRes)) }, onClick = { timeMenuExpanded = false; viewModel.setTimeFilter(f) }) } - DropdownMenuItem(text = { Text("自定义天") }, onClick = { + DropdownMenuItem(text = { Text(stringResource(R.string.time_custom_day)) }, onClick = { timeMenuExpanded = false; showCustomDayPicker = true }) - DropdownMenuItem(text = { Text("自定义月") }, onClick = { + DropdownMenuItem(text = { Text(stringResource(R.string.time_custom_month)) }, onClick = { timeMenuExpanded = false; showCustomMonthPicker = true }) - DropdownMenuItem(text = { Text("自定义时间") }, onClick = { + DropdownMenuItem(text = { Text(stringResource(R.string.time_custom_range)) }, onClick = { timeMenuExpanded = false; viewModel.setTimeFilter(TimeFilter.Custom(0L, 0L)) }) } @@ -133,13 +135,13 @@ fun UsageDataScreen( Spacer(Modifier.weight(1f)) // 模型筛选 Box { - val label = if (state.selectedModels.isEmpty()) "全部模型" else "${state.selectedModels.size} 个" + val label = if (state.selectedModels.isEmpty()) stringResource(R.string.common_all_models) else stringResource(R.string.model_count_short, state.selectedModels.size) TextButton(onClick = { modelMenuExpanded = true }) { Text(label, color = StrawberryPink, fontSize = 13.sp) Icon(Icons.Filled.ArrowDropDown, null, tint = StrawberryPink) } DropdownMenu(modelMenuExpanded, { modelMenuExpanded = false }) { - DropdownMenuItem(text = { Text("全部模型", fontWeight = FontWeight.Bold) }, + DropdownMenuItem(text = { Text(stringResource(R.string.common_all_models), fontWeight = FontWeight.Bold) }, onClick = { modelMenuExpanded = false; viewModel.selectAllModels() }) state.allModels.forEach { m -> DropdownMenuItem(text = { @@ -158,12 +160,12 @@ fun UsageDataScreen( CustomTimeRangeRow(customStartMs, customEndMs, { showStartPicker = true }, { showEndPicker = true }, { viewModel.setTimeFilter(TimeFilter.Custom(customStartMs, customEndMs)) }) - Text("所有时间按 UTC+0 计算", style = MaterialTheme.typography.bodySmall, color = inkMuted()) + Text(stringResource(R.string.usage_utc0_note), style = MaterialTheme.typography.bodySmall, color = inkMuted()) } // ─── 记录数 + 分页 ─── Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), Arrangement.SpaceBetween, Alignment.CenterVertically) { - Text("${state.totalRecords} 条记录", style = MaterialTheme.typography.bodySmall, color = inkMuted()) + Text(stringResource(R.string.usage_record_count, state.totalRecords), style = MaterialTheme.typography.bodySmall, color = inkMuted()) if (state.totalPages > 1) { Row(verticalAlignment = Alignment.CenterVertically) { TextButton(onClick = { viewModel.prevPage() }, enabled = state.currentPage > 1) { @@ -188,7 +190,7 @@ fun UsageDataScreen( val p = pageInput.toIntOrNull() if (p != null) { viewModel.goToPage(p); pageInput = "" } }) { - Text("跳转", color = StrawberryPink, fontSize = 12.sp) + Text(stringResource(R.string.action_jump), color = StrawberryPink, fontSize = 12.sp) } } } @@ -198,22 +200,22 @@ fun UsageDataScreen( Card(Modifier.fillMaxWidth(), RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp), CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) { Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp)) { - HeaderCell("时间", Modifier.weight(1.5f), false) - HeaderCell("模型", Modifier.weight(1.8f), false) - HeaderCell("输入(缓存)", Modifier.weight(1.5f), false) - HeaderCell("输出", Modifier.weight(1.2f), false) - HeaderCell("成本", Modifier.weight(1.0f), false) + HeaderCell(stringResource(R.string.header_time), Modifier.weight(1.5f), false) + HeaderCell(stringResource(R.string.header_model), Modifier.weight(1.8f), false) + HeaderCell(stringResource(R.string.header_input_cache), Modifier.weight(1.5f), false) + HeaderCell(stringResource(R.string.header_output), Modifier.weight(1.2f), false) + HeaderCell(stringResource(R.string.header_cost), Modifier.weight(1.0f), false) } } // ─── 记录列表 ─── if (state.loading) { Box(Modifier.fillMaxWidth().height(200.dp), contentAlignment = Alignment.Center) { - Text("加载中…", color = inkMuted()) + Text(stringResource(R.string.common_loading), color = inkMuted()) } } else if (state.records.isEmpty()) { Box(Modifier.fillMaxWidth().height(200.dp), contentAlignment = Alignment.Center) { - Text("暂无数据", color = inkMuted()) + Text(stringResource(R.string.common_no_data), color = inkMuted()) } } else { LazyColumn(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(1.dp)) { @@ -226,18 +228,18 @@ fun UsageDataScreen( } // 日期选择器 - if (showCustomDayPicker) DateTimePickerDialog("选择日期", { ms -> + if (showCustomDayPicker) DateTimePickerDialog(stringResource(R.string.date_select_day), { ms -> val utc = ZoneOffset.UTC; val ds = Instant.ofEpochMilli(ms).atOffset(utc).toLocalDate().atStartOfDay(utc).toInstant().toEpochMilli() viewModel.setTimeFilter(TimeFilter.Custom(ds, ds + 86400_000L - 1)); showCustomDayPicker = false }, { showCustomDayPicker = false }) - if (showCustomMonthPicker) DateTimePickerDialog("选择月份(任意一天)", { ms -> + if (showCustomMonthPicker) DateTimePickerDialog(stringResource(R.string.date_select_month), { ms -> val utc = ZoneOffset.UTC; val ld = Instant.ofEpochMilli(ms).atOffset(utc).toLocalDate() val msStart = ld.withDayOfMonth(1).atStartOfDay(utc).toInstant().toEpochMilli() val msEnd = ld.withDayOfMonth(ld.lengthOfMonth()).plusDays(1).atStartOfDay(utc).toInstant().toEpochMilli() - 1 viewModel.setTimeFilter(TimeFilter.Custom(msStart, msEnd)); showCustomMonthPicker = false }, { showCustomMonthPicker = false }) - if (showStartPicker) DateTimePickerDialog("开始时间", { customStartMs = it; showStartPicker = false }, { showStartPicker = false }) - if (showEndPicker) DateTimePickerDialog("结束时间", { ms -> + if (showStartPicker) DateTimePickerDialog(stringResource(R.string.date_start), { customStartMs = it; showStartPicker = false }, { showStartPicker = false }) + if (showEndPicker) DateTimePickerDialog(stringResource(R.string.date_end), { ms -> viewModel.setTimeFilter(TimeFilter.Custom(customStartMs, ms)); showEndPicker = false }, { showEndPicker = false }) @@ -245,26 +247,26 @@ fun UsageDataScreen( rawRecord?.let { record -> AlertDialog( onDismissRequest = { rawRecord = null }, - title = { Text("原始数据", fontWeight = FontWeight.Bold) }, + title = { Text(stringResource(R.string.raw_record_title), fontWeight = FontWeight.Bold) }, text = { Column(Modifier.horizontalScroll(rememberScrollState())) { RawField("ID", record.id) - RawField("时间", formatUtcTime(record.timeCreated)) - RawField("模型", record.model) + RawField(stringResource(R.string.header_time), formatUtcTime(record.timeCreated)) + RawField(stringResource(R.string.header_model), record.model) RawField("Provider", record.provider) - RawField("输入 Token", "%,d".format(Locale.US, record.inputTokens)) - RawField("输出 Token", "%,d".format(Locale.US, record.outputTokens)) - RawField("推理 Token", "%,d".format(Locale.US, record.reasoningTokens)) - RawField("缓存读取", "%,d".format(Locale.US, record.cacheReadTokens)) - RawField("缓存写入(5m)", "%,d".format(Locale.US, record.cacheWrite5mTokens)) - RawField("缓存写入(1h)", "%,d".format(Locale.US, record.cacheWrite1hTokens)) - RawField("成本(USD)", "$${String.format(Locale.US, "%.6f", record.costUsd)}") - RawField("成本(Raw)", "${record.cost}") + RawField(stringResource(R.string.usage_input_tokens), "%,d".format(Locale.US, record.inputTokens)) + RawField(stringResource(R.string.usage_output_tokens), "%,d".format(Locale.US, record.outputTokens)) + RawField(stringResource(R.string.usage_reasoning_tokens), "%,d".format(Locale.US, record.reasoningTokens)) + RawField(stringResource(R.string.stat_cache_read), "%,d".format(Locale.US, record.cacheReadTokens)) + RawField(stringResource(R.string.stat_cache_write_5m), "%,d".format(Locale.US, record.cacheWrite5mTokens)) + RawField(stringResource(R.string.stat_cache_write_1h), "%,d".format(Locale.US, record.cacheWrite1hTokens)) + RawField(stringResource(R.string.raw_cost_usd), "$${String.format(Locale.US, "%.6f", record.costUsd)}") + RawField(stringResource(R.string.raw_cost_raw), "${record.cost}") RawField("KeyId", record.keyId) RawField("SessionId", record.sessionId) } }, - confirmButton = { TextButton(onClick = { rawRecord = null }) { Text("关闭") } } + confirmButton = { TextButton(onClick = { rawRecord = null }) { Text(stringResource(R.string.action_close)) } } ) } } diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/UsageDetailScreen.kt b/app/src/main/java/com/rainy/token/ui/dashboard/UsageDetailScreen.kt index 3cf3526..df2a2cd 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/UsageDetailScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/UsageDetailScreen.kt @@ -50,11 +50,14 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.rainy.token.R import com.rainy.token.data.local.ChartGranularity import com.rainy.token.ui.theme.InkMuted import com.rainy.token.ui.theme.StrawberryPink @@ -80,6 +83,7 @@ fun UsageDetailScreen( clearViewModel: UsageViewModel? = null // non-null = CCGO, 显示清除按钮 ) { val state by viewModel.state.collectAsStateWithLifecycle() + val context = LocalContext.current // OCGO 首次加载(CCGO 由 NavHost 的 setWorkspace 触发,不重复 load) LaunchedEffect(Unit) { @@ -106,16 +110,16 @@ fun UsageDetailScreen( snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { TopAppBar( - title = { Text("用量详情") }, - navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, "返回") } }, + title = { Text(stringResource(R.string.title_usage_detail)) }, + navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, stringResource(R.string.action_back)) } }, actions = { if (clearViewModel != null) { TextButton(onClick = { showClearDialog = true }) { - Text("清除", color = MaterialTheme.colorScheme.error, fontWeight = FontWeight.SemiBold, fontSize = 13.sp) + Text(stringResource(R.string.action_clear), color = MaterialTheme.colorScheme.error, fontWeight = FontWeight.SemiBold, fontSize = 13.sp) } } TextButton(onClick = onOpenData) { - Text("详细数据", color = StrawberryPink, fontWeight = FontWeight.SemiBold, fontSize = 13.sp) + Text(stringResource(R.string.action_view_raw_data), color = StrawberryPink, fontWeight = FontWeight.SemiBold, fontSize = 13.sp) } }, colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent) @@ -124,7 +128,7 @@ fun UsageDetailScreen( ) { innerPadding -> if (state.loading) { Box(Modifier.fillMaxSize().padding(innerPadding), contentAlignment = Alignment.Center) { - Text("加载中…", color = InkMuted) + Text(stringResource(R.string.common_loading), color = InkMuted) } } else { BoxWithConstraints(modifier = Modifier.fillMaxSize().padding(innerPadding)) { @@ -139,12 +143,12 @@ fun UsageDetailScreen( Row(Modifier.fillMaxWidth(), Arrangement.spacedBy(8.dp), Alignment.CenterVertically) { Box { TextButton(onClick = { granularityExpanded = true }) { - Text(state.granularity.label, color = StrawberryPink) + Text(stringResource(state.granularity.labelRes), color = StrawberryPink) Icon(Icons.Filled.ArrowDropDown, null, tint = StrawberryPink) } DropdownMenu(granularityExpanded, { granularityExpanded = false }) { ChartGranularity.entries.forEach { g -> - DropdownMenuItem(text = { Text(g.label) }, onClick = { + DropdownMenuItem(text = { Text(stringResource(g.labelRes)) }, onClick = { granularityExpanded = false when (g) { ChartGranularity.CUSTOM_DAY_HOURLY -> showCustomDayPicker = true @@ -160,7 +164,7 @@ fun UsageDetailScreen( viewModel.toggleUtc8() scope.launch { snackbarHostState.showSnackbar( - if (state.useUtc8) "已切换到 UTC+8,图表按北京时间显示" else "已切换到 UTC+0,图表按世界协调时显示", + if (state.useUtc8) context.getString(R.string.msg_utc8_on) else context.getString(R.string.msg_utc0_on), duration = SnackbarDuration.Short ) } @@ -172,7 +176,7 @@ fun UsageDetailScreen( fontWeight = if (state.useUtc8) FontWeight.SemiBold else FontWeight.Normal ) Text( - " 点击切换", + stringResource(R.string.usage_click_to_switch), style = MaterialTheme.typography.bodySmall, color = InkMuted.copy(alpha = 0.5f), fontWeight = FontWeight.Normal @@ -180,14 +184,14 @@ fun UsageDetailScreen( } Spacer(Modifier.weight(1f)) Box { - val label = if (state.selectedModels.isEmpty()) "全部模型" else "${state.selectedModels.size} 个模型" + val label = if (state.selectedModels.isEmpty()) stringResource(R.string.common_all_models) else stringResource(R.string.model_count, state.selectedModels.size) TextButton(onClick = { modelExpanded = true }) { Text(label, color = StrawberryPink, style = MaterialTheme.typography.bodySmall) Icon(Icons.Filled.ArrowDropDown, null, tint = StrawberryPink) } DropdownMenu(modelExpanded, { modelExpanded = false }) { DropdownMenuItem( - text = { Text("全部模型", fontWeight = FontWeight.Bold) }, + text = { Text(stringResource(R.string.common_all_models), fontWeight = FontWeight.Bold) }, onClick = { modelExpanded = false; viewModel.selectAllModels() }) state.allModels.forEach { model -> DropdownMenuItem(text = { @@ -213,7 +217,7 @@ fun UsageDetailScreen( horizontalArrangement = Arrangement.spacedBy(12.dp) ) { Box(modifier = Modifier.weight(1f)) { - ChartCard("消耗金额 (USD)", "$${String.format(Locale.US, "%.4f", costTotal)}", { showCostDetail = true }) { + ChartCard(stringResource(R.string.chart_cost), "$${String.format(Locale.US, "%.4f", costTotal)}", { showCostDetail = true }) { StackedBarChart(state.buckets, { it.totalCost.toDouble() / 100_000_000.0 }, { bucket -> models.mapIndexedNotNull { idx, m -> val v = bucket.byModel[m]?.cost ?: return@mapIndexedNotNull null; v.toDouble() / 100_000_000.0 to modelColors[idx % modelColors.size] } }, @@ -228,33 +232,33 @@ fun UsageDetailScreen( } } Box(modifier = Modifier.weight(1f)) { - ChartCard("API 请求次数", "${reqTotal}次", { showReqDetail = true }) { - LineChart(state.buckets, { it.totalRequests.toFloat() }, StrawberryPink, { "${it.toInt()}次" }, state.granularity, useUtc8 = state.useUtc8) + ChartCard(stringResource(R.string.chart_requests), stringResource(R.string.count_times, reqTotal), { showReqDetail = true }) { + LineChart(state.buckets, { it.totalRequests.toFloat() }, StrawberryPink, { context.getString(R.string.count_times, it.toInt()) }, state.granularity, useUtc8 = state.useUtc8) } } } } item { val tokTotal = state.buckets.sumOf { it.cacheHitTokens + it.inputTokens + it.outputTokens } - ChartCard("Token 消耗", formatTokenComma(tokTotal), { showTokenDetail = true }) { + ChartCard(stringResource(R.string.chart_tokens), formatTokenComma(tokTotal), { showTokenDetail = true }) { StackedBarChart(state.buckets, { (it.cacheHitTokens + it.inputTokens + it.outputTokens).toDouble() }, { bucket -> listOfNotNull(bucket.outputTokens.toDouble() to tokenColors[2], bucket.inputTokens.toDouble() to tokenColors[1], bucket.cacheHitTokens.toDouble() to tokenColors[0]) }, - { listOf("输出", "输入(未命中)", "命中缓存") }, + { listOf(context.getString(R.string.chart_stack_output), context.getString(R.string.chart_stack_input), context.getString(R.string.chart_stack_cache)) }, formatValue = { formatTokenComma(it.toLong()) }, granularity = state.granularity, tooltipReversed = true, useUtc8 = state.useUtc8 ) } - ChartLegend(listOf("输入(未命中)" to tokenColors[1], "命中缓存" to tokenColors[0], "输出" to tokenColors[2])) + ChartLegend(listOf(stringResource(R.string.chart_stack_input) to tokenColors[1], stringResource(R.string.chart_stack_cache) to tokenColors[0], stringResource(R.string.chart_stack_output) to tokenColors[2])) } } else { // 窄面板:三张图表纵向堆叠 item { val models = state.selectedModels.ifEmpty { state.allModels.toSet() } val costTotal = state.buckets.sumOf { it.totalCost.toDouble() / 100_000_000.0 } - ChartCard("消耗金额 (USD)", "$${String.format(Locale.US, "%.4f", costTotal)}", { showCostDetail = true }) { + ChartCard(stringResource(R.string.chart_cost), "$${String.format(Locale.US, "%.4f", costTotal)}", { showCostDetail = true }) { StackedBarChart(state.buckets, { it.totalCost.toDouble() / 100_000_000.0 }, { bucket -> models.mapIndexedNotNull { idx, m -> val v = bucket.byModel[m]?.cost ?: return@mapIndexedNotNull null; v.toDouble() / 100_000_000.0 to modelColors[idx % modelColors.size] } }, @@ -270,29 +274,29 @@ fun UsageDetailScreen( } item { val reqTotal = state.buckets.sumOf { it.totalRequests } - ChartCard("API 请求次数", "${reqTotal}次", { showReqDetail = true }) { - LineChart(state.buckets, { it.totalRequests.toFloat() }, StrawberryPink, { "${it.toInt()}次" }, state.granularity, useUtc8 = state.useUtc8) + ChartCard(stringResource(R.string.chart_requests), stringResource(R.string.count_times, reqTotal), { showReqDetail = true }) { + LineChart(state.buckets, { it.totalRequests.toFloat() }, StrawberryPink, { context.getString(R.string.count_times, it.toInt()) }, state.granularity, useUtc8 = state.useUtc8) } } item { val tokTotal = state.buckets.sumOf { it.cacheHitTokens + it.inputTokens + it.outputTokens } - ChartCard("Token 消耗", formatTokenComma(tokTotal), { showTokenDetail = true }) { + ChartCard(stringResource(R.string.chart_tokens), formatTokenComma(tokTotal), { showTokenDetail = true }) { StackedBarChart(state.buckets, { (it.cacheHitTokens + it.inputTokens + it.outputTokens).toDouble() }, { bucket -> listOfNotNull(bucket.outputTokens.toDouble() to tokenColors[2], bucket.inputTokens.toDouble() to tokenColors[1], bucket.cacheHitTokens.toDouble() to tokenColors[0]) }, - { listOf("输出", "输入(未命中)", "命中缓存") }, + { listOf(context.getString(R.string.chart_stack_output), context.getString(R.string.chart_stack_input), context.getString(R.string.chart_stack_cache)) }, formatValue = { formatTokenComma(it.toLong()) }, granularity = state.granularity, tooltipReversed = true, useUtc8 = state.useUtc8 ) } - ChartLegend(listOf("输入(未命中)" to tokenColors[1], "命中缓存" to tokenColors[0], "输出" to tokenColors[2])) + ChartLegend(listOf(stringResource(R.string.chart_stack_input) to tokenColors[1], stringResource(R.string.chart_stack_cache) to tokenColors[0], stringResource(R.string.chart_stack_output) to tokenColors[2])) } } item { TextButton(onClick = onOpenOverview, modifier = Modifier.fillMaxWidth()) { - Text("📋 总统计", color = StrawberryPink, fontWeight = FontWeight.Bold) + Text("📋 " + stringResource(R.string.title_overview), color = StrawberryPink, fontWeight = FontWeight.Bold) } } item { Spacer(Modifier.height(32.dp)) } @@ -300,15 +304,15 @@ fun UsageDetailScreen( } } // 自定义日期选择器 - if (showCustomDayPicker) DateOnlyPickerDialog("选择日期", { date -> + if (showCustomDayPicker) DateOnlyPickerDialog(stringResource(R.string.date_select_day), { date -> viewModel.setCustomDay(date) showCustomDayPicker = false }, { showCustomDayPicker = false }) - if (showCustomMonthPicker) DateOnlyPickerDialog("选择月份(任意一天)", { date -> + if (showCustomMonthPicker) DateOnlyPickerDialog(stringResource(R.string.date_select_month), { date -> viewModel.setCustomMonth(date) showCustomMonthPicker = false }, { showCustomMonthPicker = false }) - if (showCustomRangeStart) DateOnlyPickerDialog("开始日期", { date -> + if (showCustomRangeStart) DateOnlyPickerDialog(stringResource(R.string.date_pick_start), { date -> customRangeStartDate = date showCustomRangeStart = false showCustomRangeEnd = true @@ -316,7 +320,7 @@ fun UsageDetailScreen( if (showCustomRangeEnd) { val startDate = customRangeStartDate DateOnlyPickerDialog( - title = "结束日期", + title = stringResource(R.string.date_pick_end), onConfirm = { endDate -> if (startDate != null && !endDate.isBefore(startDate)) { viewModel.setCustomRange(startDate, endDate) @@ -328,27 +332,27 @@ fun UsageDetailScreen( ) } val models = state.selectedModels.ifEmpty { state.allModels.toSet() } - if (showCostDetail) ChartDetailDialog("消费明细", { showCostDetail = false }) { + if (showCostDetail) ChartDetailDialog(stringResource(R.string.chart_detail_cost), { showCostDetail = false }) { models.forEach { model -> val t = state.buckets.sumOf { it.byModel[model]?.cost ?:0L }; if (t>0) DetailRow(model, "$${String.format(Locale.US, "%.4f", t/100_000_000.0)}") } } - if (showReqDetail) ChartDetailDialog("调用次数明细", { showReqDetail = false }) { - models.forEach { model -> val t = state.buckets.sumOf { it.byModel[model]?.requests ?:0 }; if (t>0) DetailRow(model, "${t}次") } + if (showReqDetail) ChartDetailDialog(stringResource(R.string.chart_detail_requests), { showReqDetail = false }) { + models.forEach { model -> val t = state.buckets.sumOf { it.byModel[model]?.requests ?: 0 }; if (t>0) DetailRow(model, stringResource(R.string.count_times, t)) } } - if (showTokenDetail) ChartDetailDialog("Token 明细", { showTokenDetail = false }) { - DetailRow("命中缓存", formatTokenComma(state.buckets.sumOf { it.cacheHitTokens })) - DetailRow("输入(未命中)", formatTokenComma(state.buckets.sumOf { it.inputTokens })) - DetailRow("输出", formatTokenComma(state.buckets.sumOf { it.outputTokens })) + if (showTokenDetail) ChartDetailDialog(stringResource(R.string.chart_detail_tokens), { showTokenDetail = false }) { + DetailRow(stringResource(R.string.chart_stack_cache), formatTokenComma(state.buckets.sumOf { it.cacheHitTokens })) + DetailRow(stringResource(R.string.chart_stack_input), formatTokenComma(state.buckets.sumOf { it.inputTokens })) + DetailRow(stringResource(R.string.chart_stack_output), formatTokenComma(state.buckets.sumOf { it.outputTokens })) } if (showClearDialog && clearViewModel != null) { val cd = clearCountdown androidx.compose.material3.AlertDialog( onDismissRequest = { showClearDialog = false; clearCountdown = 0 }, - title = { Text("⚠️ 清除数据", fontWeight = FontWeight.Bold) }, + title = { Text("⚠️ " + stringResource(R.string.dialog_clear_title), fontWeight = FontWeight.Bold) }, text = { Column { - Text("此操作将清除所有 CommandCode 用量本地缓存并重新同步,需要几秒钟完成。") + Text(stringResource(R.string.dialog_clear_body)) Spacer(Modifier.height(12.dp)) - Text("是否继续?", fontWeight = FontWeight.SemiBold) + Text(stringResource(R.string.dialog_clear_confirm_question), fontWeight = FontWeight.SemiBold) } }, confirmButton = { @@ -362,14 +366,14 @@ fun UsageDetailScreen( enabled = cd == 0 ) { Text( - if (cd > 0) "确认(${cd}s)" else "确认清除", + if (cd > 0) stringResource(R.string.action_confirm_countdown, cd) else stringResource(R.string.action_confirm_clear), color = if (cd == 0) MaterialTheme.colorScheme.error else InkMuted ) } }, dismissButton = { TextButton(onClick = { showClearDialog = false; clearCountdown = 0 }) { - Text("取消") + Text(stringResource(R.string.action_cancel)) } } ) @@ -391,7 +395,7 @@ private fun ChartDetailDialog(title: String, onDismiss: () -> Unit, content: @Co onDismissRequest = onDismiss, title = { Text(title, fontWeight = FontWeight.Bold) }, text = { androidx.compose.foundation.layout.Column { content() } }, - confirmButton = { TextButton(onClick = onDismiss) { Text("关闭") } }) + confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_close)) } }) } @Composable private fun DetailRow(label: String, value: String) { Row(Modifier.fillMaxWidth().padding(vertical = 2.dp), Arrangement.SpaceBetween) { @@ -421,14 +425,14 @@ internal fun CustomTimeRangeRow( ) { TextButton(onClick = onPickStart, modifier = Modifier.weight(1f)) { Text( - text = if (startMs > 0) "从 ${fmt.format(Date(startMs))}" else "开始时间", + text = if (startMs > 0) stringResource(R.string.range_from, fmt.format(Date(startMs))) else stringResource(R.string.date_start), style = MaterialTheme.typography.bodySmall, color = if (startMs > 0) StrawberryPink else InkMuted ) } TextButton(onClick = onPickEnd, modifier = Modifier.weight(1f)) { Text( - text = if (endMs > 0) "至 ${fmt.format(Date(endMs))}" else "结束时间", + text = if (endMs > 0) stringResource(R.string.range_to, fmt.format(Date(endMs))) else stringResource(R.string.date_end), style = MaterialTheme.typography.bodySmall, color = if (endMs > 0) StrawberryPink else InkMuted ) @@ -436,7 +440,7 @@ internal fun CustomTimeRangeRow( } if (startMs > 0 && endMs > 0) { TextButton(onClick = onApply) { - Text("应用自定义范围", color = StrawberryPink, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.action_apply_custom_range), color = StrawberryPink, fontWeight = FontWeight.Bold) } } } @@ -471,10 +475,10 @@ private fun DateOnlyPickerDialog( }, enabled = dateState.selectedDateMillis != null ) { - Text("确定") + Text(stringResource(R.string.action_confirm)) } }, - dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } } + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } } ) { Column { Text( @@ -485,7 +489,7 @@ private fun DateOnlyPickerDialog( ) if (minDate != null) { Text( - text = "结束日期不能早于开始日期 ${minDate.formatDateLabel()}", + text = stringResource(R.string.date_end_before_start, minDate.formatDateLabel()), modifier = Modifier.padding(start = 24.dp, top = 8.dp, end = 24.dp), style = MaterialTheme.typography.bodySmall, color = InkMuted @@ -525,20 +529,20 @@ internal fun DateTimePickerDialog( onConfirm(epochMs) } }) { - Text("确定") + Text(stringResource(R.string.action_confirm)) } }, - dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } } + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } } ) { Column { DatePicker(state = dateState) Spacer(modifier = Modifier.height(8.dp)) Text( - text = "时间 ${ - String.format("%02d", timeState.hour) - }:${ + text = stringResource( + R.string.time_hm, + String.format("%02d", timeState.hour), String.format("%02d", timeState.minute) - }", + ), modifier = Modifier.padding(horizontal = 24.dp), style = MaterialTheme.typography.titleMedium ) diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/UsageOverviewScreen.kt b/app/src/main/java/com/rainy/token/ui/dashboard/UsageOverviewScreen.kt index 0753e6a..463adda 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/UsageOverviewScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/UsageOverviewScreen.kt @@ -40,10 +40,13 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.rainy.token.R import com.rainy.token.data.local.ModelStats import com.rainy.token.data.local.OverviewStats import com.rainy.token.ui.theme.inkMuted @@ -80,9 +83,9 @@ fun UsageOverviewScreen( containerColor = Color.Transparent, topBar = { TopAppBar( - title = { Text("总统计") }, + title = { Text(stringResource(R.string.title_overview)) }, navigationIcon = { - IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, "返回") } + IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, stringResource(R.string.action_back)) } }, colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent) ) @@ -96,7 +99,7 @@ fun UsageOverviewScreen( item { Row(verticalAlignment = Alignment.CenterVertically) { TextButton(onClick = { menuExpanded = true }) { - Text(uiState.timeFilter.label, color = StrawberryPink, fontWeight = FontWeight.SemiBold) + Text(stringResource(uiState.timeFilter.labelRes), color = StrawberryPink, fontWeight = FontWeight.SemiBold) Icon(Icons.Filled.ArrowDropDown, null, tint = StrawberryPink) } Text("UTC+0", style = MaterialTheme.typography.bodySmall, color = inkMuted(), modifier = Modifier.padding(start = 4.dp)) @@ -106,12 +109,12 @@ fun UsageOverviewScreen( TimeFilter.Last7Days, TimeFilter.Last30Days, TimeFilter.ThisMonth ).forEach { filter -> DropdownMenuItem( - text = { Text(filter.label) }, + text = { Text(stringResource(filter.labelRes)) }, onClick = { menuExpanded = false; viewModel.setTimeFilter(filter) } ) } DropdownMenuItem( - text = { Text("自定义") }, + text = { Text(stringResource(R.string.time_custom)) }, onClick = { menuExpanded = false; viewModel.setTimeFilter(TimeFilter.Custom(0L, 0L)) } ) } @@ -122,7 +125,7 @@ fun UsageOverviewScreen( CustomTimeRangeRow(customStartMs, customEndMs, { showStartPicker = true }, { showEndPicker = true }, { viewModel.setTimeFilter(TimeFilter.Custom(customStartMs, customEndMs)) }) - Text("所有时间按 UTC+0 计算", style = MaterialTheme.typography.bodySmall, color = inkMuted()) + Text(stringResource(R.string.usage_utc0_note), style = MaterialTheme.typography.bodySmall, color = inkMuted()) } } // 总览 @@ -134,7 +137,7 @@ fun UsageOverviewScreen( } // 按模型 if (uiState.modelStats.isNotEmpty()) { - item { SectionHeader("按模型统计") } + item { SectionHeader(stringResource(R.string.section_by_model)) } items(uiState.modelStats, key = { it.model }) { ModelDetailRow(it) } } // 按天 @@ -144,14 +147,14 @@ fun UsageOverviewScreen( val shown = uiState.dailyStats.drop(startIdx).take(DAILY_PAGE_SIZE) item { Row(verticalAlignment = Alignment.CenterVertically) { - SectionHeader("按天统计(UTC+0)"); Spacer(Modifier.weight(1f)) + SectionHeader(stringResource(R.string.section_by_day)); Spacer(Modifier.weight(1f)) Box { TextButton(onClick = { modelMenuExpanded = true }) { - Text(uiState.modelFilter ?: "全部模型", color = StrawberryPink, style = MaterialTheme.typography.bodySmall) + Text(uiState.modelFilter ?: stringResource(R.string.common_all_models), color = StrawberryPink, style = MaterialTheme.typography.bodySmall) Icon(Icons.Filled.ArrowDropDown, null, tint = StrawberryPink) } DropdownMenu(modelMenuExpanded, { modelMenuExpanded = false }) { - DropdownMenuItem(text = { Text("全部模型", fontWeight = FontWeight.Bold) }, + DropdownMenuItem(text = { Text(stringResource(R.string.common_all_models), fontWeight = FontWeight.Bold) }, onClick = { modelMenuExpanded = false; viewModel.setModelFilter(null) }) uiState.modelStats.forEach { stat -> DropdownMenuItem(text = { Text(stat.model) }, @@ -166,18 +169,18 @@ fun UsageOverviewScreen( item { Row(Modifier.fillMaxWidth(), Arrangement.SpaceBetween, Alignment.CenterVertically) { TextButton(onClick = { viewModel.prevDailyPage() }, enabled = uiState.dailyPage > 1) { - Text("◀ 上一页", color = StrawberryPink, style = MaterialTheme.typography.bodySmall) } + Text(stringResource(R.string.action_prev_page), color = StrawberryPink, style = MaterialTheme.typography.bodySmall) } Text("${uiState.dailyPage} / $totalPages", style = MaterialTheme.typography.bodySmall, color = inkMuted()) TextButton(onClick = { viewModel.nextDailyPage() }, enabled = uiState.dailyPage < totalPages) { - Text("下一页 ▶", color = StrawberryPink, style = MaterialTheme.typography.bodySmall) } + Text(stringResource(R.string.action_next_page), color = StrawberryPink, style = MaterialTheme.typography.bodySmall) } } } } } item { Spacer(Modifier.height(24.dp)) } } - if (showStartPicker) DateTimePickerDialog("开始时间", { customStartMs = it; showStartPicker = false }, { showStartPicker = false }) - if (showEndPicker) DateTimePickerDialog("结束时间", { customEndMs = it; showEndPicker = false }, { showEndPicker = false }) + if (showStartPicker) DateTimePickerDialog(stringResource(R.string.date_start), { customStartMs = it; showStartPicker = false }, { showStartPicker = false }) + if (showEndPicker) DateTimePickerDialog(stringResource(R.string.date_end), { customEndMs = it; showEndPicker = false }, { showEndPicker = false }) } } @@ -193,24 +196,24 @@ fun UsageOverviewScreen( horizontalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { - StatItem("输入 Token", formatTokenCount(inputTotal)) - StatItem("输出 Token", formatTokenCount(overview.outputTokens)) - StatItem("推理 Token", formatTokenCount(overview.reasoningTokens)) - StatItem("总计", formatTokenCount(overview.totalTokens)) - StatItem("总花费", "$${String.format(Locale.US, "%.4f", overview.totalCost / 100_000_000.0)}") - StatItem("记录数", "${overview.totalCount}") + StatItem(stringResource(R.string.usage_input_tokens), formatTokenCount(inputTotal)) + StatItem(stringResource(R.string.usage_output_tokens), formatTokenCount(overview.outputTokens)) + StatItem(stringResource(R.string.usage_reasoning_tokens), formatTokenCount(overview.reasoningTokens)) + StatItem(stringResource(R.string.usage_total), formatTokenCount(overview.totalTokens)) + StatItem(stringResource(R.string.usage_total_cost), "$${String.format(Locale.US, "%.4f", overview.totalCost / 100_000_000.0)}") + StatItem(stringResource(R.string.usage_records), "${overview.totalCount}") } } else { Row(Modifier.fillMaxWidth(), Arrangement.SpaceEvenly) { - StatItem("输入 Token", formatTokenCount(inputTotal)) - StatItem("输出 Token", formatTokenCount(overview.outputTokens)) - StatItem("推理 Token", formatTokenCount(overview.reasoningTokens)) + StatItem(stringResource(R.string.usage_input_tokens), formatTokenCount(inputTotal)) + StatItem(stringResource(R.string.usage_output_tokens), formatTokenCount(overview.outputTokens)) + StatItem(stringResource(R.string.usage_reasoning_tokens), formatTokenCount(overview.reasoningTokens)) } Spacer(Modifier.height(12.dp)) Row(Modifier.fillMaxWidth(), Arrangement.SpaceEvenly) { - StatItem("总计", formatTokenCount(overview.totalTokens)) - StatItem("总花费", "$${String.format(Locale.US, "%.4f", overview.totalCost / 100_000_000.0)}") - StatItem("记录数", "${overview.totalCount}") + StatItem(stringResource(R.string.usage_total), formatTokenCount(overview.totalTokens)) + StatItem(stringResource(R.string.usage_total_cost), "$${String.format(Locale.US, "%.4f", overview.totalCost / 100_000_000.0)}") + StatItem(stringResource(R.string.usage_records), "${overview.totalCount}") } } } @@ -220,15 +223,15 @@ fun UsageOverviewScreen( @Composable private fun CacheBreakdownCard(overview: OverviewStats) { Card(Modifier.fillMaxWidth(), RoundedCornerShape(20.dp), CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) { Column(Modifier.padding(16.dp)) { - Text("缓存 Token 细分", style = MaterialTheme.typography.labelMedium, color = inkMuted()) + Text(stringResource(R.string.usage_cache_breakdown), style = MaterialTheme.typography.labelMedium, color = inkMuted()) Spacer(Modifier.height(8.dp)) Row(Modifier.fillMaxWidth(), Arrangement.SpaceEvenly) { - StatItem("缓存读取", formatTokenCount(overview.cacheReadTokens)) - StatItem("缓存写入", formatTokenCount(overview.cacheWriteTokens)) + StatItem(stringResource(R.string.stat_cache_read), formatTokenCount(overview.cacheReadTokens)) + StatItem(stringResource(R.string.stat_cache_write), formatTokenCount(overview.cacheWriteTokens)) } if (overview.cacheReadTokens > 0) { Spacer(Modifier.height(4.dp)) - Text("※ 缓存读取已计入「输入 Token」", style = MaterialTheme.typography.bodySmall, color = inkMuted()) + Text(stringResource(R.string.usage_cache_read_note), style = MaterialTheme.typography.bodySmall, color = inkMuted()) } } } @@ -243,7 +246,7 @@ fun UsageOverviewScreen( Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) { Column(Modifier.weight(1f)) { Text(stat.model, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) - Text("${stat.count} 次调用", style = MaterialTheme.typography.bodySmall, color = inkMuted()) + Text(stringResource(R.string.usage_calls, stat.count), style = MaterialTheme.typography.bodySmall, color = inkMuted()) } Column(horizontalAlignment = Alignment.End) { Text(formatTokenCount(stat.totalTokens), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold, color = StrawberryPink) @@ -254,13 +257,18 @@ fun UsageOverviewScreen( } @Composable private fun DailyDetailRow(day: com.rainy.token.data.local.DailyStats) { - val utcFmt = remember { SimpleDateFormat("MM月dd日 EEEE", Locale.CHINA).apply { timeZone = java.util.TimeZone.getTimeZone("UTC") } } + val datePattern = stringResource(R.string.date_format_md) + // 从当前资源 configuration 取 locale(跟随应用内语言切换), + // 不能依赖 Locale.getDefault():LocaleManager 只覆写 activity Configuration,不改变 JVM 默认 locale + val configuration = LocalConfiguration.current + val activeLocale = remember(configuration) { configuration.locales[0] } + val utcFmt = remember(datePattern, activeLocale) { SimpleDateFormat(datePattern, activeLocale).apply { timeZone = java.util.TimeZone.getTimeZone("UTC") } } Card(Modifier.fillMaxWidth(), RoundedCornerShape(12.dp), CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) { Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) { Text(utcFmt.format(Date(day.dayTs)), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f)) Column(horizontalAlignment = Alignment.End) { Text(formatTokenCount(day.totalTokens), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold, color = StrawberryPink) - Text("${day.count} 次 · $${String.format(Locale.US, "%.4f", day.totalCost / 100_000_000.0)}", style = MaterialTheme.typography.bodySmall, color = inkMuted()) + Text(stringResource(R.string.daily_count_cost, day.count, String.format(Locale.US, "%.4f", day.totalCost / 100_000_000.0)), style = MaterialTheme.typography.bodySmall, color = inkMuted()) } } } diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/UsageStatsCard.kt b/app/src/main/java/com/rainy/token/ui/dashboard/UsageStatsCard.kt index fb266f8..cd088fc 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/UsageStatsCard.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/UsageStatsCard.kt @@ -24,11 +24,14 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.rainy.token.R import com.rainy.token.data.local.OverviewStats +import com.rainy.token.ui.components.asString import com.rainy.token.ui.theme.InkMuted import com.rainy.token.ui.theme.StrawberryPink import java.util.Locale @@ -67,12 +70,12 @@ fun UsageStatsCard( Row(verticalAlignment = Alignment.CenterVertically) { Column(modifier = Modifier.weight(1f)) { Text( - text = "OpenCode 用量", + text = stringResource(R.string.usage_title_opencode), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold ) Text( - text = if (uiState.recordCount > 0) "${uiState.recordCount} 条记录" else "点击同步", + text = if (uiState.recordCount > 0) stringResource(R.string.usage_record_count, uiState.recordCount) else stringResource(R.string.usage_click_to_sync), style = MaterialTheme.typography.bodySmall, color = InkMuted ) @@ -87,7 +90,7 @@ fun UsageStatsCard( IconButton(onClick = { viewModel.sync() }) { Icon( imageVector = Icons.Filled.Refresh, - contentDescription = "同步用量数据", + contentDescription = stringResource(R.string.action_sync_usage), tint = StrawberryPink ) } @@ -100,13 +103,13 @@ fun UsageStatsCard( // 输入 Token = input + cacheRead val inputTotal = overview.inputTokens + overview.cacheReadTokens - MetricRow("输入 Token", formatTokenCount(inputTotal)) + MetricRow(stringResource(R.string.usage_input_tokens), formatTokenCount(inputTotal)) // 缓存子行 if (overview.cacheReadTokens > 0 || overview.cacheWriteTokens > 0) { val parts = mutableListOf() - if (overview.cacheReadTokens > 0) parts.add("缓存读取 ${formatTokenCount(overview.cacheReadTokens)}") - if (overview.cacheWriteTokens > 0) parts.add("缓存写入 ${formatTokenCount(overview.cacheWriteTokens)}") + if (overview.cacheReadTokens > 0) parts.add(stringResource(R.string.usage_cache_read, formatTokenCount(overview.cacheReadTokens))) + if (overview.cacheWriteTokens > 0) parts.add(stringResource(R.string.usage_cache_write, formatTokenCount(overview.cacheWriteTokens))) Text( text = parts.joinToString(" · "), style = MaterialTheme.typography.bodySmall, @@ -119,8 +122,8 @@ fun UsageStatsCard( // 输出 Token + 总花费 同一排 Row(Modifier.fillMaxWidth()) { - MetricRow("输出 Token", formatTokenCount(overview.outputTokens), Modifier.weight(1f)) - MetricRow("总花费", "$${String.format(Locale.US, "%.4f", overview.totalCost / 100_000_000.0)}", Modifier.weight(1f)) + MetricRow(stringResource(R.string.usage_output_tokens), formatTokenCount(overview.outputTokens), Modifier.weight(1f)) + MetricRow(stringResource(R.string.usage_total_cost), "$${String.format(Locale.US, "%.4f", overview.totalCost / 100_000_000.0)}", Modifier.weight(1f)) } } @@ -128,7 +131,7 @@ fun UsageStatsCard( if (uiState.overview == null && !uiState.syncing) { Spacer(modifier = Modifier.height(8.dp)) Text( - text = if (uiState.loading) "加载中…" else "暂无数据,点击 🔄 同步", + text = if (uiState.loading) stringResource(R.string.common_loading) else stringResource(R.string.usage_no_data_sync), style = MaterialTheme.typography.bodyMedium, color = InkMuted ) @@ -138,7 +141,7 @@ fun UsageStatsCard( if (uiState.overview != null) { Spacer(modifier = Modifier.height(8.dp)) TextButton(onClick = onOpenDetail) { - Text("查看详情", color = StrawberryPink) + Text(stringResource(R.string.action_view_detail), color = StrawberryPink) Spacer(modifier = Modifier.width(2.dp)) Icon( imageVector = Icons.Filled.KeyboardArrowRight, @@ -153,7 +156,7 @@ fun UsageStatsCard( if (uiState.lastSyncResult > 0) { Spacer(modifier = Modifier.height(4.dp)) Text( - text = "✓ 新增 ${uiState.lastSyncResult} 条", + text = stringResource(R.string.usage_new_records, uiState.lastSyncResult), style = MaterialTheme.typography.bodySmall, color = StrawberryPink ) @@ -161,7 +164,7 @@ fun UsageStatsCard( uiState.lastSyncError?.let { err -> Spacer(modifier = Modifier.height(4.dp)) Text( - text = "✗ $err", + text = "✗ ${err.asString()}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error ) diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/UsageViewModel.kt b/app/src/main/java/com/rainy/token/ui/dashboard/UsageViewModel.kt index a179941..aae4e15 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/UsageViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/UsageViewModel.kt @@ -1,16 +1,21 @@ package com.rainy.token.ui.dashboard +import androidx.annotation.StringRes import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.rainy.token.R import com.rainy.token.data.local.OverviewStats import com.rainy.token.data.local.ModelStats import com.rainy.token.data.local.DailyStats import com.rainy.token.data.local.UsageCache import com.rainy.token.data.repository.CredentialRepository +import com.rainy.token.data.repository.RepositoryError import com.rainy.token.domain.model.Credential import com.rainy.token.domain.service.ServiceType import com.rainy.token.domain.usecase.SyncCommandCodeUsageUseCase +import com.rainy.token.domain.usecase.SyncError import com.rainy.token.domain.usecase.SyncUsageUseCase +import com.rainy.token.ui.components.UiText import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -26,17 +31,17 @@ import javax.inject.Provider internal const val DAILY_PAGE_SIZE = 5 -/** 时间筛选条件 */ -sealed class TimeFilter(val label: String) { - data object All : TimeFilter("全部") - data object Last5h : TimeFilter("最近5小时") - data object Last24h : TimeFilter("最近24小时") - data object Today : TimeFilter("今天") - data object Yesterday : TimeFilter("昨天") - data object Last7Days : TimeFilter("最近7天") - data object Last30Days : TimeFilter("最近30天") - data object ThisMonth : TimeFilter("当月") - data class Custom(val from: Long, val to: Long) : TimeFilter("自定义") +/** 时间筛选条件(label 用字符串资源 ID,UI 层按当前语言解析) */ +sealed class TimeFilter(@StringRes val labelRes: Int) { + data object All : TimeFilter(R.string.time_all) + data object Last5h : TimeFilter(R.string.time_last_5h) + data object Last24h : TimeFilter(R.string.time_last_24h) + data object Today : TimeFilter(R.string.time_today) + data object Yesterday : TimeFilter(R.string.time_yesterday) + data object Last7Days : TimeFilter(R.string.time_last_7d) + data object Last30Days : TimeFilter(R.string.time_last_30d) + data object ThisMonth : TimeFilter(R.string.time_this_month) + data class Custom(val from: Long, val to: Long) : TimeFilter(R.string.time_custom) /** 计算筛选的起止 epoch 毫秒(from 含,to 含)。 * 返回 Pair(null, null) 表示不限制。 */ @@ -201,7 +206,7 @@ class UsageViewModel @Inject constructor( it.copy( syncing = false, lastSyncResult = result.getOrNull()?.inserted ?: 0, - lastSyncError = result.exceptionOrNull()?.message + lastSyncError = result.exceptionOrNull()?.let { syncErrorToUiText(it) } ) } } @@ -222,7 +227,7 @@ class UsageViewModel @Inject constructor( it.copy( syncing = false, lastSyncResult = result.getOrNull()?.inserted ?: 0, - lastSyncError = result.exceptionOrNull()?.message + lastSyncError = result.exceptionOrNull()?.let { syncErrorToUiText(it) } ) } } @@ -241,7 +246,7 @@ data class UsageUiState( val dailyStats: List = emptyList(), val recordCount: Int = 0, val lastSyncResult: Int = 0, - val lastSyncError: String? = null, + val lastSyncError: UiText? = null, val timeFilter: TimeFilter = TimeFilter.All, val dailyPage: Int = 1, val modelFilter: String? = null // null = 全部模型 @@ -253,4 +258,35 @@ private data class LoadResult( val modelStats: List, val dailyStats: List, val totalCount: Int -) \ No newline at end of file +) + +/** 把同步错误映射为本地化文案:SyncError / RepositoryError 全部走资源,不透传中文 message。 */ +private fun syncErrorToUiText(error: Throwable): UiText = when (error) { + is SyncError.PartialSync -> + UiText.Resource(R.string.sync_partial, listOf(error.inserted, error.errors.size)) + is RepositoryError.InvalidCredential -> + UiText.Resource(R.string.error_credential_invalid_reconfigure) + is RepositoryError.CredentialChanged -> + UiText.Resource(R.string.error_credential_changed_reload) + is RepositoryError.RateLimited -> UiText.Resource( + R.string.error_rate_limited_retry, + listOf( + error.retryAfterSeconds?.let { + UiText.Resource(R.string.error_rate_limited_retry_suffix, listOf(it)) + } ?: "" + ) + ) + is RepositoryError.Network -> UiText.Resource(R.string.error_network_check) + is RepositoryError.ServerError -> + UiText.Resource(R.string.error_server_http, listOf(error.code)) + is RepositoryError.ParseError -> when (error.reason) { + RepositoryError.ParseErrorReason.EMPTY_BODY -> UiText.Resource(R.string.error_parse_empty_body) + RepositoryError.ParseErrorReason.NOT_JSON_OBJECT -> UiText.Resource(R.string.error_parse_not_json) + RepositoryError.ParseErrorReason.NO_WINDOWS -> UiText.Resource(R.string.error_parse_no_windows) + RepositoryError.ParseErrorReason.NO_MODELS -> UiText.Resource(R.string.error_parse_no_models) + RepositoryError.ParseErrorReason.MODELS_EMPTY -> UiText.Resource(R.string.error_parse_models_empty) + RepositoryError.ParseErrorReason.MALFORMED_RESPONSE -> UiText.Resource(R.string.error_parse_malformed) + } + is RepositoryError.Unknown -> UiText.Resource(R.string.common_unknown) + else -> UiText.Resource(R.string.common_unknown) +} \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt index b323d67..a9ee908 100644 --- a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt @@ -52,13 +52,16 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.rainy.token.R import com.rainy.token.domain.model.ServiceBalance +import com.rainy.token.domain.model.TriggerSummary import com.rainy.token.domain.service.FetchMethod import com.rainy.token.domain.service.ServiceConfigProvider import com.rainy.token.domain.service.ServiceType @@ -66,6 +69,8 @@ import com.rainy.token.ui.components.ServiceIcon import com.rainy.token.ui.components.StatusChip import com.rainy.token.ui.components.StatusLevel import com.rainy.token.ui.components.StatusStyle +import com.rainy.token.ui.components.asString +import com.rainy.token.ui.components.DurationText import com.rainy.token.ui.components.formatAmount import com.rainy.token.ui.components.formatResetInSec import com.rainy.token.ui.theme.inkMuted @@ -135,7 +140,7 @@ fun ServiceDetailScreen( IconButton(onClick = onBack) { Icon( Icons.Filled.ArrowBack, - contentDescription = "返回", + contentDescription = stringResource(R.string.action_back), tint = StrawberryPink ) } @@ -177,7 +182,7 @@ fun ServiceDetailScreen( // 错误信息(如有) (uiState.state as? State.Error)?.let { err -> - item { ErrorCard(message = err.message) } + item { ErrorCard(message = err.message.asString()) } } // 凭据状态条 @@ -213,15 +218,18 @@ fun ServiceDetailScreen( // 响应弹窗(成功和失败都弹) when (triggerState) { is TriggerState.Success -> ResponseDialog( - title = "✓ 用量已激活", - responseBody = (triggerState as TriggerState.Success).responseBody, + title = stringResource(R.string.trigger_success_title), + responseBody = buildTriggerSummaryText((triggerState as TriggerState.Success).summary), isError = false, onDismiss = { viewModel.dismissTrigger() } ) is TriggerState.Error -> ResponseDialog( - title = "请求结果", - responseBody = (triggerState as TriggerState.Error).let { - "错误: ${it.message}" + (it.responseBody?.let { body -> "\n\n响应内容:\n$body" } ?: "") + title = stringResource(R.string.trigger_result_title), + responseBody = (triggerState as TriggerState.Error).let { + stringResource(R.string.trigger_error_prefix, it.message.asString()) + + (it.responseBody?.let { body -> + "\n\n" + stringResource(R.string.trigger_response_body, body) + } ?: "") }, isError = true, onDismiss = { viewModel.dismissTrigger() } @@ -259,7 +267,10 @@ private fun CommandCodeGoUsageCard(state: State) { ) { Column(modifier = Modifier.padding(20.dp)) { Text( - text = "用量窗口${if (planName.isNotBlank()) " · $planName" else ""}", + text = stringResource( + R.string.usage_window_title, + if (planName.isNotBlank()) " · $planName" else "" + ), style = MaterialTheme.typography.labelLarge, color = inkMuted() ) @@ -271,7 +282,7 @@ private fun CommandCodeGoUsageCard(state: State) { if (fiveHourUsed != null && fiveHourCap != null && fiveHourCap > 0) { val pct = ((fiveHourUsed / fiveHourCap) * 100).toFloat().coerceIn(0f, 100f) UsageWindowRow( - label = "5 小时滚动", + label = stringResource(R.string.window_5h_rolling), pct = pct, resetInSec = extras["fiveHour.resetInSec"]?.toLongOrNull() ) @@ -286,7 +297,7 @@ private fun CommandCodeGoUsageCard(state: State) { if (weeklyUsed != null && weeklyCap != null && weeklyCap > 0) { val pct = ((weeklyUsed / weeklyCap) * 100).toFloat().coerceIn(0f, 100f) UsageWindowRow( - label = "本周", + label = stringResource(R.string.window_weekly), pct = pct, resetInSec = extras["weekly.resetInSec"]?.toLongOrNull() ) @@ -300,19 +311,23 @@ private fun CommandCodeGoUsageCard(state: State) { val used = monthlyTotal - monthlyRemaining val pct = ((used / monthlyTotal) * 100).toFloat().coerceIn(0f, 100f) UsageWindowRow( - label = "本月", + label = stringResource(R.string.window_monthly), pct = pct, resetInSec = extras["billingPeriodEnd"]?.let { parseIsoDuration(it) } ) Spacer(modifier = Modifier.height(4.dp)) Text( - text = "已用 \$${formatAmount(used)} / 共 \$${formatAmount(monthlyTotal)}", + text = stringResource( + R.string.usage_spent_total, + formatAmount(used), + formatAmount(monthlyTotal) + ), style = MaterialTheme.typography.bodySmall, color = inkMuted() ) } else { Text( - text = "剩余 \$${formatAmount(monthlyRemaining)}", + text = stringResource(R.string.usage_remaining, formatAmount(monthlyRemaining)), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold ) @@ -323,7 +338,7 @@ private fun CommandCodeGoUsageCard(state: State) { Spacer(modifier = Modifier.height(8.dp)) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) Spacer(modifier = Modifier.height(8.dp)) - BreakdownRow("额外充值", purchased, "$") + BreakdownRow(stringResource(R.string.usage_extra_credits), purchased, "$") } } } @@ -365,13 +380,13 @@ private fun OpenCodeGoWindowsCard(state: State) { ) { Column(modifier = Modifier.padding(20.dp)) { Text( - text = "用量窗口", + text = stringResource(R.string.usage_window_title_plain), style = MaterialTheme.typography.labelLarge, color = inkMuted() ) Spacer(modifier = Modifier.height(12.dp)) UsageWindowRow( - label = "5 小时滚动", + label = stringResource(R.string.window_5h_rolling), pct = extras["rolling.pct"]?.toFloatOrNull(), resetInSec = extras["rolling.resetInSec"]?.toLongOrNull() ) @@ -379,7 +394,7 @@ private fun OpenCodeGoWindowsCard(state: State) { HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) Spacer(modifier = Modifier.height(14.dp)) UsageWindowRow( - label = "本周", + label = stringResource(R.string.window_weekly), pct = extras["weekly.pct"]?.toFloatOrNull(), resetInSec = extras["weekly.resetInSec"]?.toLongOrNull() ) @@ -387,7 +402,7 @@ private fun OpenCodeGoWindowsCard(state: State) { HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) Spacer(modifier = Modifier.height(14.dp)) UsageWindowRow( - label = "本月", + label = stringResource(R.string.window_monthly), pct = extras["monthly.pct"]?.toFloatOrNull(), resetInSec = extras["monthly.resetInSec"]?.toLongOrNull() ) @@ -397,6 +412,11 @@ private fun OpenCodeGoWindowsCard(state: State) { @Composable private fun UsageWindowRow(label: String, pct: Float?, resetInSec: Long?, decimals: Int = 0) { + val durationText = DurationText( + day = stringResource(R.string.format_day), + hour = stringResource(R.string.format_hour), + minute = stringResource(R.string.format_minute) + ) val pctValue = (pct ?: 0f).coerceIn(0f, 100f) Column { Row( @@ -440,7 +460,7 @@ private fun UsageWindowRow(label: String, pct: Float?, resetInSec: Long?, decima if (resetInSec != null && resetInSec > 0) { Spacer(modifier = Modifier.height(4.dp)) Text( - text = "${formatResetInSec(resetInSec)}后重置", + text = stringResource(R.string.service_reset_in, formatResetInSec(resetInSec, durationText)), style = MaterialTheme.typography.bodySmall, color = inkMuted() ) @@ -468,7 +488,10 @@ private fun CodexUsageCard(state: State) { if (windows.isEmpty()) return // 判断是否有 5h 窗口 - val has5h = windows.any { it.label.contains("5") && it.label.contains("小时") } + val has5h = windows.any { + it.rawLabel.equals("5h", ignoreCase = true) || + (it.rawLabel.contains("5") && it.rawLabel.contains("小时")) + } Card( modifier = Modifier.fillMaxWidth(), @@ -479,7 +502,7 @@ private fun CodexUsageCard(state: State) { Column(modifier = Modifier.padding(20.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = "用量窗口", + text = stringResource(R.string.usage_window_title_plain), style = MaterialTheme.typography.labelLarge, color = inkMuted() ) @@ -498,7 +521,7 @@ private fun CodexUsageCard(state: State) { // 没有 5h 窗口时,在顶部插入空槽位 if (!has5h) { UsageWindowRow( - label = "5 小时", + label = stringResource(R.string.window_5h), pct = null, resetInSec = null, decimals = 2 @@ -518,7 +541,7 @@ private fun CodexUsageCard(state: State) { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 } UsageWindowRow( - label = window.label, + label = codexWindowLabel(window.rawLabel), pct = window.usedPct, resetInSec = resetInSec, decimals = 2 @@ -529,7 +552,7 @@ private fun CodexUsageCard(state: State) { } private data class CodexWindow( - val label: String, + val rawLabel: String, val usedPct: Float, val resetAt: Long? ) @@ -542,16 +565,17 @@ private fun extractCodexWindows(extras: Map): List val remaining = extras["window_${i}.remainingPct"]?.toFloatOrNull() ?: 0f val resetAt = extras["window_${i}.resetAt"]?.toLongOrNull()?.takeIf { it > 0 } val usedPct = (100f - remaining).coerceIn(0f, 100f) - result.add(CodexWindow(formatCodexWindowLabel(rawLabel), usedPct, resetAt)) + result.add(CodexWindow(rawLabel, usedPct, resetAt)) i++ } return result } -private fun formatCodexWindowLabel(raw: String): String = when (raw.lowercase()) { - "5h" -> "5 小时" - "7d", "每周" -> "本周" - "30d", "每月" -> "本月" +@Composable +private fun codexWindowLabel(raw: String): String = when (raw.lowercase()) { + "5h" -> stringResource(R.string.window_5h) + "7d", "每周" -> stringResource(R.string.window_weekly) + "30d", "每月" -> stringResource(R.string.window_monthly) else -> raw } @@ -581,7 +605,7 @@ private fun OllamaUsageCard(state: State) { Column(modifier = Modifier.padding(20.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = "用量窗口", + text = stringResource(R.string.usage_window_title_plain), style = MaterialTheme.typography.labelLarge, color = inkMuted() ) @@ -595,7 +619,7 @@ private fun OllamaUsageCard(state: State) { } Spacer(modifier = Modifier.height(12.dp)) UsageWindowRow( - label = "5 小时", + label = stringResource(R.string.window_5h), pct = extras["session.pct"]?.toFloatOrNull(), resetInSec = extras["session.resetAt"]?.toLongOrNull()?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 }, decimals = 2 @@ -608,7 +632,7 @@ private fun OllamaUsageCard(state: State) { HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) Spacer(modifier = Modifier.height(14.dp)) UsageWindowRow( - label = "每周", + label = stringResource(R.string.window_every_week), pct = extras["weekly.pct"]?.toFloatOrNull(), resetInSec = extras["weekly.resetAt"]?.toLongOrNull()?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 }, decimals = 2 @@ -645,7 +669,7 @@ private fun parseModelRequests(raw: String?): List> { private fun ModelRequestList(models: List>) { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Text( - text = "模型调用次数", + text = stringResource(R.string.msg_model_call_count), style = MaterialTheme.typography.labelSmall, color = inkMuted() ) @@ -702,7 +726,7 @@ private fun MainBalanceCard(state: State, service: ServiceType) { BalanceBigNumber(state.data) Spacer(modifier = Modifier.height(8.dp)) Text( - text = "缓存数据 · ${formatTime(state.lastFetchedAt)}", + text = stringResource(R.string.msg_stale_cache, formatTime(state.lastFetchedAt)), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error ) @@ -711,7 +735,7 @@ private fun MainBalanceCard(state: State, service: ServiceType) { BalanceBigNumber(state.cached) Spacer(modifier = Modifier.height(8.dp)) Text( - text = "显示的是上次成功获取的余额", + text = stringResource(R.string.msg_showing_last_balance), style = MaterialTheme.typography.bodySmall, color = inkMuted() ) @@ -756,7 +780,7 @@ private fun BalanceBigNumber(balance: ServiceBalance?) { if (!balance.isAvailable) { Spacer(modifier = Modifier.height(4.dp)) Text( - text = "服务当前不可用", + text = stringResource(R.string.service_unavailable), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error ) @@ -764,7 +788,7 @@ private fun BalanceBigNumber(balance: ServiceBalance?) { balance.monthlySpent?.let { spent -> Spacer(modifier = Modifier.height(4.dp)) Text( - text = "本月消费 ${formatAmount(spent)}${balance.unit}", + text = stringResource(R.string.msg_monthly_spent, formatAmount(spent), balance.unit), style = MaterialTheme.typography.bodyMedium, color = inkMuted() ) @@ -795,16 +819,16 @@ private fun DeepSeekBreakdownCard(state: State) { ) { Column(modifier = Modifier.padding(20.dp)) { Text( - text = "余额构成", + text = stringResource(R.string.msg_balance_breakdown), style = MaterialTheme.typography.labelLarge, color = inkMuted() ) Spacer(modifier = Modifier.height(12.dp)) - BreakdownRow("赠送余额", granted, balance!!.unit) + BreakdownRow(stringResource(R.string.msg_granted_balance), granted, balance!!.unit) Spacer(modifier = Modifier.height(8.dp)) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) Spacer(modifier = Modifier.height(8.dp)) - BreakdownRow("自费充值", toppedUp, balance.unit) + BreakdownRow(stringResource(R.string.msg_topped_up_balance), toppedUp, balance.unit) } } } @@ -850,7 +874,7 @@ private fun ErrorCard(message: String) { ) { Column(modifier = Modifier.padding(16.dp)) { Text( - text = "刷新失败", + text = stringResource(R.string.status_refresh_failed), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.error @@ -868,9 +892,9 @@ private fun ErrorCard(message: String) { @Composable private fun CredentialStatusRow(hasCredential: Boolean, isManualMode: Boolean) { val style = when { - isManualMode -> StatusStyle("手动输入模式", StatusLevel.INFO) - hasCredential -> StatusStyle("凭据已配置", StatusLevel.OK) - else -> StatusStyle("未配置凭据", StatusLevel.ERROR) + isManualMode -> StatusStyle(R.string.status_manual_mode, StatusLevel.INFO) + hasCredential -> StatusStyle(R.string.status_credential_configured, StatusLevel.OK) + else -> StatusStyle(R.string.status_not_configured_cred, StatusLevel.ERROR) } StatusChip(style = style) } @@ -908,7 +932,11 @@ private fun ActionButtons( strokeWidth = 2.dp ) Spacer(modifier = Modifier.width(8.dp)) - Text("加载模型列表...", style = MaterialTheme.typography.bodySmall, color = inkMuted()) + Text( + stringResource(R.string.msg_loading_models), + style = MaterialTheme.typography.bodySmall, + color = inkMuted() + ) } } else if (models.isNotEmpty()) { Row( @@ -916,7 +944,7 @@ private fun ActionButtons( verticalAlignment = Alignment.CenterVertically ) { Text( - text = "选择模型", + text = stringResource(R.string.msg_select_model), style = MaterialTheme.typography.labelMedium, color = inkMuted() ) @@ -927,7 +955,7 @@ private fun ActionButtons( ) { Icon( imageVector = Icons.Filled.Refresh, - contentDescription = "刷新模型列表", + contentDescription = stringResource(R.string.action_refresh_models), tint = StrawberryPink, modifier = Modifier.size(18.dp) ) @@ -959,7 +987,7 @@ private fun ActionButtons( strokeWidth = 2.dp ) Spacer(modifier = Modifier.width(8.dp)) - Text("激活中...") + Text(stringResource(R.string.msg_triggering)) } } is TriggerState.Error -> { @@ -971,10 +999,10 @@ private fun ActionButtons( contentColor = MaterialTheme.colorScheme.error ) ) { - Text("重试激活用量") + Text(stringResource(R.string.action_retry_trigger)) } Text( - text = triggerState.message, + text = triggerState.message.asString(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(horizontal = 4.dp) @@ -986,7 +1014,7 @@ private fun ActionButtons( modifier = Modifier.fillMaxWidth(), enabled = state !is State.Loading && selectedModel != null ) { - Text("⚡ 一键激活用量") + Text(stringResource(R.string.action_trigger_usage)) } } is TriggerState.Success -> { @@ -1008,13 +1036,13 @@ private fun ActionButtons( ) { Icon(Icons.Filled.Refresh, contentDescription = null) Spacer(modifier = Modifier.width(8.dp)) - Text("刷新余额") + Text(stringResource(R.string.action_refresh_balance)) } OutlinedButton( onClick = onConfigureCredential, modifier = Modifier.fillMaxWidth() ) { - Text("重新登录") + Text(stringResource(R.string.action_relogin)) } } else if (!hasCredential) { Button( @@ -1025,26 +1053,27 @@ private fun ActionButtons( contentColor = Color.White ) ) { - Text("配置凭据") + Text(stringResource(R.string.action_configure_credential)) } } } } +@Composable private fun mainCardLabel(service: ServiceType): String = when (service) { - ServiceType.DEEPSEEK -> "当前余额" - ServiceType.OPENCODE_GO -> "5h 实时用量" - ServiceType.COMMANDCODE_GO -> "月度余额" - ServiceType.CODEX -> "用量" - ServiceType.OLLAMA -> "Session 用量" + ServiceType.DEEPSEEK -> stringResource(R.string.main_card_current_balance) + ServiceType.OPENCODE_GO -> stringResource(R.string.main_card_5h_usage) + ServiceType.COMMANDCODE_GO -> stringResource(R.string.main_card_monthly_balance) + ServiceType.CODEX -> stringResource(R.string.window_usage) + ServiceType.OLLAMA -> stringResource(R.string.main_card_session_usage) } private fun stateToChip(state: State): StatusStyle = when (state) { - is State.Loading -> StatusStyle("加载中", StatusLevel.INFO) - is State.Fresh -> StatusStyle("最新", StatusLevel.OK) - is State.Stale -> StatusStyle("缓存", StatusLevel.WARNING) - is State.Error -> StatusStyle("失败", StatusLevel.ERROR) - is State.ManualModeHint -> StatusStyle("待输入", StatusLevel.WARNING) + is State.Loading -> StatusStyle(R.string.status_loading, StatusLevel.INFO) + is State.Fresh -> StatusStyle(R.string.status_fresh, StatusLevel.OK) + is State.Stale -> StatusStyle(R.string.status_stale, StatusLevel.WARNING) + is State.Error -> StatusStyle(R.string.status_failed, StatusLevel.ERROR) + is State.ManualModeHint -> StatusStyle(R.string.status_pending_input, StatusLevel.WARNING) } private fun formatTime(epochMillis: Long): String { @@ -1062,7 +1091,7 @@ private fun ModelDropdown( onSelect: (String) -> Unit ) { var expanded by remember { androidx.compose.runtime.mutableStateOf(false) } - val display = selectedModel ?: "请选择模型" + val display = selectedModel ?: stringResource(R.string.msg_please_select_model) ExposedDropdownMenuBox( expanded = expanded, @@ -1094,6 +1123,43 @@ private fun ModelDropdown( // ── Codex 一键激活用量:响应弹窗 ── +/** 把结构化的激活摘要组装为本地化文本(Repository 不再拼接展示文案)。 */ +@Composable +private fun buildTriggerSummaryText(summary: TriggerSummary): String = buildString { + appendLine(stringResource(R.string.trigger_success_header)) + appendLine(stringResource(R.string.trigger_success_model, summary.model)) + summary.responseId?.let { appendLine(stringResource(R.string.trigger_success_response_id, it)) } + appendLine() + appendLine(stringResource(R.string.trigger_reply_header)) + if (summary.parseFailed) { + appendLine(stringResource(R.string.trigger_parse_failed)) + } else { + appendLine( + summary.reply?.ifEmpty { stringResource(R.string.trigger_reply_empty) } + ?: stringResource(R.string.trigger_reply_missing) + ) + } + if (summary.inputTokens != null || summary.outputTokens != null) { + appendLine() + appendLine( + if (summary.totalTokens != null) { + stringResource( + R.string.trigger_usage_prompt_completion_total, + summary.inputTokens ?: "—", + summary.outputTokens ?: "—", + summary.totalTokens ?: "—" + ) + } else { + stringResource( + R.string.trigger_usage_input_output, + summary.inputTokens ?: "—", + summary.outputTokens ?: "—" + ) + } + ) + } +}.trim() + @Composable private fun ResponseDialog( title: String, @@ -1110,7 +1176,11 @@ private fun ResponseDialog( text = { Column { Text( - text = if (isError) "错误详情:" else "API 响应内容:", + text = if (isError) { + stringResource(R.string.trigger_error_detail) + } else { + stringResource(R.string.trigger_api_response) + }, style = MaterialTheme.typography.labelMedium, color = if (isError) MaterialTheme.colorScheme.error else inkMuted() ) @@ -1134,7 +1204,7 @@ private fun ResponseDialog( val clipboard = context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager clipboard.setPrimaryClip(android.content.ClipData.newPlainText("API Response", responseBody)) }) { - Text("复制") + Text(stringResource(R.string.action_copy)) } Button( onClick = onDismiss, @@ -1143,7 +1213,7 @@ private fun ResponseDialog( contentColor = Color.White ) ) { - Text("关闭") + Text(stringResource(R.string.action_close)) } } } diff --git a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModel.kt b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModel.kt index 2aacd16..2c6ac2a 100644 --- a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModel.kt @@ -8,11 +8,15 @@ import com.rainy.token.data.debug.DebugLog import com.rainy.token.data.repository.CredentialRepository import com.rainy.token.data.repository.RepositoryError import com.rainy.token.data.repository.TriggerError +import com.rainy.token.data.repository.TriggerErrorReason import com.rainy.token.domain.model.Credential import com.rainy.token.domain.model.CredentialStatus import com.rainy.token.domain.model.ServiceBalance +import com.rainy.token.domain.model.TriggerSummary +import com.rainy.token.R import com.rainy.token.domain.service.ServiceType import com.rainy.token.domain.usecase.RefreshBalanceUseCase +import com.rainy.token.ui.components.UiText import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow @@ -150,7 +154,7 @@ class ServiceDetailViewModel @Inject constructor( cached = local.cachedBalance, state = State.Error( local.cachedBalance?.balance, - "凭据未配置", + UiText.Resource(R.string.error_credential_not_configured), RepositoryError.InvalidCredential() ) ) @@ -303,7 +307,10 @@ class ServiceDetailViewModel @Inject constructor( val service = _serviceType.value ?: return val serviceGen = serviceGeneration val model = _selectedModel.value ?: run { - _triggerState.value = TriggerState.Error("请先选择模型", null) + _triggerState.value = TriggerState.Error( + UiText.Resource(R.string.error_select_model_first), + null + ) return } viewModelScope.launch { @@ -319,39 +326,49 @@ class ServiceDetailViewModel @Inject constructor( if (serviceGen != serviceGeneration || _serviceType.value != service) return@launch result - .onSuccess { responseBody -> - _triggerState.value = TriggerState.Success(responseBody) + .onSuccess { summary -> + _triggerState.value = TriggerState.Success(summary) kotlinx.coroutines.delay(2000) if (serviceGen == serviceGeneration && _serviceType.value == service) { refresh() } } .onFailure { error -> - val message: String + val message: UiText val responseBody: String? when (error) { is TriggerError -> { - message = error.summary + // reason 结构化:token 刷新失败显示本地化文案(summary 中文提示只进日志); + // 其余(HTTP 错误)summary 为 ASCII 可直接展示 + message = if (error.reason == TriggerErrorReason.TOKEN_REFRESH) { + UiText.Resource(R.string.error_token_refresh_generic) + } else { + UiText.Dynamic(error.summary) + } responseBody = error.responseBody.ifBlank { null } } is RepositoryError.InvalidCredential -> { - message = error.message ?: "凭据无效,请重新登录" + message = UiText.Resource(R.string.error_credential_invalid_relogin) responseBody = null } is RepositoryError.CredentialChanged -> { - message = "凭据已变更,请重新操作" + message = UiText.Resource(R.string.error_credential_changed_retry) responseBody = null } is RepositoryError.Network -> { - message = "网络异常" + message = UiText.Resource(R.string.error_network_generic) responseBody = null } else -> { - message = error.message ?: "未知错误" + message = if ((error as? RepositoryError.Unknown)?.cause is IllegalArgumentException) { + UiText.Resource(R.string.error_trigger_not_supported) + } else { + errorMessage(error) + } responseBody = null } } @@ -418,13 +435,17 @@ class ServiceDetailViewModel @Inject constructor( isManual && cached != null -> State.Stale(cached.balance, cached.fetchedAt) isManual -> State.ManualModeHint status.state == CredentialStatus.State.NOT_CONFIGURED -> - State.Error(null, "凭据未配置", RepositoryError.InvalidCredential()) + State.Error( + null, + UiText.Resource(R.string.error_credential_not_configured), + RepositoryError.InvalidCredential() + ) cached != null && status.state == CredentialStatus.State.OK -> State.Stale(cached.balance, cached.fetchedAt) cached != null -> State.Error( cached.balance, - "凭据未配置或已过期", + UiText.Resource(R.string.error_credential_not_configured_expired), RepositoryError.InvalidCredential() ) else -> State.Loading @@ -455,7 +476,7 @@ class ServiceDetailViewModel @Inject constructor( val mergedState: State = when { !hasCredential -> State.Error( mergedCache?.balance, - "凭据未配置", + UiText.Resource(R.string.error_credential_not_configured), RepositoryError.InvalidCredential() ) current.state is State.Loading -> current.state @@ -498,15 +519,34 @@ class ServiceDetailViewModel @Inject constructor( serviceGen != serviceGeneration || _serviceType.value != type - private fun errorMessage(error: Throwable): String = when (error) { - is RepositoryError.InvalidCredential -> "凭据无效,请在设置中重新配置" - is RepositoryError.CredentialChanged -> "凭据已变更,正在重新加载" - is RepositoryError.RateLimited -> - "请求过于频繁${error.retryAfterSeconds?.let { ",请 ${it} 秒后重试" } ?: ""}" - is RepositoryError.Network -> "网络异常,请检查网络" - is RepositoryError.ServerError -> "服务端异常 (HTTP ${error.code})" - is RepositoryError.ParseError -> "数据解析失败: ${error.message}" - else -> error.message ?: "未知错误" + private fun errorMessage(error: Throwable): UiText = when (error) { + is RepositoryError.InvalidCredential -> + UiText.Resource(R.string.error_credential_invalid_reconfigure) + is RepositoryError.CredentialChanged -> + UiText.Resource(R.string.error_credential_changed_reload) + is RepositoryError.RateLimited -> UiText.Resource( + R.string.error_rate_limited_retry, + listOf( + error.retryAfterSeconds?.let { + UiText.Resource(R.string.error_rate_limited_retry_suffix, listOf(it)) + } ?: "" + ) + ) + is RepositoryError.Network -> UiText.Resource(R.string.error_network_check) + is RepositoryError.ServerError -> + UiText.Resource(R.string.error_server_http, listOf(error.code)) + is RepositoryError.ParseError -> when (error.reason) { + RepositoryError.ParseErrorReason.EMPTY_BODY -> UiText.Resource(R.string.error_parse_empty_body) + RepositoryError.ParseErrorReason.NOT_JSON_OBJECT -> UiText.Resource(R.string.error_parse_not_json) + RepositoryError.ParseErrorReason.NO_WINDOWS -> UiText.Resource(R.string.error_parse_no_windows) + RepositoryError.ParseErrorReason.NO_MODELS -> UiText.Resource(R.string.error_parse_no_models) + RepositoryError.ParseErrorReason.MODELS_EMPTY -> UiText.Resource(R.string.error_parse_models_empty) + RepositoryError.ParseErrorReason.MALFORMED_RESPONSE -> UiText.Resource(R.string.error_parse_malformed) + } + // Unknown 的 message 以硬编码中文"未知错误"开头,不能透传 UI,统一映射本地化文案 + is RepositoryError.Unknown -> UiText.Resource(R.string.common_unknown) + else -> error.message?.let { UiText.Dynamic(it) } + ?: UiText.Resource(R.string.common_unknown) } private fun prefs(service: ServiceType): android.content.SharedPreferences = @@ -539,7 +579,7 @@ sealed class State { data class Stale(val data: ServiceBalance, val lastFetchedAt: Long) : State() data class Error( val cached: ServiceBalance?, - val message: String, + val message: UiText, val error: Throwable ) : State() data object ManualModeHint : State() @@ -554,6 +594,6 @@ data class ServiceDetailUiState( sealed class TriggerState { data object Idle : TriggerState() data object Loading : TriggerState() - data class Success(val responseBody: String) : TriggerState() - data class Error(val message: String, val responseBody: String?) : TriggerState() + data class Success(val summary: TriggerSummary) : TriggerState() + data class Error(val message: UiText, val responseBody: String?) : TriggerState() } diff --git a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt index 141693f..3fef1a8 100644 --- a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt @@ -41,11 +41,14 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType +import com.rainy.token.ui.components.resolve import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.rainy.token.R import com.rainy.token.domain.service.ServiceType /** @@ -77,7 +80,7 @@ fun CredentialEditScreen( LaunchedEffect(uiState.message) { uiState.message?.let { msg -> - snackbarHostState.showSnackbar(msg) + snackbarHostState.showSnackbar(msg.resolve(context)) viewModel.clearMessage() } } @@ -88,13 +91,13 @@ fun CredentialEditScreen( title = { Text(service.displayName) }, navigationIcon = { IconButton(onClick = onBack) { - Icon(Icons.Filled.ArrowBack, contentDescription = "返回") + Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back)) } }, actions = { if (uiState.hasExisting) { IconButton(onClick = { viewModel.deleteCredential() }) { - Icon(Icons.Filled.Delete, contentDescription = "删除") + Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.action_delete)) } } } @@ -193,7 +196,7 @@ fun CredentialEditScreen( ) if (uiState.hasExisting) { Text( - text = "✓ 已配置登录态(${uiState.cookieCount} 个 Cookie)", + text = stringResource(R.string.credential_configured_cookies, uiState.cookieCount), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary ) @@ -207,19 +210,19 @@ fun CredentialEditScreen( if (showCookieHelp) { AlertDialog( onDismissRequest = { showCookieHelp = false }, - title = { Text("如何获取 Cookie?") }, + title = { Text(stringResource(R.string.help_cookie_title)) }, text = { Column { - Text("1. 在桌面 Chrome 打开下方登录入口") - Text("2. 用 GitHub / Google 登录(不会被拦截)") - Text("3. 登录后按 F12 → Application → Cookies") - Text("4. 复制该站点下的所有 Cookie 粘贴到上面输入框") - Text("格式:name1=value1; name2=value2") + Text(stringResource(R.string.help_cookie_1)) + Text(stringResource(R.string.help_cookie_2)) + Text(stringResource(R.string.help_cookie_3)) + Text(stringResource(R.string.help_cookie_4)) + Text(stringResource(R.string.help_cookie_5)) } }, confirmButton = { TextButton(onClick = { showCookieHelp = false }) { - Text("我知道了") + Text(stringResource(R.string.action_got_it)) } } ) @@ -228,23 +231,23 @@ fun CredentialEditScreen( if (showGoHelp) { AlertDialog( onDismissRequest = { showGoHelp = false }, - title = { Text("如何获取 OpenCode Go 凭据?") }, + title = { Text(stringResource(R.string.help_ocgo_title)) }, text = { Column { - Text("1. 桌面 Chrome 打开 opencode.ai/auth") - Text("2. 用 GitHub / Google 登录(Google 不会拦截 Chrome)") - Text("3. 登录后跳转到 dashboard,URL 形如:") - Text(" https://opencode.ai/workspace/{id}/go") - Text("4. 复制 URL 中的 {id} 段 → workspaceId") - Text("5. F12 → Application → Cookies") - Text("6. 找到域名 opencode.ai 下名为 auth 的 Cookie") - Text("7. 复制它的 Value 字段 → auth cookie") - Text("注意:只填 auth 一个 Cookie 即可,不要整个 Cookie 字符串") + Text(stringResource(R.string.help_ocgo_1)) + Text(stringResource(R.string.help_ocgo_2)) + Text(stringResource(R.string.help_ocgo_3)) + Text(stringResource(R.string.help_ocgo_4)) + Text(stringResource(R.string.help_ocgo_5)) + Text(stringResource(R.string.help_ocgo_6)) + Text(stringResource(R.string.help_ocgo_7)) + Text(stringResource(R.string.help_ocgo_8)) + Text(stringResource(R.string.help_ocgo_note)) } }, confirmButton = { TextButton(onClick = { showGoHelp = false }) { - Text("我知道了") + Text(stringResource(R.string.action_got_it)) } } ) @@ -253,25 +256,25 @@ fun CredentialEditScreen( if (showOpenCcgoHelp) { AlertDialog( onDismissRequest = { showOpenCcgoHelp = false }, - title = { Text("如何获取 CommandCode Go 凭据?") }, + title = { Text(stringResource(R.string.help_ccgo_title)) }, text = { Column { - Text("1. 打开 https://commandcode.ai 并登录 GitHub") + Text(stringResource(R.string.help_ccgo_1)) Text("") - Text("▸ 获取 API Key(必填):") - Text(" 右上角头像 → Settings → API Keys → 创建一个 Key") + Text(stringResource(R.string.help_ccgo_2)) + Text(stringResource(R.string.help_ccgo_3)) Text("") - Text("▸ 获取全部 Cookie(用于用量记录):") - Text(" F12 → Application → Cookies → commandcode.ai") - Text(" 选中所有 Cookie,复制整个 Cookie 字符串") - Text(" 粘贴到下方输入框即可,无需逐个挑选") + Text(stringResource(R.string.help_ccgo_4)) + Text(stringResource(R.string.help_ccgo_5)) + Text(stringResource(R.string.help_ccgo_6)) + Text(stringResource(R.string.help_ccgo_7)) Text("") - Text("提示:不填 Cookie 也能正常查看余额,只是用量记录为空。") + Text(stringResource(R.string.help_ccgo_note)) } }, confirmButton = { TextButton(onClick = { showOpenCcgoHelp = false }) { - Text("我知道了") + Text(stringResource(R.string.action_got_it)) } } ) @@ -280,32 +283,32 @@ fun CredentialEditScreen( if (showCodexHelp) { AlertDialog( onDismissRequest = { showCodexHelp = false }, - title = { Text("如何获取 Codex auth.json?") }, + title = { Text(stringResource(R.string.help_codex_title)) }, text = { Column { - Text("1. 在终端运行 codex-oauth-proxy 或") - Text(" codex-proxy,完成 ChatGPT 登录") + Text(stringResource(R.string.help_codex_1)) + Text(stringResource(R.string.help_codex_2)) Text("") - Text("2. 凭证文件保存在:") - Text(" ~/.config/codex-oauth-proxy/auth.json") + Text(stringResource(R.string.help_codex_3)) + Text(stringResource(R.string.help_codex_4)) Text("") - Text("3. 用文本编辑器打开该文件,") - Text(" 复制全部内容粘贴到上方输入框") + Text(stringResource(R.string.help_codex_5)) + Text(stringResource(R.string.help_codex_6)) Text("") - Text(" JSON 格式示例:") - Text(" {\"tokens\": {") - Text(" \"access_token\": \"...\",") - Text(" \"refresh_token\": \"...\",") - Text(" \"expiresAt\": 1783727108252") - Text(" }}") + Text(stringResource(R.string.help_codex_7)) + Text(stringResource(R.string.help_codex_8)) + Text(stringResource(R.string.help_codex_9)) + Text(stringResource(R.string.help_codex_10)) + Text(stringResource(R.string.help_codex_11)) + Text(stringResource(R.string.help_codex_12)) Text("") - Text("提示:导入后 APP 会自动刷新 token,") - Text("不需要手动更新。") + Text(stringResource(R.string.help_codex_note)) + Text(stringResource(R.string.help_codex_note2)) } }, confirmButton = { TextButton(onClick = { showCodexHelp = false }) { - Text("我知道了") + Text(stringResource(R.string.action_got_it)) } } ) @@ -326,9 +329,9 @@ private fun OllamaCookieForm( onApiKeyChange: (String) -> Unit, onSaveApiKey: () -> Unit ) { - Text(text = "Ollama Pro 凭据", style = MaterialTheme.typography.titleMedium) + Text(text = stringResource(R.string.credential_title_ollama), style = MaterialTheme.typography.titleMedium) Text( - text = "需要从浏览器复制完整 Cookie。登录 ollama.com/settings 后,在 DevTools → Network → 请求头里找到 Cookie 行复制。", + text = stringResource(R.string.credential_hint_ollama), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.outline ) @@ -351,34 +354,34 @@ private fun OllamaCookieForm( modifier = Modifier.weight(1f), enabled = cookie.isNotBlank() ) { - Text(text = if (hasExisting) "更新凭据" else "保存凭据") + Text(text = if (hasExisting) stringResource(R.string.credential_update) else stringResource(R.string.credential_save)) } OutlinedButton( onClick = onTestAndSave, modifier = Modifier.weight(1f), enabled = cookie.isNotBlank() ) { - Text(text = "测试并保存") + Text(text = stringResource(R.string.action_test_and_save)) } } OutlinedButton( onClick = onOpenLoginUrl, modifier = Modifier.fillMaxWidth() ) { - Text(text = "在浏览器中打开 Ollama Settings") + Text(text = stringResource(R.string.action_open_ollama_settings)) } TextButton(onClick = onCopyLoginUrl, modifier = Modifier.fillMaxWidth()) { - Text(text = "复制登录 URL 到剪贴板") + Text(text = stringResource(R.string.action_copy_login_url)) } // ── 一键激活用量 API Key ── androidx.compose.material3.HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) Text( - text = "一键激活用量 API Key(可选)", + text = stringResource(R.string.activate_api_key_optional), style = MaterialTheme.typography.titleSmall ) Text( - text = "填入 Ollama Cloud API Key 后,可在详情页一键发送请求激活用量统计。从 ollama.com/settings → API Keys 获取。", + text = stringResource(R.string.hint_ollama_trigger_key), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.outline ) @@ -386,7 +389,7 @@ private fun OllamaCookieForm( value = triggerApiKey, onValueChange = onApiKeyChange, label = { Text("API Key") }, - placeholder = { Text("ollama-xxx 或 sk-xxx") }, + placeholder = { Text(stringResource(R.string.placeholder_ollama_api_key)) }, singleLine = true, visualTransformation = PasswordVisualTransformation(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), @@ -397,7 +400,7 @@ private fun OllamaCookieForm( modifier = Modifier.fillMaxWidth(), enabled = triggerApiKey.isNotBlank() ) { - Text("保存 API Key") + Text(stringResource(R.string.action_save_api_key)) } } @@ -419,28 +422,28 @@ private fun OpenCodeGoForm( onApiKeyChange: (String) -> Unit, onSaveApiKey: () -> Unit ) { - Text(text = "OpenCode Go 凭据", style = MaterialTheme.typography.titleMedium) + Text(text = stringResource(R.string.credential_title_opencode_go), style = MaterialTheme.typography.titleMedium) Text( - text = "需要 auth cookie + workspaceId 两个值。APP 会用 OkHttp 抓 dashboard 并自动解析 5h/周/月 三档用量。", + text = stringResource(R.string.credential_hint_opencode_go), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.outline ) OutlinedButton(onClick = onImportFromClipboard, modifier = Modifier.fillMaxWidth()) { - Text(text = "从剪贴板自动导入") + Text(text = stringResource(R.string.action_import_from_clipboard)) } OutlinedTextField( value = workspaceId, onValueChange = onWorkspaceIdChange, label = { Text("Workspace ID") }, - placeholder = { Text("dashboard URL 中的 {id} 段") }, + placeholder = { Text(stringResource(R.string.placeholder_workspace_id)) }, singleLine = true, modifier = Modifier.fillMaxWidth() ) OutlinedTextField( value = authCookie, onValueChange = onAuthCookieChange, - label = { Text("auth Cookie 值") }, - placeholder = { Text("F12 → Cookies → opencode.ai → auth 的 Value") }, + label = { Text(stringResource(R.string.field_auth_cookie_value)) }, + placeholder = { Text(stringResource(R.string.placeholder_auth_cookie)) }, singleLine = true, visualTransformation = PasswordVisualTransformation(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), @@ -451,36 +454,36 @@ private fun OpenCodeGoForm( modifier = Modifier.fillMaxWidth(), enabled = authCookie.isNotBlank() && workspaceId.isNotBlank() ) { - Text(text = if (hasExisting) "更新凭据" else "保存凭据") + Text(text = if (hasExisting) stringResource(R.string.credential_update) else stringResource(R.string.credential_save)) } OutlinedButton( onClick = onTestAndSave, modifier = Modifier.fillMaxWidth(), enabled = authCookie.isNotBlank() && workspaceId.isNotBlank() ) { - Text(text = "测试并保存") + Text(text = stringResource(R.string.action_test_and_save)) } OutlinedButton(onClick = onShowHelp, modifier = Modifier.fillMaxWidth()) { - Text(text = "如何获取这两个值?") + Text(text = stringResource(R.string.action_how_get_two_values)) } OutlinedButton( onClick = onOpenLoginUrl, modifier = Modifier.fillMaxWidth() ) { - Text(text = "在浏览器中打开登录入口") + Text(text = stringResource(R.string.action_open_login_entry)) } TextButton(onClick = onCopyLoginUrl, modifier = Modifier.fillMaxWidth()) { - Text(text = "复制登录 URL 到剪贴板") + Text(text = stringResource(R.string.action_copy_login_url)) } // ── 一键激活用量 API Key ── androidx.compose.material3.HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) Text( - text = "一键激活用量 API Key(可选)", + text = stringResource(R.string.activate_api_key_optional), style = MaterialTheme.typography.titleSmall ) Text( - text = "填入 OpenCode Zen API Key 后,可在详情页一键发送请求激活用量统计。从 opencode.ai/settings → API Keys 获取。", + text = stringResource(R.string.hint_opencode_trigger_key), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.outline ) @@ -499,7 +502,7 @@ private fun OpenCodeGoForm( modifier = Modifier.fillMaxWidth(), enabled = triggerApiKey.isNotBlank() ) { - Text("保存 API Key") + Text(stringResource(R.string.action_save_api_key)) } } @@ -514,9 +517,9 @@ private fun CommandCodeGoForm( onTestAndSave: () -> Unit, onShowHelp: () -> Unit ) { - Text(text = "CommandCode Go 凭据", style = MaterialTheme.typography.titleMedium) + Text(text = stringResource(R.string.credential_title_ccgo), style = MaterialTheme.typography.titleMedium) Text( - text = "需要 API Key(拉余额)。Session Cookie(可选)在浏览器 DevTools 复制,用于拉用量记录。不填也可正常查看余额。", + text = stringResource(R.string.credential_hint_ccgo), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.outline ) @@ -524,7 +527,7 @@ private fun CommandCodeGoForm( value = apiKey, onValueChange = onApiKeyChange, label = { Text("API Key") }, - placeholder = { Text("从 Settings → API Keys 生成") }, + placeholder = { Text(stringResource(R.string.placeholder_api_key_from_settings)) }, singleLine = true, visualTransformation = PasswordVisualTransformation(), keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Password), @@ -533,23 +536,23 @@ private fun CommandCodeGoForm( OutlinedTextField( value = cookieInput, onValueChange = onCookieChange, - label = { Text("Session Cookie(可选)") }, - placeholder = { Text("F12 → Cookies → commandcode.ai → 全选复制粘贴即可") }, + label = { Text(stringResource(R.string.field_session_cookie_optional)) }, + placeholder = { Text(stringResource(R.string.placeholder_session_cookie)) }, modifier = Modifier.fillMaxWidth(), minLines = 2 ) Button(onClick = onSave, modifier = Modifier.fillMaxWidth()) { - Text(if (hasExisting) "更新凭据" else "保存凭据") + Text(if (hasExisting) stringResource(R.string.credential_update) else stringResource(R.string.credential_save)) } OutlinedButton( onClick = onTestAndSave, modifier = Modifier.fillMaxWidth(), enabled = apiKey.isNotBlank() ) { - Text(text = "测试并保存") + Text(text = stringResource(R.string.action_test_and_save)) } OutlinedButton(onClick = onShowHelp, modifier = Modifier.fillMaxWidth()) { - Text(text = "如何获取 API Key 和 Cookie?") + Text(text = stringResource(R.string.action_how_get_api_key_cookie)) } } @@ -561,9 +564,9 @@ private fun ApiKeyForm( onSave: () -> Unit, onTestAndSave: () -> Unit ) { - Text(text = "请输入 API Key", style = MaterialTheme.typography.titleMedium) + Text(text = stringResource(R.string.api_key_form_title), style = MaterialTheme.typography.titleMedium) Text( - text = "凭据会使用 Android Keystore (AES-256 GCM) 加密后存储到本机 DataStore。", + text = stringResource(R.string.api_key_form_hint), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.outline ) @@ -579,14 +582,14 @@ private fun ApiKeyForm( modifier = Modifier.fillMaxWidth() ) Button(onClick = onSave, modifier = Modifier.fillMaxWidth()) { - Text(if (hasExisting) "更新" else "保存") + Text(if (hasExisting) stringResource(R.string.action_update) else stringResource(R.string.action_save)) } OutlinedButton( onClick = onTestAndSave, modifier = Modifier.fillMaxWidth(), enabled = apiKey.isNotBlank() ) { - Text(text = "测试并保存") + Text(text = stringResource(R.string.action_test_and_save)) } } @@ -599,17 +602,17 @@ private fun CodexAuthJsonForm( onShowHelp: () -> Unit, onStartOAuth: () -> Unit = {} ) { - Text(text = "Codex / ChatGPT Plus 凭据", style = MaterialTheme.typography.titleMedium) + Text(text = stringResource(R.string.credential_title_codex), style = MaterialTheme.typography.titleMedium) Text( - text = "推荐使用 OAuth 登录自动获取凭据。也可手动粘贴 auth.json 内容。", + text = stringResource(R.string.credential_hint_codex), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.outline ) Button(onClick = onStartOAuth, modifier = Modifier.fillMaxWidth()) { - Text("🔐 OAuth 登录(推荐)") + Text(stringResource(R.string.action_oauth_login)) } Text( - text = "── 或手动导入 ──", + text = stringResource(R.string.or_manual_import), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline, modifier = Modifier.fillMaxWidth().padding(top = 4.dp), @@ -618,16 +621,16 @@ private fun CodexAuthJsonForm( OutlinedTextField( value = authJson, onValueChange = onAuthJsonChange, - label = { Text("auth.json 完整内容") }, - placeholder = { Text("粘贴 {\"tokens\": {...}} 完整 JSON") }, + label = { Text(stringResource(R.string.field_auth_json)) }, + placeholder = { Text(stringResource(R.string.placeholder_auth_json)) }, modifier = Modifier.fillMaxWidth(), minLines = 6 ) Button(onClick = onSave, modifier = Modifier.fillMaxWidth()) { - Text(if (hasExisting) "更新凭据" else "导入并保存") + Text(if (hasExisting) stringResource(R.string.credential_update) else stringResource(R.string.credential_import_save)) } OutlinedButton(onClick = onShowHelp, modifier = Modifier.fillMaxWidth()) { - Text(text = "如何获取 auth.json?") + Text(text = stringResource(R.string.action_how_get_auth_json)) } } @@ -640,16 +643,16 @@ private fun ManualCookieForm( onOpenLoginUrl: () -> Unit, onShowHelp: () -> Unit ) { - Text(text = "粘贴登录态", style = MaterialTheme.typography.titleMedium) + Text(text = stringResource(R.string.manual_cookie_title), style = MaterialTheme.typography.titleMedium) Text( - text = "Google OAuth 会拒绝 APP 内置 WebView,所以请用浏览器登录后把 Cookie 粘到这里。", + text = stringResource(R.string.manual_cookie_hint), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.outline ) OutlinedTextField( value = cookieValue, onValueChange = onCookieChange, - label = { Text("Cookie 字符串") }, + label = { Text(stringResource(R.string.field_cookie_string)) }, placeholder = { Text("name1=value1; name2=value2; ...") }, modifier = Modifier .fillMaxWidth() @@ -657,19 +660,19 @@ private fun ManualCookieForm( minLines = 4 ) Button(onClick = onSave, modifier = Modifier.fillMaxWidth()) { - Text(text = "保存 Cookie") + Text(text = stringResource(R.string.action_save_cookie)) } OutlinedButton(onClick = onShowHelp, modifier = Modifier.fillMaxWidth()) { - Text(text = "如何获取 Cookie?") + Text(text = stringResource(R.string.help_cookie_title)) } OutlinedButton( onClick = onOpenLoginUrl, modifier = Modifier.fillMaxWidth() ) { - Text(text = "在浏览器中打开登录入口") + Text(text = stringResource(R.string.action_open_login_entry)) } TextButton(onClick = onCopyLoginUrl, modifier = Modifier.fillMaxWidth()) { - Text(text = "复制登录 URL 到剪贴板") + Text(text = stringResource(R.string.action_copy_login_url)) } } diff --git a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt index ea50b05..4e7fad4 100644 --- a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt @@ -4,6 +4,7 @@ import android.content.ClipboardManager import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.rainy.token.R import com.rainy.token.data.repository.CredentialRepository import com.rainy.token.data.repository.RepositoryError import com.rainy.token.domain.model.CookieEntry @@ -12,6 +13,7 @@ import com.rainy.token.domain.service.FetchMethod import com.rainy.token.domain.service.ServiceConfigProvider import com.rainy.token.domain.service.ServiceType import com.rainy.token.domain.usecase.RefreshBalanceUseCase +import com.rainy.token.ui.components.UiText import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import javax.inject.Provider @@ -117,7 +119,7 @@ class CredentialEditViewModel @Inject constructor( val current = _uiState.value val trimmedKey = current.apiKey.trim() if (trimmedKey.isBlank()) { - _uiState.update { it.copy(message = "API Key 不能为空") } + _uiState.update { it.copy(message = UiText.Resource(R.string.error_api_key_empty)) } return } viewModelScope.launch { @@ -131,7 +133,7 @@ class CredentialEditViewModel @Inject constructor( _uiState.update { it.copy( apiKey = trimmedKey, - message = "已保存", + message = UiText.Resource(R.string.msg_saved), hasExisting = true ) } @@ -142,7 +144,7 @@ class CredentialEditViewModel @Inject constructor( private suspend fun testAndRollback( type: ServiceType, saveAndPrep: suspend () -> Pair Result>, - formatSuccess: (com.rainy.token.domain.model.ServiceBalance) -> String, + formatSuccess: (com.rainy.token.domain.model.ServiceBalance) -> UiText, rollbackOnFailure: Boolean ) { val (previous, testBlock) = saveAndPrep() @@ -158,23 +160,45 @@ class CredentialEditViewModel @Inject constructor( false } val err = result.exceptionOrNull() - val reason = when (err) { - is RepositoryError.InvalidCredential -> "服务拒绝该凭据 (401/403)" - is RepositoryError.CredentialChanged -> "测试期间凭据已变更" - is RepositoryError.RateLimited -> "请求过于频繁 (429)" - is RepositoryError.ServerError -> "服务端错误 (${err.code})" - is RepositoryError.Network -> "网络错误:${err.cause?.message ?: "未知"}" - else -> err?.message ?: "未知错误" + val reason: UiText = when (err) { + is RepositoryError.InvalidCredential -> + UiText.Resource(R.string.error_credential_rejected) + is RepositoryError.CredentialChanged -> + UiText.Resource(R.string.error_credential_changed_test) + is RepositoryError.RateLimited -> + UiText.Resource(R.string.error_rate_limited) + is RepositoryError.ServerError -> + UiText.Resource(R.string.error_server, listOf(err.code)) + is RepositoryError.Network -> + UiText.Resource( + R.string.error_network, + listOf( + err.cause?.message?.let { UiText.Dynamic(it) } + ?: UiText.Resource(R.string.common_unknown) + ) + ) + is RepositoryError.ParseError -> when (err.reason) { + RepositoryError.ParseErrorReason.EMPTY_BODY -> UiText.Resource(R.string.error_parse_empty_body) + RepositoryError.ParseErrorReason.NOT_JSON_OBJECT -> UiText.Resource(R.string.error_parse_not_json) + RepositoryError.ParseErrorReason.NO_WINDOWS -> UiText.Resource(R.string.error_parse_no_windows) + RepositoryError.ParseErrorReason.NO_MODELS -> UiText.Resource(R.string.error_parse_no_models) + RepositoryError.ParseErrorReason.MODELS_EMPTY -> UiText.Resource(R.string.error_parse_models_empty) + RepositoryError.ParseErrorReason.MALFORMED_RESPONSE -> UiText.Resource(R.string.error_parse_malformed) + } + // Unknown 的 message 以硬编码中文"未知错误"开头,不能透传 UI,统一映射本地化文案 + is RepositoryError.Unknown -> UiText.Resource(R.string.common_unknown) + else -> err?.message?.let { UiText.Dynamic(it) } + ?: UiText.Resource(R.string.common_unknown) } val hasExisting = credentialRepository.get(type) != null - val rollbackNote = when { + val rollbackNote: Any = when { !rollbackOnFailure -> "" - rolledBack -> ",已恢复原凭据" - else -> ",检测到凭据已变化,未执行回滚" + rolledBack -> UiText.Resource(R.string.error_rollback_done) + else -> UiText.Resource(R.string.error_rollback_skipped) } _uiState.update { it.copy( - message = "测试失败。$reason$rollbackNote", + message = UiText.Resource(R.string.error_test_failed, listOf(reason, rollbackNote)), hasExisting = hasExisting ) } @@ -184,13 +208,13 @@ class CredentialEditViewModel @Inject constructor( fun testAndSaveApiKey() { val type = serviceType ?: return if (type != ServiceType.DEEPSEEK && type != ServiceType.COMMANDCODE_GO && type != ServiceType.CODEX) { - _uiState.update { it.copy(message = "暂不支持测试此服务") } + _uiState.update { it.copy(message = UiText.Resource(R.string.error_test_not_supported)) } return } val current = _uiState.value val trimmedKey = current.apiKey.trim() if (trimmedKey.isBlank()) { - _uiState.update { it.copy(message = "API Key 不能为空") } + _uiState.update { it.copy(message = UiText.Resource(R.string.error_api_key_empty)) } return } viewModelScope.launch { @@ -205,30 +229,41 @@ class CredentialEditViewModel @Inject constructor( testAndRollback( type = type, saveAndPrep = { existing to { refreshBalanceUseCaseProvider.get().invoke(type) } }, - formatSuccess = { bal -> "连接成功!余额: ${bal.amount} ${bal.unit}" }, + formatSuccess = { bal -> + UiText.Resource( + R.string.msg_connect_success_balance, + listOf(bal.amount.toString(), bal.unit) + ) + }, rollbackOnFailure = false ) } } /** 把 API Key 缩成 'sk-a***xyz' 这种形式,前 4 后 4,中间用 *** 代替。 */ - private fun maskedKeyPreview(key: String): String { - if (key.length <= 8) return "*** (长度 ${key.length}) ***" + private fun maskedKeyPreview(key: String): UiText { + if (key.length <= 8) { + return UiText.Resource(R.string.msg_key_length, listOf(key.length)) + } val head = key.take(4) val tail = key.takeLast(4) - return "$head***$tail (长度 ${key.length})" + return UiText.Resource(R.string.msg_key_masked, listOf(head, tail, key.length)) } fun saveOpenCodeGoSession() { val type = serviceType ?: return val current = _uiState.value if (current.authCookie.isBlank() || current.workspaceId.isBlank()) { - _uiState.update { it.copy(message = "auth cookie 和 workspaceId 都需要填写") } + _uiState.update { + it.copy(message = UiText.Resource(R.string.error_auth_cookie_workspace)) + } return } viewModelScope.launch { doSaveOpenCodeGo(current.workspaceId.trim(), current.authCookie.trim()) - _uiState.update { it.copy(message = "已保存凭据", hasExisting = true) } + _uiState.update { + it.copy(message = UiText.Resource(R.string.msg_credentials_saved), hasExisting = true) + } } } @@ -236,7 +271,9 @@ class CredentialEditViewModel @Inject constructor( val type = serviceType ?: return val current = _uiState.value if (current.authCookie.isBlank() || current.workspaceId.isBlank()) { - _uiState.update { it.copy(message = "auth cookie 和 workspaceId 都需要填写") } + _uiState.update { + it.copy(message = UiText.Resource(R.string.error_auth_cookie_workspace)) + } return } viewModelScope.launch { @@ -245,7 +282,7 @@ class CredentialEditViewModel @Inject constructor( testAndRollback( type = type, saveAndPrep = { previous to { refreshBalanceUseCaseProvider.get().invoke(type) } }, - formatSuccess = { "连接成功,凭据已保存" }, + formatSuccess = { UiText.Resource(R.string.msg_connect_success_saved) }, rollbackOnFailure = true ) } @@ -269,12 +306,12 @@ class CredentialEditViewModel @Inject constructor( val type = serviceType ?: return val current = _uiState.value if (current.cookieInput.isBlank()) { - _uiState.update { it.copy(message = "Cookie 不能为空") } + _uiState.update { it.copy(message = UiText.Resource(R.string.error_cookie_empty)) } return } val cookies = parseCookieString(current.cookieInput) if (cookies.isEmpty()) { - _uiState.update { it.copy(message = "Cookie 格式错误,应为 name1=value1; name2=value2") } + _uiState.update { it.copy(message = UiText.Resource(R.string.error_cookie_format)) } return } viewModelScope.launch { @@ -289,7 +326,7 @@ class CredentialEditViewModel @Inject constructor( credentialRepository.save(updated) _uiState.update { it.copy( - message = "已保存 ${cookies.size} 个 Cookie", + message = UiText.Resource(R.string.msg_cookies_saved, listOf(cookies.size)), hasExisting = true, cookieCount = cookies.size ) @@ -306,7 +343,7 @@ class CredentialEditViewModel @Inject constructor( val current = _uiState.value val text = current.codexAuthJson.trim() if (text.isBlank()) { - _uiState.update { it.copy(message = "请粘贴 auth.json 内容") } + _uiState.update { it.copy(message = UiText.Resource(R.string.error_auth_json_empty)) } return } viewModelScope.launch { @@ -324,7 +361,9 @@ class CredentialEditViewModel @Inject constructor( ?: System.currentTimeMillis() + 10L * 24 * 3600 * 1000 if (accessToken.isNullOrBlank() || refreshToken.isNullOrBlank()) { - _uiState.update { it.copy(message = "auth.json 缺少 access_token 或 refresh_token") } + _uiState.update { + it.copy(message = UiText.Resource(R.string.error_auth_json_missing_tokens)) + } return@launch } @@ -339,12 +378,22 @@ class CredentialEditViewModel @Inject constructor( credentialRepository.save(newCred) _uiState.update { it.copy( - message = "已保存 Codex 凭据,token 到期后会自动刷新", + message = UiText.Resource(R.string.msg_codex_saved), hasExisting = true ) } } catch (e: Exception) { - _uiState.update { it.copy(message = "解析失败:${e.message ?: "JSON 格式错误"}") } + _uiState.update { + it.copy( + message = UiText.Resource( + R.string.error_auth_json_parse, + listOf( + e.message?.let { UiText.Dynamic(it) } + ?: UiText.Resource(R.string.error_json_format) + ) + ) + ) + } } } } @@ -354,7 +403,7 @@ class CredentialEditViewModel @Inject constructor( val current = _uiState.value val trimmedKey = current.apiKey.trim() if (trimmedKey.isBlank()) { - _uiState.update { it.copy(message = "API Key 不能为空") } + _uiState.update { it.copy(message = UiText.Resource(R.string.error_api_key_empty)) } return } val cookies = if (current.cookieInput.isNotBlank()) { @@ -378,7 +427,7 @@ class CredentialEditViewModel @Inject constructor( _uiState.update { it.copy( apiKey = trimmedKey, - message = "已保存凭据", + message = UiText.Resource(R.string.msg_credentials_saved), hasExisting = true, cookieCount = cookies.size ) @@ -391,7 +440,7 @@ class CredentialEditViewModel @Inject constructor( val current = _uiState.value val trimmedKey = current.apiKey.trim() if (trimmedKey.isBlank()) { - _uiState.update { it.copy(message = "API Key 不能为空") } + _uiState.update { it.copy(message = UiText.Resource(R.string.error_api_key_empty)) } return } viewModelScope.launch { @@ -416,7 +465,10 @@ class CredentialEditViewModel @Inject constructor( type = type, saveAndPrep = { existing to { refreshBalanceUseCaseProvider.get().invoke(type) } }, formatSuccess = { bal -> - "连接成功!余额: \$${String.format(java.util.Locale.US, "%.2f", bal.amount)}" + UiText.Resource( + R.string.msg_connect_success_dollar, + listOf(String.format(java.util.Locale.US, "%.2f", bal.amount)) + ) }, rollbackOnFailure = false ) @@ -448,7 +500,7 @@ class CredentialEditViewModel @Inject constructor( _uiState.update { it.copy( triggerApiKey = trimmedKey, - message = "API Key 已保存", + message = UiText.Resource(R.string.msg_api_key_saved), hasExisting = true ) } @@ -459,12 +511,14 @@ class CredentialEditViewModel @Inject constructor( val type = serviceType ?: return val current = _uiState.value if (current.ollamaCookie.isBlank()) { - _uiState.update { it.copy(message = "Cookie 不能为空") } + _uiState.update { it.copy(message = UiText.Resource(R.string.error_cookie_empty)) } return } viewModelScope.launch { doSaveOllama(current.ollamaCookie.trim()) - _uiState.update { it.copy(message = "已保存凭据", hasExisting = true) } + _uiState.update { + it.copy(message = UiText.Resource(R.string.msg_credentials_saved), hasExisting = true) + } } } @@ -472,7 +526,7 @@ class CredentialEditViewModel @Inject constructor( val type = serviceType ?: return val current = _uiState.value if (current.ollamaCookie.isBlank()) { - _uiState.update { it.copy(message = "Cookie 不能为空") } + _uiState.update { it.copy(message = UiText.Resource(R.string.error_cookie_empty)) } return } viewModelScope.launch { @@ -482,7 +536,10 @@ class CredentialEditViewModel @Inject constructor( type = type, saveAndPrep = { previous to { refreshBalanceUseCaseProvider.get().invoke(type) } }, formatSuccess = { bal -> - "连接成功!Session: ${bal.amount}% · ${bal.extras["plan"] ?: "—"}" + UiText.Resource( + R.string.msg_connect_success_session, + listOf(bal.amount.toString(), bal.extras["plan"] ?: "—") + ) }, rollbackOnFailure = true ) @@ -508,7 +565,7 @@ class CredentialEditViewModel @Inject constructor( credentialRepository.remove(type) _uiState.update { it.copy( - message = "凭据已删除", + message = UiText.Resource(R.string.msg_credential_deleted), hasExisting = false, apiKey = "", cookieInput = "", @@ -530,7 +587,7 @@ class CredentialEditViewModel @Inject constructor( val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return val text = cm.primaryClip?.getItemAt(0)?.text?.toString() if (text.isNullOrBlank()) { - _uiState.update { it.copy(message = "剪贴板为空") } + _uiState.update { it.copy(message = UiText.Resource(R.string.error_clipboard_empty)) } return } val ws = extractWorkspaceId(text) @@ -543,13 +600,13 @@ class CredentialEditViewModel @Inject constructor( } when { ws != null && auth != null -> - _uiState.update { it.copy(message = "已识别 workspaceId 和 auth cookie") } + _uiState.update { it.copy(message = UiText.Resource(R.string.msg_clipboard_ws_auth)) } ws != null -> - _uiState.update { it.copy(message = "已识别 workspaceId,请再粘贴 auth cookie") } + _uiState.update { it.copy(message = UiText.Resource(R.string.msg_clipboard_ws_only)) } auth != null -> - _uiState.update { it.copy(message = "已识别 auth cookie,请再粘贴 workspaceId") } + _uiState.update { it.copy(message = UiText.Resource(R.string.msg_clipboard_auth_only)) } else -> - _uiState.update { it.copy(message = "未识别到有效凭据") } + _uiState.update { it.copy(message = UiText.Resource(R.string.error_clipboard_none)) } } } @@ -594,5 +651,5 @@ data class CredentialEditUiState( val ollamaCookie: String = "", /** OCGO / Ollama 的一键激活用量 API Key */ val triggerApiKey: String = "", - val message: String? = null + val message: UiText? = null ) diff --git a/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt b/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt index f0e8039..da5d181 100644 --- a/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt @@ -1,5 +1,7 @@ package com.rainy.token.ui.settings +import android.app.Activity +import android.os.Build import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -17,6 +19,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator @@ -26,13 +29,19 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel @@ -45,9 +54,11 @@ import com.rainy.token.ui.components.ServiceIcon import com.rainy.token.ui.components.StatusChip import com.rainy.token.ui.components.StatusLevel import com.rainy.token.ui.components.StatusStyle +import com.rainy.token.R import com.rainy.token.ui.components.AppTips import com.rainy.token.ui.theme.InkMuted import com.rainy.token.ui.theme.StrawberryPink +import com.rainy.token.util.LocaleManager /** * 设置页(雨晴风格重做版)。 @@ -72,6 +83,7 @@ fun SettingsScreen( } val uiState by viewModel.uiState.collectAsStateWithLifecycle() + var showLanguageDialog by remember { mutableStateOf(false) } Scaffold( containerColor = Color.Transparent, @@ -79,7 +91,7 @@ fun SettingsScreen( TopAppBar( title = { Text( - "设置", + stringResource(R.string.title_settings), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold ) @@ -88,7 +100,7 @@ fun SettingsScreen( IconButton(onClick = onBack) { Icon( Icons.Filled.ArrowBack, - contentDescription = "返回", + contentDescription = stringResource(R.string.action_back), tint = StrawberryPink ) } @@ -118,7 +130,7 @@ fun SettingsScreen( ) { item { Text( - text = "凭据管理", + text = stringResource(R.string.title_credentials), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = InkMuted, @@ -131,6 +143,9 @@ fun SettingsScreen( onClick = { onEditCredential(status.service) } ) } + item { + LanguageCard(onClick = { showLanguageDialog = true }) + } item { TipsCard(onClick = { onOpenTips() }) } @@ -144,6 +159,105 @@ fun SettingsScreen( } } } + + if (showLanguageDialog) { + LanguageDialog(onDismiss = { showLanguageDialog = false }) + } +} + +@Composable +private fun LanguageCard(onClick: () -> Unit) { + val context = LocalContext.current + val current = LocaleManager.getLocaleCode(context) + val labelRes = when (current) { + "zh" -> R.string.app_locale_zh + "zh-Hant" -> R.string.app_locale_zh_hant + "en" -> R.string.app_locale_en + else -> R.string.app_locale_follow_system + } + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { onClick() }, + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "🌐", + style = MaterialTheme.typography.titleLarge + ) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.title_language), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + Text( + text = stringResource(labelRes), + style = MaterialTheme.typography.bodySmall, + color = InkMuted, + modifier = Modifier.padding(top = 2.dp) + ) + } + } + } +} + +@Composable +private fun LanguageDialog(onDismiss: () -> Unit) { + val context = LocalContext.current + val current = LocaleManager.getLocaleCode(context) + val options: List> = listOf( + null to R.string.app_locale_follow_system, + "zh" to R.string.app_locale_zh, + "zh-Hant" to R.string.app_locale_zh_hant, + "en" to R.string.app_locale_en + ) + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.dialog_language_title)) }, + text = { + Column { + options.forEach { (code, labelRes) -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + LocaleManager.saveLocale(context, code) + onDismiss() + // Android 13+:framework 设置语言后系统自动重建 Activity; + // 旧系统才需要手动 recreate 让资源按新语言重新解析 + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + (context as? Activity)?.recreate() + } + } + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(labelRes), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f) + ) + if (current == code) { + Text("✓", color = StrawberryPink, style = MaterialTheme.typography.bodyMedium) + } + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.action_cancel)) + } + } + ) } @Composable @@ -190,13 +304,13 @@ private fun AboutCard() { ) { Column(modifier = Modifier.padding(20.dp)) { Text( - text = "关于 RainyToken", + text = stringResource(R.string.title_about), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold ) Spacer(modifier = Modifier.padding(top = 8.dp)) Text( - text = "统一查询 AI 余额的小工具。\n本地加密存储凭据,不上传任何数据。", + text = stringResource(R.string.about_description), style = MaterialTheme.typography.bodyMedium, color = InkMuted ) @@ -204,18 +318,19 @@ private fun AboutCard() { } } +@Composable private fun stateLabel(state: CredentialStatus.State): String = when (state) { - CredentialStatus.State.NOT_CONFIGURED -> "未配置 · 点击添加" - CredentialStatus.State.OK -> "已配置 · 凭据有效" - CredentialStatus.State.EXPIRED -> "已过期 · 点击重新配置" - CredentialStatus.State.WARNING -> "需要重新验证" + CredentialStatus.State.NOT_CONFIGURED -> stringResource(R.string.status_not_configured_add) + CredentialStatus.State.OK -> stringResource(R.string.status_ok_valid) + CredentialStatus.State.EXPIRED -> stringResource(R.string.status_expired_reconfigure) + CredentialStatus.State.WARNING -> stringResource(R.string.status_need_reverify) } private fun stateToChip(state: CredentialStatus.State): StatusStyle = when (state) { - CredentialStatus.State.NOT_CONFIGURED -> StatusStyle("未配置", StatusLevel.WARNING) - CredentialStatus.State.OK -> StatusStyle("已配置", StatusLevel.OK) - CredentialStatus.State.EXPIRED -> StatusStyle("已过期", StatusLevel.ERROR) - CredentialStatus.State.WARNING -> StatusStyle("需重登", StatusLevel.WARNING) + CredentialStatus.State.NOT_CONFIGURED -> StatusStyle(R.string.status_not_configured, StatusLevel.WARNING) + CredentialStatus.State.OK -> StatusStyle(R.string.status_configured, StatusLevel.OK) + CredentialStatus.State.EXPIRED -> StatusStyle(R.string.status_expired, StatusLevel.ERROR) + CredentialStatus.State.WARNING -> StatusStyle(R.string.status_relogin, StatusLevel.WARNING) } @Composable @@ -239,13 +354,13 @@ private fun TipsCard(onClick: () -> Unit) { Spacer(modifier = Modifier.width(12.dp)) Column(modifier = Modifier.weight(1f)) { Text( - text = "使用小技巧", + text = stringResource(R.string.title_tips), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = StrawberryPink ) Text( - text = "查看全部 ${AppTips.tips.size} 条隐藏操作", + text = stringResource(R.string.tips_view_all, AppTips.tips.size), style = MaterialTheme.typography.bodySmall, color = InkMuted, modifier = Modifier.padding(top = 2.dp) @@ -276,13 +391,13 @@ private fun DebugLogCard(onClick: () -> Unit) { Spacer(modifier = Modifier.width(12.dp)) Column(modifier = Modifier.weight(1f)) { Text( - text = "调试日志", + text = stringResource(R.string.title_debug_log), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = InkMuted ) Text( - text = "查看 token 刷新、网络请求等调试记录", + text = stringResource(R.string.debug_log_desc), style = MaterialTheme.typography.bodySmall, color = InkMuted, modifier = Modifier.padding(top = 2.dp) diff --git a/app/src/main/java/com/rainy/token/ui/webview/CodexOAuthScreen.kt b/app/src/main/java/com/rainy/token/ui/webview/CodexOAuthScreen.kt index ac36eae..2d9d026 100644 --- a/app/src/main/java/com/rainy/token/ui/webview/CodexOAuthScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/webview/CodexOAuthScreen.kt @@ -43,11 +43,15 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.rainy.token.R +import com.rainy.token.ui.components.UiText +import com.rainy.token.ui.components.asString import com.rainy.token.ui.theme.StrawberryPink /** @@ -77,10 +81,10 @@ fun CodexOAuthScreen( Scaffold( topBar = { TopAppBar( - title = { Text("Codex OAuth 登录") }, + title = { Text(stringResource(R.string.oauth_screen_title)) }, navigationIcon = { IconButton(onClick = onBack) { - Icon(Icons.Filled.ArrowBack, contentDescription = "返回") + Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back)) } } ) @@ -100,7 +104,7 @@ fun CodexOAuthScreen( ) { CircularProgressIndicator(color = StrawberryPink) Text( - "正在交换 Token...", + stringResource(R.string.oauth_exchanging), modifier = Modifier.padding(top = 16.dp), style = MaterialTheme.typography.bodyLarge ) @@ -116,12 +120,12 @@ fun CodexOAuthScreen( verticalArrangement = Arrangement.Center ) { Text( - uiState.error!!, + uiState.error!!.asString(), style = MaterialTheme.typography.bodyLarge, color = androidx.compose.ui.graphics.Color(0xFFE91E63) ) TextButton(onClick = { viewModel.start(CodexOAuthMode.WEBVIEW) }) { - Text("重试", color = StrawberryPink) + Text(stringResource(R.string.action_retry), color = StrawberryPink) } } } @@ -180,7 +184,7 @@ fun CodexOAuthScreen( ) { CircularProgressIndicator(color = StrawberryPink) Text( - "正在准备授权...", + stringResource(R.string.oauth_preparing), modifier = Modifier.padding(top = 16.dp), style = MaterialTheme.typography.bodyLarge ) @@ -197,7 +201,7 @@ fun CodexOAuthScreen( @Composable private fun HeadlessOAuthContent( authUrl: String, - error: String?, + error: UiText?, onCopyUrl: () -> Unit, onOpenInBrowser: () -> Unit, onSubmit: (String) -> Unit, @@ -213,16 +217,12 @@ private fun HeadlessOAuthContent( verticalArrangement = Arrangement.spacedBy(12.dp) ) { Text( - text = "无头模式登录", + text = stringResource(R.string.oauth_headless_title), style = MaterialTheme.typography.titleMedium, color = StrawberryPink ) Text( - text = "1. 点击「在浏览器中打开」或复制下方链接到浏览器\n" + - "2. 在 OpenAI 页面完成登录和授权\n" + - "3. 浏览器会跳转到一个 localhost 地址(页面打不开是正常的)\n" + - "4. 复制浏览器地址栏的完整 URL 粘贴到下方输入框\n" + - "5. 点击「提交回调 URL」", + text = stringResource(R.string.oauth_steps), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.outline ) @@ -231,7 +231,7 @@ private fun HeadlessOAuthContent( OutlinedTextField( value = authUrl, onValueChange = {}, - label = { Text("授权链接") }, + label = { Text(stringResource(R.string.oauth_auth_url_label)) }, readOnly = true, minLines = 3, maxLines = 5, @@ -246,18 +246,18 @@ private fun HeadlessOAuthContent( onClick = onOpenInBrowser, modifier = Modifier.weight(1f) ) { - Text("在浏览器中打开") + Text(stringResource(R.string.action_open_in_browser)) } OutlinedButton( onClick = onCopyUrl, modifier = Modifier.weight(1f) ) { - Text("复制链接") + Text(stringResource(R.string.action_copy_link)) } } Text( - text = "── 登录后粘贴回调 URL ──", + text = stringResource(R.string.oauth_paste_callback_hint), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline, modifier = Modifier.fillMaxWidth().padding(top = 8.dp), @@ -267,8 +267,8 @@ private fun HeadlessOAuthContent( OutlinedTextField( value = callbackUrl, onValueChange = { callbackUrl = it }, - label = { Text("回调 URL") }, - placeholder = { Text("http://localhost:1455/auth/callback?code=...") }, + label = { Text(stringResource(R.string.oauth_callback_url_label)) }, + placeholder = { Text(stringResource(R.string.oauth_callback_url_placeholder)) }, minLines = 2, maxLines = 6, modifier = Modifier.fillMaxWidth() @@ -279,18 +279,18 @@ private fun HeadlessOAuthContent( modifier = Modifier.fillMaxWidth(), enabled = callbackUrl.isNotBlank() ) { - Text("提交回调 URL") + Text(stringResource(R.string.action_submit_callback)) } if (error != null) { Text( - text = error, + text = error.asString(), style = MaterialTheme.typography.bodySmall, color = androidx.compose.ui.graphics.Color(0xFFE91E63), modifier = Modifier.padding(top = 4.dp) ) TextButton(onClick = onRetry) { - Text("重新生成授权链接", color = StrawberryPink) + Text(stringResource(R.string.action_regenerate_link), color = StrawberryPink) } } } diff --git a/app/src/main/java/com/rainy/token/ui/webview/CodexOAuthViewModel.kt b/app/src/main/java/com/rainy/token/ui/webview/CodexOAuthViewModel.kt index a89b11f..9d42c3a 100644 --- a/app/src/main/java/com/rainy/token/ui/webview/CodexOAuthViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/webview/CodexOAuthViewModel.kt @@ -2,11 +2,13 @@ package com.rainy.token.ui.webview import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.rainy.token.R import com.rainy.token.data.debug.DebugLog import com.rainy.token.data.repository.CodexOAuthHelper import com.rainy.token.data.repository.CredentialRepository import com.rainy.token.domain.model.Credential import com.rainy.token.domain.service.ServiceType +import com.rainy.token.ui.components.UiText import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -80,7 +82,14 @@ class CodexOAuthViewModel @Inject constructor( */ private fun handleCallbackUrl(url: String) { if (!url.startsWith(CodexOAuthHelper.CALLBACK_PREFIX)) { - _uiState.update { it.copy(error = "URL 应以 ${CodexOAuthHelper.CALLBACK_PREFIX} 开头") } + _uiState.update { + it.copy( + error = UiText.Resource( + R.string.error_oauth_url_prefix, + listOf(CodexOAuthHelper.CALLBACK_PREFIX) + ) + ) + } return } @@ -92,19 +101,21 @@ class CodexOAuthViewModel @Inject constructor( if (error != null) { val errorDesc = uri.getQueryParameter("error_description") ?: error DebugLog.e(TAG, "OAuth 授权失败: $errorDesc") - _uiState.update { it.copy(error = "授权失败: $errorDesc") } + _uiState.update { + it.copy(error = UiText.Resource(R.string.error_oauth_auth_failed, listOf(errorDesc))) + } return } if (receivedState != _uiState.value.expectedState) { DebugLog.e(TAG, "OAuth state 不匹配(CSRF 防护)") - _uiState.update { it.copy(error = "State 不匹配,请重试") } + _uiState.update { it.copy(error = UiText.Resource(R.string.error_oauth_state_mismatch)) } return } if (code.isNullOrBlank()) { DebugLog.e(TAG, "OAuth 回调缺少 code 参数") - _uiState.update { it.copy(error = "回调缺少授权码") } + _uiState.update { it.copy(error = UiText.Resource(R.string.error_oauth_missing_code)) } return } @@ -118,7 +129,12 @@ class CodexOAuthViewModel @Inject constructor( if (result == null) { DebugLog.e(TAG, "token 交换失败") - _uiState.update { it.copy(exchanging = false, error = "Token 交换失败,请查看调试日志") } + _uiState.update { + it.copy( + exchanging = false, + error = UiText.Resource(R.string.error_oauth_token_exchange) + ) + } return@launch } @@ -149,5 +165,5 @@ data class CodexOAuthUiState( val expectedState: String? = null, val exchanging: Boolean = false, val loginSucceeded: Boolean = false, - val error: String? = null + val error: UiText? = null ) \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginScreen.kt b/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginScreen.kt index 5151fcd..c2ad9fb 100644 --- a/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginScreen.kt @@ -27,11 +27,14 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.rainy.token.R import com.rainy.token.domain.service.ServiceType +import com.rainy.token.ui.components.asString /** * 通用 WebView 登录容器。计划 4.1: @@ -65,10 +68,10 @@ fun WebViewLoginScreen( Scaffold( topBar = { TopAppBar( - title = { Text("登录 ${service.displayName}") }, + title = { Text(stringResource(R.string.title_webview_login, service.displayName)) }, navigationIcon = { IconButton(onClick = onBack) { - Icon(Icons.Filled.ArrowBack, contentDescription = "返回") + Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back)) } } ) @@ -113,7 +116,7 @@ fun WebViewLoginScreen( .fillMaxSize() .padding(16.dp)) { Text( - text = "未配置登录 URL", + text = stringResource(R.string.error_login_url_missing), style = MaterialTheme.typography.bodyLarge ) } @@ -125,21 +128,20 @@ fun WebViewLoginScreen( if (uiState.pendingManualConfirm && !uiState.loginSucceeded) { AlertDialog( onDismissRequest = { viewModel.dismissManualPrompt() }, - title = { Text("完成登录了吗?") }, + title = { Text(stringResource(R.string.dialog_login_done_title)) }, text = { Text( - uiState.error ?: "如果你已经在页面上完成登录但页面没有跳转," + - "可点击下方按钮让我抓取 Cookie 并保存登录态。" + uiState.error?.asString() ?: stringResource(R.string.dialog_login_done_body) ) }, confirmButton = { Button(onClick = { viewModel.confirmLoginManually() }) { - Text("我已登录,保存") + Text(stringResource(R.string.action_login_save)) } }, dismissButton = { TextButton(onClick = { viewModel.dismissManualPrompt() }) { - Text("继续等待") + Text(stringResource(R.string.action_keep_waiting)) } } ) diff --git a/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginViewModel.kt b/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginViewModel.kt index 8cb01e8..780f21d 100644 --- a/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginViewModel.kt @@ -1,10 +1,12 @@ package com.rainy.token.ui.webview import androidx.lifecycle.ViewModel +import com.rainy.token.R import com.rainy.token.data.repository.WebViewSessionSaver import com.rainy.token.domain.model.Credential import com.rainy.token.domain.service.ServiceConfigProvider import com.rainy.token.domain.service.ServiceType +import com.rainy.token.ui.components.UiText import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -75,7 +77,7 @@ class WebViewLoginViewModel @Inject constructor( } else { _uiState.update { it.copy( - error = "未抓到 Cookie,请确认已登录", + error = UiText.Resource(R.string.error_cookie_not_found), pendingManualConfirm = true ) } @@ -96,5 +98,5 @@ data class WebViewLoginUiState( val loginSucceeded: Boolean = false, val savedSession: Credential.SessionCredential? = null, val pendingManualConfirm: Boolean = false, - val error: String? = null + val error: UiText? = null ) \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt b/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt index 7c172c6..83932a5 100644 --- a/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt +++ b/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt @@ -15,6 +15,7 @@ import com.rainy.token.data.cache.BalanceCache import com.rainy.token.data.cache.balanceCacheDataStore import com.rainy.token.domain.service.ServiceType import com.rainy.token.ui.components.normalizeWindowLabel +import com.rainy.token.util.LocaleManager import kotlinx.coroutines.runBlocking import java.text.SimpleDateFormat import java.util.Date @@ -57,9 +58,20 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() { ) { var selectedHasCachedData = false + // 每次渲染都用最新语言偏好重新包装 context:Application 层的 wrap 在进程启动时固定, + // 应用内切换语言后不会自动更新,必须在这里按当前偏好重建,否则 widget 文本停留在旧语言 + val localized = LocaleManager.wrapContext(context) + for (widgetId in appWidgetIds) { val views = RemoteViews(context.packageName, R.layout.widget_opencode_go) + // 署名动态设置:与行标签同源(跟随应用内语言), + // 避免 XML 静态文本由宿主进程按系统语言渲染造成中英混搭 + views.setTextViewText(R.id.widget_brand, localized.getString(R.string.widget_brand)) + + // DeepSeek 余额标签同样动态设置(静态 XML 文本由 Launcher 按系统语言解析) + views.setTextViewText(R.id.widget_ds_label, localized.getString(R.string.widget_deepseek_balance)) + // 点击左上角品牌 → 打开 APP;点击其它内容区域 → 切换服务;刷新按钮单独刷新。 val openAppIntent = Intent(context, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP @@ -93,7 +105,10 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() { val cache = BalanceCache(dataStore) val selectedService = currentDisplayService(context) views.setTextViewText(R.id.widget_switch, shortName(selectedService)) - views.setTextViewText(R.id.widget_service_title, "${selectedService.displayName} · 额度") + views.setTextViewText( + R.id.widget_service_title, + localized.getString(R.string.widget_service_quota, selectedService.displayName) + ) views.setImageViewResource(R.id.widget_logo, widgetLogo(selectedService)) // Ollama logo 是正方形,XML 默认 22x12 是给宽扁 logo 的 if (selectedService == ServiceType.OLLAMA) { @@ -106,15 +121,21 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() { val cached = cache.get(selectedService) if (cached != null) { selectedHasCachedData = true - populateServiceRows(views, selectedService, cached.balance) + populateServiceRows(views, localized, selectedService, cached.balance) val sdf = SimpleDateFormat("MM-dd HH:mm", Locale.getDefault()) - val timeText = "更新 " + sdf.format(Date(cached.fetchedAt)) - views.setTextViewText(R.id.widget_updated, timeText) + val timeText = sdf.format(Date(cached.fetchedAt)) + views.setTextViewText( + R.id.widget_updated, + localized.getString(R.string.widget_updated_at, timeText) + ) } else { - setEmptyState(views) + setEmptyState(views, localized) views.setTextViewText(R.id.widget_switch, shortName(selectedService)) - views.setTextViewText(R.id.widget_service_title, "${selectedService.displayName} · 额度") + views.setTextViewText( + R.id.widget_service_title, + localized.getString(R.string.widget_service_quota, selectedService.displayName) + ) } // DeepSeek 余额 @@ -127,7 +148,7 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() { views.setTextViewText(R.id.widget_ds_amount, "—") } } catch (_: Exception) { - setEmptyState(views) + setEmptyState(views, localized) } } @@ -143,13 +164,14 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() { private fun populateServiceRows( views: RemoteViews, + context: Context, service: ServiceType, balance: com.rainy.token.domain.model.ServiceBalance ) { val extras = balance.extras when (service) { ServiceType.OPENCODE_GO -> { - setRowLabel(views, "5h", "本周", "本月") + setRowLabel(views, context.getString(R.string.window_5h_short), context.getString(R.string.window_weekly), context.getString(R.string.window_monthly)) populateRow(views, R.id.row1_pct, R.id.row1_bar, R.id.row1_reset, pct = extras["rolling.pct"]?.toIntOrNull(), resetSec = extras["rolling.resetInSec"]?.toLongOrNull()) @@ -165,7 +187,7 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() { if (used == null || cap == null || cap <= 0) return null return ((used / cap) * 100).toInt().coerceIn(0, 100) } - setRowLabel(views, "5h", "本周", "本月") + setRowLabel(views, context.getString(R.string.window_5h_short), context.getString(R.string.window_weekly), context.getString(R.string.window_monthly)) populateRow(views, R.id.row1_pct, R.id.row1_bar, R.id.row1_reset, pct = calcPct(extras["fiveHour.used"]?.toDoubleOrNull(), extras["fiveHour.cap"]?.toDoubleOrNull()), resetSec = extras["fiveHour.resetInSec"]?.toLongOrNull()) @@ -180,8 +202,14 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() { val windowCount = extras.keys .mapNotNull { key -> key.removePrefix("window_").substringBefore('.').toIntOrNull() } .distinct().maxOrNull()?.plus(1) ?: 0 + val weeklyLabel = context.getString(R.string.window_every_week) + val monthlyLabel = context.getString(R.string.window_every_month) val windows = (0 until windowCount).map { index -> - val label = normalizeWindowLabel(extras["window_$index.label"] ?: "Usage") + val label = normalizeWindowLabel( + extras["window_$index.label"] ?: "Usage", + weeklyLabel = weeklyLabel, + monthlyLabel = monthlyLabel + ) val remaining = extras["window_$index.remainingPct"]?.toIntOrNull() val resetAt = extras["window_$index.resetAt"]?.toLongOrNull()?.takeIf { it > 0 } Triple(label, remaining?.let { (100 - it).coerceIn(0, 100) }, resetAt?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 }) @@ -195,8 +223,8 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() { windows.firstOrNull { it.first.contains("5") } ?: windows.getOrNull(0) ?: Triple("5h", null, null) } val otherWindows = windows.filter { it != row1 } - val row2 = otherWindows.getOrNull(0) ?: Triple("每周", null, null) - val row3 = otherWindows.getOrNull(1) ?: Triple("每月", null, null) + val row2 = otherWindows.getOrNull(0) ?: Triple(weeklyLabel, null, null) + val row3 = otherWindows.getOrNull(1) ?: Triple(monthlyLabel, null, null) setRowLabel(views, row1.first, row2.first, row3.first) listOf( Triple(R.id.row1_pct, R.id.row1_bar, R.id.row1_reset), @@ -207,9 +235,9 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() { populateRow(views, ids.first, ids.second, ids.third, pct = window.second, resetSec = window.third) } } - ServiceType.DEEPSEEK -> setEmptyState(views) + ServiceType.DEEPSEEK -> setEmptyState(views, context) ServiceType.OLLAMA -> { - setRowLabel(views, "5h", "每周", "") + setRowLabel(views, context.getString(R.string.window_5h_short), context.getString(R.string.window_every_week), "") populateRow(views, R.id.row1_pct, R.id.row1_bar, R.id.row1_reset, pct = extras["session.pct"]?.toFloatOrNull()?.toInt(), resetSec = extras["session.resetAt"]?.toLongOrNull()?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 }) @@ -223,8 +251,11 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() { views.setTextViewText(R.id.row3_reset, "") // Plan 信息合并到标题行 val plan = extras["plan"] ?: "" - val titleText = if (plan.isNotEmpty()) "${service.displayName} · $plan" - else "${service.displayName} · 额度" + val titleText = if (plan.isNotEmpty()) { + context.getString(R.string.widget_quota_with_plan, service.displayName, plan) + } else { + context.getString(R.string.widget_service_quota, service.displayName) + } views.setTextViewText(R.id.widget_service_title, titleText) } } @@ -269,8 +300,8 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() { views.setColorStateList(barViewId, "setProgressTintList", ColorStateList.valueOf(color)) } - private fun setEmptyState(views: RemoteViews) { - views.setTextViewText(R.id.widget_updated, "暂无数据") + private fun setEmptyState(views: RemoteViews, context: Context) { + views.setTextViewText(R.id.widget_updated, context.getString(R.string.widget_no_data)) for (pctId in listOf(R.id.row1_pct, R.id.row2_pct, R.id.row3_pct)) { views.setTextViewText(pctId, "—") } @@ -352,12 +383,14 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() { } fun showRefreshing(context: Context) { + // 与 onUpdate 同理:用最新语言偏好包装,避免进程启动后切换语言不生效 + val localized = LocaleManager.wrapContext(context) val appWidgetManager = AppWidgetManager.getInstance(context) val component = ComponentName(context, OpenCodeGoWidgetProvider::class.java) val ids = appWidgetManager.getAppWidgetIds(component) ids.forEach { id -> val views = RemoteViews(context.packageName, R.layout.widget_opencode_go) - views.setTextViewText(R.id.widget_updated, "刷新中…") + views.setTextViewText(R.id.widget_updated, localized.getString(R.string.widget_refreshing)) appWidgetManager.partiallyUpdateAppWidget(id, views) } } diff --git a/app/src/main/java/com/rainy/token/util/LocaleManager.kt b/app/src/main/java/com/rainy/token/util/LocaleManager.kt new file mode 100644 index 0000000..1d6258b --- /dev/null +++ b/app/src/main/java/com/rainy/token/util/LocaleManager.kt @@ -0,0 +1,98 @@ +package com.rainy.token.util + +import android.content.Context +import android.content.res.Configuration +import android.os.Build +import android.os.LocaleList +import java.util.Locale + +/** + * 应用内语言偏好管理(自建轻量方案,不引入 appcompat 依赖)。 + * + * 语言值约定: + * - `null`(未设置)= 跟随系统 + * - `"zh"` = 简体中文(匹配 values-b+zh+Hans,BCP-47 写法,覆盖中国大陆等简体地区) + * - `"zh-Hant"` = 繁体中文(匹配 values-b+zh+Hant,BCP-47 写法,覆盖台/港/澳所有繁体地区) + * - `"en"` = English(匹配默认 values/ 英文资源) + * + * 注:AGP 9 资源合并器不接受 values-zh-Hans/values-zh-Hant 目录名,故用等价的 + * BCP-47 写法 values-b+zh+Hans / values-b+zh+Hant;编译产物同为 + * language=zh + script=Hans/Hant 配置。 + * + * 在 [Application] 与 [MainActivity] 的 attachBaseContext 中调用 [wrapContext] + * 应用偏好;切换语言后由设置页保存偏好并 recreate Activity 触发重新解析。 + * + * Android 13+(TIRAMISU)与系统"应用语言"页同步:读写统一走 framework 的 + * [android.app.LocaleManager],私有 SharedPreferences 仅在 API < 33 或 + * framework 未设置时作为兜底——避免应用内偏好与系统级设置互相覆盖造成双源冲突。 + */ +object LocaleManager { + + private const val PREFS_NAME = "locale_prefs" + private const val KEY_LOCALE = "locale" + + /** 当前生效的语言代码;null = 跟随系统 */ + fun getLocaleCode(context: Context): String? { + // Android 13+:framework 是唯一权威源(系统"应用语言"页与应用内选择共用) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val locales = context.getSystemService(android.app.LocaleManager::class.java)?.applicationLocales + if (locales != null && !locales.isEmpty) { + for (i in 0 until locales.size()) { + val l = locales.get(i) + when { + l.language == "zh" && l.script != "Hant" -> return "zh" + l.language == "zh" && l.script == "Hant" -> return "zh-Hant" + l.language == "en" -> return "en" + } + } + return null // 系统选了不支持的第三方语言 → 回退默认英文资源 + } + // framework 未设置(跟随系统):一次性迁移旧 SharedPreferences 偏好到 + // framework 后清除,避免升级设备上过期偏好复活(framework 才是唯一权威源) + migrateLegacyPrefs(context) + return null + } + // API < 33:回退私有偏好 + val code = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .getString(KEY_LOCALE, null) ?: return null + return code.takeIf { it == "zh" || it == "zh-Hant" || it == "en" } + } + + /** 把 API < 33 时代写入的私有偏好一次性迁移到 framework 并清除 */ + private fun migrateLegacyPrefs(context: Context) { + // 方法体内守卫:lint 据此识别 API33 调用(@RequiresApi 注解在本环境解析异常) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + val code = prefs.getString(KEY_LOCALE, null) ?: return + context.getSystemService(android.app.LocaleManager::class.java) + ?.setApplicationLocales(LocaleList.forLanguageTags(code)) + prefs.edit().remove(KEY_LOCALE).apply() + } + + /** 保存语言偏好;传 null 表示跟随系统 */ + fun saveLocale(context: Context, code: String?) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + // Android 13+:写入系统"应用语言",framework 生效并自动重建 Activity + context.getSystemService(android.app.LocaleManager::class.java) + ?.setApplicationLocales(LocaleList.forLanguageTags(code ?: "")) + return + } + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .putString(KEY_LOCALE, code) + .apply() + } + + /** 把 base context 包装上应用语言配置;未设置偏好时原样返回 */ + fun wrapContext(base: Context): Context { + val code = getLocaleCode(base) ?: return base + val locale = when (code) { + "zh" -> Locale.SIMPLIFIED_CHINESE + "zh-Hant" -> Locale.forLanguageTag("zh-Hant") + else -> Locale.ENGLISH + } + val config = Configuration(base.resources.configuration) + config.setLocales(android.os.LocaleList(locale)) + return base.createConfigurationContext(config) + } +} diff --git a/app/src/main/res/layout-night/widget_opencode_go.xml b/app/src/main/res/layout-night/widget_opencode_go.xml index ded7774..a686d54 100644 --- a/app/src/main/res/layout-night/widget_opencode_go.xml +++ b/app/src/main/res/layout-night/widget_opencode_go.xml @@ -64,6 +64,8 @@ android:id="@+id/widget_updated" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:maxLines="1" + android:ellipsize="end" android:text="--" android:textColor="#D4C4CC" android:textSize="10sp" /> @@ -96,7 +98,7 @@ android:id="@+id/widget_service_title" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:text="OpenCode Go · 用量" + android:text="@string/widget_title_opencode" android:textColor="#C4A0AB" android:textSize="11sp" /> @@ -109,7 +111,8 @@ android:gravity="center_vertical" android:layout_marginBottom="3dp"> + android:layout_width="44dp" android:layout_height="wrap_content" + android:maxLines="1" android:ellipsize="end" + android:text="@string/window_weekly" android:textColor="#C4A0AB" android:textSize="11sp" /> + android:layout_width="44dp" android:layout_height="wrap_content" + android:maxLines="1" android:ellipsize="end" + android:text="@string/window_monthly" android:textColor="#C4A0AB" android:textSize="11sp" /> diff --git a/app/src/main/res/layout/widget_opencode_go.xml b/app/src/main/res/layout/widget_opencode_go.xml index b2e8810..f22080c 100644 --- a/app/src/main/res/layout/widget_opencode_go.xml +++ b/app/src/main/res/layout/widget_opencode_go.xml @@ -66,6 +66,8 @@ android:id="@+id/widget_updated" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:maxLines="1" + android:ellipsize="end" android:text="--" android:textColor="#C4A0AB" android:textSize="10sp" /> @@ -102,7 +104,7 @@ android:id="@+id/widget_service_title" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:text="OpenCode Go · 用量" + android:text="@string/widget_title_opencode" android:textColor="#8A7A82" android:textSize="11sp" /> @@ -116,7 +118,8 @@ android:layout_marginBottom="3dp"> + android:layout_width="44dp" android:layout_height="wrap_content" + android:maxLines="1" android:ellipsize="end" + android:text="@string/window_weekly" android:textColor="#8A7A82" android:textSize="11sp" /> + android:layout_width="44dp" android:layout_height="wrap_content" + android:maxLines="1" android:ellipsize="end" + android:text="@string/window_monthly" android:textColor="#8A7A82" android:textSize="11sp" /> @@ -247,9 +253,10 @@ android:indeterminate="false" /> diff --git a/app/src/main/res/layout/widget_opencode_go_preview.xml b/app/src/main/res/layout/widget_opencode_go_preview.xml index c6927ff..a33984a 100644 --- a/app/src/main/res/layout/widget_opencode_go_preview.xml +++ b/app/src/main/res/layout/widget_opencode_go_preview.xml @@ -41,7 +41,7 @@ @@ -63,7 +63,7 @@ @@ -77,7 +77,8 @@ android:layout_marginBottom="6dp"> + android:layout_width="44dp" android:layout_height="wrap_content" + android:maxLines="1" android:ellipsize="end" + android:text="@string/window_weekly" android:textColor="#8A7A82" android:textSize="11sp" /> + android:layout_width="44dp" android:layout_height="wrap_content" + android:maxLines="1" android:ellipsize="end" + android:text="@string/window_monthly" android:textColor="#8A7A82" android:textSize="11sp" /> diff --git a/app/src/main/res/resources.properties b/app/src/main/res/resources.properties new file mode 100644 index 0000000..6005758 --- /dev/null +++ b/app/src/main/res/resources.properties @@ -0,0 +1,5 @@ +# 默认(无 qualifier)资源对应的语言:英文(国际回退语言) +# 非中文/英文系统语言会回退到默认英文资源;中文系统走 values-b+zh+Hans / values-b+zh+Hant +# AGP 据此 + 中文资源目录生成 locale config,Android 13+ 系统设置中 +# 会显示 RainyToken 的独立语言选项(English / 简体中文 / 繁體中文) +unqualifiedResLocale=en diff --git a/app/src/main/res/values-b+zh+Hans/strings.xml b/app/src/main/res/values-b+zh+Hans/strings.xml new file mode 100644 index 0000000..8579044 --- /dev/null +++ b/app/src/main/res/values-b+zh+Hans/strings.xml @@ -0,0 +1,495 @@ + + + + AI用量面板 + OpenCode Go 用量 + DeepSeek 余额,自动刷新 + 雨晴喵 + 雨晴Token + + + + + 返回 + 取消 + 确认添加 + 我知道了 + 刷新 + 设置 + 删除 + 清除 + 保存 + 跳转 + ◀ 上一页 + 下一页 ▶ + 添加小组件到桌面 + 语言 + 选择语言 + 跟随系统 + 简体中文 + 繁體中文 + English + 查看详情 + 查看用量详情 + 详细数据 + 同步用量数据 + 我已登录,保存 + 继续等待 + + + 加载中… + 未知错误 + 全部模型 + + + 👈 选择服务查看详情 + 点击左侧卡片即可 + 设置 + 凭据管理 + 关于 RainyToken + 使用小技巧 + 调试日志 + 用量详情 + 总统计 + 详细数据 + 统计图表 + + + 未配置 + 已配置 + 已过期 + 需重登 + 刷新失败 + 未获取 + 待获取 + 正常 + 未配置 · 点击添加 + 已配置 · 凭据有效 + 已过期 · 点击重新配置 + 需要重新验证 + 手动输入模式 + 凭据已配置 + 未配置凭据 + 加载中 + 最新 + 缓存 + 失败 + 待输入 + + + AI 余额一览 + 从未获取 + 刚刚更新 + %1$d 分钟前更新 + %1$d 小时前更新 + %1$s 更新 + 💡 长按卡片可拖拽排序 + ⚠ %1$s + + 本地加密存储,不上传任何数据 + 添加桌面小组件? + 确认后会请求桌面启动器添加雨晴Token小组件。部分系统仍会弹出系统确认窗口。 + 桌面快捷方式权限已被拒绝,请在系统设置中允许后重试 + 桌面未接受添加请求,请检查启动器权限 + 当前桌面不支持应用内添加,请在桌面长按 → 小组件 → 查找雨晴Token + + + 点击配置凭据 + 下拉刷新 + 服务当前不可用 + 本月已用 %1$s%2$s + 剩余 + · 已用 %1$s / 共 %2$s + %1$s 已用 + 5h 已用 + · %1$s + %1$s 后重置 + REST API · ¥ + WebView 抓取 · 5h 配额 + JSON API · $ + ChatGPT Plus · Codex 额度 + Cookie 抓取 · Cloud 配额 + + + 天 + 小时 + 分 + + + 5 小时 + 5h + 5h 用量 + 5 小时滚动 + 本周 + 本月 + 每月 + 用量 + 用量窗口%1$s + 用量窗口 + + + OpenCode 用量 + CommandCode 用量 + %1$d 条记录 + 点击同步 + 输入 Token + 输出 Token + 推理 Token + 总花费 + 缓存读取 %1$s + 缓存写入 %1$s + 暂无数据,点击 🔄 同步 + ✓ 新增 %1$d 条 + 部分同步完成:插入 %1$d条,%2$d页失败 + 点击切换 + 所有时间按 UTC+0 计算 + 已切换到 UTC+8,图表按北京时间显示 + 已切换到 UTC+0,图表按世界协调时显示 + 已用 $%1$s / 共 $%2$s + 剩余 $%1$s + 额外充值 + 按模型统计 + 按天统计(UTC+0) + %1$d 个模型 + %1$d 个 + 开始时间 + 结束时间 + + + 消耗金额 (USD) + API 请求次数 + Token 消耗 + 输出 + 输入(未命中) + 命中缓存 + + + 时间 + 模型 + 输入(缓存) + 输出 + 成本 + + + 全部 + 最近5小时 + 最近12小时 + 最近24小时 + 今天 + 昨天 + 最近7天 + 最近30天 + 当月 + 自定义 + 自定义天 + 自定义月 + 自定义时间 + + + 统一查询 AI 余额的小工具。\n本地加密存储凭据,不上传任何数据。 + 查看全部 %1$d 条隐藏操作 + 查看 token 刷新、网络请求等调试记录 + + + ✓ 已配置登录态(%1$d 个 Cookie) + 如何获取 Cookie? + 1. 在桌面 Chrome 打开下方登录入口 + 2. 用 GitHub / Google 登录(不会被拦截) + 3. 登录后按 F12 → Application → Cookies + 4. 复制该站点下的所有 Cookie 粘贴到上面输入框 + 格式:name1=value1; name2=value2 + 如何获取 OpenCode Go 凭据? + 1. 桌面 Chrome 打开 opencode.ai/auth + 2. 用 GitHub / Google 登录(Google 不会拦截 Chrome) + 3. 登录后跳转到 dashboard,URL 形如: + https://opencode.ai/workspace/{id}/go + 4. 复制 URL 中的 {id} 段 → workspaceId + 5. F12 → Application → Cookies + 6. 找到域名 opencode.ai 下名为 auth 的 Cookie + 7. 复制它的 Value 字段 → auth cookie + 注意:只填 auth 一个 Cookie 即可,不要整个 Cookie 字符串 + 如何获取 CommandCode Go 凭据? + 1. 打开 https://commandcode.ai 并登录 GitHub + ▸ 获取 API Key(必填): + 右上角头像 → Settings → API Keys → 创建一个 Key + ▸ 获取全部 Cookie(用于用量记录): + F12 → Application → Cookies → commandcode.ai + 选中所有 Cookie,复制整个 Cookie 字符串 + 粘贴到下方输入框即可,无需逐个挑选 + 提示:不填 Cookie 也能正常查看余额,只是用量记录为空。 + 如何获取 Codex auth.json? + 1. 在终端运行 codex-oauth-proxy 或 + codex-proxy,完成 ChatGPT 登录 + 2. 凭证文件保存在: + + + 已保存 + 已保存凭据 + 连接成功!余额: %1$s %2$s + 连接成功,凭据已保存 + API Key 不能为空 + JSON 格式错误 + 服务拒绝该凭据 (401/403) + 测试期间凭据已变更 + 请求过于频繁 (429) + 服务端错误 (%1$d) + 网络错误:%1$s + ,已恢复原凭据 + ,检测到凭据已变化,未执行回滚 + 测试失败。%1$s%2$s + 暂不支持测试此服务 + auth cookie 和 workspaceId 都需要填写 + Cookie 不能为空 + 凭据未配置 + 凭据未配置或已过期 + 凭据无效,请在设置中重新配置 + 凭据无效,请重新登录 + 凭据已变更,正在重新加载 + 凭据已变更,请重新操作 + 网络异常,请检查网络 + 网络异常 + 服务端异常 (HTTP %1$d) + 请求过于频繁%1$s + ,请 %1$d 秒后重试 + 不支持激活用量 + 请先选择模型 + 未抓到 Cookie,请确认已登录 + 未配置登录 URL + URL 应以 %1$s 开头 + 授权失败: %1$s + State 不匹配,请重试 + 回调缺少授权码 + Token 交换失败,请查看调试日志 + + + ✓ 用量已激活 + ✓ 激活成功 + 请求结果 + 错误: %1$s + 响应内容:\n%1$s + 模型: %1$s + 响应 ID: %1$s + 回复: + (空) + (无回复内容) + (响应解析失败,但请求已发出) + 用量: prompt=%1$s completion=%2$s total=%3$s tokens + 用量: input=%1$s output=%2$s tokens + token 刷新失败,请重新登录或重新导入 auth.json + + + 登录 %1$s + 完成登录了吗? + 如果你已经在页面上完成登录但页面没有跳转,可点击下方按钮让我抓取 Cookie 并保存登录态。 + + + 暂无日志记录\n刷新 Codex 用量后这里会出现调试信息 + + + OpenCode Go · 用量 + DeepSeek 余额 + 自动刷新 + %1$s · 额度 + %1$s · %2$s + 更新 %1$s + 暂无数据 + 刷新中… + + + 长按卡片拖拽排序 + 长按首页卡片可以拖拽排序 + 在首页长按任意服务卡片即可拖拽到想要的位置,排序会自动保存。 + 小组件点左上角进 APP + 小组件左上角标识可打开 APP + 桌面小组件左上角 RainyToken › 标识可以点击打开应用,其它区域点击切换服务。 + 图表点击查看详情 + 点击图表柱子可查看时段详情 + 在用量图表上点击或滑动手指,可查看对应时段的详细数值。 + 图表标题可点击 + 点击图表标题弹出按模型明细 + 图表卡片标题(如「消耗金额」)点击后会弹出按模型拆分的明细。 + 切换图表时区 + 用量详情页可切换 UTC+0 / UTC+8 + 用量详情页顶部可切换 UTC+0 / UTC+8,图表标签会跟随切换。 + CCGO 清除并重新同步 + CCGO 详情页可清除缓存重新同步 + CCGO 用量详情页右上角「清除」可删除本地缓存并重新拉取全部数据。 + 查看用量详情 + 服务卡片底部可进入用量图表 + 有凭据的服务卡片底部会出现「查看用量详情」入口,点击进入图表统计。 + 原始数据浏览 + 用量详情页右上角可查看原始记录 + 用量详情页右上角「详细数据」可查看每条原始调用记录,支持分页跳转。 + 下拉刷新联动用量 + 下拉刷新会同步余额和用量 + 首页下拉刷新不仅刷新余额,还会自动同步 OCGO / CCGO 用量记录。 + 凭据获取教程 + 凭据页底部有获取教程 + 凭据编辑页底部的「如何获取」按钮有针对每个服务的详细操作步骤。 + 剪贴板自动导入 + Codex 支持从剪贴板一键导入 + Codex 凭据页支持「从剪贴板自动导入」,粘贴 auth.json 后一键填入。 + 详细数据页码跳转 + 原始数据页可输入页码直跳 + 原始数据页底部可输入页码直接跳转,数据量大时不必逐页翻。 + 小组件自动刷新 + 划到小组件会自动刷新余额 + 桌面小组件划到负一屏时会自动刷新余额,也可手动点 ↻ 按钮刷新。 + + + %1$d次 + 选择日期 + 选择月份(任意一天) + 开始日期 + 结束日期 + 消费明细 + 调用次数明细 + Token 明细 + 清除数据 + 此操作将清除所有 CommandCode 用量本地缓存并重新同步,需要几秒钟完成。 + 是否继续? + 确认(%1$ds) + 确认清除 + 关闭 + 从 %1$s + 至 %1$s + 应用自定义范围 + 确定 + 结束日期不能早于开始日期 %1$s + 时间 %1$s:%2$s + 暂无数据 + 合计: %1$s + 总计 + 记录数 + 缓存读取 + 缓存写入 + 缓存 Token 细分 + ※ 缓存读取已计入「输入 Token」 + %1$d 次调用 + MM月dd日 EEEE + %1$d 次 · $%2$s + 原始数据 + 缓存写入(5m) + 缓存写入(1h) + 成本(USD) + 成本(Raw) + + + ~/.config/codex-oauth-proxy/auth.json + 3. 用文本编辑器打开该文件, + 复制全部内容粘贴到上方输入框 + JSON 格式示例: + {"tokens": { + "access_token": "...", + "refresh_token": "...", + "expiresAt": 1783727108252 + }} + 提示:导入后 APP 会自动刷新 token, + 不需要手动更新。 + Ollama Pro 凭据 + 需要从浏览器复制完整 Cookie。登录 ollama.com/settings 后,在 DevTools → Network → 请求头里找到 Cookie 行复制。 + 更新凭据 + 保存凭据 + 测试并保存 + 在浏览器中打开 Ollama Settings + 复制登录 URL 到剪贴板 + 一键激活用量 API Key(可选) + 填入 Ollama Cloud API Key 后,可在详情页一键发送请求激活用量统计。从 ollama.com/settings → API Keys 获取。 + ollama-xxx 或 sk-xxx + 保存 API Key + OpenCode Go 凭据 + 需要 auth cookie + workspaceId 两个值。APP 会用 OkHttp 抓 dashboard 并自动解析 5h/周/月 三档用量。 + 从剪贴板自动导入 + dashboard URL 中的 {id} 段 + auth Cookie 值 + F12 → Cookies → opencode.ai → auth 的 Value + 如何获取这两个值? + 在浏览器中打开登录入口 + 填入 OpenCode Zen API Key 后,可在详情页一键发送请求激活用量统计。从 opencode.ai/settings → API Keys 获取。 + CommandCode Go 凭据 + 需要 API Key(拉余额)。Session Cookie(可选)在浏览器 DevTools 复制,用于拉用量记录。不填也可正常查看余额。 + 从 Settings → API Keys 生成 + Session Cookie(可选) + F12 → Cookies → commandcode.ai → 全选复制粘贴即可 + 如何获取 API Key 和 Cookie? + 请输入 API Key + 凭据会使用 Android Keystore (AES-256 GCM) 加密后存储到本机 DataStore。 + 更新 + Codex / ChatGPT Plus 凭据 + 推荐使用 OAuth 登录自动获取凭据。也可手动粘贴 auth.json 内容。 + 🔐 OAuth 登录(推荐) + ── 或手动导入 ── + auth.json 完整内容 + 粘贴 {"tokens": {...}} 完整 JSON + 导入并保存 + 如何获取 auth.json? + 粘贴登录态 + Google OAuth 会拒绝 APP 内置 WebView,所以请用浏览器登录后把 Cookie 粘到这里。 + Cookie 字符串 + 保存 Cookie + + + 响应体为空 + 响应根节点不是 JSON 对象 + 未找到用量窗口数据 + 未找到模型列表 + 模型列表为空 + 响应格式异常 + 每周 + 模型调用次数 + 缓存数据 · %1$s + 显示的是上次成功获取的余额 + 本月消费 %1$s%2$s + 余额构成 + 赠送余额 + 自费充值 + 加载模型列表... + 选择模型 + 刷新模型列表 + 激活中... + 重试激活用量 + ⚡ 一键激活用量 + 刷新余额 + 重新登录 + 配置凭据 + 当前余额 + 5h 实时用量 + 月度余额 + Session 用量 + 请选择模型 + 错误详情: + API 响应内容: + 复制 + *** (长度 %1$d) *** + %1$s***%2$s (长度 %3$d) + Cookie 格式错误,应为 name1=value1; name2=value2 + 已保存 %1$d 个 Cookie + 请粘贴 auth.json 内容 + auth.json 缺少 access_token 或 refresh_token + 已保存 Codex 凭据,token 到期后会自动刷新 + 解析失败:%1$s + 连接成功!余额: $%1$s + API Key 已保存 + 连接成功!Session: %1$s%% · %2$s + 凭据已删除 + 剪贴板为空 + 已识别 workspaceId 和 auth cookie + 已识别 workspaceId,请再粘贴 auth cookie + 已识别 auth cookie,请再粘贴 workspaceId + 未识别到有效凭据 + + + Codex OAuth 登录 + 正在交换 Token... + 重试 + 无头模式登录 + 1. 点击「在浏览器中打开」或复制下方链接到浏览器\n2. 在 OpenAI 页面完成登录和授权\n3. 浏览器会跳转到一个 localhost 地址(页面打不开是正常的)\n4. 复制浏览器地址栏的完整 URL 粘贴到下方输入框\n5. 点击「提交回调 URL」 + 授权链接 + 在浏览器中打开 + 复制链接 + ── 登录后粘贴回调 URL ── + 回调 URL + http://localhost:1455/auth/callback?code=... + 提交回调 URL + 重新生成授权链接 + 正在准备授权... + \ No newline at end of file diff --git a/app/src/main/res/values-b+zh+Hant/strings.xml b/app/src/main/res/values-b+zh+Hant/strings.xml new file mode 100644 index 0000000..fcdde38 --- /dev/null +++ b/app/src/main/res/values-b+zh+Hant/strings.xml @@ -0,0 +1,495 @@ + + + + AI用量面板 + OpenCode Go 用量 + DeepSeek 餘額,自動重新整理 + 雨晴喵 + 雨晴Token + + + + + 返回 + 取消 + 確認新增 + 我知道了 + 重新整理 + 設定 + 刪除 + 清除 + 儲存 + 跳轉 + ◀ 上一頁 + 下一頁 ▶ + 新增小工具到桌面 + 語言 + 選擇語言 + 跟隨系統 + 簡體中文 + 繁體中文 + English + 檢視詳情 + 檢視用量詳情 + 詳細數據 + 同步用量數據 + 我已登入,儲存 + 繼續等待 + + + 載入中… + 未知錯誤 + 全部模型 + + + 👈 選擇服務檢視詳情 + 點選左側卡片即可 + 設定 + 憑據管理 + 關於 RainyToken + 使用小技巧 + 除錯日誌 + 用量詳情 + 總統計 + 詳細數據 + 統計圖表 + + + 未配置 + 已配置 + 已過期 + 需重登 + 重新整理失敗 + 未獲取 + 待獲取 + 正常 + 未配置 · 點選新增 + 已配置 · 憑據有效 + 已過期 · 點選重新配置 + 需要重新驗證 + 手動輸入模式 + 憑據已配置 + 未配置憑據 + 載入中 + 最新 + 快取 + 失敗 + 待輸入 + + + AI 餘額一覽 + 從未獲取 + 剛剛更新 + %1$d 分鐘前更新 + %1$d 小時前更新 + %1$s 更新 + 💡 長按卡片可拖曳排序 + ⚠ %1$s + + 本地加密儲存,不上傳任何數據 + 新增桌面小工具? + 確認後會請求桌面啟動器新增雨晴Token小工具。部分系統仍會彈出系統確認視窗。 + 桌面快捷方式權限已被拒絕,請在系統設定中允許後重試 + 桌面未接受新增請求,請檢查啟動器權限 + 當前桌面不支援應用內新增,請在桌面長按 → 小工具 → 查詢雨晴Token + + + 點選配置憑據 + 下拉重新整理 + 服務當前不可用 + 本月已用 %1$s%2$s + 剩餘 + · 已用 %1$s / 共 %2$s + %1$s 已用 + 5h 已用 + · %1$s + %1$s 後重置 + REST API · ¥ + WebView 抓取 · 5h 配額 + JSON API · $ + ChatGPT Plus · Codex 額度 + Cookie 抓取 · Cloud 配額 + + + 天 + 小時 + 分 + + + 5 小時 + 5h + 5h 用量 + 5 小時滾動 + 本週 + 本月 + 每月 + 用量 + 用量視窗%1$s + 用量視窗 + + + OpenCode 用量 + CommandCode 用量 + %1$d 條記錄 + 點選同步 + 輸入 Token + 輸出 Token + 推理 Token + 總花費 + 快取讀取 %1$s + 快取寫入 %1$s + 暫無數據,點選 🔄 同步 + ✓ 新增 %1$d 條 + 部分同步完成:插入 %1$d條,%2$d頁失敗 + 點選切換 + 所有時間按 UTC+0 計算 + 已切換到 UTC+8,圖表按北京時間顯示 + 已切換到 UTC+0,圖表按世界協調時顯示 + 已用 $%1$s / 共 $%2$s + 剩餘 $%1$s + 額外充值 + 按模型統計 + 按天統計(UTC+0) + %1$d 個模型 + %1$d 個 + 開始時間 + 結束時間 + + + 消耗金額 (USD) + API 請求次數 + Token 消耗 + 輸出 + 輸入(未命中) + 命中快取 + + + 時間 + 模型 + 輸入(快取) + 輸出 + 成本 + + + 全部 + 最近5小時 + 最近12小時 + 最近24小時 + 今天 + 昨天 + 最近7天 + 最近30天 + 當月 + 自定義 + 自定義天 + 自定義月 + 自定義時間 + + + 統一查詢 AI 餘額的小工具。\n本地加密儲存憑據,不上傳任何數據。 + 檢視全部 %1$d 條隱藏操作 + 檢視 token 重新整理、網路請求等除錯記錄 + + + ✓ 已配置登入態(%1$d 個 Cookie) + 如何獲取 Cookie? + 1. 在桌面 Chrome 開啟下方登入入口 + 2. 用 GitHub / Google 登入(不會被攔截) + 3. 登入後按 F12 → Application → Cookies + 4. 複製該站點下的所有 Cookie 貼上到上面輸入框 + 格式:name1=value1; name2=value2 + 如何獲取 OpenCode Go 憑據? + 1. 桌面 Chrome 開啟 opencode.ai/auth + 2. 用 GitHub / Google 登入(Google 不會攔截 Chrome) + 3. 登入後跳轉到 dashboard,URL 形如: + https://opencode.ai/workspace/{id}/go + 4. 複製 URL 中的 {id} 段 → workspaceId + 5. F12 → Application → Cookies + 6. 找到域名 opencode.ai 下名為 auth 的 Cookie + 7. 複製它的 Value 欄位 → auth cookie + 注意:只填 auth 一個 Cookie 即可,不要整個 Cookie 字串 + 如何獲取 CommandCode Go 憑據? + 1. 開啟 https://commandcode.ai 並登入 GitHub + ▸ 獲取 API Key(必填): + 右上角頭像 → Settings → API Keys → 建立一個 Key + ▸ 獲取全部 Cookie(用於用量記錄): + F12 → Application → Cookies → commandcode.ai + 選中所有 Cookie,複製整個 Cookie 字串 + 貼上到下方輸入框即可,無需逐個挑選 + 提示:不填 Cookie 也能正常檢視餘額,只是用量記錄為空。 + 如何獲取 Codex auth.json? + 1. 在終端執行 codex-oauth-proxy 或 + codex-proxy,完成 ChatGPT 登入 + 2. 憑證檔案儲存在: + + + 已儲存 + 已儲存憑據 + 連線成功!餘額: %1$s %2$s + 連線成功,憑據已儲存 + API Key 不能為空 + JSON 格式錯誤 + 服務拒絕該憑據 (401/403) + 測試期間憑據已變更 + 請求過於頻繁 (429) + 服務端錯誤 (%1$d) + 網路錯誤:%1$s + ,已恢復原憑據 + ,檢測到憑據已變化,未執行回滾 + 測試失敗。%1$s%2$s + 暫不支援測試此服務 + auth cookie 和 workspaceId 都需要填寫 + Cookie 不能為空 + 憑據未配置 + 憑據未配置或已過期 + 憑據無效,請在設定中重新配置 + 憑據無效,請重新登入 + 憑據已變更,正在重新載入 + 憑據已變更,請重新操作 + 網路異常,請檢查網路 + 網路異常 + 服務端異常 (HTTP %1$d) + 請求過於頻繁%1$s + ,請 %1$d 秒後重試 + 不支援啟用用量 + 請先選擇模型 + 未抓到 Cookie,請確認已登入 + 未配置登入 URL + URL 應以 %1$s 開頭 + 授權失敗: %1$s + State 不匹配,請重試 + 回撥缺少授權碼 + Token 交換失敗,請檢視除錯日誌 + + + ✓ 用量已啟用 + ✓ 啟用成功 + 請求結果 + 錯誤: %1$s + 響應內容:\n%1$s + 模型: %1$s + 響應 ID: %1$s + 回覆: + (空) + (無回覆內容) + (響應解析失敗,但請求已發出) + 用量: prompt=%1$s completion=%2$s total=%3$s tokens + 用量: input=%1$s output=%2$s tokens + token 重新整理失敗,請重新登入或重新匯入 auth.json + + + 登入 %1$s + 完成登入了嗎? + 如果你已經在頁面上完成登入但頁面沒有跳轉,可點選下方按鈕讓我抓取 Cookie 並儲存登入態。 + + + 暫無日誌記錄\n重新整理 Codex 用量後這裡會出現除錯資訊 + + + OpenCode Go · 用量 + DeepSeek 餘額 + 自動重新整理 + %1$s · 額度 + %1$s · %2$s + 更新 %1$s + 暫無數據 + 重新整理中… + + + 長按卡片拖曳排序 + 長按首頁卡片可以拖曳排序 + 在首頁長按任意服務卡片即可拖曳到想要的位置,排序會自動儲存。 + 小工具點左上角進 APP + 小工具左上角標識可開啟 APP + 桌面小工具左上角 RainyToken › 標識可以點選開啟應用,其它區域點選切換服務。 + 圖表點選檢視詳情 + 點選圖表柱子可檢視時段詳情 + 在用量圖表上點選或滑動手指,可檢視對應時段的詳細數值。 + 圖表標題可點選 + 點選圖表標題彈出按模型明細 + 圖表卡片標題(如「消耗金額」)點選後會彈出按模型拆分的明細。 + 切換圖表時區 + 用量詳情頁可切換 UTC+0 / UTC+8 + 用量詳情頁頂部可切換 UTC+0 / UTC+8,圖表標籤會跟隨切換。 + CCGO 清除並重新同步 + CCGO 詳情頁可清除快取重新同步 + CCGO 用量詳情頁右上角「清除」可刪除本地快取並重新拉取全部數據。 + 檢視用量詳情 + 服務卡片底部可進入用量圖表 + 有憑據的服務卡片底部會出現「檢視用量詳情」入口,點選進入圖表統計。 + 原始數據瀏覽 + 用量詳情頁右上角可檢視原始記錄 + 用量詳情頁右上角「詳細數據」可檢視每條原始呼叫記錄,支援分頁跳轉。 + 下拉重新整理聯動用量 + 下拉重新整理會同步餘額和用量 + 首頁下拉重新整理不僅重新整理餘額,還會自動同步 OCGO / CCGO 用量記錄。 + 憑據獲取教程 + 憑據頁底部有獲取教程 + 憑據編輯頁底部的「如何獲取」按鈕有針對每個服務的詳細操作步驟。 + 剪貼簿自動匯入 + Codex 支援從剪貼簿一鍵匯入 + Codex 憑據頁支援「從剪貼簿自動匯入」,貼上 auth.json 後一鍵填入。 + 詳細數據頁碼跳轉 + 原始數據頁可輸入頁碼直跳 + 原始數據頁底部可輸入頁碼直接跳轉,數據量大時不必逐頁翻。 + 小工具自動重新整理 + 劃到小工具會自動重新整理餘額 + 桌面小工具劃到負一屏時會自動重新整理餘額,也可手動點 ↻ 按鈕重新整理。 + + + %1$d次 + 選擇日期 + 選擇月份(任意一天) + 開始日期 + 結束日期 + 消費明細 + 呼叫次數明細 + Token 明細 + 清除數據 + 此操作將清除所有 CommandCode 用量本地快取並重新同步,需要幾秒鐘完成。 + 是否繼續? + 確認(%1$ds) + 確認清除 + 關閉 + 從 %1$s + 至 %1$s + 應用自定義範圍 + 確定 + 結束日期不能早於開始日期 %1$s + 時間 %1$s:%2$s + 暫無數據 + 合計: %1$s + 總計 + 記錄數 + 快取讀取 + 快取寫入 + 快取 Token 細分 + ※ 快取讀取已計入「輸入 Token」 + %1$d 次呼叫 + MM月dd日 EEEE + %1$d 次 · $%2$s + 原始數據 + 快取寫入(5m) + 快取寫入(1h) + 成本(USD) + 成本(Raw) + + + ~/.config/codex-oauth-proxy/auth.json + 3. 用文字編輯器開啟該檔案, + 複製全部內容貼上到上方輸入框 + JSON 格式示例: + {"tokens": { + "access_token": "...", + "refresh_token": "...", + "expiresAt": 1783727108252 + }} + 提示:匯入後 APP 會自動重新整理 token, + 不需要手動更新。 + Ollama Pro 憑據 + 需要從瀏覽器複製完整 Cookie。登入 ollama.com/settings 後,在 DevTools → Network → 請求頭裡找到 Cookie 行複製。 + 更新憑據 + 儲存憑據 + 測試並儲存 + 在瀏覽器中開啟 Ollama Settings + 複製登入 URL 到剪貼簿 + 一鍵啟用用量 API Key(可選) + 填入 Ollama Cloud API Key 後,可在詳情頁一鍵傳送請求啟用用量統計。從 ollama.com/settings → API Keys 獲取。 + ollama-xxx 或 sk-xxx + 儲存 API Key + OpenCode Go 憑據 + 需要 auth cookie + workspaceId 兩個值。APP 會用 OkHttp 抓 dashboard 並自動解析 5h/周/月 三檔用量。 + 從剪貼簿自動匯入 + dashboard URL 中的 {id} 段 + auth Cookie 值 + F12 → Cookies → opencode.ai → auth 的 Value + 如何獲取這兩個值? + 在瀏覽器中開啟登入入口 + 填入 OpenCode Zen API Key 後,可在詳情頁一鍵傳送請求啟用用量統計。從 opencode.ai/settings → API Keys 獲取。 + CommandCode Go 憑據 + 需要 API Key(拉餘額)。Session Cookie(可選)在瀏覽器 DevTools 複製,用於拉用量記錄。不填也可正常檢視餘額。 + 從 Settings → API Keys 生成 + Session Cookie(可選) + F12 → Cookies → commandcode.ai → 全選複製貼上即可 + 如何獲取 API Key 和 Cookie? + 請輸入 API Key + 憑據會使用 Android Keystore (AES-256 GCM) 加密後儲存到本機 DataStore。 + 更新 + Codex / ChatGPT Plus 憑據 + 推薦使用 OAuth 登入自動獲取憑據。也可手動貼上 auth.json 內容。 + 🔐 OAuth 登入(推薦) + ── 或手動匯入 ── + auth.json 完整內容 + 貼上 {"tokens": {...}} 完整 JSON + 匯入並儲存 + 如何獲取 auth.json? + 貼上登入態 + Google OAuth 會拒絕 APP 內建 WebView,所以請用瀏覽器登入後把 Cookie 粘到這裡。 + Cookie 字串 + 儲存 Cookie + + + 回應體為空 + 回應根節點不是 JSON 物件 + 未找到用量視窗資料 + 未找到模型清單 + 模型清單為空 + 回應格式異常 + 每週 + 模型呼叫次數 + 快取數據 · %1$s + 顯示的是上次成功獲取的餘額 + 本月消費 %1$s%2$s + 餘額構成 + 贈送餘額 + 自費充值 + 載入模型列表... + 選擇模型 + 重新整理模型列表 + 啟用中... + 重試啟用用量 + ⚡ 一鍵啟用用量 + 重新整理餘額 + 重新登入 + 配置憑據 + 當前餘額 + 5h 實時用量 + 月度餘額 + Session 用量 + 請選擇模型 + 錯誤詳情: + API 響應內容: + 複製 + *** (長度 %1$d) *** + %1$s***%2$s (長度 %3$d) + Cookie 格式錯誤,應為 name1=value1; name2=value2 + 已儲存 %1$d 個 Cookie + 請貼上 auth.json 內容 + auth.json 缺少 access_token 或 refresh_token + 已儲存 Codex 憑據,token 到期後會自動重新整理 + 解析失敗:%1$s + 連線成功!餘額: $%1$s + API Key 已儲存 + 連線成功!Session: %1$s%% · %2$s + 憑據已刪除 + 剪貼簿為空 + 已識別 workspaceId 和 auth cookie + 已識別 workspaceId,請再貼上 auth cookie + 已識別 auth cookie,請再貼上 workspaceId + 未識別到有效憑據 + + + Codex OAuth 登入 + 正在交換 Token... + 重試 + 無頭模式登入 + 1. 點選「在瀏覽器中開啟」或複製下方連結到瀏覽器\n2. 在 OpenAI 頁面完成登入和授權\n3. 瀏覽器會跳轉到一個 localhost 地址(頁面打不開是正常的)\n4. 複製瀏覽器位址列的完整 URL 貼上到下方輸入框\n5. 點選「提交回撥 URL」 + 授權連結 + 在瀏覽器中開啟 + 複製連結 + ── 登入後貼上回撥 URL ── + 回撥 URL + http://localhost:1455/auth/callback?code=... + 提交回撥 URL + 重新生成授權連結 + 正在準備授權... + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a787889..e32c4bc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,5 +1,498 @@ + - 雨晴Token - AI用量面板 -OpenCode Go 用量 + DeepSeek 余额,自动刷新 - \ No newline at end of file + + RainyToken + RainyToken · v1.0 + + + RainyToken + + + AI Usage Panel + OpenCode Go usage + DeepSeek balance, auto-refresh + Rainy + + + Back + Cancel + Add Widget + Got it + Refresh + Settings + Delete + Clear + Save + Go + ◀ Prev + Next ▶ + Add widget to home screen + Language + Select language + Follow system + 简体中文 + 繁體中文 + English + View details + View usage details + Raw data + Sync usage data + I\'m logged in, save + Keep waiting + + + Loading… + Unknown error + All models + + + 👈 Select a service to view details + Tap a card on the left + Settings + Credentials + About RainyToken + Tips + Debug log + Usage details + Overview + Raw data + Charts + + + Not set up + Configured + Expired + Re-login + Refresh failed + No data + Pending + OK + Not set up · Tap to add + Configured · Valid + Expired · Tap to reconfigure + Needs re-verification + Manual entry mode + Credential set + No credential + Loading + Latest + Cached + Failed + Awaiting input + + + AI Balance Overview + Never fetched + Updated just now + Updated %1$d min ago + Updated %1$d h ago + Updated %1$s + 💡 Long-press a card to reorder + ⚠ %1$s + Stored locally & encrypted, nothing is uploaded + Add home screen widget? + This will ask the launcher to add the RainyToken widget. Some systems may still show their own confirmation dialog. + Shortcut permission denied. Please allow it in system settings and try again. + The launcher rejected the request. Please check launcher permissions. + This launcher does not support in-app adding. Long-press home → Widgets → find RainyToken. + + + Tap to configure credentials + Pull to refresh + Service currently unavailable + %1$s%2$s used this month + Remaining + · %1$s used / %2$s total + %1$s used + 5h used + · %1$s + Resets in %1$s + REST API · ¥ + WebView fetch · 5h quota + JSON API · $ + ChatGPT Plus · Codex quota + Cookie fetch · Cloud quota + + + d + h + m + + + 5 hours + 5h + 5h usage + 5-hour rolling + Week + Month + Monthly + Usage + Usage window%1$s + Usage window + + + OpenCode Usage + CommandCode Usage + %1$d records + Tap to sync + Input tokens + Output tokens + Reasoning tokens + Total cost + Cache read %1$s + Cache write %1$s + No data yet · tap 🔄 to sync + ✓ %1$d new records + Partial sync: %1$d records inserted, %2$d pages failed + tap to switch + All times in UTC+0 + Switched to UTC+8, charts shown in Beijing time + Switched to UTC+0, charts shown in UTC + $%1$s spent / $%2$s total + $%1$s remaining + Extra credits + By model + By day (UTC+0) + %1$d models + %1$d + Start time + End time + + + Spending (USD) + API requests + Token usage + Output + Input (miss) + Cache hit + + + Time + Model + Input(cache) + Output + Cost + + + All + Last 5 hours + Last 12 hours + Last 24 hours + Today + Yesterday + Last 7 days + Last 30 days + This month + Custom + Custom day + Custom month + Custom range + + + A tiny tool to check AI balances.\nCredentials are encrypted locally — nothing is uploaded. + View all %1$d hidden tips + Inspect token refresh, network requests and other debug records + + + ✓ Login state configured (%1$d cookies) + How to get the Cookie? + 1. Open the login page below in desktop Chrome + 2. Sign in with GitHub / Google (won\'t be blocked) + 3. After login, press F12 → Application → Cookies + 4. Copy all cookies of the site and paste them above + Format: name1=value1; name2=value2 + How to get OpenCode Go credentials? + 1. Open opencode.ai/auth in desktop Chrome + 2. Sign in with GitHub / Google (Google won\'t block Chrome) + 3. You\'ll land on the dashboard, URL looks like: + https://opencode.ai/workspace/{id}/go + 4. Copy the {id} segment → workspaceId + 5. F12 → Application → Cookies + 6. Find the cookie named auth under opencode.ai + 7. Copy its Value field → auth cookie + Note: only the auth cookie is needed, not the whole cookie string + How to get CommandCode Go credentials? + 1. Open https://commandcode.ai and sign in with GitHub + ▸ Get API Key (required): + Avatar (top right) → Settings → API Keys → Create a Key + ▸ Get all cookies (for usage records): + F12 → Application → Cookies → commandcode.ai + Select all cookies and copy the whole string + Paste it below — no need to pick them one by one + Tip: balance works without cookies, usage records will just be empty. + How to get Codex auth.json? + 1. Run codex-oauth-proxy or + codex-proxy in a terminal to sign in to ChatGPT + 2. The credential file is saved at: + + + Saved + Credentials saved + Connected! Balance: %1$s %2$s + Connected, credentials saved + API Key cannot be empty + Invalid JSON format + Service rejected this credential (401/403) + Credentials changed during test + Too many requests (429) + Server error (%1$d) + Network error: %1$s + , previous credential restored + , credential changed, rollback skipped + Test failed. %1$s%2$s + Testing is not supported for this service + Both auth cookie and workspaceId are required + Cookie cannot be empty + Credential not configured + Credential missing or expired + Credential invalid, please reconfigure in Settings + Credential invalid, please log in again + Credentials changed, reloading + Credentials changed, please retry + Network error, please check your connection + Network error + Server error (HTTP %1$d) + Too many requests%1$s + , retry in %1$d seconds + Usage activation is not supported + Please select a model first + No cookie captured, please make sure you\'re logged in + No login URL configured + URL should start with %1$s + Authorization failed: %1$s + State mismatch, please retry + Callback missing authorization code + Token exchange failed, check the debug log + + + ✓ Usage activated + ✓ Activation successful + Request result + Error: %1$s + Response body:\n%1$s + Model: %1$s + Response ID: %1$s + Reply: + (empty) + (no reply content) + (response parsing failed, but the request was sent) + Usage: prompt=%1$s completion=%2$s total=%3$s tokens + Usage: input=%1$s output=%2$s tokens + Token refresh failed. Please log in again or import auth.json. + + + Sign in to %1$s + Done logging in? + If you finished signing in but the page didn\'t redirect, tap the button below to grab the cookie and save your session. + + + No log entries yet\nRefresh Codex usage and debug info will show up here + + + OpenCode Go · Usage + DeepSeek balance + Auto refresh + %1$s · Quota + %1$s · %2$s + Updated %1$s + No data + Refreshing… + + + Reorder cards by long-press + Long-press a card to reorder it + Long-press any service card on the home screen and drag it wherever you like. The order is saved automatically. + Widget top-left opens the app + Tap the widget\'s top-left mark to open the app + The RainyToken › mark at the top-left of the widget opens the app. Tapping elsewhere switches services. + Tap charts for details + Tap a chart bar to see that time slot + Tap or slide on the usage charts to inspect the exact values of a time slot. + Chart titles are clickable + Tap a chart title for a per-model breakdown + Tapping a chart card title (e.g. "Spending") shows a per-model breakdown dialog. + Switch chart timezone + Usage details can toggle UTC+0 / UTC+8 + The usage details page can toggle between UTC+0 and UTC+8; chart labels follow along. + CCGO clear & resync + CCGO details can clear cache and resync + The "Clear" button on the CCGO usage details page deletes local cache and re-fetches everything. + View usage details + Service cards link to usage charts + Configured service cards show a "View usage details" entry at the bottom that opens the charts. + Browse raw data + Top-right of usage details opens raw records + "Raw data" at the top-right of usage details lists every record with pagination. + Pull-to-refresh syncs usage + Pull to refresh syncs balances and usage + Pull-to-refresh on the home screen refreshes balances and also syncs OCGO / CCGO usage records. + Credential guides + Credential pages include step-by-step guides + The "How to get" button at the bottom of credential pages walks through every service step by step. + Clipboard auto-import + Codex supports one-tap import from clipboard + The Codex credential page supports "Auto-import from clipboard": paste auth.json and it fills in with one tap. + Jump to a page number + Raw data page accepts a page number + The raw data page lets you enter a page number to jump directly — no need to flip through pages. + Widget auto-refresh + Swipe to the widget and it auto-refreshes + The widget refreshes balances when you swipe to it, or tap the ↻ button manually. + + + %1$d calls + Pick a date + Pick a month (any day) + Start date + End date + Spending breakdown + Request breakdown + Token breakdown + Clear data + This clears all cached CommandCode usage and re-syncs. It takes a few seconds. + Continue? + Confirm (%1$ds) + Yes, clear + Close + From %1$s + To %1$s + Apply custom range + OK + End date cannot be earlier than start date %1$s + Time %1$s:%2$s + No data + Total: %1$s + Total + Records + Cache read + Cache write + Cache token breakdown + ※ Cache reads are included in "Input tokens" + %1$d calls + MMM d, EEEE + %1$d calls · $%2$s + Raw data + Cache write (5m) + Cache write (1h) + Cost (USD) + Cost (raw) + + + ~/.config/codex-oauth-proxy/auth.json + 3. Open it with a text editor, + copy everything and paste it above + JSON example: + {"tokens": { + "access_token": "...", + "refresh_token": "...", + "expiresAt": 1783727108252 + }} + Tip: the app auto-refreshes the token after import, + no manual updates needed. + Ollama Pro credentials + Copy the full cookie from your browser. After logging into ollama.com/settings, find the Cookie header in DevTools → Network and copy it. + Update credentials + Save credentials + Test & save + Open Ollama Settings in browser + Copy login URL to clipboard + One-tap usage API Key (optional) + With an Ollama Cloud API Key, the details page can send one request to activate usage stats. Get it at ollama.com/settings → API Keys. + ollama-xxx or sk-xxx + Save API Key + OpenCode Go credentials + Needs both auth cookie and workspaceId. The app fetches the dashboard via OkHttp and parses 5h/week/month usage. + Auto-import from clipboard + The {id} segment of the dashboard URL + auth Cookie value + F12 → Cookies → opencode.ai → Value of auth + How to get these two? + Open login page in browser + With an OpenCode Zen API Key, the details page can send one request to activate usage stats. Get it at opencode.ai/settings → API Keys. + CommandCode Go credentials + Needs an API Key (for balance). Session Cookie (optional) for usage records, copied from browser DevTools. Balance works without it. + Generate at Settings → API Keys + Session Cookie (optional) + F12 → Cookies → commandcode.ai → select all & paste + How to get API Key and Cookie? + Enter API Key + Credentials are encrypted with Android Keystore (AES-256 GCM) and stored in local DataStore. + Update + Codex / ChatGPT Plus credentials + OAuth login fetches credentials automatically. You can also paste auth.json manually. + 🔐 OAuth login (recommended) + ── or import manually ── + Full auth.json content + Paste the full {"tokens": {...}} JSON + Import & save + How to get auth.json? + Paste session + Google OAuth blocks the in-app WebView, so log in with a browser and paste the cookie here. + Cookie string + Save cookie + + + Empty response body + Response root is not a JSON object + No usage window data found + Model list not found + Model list is empty + Response format is abnormal + Weekly + Model call count + Cached data · %1$s + Showing the last successfully fetched balance + %1$s%2$s spent this month + Balance breakdown + Granted balance + Top-ups + Loading model list... + Select model + Refresh model list + Activating... + Retry usage activation + ⚡ One-tap usage activation + Refresh balance + Log in again + Configure credential + Current balance + 5h live usage + Monthly balance + Session usage + Please select a model + Error details: + API response: + Copy + *** (length %1$d) *** + %1$s***%2$s (length %3$d) + Bad cookie format, expected name1=value1; name2=value2 + %1$d cookies saved + Please paste the auth.json content + auth.json is missing access_token or refresh_token + Codex credential saved; token auto-refreshes when expiring + Parse failed: %1$s + Connected! Balance: $%1$s + API Key saved + Connected! Session: %1$s%% · %2$s + Credential deleted + Clipboard is empty + Recognized workspaceId and auth cookie + Recognized workspaceId, please paste the auth cookie too + Recognized auth cookie, please paste the workspaceId too + No valid credential recognized + + + Codex OAuth Login + Exchanging token... + Retry + Headless login + 1. Tap "Open in browser" or copy the link below to a browser\n2. Complete login & authorization on the OpenAI page\n3. The browser redirects to a localhost address (page not loading is normal)\n4. Copy the full URL from the address bar and paste it below\n5. Tap "Submit callback URL" + Authorization link + Open in browser + Copy link + ── paste the callback URL after login ── + Callback URL + http://localhost:1455/auth/callback?code=... + Submit callback URL + Regenerate auth link + Preparing authorization... + diff --git a/app/src/test/java/com/rainy/token/data/repository/CodexRepositoryTest.kt b/app/src/test/java/com/rainy/token/data/repository/CodexRepositoryTest.kt index aea46e1..628b263 100644 --- a/app/src/test/java/com/rainy/token/data/repository/CodexRepositoryTest.kt +++ b/app/src/test/java/com/rainy/token/data/repository/CodexRepositoryTest.kt @@ -4,6 +4,8 @@ import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonNull import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -236,11 +238,12 @@ class CodexRepositoryTest { """.trimIndent() val result = parseSseResponse(sse, "gpt-5.6") - assertTrue(result.contains("Hello, world!")) - assertTrue(result.contains("input=100")) - assertTrue(result.contains("output=50")) - assertTrue(result.contains("resp_123")) - assertTrue(result.contains("gpt-5.6")) + assertEquals("Hello, world!", result.reply) + assertEquals("100", result.inputTokens) + assertEquals("50", result.outputTokens) + assertEquals("resp_123", result.responseId) + assertEquals("gpt-5.6", result.model) + assertFalse(result.parseFailed) } @Test @@ -251,9 +254,10 @@ class CodexRepositoryTest { """.trimIndent() val result = parseSseResponse(sse, "o4-mini") - assertTrue(result.contains("(空)")) - assertTrue(result.contains("input=10")) - assertTrue(result.contains("output=5")) + assertEquals("", result.reply) + assertEquals("10", result.inputTokens) + assertEquals("5", result.outputTokens) + assertNull(result.totalTokens) } @Test @@ -265,16 +269,18 @@ class CodexRepositoryTest { """.trimIndent() val result = parseSseResponse(sse, "gpt-5.6") - assertTrue(result.contains("Hi there")) - // Should not contain usage line when no usage data - assertTrue(!result.contains("用量:")) + assertEquals("Hi there", result.reply) + // Should be null when no usage data present + assertNull(result.inputTokens) + assertNull(result.outputTokens) } @Test fun `parseSseResponse handles empty input`() { val result = parseSseResponse("", "gpt-5.6") - assertTrue(result.contains("(空)")) - assertTrue(result.contains("gpt-5.6")) + assertEquals("", result.reply) + assertEquals("gpt-5.6", result.model) + assertNull(result.responseId) } @Test @@ -288,9 +294,9 @@ class CodexRepositoryTest { """.trimIndent() val result = parseSseResponse(sse, "o4-mini") - assertTrue(result.contains("Test reply")) - assertTrue(result.contains("input=200")) - assertTrue(result.contains("output=100")) + assertEquals("Test reply", result.reply) + assertEquals("200", result.inputTokens) + assertEquals("100", result.outputTokens) } @Test @@ -304,18 +310,22 @@ class CodexRepositoryTest { val result = parseSseResponse(sse, "gpt-5.6") // Malformed line should be skipped, valid ones parsed - assertTrue(result.contains("Good")) - assertTrue(result.contains("input=1")) + assertEquals("Good", result.reply) + assertEquals("1", result.inputTokens) } @Test - fun `parseSseResponse contains activation success header`() { + fun `parseSseResponse returns structured metadata`() { val sse = """ data: {"type":"response.completed","response":{"id":"r1","usage":{"input_tokens":"0","output_tokens":"0"}}} data: [DONE] """.trimIndent() val result = parseSseResponse(sse, "gpt-5.6") - assertTrue(result.contains("✓ 激活成功")) + assertEquals("r1", result.responseId) + assertEquals("0", result.inputTokens) + assertEquals("0", result.outputTokens) + assertEquals("", result.reply) + assertFalse(result.parseFailed) } } \ No newline at end of file diff --git a/app/src/test/java/com/rainy/token/ui/components/FormatUtilsTest.kt b/app/src/test/java/com/rainy/token/ui/components/FormatUtilsTest.kt index 79df352..7f13167 100644 --- a/app/src/test/java/com/rainy/token/ui/components/FormatUtilsTest.kt +++ b/app/src/test/java/com/rainy/token/ui/components/FormatUtilsTest.kt @@ -112,12 +112,19 @@ class FormatUtilsTest { assertEquals("每周", normalizeWindowLabel("weekly")) assertEquals("每周", normalizeWindowLabel("Weekly")) assertEquals("每周", normalizeWindowLabel("WEEKLY")) + assertEquals("每周", normalizeWindowLabel("每周")) } @Test - fun `normalizeWindowLabel non-weekly passes through`() { + fun `normalizeWindowLabel monthly maps to monthly label`() { + assertEquals("每月", normalizeWindowLabel("monthly")) + assertEquals("Monthly", normalizeWindowLabel("MONTHLY", monthlyLabel = "Monthly")) + assertEquals("每月", normalizeWindowLabel("每月")) + } + + @Test + fun `normalizeWindowLabel unknown passes through`() { assertEquals("5h", normalizeWindowLabel("5h")) - assertEquals("monthly", normalizeWindowLabel("monthly")) assertEquals("30d", normalizeWindowLabel("30d")) }