Merge pull request #4 from CATMIAOZHI/i18n-localization
i18n: 多语言支持(全量资源化 + 英文翻译)
This commit is contained in:
commit
6498e4cf71
1
.gitignore
vendored
1
.gitignore
vendored
@ -20,3 +20,4 @@ release.jks
|
||||
.kotlin/
|
||||
*.jks
|
||||
tmp_cc/
|
||||
goal.md
|
||||
|
||||
@ -1,5 +1,14 @@
|
||||
# 雨晴Token — AI 余额查询 APP
|
||||
|
||||
## ⚠️ 提交/推送许可红线(最高优先级)
|
||||
|
||||
> 本仓库的所有 Git 写操作(`git commit`、`git push`、创建/合并 PR、推送 tag 等)**必须事先获得水晴喵的明确同意**,未经允许一律禁止执行。
|
||||
>
|
||||
> - 任何代码修改完成后,只做本地编辑,**不得擅自 commit / push**。需要提交时,先向水晴喵说明改动内容与提交意图,等她说「可以提交」「push 吧」之类的明确许可后再执行。
|
||||
> - 不得以「PR 审查」「CI 验证」等理由绕过许可——创建 PR、推送分支也属于写操作,同样需要先征求同意。
|
||||
> - 水晴喵说「不用了」「先别提交」「再看看」时,保持当前状态,不 push 任何东西。
|
||||
> - 这条规则优先级高于文件中其它任何流程(包括「重大修改 PR 审计红线」),其它规则只决定**怎么提交**,这条决定**能不能提交**。
|
||||
|
||||
## 项目概述
|
||||
|
||||
Android(Jetpack Compose + Kotlin)APP,统一查询 DeepSeek、OpenCode Go、CommandCode Go、Codex / ChatGPT Plus、Ollama Pro 的余额/配额。
|
||||
|
||||
224
README.en.md
Normal file
224
README.en.md
Normal file
@ -0,0 +1,224 @@
|
||||
# RainyToken (雨晴Token)
|
||||
|
||||
> *"AI Balance & Usage at a Glance"*
|
||||
|
||||
[](https://github.com/CATMIAOZHI/Rainytoken/actions/workflows/ci.yml)
|
||||
[](https://github.com/CATMIAOZHI/Rainytoken/actions)
|
||||
[](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>
|
||||
11
README.md
11
README.md
@ -1,12 +1,12 @@
|
||||
# 🌧️ 雨晴Token (RainyToken)
|
||||
|
||||
> *"AI 用量,尽在掌握 — AI Balance & Usage at a Glance"* 🐱✨
|
||||
> *"AI 用量,尽在掌握 — AI Balance & Usage at a Glance"*
|
||||
|
||||
[](https://github.com/CATMIAOZHI/Rainytoken/actions/workflows/ci.yml)
|
||||
[](https://github.com/CATMIAOZHI/Rainytoken/actions)
|
||||
[](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>
|
||||
@ -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}"
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
/** 聚合工具 */
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
)
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
|
||||
44
app/src/main/java/com/rainy/token/ui/components/UiText.kt
Normal file
44
app/src/main/java/com/rainy/token/ui/components/UiText.kt
Normal 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
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
@ -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()) }
|
||||
|
||||
@ -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)) } }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
)
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@ -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
|
||||
)
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
98
app/src/main/java/com/rainy/token/util/LocaleManager.kt
Normal file
98
app/src/main/java/com/rainy/token/util/LocaleManager.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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" />
|
||||
|
||||
|
||||
5
app/src/main/res/resources.properties
Normal file
5
app/src/main/res/resources.properties
Normal file
@ -0,0 +1,5 @@
|
||||
# 默认(无 qualifier)资源对应的语言:英文(国际回退语言)
|
||||
# 非中文/英文系统语言会回退到默认英文资源;中文系统走 values-b+zh+Hans / values-b+zh+Hant
|
||||
# AGP 据此 + 中文资源目录生成 locale config,Android 13+ 系统设置中
|
||||
# 会显示 RainyToken 的独立语言选项(English / 简体中文 / 繁體中文)
|
||||
unqualifiedResLocale=en
|
||||
495
app/src/main/res/values-b+zh+Hans/strings.xml
Normal file
495
app/src/main/res/values-b+zh+Hans/strings.xml
Normal 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>
|
||||
495
app/src/main/res/values-b+zh+Hant/strings.xml
Normal file
495
app/src/main/res/values-b+zh+Hant/strings.xml
Normal 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>
|
||||
@ -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 & 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 & 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 & 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 & 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 & 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 & 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>
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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"))
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user