Merge pull request #4 from CATMIAOZHI/i18n-localization

i18n: 多语言支持(全量资源化 + 英文翻译)
This commit is contained in:
WaterRain 2026-08-02 11:32:12 +08:00 committed by GitHub
commit 6498e4cf71
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
52 changed files with 3255 additions and 791 deletions

1
.gitignore vendored
View File

@ -20,3 +20,4 @@ release.jks
.kotlin/
*.jks
tmp_cc/
goal.md

View File

@ -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 的余额/配额。

224
README.en.md Normal file
View File

@ -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
<p align="center">
<img src="docs/screenshots/dashboard-light.jpg" width="200" alt="Dashboard (light)" />
<img src="docs/screenshots/dashboard-dark.jpg" width="200" alt="Dashboard (dark)" />
<img src="docs/screenshots/detail.jpg" width="200" alt="Usage charts" />
<img src="docs/screenshots/ollama-detail.jpg" width="200" alt="Ollama Pro model call counts" />
</p>
<p align="center">
<em>Dashboard (light) · Dashboard (dark) · Usage charts · Ollama Pro model call counts</em>
</p>
<p align="center">
<img src="docs/screenshots/widget.jpg" width="300" alt="Home screen widget" />
<img src="docs/screenshots/ollama-card.jpg" width="200" alt="Ollama Pro card" />
</p>
<p align="center">
<em>Home screen widget · Ollama Pro home card</em>
</p>
---
## ✨ 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
```
<details>
<summary>🔧 ARM64 environment notes (optional)</summary>
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=<path>` (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.
</details>
---
## 📦 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
---
<p align="center">RainyToken · the Rainy Family tools</p>

View File

@ -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
---
<p align="center">💖 Made with love by 雨晴喵</p>
<p align="center">RainyToken · the Rainy Family tools</p>

View File

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

View File

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

View File

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

View File

@ -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)
}
/** 聚合工具 */

View File

@ -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<String> = withContext(Dispatchers.IO) {
suspend fun triggerUsage(model: String): Result<TriggerSummary> = 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<String>(
return@use Result.failure<TriggerSummary>(
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<String>(
TriggerError("HTTP ${resp.code}", bodyStr.ifBlank { "无响应体" })
return@use Result.failure<TriggerSummary>(
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
)
}

View File

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

View File

@ -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<String> = withContext(Dispatchers.IO) {
suspend fun triggerUsage(model: String): Result<TriggerSummary> = 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<String>(
TriggerError("HTTP ${resp.code}", bodyStr.ifBlank { "无响应体" })
return@withContext Result.failure<TriggerSummary>(
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)
}
}

View File

@ -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<String> = withContext(Dispatchers.IO) {
suspend fun triggerUsage(model: String): Result<TriggerSummary> = 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<String>(
TriggerError("HTTP ${resp.code}", bodyStr.ifBlank { "无响应体" })
return@withContext Result.failure<TriggerSummary>(
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)
}
}

View File

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

View File

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

View File

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

View File

@ -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<List<String>> =
codexRepositoryProvider.get().fetchModels()
suspend fun triggerCodexUsage(model: String): Result<String> =
suspend fun triggerCodexUsage(model: String): Result<TriggerSummary> =
withCredentialSession(ServiceType.CODEX) {
codexRepositoryProvider.get().triggerUsage(model)
}
@ -63,7 +64,7 @@ class RefreshBalanceUseCase @Inject constructor(
suspend fun fetchOpenCodeGoModels(): Result<List<String>> =
openCodeGoRepositoryProvider.get().fetchModels()
suspend fun triggerOpenCodeGoUsage(model: String): Result<String> =
suspend fun triggerOpenCodeGoUsage(model: String): Result<TriggerSummary> =
withCredentialSession(ServiceType.OPENCODE_GO) {
openCodeGoRepositoryProvider.get().triggerUsage(model)
}
@ -71,7 +72,7 @@ class RefreshBalanceUseCase @Inject constructor(
suspend fun fetchOllamaModels(): Result<List<String>> =
ollamaRepositoryProvider.get().fetchModels()
suspend fun triggerOllamaUsage(model: String): Result<String> =
suspend fun triggerOllamaUsage(model: String): Result<TriggerSummary> =
withCredentialSession(ServiceType.OLLAMA) {
ollamaRepositoryProvider.get().triggerUsage(model)
}

View File

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

View File

@ -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<Tip> = 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<String> = tips.map { it.hint }
/** 首页轮换用的一句话文案(资源 ID 形式)。 */
val shortHintRes: List<Int> = 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
)

View File

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

View File

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

View File

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

View File

@ -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<Any> = 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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<DailyStats> = 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 = 全部模型
@ -254,3 +259,34 @@ private data class LoadResult(
val dailyStats: List<DailyStats>,
val totalCount: Int
)
/** 把同步错误映射为本地化文案: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)
}

View File

@ -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 = "请求结果",
title = stringResource(R.string.trigger_result_title),
responseBody = (triggerState as TriggerState.Error).let {
"错误: ${it.message}" + (it.responseBody?.let { body -> "\n\n响应内容:\n$body" } ?: "")
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<String, String>): List<CodexWindow>
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<Pair<String, Int>> {
private fun ModelRequestList(models: List<Pair<String, Int>>) {
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))
}
}
}

View File

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

View File

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

View File

@ -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<Credential?, suspend () -> Result<com.rainy.token.domain.model.ServiceBalance>>,
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
)

View File

@ -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<Pair<String?, Int>> = 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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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" />
</LinearLayout>
@ -109,7 +111,8 @@
android:gravity="center_vertical"
android:layout_marginBottom="3dp">
<TextView android:id="@+id/row1_label"
android:layout_width="36dp" android:layout_height="wrap_content"
android:layout_width="44dp" android:layout_height="wrap_content"
android:maxLines="1" android:ellipsize="end"
android:text="5h" android:textColor="#C4A0AB" android:textSize="11sp" />
<TextView android:id="@+id/row1_pct"
android:layout_width="38dp" android:layout_height="wrap_content"
@ -134,8 +137,9 @@
android:gravity="center_vertical"
android:layout_marginBottom="3dp">
<TextView android:id="@+id/row2_label"
android:layout_width="36dp" android:layout_height="wrap_content"
android:text="本周" 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_weekly" android:textColor="#C4A0AB" android:textSize="11sp" />
<TextView android:id="@+id/row2_pct"
android:layout_width="38dp" android:layout_height="wrap_content"
android:text="--" android:textColor="#FFD1DC" android:textSize="12sp"
@ -159,8 +163,9 @@
android:gravity="center_vertical"
android:layout_marginBottom="3dp">
<TextView android:id="@+id/row3_label"
android:layout_width="36dp" android:layout_height="wrap_content"
android:text="本月" 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" />
<TextView android:id="@+id/row3_pct"
android:layout_width="38dp" android:layout_height="wrap_content"
android:text="--" android:textColor="#FFD1DC" android:textSize="12sp"
@ -190,9 +195,10 @@
android:adjustViewBounds="true"
android:layout_marginEnd="5dp" />
<TextView
android:id="@+id/widget_ds_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="DeepSeek 余额"
android:text="@string/widget_deepseek_balance"
android:textColor="#C4A0AB"
android:textSize="11sp" />
<TextView
@ -222,9 +228,10 @@
android:progressBackgroundTint="#00000000"
android:indeterminate="false" />
<TextView
android:id="@+id/widget_brand"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="雨晴喵"
android:text="@string/widget_brand"
android:textColor="#8A7A82"
android:textSize="9sp" />
</LinearLayout>

View File

@ -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" />
</LinearLayout>
@ -116,7 +118,8 @@
android:layout_marginBottom="3dp">
<TextView android:id="@+id/row1_label"
android:layout_width="36dp" android:layout_height="wrap_content"
android:layout_width="44dp" android:layout_height="wrap_content"
android:maxLines="1" android:ellipsize="end"
android:text="5h" android:textColor="#8A7A82" android:textSize="11sp" />
<TextView android:id="@+id/row1_pct"
@ -145,8 +148,9 @@
android:layout_marginBottom="3dp">
<TextView android:id="@+id/row2_label"
android:layout_width="36dp" android:layout_height="wrap_content"
android:text="本周" 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_weekly" android:textColor="#8A7A82" android:textSize="11sp" />
<TextView android:id="@+id/row2_pct"
android:layout_width="38dp" android:layout_height="wrap_content"
@ -174,8 +178,9 @@
android:layout_marginBottom="3dp">
<TextView android:id="@+id/row3_label"
android:layout_width="36dp" android:layout_height="wrap_content"
android:text="本月" 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" />
<TextView android:id="@+id/row3_pct"
android:layout_width="38dp" android:layout_height="wrap_content"
@ -211,9 +216,10 @@
android:layout_marginEnd="5dp" />
<TextView
android:id="@+id/widget_ds_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="DeepSeek 余额"
android:text="@string/widget_deepseek_balance"
android:textColor="#8A7A82"
android:textSize="11sp" />
@ -247,9 +253,10 @@
android:indeterminate="false" />
<TextView
android:id="@+id/widget_brand"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="雨晴喵"
android:text="@string/widget_brand"
android:textColor="#D4C4CC"
android:textSize="9sp" />
</LinearLayout>

View File

@ -41,7 +41,7 @@
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="自动刷新"
android:text="@string/widget_auto_refresh"
android:textColor="#C4A0AB"
android:textSize="10sp" />
</LinearLayout>
@ -63,7 +63,7 @@
<TextView
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" />
</LinearLayout>
@ -77,7 +77,8 @@
android:layout_marginBottom="6dp">
<TextView
android:layout_width="36dp" android:layout_height="wrap_content"
android:layout_width="44dp" android:layout_height="wrap_content"
android:maxLines="1" android:ellipsize="end"
android:text="5h" android:textColor="#8A7A82" android:textSize="11sp" />
<TextView
@ -102,8 +103,9 @@
android:layout_marginBottom="6dp">
<TextView
android:layout_width="36dp" android:layout_height="wrap_content"
android:text="本周" 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_weekly" android:textColor="#8A7A82" android:textSize="11sp" />
<TextView
android:layout_width="38dp" android:layout_height="wrap_content"
@ -127,8 +129,9 @@
android:layout_marginBottom="8dp">
<TextView
android:layout_width="36dp" android:layout_height="wrap_content"
android:text="本月" 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" />
<TextView
android:layout_width="38dp" android:layout_height="wrap_content"
@ -161,7 +164,7 @@
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="DeepSeek 余额"
android:text="@string/widget_deepseek_balance"
android:textColor="#8A7A82"
android:textSize="11sp" />

View File

@ -0,0 +1,5 @@
# 默认(无 qualifier)资源对应的语言:英文(国际回退语言)
# 非中文/英文系统语言会回退到默认英文资源;中文系统走 values-b+zh+Hans / values-b+zh+Hant
# AGP 据此 + 中文资源目录生成 locale config,Android 13+ 系统设置中
# 会显示 RainyToken 的独立语言选项(English / 简体中文 / 繁體中文)
unqualifiedResLocale=en

View File

@ -0,0 +1,495 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="widget_name">AI用量面板</string>
<string name="widget_description">OpenCode Go 用量 + DeepSeek 余额,自动刷新</string>
<string name="widget_brand">雨晴喵</string>
<string name="app_name">雨晴Token</string>
<!-- ══════════ 通用动作 ══════════ -->
<string name="action_back">返回</string>
<string name="action_cancel">取消</string>
<string name="action_confirm_add">确认添加</string>
<string name="action_got_it">我知道了</string>
<string name="action_refresh">刷新</string>
<string name="action_settings">设置</string>
<string name="action_delete">删除</string>
<string name="action_clear">清除</string>
<string name="action_save">保存</string>
<string name="action_jump">跳转</string>
<string name="action_prev_page">◀ 上一页</string>
<string name="action_next_page">下一页 ▶</string>
<string name="action_add_widget">添加小组件到桌面</string>
<string name="title_language">语言</string>
<string name="dialog_language_title">选择语言</string>
<string name="app_locale_follow_system">跟随系统</string>
<string name="app_locale_zh">简体中文</string>
<string name="app_locale_zh_hant">繁體中文</string>
<string name="app_locale_en">English</string>
<string name="action_view_detail">查看详情</string>
<string name="action_view_usage_detail">查看用量详情</string>
<string name="action_view_raw_data">详细数据</string>
<string name="action_sync_usage">同步用量数据</string>
<string name="action_login_save">我已登录,保存</string>
<string name="action_keep_waiting">继续等待</string>
<!-- ══════════ 通用文案 ══════════ -->
<string name="common_loading">加载中…</string>
<string name="common_unknown">未知错误</string>
<string name="common_all_models">全部模型</string>
<!-- ══════════ 页面标题 ══════════ -->
<string name="nav_empty_title">👈 选择服务查看详情</string>
<string name="nav_empty_subtitle">点击左侧卡片即可</string>
<string name="title_settings">设置</string>
<string name="title_credentials">凭据管理</string>
<string name="title_about">关于 RainyToken</string>
<string name="title_tips">使用小技巧</string>
<string name="title_debug_log">调试日志</string>
<string name="title_usage_detail">用量详情</string>
<string name="title_overview">总统计</string>
<string name="title_raw_data">详细数据</string>
<string name="title_chart">统计图表</string>
<!-- ══════════ 状态徽章 ══════════ -->
<string name="status_not_configured">未配置</string>
<string name="status_configured">已配置</string>
<string name="status_expired">已过期</string>
<string name="status_relogin">需重登</string>
<string name="status_refresh_failed">刷新失败</string>
<string name="status_not_fetched">未获取</string>
<string name="status_waiting">待获取</string>
<string name="status_normal">正常</string>
<string name="status_not_configured_add">未配置 · 点击添加</string>
<string name="status_ok_valid">已配置 · 凭据有效</string>
<string name="status_expired_reconfigure">已过期 · 点击重新配置</string>
<string name="status_need_reverify">需要重新验证</string>
<string name="status_manual_mode">手动输入模式</string>
<string name="status_credential_configured">凭据已配置</string>
<string name="status_not_configured_cred">未配置凭据</string>
<string name="status_loading">加载中</string>
<string name="status_fresh">最新</string>
<string name="status_stale">缓存</string>
<string name="status_failed">失败</string>
<string name="status_pending_input">待输入</string>
<!-- ══════════ 仪表盘 ══════════ -->
<string name="dashboard_subtitle">AI 余额一览</string>
<string name="footer_never_fetched">从未获取</string>
<string name="footer_just_updated">刚刚更新</string>
<string name="footer_minutes_ago">%1$d 分钟前更新</string>
<string name="footer_hours_ago">%1$d 小时前更新</string>
<string name="footer_updated_at">%1$s 更新</string>
<string name="dashboard_drag_hint">💡 长按卡片可拖拽排序</string>
<string name="dashboard_error_prefix">⚠ %1$s</string>
<string name="dashboard_footer_privacy">本地加密存储,不上传任何数据</string>
<string name="dialog_add_widget_title">添加桌面小组件?</string>
<string name="dialog_add_widget_body">确认后会请求桌面启动器添加雨晴Token小组件。部分系统仍会弹出系统确认窗口。</string>
<string name="toast_shortcut_permission_denied">桌面快捷方式权限已被拒绝,请在系统设置中允许后重试</string>
<string name="toast_add_rejected">桌面未接受添加请求,请检查启动器权限</string>
<string name="toast_picker_unsupported">当前桌面不支持应用内添加,请在桌面长按 → 小组件 → 查找雨晴Token</string>
<!-- ══════════ 服务卡片 ══════════ -->
<string name="service_click_to_configure">点击配置凭据</string>
<string name="service_pull_to_refresh">下拉刷新</string>
<string name="service_unavailable">服务当前不可用</string>
<string name="service_monthly_used">本月已用 %1$s%2$s</string>
<string name="service_remaining">剩余</string>
<string name="service_used_total"> · 已用 %1$s / 共 %2$s</string>
<string name="service_used_label">%1$s 已用</string>
<string name="service_5h_used">5h 已用</string>
<string name="service_plan_suffix">· %1$s</string>
<string name="service_reset_in">%1$s 后重置</string>
<string name="service_desc_deepseek">REST API · ¥</string>
<string name="service_desc_opencode_go">WebView 抓取 · 5h 配额</string>
<string name="service_desc_commandcode_go">JSON API · $</string>
<string name="service_desc_codex">ChatGPT Plus · Codex 额度</string>
<string name="service_desc_ollama">Cookie 抓取 · Cloud 配额</string>
<!-- ══════════ 时长标签(DurationText 注入用) ══════════ -->
<string name="format_day">天</string>
<string name="format_hour">小时</string>
<string name="format_minute">分</string>
<!-- ══════════ 用量窗口 ══════════ -->
<string name="window_5h">5 小时</string>
<string name="window_5h_short">5h</string>
<string name="window_5h_usage">5h 用量</string>
<string name="window_5h_rolling">5 小时滚动</string>
<string name="window_weekly">本周</string>
<string name="window_monthly">本月</string>
<string name="window_every_month">每月</string>
<string name="window_usage">用量</string>
<string name="usage_window_title">用量窗口%1$s</string>
<string name="usage_window_title_plain">用量窗口</string>
<!-- ══════════ 用量统计 ══════════ -->
<string name="usage_title_opencode">OpenCode 用量</string>
<string name="usage_title_commandcode">CommandCode 用量</string>
<string name="usage_record_count">%1$d 条记录</string>
<string name="usage_click_to_sync">点击同步</string>
<string name="usage_input_tokens">输入 Token</string>
<string name="usage_output_tokens">输出 Token</string>
<string name="usage_reasoning_tokens">推理 Token</string>
<string name="usage_total_cost">总花费</string>
<string name="usage_cache_read">缓存读取 %1$s</string>
<string name="usage_cache_write">缓存写入 %1$s</string>
<string name="usage_no_data_sync">暂无数据,点击 🔄 同步</string>
<string name="usage_new_records">✓ 新增 %1$d 条</string>
<string name="sync_partial">部分同步完成:插入 %1$d条,%2$d页失败</string>
<string name="usage_click_to_switch"> 点击切换</string>
<string name="usage_utc0_note">所有时间按 UTC+0 计算</string>
<string name="msg_utc8_on">已切换到 UTC+8,图表按北京时间显示</string>
<string name="msg_utc0_on">已切换到 UTC+0,图表按世界协调时显示</string>
<string name="usage_spent_total">已用 $%1$s / 共 $%2$s</string>
<string name="usage_remaining">剩余 $%1$s</string>
<string name="usage_extra_credits">额外充值</string>
<string name="section_by_model">按模型统计</string>
<string name="section_by_day">按天统计(UTC+0)</string>
<string name="model_count">%1$d 个模型</string>
<string name="model_count_short">%1$d 个</string>
<string name="date_start">开始时间</string>
<string name="date_end">结束时间</string>
<!-- ══════════ 图表 ══════════ -->
<string name="chart_cost">消耗金额 (USD)</string>
<string name="chart_requests">API 请求次数</string>
<string name="chart_tokens">Token 消耗</string>
<string name="chart_stack_output">输出</string>
<string name="chart_stack_input">输入(未命中)</string>
<string name="chart_stack_cache">命中缓存</string>
<!-- ══════════ 原始数据 ══════════ -->
<string name="header_time">时间</string>
<string name="header_model">模型</string>
<string name="header_input_cache">输入(缓存)</string>
<string name="header_output">输出</string>
<string name="header_cost">成本</string>
<!-- ══════════ 时间筛选 ══════════ -->
<string name="time_all">全部</string>
<string name="time_last_5h">最近5小时</string>
<string name="time_last_12h">最近12小时</string>
<string name="time_last_24h">最近24小时</string>
<string name="time_today">今天</string>
<string name="time_yesterday">昨天</string>
<string name="time_last_7d">最近7天</string>
<string name="time_last_30d">最近30天</string>
<string name="time_this_month">当月</string>
<string name="time_custom">自定义</string>
<string name="time_custom_day">自定义天</string>
<string name="time_custom_month">自定义月</string>
<string name="time_custom_range">自定义时间</string>
<!-- ══════════ 设置页 ══════════ -->
<string name="about_description">统一查询 AI 余额的小工具。\n本地加密存储凭据,不上传任何数据。</string>
<string name="tips_view_all">查看全部 %1$d 条隐藏操作</string>
<string name="debug_log_desc">查看 token 刷新、网络请求等调试记录</string>
<!-- ══════════ 凭据编辑 ══════════ -->
<string name="credential_configured_cookies">✓ 已配置登录态(%1$d 个 Cookie)</string>
<string name="help_cookie_title">如何获取 Cookie?</string>
<string name="help_cookie_1">1. 在桌面 Chrome 打开下方登录入口</string>
<string name="help_cookie_2">2. 用 GitHub / Google 登录(不会被拦截)</string>
<string name="help_cookie_3">3. 登录后按 F12 → Application → Cookies</string>
<string name="help_cookie_4">4. 复制该站点下的所有 Cookie 粘贴到上面输入框</string>
<string name="help_cookie_5">格式:name1=value1; name2=value2</string>
<string name="help_ocgo_title">如何获取 OpenCode Go 凭据?</string>
<string name="help_ocgo_1">1. 桌面 Chrome 打开 opencode.ai/auth</string>
<string name="help_ocgo_2">2. 用 GitHub / Google 登录(Google 不会拦截 Chrome)</string>
<string name="help_ocgo_3">3. 登录后跳转到 dashboard,URL 形如:</string>
<string name="help_ocgo_4"> https://opencode.ai/workspace/{id}/go</string>
<string name="help_ocgo_5">4. 复制 URL 中的 {id} 段 → workspaceId</string>
<string name="help_ocgo_6">5. F12 → Application → Cookies</string>
<string name="help_ocgo_7">6. 找到域名 opencode.ai 下名为 auth 的 Cookie</string>
<string name="help_ocgo_8">7. 复制它的 Value 字段 → auth cookie</string>
<string name="help_ocgo_note">注意:只填 auth 一个 Cookie 即可,不要整个 Cookie 字符串</string>
<string name="help_ccgo_title">如何获取 CommandCode Go 凭据?</string>
<string name="help_ccgo_1">1. 打开 https://commandcode.ai 并登录 GitHub</string>
<string name="help_ccgo_2">▸ 获取 API Key(必填):</string>
<string name="help_ccgo_3"> 右上角头像 → Settings → API Keys → 创建一个 Key</string>
<string name="help_ccgo_4">▸ 获取全部 Cookie(用于用量记录):</string>
<string name="help_ccgo_5"> F12 → Application → Cookies → commandcode.ai</string>
<string name="help_ccgo_6"> 选中所有 Cookie,复制整个 Cookie 字符串</string>
<string name="help_ccgo_7"> 粘贴到下方输入框即可,无需逐个挑选</string>
<string name="help_ccgo_note">提示:不填 Cookie 也能正常查看余额,只是用量记录为空。</string>
<string name="help_codex_title">如何获取 Codex auth.json?</string>
<string name="help_codex_1">1. 在终端运行 codex-oauth-proxy 或</string>
<string name="help_codex_2"> codex-proxy,完成 ChatGPT 登录</string>
<string name="help_codex_3">2. 凭证文件保存在:</string>
<!-- ══════════ 提示/错误消息 ══════════ -->
<string name="msg_saved">已保存</string>
<string name="msg_credentials_saved">已保存凭据</string>
<string name="msg_connect_success_balance">连接成功!余额: %1$s %2$s</string>
<string name="msg_connect_success_saved">连接成功,凭据已保存</string>
<string name="error_api_key_empty">API Key 不能为空</string>
<string name="error_json_format">JSON 格式错误</string>
<string name="error_credential_rejected">服务拒绝该凭据 (401/403)</string>
<string name="error_credential_changed_test">测试期间凭据已变更</string>
<string name="error_rate_limited">请求过于频繁 (429)</string>
<string name="error_server">服务端错误 (%1$d)</string>
<string name="error_network">网络错误:%1$s</string>
<string name="error_rollback_done">,已恢复原凭据</string>
<string name="error_rollback_skipped">,检测到凭据已变化,未执行回滚</string>
<string name="error_test_failed">测试失败。%1$s%2$s</string>
<string name="error_test_not_supported">暂不支持测试此服务</string>
<string name="error_auth_cookie_workspace">auth cookie 和 workspaceId 都需要填写</string>
<string name="error_cookie_empty">Cookie 不能为空</string>
<string name="error_credential_not_configured">凭据未配置</string>
<string name="error_credential_not_configured_expired">凭据未配置或已过期</string>
<string name="error_credential_invalid_reconfigure">凭据无效,请在设置中重新配置</string>
<string name="error_credential_invalid_relogin">凭据无效,请重新登录</string>
<string name="error_credential_changed_reload">凭据已变更,正在重新加载</string>
<string name="error_credential_changed_retry">凭据已变更,请重新操作</string>
<string name="error_network_check">网络异常,请检查网络</string>
<string name="error_network_generic">网络异常</string>
<string name="error_server_http">服务端异常 (HTTP %1$d)</string>
<string name="error_rate_limited_retry">请求过于频繁%1$s</string>
<string name="error_rate_limited_retry_suffix">,请 %1$d 秒后重试</string>
<string name="error_trigger_not_supported">不支持激活用量</string>
<string name="error_select_model_first">请先选择模型</string>
<string name="error_cookie_not_found">未抓到 Cookie,请确认已登录</string>
<string name="error_login_url_missing">未配置登录 URL</string>
<string name="error_oauth_url_prefix">URL 应以 %1$s 开头</string>
<string name="error_oauth_auth_failed">授权失败: %1$s</string>
<string name="error_oauth_state_mismatch">State 不匹配,请重试</string>
<string name="error_oauth_missing_code">回调缺少授权码</string>
<string name="error_oauth_token_exchange">Token 交换失败,请查看调试日志</string>
<!-- ══════════ 触发用量 ══════════ -->
<string name="trigger_success_title">✓ 用量已激活</string>
<string name="trigger_success_header">✓ 激活成功</string>
<string name="trigger_result_title">请求结果</string>
<string name="trigger_error_prefix">错误: %1$s</string>
<string name="trigger_response_body">响应内容:\n%1$s</string>
<string name="trigger_success_model">模型: %1$s</string>
<string name="trigger_success_response_id">响应 ID: %1$s</string>
<string name="trigger_reply_header">回复:</string>
<string name="trigger_reply_empty">(空)</string>
<string name="trigger_reply_missing">(无回复内容)</string>
<string name="trigger_parse_failed">(响应解析失败,但请求已发出)</string>
<string name="trigger_usage_prompt_completion_total">用量: prompt=%1$s completion=%2$s total=%3$s tokens</string>
<string name="trigger_usage_input_output">用量: input=%1$s output=%2$s tokens</string>
<string name="error_token_refresh_generic">token 刷新失败,请重新登录或重新导入 auth.json</string>
<!-- ══════════ WebView 登录 ══════════ -->
<string name="title_webview_login">登录 %1$s</string>
<string name="dialog_login_done_title">完成登录了吗?</string>
<string name="dialog_login_done_body">如果你已经在页面上完成登录但页面没有跳转,可点击下方按钮让我抓取 Cookie 并保存登录态。</string>
<!-- ══════════ 调试日志 ══════════ -->
<string name="debug_log_empty">暂无日志记录\n刷新 Codex 用量后这里会出现调试信息</string>
<!-- ══════════ 小组件 ══════════ -->
<string name="widget_title_opencode">OpenCode Go · 用量</string>
<string name="widget_deepseek_balance">DeepSeek 余额</string>
<string name="widget_auto_refresh">自动刷新</string>
<string name="widget_service_quota">%1$s · 额度</string>
<string name="widget_quota_with_plan">%1$s · %2$s</string>
<string name="widget_updated_at">更新 %1$s</string>
<string name="widget_no_data">暂无数据</string>
<string name="widget_refreshing">刷新中…</string>
<!-- ══════════ 小技巧(AppTips) ══════════ -->
<string name="tip_1_title">长按卡片拖拽排序</string>
<string name="tip_1_hint">长按首页卡片可以拖拽排序</string>
<string name="tip_1_detail">在首页长按任意服务卡片即可拖拽到想要的位置,排序会自动保存。</string>
<string name="tip_2_title">小组件点左上角进 APP</string>
<string name="tip_2_hint">小组件左上角标识可打开 APP</string>
<string name="tip_2_detail">桌面小组件左上角 RainyToken › 标识可以点击打开应用,其它区域点击切换服务。</string>
<string name="tip_3_title">图表点击查看详情</string>
<string name="tip_3_hint">点击图表柱子可查看时段详情</string>
<string name="tip_3_detail">在用量图表上点击或滑动手指,可查看对应时段的详细数值。</string>
<string name="tip_4_title">图表标题可点击</string>
<string name="tip_4_hint">点击图表标题弹出按模型明细</string>
<string name="tip_4_detail">图表卡片标题(如「消耗金额」)点击后会弹出按模型拆分的明细。</string>
<string name="tip_5_title">切换图表时区</string>
<string name="tip_5_hint">用量详情页可切换 UTC+0 / UTC+8</string>
<string name="tip_5_detail">用量详情页顶部可切换 UTC+0 / UTC+8,图表标签会跟随切换。</string>
<string name="tip_6_title">CCGO 清除并重新同步</string>
<string name="tip_6_hint">CCGO 详情页可清除缓存重新同步</string>
<string name="tip_6_detail">CCGO 用量详情页右上角「清除」可删除本地缓存并重新拉取全部数据。</string>
<string name="tip_7_title">查看用量详情</string>
<string name="tip_7_hint">服务卡片底部可进入用量图表</string>
<string name="tip_7_detail">有凭据的服务卡片底部会出现「查看用量详情」入口,点击进入图表统计。</string>
<string name="tip_8_title">原始数据浏览</string>
<string name="tip_8_hint">用量详情页右上角可查看原始记录</string>
<string name="tip_8_detail">用量详情页右上角「详细数据」可查看每条原始调用记录,支持分页跳转。</string>
<string name="tip_9_title">下拉刷新联动用量</string>
<string name="tip_9_hint">下拉刷新会同步余额和用量</string>
<string name="tip_9_detail">首页下拉刷新不仅刷新余额,还会自动同步 OCGO / CCGO 用量记录。</string>
<string name="tip_10_title">凭据获取教程</string>
<string name="tip_10_hint">凭据页底部有获取教程</string>
<string name="tip_10_detail">凭据编辑页底部的「如何获取」按钮有针对每个服务的详细操作步骤。</string>
<string name="tip_11_title">剪贴板自动导入</string>
<string name="tip_11_hint">Codex 支持从剪贴板一键导入</string>
<string name="tip_11_detail">Codex 凭据页支持「从剪贴板自动导入」,粘贴 auth.json 后一键填入。</string>
<string name="tip_12_title">详细数据页码跳转</string>
<string name="tip_12_hint">原始数据页可输入页码直跳</string>
<string name="tip_12_detail">原始数据页底部可输入页码直接跳转,数据量大时不必逐页翻。</string>
<string name="tip_13_title">小组件自动刷新</string>
<string name="tip_13_hint">划到小组件会自动刷新余额</string>
<string name="tip_13_detail">桌面小组件划到负一屏时会自动刷新余额,也可手动点 ↻ 按钮刷新。</string>
<!-- ══════════ 图表/明细(B 组迁移) ══════════ -->
<string name="count_times">%1$d次</string>
<string name="date_select_day">选择日期</string>
<string name="date_select_month">选择月份(任意一天)</string>
<string name="date_pick_start">开始日期</string>
<string name="date_pick_end">结束日期</string>
<string name="chart_detail_cost">消费明细</string>
<string name="chart_detail_requests">调用次数明细</string>
<string name="chart_detail_tokens">Token 明细</string>
<string name="dialog_clear_title">清除数据</string>
<string name="dialog_clear_body">此操作将清除所有 CommandCode 用量本地缓存并重新同步,需要几秒钟完成。</string>
<string name="dialog_clear_confirm_question">是否继续?</string>
<string name="action_confirm_countdown">确认(%1$ds)</string>
<string name="action_confirm_clear">确认清除</string>
<string name="action_close">关闭</string>
<string name="range_from">从 %1$s</string>
<string name="range_to">至 %1$s</string>
<string name="action_apply_custom_range">应用自定义范围</string>
<string name="action_confirm">确定</string>
<string name="date_end_before_start">结束日期不能早于开始日期 %1$s</string>
<string name="time_hm">时间 %1$s:%2$s</string>
<string name="common_no_data">暂无数据</string>
<string name="chart_total_label">合计: %1$s</string>
<string name="usage_total">总计</string>
<string name="usage_records">记录数</string>
<string name="stat_cache_read">缓存读取</string>
<string name="stat_cache_write">缓存写入</string>
<string name="usage_cache_breakdown">缓存 Token 细分</string>
<string name="usage_cache_read_note">※ 缓存读取已计入「输入 Token」</string>
<string name="usage_calls">%1$d 次调用</string>
<string name="date_format_md">MM月dd日 EEEE</string>
<string name="daily_count_cost">%1$d 次 · $%2$s</string>
<string name="raw_record_title">原始数据</string>
<string name="stat_cache_write_5m">缓存写入(5m)</string>
<string name="stat_cache_write_1h">缓存写入(1h)</string>
<string name="raw_cost_usd">成本(USD)</string>
<string name="raw_cost_raw">成本(Raw)</string>
<!-- ══════════ 凭据编辑(C 组迁移) ══════════ -->
<string name="help_codex_4"> ~/.config/codex-oauth-proxy/auth.json</string>
<string name="help_codex_5">3. 用文本编辑器打开该文件,</string>
<string name="help_codex_6"> 复制全部内容粘贴到上方输入框</string>
<string name="help_codex_7"> JSON 格式示例:</string>
<string name="help_codex_8"> {"tokens": {</string>
<string name="help_codex_9"> "access_token": "...",</string>
<string name="help_codex_10"> "refresh_token": "...",</string>
<string name="help_codex_11"> "expiresAt": 1783727108252</string>
<string name="help_codex_12"> }}</string>
<string name="help_codex_note">提示:导入后 APP 会自动刷新 token,</string>
<string name="help_codex_note2">不需要手动更新。</string>
<string name="credential_title_ollama">Ollama Pro 凭据</string>
<string name="credential_hint_ollama">需要从浏览器复制完整 Cookie。登录 ollama.com/settings 后,在 DevTools → Network → 请求头里找到 Cookie 行复制。</string>
<string name="credential_update">更新凭据</string>
<string name="credential_save">保存凭据</string>
<string name="action_test_and_save">测试并保存</string>
<string name="action_open_ollama_settings">在浏览器中打开 Ollama Settings</string>
<string name="action_copy_login_url">复制登录 URL 到剪贴板</string>
<string name="activate_api_key_optional">一键激活用量 API Key(可选)</string>
<string name="hint_ollama_trigger_key">填入 Ollama Cloud API Key 后,可在详情页一键发送请求激活用量统计。从 ollama.com/settings → API Keys 获取。</string>
<string name="placeholder_ollama_api_key">ollama-xxx 或 sk-xxx</string>
<string name="action_save_api_key">保存 API Key</string>
<string name="credential_title_opencode_go">OpenCode Go 凭据</string>
<string name="credential_hint_opencode_go">需要 auth cookie + workspaceId 两个值。APP 会用 OkHttp 抓 dashboard 并自动解析 5h/周/月 三档用量。</string>
<string name="action_import_from_clipboard">从剪贴板自动导入</string>
<string name="placeholder_workspace_id">dashboard URL 中的 {id} 段</string>
<string name="field_auth_cookie_value">auth Cookie 值</string>
<string name="placeholder_auth_cookie">F12 → Cookies → opencode.ai → auth 的 Value</string>
<string name="action_how_get_two_values">如何获取这两个值?</string>
<string name="action_open_login_entry">在浏览器中打开登录入口</string>
<string name="hint_opencode_trigger_key">填入 OpenCode Zen API Key 后,可在详情页一键发送请求激活用量统计。从 opencode.ai/settings → API Keys 获取。</string>
<string name="credential_title_ccgo">CommandCode Go 凭据</string>
<string name="credential_hint_ccgo">需要 API Key(拉余额)。Session Cookie(可选)在浏览器 DevTools 复制,用于拉用量记录。不填也可正常查看余额。</string>
<string name="placeholder_api_key_from_settings">从 Settings → API Keys 生成</string>
<string name="field_session_cookie_optional">Session Cookie(可选)</string>
<string name="placeholder_session_cookie">F12 → Cookies → commandcode.ai → 全选复制粘贴即可</string>
<string name="action_how_get_api_key_cookie">如何获取 API Key 和 Cookie?</string>
<string name="api_key_form_title">请输入 API Key</string>
<string name="api_key_form_hint">凭据会使用 Android Keystore (AES-256 GCM) 加密后存储到本机 DataStore。</string>
<string name="action_update">更新</string>
<string name="credential_title_codex">Codex / ChatGPT Plus 凭据</string>
<string name="credential_hint_codex">推荐使用 OAuth 登录自动获取凭据。也可手动粘贴 auth.json 内容。</string>
<string name="action_oauth_login">🔐 OAuth 登录(推荐)</string>
<string name="or_manual_import">── 或手动导入 ──</string>
<string name="field_auth_json">auth.json 完整内容</string>
<string name="placeholder_auth_json">粘贴 {"tokens": {...}} 完整 JSON</string>
<string name="credential_import_save">导入并保存</string>
<string name="action_how_get_auth_json">如何获取 auth.json?</string>
<string name="manual_cookie_title">粘贴登录态</string>
<string name="manual_cookie_hint">Google OAuth 会拒绝 APP 内置 WebView,所以请用浏览器登录后把 Cookie 粘到这里。</string>
<string name="field_cookie_string">Cookie 字符串</string>
<string name="action_save_cookie">保存 Cookie</string>
<!-- ══════════ 服务详情/凭据校验(D 组迁移) ══════════ -->
<string name="error_parse_empty_body">响应体为空</string>
<string name="error_parse_not_json">响应根节点不是 JSON 对象</string>
<string name="error_parse_no_windows">未找到用量窗口数据</string>
<string name="error_parse_no_models">未找到模型列表</string>
<string name="error_parse_models_empty">模型列表为空</string>
<string name="error_parse_malformed">响应格式异常</string>
<string name="window_every_week">每周</string>
<string name="msg_model_call_count">模型调用次数</string>
<string name="msg_stale_cache">缓存数据 · %1$s</string>
<string name="msg_showing_last_balance">显示的是上次成功获取的余额</string>
<string name="msg_monthly_spent">本月消费 %1$s%2$s</string>
<string name="msg_balance_breakdown">余额构成</string>
<string name="msg_granted_balance">赠送余额</string>
<string name="msg_topped_up_balance">自费充值</string>
<string name="msg_loading_models">加载模型列表...</string>
<string name="msg_select_model">选择模型</string>
<string name="action_refresh_models">刷新模型列表</string>
<string name="msg_triggering">激活中...</string>
<string name="action_retry_trigger">重试激活用量</string>
<string name="action_trigger_usage">⚡ 一键激活用量</string>
<string name="action_refresh_balance">刷新余额</string>
<string name="action_relogin">重新登录</string>
<string name="action_configure_credential">配置凭据</string>
<string name="main_card_current_balance">当前余额</string>
<string name="main_card_5h_usage">5h 实时用量</string>
<string name="main_card_monthly_balance">月度余额</string>
<string name="main_card_session_usage">Session 用量</string>
<string name="msg_please_select_model">请选择模型</string>
<string name="trigger_error_detail">错误详情:</string>
<string name="trigger_api_response">API 响应内容:</string>
<string name="action_copy">复制</string>
<string name="msg_key_length">*** (长度 %1$d) ***</string>
<string name="msg_key_masked">%1$s***%2$s (长度 %3$d)</string>
<string name="error_cookie_format">Cookie 格式错误,应为 name1=value1; name2=value2</string>
<string name="msg_cookies_saved">已保存 %1$d 个 Cookie</string>
<string name="error_auth_json_empty">请粘贴 auth.json 内容</string>
<string name="error_auth_json_missing_tokens">auth.json 缺少 access_token 或 refresh_token</string>
<string name="msg_codex_saved">已保存 Codex 凭据,token 到期后会自动刷新</string>
<string name="error_auth_json_parse">解析失败:%1$s</string>
<string name="msg_connect_success_dollar">连接成功!余额: $%1$s</string>
<string name="msg_api_key_saved">API Key 已保存</string>
<string name="msg_connect_success_session">连接成功!Session: %1$s%% · %2$s</string>
<string name="msg_credential_deleted">凭据已删除</string>
<string name="error_clipboard_empty">剪贴板为空</string>
<string name="msg_clipboard_ws_auth">已识别 workspaceId 和 auth cookie</string>
<string name="msg_clipboard_ws_only">已识别 workspaceId,请再粘贴 auth cookie</string>
<string name="msg_clipboard_auth_only">已识别 auth cookie,请再粘贴 workspaceId</string>
<string name="error_clipboard_none">未识别到有效凭据</string>
<!-- ══════════ Codex OAuth 登录页 ══════════ -->
<string name="oauth_screen_title">Codex OAuth 登录</string>
<string name="oauth_exchanging">正在交换 Token...</string>
<string name="action_retry">重试</string>
<string name="oauth_headless_title">无头模式登录</string>
<string name="oauth_steps">1. 点击「在浏览器中打开」或复制下方链接到浏览器\n2. 在 OpenAI 页面完成登录和授权\n3. 浏览器会跳转到一个 localhost 地址(页面打不开是正常的)\n4. 复制浏览器地址栏的完整 URL 粘贴到下方输入框\n5. 点击「提交回调 URL」</string>
<string name="oauth_auth_url_label">授权链接</string>
<string name="action_open_in_browser">在浏览器中打开</string>
<string name="action_copy_link">复制链接</string>
<string name="oauth_paste_callback_hint">── 登录后粘贴回调 URL ──</string>
<string name="oauth_callback_url_label">回调 URL</string>
<string name="oauth_callback_url_placeholder">http://localhost:1455/auth/callback?code=...</string>
<string name="action_submit_callback">提交回调 URL</string>
<string name="action_regenerate_link">重新生成授权链接</string>
<string name="oauth_preparing">正在准备授权...</string>
</resources>

View File

@ -0,0 +1,495 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="widget_name">AI用量面板</string>
<string name="widget_description">OpenCode Go 用量 + DeepSeek 餘額,自動重新整理</string>
<string name="widget_brand">雨晴喵</string>
<string name="app_name">雨晴Token</string>
<!-- ══════════ 通用动作 ══════════ -->
<string name="action_back">返回</string>
<string name="action_cancel">取消</string>
<string name="action_confirm_add">確認新增</string>
<string name="action_got_it">我知道了</string>
<string name="action_refresh">重新整理</string>
<string name="action_settings">設定</string>
<string name="action_delete">刪除</string>
<string name="action_clear">清除</string>
<string name="action_save">儲存</string>
<string name="action_jump">跳轉</string>
<string name="action_prev_page">◀ 上一頁</string>
<string name="action_next_page">下一頁 ▶</string>
<string name="action_add_widget">新增小工具到桌面</string>
<string name="title_language">語言</string>
<string name="dialog_language_title">選擇語言</string>
<string name="app_locale_follow_system">跟隨系統</string>
<string name="app_locale_zh">簡體中文</string>
<string name="app_locale_zh_hant">繁體中文</string>
<string name="app_locale_en">English</string>
<string name="action_view_detail">檢視詳情</string>
<string name="action_view_usage_detail">檢視用量詳情</string>
<string name="action_view_raw_data">詳細數據</string>
<string name="action_sync_usage">同步用量數據</string>
<string name="action_login_save">我已登入,儲存</string>
<string name="action_keep_waiting">繼續等待</string>
<!-- ══════════ 通用文案 ══════════ -->
<string name="common_loading">載入中…</string>
<string name="common_unknown">未知錯誤</string>
<string name="common_all_models">全部模型</string>
<!-- ══════════ 页面标题 ══════════ -->
<string name="nav_empty_title">👈 選擇服務檢視詳情</string>
<string name="nav_empty_subtitle">點選左側卡片即可</string>
<string name="title_settings">設定</string>
<string name="title_credentials">憑據管理</string>
<string name="title_about">關於 RainyToken</string>
<string name="title_tips">使用小技巧</string>
<string name="title_debug_log">除錯日誌</string>
<string name="title_usage_detail">用量詳情</string>
<string name="title_overview">總統計</string>
<string name="title_raw_data">詳細數據</string>
<string name="title_chart">統計圖表</string>
<!-- ══════════ 状态徽章 ══════════ -->
<string name="status_not_configured">未配置</string>
<string name="status_configured">已配置</string>
<string name="status_expired">已過期</string>
<string name="status_relogin">需重登</string>
<string name="status_refresh_failed">重新整理失敗</string>
<string name="status_not_fetched">未獲取</string>
<string name="status_waiting">待獲取</string>
<string name="status_normal">正常</string>
<string name="status_not_configured_add">未配置 · 點選新增</string>
<string name="status_ok_valid">已配置 · 憑據有效</string>
<string name="status_expired_reconfigure">已過期 · 點選重新配置</string>
<string name="status_need_reverify">需要重新驗證</string>
<string name="status_manual_mode">手動輸入模式</string>
<string name="status_credential_configured">憑據已配置</string>
<string name="status_not_configured_cred">未配置憑據</string>
<string name="status_loading">載入中</string>
<string name="status_fresh">最新</string>
<string name="status_stale">快取</string>
<string name="status_failed">失敗</string>
<string name="status_pending_input">待輸入</string>
<!-- ══════════ 仪表盘 ══════════ -->
<string name="dashboard_subtitle">AI 餘額一覽</string>
<string name="footer_never_fetched">從未獲取</string>
<string name="footer_just_updated">剛剛更新</string>
<string name="footer_minutes_ago">%1$d 分鐘前更新</string>
<string name="footer_hours_ago">%1$d 小時前更新</string>
<string name="footer_updated_at">%1$s 更新</string>
<string name="dashboard_drag_hint">💡 長按卡片可拖曳排序</string>
<string name="dashboard_error_prefix">⚠ %1$s</string>
<string name="dashboard_footer_privacy">本地加密儲存,不上傳任何數據</string>
<string name="dialog_add_widget_title">新增桌面小工具?</string>
<string name="dialog_add_widget_body">確認後會請求桌面啟動器新增雨晴Token小工具。部分系統仍會彈出系統確認視窗。</string>
<string name="toast_shortcut_permission_denied">桌面快捷方式權限已被拒絕,請在系統設定中允許後重試</string>
<string name="toast_add_rejected">桌面未接受新增請求,請檢查啟動器權限</string>
<string name="toast_picker_unsupported">當前桌面不支援應用內新增,請在桌面長按 → 小工具 → 查詢雨晴Token</string>
<!-- ══════════ 服务卡片 ══════════ -->
<string name="service_click_to_configure">點選配置憑據</string>
<string name="service_pull_to_refresh">下拉重新整理</string>
<string name="service_unavailable">服務當前不可用</string>
<string name="service_monthly_used">本月已用 %1$s%2$s</string>
<string name="service_remaining">剩餘</string>
<string name="service_used_total"> · 已用 %1$s / 共 %2$s</string>
<string name="service_used_label">%1$s 已用</string>
<string name="service_5h_used">5h 已用</string>
<string name="service_plan_suffix">· %1$s</string>
<string name="service_reset_in">%1$s 後重置</string>
<string name="service_desc_deepseek">REST API · ¥</string>
<string name="service_desc_opencode_go">WebView 抓取 · 5h 配額</string>
<string name="service_desc_commandcode_go">JSON API · $</string>
<string name="service_desc_codex">ChatGPT Plus · Codex 額度</string>
<string name="service_desc_ollama">Cookie 抓取 · Cloud 配額</string>
<!-- ══════════ 时长标签(DurationText 注入用) ══════════ -->
<string name="format_day">天</string>
<string name="format_hour">小時</string>
<string name="format_minute">分</string>
<!-- ══════════ 用量窗口 ══════════ -->
<string name="window_5h">5 小時</string>
<string name="window_5h_short">5h</string>
<string name="window_5h_usage">5h 用量</string>
<string name="window_5h_rolling">5 小時滾動</string>
<string name="window_weekly">本週</string>
<string name="window_monthly">本月</string>
<string name="window_every_month">每月</string>
<string name="window_usage">用量</string>
<string name="usage_window_title">用量視窗%1$s</string>
<string name="usage_window_title_plain">用量視窗</string>
<!-- ══════════ 用量统计 ══════════ -->
<string name="usage_title_opencode">OpenCode 用量</string>
<string name="usage_title_commandcode">CommandCode 用量</string>
<string name="usage_record_count">%1$d 條記錄</string>
<string name="usage_click_to_sync">點選同步</string>
<string name="usage_input_tokens">輸入 Token</string>
<string name="usage_output_tokens">輸出 Token</string>
<string name="usage_reasoning_tokens">推理 Token</string>
<string name="usage_total_cost">總花費</string>
<string name="usage_cache_read">快取讀取 %1$s</string>
<string name="usage_cache_write">快取寫入 %1$s</string>
<string name="usage_no_data_sync">暫無數據,點選 🔄 同步</string>
<string name="usage_new_records">✓ 新增 %1$d 條</string>
<string name="sync_partial">部分同步完成:插入 %1$d條,%2$d頁失敗</string>
<string name="usage_click_to_switch"> 點選切換</string>
<string name="usage_utc0_note">所有時間按 UTC+0 計算</string>
<string name="msg_utc8_on">已切換到 UTC+8,圖表按北京時間顯示</string>
<string name="msg_utc0_on">已切換到 UTC+0,圖表按世界協調時顯示</string>
<string name="usage_spent_total">已用 $%1$s / 共 $%2$s</string>
<string name="usage_remaining">剩餘 $%1$s</string>
<string name="usage_extra_credits">額外充值</string>
<string name="section_by_model">按模型統計</string>
<string name="section_by_day">按天統計(UTC+0)</string>
<string name="model_count">%1$d 個模型</string>
<string name="model_count_short">%1$d 個</string>
<string name="date_start">開始時間</string>
<string name="date_end">結束時間</string>
<!-- ══════════ 图表 ══════════ -->
<string name="chart_cost">消耗金額 (USD)</string>
<string name="chart_requests">API 請求次數</string>
<string name="chart_tokens">Token 消耗</string>
<string name="chart_stack_output">輸出</string>
<string name="chart_stack_input">輸入(未命中)</string>
<string name="chart_stack_cache">命中快取</string>
<!-- ══════════ 原始数据 ══════════ -->
<string name="header_time">時間</string>
<string name="header_model">模型</string>
<string name="header_input_cache">輸入(快取)</string>
<string name="header_output">輸出</string>
<string name="header_cost">成本</string>
<!-- ══════════ 时间筛选 ══════════ -->
<string name="time_all">全部</string>
<string name="time_last_5h">最近5小時</string>
<string name="time_last_12h">最近12小時</string>
<string name="time_last_24h">最近24小時</string>
<string name="time_today">今天</string>
<string name="time_yesterday">昨天</string>
<string name="time_last_7d">最近7天</string>
<string name="time_last_30d">最近30天</string>
<string name="time_this_month">當月</string>
<string name="time_custom">自定義</string>
<string name="time_custom_day">自定義天</string>
<string name="time_custom_month">自定義月</string>
<string name="time_custom_range">自定義時間</string>
<!-- ══════════ 设置页 ══════════ -->
<string name="about_description">統一查詢 AI 餘額的小工具。\n本地加密儲存憑據,不上傳任何數據。</string>
<string name="tips_view_all">檢視全部 %1$d 條隱藏操作</string>
<string name="debug_log_desc">檢視 token 重新整理、網路請求等除錯記錄</string>
<!-- ══════════ 凭据编辑 ══════════ -->
<string name="credential_configured_cookies">✓ 已配置登入態(%1$d 個 Cookie)</string>
<string name="help_cookie_title">如何獲取 Cookie?</string>
<string name="help_cookie_1">1. 在桌面 Chrome 開啟下方登入入口</string>
<string name="help_cookie_2">2. 用 GitHub / Google 登入(不會被攔截)</string>
<string name="help_cookie_3">3. 登入後按 F12 → Application → Cookies</string>
<string name="help_cookie_4">4. 複製該站點下的所有 Cookie 貼上到上面輸入框</string>
<string name="help_cookie_5">格式:name1=value1; name2=value2</string>
<string name="help_ocgo_title">如何獲取 OpenCode Go 憑據?</string>
<string name="help_ocgo_1">1. 桌面 Chrome 開啟 opencode.ai/auth</string>
<string name="help_ocgo_2">2. 用 GitHub / Google 登入(Google 不會攔截 Chrome)</string>
<string name="help_ocgo_3">3. 登入後跳轉到 dashboard,URL 形如:</string>
<string name="help_ocgo_4"> https://opencode.ai/workspace/{id}/go</string>
<string name="help_ocgo_5">4. 複製 URL 中的 {id} 段 → workspaceId</string>
<string name="help_ocgo_6">5. F12 → Application → Cookies</string>
<string name="help_ocgo_7">6. 找到域名 opencode.ai 下名為 auth 的 Cookie</string>
<string name="help_ocgo_8">7. 複製它的 Value 欄位 → auth cookie</string>
<string name="help_ocgo_note">注意:只填 auth 一個 Cookie 即可,不要整個 Cookie 字串</string>
<string name="help_ccgo_title">如何獲取 CommandCode Go 憑據?</string>
<string name="help_ccgo_1">1. 開啟 https://commandcode.ai 並登入 GitHub</string>
<string name="help_ccgo_2">▸ 獲取 API Key(必填):</string>
<string name="help_ccgo_3"> 右上角頭像 → Settings → API Keys → 建立一個 Key</string>
<string name="help_ccgo_4">▸ 獲取全部 Cookie(用於用量記錄):</string>
<string name="help_ccgo_5"> F12 → Application → Cookies → commandcode.ai</string>
<string name="help_ccgo_6"> 選中所有 Cookie,複製整個 Cookie 字串</string>
<string name="help_ccgo_7"> 貼上到下方輸入框即可,無需逐個挑選</string>
<string name="help_ccgo_note">提示:不填 Cookie 也能正常檢視餘額,只是用量記錄為空。</string>
<string name="help_codex_title">如何獲取 Codex auth.json?</string>
<string name="help_codex_1">1. 在終端執行 codex-oauth-proxy 或</string>
<string name="help_codex_2"> codex-proxy,完成 ChatGPT 登入</string>
<string name="help_codex_3">2. 憑證檔案儲存在:</string>
<!-- ══════════ 提示/错误消息 ══════════ -->
<string name="msg_saved">已儲存</string>
<string name="msg_credentials_saved">已儲存憑據</string>
<string name="msg_connect_success_balance">連線成功!餘額: %1$s %2$s</string>
<string name="msg_connect_success_saved">連線成功,憑據已儲存</string>
<string name="error_api_key_empty">API Key 不能為空</string>
<string name="error_json_format">JSON 格式錯誤</string>
<string name="error_credential_rejected">服務拒絕該憑據 (401/403)</string>
<string name="error_credential_changed_test">測試期間憑據已變更</string>
<string name="error_rate_limited">請求過於頻繁 (429)</string>
<string name="error_server">服務端錯誤 (%1$d)</string>
<string name="error_network">網路錯誤:%1$s</string>
<string name="error_rollback_done">,已恢復原憑據</string>
<string name="error_rollback_skipped">,檢測到憑據已變化,未執行回滾</string>
<string name="error_test_failed">測試失敗。%1$s%2$s</string>
<string name="error_test_not_supported">暫不支援測試此服務</string>
<string name="error_auth_cookie_workspace">auth cookie 和 workspaceId 都需要填寫</string>
<string name="error_cookie_empty">Cookie 不能為空</string>
<string name="error_credential_not_configured">憑據未配置</string>
<string name="error_credential_not_configured_expired">憑據未配置或已過期</string>
<string name="error_credential_invalid_reconfigure">憑據無效,請在設定中重新配置</string>
<string name="error_credential_invalid_relogin">憑據無效,請重新登入</string>
<string name="error_credential_changed_reload">憑據已變更,正在重新載入</string>
<string name="error_credential_changed_retry">憑據已變更,請重新操作</string>
<string name="error_network_check">網路異常,請檢查網路</string>
<string name="error_network_generic">網路異常</string>
<string name="error_server_http">服務端異常 (HTTP %1$d)</string>
<string name="error_rate_limited_retry">請求過於頻繁%1$s</string>
<string name="error_rate_limited_retry_suffix">,請 %1$d 秒後重試</string>
<string name="error_trigger_not_supported">不支援啟用用量</string>
<string name="error_select_model_first">請先選擇模型</string>
<string name="error_cookie_not_found">未抓到 Cookie,請確認已登入</string>
<string name="error_login_url_missing">未配置登入 URL</string>
<string name="error_oauth_url_prefix">URL 應以 %1$s 開頭</string>
<string name="error_oauth_auth_failed">授權失敗: %1$s</string>
<string name="error_oauth_state_mismatch">State 不匹配,請重試</string>
<string name="error_oauth_missing_code">回撥缺少授權碼</string>
<string name="error_oauth_token_exchange">Token 交換失敗,請檢視除錯日誌</string>
<!-- ══════════ 触发用量 ══════════ -->
<string name="trigger_success_title">✓ 用量已啟用</string>
<string name="trigger_success_header">✓ 啟用成功</string>
<string name="trigger_result_title">請求結果</string>
<string name="trigger_error_prefix">錯誤: %1$s</string>
<string name="trigger_response_body">響應內容:\n%1$s</string>
<string name="trigger_success_model">模型: %1$s</string>
<string name="trigger_success_response_id">響應 ID: %1$s</string>
<string name="trigger_reply_header">回覆:</string>
<string name="trigger_reply_empty">(空)</string>
<string name="trigger_reply_missing">(無回覆內容)</string>
<string name="trigger_parse_failed">(響應解析失敗,但請求已發出)</string>
<string name="trigger_usage_prompt_completion_total">用量: prompt=%1$s completion=%2$s total=%3$s tokens</string>
<string name="trigger_usage_input_output">用量: input=%1$s output=%2$s tokens</string>
<string name="error_token_refresh_generic">token 重新整理失敗,請重新登入或重新匯入 auth.json</string>
<!-- ══════════ WebView 登录 ══════════ -->
<string name="title_webview_login">登入 %1$s</string>
<string name="dialog_login_done_title">完成登入了嗎?</string>
<string name="dialog_login_done_body">如果你已經在頁面上完成登入但頁面沒有跳轉,可點選下方按鈕讓我抓取 Cookie 並儲存登入態。</string>
<!-- ══════════ 调试日志 ══════════ -->
<string name="debug_log_empty">暫無日誌記錄\n重新整理 Codex 用量後這裡會出現除錯資訊</string>
<!-- ══════════ 小组件 ══════════ -->
<string name="widget_title_opencode">OpenCode Go · 用量</string>
<string name="widget_deepseek_balance">DeepSeek 餘額</string>
<string name="widget_auto_refresh">自動重新整理</string>
<string name="widget_service_quota">%1$s · 額度</string>
<string name="widget_quota_with_plan">%1$s · %2$s</string>
<string name="widget_updated_at">更新 %1$s</string>
<string name="widget_no_data">暫無數據</string>
<string name="widget_refreshing">重新整理中…</string>
<!-- ══════════ 小技巧(AppTips) ══════════ -->
<string name="tip_1_title">長按卡片拖曳排序</string>
<string name="tip_1_hint">長按首頁卡片可以拖曳排序</string>
<string name="tip_1_detail">在首頁長按任意服務卡片即可拖曳到想要的位置,排序會自動儲存。</string>
<string name="tip_2_title">小工具點左上角進 APP</string>
<string name="tip_2_hint">小工具左上角標識可開啟 APP</string>
<string name="tip_2_detail">桌面小工具左上角 RainyToken › 標識可以點選開啟應用,其它區域點選切換服務。</string>
<string name="tip_3_title">圖表點選檢視詳情</string>
<string name="tip_3_hint">點選圖表柱子可檢視時段詳情</string>
<string name="tip_3_detail">在用量圖表上點選或滑動手指,可檢視對應時段的詳細數值。</string>
<string name="tip_4_title">圖表標題可點選</string>
<string name="tip_4_hint">點選圖表標題彈出按模型明細</string>
<string name="tip_4_detail">圖表卡片標題(如「消耗金額」)點選後會彈出按模型拆分的明細。</string>
<string name="tip_5_title">切換圖表時區</string>
<string name="tip_5_hint">用量詳情頁可切換 UTC+0 / UTC+8</string>
<string name="tip_5_detail">用量詳情頁頂部可切換 UTC+0 / UTC+8,圖表標籤會跟隨切換。</string>
<string name="tip_6_title">CCGO 清除並重新同步</string>
<string name="tip_6_hint">CCGO 詳情頁可清除快取重新同步</string>
<string name="tip_6_detail">CCGO 用量詳情頁右上角「清除」可刪除本地快取並重新拉取全部數據。</string>
<string name="tip_7_title">檢視用量詳情</string>
<string name="tip_7_hint">服務卡片底部可進入用量圖表</string>
<string name="tip_7_detail">有憑據的服務卡片底部會出現「檢視用量詳情」入口,點選進入圖表統計。</string>
<string name="tip_8_title">原始數據瀏覽</string>
<string name="tip_8_hint">用量詳情頁右上角可檢視原始記錄</string>
<string name="tip_8_detail">用量詳情頁右上角「詳細數據」可檢視每條原始呼叫記錄,支援分頁跳轉。</string>
<string name="tip_9_title">下拉重新整理聯動用量</string>
<string name="tip_9_hint">下拉重新整理會同步餘額和用量</string>
<string name="tip_9_detail">首頁下拉重新整理不僅重新整理餘額,還會自動同步 OCGO / CCGO 用量記錄。</string>
<string name="tip_10_title">憑據獲取教程</string>
<string name="tip_10_hint">憑據頁底部有獲取教程</string>
<string name="tip_10_detail">憑據編輯頁底部的「如何獲取」按鈕有針對每個服務的詳細操作步驟。</string>
<string name="tip_11_title">剪貼簿自動匯入</string>
<string name="tip_11_hint">Codex 支援從剪貼簿一鍵匯入</string>
<string name="tip_11_detail">Codex 憑據頁支援「從剪貼簿自動匯入」,貼上 auth.json 後一鍵填入。</string>
<string name="tip_12_title">詳細數據頁碼跳轉</string>
<string name="tip_12_hint">原始數據頁可輸入頁碼直跳</string>
<string name="tip_12_detail">原始數據頁底部可輸入頁碼直接跳轉,數據量大時不必逐頁翻。</string>
<string name="tip_13_title">小工具自動重新整理</string>
<string name="tip_13_hint">劃到小工具會自動重新整理餘額</string>
<string name="tip_13_detail">桌面小工具劃到負一屏時會自動重新整理餘額,也可手動點 ↻ 按鈕重新整理。</string>
<!-- ══════════ 图表/明细(B 组迁移) ══════════ -->
<string name="count_times">%1$d次</string>
<string name="date_select_day">選擇日期</string>
<string name="date_select_month">選擇月份(任意一天)</string>
<string name="date_pick_start">開始日期</string>
<string name="date_pick_end">結束日期</string>
<string name="chart_detail_cost">消費明細</string>
<string name="chart_detail_requests">呼叫次數明細</string>
<string name="chart_detail_tokens">Token 明細</string>
<string name="dialog_clear_title">清除數據</string>
<string name="dialog_clear_body">此操作將清除所有 CommandCode 用量本地快取並重新同步,需要幾秒鐘完成。</string>
<string name="dialog_clear_confirm_question">是否繼續?</string>
<string name="action_confirm_countdown">確認(%1$ds)</string>
<string name="action_confirm_clear">確認清除</string>
<string name="action_close">關閉</string>
<string name="range_from">從 %1$s</string>
<string name="range_to">至 %1$s</string>
<string name="action_apply_custom_range">應用自定義範圍</string>
<string name="action_confirm">確定</string>
<string name="date_end_before_start">結束日期不能早於開始日期 %1$s</string>
<string name="time_hm">時間 %1$s:%2$s</string>
<string name="common_no_data">暫無數據</string>
<string name="chart_total_label">合計: %1$s</string>
<string name="usage_total">總計</string>
<string name="usage_records">記錄數</string>
<string name="stat_cache_read">快取讀取</string>
<string name="stat_cache_write">快取寫入</string>
<string name="usage_cache_breakdown">快取 Token 細分</string>
<string name="usage_cache_read_note">※ 快取讀取已計入「輸入 Token」</string>
<string name="usage_calls">%1$d 次呼叫</string>
<string name="date_format_md">MM月dd日 EEEE</string>
<string name="daily_count_cost">%1$d 次 · $%2$s</string>
<string name="raw_record_title">原始數據</string>
<string name="stat_cache_write_5m">快取寫入(5m)</string>
<string name="stat_cache_write_1h">快取寫入(1h)</string>
<string name="raw_cost_usd">成本(USD)</string>
<string name="raw_cost_raw">成本(Raw)</string>
<!-- ══════════ 凭据编辑(C 组迁移) ══════════ -->
<string name="help_codex_4"> ~/.config/codex-oauth-proxy/auth.json</string>
<string name="help_codex_5">3. 用文字編輯器開啟該檔案,</string>
<string name="help_codex_6"> 複製全部內容貼上到上方輸入框</string>
<string name="help_codex_7"> JSON 格式示例:</string>
<string name="help_codex_8"> {"tokens": {</string>
<string name="help_codex_9"> "access_token": "...",</string>
<string name="help_codex_10"> "refresh_token": "...",</string>
<string name="help_codex_11"> "expiresAt": 1783727108252</string>
<string name="help_codex_12"> }}</string>
<string name="help_codex_note">提示:匯入後 APP 會自動重新整理 token,</string>
<string name="help_codex_note2">不需要手動更新。</string>
<string name="credential_title_ollama">Ollama Pro 憑據</string>
<string name="credential_hint_ollama">需要從瀏覽器複製完整 Cookie。登入 ollama.com/settings 後,在 DevTools → Network → 請求頭裡找到 Cookie 行複製。</string>
<string name="credential_update">更新憑據</string>
<string name="credential_save">儲存憑據</string>
<string name="action_test_and_save">測試並儲存</string>
<string name="action_open_ollama_settings">在瀏覽器中開啟 Ollama Settings</string>
<string name="action_copy_login_url">複製登入 URL 到剪貼簿</string>
<string name="activate_api_key_optional">一鍵啟用用量 API Key(可選)</string>
<string name="hint_ollama_trigger_key">填入 Ollama Cloud API Key 後,可在詳情頁一鍵傳送請求啟用用量統計。從 ollama.com/settings → API Keys 獲取。</string>
<string name="placeholder_ollama_api_key">ollama-xxx 或 sk-xxx</string>
<string name="action_save_api_key">儲存 API Key</string>
<string name="credential_title_opencode_go">OpenCode Go 憑據</string>
<string name="credential_hint_opencode_go">需要 auth cookie + workspaceId 兩個值。APP 會用 OkHttp 抓 dashboard 並自動解析 5h/周/月 三檔用量。</string>
<string name="action_import_from_clipboard">從剪貼簿自動匯入</string>
<string name="placeholder_workspace_id">dashboard URL 中的 {id} 段</string>
<string name="field_auth_cookie_value">auth Cookie 值</string>
<string name="placeholder_auth_cookie">F12 → Cookies → opencode.ai → auth 的 Value</string>
<string name="action_how_get_two_values">如何獲取這兩個值?</string>
<string name="action_open_login_entry">在瀏覽器中開啟登入入口</string>
<string name="hint_opencode_trigger_key">填入 OpenCode Zen API Key 後,可在詳情頁一鍵傳送請求啟用用量統計。從 opencode.ai/settings → API Keys 獲取。</string>
<string name="credential_title_ccgo">CommandCode Go 憑據</string>
<string name="credential_hint_ccgo">需要 API Key(拉餘額)。Session Cookie(可選)在瀏覽器 DevTools 複製,用於拉用量記錄。不填也可正常檢視餘額。</string>
<string name="placeholder_api_key_from_settings">從 Settings → API Keys 生成</string>
<string name="field_session_cookie_optional">Session Cookie(可選)</string>
<string name="placeholder_session_cookie">F12 → Cookies → commandcode.ai → 全選複製貼上即可</string>
<string name="action_how_get_api_key_cookie">如何獲取 API Key 和 Cookie?</string>
<string name="api_key_form_title">請輸入 API Key</string>
<string name="api_key_form_hint">憑據會使用 Android Keystore (AES-256 GCM) 加密後儲存到本機 DataStore。</string>
<string name="action_update">更新</string>
<string name="credential_title_codex">Codex / ChatGPT Plus 憑據</string>
<string name="credential_hint_codex">推薦使用 OAuth 登入自動獲取憑據。也可手動貼上 auth.json 內容。</string>
<string name="action_oauth_login">🔐 OAuth 登入(推薦)</string>
<string name="or_manual_import">── 或手動匯入 ──</string>
<string name="field_auth_json">auth.json 完整內容</string>
<string name="placeholder_auth_json">貼上 {"tokens": {...}} 完整 JSON</string>
<string name="credential_import_save">匯入並儲存</string>
<string name="action_how_get_auth_json">如何獲取 auth.json?</string>
<string name="manual_cookie_title">貼上登入態</string>
<string name="manual_cookie_hint">Google OAuth 會拒絕 APP 內建 WebView,所以請用瀏覽器登入後把 Cookie 粘到這裡。</string>
<string name="field_cookie_string">Cookie 字串</string>
<string name="action_save_cookie">儲存 Cookie</string>
<!-- ══════════ 服务详情/凭据校验(D 组迁移) ══════════ -->
<string name="error_parse_empty_body">回應體為空</string>
<string name="error_parse_not_json">回應根節點不是 JSON 物件</string>
<string name="error_parse_no_windows">未找到用量視窗資料</string>
<string name="error_parse_no_models">未找到模型清單</string>
<string name="error_parse_models_empty">模型清單為空</string>
<string name="error_parse_malformed">回應格式異常</string>
<string name="window_every_week">每週</string>
<string name="msg_model_call_count">模型呼叫次數</string>
<string name="msg_stale_cache">快取數據 · %1$s</string>
<string name="msg_showing_last_balance">顯示的是上次成功獲取的餘額</string>
<string name="msg_monthly_spent">本月消費 %1$s%2$s</string>
<string name="msg_balance_breakdown">餘額構成</string>
<string name="msg_granted_balance">贈送餘額</string>
<string name="msg_topped_up_balance">自費充值</string>
<string name="msg_loading_models">載入模型列表...</string>
<string name="msg_select_model">選擇模型</string>
<string name="action_refresh_models">重新整理模型列表</string>
<string name="msg_triggering">啟用中...</string>
<string name="action_retry_trigger">重試啟用用量</string>
<string name="action_trigger_usage">⚡ 一鍵啟用用量</string>
<string name="action_refresh_balance">重新整理餘額</string>
<string name="action_relogin">重新登入</string>
<string name="action_configure_credential">配置憑據</string>
<string name="main_card_current_balance">當前餘額</string>
<string name="main_card_5h_usage">5h 實時用量</string>
<string name="main_card_monthly_balance">月度餘額</string>
<string name="main_card_session_usage">Session 用量</string>
<string name="msg_please_select_model">請選擇模型</string>
<string name="trigger_error_detail">錯誤詳情:</string>
<string name="trigger_api_response">API 響應內容:</string>
<string name="action_copy">複製</string>
<string name="msg_key_length">*** (長度 %1$d) ***</string>
<string name="msg_key_masked">%1$s***%2$s (長度 %3$d)</string>
<string name="error_cookie_format">Cookie 格式錯誤,應為 name1=value1; name2=value2</string>
<string name="msg_cookies_saved">已儲存 %1$d 個 Cookie</string>
<string name="error_auth_json_empty">請貼上 auth.json 內容</string>
<string name="error_auth_json_missing_tokens">auth.json 缺少 access_token 或 refresh_token</string>
<string name="msg_codex_saved">已儲存 Codex 憑據,token 到期後會自動重新整理</string>
<string name="error_auth_json_parse">解析失敗:%1$s</string>
<string name="msg_connect_success_dollar">連線成功!餘額: $%1$s</string>
<string name="msg_api_key_saved">API Key 已儲存</string>
<string name="msg_connect_success_session">連線成功!Session: %1$s%% · %2$s</string>
<string name="msg_credential_deleted">憑據已刪除</string>
<string name="error_clipboard_empty">剪貼簿為空</string>
<string name="msg_clipboard_ws_auth">已識別 workspaceId 和 auth cookie</string>
<string name="msg_clipboard_ws_only">已識別 workspaceId,請再貼上 auth cookie</string>
<string name="msg_clipboard_auth_only">已識別 auth cookie,請再貼上 workspaceId</string>
<string name="error_clipboard_none">未識別到有效憑據</string>
<!-- ══════════ Codex OAuth 登录页 ══════════ -->
<string name="oauth_screen_title">Codex OAuth 登入</string>
<string name="oauth_exchanging">正在交換 Token...</string>
<string name="action_retry">重試</string>
<string name="oauth_headless_title">無頭模式登入</string>
<string name="oauth_steps">1. 點選「在瀏覽器中開啟」或複製下方連結到瀏覽器\n2. 在 OpenAI 頁面完成登入和授權\n3. 瀏覽器會跳轉到一個 localhost 地址(頁面打不開是正常的)\n4. 複製瀏覽器位址列的完整 URL 貼上到下方輸入框\n5. 點選「提交回撥 URL」</string>
<string name="oauth_auth_url_label">授權連結</string>
<string name="action_open_in_browser">在瀏覽器中開啟</string>
<string name="action_copy_link">複製連結</string>
<string name="oauth_paste_callback_hint">── 登入後貼上回撥 URL ──</string>
<string name="oauth_callback_url_label">回撥 URL</string>
<string name="oauth_callback_url_placeholder">http://localhost:1455/auth/callback?code=...</string>
<string name="action_submit_callback">提交回撥 URL</string>
<string name="action_regenerate_link">重新生成授權連結</string>
<string name="oauth_preparing">正在準備授權...</string>
</resources>

View File

@ -1,5 +1,498 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">雨晴Token</string>
<string name="widget_name">AI用量面板</string>
<string name="widget_description">OpenCode Go 用量 + DeepSeek 余额,自动刷新</string>
<!-- ══════════ 品牌资源(translatable=false,所有语言共用,不参与翻译) ══════════ -->
<string name="brand_token" translatable="false">RainyToken</string>
<string name="dashboard_footer_version" translatable="false">RainyToken · v1.0</string>
<!-- ══════════ 应用名称(跟随语言:中文系统显示中文名,其他显示英文名) ══════════ -->
<string name="app_name">RainyToken</string>
<!-- ══════════ 小组件名称/描述/署名 ══════════ -->
<string name="widget_name">AI Usage Panel</string>
<string name="widget_description">OpenCode Go usage + DeepSeek balance, auto-refresh</string>
<string name="widget_brand">Rainy</string>
<!-- ══════════ 通用动作 ══════════ -->
<string name="action_back">Back</string>
<string name="action_cancel">Cancel</string>
<string name="action_confirm_add">Add Widget</string>
<string name="action_got_it">Got it</string>
<string name="action_refresh">Refresh</string>
<string name="action_settings">Settings</string>
<string name="action_delete">Delete</string>
<string name="action_clear">Clear</string>
<string name="action_save">Save</string>
<string name="action_jump">Go</string>
<string name="action_prev_page">◀ Prev</string>
<string name="action_next_page">Next ▶</string>
<string name="action_add_widget">Add widget to home screen</string>
<string name="title_language">Language</string>
<string name="dialog_language_title">Select language</string>
<string name="app_locale_follow_system">Follow system</string>
<string name="app_locale_zh">简体中文</string>
<string name="app_locale_zh_hant">繁體中文</string>
<string name="app_locale_en">English</string>
<string name="action_view_detail">View details</string>
<string name="action_view_usage_detail">View usage details</string>
<string name="action_view_raw_data">Raw data</string>
<string name="action_sync_usage">Sync usage data</string>
<string name="action_login_save">I\'m logged in, save</string>
<string name="action_keep_waiting">Keep waiting</string>
<!-- ══════════ 通用文案 ══════════ -->
<string name="common_loading">Loading…</string>
<string name="common_unknown">Unknown error</string>
<string name="common_all_models">All models</string>
<!-- ══════════ 页面标题 ══════════ -->
<string name="nav_empty_title">👈 Select a service to view details</string>
<string name="nav_empty_subtitle">Tap a card on the left</string>
<string name="title_settings">Settings</string>
<string name="title_credentials">Credentials</string>
<string name="title_about">About RainyToken</string>
<string name="title_tips">Tips</string>
<string name="title_debug_log">Debug log</string>
<string name="title_usage_detail">Usage details</string>
<string name="title_overview">Overview</string>
<string name="title_raw_data">Raw data</string>
<string name="title_chart">Charts</string>
<!-- ══════════ 状态徽章 ══════════ -->
<string name="status_not_configured">Not set up</string>
<string name="status_configured">Configured</string>
<string name="status_expired">Expired</string>
<string name="status_relogin">Re-login</string>
<string name="status_refresh_failed">Refresh failed</string>
<string name="status_not_fetched">No data</string>
<string name="status_waiting">Pending</string>
<string name="status_normal">OK</string>
<string name="status_not_configured_add">Not set up · Tap to add</string>
<string name="status_ok_valid">Configured · Valid</string>
<string name="status_expired_reconfigure">Expired · Tap to reconfigure</string>
<string name="status_need_reverify">Needs re-verification</string>
<string name="status_manual_mode">Manual entry mode</string>
<string name="status_credential_configured">Credential set</string>
<string name="status_not_configured_cred">No credential</string>
<string name="status_loading">Loading</string>
<string name="status_fresh">Latest</string>
<string name="status_stale">Cached</string>
<string name="status_failed">Failed</string>
<string name="status_pending_input">Awaiting input</string>
<!-- ══════════ 仪表盘 ══════════ -->
<string name="dashboard_subtitle">AI Balance Overview</string>
<string name="footer_never_fetched">Never fetched</string>
<string name="footer_just_updated">Updated just now</string>
<string name="footer_minutes_ago">Updated %1$d min ago</string>
<string name="footer_hours_ago">Updated %1$d h ago</string>
<string name="footer_updated_at">Updated %1$s</string>
<string name="dashboard_drag_hint">💡 Long-press a card to reorder</string>
<string name="dashboard_error_prefix">⚠ %1$s</string>
<string name="dashboard_footer_privacy">Stored locally &amp; encrypted, nothing is uploaded</string>
<string name="dialog_add_widget_title">Add home screen widget?</string>
<string name="dialog_add_widget_body">This will ask the launcher to add the RainyToken widget. Some systems may still show their own confirmation dialog.</string>
<string name="toast_shortcut_permission_denied">Shortcut permission denied. Please allow it in system settings and try again.</string>
<string name="toast_add_rejected">The launcher rejected the request. Please check launcher permissions.</string>
<string name="toast_picker_unsupported">This launcher does not support in-app adding. Long-press home → Widgets → find RainyToken.</string>
<!-- ══════════ 服务卡片 ══════════ -->
<string name="service_click_to_configure">Tap to configure credentials</string>
<string name="service_pull_to_refresh">Pull to refresh</string>
<string name="service_unavailable">Service currently unavailable</string>
<string name="service_monthly_used">%1$s%2$s used this month</string>
<string name="service_remaining">Remaining</string>
<string name="service_used_total"> · %1$s used / %2$s total</string>
<string name="service_used_label">%1$s used</string>
<string name="service_5h_used">5h used</string>
<string name="service_plan_suffix">· %1$s</string>
<string name="service_reset_in">Resets in %1$s</string>
<string name="service_desc_deepseek">REST API · ¥</string>
<string name="service_desc_opencode_go">WebView fetch · 5h quota</string>
<string name="service_desc_commandcode_go">JSON API · $</string>
<string name="service_desc_codex">ChatGPT Plus · Codex quota</string>
<string name="service_desc_ollama">Cookie fetch · Cloud quota</string>
<!-- ══════════ 时长标签(DurationText 注入用) ══════════ -->
<string name="format_day">d</string>
<string name="format_hour">h</string>
<string name="format_minute">m</string>
<!-- ══════════ 用量窗口 ══════════ -->
<string name="window_5h">5 hours</string>
<string name="window_5h_short">5h</string>
<string name="window_5h_usage">5h usage</string>
<string name="window_5h_rolling">5-hour rolling</string>
<string name="window_weekly">Week</string>
<string name="window_monthly">Month</string>
<string name="window_every_month">Monthly</string>
<string name="window_usage">Usage</string>
<string name="usage_window_title">Usage window%1$s</string>
<string name="usage_window_title_plain">Usage window</string>
<!-- ══════════ 用量统计 ══════════ -->
<string name="usage_title_opencode">OpenCode Usage</string>
<string name="usage_title_commandcode">CommandCode Usage</string>
<string name="usage_record_count">%1$d records</string>
<string name="usage_click_to_sync">Tap to sync</string>
<string name="usage_input_tokens">Input tokens</string>
<string name="usage_output_tokens">Output tokens</string>
<string name="usage_reasoning_tokens">Reasoning tokens</string>
<string name="usage_total_cost">Total cost</string>
<string name="usage_cache_read">Cache read %1$s</string>
<string name="usage_cache_write">Cache write %1$s</string>
<string name="usage_no_data_sync">No data yet · tap 🔄 to sync</string>
<string name="usage_new_records">✓ %1$d new records</string>
<string name="sync_partial">Partial sync: %1$d records inserted, %2$d pages failed</string>
<string name="usage_click_to_switch"> tap to switch</string>
<string name="usage_utc0_note">All times in UTC+0</string>
<string name="msg_utc8_on">Switched to UTC+8, charts shown in Beijing time</string>
<string name="msg_utc0_on">Switched to UTC+0, charts shown in UTC</string>
<string name="usage_spent_total">$%1$s spent / $%2$s total</string>
<string name="usage_remaining">$%1$s remaining</string>
<string name="usage_extra_credits">Extra credits</string>
<string name="section_by_model">By model</string>
<string name="section_by_day">By day (UTC+0)</string>
<string name="model_count">%1$d models</string>
<string name="model_count_short">%1$d</string>
<string name="date_start">Start time</string>
<string name="date_end">End time</string>
<!-- ══════════ 图表 ══════════ -->
<string name="chart_cost">Spending (USD)</string>
<string name="chart_requests">API requests</string>
<string name="chart_tokens">Token usage</string>
<string name="chart_stack_output">Output</string>
<string name="chart_stack_input">Input (miss)</string>
<string name="chart_stack_cache">Cache hit</string>
<!-- ══════════ 原始数据 ══════════ -->
<string name="header_time">Time</string>
<string name="header_model">Model</string>
<string name="header_input_cache">Input(cache)</string>
<string name="header_output">Output</string>
<string name="header_cost">Cost</string>
<!-- ══════════ 时间筛选 ══════════ -->
<string name="time_all">All</string>
<string name="time_last_5h">Last 5 hours</string>
<string name="time_last_12h">Last 12 hours</string>
<string name="time_last_24h">Last 24 hours</string>
<string name="time_today">Today</string>
<string name="time_yesterday">Yesterday</string>
<string name="time_last_7d">Last 7 days</string>
<string name="time_last_30d">Last 30 days</string>
<string name="time_this_month">This month</string>
<string name="time_custom">Custom</string>
<string name="time_custom_day">Custom day</string>
<string name="time_custom_month">Custom month</string>
<string name="time_custom_range">Custom range</string>
<!-- ══════════ 设置页 ══════════ -->
<string name="about_description">A tiny tool to check AI balances.\nCredentials are encrypted locally — nothing is uploaded.</string>
<string name="tips_view_all">View all %1$d hidden tips</string>
<string name="debug_log_desc">Inspect token refresh, network requests and other debug records</string>
<!-- ══════════ 凭据编辑 ══════════ -->
<string name="credential_configured_cookies">✓ Login state configured (%1$d cookies)</string>
<string name="help_cookie_title">How to get the Cookie?</string>
<string name="help_cookie_1">1. Open the login page below in desktop Chrome</string>
<string name="help_cookie_2">2. Sign in with GitHub / Google (won\'t be blocked)</string>
<string name="help_cookie_3">3. After login, press F12 → Application → Cookies</string>
<string name="help_cookie_4">4. Copy all cookies of the site and paste them above</string>
<string name="help_cookie_5">Format: name1=value1; name2=value2</string>
<string name="help_ocgo_title">How to get OpenCode Go credentials?</string>
<string name="help_ocgo_1">1. Open opencode.ai/auth in desktop Chrome</string>
<string name="help_ocgo_2">2. Sign in with GitHub / Google (Google won\'t block Chrome)</string>
<string name="help_ocgo_3">3. You\'ll land on the dashboard, URL looks like:</string>
<string name="help_ocgo_4"> https://opencode.ai/workspace/{id}/go</string>
<string name="help_ocgo_5">4. Copy the {id} segment → workspaceId</string>
<string name="help_ocgo_6">5. F12 → Application → Cookies</string>
<string name="help_ocgo_7">6. Find the cookie named auth under opencode.ai</string>
<string name="help_ocgo_8">7. Copy its Value field → auth cookie</string>
<string name="help_ocgo_note">Note: only the auth cookie is needed, not the whole cookie string</string>
<string name="help_ccgo_title">How to get CommandCode Go credentials?</string>
<string name="help_ccgo_1">1. Open https://commandcode.ai and sign in with GitHub</string>
<string name="help_ccgo_2">▸ Get API Key (required):</string>
<string name="help_ccgo_3"> Avatar (top right) → Settings → API Keys → Create a Key</string>
<string name="help_ccgo_4">▸ Get all cookies (for usage records):</string>
<string name="help_ccgo_5"> F12 → Application → Cookies → commandcode.ai</string>
<string name="help_ccgo_6"> Select all cookies and copy the whole string</string>
<string name="help_ccgo_7"> Paste it below — no need to pick them one by one</string>
<string name="help_ccgo_note">Tip: balance works without cookies, usage records will just be empty.</string>
<string name="help_codex_title">How to get Codex auth.json?</string>
<string name="help_codex_1">1. Run codex-oauth-proxy or</string>
<string name="help_codex_2"> codex-proxy in a terminal to sign in to ChatGPT</string>
<string name="help_codex_3">2. The credential file is saved at:</string>
<!-- ══════════ 提示/错误消息 ══════════ -->
<string name="msg_saved">Saved</string>
<string name="msg_credentials_saved">Credentials saved</string>
<string name="msg_connect_success_balance">Connected! Balance: %1$s %2$s</string>
<string name="msg_connect_success_saved">Connected, credentials saved</string>
<string name="error_api_key_empty">API Key cannot be empty</string>
<string name="error_json_format">Invalid JSON format</string>
<string name="error_credential_rejected">Service rejected this credential (401/403)</string>
<string name="error_credential_changed_test">Credentials changed during test</string>
<string name="error_rate_limited">Too many requests (429)</string>
<string name="error_server">Server error (%1$d)</string>
<string name="error_network">Network error: %1$s</string>
<string name="error_rollback_done">, previous credential restored</string>
<string name="error_rollback_skipped">, credential changed, rollback skipped</string>
<string name="error_test_failed">Test failed. %1$s%2$s</string>
<string name="error_test_not_supported">Testing is not supported for this service</string>
<string name="error_auth_cookie_workspace">Both auth cookie and workspaceId are required</string>
<string name="error_cookie_empty">Cookie cannot be empty</string>
<string name="error_credential_not_configured">Credential not configured</string>
<string name="error_credential_not_configured_expired">Credential missing or expired</string>
<string name="error_credential_invalid_reconfigure">Credential invalid, please reconfigure in Settings</string>
<string name="error_credential_invalid_relogin">Credential invalid, please log in again</string>
<string name="error_credential_changed_reload">Credentials changed, reloading</string>
<string name="error_credential_changed_retry">Credentials changed, please retry</string>
<string name="error_network_check">Network error, please check your connection</string>
<string name="error_network_generic">Network error</string>
<string name="error_server_http">Server error (HTTP %1$d)</string>
<string name="error_rate_limited_retry">Too many requests%1$s</string>
<string name="error_rate_limited_retry_suffix">, retry in %1$d seconds</string>
<string name="error_trigger_not_supported">Usage activation is not supported</string>
<string name="error_select_model_first">Please select a model first</string>
<string name="error_cookie_not_found">No cookie captured, please make sure you\'re logged in</string>
<string name="error_login_url_missing">No login URL configured</string>
<string name="error_oauth_url_prefix">URL should start with %1$s</string>
<string name="error_oauth_auth_failed">Authorization failed: %1$s</string>
<string name="error_oauth_state_mismatch">State mismatch, please retry</string>
<string name="error_oauth_missing_code">Callback missing authorization code</string>
<string name="error_oauth_token_exchange">Token exchange failed, check the debug log</string>
<!-- ══════════ 触发用量 ══════════ -->
<string name="trigger_success_title">✓ Usage activated</string>
<string name="trigger_success_header">✓ Activation successful</string>
<string name="trigger_result_title">Request result</string>
<string name="trigger_error_prefix">Error: %1$s</string>
<string name="trigger_response_body">Response body:\n%1$s</string>
<string name="trigger_success_model">Model: %1$s</string>
<string name="trigger_success_response_id">Response ID: %1$s</string>
<string name="trigger_reply_header">Reply:</string>
<string name="trigger_reply_empty">(empty)</string>
<string name="trigger_reply_missing">(no reply content)</string>
<string name="trigger_parse_failed">(response parsing failed, but the request was sent)</string>
<string name="trigger_usage_prompt_completion_total">Usage: prompt=%1$s completion=%2$s total=%3$s tokens</string>
<string name="trigger_usage_input_output">Usage: input=%1$s output=%2$s tokens</string>
<string name="error_token_refresh_generic">Token refresh failed. Please log in again or import auth.json.</string>
<!-- ══════════ WebView 登录 ══════════ -->
<string name="title_webview_login">Sign in to %1$s</string>
<string name="dialog_login_done_title">Done logging in?</string>
<string name="dialog_login_done_body">If you finished signing in but the page didn\'t redirect, tap the button below to grab the cookie and save your session.</string>
<!-- ══════════ 调试日志 ══════════ -->
<string name="debug_log_empty">No log entries yet\nRefresh Codex usage and debug info will show up here</string>
<!-- ══════════ 小组件 ══════════ -->
<string name="widget_title_opencode">OpenCode Go · Usage</string>
<string name="widget_deepseek_balance">DeepSeek balance</string>
<string name="widget_auto_refresh">Auto refresh</string>
<string name="widget_service_quota">%1$s · Quota</string>
<string name="widget_quota_with_plan">%1$s · %2$s</string>
<string name="widget_updated_at">Updated %1$s</string>
<string name="widget_no_data">No data</string>
<string name="widget_refreshing">Refreshing…</string>
<!-- ══════════ 小技巧(AppTips) ══════════ -->
<string name="tip_1_title">Reorder cards by long-press</string>
<string name="tip_1_hint">Long-press a card to reorder it</string>
<string name="tip_1_detail">Long-press any service card on the home screen and drag it wherever you like. The order is saved automatically.</string>
<string name="tip_2_title">Widget top-left opens the app</string>
<string name="tip_2_hint">Tap the widget\'s top-left mark to open the app</string>
<string name="tip_2_detail">The RainyToken › mark at the top-left of the widget opens the app. Tapping elsewhere switches services.</string>
<string name="tip_3_title">Tap charts for details</string>
<string name="tip_3_hint">Tap a chart bar to see that time slot</string>
<string name="tip_3_detail">Tap or slide on the usage charts to inspect the exact values of a time slot.</string>
<string name="tip_4_title">Chart titles are clickable</string>
<string name="tip_4_hint">Tap a chart title for a per-model breakdown</string>
<string name="tip_4_detail">Tapping a chart card title (e.g. "Spending") shows a per-model breakdown dialog.</string>
<string name="tip_5_title">Switch chart timezone</string>
<string name="tip_5_hint">Usage details can toggle UTC+0 / UTC+8</string>
<string name="tip_5_detail">The usage details page can toggle between UTC+0 and UTC+8; chart labels follow along.</string>
<string name="tip_6_title">CCGO clear &amp; resync</string>
<string name="tip_6_hint">CCGO details can clear cache and resync</string>
<string name="tip_6_detail">The "Clear" button on the CCGO usage details page deletes local cache and re-fetches everything.</string>
<string name="tip_7_title">View usage details</string>
<string name="tip_7_hint">Service cards link to usage charts</string>
<string name="tip_7_detail">Configured service cards show a "View usage details" entry at the bottom that opens the charts.</string>
<string name="tip_8_title">Browse raw data</string>
<string name="tip_8_hint">Top-right of usage details opens raw records</string>
<string name="tip_8_detail">"Raw data" at the top-right of usage details lists every record with pagination.</string>
<string name="tip_9_title">Pull-to-refresh syncs usage</string>
<string name="tip_9_hint">Pull to refresh syncs balances and usage</string>
<string name="tip_9_detail">Pull-to-refresh on the home screen refreshes balances and also syncs OCGO / CCGO usage records.</string>
<string name="tip_10_title">Credential guides</string>
<string name="tip_10_hint">Credential pages include step-by-step guides</string>
<string name="tip_10_detail">The "How to get" button at the bottom of credential pages walks through every service step by step.</string>
<string name="tip_11_title">Clipboard auto-import</string>
<string name="tip_11_hint">Codex supports one-tap import from clipboard</string>
<string name="tip_11_detail">The Codex credential page supports "Auto-import from clipboard": paste auth.json and it fills in with one tap.</string>
<string name="tip_12_title">Jump to a page number</string>
<string name="tip_12_hint">Raw data page accepts a page number</string>
<string name="tip_12_detail">The raw data page lets you enter a page number to jump directly — no need to flip through pages.</string>
<string name="tip_13_title">Widget auto-refresh</string>
<string name="tip_13_hint">Swipe to the widget and it auto-refreshes</string>
<string name="tip_13_detail">The widget refreshes balances when you swipe to it, or tap the ↻ button manually.</string>
<!-- ══════════ 图表/明细 ══════════ -->
<string name="count_times">%1$d calls</string>
<string name="date_select_day">Pick a date</string>
<string name="date_select_month">Pick a month (any day)</string>
<string name="date_pick_start">Start date</string>
<string name="date_pick_end">End date</string>
<string name="chart_detail_cost">Spending breakdown</string>
<string name="chart_detail_requests">Request breakdown</string>
<string name="chart_detail_tokens">Token breakdown</string>
<string name="dialog_clear_title">Clear data</string>
<string name="dialog_clear_body">This clears all cached CommandCode usage and re-syncs. It takes a few seconds.</string>
<string name="dialog_clear_confirm_question">Continue?</string>
<string name="action_confirm_countdown">Confirm (%1$ds)</string>
<string name="action_confirm_clear">Yes, clear</string>
<string name="action_close">Close</string>
<string name="range_from">From %1$s</string>
<string name="range_to">To %1$s</string>
<string name="action_apply_custom_range">Apply custom range</string>
<string name="action_confirm">OK</string>
<string name="date_end_before_start">End date cannot be earlier than start date %1$s</string>
<string name="time_hm">Time %1$s:%2$s</string>
<string name="common_no_data">No data</string>
<string name="chart_total_label">Total: %1$s</string>
<string name="usage_total">Total</string>
<string name="usage_records">Records</string>
<string name="stat_cache_read">Cache read</string>
<string name="stat_cache_write">Cache write</string>
<string name="usage_cache_breakdown">Cache token breakdown</string>
<string name="usage_cache_read_note">※ Cache reads are included in "Input tokens"</string>
<string name="usage_calls">%1$d calls</string>
<string name="date_format_md">MMM d, EEEE</string>
<string name="daily_count_cost">%1$d calls · $%2$s</string>
<string name="raw_record_title">Raw data</string>
<string name="stat_cache_write_5m">Cache write (5m)</string>
<string name="stat_cache_write_1h">Cache write (1h)</string>
<string name="raw_cost_usd">Cost (USD)</string>
<string name="raw_cost_raw">Cost (raw)</string>
<!-- ══════════ 凭据编辑(C 组) ══════════ -->
<string name="help_codex_4"> ~/.config/codex-oauth-proxy/auth.json</string>
<string name="help_codex_5">3. Open it with a text editor,</string>
<string name="help_codex_6"> copy everything and paste it above</string>
<string name="help_codex_7"> JSON example:</string>
<string name="help_codex_8"> {"tokens": {</string>
<string name="help_codex_9"> "access_token": "...",</string>
<string name="help_codex_10"> "refresh_token": "...",</string>
<string name="help_codex_11"> "expiresAt": 1783727108252</string>
<string name="help_codex_12"> }}</string>
<string name="help_codex_note">Tip: the app auto-refreshes the token after import,</string>
<string name="help_codex_note2">no manual updates needed.</string>
<string name="credential_title_ollama">Ollama Pro credentials</string>
<string name="credential_hint_ollama">Copy the full cookie from your browser. After logging into ollama.com/settings, find the Cookie header in DevTools → Network and copy it.</string>
<string name="credential_update">Update credentials</string>
<string name="credential_save">Save credentials</string>
<string name="action_test_and_save">Test &amp; save</string>
<string name="action_open_ollama_settings">Open Ollama Settings in browser</string>
<string name="action_copy_login_url">Copy login URL to clipboard</string>
<string name="activate_api_key_optional">One-tap usage API Key (optional)</string>
<string name="hint_ollama_trigger_key">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.</string>
<string name="placeholder_ollama_api_key">ollama-xxx or sk-xxx</string>
<string name="action_save_api_key">Save API Key</string>
<string name="credential_title_opencode_go">OpenCode Go credentials</string>
<string name="credential_hint_opencode_go">Needs both auth cookie and workspaceId. The app fetches the dashboard via OkHttp and parses 5h/week/month usage.</string>
<string name="action_import_from_clipboard">Auto-import from clipboard</string>
<string name="placeholder_workspace_id">The {id} segment of the dashboard URL</string>
<string name="field_auth_cookie_value">auth Cookie value</string>
<string name="placeholder_auth_cookie">F12 → Cookies → opencode.ai → Value of auth</string>
<string name="action_how_get_two_values">How to get these two?</string>
<string name="action_open_login_entry">Open login page in browser</string>
<string name="hint_opencode_trigger_key">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.</string>
<string name="credential_title_ccgo">CommandCode Go credentials</string>
<string name="credential_hint_ccgo">Needs an API Key (for balance). Session Cookie (optional) for usage records, copied from browser DevTools. Balance works without it.</string>
<string name="placeholder_api_key_from_settings">Generate at Settings → API Keys</string>
<string name="field_session_cookie_optional">Session Cookie (optional)</string>
<string name="placeholder_session_cookie">F12 → Cookies → commandcode.ai → select all &amp; paste</string>
<string name="action_how_get_api_key_cookie">How to get API Key and Cookie?</string>
<string name="api_key_form_title">Enter API Key</string>
<string name="api_key_form_hint">Credentials are encrypted with Android Keystore (AES-256 GCM) and stored in local DataStore.</string>
<string name="action_update">Update</string>
<string name="credential_title_codex">Codex / ChatGPT Plus credentials</string>
<string name="credential_hint_codex">OAuth login fetches credentials automatically. You can also paste auth.json manually.</string>
<string name="action_oauth_login">🔐 OAuth login (recommended)</string>
<string name="or_manual_import">── or import manually ──</string>
<string name="field_auth_json">Full auth.json content</string>
<string name="placeholder_auth_json">Paste the full {"tokens": {...}} JSON</string>
<string name="credential_import_save">Import &amp; save</string>
<string name="action_how_get_auth_json">How to get auth.json?</string>
<string name="manual_cookie_title">Paste session</string>
<string name="manual_cookie_hint">Google OAuth blocks the in-app WebView, so log in with a browser and paste the cookie here.</string>
<string name="field_cookie_string">Cookie string</string>
<string name="action_save_cookie">Save cookie</string>
<!-- ══════════ 服务详情/凭据校验(D 组) ══════════ -->
<string name="error_parse_empty_body">Empty response body</string>
<string name="error_parse_not_json">Response root is not a JSON object</string>
<string name="error_parse_no_windows">No usage window data found</string>
<string name="error_parse_no_models">Model list not found</string>
<string name="error_parse_models_empty">Model list is empty</string>
<string name="error_parse_malformed">Response format is abnormal</string>
<string name="window_every_week">Weekly</string>
<string name="msg_model_call_count">Model call count</string>
<string name="msg_stale_cache">Cached data · %1$s</string>
<string name="msg_showing_last_balance">Showing the last successfully fetched balance</string>
<string name="msg_monthly_spent">%1$s%2$s spent this month</string>
<string name="msg_balance_breakdown">Balance breakdown</string>
<string name="msg_granted_balance">Granted balance</string>
<string name="msg_topped_up_balance">Top-ups</string>
<string name="msg_loading_models">Loading model list...</string>
<string name="msg_select_model">Select model</string>
<string name="action_refresh_models">Refresh model list</string>
<string name="msg_triggering">Activating...</string>
<string name="action_retry_trigger">Retry usage activation</string>
<string name="action_trigger_usage">⚡ One-tap usage activation</string>
<string name="action_refresh_balance">Refresh balance</string>
<string name="action_relogin">Log in again</string>
<string name="action_configure_credential">Configure credential</string>
<string name="main_card_current_balance">Current balance</string>
<string name="main_card_5h_usage">5h live usage</string>
<string name="main_card_monthly_balance">Monthly balance</string>
<string name="main_card_session_usage">Session usage</string>
<string name="msg_please_select_model">Please select a model</string>
<string name="trigger_error_detail">Error details:</string>
<string name="trigger_api_response">API response:</string>
<string name="action_copy">Copy</string>
<string name="msg_key_length">*** (length %1$d) ***</string>
<string name="msg_key_masked">%1$s***%2$s (length %3$d)</string>
<string name="error_cookie_format">Bad cookie format, expected name1=value1; name2=value2</string>
<string name="msg_cookies_saved">%1$d cookies saved</string>
<string name="error_auth_json_empty">Please paste the auth.json content</string>
<string name="error_auth_json_missing_tokens">auth.json is missing access_token or refresh_token</string>
<string name="msg_codex_saved">Codex credential saved; token auto-refreshes when expiring</string>
<string name="error_auth_json_parse">Parse failed: %1$s</string>
<string name="msg_connect_success_dollar">Connected! Balance: $%1$s</string>
<string name="msg_api_key_saved">API Key saved</string>
<string name="msg_connect_success_session">Connected! Session: %1$s%% · %2$s</string>
<string name="msg_credential_deleted">Credential deleted</string>
<string name="error_clipboard_empty">Clipboard is empty</string>
<string name="msg_clipboard_ws_auth">Recognized workspaceId and auth cookie</string>
<string name="msg_clipboard_ws_only">Recognized workspaceId, please paste the auth cookie too</string>
<string name="msg_clipboard_auth_only">Recognized auth cookie, please paste the workspaceId too</string>
<string name="error_clipboard_none">No valid credential recognized</string>
<!-- ══════════ Codex OAuth 登录页 ══════════ -->
<string name="oauth_screen_title">Codex OAuth Login</string>
<string name="oauth_exchanging">Exchanging token...</string>
<string name="action_retry">Retry</string>
<string name="oauth_headless_title">Headless login</string>
<string name="oauth_steps">1. Tap "Open in browser" or copy the link below to a browser\n2. Complete login &amp; 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"</string>
<string name="oauth_auth_url_label">Authorization link</string>
<string name="action_open_in_browser">Open in browser</string>
<string name="action_copy_link">Copy link</string>
<string name="oauth_paste_callback_hint">── paste the callback URL after login ──</string>
<string name="oauth_callback_url_label">Callback URL</string>
<string name="oauth_callback_url_placeholder">http://localhost:1455/auth/callback?code=...</string>
<string name="action_submit_callback">Submit callback URL</string>
<string name="action_regenerate_link">Regenerate auth link</string>
<string name="oauth_preparing">Preparing authorization...</string>
</resources>

View File

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

View File

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