Compare commits
179 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 618eecf676 | |||
| 8207285b23 | |||
| 82bd04597d | |||
| 5850fc452c | |||
| c6a75f8fce | |||
| a384309689 | |||
| 8692014340 | |||
| 15c6bc24c4 | |||
| 460bed7279 | |||
| ecbc86fdf2 | |||
| 05b5107f46 | |||
| bccd7bad83 | |||
| c5120c5952 | |||
| 2c5a3b2055 | |||
| c76193e892 | |||
| 6edd68f80e | |||
| 750b9d9f90 | |||
| 0f4d68033e | |||
| 7c7a23f097 | |||
| 7c3809e879 | |||
| 7ad49e994d | |||
| 8f40360b9f | |||
| 08a3b42a82 | |||
| aab50bbe78 | |||
| faf7f40524 | |||
| 342788056b | |||
| b479cb2a7a | |||
| 6e0d67afb7 | |||
| 8d68c3b280 | |||
| 71f3f8d554 | |||
| dfd91a5355 | |||
| 149fc5f125 | |||
| 95cde916ca | |||
| 79f99f5dee | |||
| 2509ab8a85 | |||
| ceb5e6a268 | |||
| ffc06a38ca | |||
| 1791fa6436 | |||
| 0bed6d626a | |||
| 085a4d722f | |||
| 3b4cd103d0 | |||
| 78f93588f8 | |||
| dc3027417e | |||
| 100b698f38 | |||
| bc862df226 | |||
| 62ddf50638 | |||
| 8592f15663 | |||
| edc2a40dc1 | |||
| 56f85d296c | |||
| ce8a656448 | |||
| eebe9f6987 | |||
| 58d8b0e077 | |||
| 2aaa225139 | |||
| 80552af204 | |||
| 2da780bf09 | |||
| fd3d74cfef | |||
| e4cd9a36f6 | |||
|
|
3bef8cc3f1 | ||
|
|
777860024d | ||
|
|
2c38c4caf1 | ||
|
|
1439e8ca43 | ||
|
|
a079a0bfcb | ||
|
|
eecf54986f | ||
|
|
95508bab9f | ||
|
|
75c6ce9568 | ||
|
|
2b2ef941a9 | ||
|
|
24eb3e190c | ||
|
|
6cd54a3a8f | ||
|
|
f1ed643b80 | ||
|
|
1a1066076a | ||
|
|
bb309c92f0 | ||
|
|
dcc93714be | ||
|
|
43d8df90f6 | ||
|
|
7e44e545bf | ||
|
|
4ed6aaa3cc | ||
|
|
a0cf7fd31c | ||
|
|
99050554e2 | ||
|
|
266d33d131 | ||
|
|
025f971f7a | ||
|
|
03424e6184 | ||
|
|
bb1dd97874 | ||
|
|
83fc18317f | ||
|
|
962d8f1215 | ||
|
|
77cb664590 | ||
|
|
dd91e94805 | ||
|
|
6e4a5ff9cb | ||
|
|
b8479eebf3 | ||
|
|
7f2ca18d9d | ||
|
|
f203149f4f | ||
|
|
172d6b61aa | ||
|
|
488f08d083 | ||
|
|
8340ded070 | ||
|
|
acddaae068 | ||
|
|
e4ea61c0f2 | ||
|
|
6498e4cf71 | ||
|
|
041df0c240 | ||
|
|
7795b553d0 | ||
|
|
f788d57798 | ||
|
|
039f27da18 | ||
|
|
2e4e5ce449 | ||
|
|
f463751fb1 | ||
|
|
5e7942c5cc | ||
|
|
b51644048d | ||
|
|
4107954080 | ||
|
|
13acca37ed | ||
|
|
3a724d46fd | ||
|
|
6ac4d06e9f | ||
|
|
52b86e95e5 | ||
|
|
a26629027e | ||
|
|
f70b77f5cc | ||
|
|
728b3ad89e | ||
|
|
6a73155ed0 | ||
|
|
2732b138ae | ||
|
|
74a4e3d494 | ||
|
|
29877c00ce | ||
|
|
79edb053cc | ||
|
|
433770d285 | ||
|
|
360dde342f | ||
|
|
246f5dece3 | ||
|
|
b7639a733e | ||
|
|
5ead63a8a3 | ||
|
|
ea1cae597d | ||
|
|
81cdd00cac | ||
|
|
5178af91f6 | ||
|
|
0b140fe73e | ||
|
|
b4c3402d22 | ||
|
|
f17d37ae39 | ||
|
|
9d06644225 | ||
|
|
fbae6701b8 | ||
|
|
1a3fdff0c1 | ||
|
|
24d8db179e | ||
|
|
a980841a3b | ||
|
|
45e10ad063 | ||
|
|
06f28c9e57 | ||
|
|
9077605da6 | ||
|
|
75a5c8f81e | ||
|
|
c5505c896a | ||
|
|
5e630c8895 | ||
|
|
814ae61f01 | ||
|
|
86aaef9a60 | ||
|
|
2c21add8c3 | ||
|
|
6e629d8eb2 | ||
|
|
220a74492a | ||
|
|
f2e5eedaa4 | ||
|
|
ec5f2dcb5c | ||
|
|
c53266a16b | ||
|
|
14083f4be1 | ||
|
|
8469f01354 | ||
|
|
e3d21e0a8f | ||
|
|
262918d9be | ||
|
|
7cdb3d9b3c | ||
|
|
7ad1934474 | ||
|
|
d880b808d4 | ||
|
|
e0301aad1d | ||
|
|
f7662d065a | ||
|
|
65e4dc5a2f | ||
|
|
2926e8ebb2 | ||
|
|
e1c2774c86 | ||
|
|
fb2e60d4a0 | ||
|
|
47d361b527 | ||
|
|
9cfb962bc8 | ||
|
|
4e5dda5521 | ||
|
|
45c8f71b8a | ||
|
|
1d98d4dee1 | ||
|
|
23ae718327 | ||
|
|
a5ba79ea4b | ||
|
|
9a7c0284a1 | ||
|
|
4cbf593983 | ||
|
|
1d11aa8ed2 | ||
|
|
2ab5f2f342 | ||
|
|
ea9f6f7e72 | ||
|
|
26693c92d4 | ||
|
|
4a63db2840 | ||
|
|
0c5f0850c0 | ||
|
|
ef30cad3a4 | ||
|
|
cc7aecc331 | ||
|
|
bb51efde09 | ||
|
|
0f26269b7e | ||
|
|
d37589b89a |
@ -1,7 +0,0 @@
|
||||
fix: 图表模型颜色从8色扩展为12色 Material Design 500调色板
|
||||
|
||||
modelColors 从8色扩展到12色 MD500 色阶,解决11个模型颜色不够区分、
|
||||
后3个模型与前面颜色循环重复的问题。
|
||||
|
||||
UsageChartScreen.kt — modelColors: 8色 → 12色 (Red/Pink/Purple/Deep Purple/
|
||||
Indigo/Blue/Cyan/Teal/Green/Orange/Brown/Blue Grey)
|
||||
129
.github/workflows/ci.yml
vendored
Normal file
129
.github/workflows/ci.yml
vendored
Normal file
@ -0,0 +1,129 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Unit Tests & Lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
- uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
- uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
|
||||
with:
|
||||
packages: 'platforms;android-35 build-tools;35.0.0'
|
||||
|
||||
- name: Grant execute permission for gradlew
|
||||
run: chmod +x gradlew
|
||||
|
||||
- name: Run unit tests
|
||||
run: ./gradlew testDebugUnitTest
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: unit-test-results
|
||||
path: app/build/test-results/testDebugUnitTest/*.xml
|
||||
retention-days: 14
|
||||
|
||||
- name: Run lint
|
||||
run: ./gradlew lintDebug
|
||||
|
||||
- name: Upload lint results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: lint-results
|
||||
path: app/build/reports/lint-results-debug.*
|
||||
retention-days: 14
|
||||
|
||||
build-debug:
|
||||
name: Debug APK
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
- uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
- uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
|
||||
with:
|
||||
packages: 'platforms;android-35 build-tools;35.0.0'
|
||||
|
||||
- name: Grant execute permission for gradlew
|
||||
run: chmod +x gradlew
|
||||
|
||||
- name: Assemble Debug
|
||||
run: ./gradlew assembleDebug
|
||||
|
||||
- name: Upload Debug APK
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: debug-apk
|
||||
path: app/build/outputs/apk/debug/app-debug.apk
|
||||
retention-days: 14
|
||||
|
||||
build-release:
|
||||
name: Release APK (debug-signed)
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
- uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
- uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
|
||||
with:
|
||||
packages: 'platforms;android-35 build-tools;35.0.0'
|
||||
|
||||
- name: Grant execute permission for gradlew
|
||||
run: chmod +x gradlew
|
||||
|
||||
# 确保存在 debug keystore(某些 runner 镜像可能没有)
|
||||
- name: Ensure debug keystore
|
||||
run: |
|
||||
KS="$HOME/.android/debug.keystore"
|
||||
mkdir -p "$HOME/.android"
|
||||
if [ ! -f "$KS" ]; then
|
||||
keytool -genkeypair -v -keystore "$KS" -storepass android -alias androiddebugkey -keypass android -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=Android Debug,O=Android,C=US"
|
||||
echo "Generated debug keystore"
|
||||
else
|
||||
echo "Debug keystore exists"
|
||||
fi
|
||||
|
||||
# CI 无 release.jks;build.gradle.kts 自动 fallback 到 debug keystore
|
||||
# 只验证编译 + 资源完整性,签名由 Release workflow 用 secrets 完成
|
||||
- name: Assemble Release
|
||||
run: ./gradlew assembleRelease
|
||||
|
||||
- name: Verify Release APK
|
||||
run: |
|
||||
APK=app/build/outputs/apk/release/app-release.apk
|
||||
if [ ! -f "$APK" ]; then
|
||||
echo "❌ Release APK not found"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Release APK size: $(du -h "$APK" | cut -f1)"
|
||||
unzip -l "$APK" | grep -q "AndroidManifest.xml" || { echo "❌ AndroidManifest.xml missing"; exit 1; }
|
||||
unzip -l "$APK" | grep -q "resources.arsc" || { echo "❌ resources.arsc missing"; exit 1; }
|
||||
unzip -l "$APK" | grep -q "res/" || { echo "❌ res/ directory missing"; exit 1; }
|
||||
echo "✅ APK integrity verified: manifest + arsc + res/ all present"
|
||||
|
||||
- name: Upload Release APK
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: release-apk
|
||||
path: app/build/outputs/apk/release/app-release.apk
|
||||
retention-days: 14
|
||||
15
.github/workflows/release.yml
vendored
15
.github/workflows/release.yml
vendored
@ -10,24 +10,31 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
- uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
- uses: android-actions/setup-android@v3
|
||||
- uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
|
||||
with:
|
||||
packages: 'platforms;android-35 build-tools;35.0.0'
|
||||
- name: Decode Keystore
|
||||
run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > release.jks
|
||||
- name: Build Release APK
|
||||
env:
|
||||
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||
KEYSTORE_ALIAS: ${{ secrets.KEYSTORE_ALIAS }}
|
||||
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||||
run: |
|
||||
chmod +x gradlew
|
||||
./gradlew assembleRelease
|
||||
- name: Cleanup keystore (always run for security)
|
||||
run: rm -f release.jks
|
||||
if: always()
|
||||
- name: Rename & Release
|
||||
run: |
|
||||
cp app/build/outputs/apk/release/app-release.apk "RainyToken-${GITHUB_REF#refs/tags/}.apk"
|
||||
- uses: softprops/action-gh-release@v2
|
||||
- uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
|
||||
with:
|
||||
name: "🎉 RainyToken ${{ github.ref_name }}"
|
||||
files: RainyToken-${{ github.ref_name }}.apk
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@ -7,6 +7,7 @@
|
||||
/.idea/workspace.xml
|
||||
/.idea/navEditor.xml
|
||||
/.idea/assetWizardSettings.xml
|
||||
/.idea/
|
||||
.DS_Store
|
||||
/build
|
||||
app/build/
|
||||
@ -19,3 +20,6 @@ release.jks
|
||||
.operit/
|
||||
.kotlin/
|
||||
*.jks
|
||||
tmp_cc/
|
||||
goal.md
|
||||
/reference/
|
||||
|
||||
109
AGENTS.md
109
AGENTS.md
@ -1,108 +1,5 @@
|
||||
# 雨晴Token — AI 余额查询 APP
|
||||
任务开始前必须先读取 taste.md(协作偏好),任务全程遵守其中约定,包括:修改后派发独立 subagent 审计(无阻断问题 + 无影响用户体验问题),修复后用同一个 subagent 复审。
|
||||
|
||||
## 项目概述
|
||||
未经用户允许禁止提交推送
|
||||
|
||||
Android(Jetpack Compose + Kotlin)APP,统一查询 DeepSeek、OpenCode Go 两项服务的余额/配额。
|
||||
DeepSeek 走 REST API,OpenCode Go 通过 OkHttp 抓取 dashboard HTML 解析 SSR hydration 数据。
|
||||
APP 名为「雨晴Token」(粉色调品牌),配套桌面小组件。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Kotlin 100% · Jetpack Compose + Material 3
|
||||
- MVVM + Repository + `RefreshBalanceUseCase`(单一 UseCase,按 ServiceType 分发)
|
||||
- Hilt + KSP(DI)
|
||||
- Retrofit 2 + OkHttp 4 + Kotlinx Serialization
|
||||
- DataStore(本地缓存)+ Android Keystore(凭据加密,AES-256 GCM)
|
||||
- `minSdk=31`(Android 12+)
|
||||
- WorkManager(计划中)
|
||||
|
||||
## 当前实现状态
|
||||
|
||||
**服务**:
|
||||
- ✅ DeepSeek — REST API `GET /user/balance`,API Key 认证
|
||||
- ✅ OpenCode Go — OkHttp 抓 dashboard HTML,解析 `rollingUsage`/`weeklyUsage`/`monthlyUsage`
|
||||
- ❌ OpenCode Zen / 小米 MiMo — 未实现
|
||||
|
||||
**用量统计系统**:
|
||||
- ✅ `UsageCache`(DataStore,~3700 条记录)— 全量 JSON 序列化 + 内存缓存(`@Volatile cachedAll`),仅在写入后失效
|
||||
- ✅ `SyncUsageUseCase` — 首次全量同步(cursor 翻页)、增量同步(逐页比对本地 ID 集合)
|
||||
- ✅ `UsageViewModel` — `loadStatsInternal()` 单次 `getRecords()`→ 内存聚合 Overview/ModelStats/DailyStats,所有重操作包在 `withContext(Dispatchers.Default)` 避免主线程卡顿
|
||||
- ✅ `UsageChartViewModel` — 图表粒度(5h/24h/今天/昨天/7天/当月/自定义日/月/范围),模型多选,3 张 Canvas 图表
|
||||
- ✅ `UsageDataViewModel` — 原始记录分页浏览(20条/页),支持时间+模型筛选,页码输入跳转
|
||||
- ✅ 全局刷新绑定 — Dashboard 下拉刷新 → `DashboardViewModel.refresh()` → `UsageViewModel.sync()`(增量)
|
||||
|
||||
**页面导航**:
|
||||
```
|
||||
Dashboard → UsageDetail(图表) → UsageOverview(总统计)
|
||||
↘ UsageData(原始数据)
|
||||
```
|
||||
|
||||
**桌面小组件(Widget)**:
|
||||
- 显示 OpenCode Go 三个用量窗口(5h/本周/本月)+ 进度条 + 重置时间 + DeepSeek 余额
|
||||
- 右上角 ↻ 手动刷新按钮(后台广播 → `WidgetRefreshReceiver` → EntryPoints 获取 `RefreshBalanceUseCase`)
|
||||
- 进度条颜色按百分比动态变化(<50% 草莓粉 / 50-80% 暖橙 / >80% 玫红)
|
||||
- **MIUI Widget 适配**:`miuiWidget` 标识 → 可拖入负一屏;`miui.appwidget.action.APPWIDGET_UPDATE` 曝光刷新(划到即触发,20s 冷却);`@android:id/background` 根布局 ID(系统统一裁切圆角)
|
||||
- **自动刷新**:`onUpdate()` 内缓存为空或超过 5 分钟冷却时自动发送 `WidgetRefreshReceiver` 广播
|
||||
- **一键添桌面**:Dashboard 顶部栏 + 按钮 → `requestPinAppWidget`(有 fallback 到 `ACTION_APPWIDGET_PICK`)
|
||||
|
||||
## RemoteViews 兼容性红线
|
||||
|
||||
以下元素在 Widget 布局中**不可用**,会导致「载入出现问题」:
|
||||
|
||||
| ❌ 不可用 | ✅ 替代方案 |
|
||||
|-----------|------------|
|
||||
| `<Space>` | 透明 ProgressBar(`0dp + weight=1`) |
|
||||
| `<View>` | ProgressBar 或 TextView |
|
||||
| `<ImageView>` + 矢量 drawable | PNG(`drawable-nodpi`) |
|
||||
| `<TextView>` `0dp+weight=1` 空串 spacer | ProgressBar spacer |
|
||||
| `<TextView>` 固定 dp 宽度 + `gravity` | 仅固定 dp,不加 gravity |
|
||||
|
||||
## 关键命令
|
||||
|
||||
```bash
|
||||
cd /data/user/0/com.ai.assistance.operit/files/workspace/Rainytoken
|
||||
export ANDROID_HOME=$HOME/Android
|
||||
export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-arm64
|
||||
./gradlew assembleDebug
|
||||
# APK: app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
## 数据流
|
||||
|
||||
```
|
||||
DashboardViewModel.refresh()
|
||||
→ RefreshBalanceUseCase(service)
|
||||
→ DeepSeekRepository.fetchBalance() / OpenCodeGoRepository.fetchBalance()
|
||||
→ BalanceCache.put(service, result)
|
||||
→ OpenCodeGoWidgetProvider.notifyDataChanged(context)
|
||||
|
||||
Dashboard 下拉刷新 → usageSyncTrigger++ → UsageViewModel.sync()
|
||||
→ SyncUsageUseCase.fullSync() / incrementalSync()
|
||||
→ OpenCodeUsageRepository.fetchPage(cursor) 逐页抓取
|
||||
→ UsageCache.insertAll() → persist() → invalidateCache()
|
||||
→ UsageViewModel.loadStats() → getRecords() → 内存聚合
|
||||
|
||||
Widget 刷新按钮:
|
||||
↻ → PendingIntent.getBroadcast() → WidgetRefreshReceiver
|
||||
→ EntryPoints → RefreshBalanceUseCase(DEEPSEEK + OPENCODE_GO)
|
||||
→ notifyDataChanged()
|
||||
|
||||
MIUI 曝光刷新(用户划到负一屏/桌面):
|
||||
→ miui.appwidget.action.APPWIDGET_UPDATE → onReceive() → onUpdate()
|
||||
→ 读缓存渲染
|
||||
→ 缓存为空/过期? → sendBroadcast(WidgetRefreshReceiver)
|
||||
```
|
||||
|
||||
## 品牌色
|
||||
|
||||
| 用途 | 色值 |
|
||||
|------|------|
|
||||
| 主品牌草莓粉 | `#FF85A2` |
|
||||
| 樱粉背景/点缀 | `#FFD1DC` |
|
||||
| 浅粉背景 | `#FFF0F5` |
|
||||
| 深暖文字 | `#3D2C35` |
|
||||
| 暖灰辅助 | `#8A7A82` |
|
||||
| 玫红(>80% 警示) | `#E91E63` |
|
||||
| 暖橙(50-80%) | `#FFA726` |
|
||||
|
||||
深色模式下文字颜色由 `inkWarm()` / `inkMuted()` composable 自动切换(定义在 `Theme.kt`),静态资源通过 `drawable-night/` / `layout-night/` 适配。
|
||||
派发子代理任务(独立审计/探索/通用 subagent)时,prompt 中涉及工作区文件的所有路径必须使用完整绝对路径(以工作区根目录为前缀)。子代理没有工作区附着上下文,相对路径会导致其找不到文件或无法执行。
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Rainy
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
238
README.en.md
Normal file
238
README.en.md
Normal file
@ -0,0 +1,238 @@
|
||||
# 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, Codex / ChatGPT, and Ollama 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 model call counts" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<em>Dashboard (light) · Dashboard (dark) · Usage charts · Ollama 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 card" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<em>Home screen widget · Ollama home card</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/heatmap.jpg" width="200" alt="Token Activity" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<em>Token Activity</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 |
|
||||
| 🗓️ **Token Activity** | OCGO daily token heatmap (GitHub contribution-graph style): daily / weekly / cumulative views + year switching (last 365 days by default, or a calendar year); personal profile + activity insights (total requests / top 3 request hours) + yearly stats (total / peak / streaks); 6-level pink quantile scale, tap / slide to inspect |
|
||||
| 📱 **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 / last 30 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 · Refined Rose primary #C9436C · Blush white background #FDEFF4 |
|
||||
| 🔐 **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 API Key, Codex (OAuth sign-in or pasted auth.json), or an Ollama Cookie to fetch data.
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ Android App │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Compose UI (3-layer screens) │ │
|
||||
│ │ Dashboard · Usage Charts · Totals │ │
|
||||
│ │ Detail · Token Activity · 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
|
||||
│ │ ├── heatmap/ # Token Activity heatmap (HeatmapScreen / HeatmapCanvas / HeatmapViewModel)
|
||||
│ │ ├── servicedetail/ # Service detail (ServiceDetailScreen)
|
||||
│ │ ├── settings/ # Settings / credential editing (SettingsScreen / CredentialEditScreen)
|
||||
│ │ ├── webview/ # Web login / Codex OAuth (WebViewLoginScreen / CodexOAuthScreen)
|
||||
│ │ ├── 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>
|
||||
115
README.md
115
README.md
@ -1,11 +1,44 @@
|
||||
# 🌧️ 雨晴Token (RainyToken)
|
||||
|
||||
> *"AI 用量,尽在掌握"* 🐱✨
|
||||
> *"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、Codex / ChatGPT、Ollama 的余额与用量配额。粉色调品牌 UI,配套桌面小组件。
|
||||
RainyToken(雨晴Token)— AI Balance & Usage Quota Query · the Rainy Family tools.
|
||||
|
||||
Android AI 余额查询 APP —— 统一查看 DeepSeek 和 OpenCode Go 的余额与用量配额。粉色调品牌 UI,配套桌面小组件。
|
||||
---
|
||||
|
||||
## 📸 截图
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/dashboard-light.jpg" width="200" alt="仪表盘(亮色)" />
|
||||
<img src="docs/screenshots/dashboard-dark.jpg" width="200" alt="仪表盘(深色)" />
|
||||
<img src="docs/screenshots/detail.jpg" width="200" alt="用量图表" />
|
||||
<img src="docs/screenshots/ollama-detail.jpg" width="200" alt="Ollama 模型调用次数" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<em>仪表盘(亮色) · 仪表盘(深色) · 用量图表 · Ollama 模型调用次数</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/widget.jpg" width="300" alt="桌面小组件" />
|
||||
<img src="docs/screenshots/ollama-card.jpg" width="200" alt="Ollama 卡片" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<em>桌面小组件 · Ollama 首页卡片</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/heatmap.jpg" width="200" alt="Token 活动" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<em>Token 活动</em>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
@ -13,17 +46,25 @@ Android AI 余额查询 APP —— 统一查看 DeepSeek 和 OpenCode Go 的余
|
||||
|
||||
| 特性 | 说明 |
|
||||
|------|------|
|
||||
| 📊 **仪表盘** | DeepSeek 余额(¥)+ OpenCode Go 用量(5h/本周/本月)+ 下拉全局刷新 |
|
||||
| 📈 **用量图表** | 3 张 Canvas 手绘图表 — 消耗金额 / API 请求次数 / Token 消耗 |
|
||||
| 📊 **仪表盘** | DeepSeek 余额(¥)+ 各服务用量/余额卡片;OCGO/CCGO 卡片可直达用量详情;长按拖动自由排序(持久化);下拉全局刷新 · 平板自适应双窗格布局 |
|
||||
| 📈 **用量图表** | 3 张 Canvas 手绘图表 — 消耗金额 / API 请求次数 / Token 消耗(OCGO & CCGO 双数据源);支持 UTC+0/UTC+8 时区切换和自定义日/月/范围;自动降级(近5h无数据→12h→7天→当月);平板并排展示 |
|
||||
| 🗓️ **Token活动** | OCGO每日 Token热力图(GitHub贡献图风格):每日 /每周 /累计三视图 +年份切换(默认最近 365 天,可切换自然年);个人资料 +活动洞察(总请求次数 /最多请求时段 Top 3)+年度统计(累计 /峰值 /连续天数);6 级粉色分位色阶,点击 /滑动查看 |
|
||||
| 📱 **平板适配** | 全局 `BoxWithConstraints` 自适应容器宽度;≥600dp 卡片双列,≥700dp 图表并排;双窗格 35/65 左右分栏(Expanded 模式);支持 Android 13+ 预见性返回手势 |
|
||||
| 📋 **详细数据** | 原始记录分页浏览,支持时间 + 模型筛选,点击查看完整字段 |
|
||||
| 🔍 **多粒度筛选** | 5小时 / 24小时 / 今天 / 昨天 / 最近7天 / 当月 / 自定义日·月·范围 |
|
||||
| 🔍 **多粒度筛选** | 5小时 / 12小时(10分钟桶) / 24小时 / 今天 / 昨天 / 最近7天 / 最近30天 / 当月 / 自定义日·月·范围 |
|
||||
| 🏷️ **模型筛选** | 多选 / 单选 / 全选,动态图例自适应换行 |
|
||||
| 📱 **桌面小组件** | 不打开 APP 也能看用量;可拖入负一屏;划到即自动刷新(MIUI 曝光刷新) |
|
||||
| 🔄 **自动同步** | 首页下拉自动同步用量;无缓存时启动自动全量同步 |
|
||||
| 📱 **桌面小组件** | 不打开 APP 也能看用量;支持四服务切换(OCGO/CCGO/Codex/Ollama)+ DeepSeek 余额;左上角 > 进 APP、其他区域点切换、↻ 刷新;可拖入负一屏;划到即自动刷新(MIUI 曝光刷新) |
|
||||
| 🔄 **自动同步** | 首页下拉自动同步用量;无缓存时启动自动全量同步;CCGO 详情页支持手动清除并重新同步 |
|
||||
| 🌙 **深色模式** | 全局自适应 — App 内文字/图标/背景自动切换,小组件独立适配暗色布局 |
|
||||
| ➕ **一键添桌面** | APP 内点 + 直接添加小组件,不用去系统列表翻 |
|
||||
| ⚡ **内存缓存** | DataStore 全量 JSON 仅反序列化一次,后续操作零 IO |
|
||||
| 🎀 **雨晴粉主题** | Material Design 3 · 草莓粉 #FF85A2 · 樱粉 #FFD1DC |
|
||||
| ➕ **一键添桌面** | APP 内点 + 直接添加小组件,不用去系统列表翻;二次确认 + 权限检测 |
|
||||
| 💡 **使用小技巧** | 首页随机展示一条操作提示(每次启动刷新);设置页可查看全部 13 条隐藏操作技巧 |
|
||||
| ⚡ **Room 数据库** | 用量记录存 Room(indexed on workspaceId+timeCreated),DAO 查询替代全量 JSON 序列化;首次启动自动从旧 DataStore 迁移 |
|
||||
| 🎀 **雨晴粉主题** | Material Design 3 · 精致玫瑰粉 主色 #C9436C · 粉白背景 #FDEFF4 |
|
||||
| 🔐 **Codex OAuth 登录** | 无头模式 OAuth PKCE:APP 生成授权链接 → 外部浏览器登录 → 粘贴回调 URL 完成授权,无需手动导出 auth.json |
|
||||
| ⚡ **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 名称与小组件跟随语言 |
|
||||
|
||||
---
|
||||
|
||||
@ -31,7 +72,7 @@ Android AI 余额查询 APP —— 统一查看 DeepSeek 和 OpenCode Go 的余
|
||||
|
||||
前往 [Releases](https://github.com/CATMIAOZHI/Rainytoken/releases) 下载最新 APK。
|
||||
|
||||
> ⚠️ 需要配置 DeepSeek API Key 和/或 OpenCode Go 登录凭据才能拉取数据。
|
||||
> ⚠️ 需要配置 DeepSeek API Key、OpenCode Go 登录凭据、CommandCode API Key、Codex(OAuth 登录或粘贴 auth.json)或 Ollama Cookie 才能拉取数据。
|
||||
|
||||
---
|
||||
|
||||
@ -43,7 +84,8 @@ Android AI 余额查询 APP —— 统一查看 DeepSeek 和 OpenCode Go 的余
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ Compose UI(3 层页面) │ │
|
||||
│ │ 仪表盘 · 用量图表 · 总统计 · 详细数据 · 设置│ │
|
||||
│ │ 仪表盘 · 用量图表 · 总统计 │ │
|
||||
│ │ 详细数据 · Token活动 · 设置 │ │
|
||||
│ └────────────────────┬────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌────────────────────▼─────────────────────────┐ │
|
||||
@ -55,19 +97,19 @@ Android AI 余额查询 APP —— 统一查看 DeepSeek 和 OpenCode Go 的余
|
||||
│ ┌────────────────────▼─────────────────────────┐ │
|
||||
│ │ UseCase 层 │ │
|
||||
│ │ RefreshBalanceUseCase(余额) │ │
|
||||
│ │ SyncUsageUseCase(用量全量/增量同步) │ │
|
||||
│ │ SyncUsageUseCase / SyncCommandCodeUsageUseCase│ │
|
||||
│ └────────────────────┬────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌────────────────────▼─────────────────────────┐ │
|
||||
│ │ Repository + Network │ │
|
||||
│ │ DeepSeekApi(Retrofit)· OpenCodeGo 网页抓取 │ │
|
||||
│ │ · OpenCodeUsageRepository(分页翻页) │ │
|
||||
│ │ DeepSeekApi(Retrofit)· OpenCodeGo 网页抓取
|
||||
│ │ · OpenCodeUsageRepository · CommandCodeUsageRepository · CodexRepository · OllamaRepository(OkHttp)│ │
|
||||
│ └────────────────────┬────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌────────────────────▼─────────────────────────┐ │
|
||||
│ │ 本地存储 │ │
|
||||
│ │ BalanceCache(DataStore) │ │
|
||||
│ │ UsageCache(DataStore + @Volatile 内存缓存) │ │
|
||||
│ │ UsageCache(Room,indexed on workspaceId+timeCreated) │ │
|
||||
│ │ CredentialRepository(Keystore AES-256 GCM) │ │
|
||||
│ └──────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────┘
|
||||
@ -82,15 +124,19 @@ Rainytoken/
|
||||
├── app/src/main/java/com/rainy/token/
|
||||
│ ├── data/
|
||||
│ │ ├── cache/ # BalanceCache(DataStore)
|
||||
│ │ ├── local/ # UsageCache(DataStore + 内存缓存)、UsageRecord、ChartBucket
|
||||
│ │ ├── remote/ # DeepSeekApi(Retrofit)+ OpenCodeGo 抓取 + OpenCodeUsageRepository
|
||||
│ │ └── repository/ # DeepSeekRepository / OpenCodeGoRepository / CredentialRepository
|
||||
│ │ ├── local/ # UsageCache(Room)、UsageRecordEntity、UsageDao、UsageDatabase、ChartBucket
|
||||
│ │ ├── remote/ # DeepSeekApi(Retrofit)+ OpenCodeGo 抓取 + UsageRepository
|
||||
│ │ └── repository/ # DeepSeek / OpenCodeGo / CommandCode / Codex / Ollama / Credential Repository
|
||||
│ ├── domain/
|
||||
│ │ ├── model/ # ServiceBalance、Credential 等
|
||||
│ │ ├── service/ # ServiceType 枚举
|
||||
│ │ └── usecase/ # RefreshBalanceUseCase / SyncUsageUseCase
|
||||
│ │ └── usecase/ # RefreshBalanceUseCase / SyncUsageUseCase / SyncCommandCodeUsageUseCase
|
||||
│ ├── ui/
|
||||
│ │ ├── dashboard/ # DashboardScreen / UsageDetailScreen(图表)/ UsageOverviewScreen(总统计)/ UsageDataScreen(原始数据)
|
||||
│ │ ├── dashboard/ # DashboardScreen / UsageDetailScreen / UsageOverviewScreen / UsageDataScreen
|
||||
│ │ ├── heatmap/ # Token活动热力图(HeatmapScreen / HeatmapCanvas / HeatmapViewModel)
|
||||
│ │ ├── servicedetail/ # 服务详情(ServiceDetailScreen)
|
||||
│ │ ├── settings/ # 设置 /凭据编辑(SettingsScreen / CredentialEditScreen)
|
||||
│ │ ├── webview/ # 网页登录 / Codex OAuth(WebViewLoginScreen / CodexOAuthScreen)
|
||||
│ │ ├── widget/ # 桌面小组件(OpenCodeGoWidgetProvider)
|
||||
│ │ ├── components/ # ServiceIcon / StatusChip 等
|
||||
│ │ ├── theme/ # 雨晴粉主题(StrawberryPink / InkMuted)
|
||||
@ -112,6 +158,9 @@ Rainytoken/
|
||||
|
||||
### 方式二:命令行
|
||||
```bash
|
||||
# 运行单元测试
|
||||
./gradlew testDebugUnitTest
|
||||
|
||||
# 构建 Debug APK
|
||||
./gradlew assembleDebug
|
||||
# APK 输出:app/build/outputs/apk/debug/app-debug.apk
|
||||
@ -129,6 +178,12 @@ chmod +x ./setup_android_env.sh
|
||||
./setup_android_env.sh
|
||||
```
|
||||
该脚本会配置 `$ANDROID_HOME` 并使用项目内置的 ARM64 build-tools。
|
||||
|
||||
**Release 构建额外配置**:ARM64 Proot 下 AGP 9.0 的 `optimizeReleaseResources` 传入 `--resource-path-shortening-map=<path>` 等号参数,ARM64 AAPT2 不接受此语法,需在 `~/.gradle/gradle.properties`(全局,不提交项目仓库)中配置 AAPT2 wrapper:
|
||||
```properties
|
||||
android.aapt2FromMavenOverride=/path/to/android-aapt2-wrapper/aapt2
|
||||
```
|
||||
wrapper 脚本将等号形式拆分为空格分隔的两个独立 argv。项目 `build.gradle.kts` 中已内置 `guardReleaseResources` 任务作为构建防护(optimized `.ap_` 缺失时自动 fallback 到 linked `.ap_`)。
|
||||
> 在 x86_64 环境(GitHub Actions / 普通 Linux)中会自动走官方 AAPT2,无需额外操作。
|
||||
</details>
|
||||
|
||||
@ -145,7 +200,7 @@ chmod +x ./setup_android_env.sh
|
||||
| `com.squareup.retrofit2:retrofit` | DeepSeek REST API |
|
||||
| `com.squareup.okhttp3:okhttp` | OpenCode Go 网页抓取 |
|
||||
| `org.jetbrains.kotlinx:kotlinx-serialization-json` | JSON 序列化 |
|
||||
| `org.jsoup:jsoup` | HTML 解析(SSR hydration 数据提取) |
|
||||
| `androidx.room:room-runtime` | 用量数据本地数据库(Room) |
|
||||
| `androidx.datastore:datastore-preferences` | 本地缓存 |
|
||||
| `com.google.dagger:hilt-android` | 依赖注入 |
|
||||
| `com.google.devtools.ksp:symbol-processing-api` | KSP 注解处理 |
|
||||
@ -156,20 +211,22 @@ chmod +x ./setup_android_env.sh
|
||||
|
||||
- ✅ API Key / Session 凭据存入 **Android Keystore**(AES-256 GCM 加密)
|
||||
- ✅ 网络请求仅向 DeepSeek / OpenCode 官方 API 发出
|
||||
- ✅ `allowBackup="false"`,拒绝应用数据被备份
|
||||
- ✅ `allowBackup=false`:凭据密文、用量数据均不参与系统备份,避免换机恢复后密文无法解密
|
||||
- ✅ 签名密钥固定,每次 Release 可覆盖安装
|
||||
- ✅ GitHub Secrets 加密存储签名密钥,CI 中解码使用
|
||||
- ✅ GitHub Secrets 加密存储签名密钥及密码,CI 中解码使用
|
||||
|
||||
---
|
||||
|
||||
## 🐱 关于
|
||||
|
||||
RainyToken 由 [雨晴喵](https://github.com/CATMIAOZHI) 与水晴共同打造,属于「雨晴系列」工具之一:
|
||||
RainyToken(雨晴Token)是「雨晴系列」的第 4 个成员,由 [雨晴喵](https://github.com/CATMIAOZHI) 开发维护,随 [雨晴系列](https://github.com/CATMIAOZHI?tab=repositories) 发布:
|
||||
|
||||
- [RainyLLM](https://github.com/CATMIAOZHI/RainyLLM) — 手机本地 LLM 推理服务器(Gemma + OpenAI 兼容 API)
|
||||
- [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 余额用量查询(本项目)
|
||||
- [Rainy2FA](https://github.com/CATMIAOZHI/Rainy2FA) — 纯本地 · 零联网 · 生物识别保护的 TOTP 验证器
|
||||
- **RainyToken** — AI 余额与用量查询 APP | AI Balance & Usage Quota Query(本项目)
|
||||
|
||||
> 守护每一分 AI 算力预算 💖
|
||||
|
||||
---
|
||||
|
||||
@ -179,4 +236,4 @@ MIT License © 2026 Rainy
|
||||
|
||||
---
|
||||
|
||||
<p align="center">💖 Made with love by 雨晴喵</p>
|
||||
<p align="center">RainyToken · the Rainy Family tools</p>
|
||||
@ -1,3 +1,5 @@
|
||||
import java.io.File
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
@ -14,8 +16,8 @@ android {
|
||||
applicationId = "com.rainy.token"
|
||||
minSdk = 31
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
versionCode = 14
|
||||
versionName = "1.6.4"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
@ -25,10 +27,40 @@ android {
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
storeFile = rootProject.file("release.jks")
|
||||
storePassword = System.getenv("KEYSTORE_PASSWORD") ?: "RainyToken2026!"
|
||||
keyAlias = System.getenv("KEYSTORE_ALIAS") ?: "rainy"
|
||||
keyPassword = System.getenv("KEY_PASSWORD") ?: "RainyToken2026!"
|
||||
// CI 环境没有 release.jks,自动 fallback 到 debug keystore
|
||||
// Release workflow 通过 Secret 注入 release.jks
|
||||
val keystoreFile = rootProject.file("release.jks")
|
||||
if (keystoreFile.exists()) {
|
||||
storeFile = keystoreFile
|
||||
// 凭据必须通过环境变量注入;缺失或为空时直接报错,不提供任何隐式 fallback
|
||||
// 注意:GitHub Actions 中未设置的 secret 会被替换为空字符串(非 null),
|
||||
// 因此用 takeIf { isNotBlank() } 同时防御 null 和空字符串
|
||||
storePassword = System.getenv("KEYSTORE_PASSWORD")
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: throw GradleException("KEYSTORE_PASSWORD env var not set or empty — cannot sign release")
|
||||
keyAlias = System.getenv("KEYSTORE_ALIAS")
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: throw GradleException("KEYSTORE_ALIAS env var not set or empty — cannot sign release")
|
||||
keyPassword = System.getenv("KEY_PASSWORD")
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: throw GradleException("KEY_PASSWORD env var not set or empty — cannot sign release")
|
||||
} else {
|
||||
// 仅在 CI 环境中 fallback 到 debug keystore(用于编译/资源完整性验证)
|
||||
// 本地构建缺少 release.jks 时直接报错,避免静默生成 debug 签名的 Release APK
|
||||
if (System.getenv("CI") != null) {
|
||||
val debugKeystore = file("${System.getProperty("user.home")}/.android/debug.keystore")
|
||||
storeFile = debugKeystore
|
||||
storePassword = "android"
|
||||
keyAlias = "androiddebugkey"
|
||||
keyPassword = "android"
|
||||
} else {
|
||||
throw GradleException(
|
||||
"release.jks 不存在,且当前不是 CI 环境。\n" +
|
||||
"正式 Release 构建需要 release.jks 密钥库文件。\n" +
|
||||
"如需本地验证编译,请设置环境变量 CI=true 或使用 assembleDebug。"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -45,12 +77,18 @@ android {
|
||||
)
|
||||
}
|
||||
}
|
||||
// noCompress workaround removed — no longer needed
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
androidResources {
|
||||
// Android 13+ 应用级语言设置:根据 values-* 目录自动生成 locales_config
|
||||
generateLocaleConfig = true
|
||||
}
|
||||
packaging {
|
||||
resources {
|
||||
@ -59,6 +97,84 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// AGP 9.0 ARM64 Proot: Release resource guard + fallback
|
||||
//
|
||||
// 根因:AGP 9.0 的 optimizeReleaseResources 传入 --resource-path-shortening-map=<path>
|
||||
// 等号形式,ARM64 AAPT2 不接受此语法(只接受空格分隔),exit code=1 但 AGP 不检查,
|
||||
// 导致 optimized .ap_ 缺失,packageRelease 生成无资源残缺 APK。
|
||||
//
|
||||
// 修复链:
|
||||
// 1. ~/.gradle/gradle.properties → android.aapt2FromMavenOverride 指向 wrapper
|
||||
// wrapper 将 --resource-path-shortening-map=<path> 拆分为两个独立 argv
|
||||
// 2. 本任务作为构建防护:optimizeReleaseResources 之后验证 optimized .ap_ 完整性,
|
||||
// 若缺失则复制 linked .ap_ 作为 fallback(功能正确,仅跳过 path shortening)
|
||||
// 3. packageRelease 依赖此任务,确保消费到完整资源
|
||||
// ═══════════════════════════════════════════════════════
|
||||
if (System.getProperty("os.arch") == "aarch64") {
|
||||
tasks.register("guardReleaseResources") {
|
||||
dependsOn("optimizeReleaseResources")
|
||||
doLast {
|
||||
val linkedAp = layout.buildDirectory
|
||||
.file("intermediates/linked_resources_binary_format/release/processReleaseResources/linked-resources-binary-format-release.ap_")
|
||||
.get().asFile
|
||||
val optimizedDir = layout.buildDirectory
|
||||
.dir("intermediates/optimized_processed_res/release/optimizeReleaseResources")
|
||||
.get().asFile
|
||||
val optimizedAp = File(optimizedDir, "resources-release-optimize.ap_")
|
||||
|
||||
if (optimizedAp.exists() && optimizedAp.length() > 0) {
|
||||
// optimized .ap_ 存在,验证内容完整性
|
||||
val zipList = providers.exec {
|
||||
commandLine("unzip", "-l", optimizedAp.absolutePath)
|
||||
isIgnoreExitValue = true
|
||||
}.standardOutput.asText.get()
|
||||
|
||||
val hasManifest = zipList.contains("AndroidManifest.xml")
|
||||
val hasArsc = zipList.contains("resources.arsc")
|
||||
val hasRes = zipList.contains("res/")
|
||||
|
||||
if (hasManifest && hasArsc && hasRes) {
|
||||
logger.lifecycle("GuardReleaseResources: optimized .ap_ OK (${optimizedAp.length()} bytes)")
|
||||
return@doLast
|
||||
}
|
||||
logger.warn("GuardReleaseResources: optimized .ap_ incomplete (manifest=$hasManifest arsc=$hasArsc res=$hasRes)")
|
||||
} else {
|
||||
logger.warn("GuardReleaseResources: optimized .ap_ missing or empty")
|
||||
}
|
||||
|
||||
// Fallback: copy linked .ap_ → optimized .ap_
|
||||
if (!linkedAp.exists() || linkedAp.length() == 0L) {
|
||||
throw GradleException("GuardReleaseResources: linked .ap_ also missing or empty — cannot recover")
|
||||
}
|
||||
|
||||
optimizedDir.mkdirs()
|
||||
linkedAp.copyTo(optimizedAp, overwrite = true)
|
||||
|
||||
// Verify the copy
|
||||
val verifyList = providers.exec {
|
||||
commandLine("unzip", "-l", optimizedAp.absolutePath)
|
||||
isIgnoreExitValue = true
|
||||
}.standardOutput.asText.get()
|
||||
|
||||
val vManifest = verifyList.contains("AndroidManifest.xml")
|
||||
val vArsc = verifyList.contains("resources.arsc")
|
||||
val vRes = verifyList.contains("res/")
|
||||
|
||||
if (!vManifest || !vArsc || !vRes) {
|
||||
throw GradleException("GuardReleaseResources: fallback copy verification failed (manifest=$vManifest arsc=$vArsc res=$vRes)")
|
||||
}
|
||||
|
||||
logger.lifecycle("GuardReleaseResources: fallback — copied linked .ap_ → optimized .ap_ (${optimizedAp.length()} bytes, path shortening skipped)")
|
||||
}
|
||||
}
|
||||
|
||||
// packageRelease 必须依赖此防护任务
|
||||
project.tasks.matching { it.name == "packageRelease" }.configureEach {
|
||||
dependsOn("guardReleaseResources")
|
||||
}
|
||||
}
|
||||
|
||||
// Force ARM64 AAPT2 in Proot environment (local only; GitHub Actions x86_64 uses default)
|
||||
if (System.getProperty("os.arch") == "aarch64") {
|
||||
configurations.all {
|
||||
@ -74,6 +190,7 @@ dependencies {
|
||||
|
||||
// Core
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidsvg)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
@ -85,6 +202,7 @@ dependencies {
|
||||
implementation(libs.androidx.ui.graphics)
|
||||
implementation(libs.androidx.ui.tooling.preview)
|
||||
implementation(libs.androidx.material3)
|
||||
implementation(libs.androidx.material3.window.size)
|
||||
|
||||
// Navigation
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
@ -95,7 +213,6 @@ dependencies {
|
||||
implementation(libs.okhttp.logging.interceptor)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.retrofit.kotlinx.serialization.converter)
|
||||
implementation(libs.jsoup)
|
||||
|
||||
// DataStore
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
@ -108,6 +225,11 @@ dependencies {
|
||||
ksp(libs.hilt.compiler)
|
||||
implementation(libs.androidx.hilt.navigation.compose)
|
||||
|
||||
// Room
|
||||
implementation(libs.room.runtime)
|
||||
implementation(libs.room.ktx)
|
||||
ksp(libs.room.compiler)
|
||||
|
||||
// Test
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
|
||||
@ -1,24 +0,0 @@
|
||||
package com.java.myapplication
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExampleInstrumentedTest {
|
||||
@Test
|
||||
fun useAppContext() {
|
||||
// Context of the app under test.
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("com.java.myapplication", appContext.packageName)
|
||||
}
|
||||
}
|
||||
@ -3,12 +3,13 @@
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="com.android.launcher.permission.INSTALL_SHORTCUT" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
<application
|
||||
android:name=".RainyTokenApplication"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:allowBackup="false"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
@ -33,6 +34,15 @@
|
||||
android:name=".ui.widget.WidgetRefreshReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- 每日自动签到:零点闹钟广播 + 开机后排程(Hilt 经 EntryPoint 注入,无 @AndroidEntryPoint) -->
|
||||
<receiver
|
||||
android:name=".ui.dashboard.autocheckin.AutoCheckinReceiver"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- OpenCode Go 小组件(MIUI Widget) -->
|
||||
<receiver
|
||||
android:name=".ui.widget.OpenCodeGoWidgetProvider"
|
||||
@ -66,6 +76,16 @@
|
||||
android:name="miuiWidgetVersion"
|
||||
android:value="1" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@ -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
|
||||
@ -14,12 +15,17 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||
*
|
||||
* 布局策略:
|
||||
* - 外层只套 RainyTokenTheme(统一品牌色 + 字体)
|
||||
* - 再套 RainyBackground(樱粉渐变背景,全局共享)
|
||||
* - 再套 RainyBackground(精致粉调渐变背景,全局共享)
|
||||
* - NavHost 在背景之上,每个页面自己用 Scaffold 处理 TopAppBar 和 padding
|
||||
* - **不**在外层再套 Scaffold —— 避免双 Scaffold 嵌套的 padding 计算混乱
|
||||
*/
|
||||
@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
|
||||
|
||||
@ -6,8 +6,10 @@ import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.rainy.token.data.repository.RefreshWriteSession
|
||||
import com.rainy.token.domain.model.ServiceBalance
|
||||
import com.rainy.token.domain.service.ServiceType
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.serialization.builtins.MapSerializer
|
||||
@ -19,48 +21,204 @@ import kotlinx.serialization.json.Json
|
||||
*
|
||||
* 计划 7.1:断网时 UI 仍可展示 stale 数据。DataStore 文件名 `balance_cache`。
|
||||
*
|
||||
* 存储结构:JSON 序列化的 `Map<ServiceType, CachedBalance>`。
|
||||
* 存储结构:
|
||||
* - `balance_cache_v3`:JSON 序列化的 `Map<ServiceType, CachedBalance>`,供单账号服务
|
||||
* (以及多账号服务的默认账号镜像,兼容旧读取路径)。
|
||||
* - `balance_cache_accounts_v1`:JSON 序列化的 `Map<String, CachedBalance>`,复合键
|
||||
* `"{service.storageKey}:{accountId}"`,存放多账号服务每个账号的独立缓存。
|
||||
*/
|
||||
class BalanceCache(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
private val json: Json = DefaultJson
|
||||
) {
|
||||
|
||||
// v2: 2026-06 重构 extras 字段名(rollingUsagePercent → rolling.pct 等)。
|
||||
// 升 v2 让旧版缓存失效,避免显示 0%。
|
||||
private val cacheKey = stringPreferencesKey("balance_cache_v2")
|
||||
// v3: 2026-07 统一 Codex amount 为 usedPct(之前是 remainingPct),旧缓存失效
|
||||
private val cacheKey = stringPreferencesKey("balance_cache_v3")
|
||||
|
||||
// v1: 2026-10 多账号服务按账号独立缓存
|
||||
private val accountsCacheKey = stringPreferencesKey("balance_cache_accounts_v1")
|
||||
|
||||
suspend fun getAll(): Map<ServiceType, CachedBalance> {
|
||||
val raw = dataStore.data.map { it[cacheKey] }.first() ?: return emptyMap()
|
||||
return runCatching {
|
||||
val map = json.decodeFromString(
|
||||
MapSerializer(ServiceType.serializer(), CachedBalance.serializer()),
|
||||
raw
|
||||
)
|
||||
map
|
||||
}.getOrDefault(emptyMap())
|
||||
return decode(raw)
|
||||
}
|
||||
|
||||
suspend fun get(service: ServiceType): CachedBalance? = getAll()[service]
|
||||
|
||||
suspend fun put(service: ServiceType, balance: ServiceBalance) {
|
||||
val current = getAll().toMutableMap()
|
||||
current[service] = CachedBalance(balance = balance, fetchedAt = System.currentTimeMillis())
|
||||
persist(current)
|
||||
/** 读取缓存:无 [accountId] 时读旧按服务缓存;指定账号时读账号分片缓存。 */
|
||||
suspend fun get(service: ServiceType, accountId: String?): CachedBalance? =
|
||||
if (accountId.isNullOrBlank()) {
|
||||
getAll()[service]
|
||||
} else {
|
||||
getAllAccounts()[accountKey(service, accountId)]
|
||||
}
|
||||
|
||||
/** 所有按账号分片的缓存,复合键 `"{service.storageKey}:{accountId}"`。 */
|
||||
suspend fun getAllAccounts(): Map<String, CachedBalance> {
|
||||
val raw = dataStore.data.map { it[accountsCacheKey] }.first() ?: return emptyMap()
|
||||
return decodeAccounts(raw)
|
||||
}
|
||||
|
||||
/** 读取指定账号的缓存(不含默认账号的旧镜像回退,由调用方处理)。 */
|
||||
suspend fun getAccountCached(service: ServiceType, accountId: String): CachedBalance? =
|
||||
getAllAccounts()[accountKey(service, accountId)]
|
||||
|
||||
/**
|
||||
* 写入余额缓存。
|
||||
*
|
||||
* - [accountId] 为 null:写入旧的按服务缓存(单账号服务 / 多账号默认账号镜像)。
|
||||
* - [accountId] 非 null:写入按账号缓存。
|
||||
* 在 [RefreshWriteSession] 上下文中一律只暂存,由会话提交时按快照账号路由。
|
||||
*/
|
||||
suspend fun put(
|
||||
service: ServiceType,
|
||||
balance: ServiceBalance,
|
||||
accountId: String? = null
|
||||
) {
|
||||
currentCoroutineContext()[RefreshWriteSession]?.let { session ->
|
||||
session.stageBalance(service, balance)
|
||||
return
|
||||
}
|
||||
val cached = CachedBalance(balance = balance, fetchedAt = System.currentTimeMillis())
|
||||
if (accountId.isNullOrBlank()) {
|
||||
putCached(service, cached)
|
||||
} else {
|
||||
putAccountCached(service, accountId, cached)
|
||||
}
|
||||
}
|
||||
|
||||
/** 恢复一份已有缓存并保留原 fetchedAt;仅供凭据测试安全回滚使用。 */
|
||||
internal suspend fun putCached(service: ServiceType, cachedBalance: CachedBalance) {
|
||||
dataStore.edit { prefs ->
|
||||
val updated = decode(prefs[cacheKey]).toMutableMap()
|
||||
updated[service] = cachedBalance
|
||||
prefs[cacheKey] = encode(updated)
|
||||
}
|
||||
}
|
||||
|
||||
/** 以账号复合键写入缓存(保留原 fetchedAt)。 */
|
||||
internal suspend fun putAccountCached(
|
||||
service: ServiceType,
|
||||
accountId: String,
|
||||
cachedBalance: CachedBalance
|
||||
) {
|
||||
val key = accountKey(service, accountId)
|
||||
dataStore.edit { prefs ->
|
||||
val updated = decodeAccounts(prefs[accountsCacheKey]).toMutableMap()
|
||||
updated[key] = cachedBalance
|
||||
prefs[accountsCacheKey] = encodeAccounts(updated)
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除单个服务的旧结构缓存。凭据新增、替换或删除时由 CredentialRepository 调用。 */
|
||||
suspend fun remove(service: ServiceType) {
|
||||
dataStore.edit { prefs ->
|
||||
var changed = false
|
||||
val current = decode(prefs[cacheKey])
|
||||
if (service in current) {
|
||||
val updated = current.toMutableMap()
|
||||
updated.remove(service)
|
||||
if (updated.isEmpty()) {
|
||||
prefs.remove(cacheKey)
|
||||
} else {
|
||||
prefs[cacheKey] = encode(updated)
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
// 顺带清理该服务全部账号缓存(多账号服务删除整个服务时)
|
||||
val accounts = decodeAccounts(prefs[accountsCacheKey])
|
||||
val prefix = service.storageKey + ":"
|
||||
val kept = accounts.filterKeys { !it.startsWith(prefix) }
|
||||
if (kept.size != accounts.size) {
|
||||
if (kept.isEmpty()) {
|
||||
prefs.remove(accountsCacheKey)
|
||||
} else {
|
||||
prefs[accountsCacheKey] = encodeAccounts(kept)
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
if (!changed) return@edit
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅删除旧结构镜像缓存([balance_cache_v3] 中该服务的条目),不动账号分片缓存。
|
||||
*
|
||||
* 多账号服务切换/删除默认账号时调用:旧镜像属于旧默认账号,若不清除,新默认账号
|
||||
* 在尚无独立分片缓存时会回退读到旧默认账号的余额(缓存串号)。
|
||||
*/
|
||||
suspend fun removeLegacy(service: ServiceType) {
|
||||
dataStore.edit { prefs ->
|
||||
val current = decode(prefs[cacheKey])
|
||||
if (service !in current) return@edit
|
||||
val updated = current.toMutableMap()
|
||||
updated.remove(service)
|
||||
if (updated.isEmpty()) {
|
||||
prefs.remove(cacheKey)
|
||||
} else {
|
||||
prefs[cacheKey] = encode(updated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除单个账号的缓存(默认账号的旧结构镜像由调用方另行清理)。 */
|
||||
suspend fun removeAccount(service: ServiceType, accountId: String) {
|
||||
val key = accountKey(service, accountId)
|
||||
dataStore.edit { prefs ->
|
||||
val current = decodeAccounts(prefs[accountsCacheKey])
|
||||
if (key !in current) return@edit
|
||||
val updated = current.toMutableMap()
|
||||
updated.remove(key)
|
||||
if (updated.isEmpty()) {
|
||||
prefs.remove(accountsCacheKey)
|
||||
} else {
|
||||
prefs[accountsCacheKey] = encodeAccounts(updated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clear() {
|
||||
dataStore.edit { it.remove(cacheKey) }
|
||||
dataStore.edit {
|
||||
it.remove(cacheKey)
|
||||
it.remove(accountsCacheKey)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun persist(map: Map<ServiceType, CachedBalance>) {
|
||||
val raw = json.encodeToString(
|
||||
MapSerializer(ServiceType.serializer(), CachedBalance.serializer()),
|
||||
map
|
||||
)
|
||||
dataStore.edit { it[cacheKey] = raw }
|
||||
private fun accountKey(service: ServiceType, accountId: String) =
|
||||
"${service.storageKey}:$accountId"
|
||||
|
||||
private fun decode(raw: String?): Map<ServiceType, CachedBalance> {
|
||||
if (raw == null) return emptyMap()
|
||||
return runCatching {
|
||||
json.decodeFromString(
|
||||
MapSerializer(ServiceType.serializer(), CachedBalance.serializer()),
|
||||
raw
|
||||
)
|
||||
}.getOrDefault(emptyMap())
|
||||
}
|
||||
|
||||
private fun encode(value: Map<ServiceType, CachedBalance>): String =
|
||||
json.encodeToString(
|
||||
MapSerializer(ServiceType.serializer(), CachedBalance.serializer()),
|
||||
value
|
||||
)
|
||||
|
||||
private fun decodeAccounts(raw: String?): Map<String, CachedBalance> {
|
||||
if (raw == null) return emptyMap()
|
||||
return runCatching {
|
||||
json.decodeFromString(
|
||||
MapSerializer(String.serializer(), CachedBalance.serializer()),
|
||||
raw
|
||||
)
|
||||
}.getOrDefault(emptyMap())
|
||||
}
|
||||
|
||||
private fun encodeAccounts(value: Map<String, CachedBalance>): String =
|
||||
json.encodeToString(
|
||||
MapSerializer(String.serializer(), CachedBalance.serializer()),
|
||||
value
|
||||
)
|
||||
|
||||
companion object {
|
||||
val DefaultJson = Json {
|
||||
ignoreUnknownKeys = true
|
||||
|
||||
51
app/src/main/java/com/rainy/token/data/debug/DebugLog.kt
Normal file
51
app/src/main/java/com/rainy/token/data/debug/DebugLog.kt
Normal file
@ -0,0 +1,51 @@
|
||||
package com.rainy.token.data.debug
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.ConcurrentLinkedDeque
|
||||
|
||||
/**
|
||||
* APP 内调试日志,内存 ring buffer(默认 200 条)。
|
||||
* 各 Repository 在关键路径写入,用户可在设置页 → 调试日志 查看。
|
||||
*/
|
||||
object DebugLog {
|
||||
|
||||
enum class Level(val label: String) { INFO("INFO"), WARN("WARN"), ERROR("ERROR") }
|
||||
|
||||
data class Entry(
|
||||
val timestamp: Long,
|
||||
val tag: String,
|
||||
val level: Level,
|
||||
val message: String
|
||||
) {
|
||||
override fun toString(): String {
|
||||
val fmt = SimpleDateFormat("MM-dd HH:mm:ss.SSS", Locale.US)
|
||||
return "${fmt.format(Calendar.getInstance().apply { timeInMillis = this@Entry.timestamp }.time)} ${level.label}/$tag: $message"
|
||||
}
|
||||
}
|
||||
|
||||
private const val MAX_SIZE = 200
|
||||
private val deque = ConcurrentLinkedDeque<Entry>()
|
||||
private val _entries = MutableStateFlow<List<Entry>>(emptyList())
|
||||
val entries: StateFlow<List<Entry>> = _entries.asStateFlow()
|
||||
|
||||
fun log(tag: String, level: Level, message: String) {
|
||||
val entry = Entry(System.currentTimeMillis(), tag, level, message)
|
||||
deque.addFirst(entry)
|
||||
while (deque.size > MAX_SIZE) deque.pollLast()
|
||||
_entries.value = deque.toList()
|
||||
}
|
||||
|
||||
fun i(tag: String, message: String) = log(tag, Level.INFO, message)
|
||||
fun w(tag: String, message: String) = log(tag, Level.WARN, message)
|
||||
fun e(tag: String, message: String) = log(tag, Level.ERROR, message)
|
||||
|
||||
fun clear() {
|
||||
deque.clear()
|
||||
_entries.value = emptyList()
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,10 @@
|
||||
package com.rainy.token.data.local
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import com.rainy.token.R
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
|
||||
/**
|
||||
* 图表数据点——按时间桶聚合后的单条数据。
|
||||
*/
|
||||
@ -22,40 +27,78 @@ data class ModelBucket(
|
||||
val outputTokens: Long
|
||||
)
|
||||
|
||||
/** 时间粒度 */
|
||||
enum class ChartGranularity(val label: String) {
|
||||
LAST_5H_HOURLY("最近5小时"),
|
||||
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),
|
||||
LAST_30D_DAILY(R.string.time_last_30d),
|
||||
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)
|
||||
}
|
||||
|
||||
/** 聚合工具 */
|
||||
object ChartAggregator {
|
||||
|
||||
/**
|
||||
* 按小时聚合(用于5小时/当天)。
|
||||
* hourStart = timeCreated / 3600_000 * 3600_000
|
||||
* 按 10 分钟聚合(用于最近12小时)。
|
||||
* @param offsetHours 时区偏移。
|
||||
*/
|
||||
fun aggregateHourly(records: List<UsageRecord>): List<ChartBucket> {
|
||||
fun aggregate10Min(records: List<UsageRecord>, offsetHours: Int = 0): List<ChartBucket> {
|
||||
val offset = if (offsetHours == 0) ZoneOffset.UTC else ZoneOffset.ofHours(offsetHours)
|
||||
return records
|
||||
.groupBy { it.timeCreated / 3600_000L * 3600_000L }
|
||||
.groupBy {
|
||||
val ldt = Instant.ofEpochMilli(it.timeCreated).atOffset(offset).toLocalDateTime()
|
||||
val min = ldt.minute / 10 * 10
|
||||
ldt.withMinute(min).withSecond(0).withNano(0).atOffset(offset).toInstant().toEpochMilli()
|
||||
}
|
||||
.map { (ts, recs) -> bucketOf(ts, recs) }
|
||||
.sortedBy { it.ts }
|
||||
}
|
||||
|
||||
/**
|
||||
* 按小时聚合。
|
||||
* @param offsetHours 时区偏移(0=UTC,8=UTC+8),影响桶边界对齐。
|
||||
*/
|
||||
fun aggregateHourly(records: List<UsageRecord>, offsetHours: Int = 0): List<ChartBucket> {
|
||||
if (offsetHours == 0) {
|
||||
return records
|
||||
.groupBy { it.timeCreated / 3600_000L * 3600_000L }
|
||||
.map { (hourTs, recs) -> bucketOf(hourTs, recs) }
|
||||
.sortedBy { it.ts }
|
||||
}
|
||||
val offset = ZoneOffset.ofHours(offsetHours)
|
||||
return records
|
||||
.groupBy {
|
||||
val ldt = Instant.ofEpochMilli(it.timeCreated).atOffset(offset).toLocalDateTime()
|
||||
ldt.withMinute(0).withSecond(0).withNano(0).atOffset(offset).toInstant().toEpochMilli()
|
||||
}
|
||||
.map { (hourTs, recs) -> bucketOf(hourTs, recs) }
|
||||
.sortedBy { it.ts }
|
||||
}
|
||||
|
||||
/**
|
||||
* 按天聚合(用于当月/自定义月)。
|
||||
* dayStart = timeCreated / 86400_000 * 86400_000
|
||||
* @param offsetHours 时区偏移(0=UTC,8=UTC+8),影响桶边界对齐。
|
||||
*/
|
||||
fun aggregateDaily(records: List<UsageRecord>): List<ChartBucket> {
|
||||
fun aggregateDaily(records: List<UsageRecord>, offsetHours: Int = 0): List<ChartBucket> {
|
||||
if (offsetHours == 0) {
|
||||
return records
|
||||
.groupBy { it.timeCreated / 86_400_000L * 86_400_000L }
|
||||
.map { (dayTs, recs) -> bucketOf(dayTs, recs) }
|
||||
.sortedBy { it.ts }
|
||||
}
|
||||
val offset = ZoneOffset.ofHours(offsetHours)
|
||||
return records
|
||||
.groupBy { it.timeCreated / 86_400_000L * 86_400_000L }
|
||||
.groupBy {
|
||||
val localDate = Instant.ofEpochMilli(it.timeCreated).atOffset(offset).toLocalDate()
|
||||
localDate.atStartOfDay(offset).toInstant().toEpochMilli()
|
||||
}
|
||||
.map { (dayTs, recs) -> bucketOf(dayTs, recs) }
|
||||
.sortedBy { it.ts }
|
||||
}
|
||||
|
||||
@ -0,0 +1,47 @@
|
||||
package com.rainy.token.data.local
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
||||
private val Context.chartSettingsDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||
name = "chart_settings"
|
||||
)
|
||||
|
||||
private val chartSettingsScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
/** 图表页偏好设置持久化 */
|
||||
class ChartSettingsStore(private val dataStore: DataStore<Preferences>) {
|
||||
|
||||
companion object {
|
||||
private val KEY_USE_UTC8 = booleanPreferencesKey("use_utc8")
|
||||
}
|
||||
|
||||
/** 流式读取偏好 */
|
||||
val useUtc8Flow: Flow<Boolean> = dataStore.data.map { prefs ->
|
||||
prefs[KEY_USE_UTC8] ?: false
|
||||
}
|
||||
|
||||
/** StateFlow 版本,UI 层 collectAsState 直接使用 */
|
||||
val useUtc8State: StateFlow<Boolean> = useUtc8Flow
|
||||
.stateIn(chartSettingsScope, kotlinx.coroutines.flow.SharingStarted.Eagerly, false)
|
||||
|
||||
/** 写入偏好 */
|
||||
suspend fun setUseUtc8(value: Boolean) {
|
||||
dataStore.edit { it[KEY_USE_UTC8] = value }
|
||||
}
|
||||
}
|
||||
|
||||
/** 顶层 DataStore 委托 */
|
||||
val Context.chartSettingsStore: ChartSettingsStore
|
||||
get() = ChartSettingsStore(chartSettingsDataStore)
|
||||
@ -3,81 +3,87 @@ package com.rainy.token.data.local
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* 用量记录本地缓存(DataStore,非 Room)。
|
||||
* 用量记录本地缓存(Room 实现)。
|
||||
*
|
||||
* 所有记录序列化为 JSON 列表存入 DataStore。
|
||||
* 约 3700 条记录,每条约 300 字节 → ~1.1MB,DataStore 可承受。
|
||||
* 接口与旧 DataStore 版本完全一致,内部改为 Room DAO 查询。
|
||||
* 首次访问时自动从旧 DataStore JSON 迁移数据到 Room。
|
||||
*/
|
||||
class UsageCache(
|
||||
private val context: Context,
|
||||
private val dao: UsageDao,
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
private val json: Json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
|
||||
) {
|
||||
private val cacheKey = stringPreferencesKey("usage_cache_v1")
|
||||
private val migrationMutex = Mutex()
|
||||
@Volatile private var migrated = false
|
||||
|
||||
// ── 内存缓存:避免每次都反序列化整个 JSON ──
|
||||
@Volatile private var cachedAll: List<UsageRecord>? = null
|
||||
@Volatile private var cachedIds: Set<String>? = null
|
||||
|
||||
/** 读取全量(优先内存缓存) */
|
||||
private suspend fun loadAll(): List<UsageRecord> {
|
||||
cachedAll?.let { return it }
|
||||
val records = readFromStore()
|
||||
cachedAll = records
|
||||
cachedIds = records.map { it.id }.toSet()
|
||||
return records
|
||||
}
|
||||
|
||||
private suspend fun readFromStore(): List<UsageRecord> {
|
||||
val raw = dataStore.data.map { it[cacheKey] }.first() ?: return emptyList()
|
||||
return runCatching {
|
||||
json.decodeFromString(ListSerializer(UsageRecord.serializer()), raw)
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
/** 写入后清空内存缓存 */
|
||||
private fun invalidateCache() {
|
||||
cachedAll = null
|
||||
cachedIds = null
|
||||
}
|
||||
|
||||
suspend fun getAll(): List<UsageRecord> = loadAll()
|
||||
|
||||
fun getAllFlow(): Flow<List<UsageRecord>> =
|
||||
dataStore.data.map { prefs ->
|
||||
val raw = prefs[cacheKey] ?: return@map emptyList()
|
||||
runCatching {
|
||||
json.decodeFromString(ListSerializer(UsageRecord.serializer()), raw)
|
||||
}.getOrDefault(emptyList())
|
||||
/** 确保旧数据已迁移到 Room(仅执行一次) */
|
||||
private suspend fun ensureMigrated() {
|
||||
if (migrated) return
|
||||
migrationMutex.withLock {
|
||||
if (migrated) return
|
||||
migrateDataStoreToRoom(context, dataStore, dao, json)
|
||||
migrated = true
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getAll(): List<UsageRecord> {
|
||||
ensureMigrated()
|
||||
return dao.getAll().map { it.toDomain() }
|
||||
}
|
||||
|
||||
fun getAllFlow(): Flow<List<UsageRecord>> = flow {
|
||||
ensureMigrated()
|
||||
emit(dao.getAll().map { it.toDomain() })
|
||||
}
|
||||
|
||||
suspend fun insertAll(newRecords: List<UsageRecord>) {
|
||||
val current = loadAll().toMutableList()
|
||||
val existingIds = cachedIds ?: current.map { it.id }.toSet()
|
||||
val toAdd = newRecords.filter { it.id !in existingIds }
|
||||
if (toAdd.isEmpty()) return
|
||||
current.addAll(toAdd)
|
||||
persist(current)
|
||||
ensureMigrated()
|
||||
if (newRecords.isEmpty()) return
|
||||
val entities = newRecords.map { it.toEntity() }
|
||||
dao.insertAll(entities)
|
||||
}
|
||||
|
||||
suspend fun getLatest(): UsageRecord? = loadAll().maxByOrNull { it.timeCreated }
|
||||
suspend fun getLatest(): UsageRecord? {
|
||||
ensureMigrated()
|
||||
return dao.getLatest()?.toDomain()
|
||||
}
|
||||
|
||||
suspend fun getAllIds(): Set<String> {
|
||||
cachedIds?.let { return it }
|
||||
return loadAll().map { it.id }.toSet()
|
||||
ensureMigrated()
|
||||
return dao.getAllIds().toSet()
|
||||
}
|
||||
|
||||
suspend fun count(): Int = loadAll().size
|
||||
/** 按 workspaceId 获取已有记录 ID 集合(增量同步用) */
|
||||
suspend fun getIdsByWorkspace(workspaceId: String): Set<String> {
|
||||
ensureMigrated()
|
||||
return dao.getIdsByWorkspace(workspaceId).toSet()
|
||||
}
|
||||
|
||||
/** 按 workspaceId 删除所有记录。用于修复旧数据格式问题后重新全量同步。 */
|
||||
suspend fun deleteByWorkspaceId(workspaceId: String) {
|
||||
ensureMigrated()
|
||||
dao.deleteByWorkspaceId(workspaceId)
|
||||
}
|
||||
|
||||
suspend fun count(): Int {
|
||||
ensureMigrated()
|
||||
return dao.count()
|
||||
}
|
||||
|
||||
/** 按 workspaceId 统计记录数。 */
|
||||
suspend fun count(workspaceId: String): Int {
|
||||
ensureMigrated()
|
||||
return dao.countByWorkspace(workspaceId)
|
||||
}
|
||||
|
||||
/** 获取过滤后的原始记录列表(供图表等聚合使用) */
|
||||
suspend fun getRecords(
|
||||
@ -85,24 +91,20 @@ class UsageCache(
|
||||
fromTs: Long? = null,
|
||||
toTs: Long? = null
|
||||
): List<UsageRecord> {
|
||||
return filterByTime(loadAll().filter { it.workspaceId == workspaceId }, fromTs, toTs)
|
||||
ensureMigrated()
|
||||
val entities = when {
|
||||
fromTs != null && toTs != null -> dao.getByWorkspaceAndTime(workspaceId, fromTs, toTs)
|
||||
fromTs != null -> dao.getByWorkspaceFrom(workspaceId, fromTs)
|
||||
toTs != null -> dao.getByWorkspaceTo(workspaceId, toTs)
|
||||
else -> dao.getByWorkspace(workspaceId)
|
||||
}
|
||||
return entities.map { it.toDomain() }
|
||||
}
|
||||
|
||||
/** 获取所有不同模型名称 */
|
||||
suspend fun getDistinctModels(workspaceId: String): List<String> {
|
||||
return loadAll()
|
||||
.filter { it.workspaceId == workspaceId }
|
||||
.map { it.model }
|
||||
.distinct()
|
||||
.sorted()
|
||||
}
|
||||
|
||||
/** 按时间范围过滤记录 */
|
||||
private fun filterByTime(records: List<UsageRecord>, fromTs: Long?, toTs: Long?): List<UsageRecord> {
|
||||
var result = records
|
||||
if (fromTs != null) result = result.filter { it.timeCreated >= fromTs }
|
||||
if (toTs != null) result = result.filter { it.timeCreated <= toTs }
|
||||
return result
|
||||
ensureMigrated()
|
||||
return dao.getDistinctModels(workspaceId)
|
||||
}
|
||||
|
||||
suspend fun getStatsByModel(
|
||||
@ -110,14 +112,15 @@ class UsageCache(
|
||||
fromTs: Long? = null,
|
||||
toTs: Long? = null
|
||||
): List<ModelStats> {
|
||||
return filterByTime(loadAll().filter { it.workspaceId == workspaceId }, fromTs, toTs)
|
||||
val records = getRecords(workspaceId, fromTs, toTs)
|
||||
return records
|
||||
.groupBy { it.model }
|
||||
.map { (model, records) ->
|
||||
.map { (model, recs) ->
|
||||
ModelStats(
|
||||
model = model,
|
||||
totalTokens = records.sumOf { it.totalTokens },
|
||||
totalCost = records.sumOf { it.cost },
|
||||
count = records.size
|
||||
totalTokens = recs.sumOf { it.totalTokens },
|
||||
totalCost = recs.sumOf { it.cost },
|
||||
count = recs.size
|
||||
)
|
||||
}
|
||||
.sortedByDescending { it.totalTokens }
|
||||
@ -129,14 +132,15 @@ class UsageCache(
|
||||
fromTs: Long? = null,
|
||||
toTs: Long? = null
|
||||
): List<DailyStats> {
|
||||
return filterByTime(loadAll().filter { it.workspaceId == workspaceId }, fromTs, toTs)
|
||||
val records = getRecords(workspaceId, fromTs, toTs)
|
||||
return records
|
||||
.groupBy { it.timeCreated / 86_400_000L * 86_400_000L }
|
||||
.map { (dayTs, records) ->
|
||||
.map { (dayTs, recs) ->
|
||||
DailyStats(
|
||||
dayTs = dayTs,
|
||||
totalTokens = records.sumOf { it.totalTokens },
|
||||
totalCost = records.sumOf { it.cost },
|
||||
count = records.size
|
||||
totalTokens = recs.sumOf { it.totalTokens },
|
||||
totalCost = recs.sumOf { it.cost },
|
||||
count = recs.size
|
||||
)
|
||||
}
|
||||
.sortedByDescending { it.dayTs }
|
||||
@ -148,7 +152,7 @@ class UsageCache(
|
||||
fromTs: Long? = null,
|
||||
toTs: Long? = null
|
||||
): OverviewStats? {
|
||||
val records = filterByTime(loadAll().filter { it.workspaceId == workspaceId }, fromTs, toTs)
|
||||
val records = getRecords(workspaceId, fromTs, toTs)
|
||||
if (records.isEmpty()) return null
|
||||
return OverviewStats(
|
||||
totalTokens = records.sumOf { it.inputTokens + it.cacheReadTokens + it.outputTokens },
|
||||
@ -163,12 +167,6 @@ class UsageCache(
|
||||
cacheWrite1hTokens = records.sumOf { it.cacheWrite1hTokens }
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun persist(list: List<UsageRecord>) {
|
||||
val raw = json.encodeToString(ListSerializer(UsageRecord.serializer()), list)
|
||||
dataStore.edit { it[cacheKey] = raw }
|
||||
invalidateCache()
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@ -224,7 +222,7 @@ data class OverviewStats(
|
||||
val cacheWriteTokens: Long get() = cacheWrite5mTokens + cacheWrite1hTokens
|
||||
}
|
||||
|
||||
/** DataStore 委托 */
|
||||
val Context.usageCacheDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||
/** DataStore 委托 — 保留用于数据迁移,迁移完成后旧 key 会被清除 */
|
||||
val Context.usageCacheDataStore: DataStore<Preferences> by androidx.datastore.preferences.preferencesDataStore(
|
||||
name = "usage_cache"
|
||||
)
|
||||
52
app/src/main/java/com/rainy/token/data/local/UsageDao.kt
Normal file
52
app/src/main/java/com/rainy/token/data/local/UsageDao.kt
Normal file
@ -0,0 +1,52 @@
|
||||
package com.rainy.token.data.local
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
|
||||
@Dao
|
||||
interface UsageDao {
|
||||
|
||||
@Query("SELECT * FROM usage_records")
|
||||
suspend fun getAll(): List<UsageRecordEntity>
|
||||
|
||||
@Query("SELECT * FROM usage_records WHERE workspaceId = :workspaceId ORDER BY timeCreated DESC")
|
||||
suspend fun getByWorkspace(workspaceId: String): List<UsageRecordEntity>
|
||||
|
||||
@Query("SELECT * FROM usage_records WHERE workspaceId = :workspaceId AND timeCreated >= :fromTs AND timeCreated <= :toTs ORDER BY timeCreated DESC")
|
||||
suspend fun getByWorkspaceAndTime(workspaceId: String, fromTs: Long, toTs: Long): List<UsageRecordEntity>
|
||||
|
||||
@Query("SELECT * FROM usage_records WHERE workspaceId = :workspaceId AND timeCreated >= :fromTs ORDER BY timeCreated DESC")
|
||||
suspend fun getByWorkspaceFrom(workspaceId: String, fromTs: Long): List<UsageRecordEntity>
|
||||
|
||||
@Query("SELECT * FROM usage_records WHERE workspaceId = :workspaceId AND timeCreated <= :toTs ORDER BY timeCreated DESC")
|
||||
suspend fun getByWorkspaceTo(workspaceId: String, toTs: Long): List<UsageRecordEntity>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
suspend fun insertAll(records: List<UsageRecordEntity>): List<Long>
|
||||
|
||||
@Query("DELETE FROM usage_records WHERE workspaceId = :workspaceId")
|
||||
suspend fun deleteByWorkspaceId(workspaceId: String): Int
|
||||
|
||||
@Query("SELECT COUNT(*) FROM usage_records")
|
||||
suspend fun count(): Int
|
||||
|
||||
@Query("SELECT COUNT(*) FROM usage_records WHERE workspaceId = :workspaceId")
|
||||
suspend fun countByWorkspace(workspaceId: String): Int
|
||||
|
||||
@Query("SELECT DISTINCT model FROM usage_records WHERE workspaceId = :workspaceId ORDER BY model ASC")
|
||||
suspend fun getDistinctModels(workspaceId: String): List<String>
|
||||
|
||||
@Query("SELECT * FROM usage_records ORDER BY timeCreated DESC LIMIT 1")
|
||||
suspend fun getLatest(): UsageRecordEntity?
|
||||
|
||||
@Query("SELECT id FROM usage_records")
|
||||
suspend fun getAllIds(): List<String>
|
||||
|
||||
@Query("SELECT id FROM usage_records WHERE workspaceId = :workspaceId")
|
||||
suspend fun getIdsByWorkspace(workspaceId: String): List<String>
|
||||
|
||||
@Query("SELECT MAX(timeCreated) FROM usage_records WHERE workspaceId = :workspaceId")
|
||||
suspend fun getMaxTimeCreated(workspaceId: String): Long?
|
||||
}
|
||||
@ -0,0 +1,80 @@
|
||||
package com.rainy.token.data.local
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
@Database(
|
||||
entities = [UsageRecordEntity::class],
|
||||
version = 1,
|
||||
exportSchema = false
|
||||
)
|
||||
abstract class UsageDatabase : RoomDatabase() {
|
||||
abstract fun usageDao(): UsageDao
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var INSTANCE: UsageDatabase? = null
|
||||
|
||||
fun getInstance(context: Context): UsageDatabase {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
UsageDatabase::class.java,
|
||||
"usage_database"
|
||||
).build().also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time migration from old DataStore JSON cache to Room.
|
||||
*
|
||||
* Called by [UsageCache] on first access. Reads the old JSON blob from DataStore,
|
||||
* deserializes it, inserts into Room, then clears the old key.
|
||||
*
|
||||
* A SharedPreferences flag prevents re-running if the old data was already consumed
|
||||
* (e.g. if the DataStore was cleared but the flag was set).
|
||||
*
|
||||
* @return true if migration ran and inserted records, false if no migration was needed.
|
||||
*/
|
||||
suspend fun migrateDataStoreToRoom(
|
||||
context: Context,
|
||||
dataStore: DataStore<Preferences>,
|
||||
dao: UsageDao,
|
||||
json: Json
|
||||
): Boolean {
|
||||
val prefs = context.getSharedPreferences("usage_cache_migration", Context.MODE_PRIVATE)
|
||||
if (prefs.getBoolean("migrated_to_room", false)) return false
|
||||
|
||||
val cacheKey = stringPreferencesKey("usage_cache_v1")
|
||||
val raw = dataStore.data.map { it[cacheKey] }.first() ?: run {
|
||||
// No old data — mark as migrated so we never check again
|
||||
prefs.edit().putBoolean("migrated_to_room", true).apply()
|
||||
return false
|
||||
}
|
||||
|
||||
val records = runCatching {
|
||||
json.decodeFromString(ListSerializer(UsageRecord.serializer()), raw)
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
if (records.isNotEmpty()) {
|
||||
val entities = records.map { it.toEntity() }
|
||||
dao.insertAll(entities)
|
||||
}
|
||||
|
||||
// Mark migrated and clear old key to free space
|
||||
prefs.edit().putBoolean("migrated_to_room", true).apply()
|
||||
dataStore.edit { it.remove(cacheKey) }
|
||||
return records.isNotEmpty()
|
||||
}
|
||||
@ -0,0 +1,81 @@
|
||||
package com.rainy.token.data.local
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/**
|
||||
* Room Entity for usage records.
|
||||
*
|
||||
* Mirrors the fields of [UsageRecord] exactly.
|
||||
* Indexes on workspaceId + timeCreated for fast time-range queries.
|
||||
*/
|
||||
@Entity(
|
||||
tableName = "usage_records",
|
||||
indices = [
|
||||
Index(value = ["workspaceId", "timeCreated"]),
|
||||
Index(value = ["workspaceId", "model"]),
|
||||
Index(value = ["id"], unique = true)
|
||||
]
|
||||
)
|
||||
data class UsageRecordEntity(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
val workspaceId: String,
|
||||
val timeCreated: Long,
|
||||
val timeUpdated: Long,
|
||||
val model: String,
|
||||
val provider: String,
|
||||
val inputTokens: Long,
|
||||
val outputTokens: Long,
|
||||
val reasoningTokens: Long,
|
||||
val cacheReadTokens: Long,
|
||||
val cacheWrite5mTokens: Long = 0,
|
||||
val cacheWrite1hTokens: Long = 0,
|
||||
val cost: Long,
|
||||
val keyId: String,
|
||||
val sessionId: String,
|
||||
val enrichmentPlan: String = ""
|
||||
) {
|
||||
/** Convert Entity → domain model */
|
||||
fun toDomain(): UsageRecord = UsageRecord(
|
||||
id = id,
|
||||
workspaceId = workspaceId,
|
||||
timeCreated = timeCreated,
|
||||
timeUpdated = timeUpdated,
|
||||
model = model,
|
||||
provider = provider,
|
||||
inputTokens = inputTokens,
|
||||
outputTokens = outputTokens,
|
||||
reasoningTokens = reasoningTokens,
|
||||
cacheReadTokens = cacheReadTokens,
|
||||
cacheWrite5mTokens = cacheWrite5mTokens,
|
||||
cacheWrite1hTokens = cacheWrite1hTokens,
|
||||
cost = cost,
|
||||
keyId = keyId,
|
||||
sessionId = sessionId,
|
||||
enrichmentPlan = enrichmentPlan
|
||||
)
|
||||
|
||||
val totalTokens: Long get() = inputTokens + outputTokens + reasoningTokens
|
||||
}
|
||||
|
||||
/** Convert domain model → Entity */
|
||||
fun UsageRecord.toEntity(): UsageRecordEntity = UsageRecordEntity(
|
||||
id = id,
|
||||
workspaceId = workspaceId,
|
||||
timeCreated = timeCreated,
|
||||
timeUpdated = timeUpdated,
|
||||
model = model,
|
||||
provider = provider,
|
||||
inputTokens = inputTokens,
|
||||
outputTokens = outputTokens,
|
||||
reasoningTokens = reasoningTokens,
|
||||
cacheReadTokens = cacheReadTokens,
|
||||
cacheWrite5mTokens = cacheWrite5mTokens,
|
||||
cacheWrite1hTokens = cacheWrite1hTokens,
|
||||
cost = cost,
|
||||
keyId = keyId,
|
||||
sessionId = sessionId,
|
||||
enrichmentPlan = enrichmentPlan
|
||||
)
|
||||
@ -0,0 +1,141 @@
|
||||
package com.rainy.token.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import com.rainy.token.domain.service.ServiceType
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.time.LocalDate
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* 每日签到状态的统一本地记录(SharedPreferences "checkin_status")。
|
||||
*
|
||||
* 一条记录包含:
|
||||
* - checkinDate:签到日期(本地时区 yyyy-MM-dd)
|
||||
* - status:签到结果状态(SUCCESS/ALREADY/FAILED/RETRYABLE/DEVICE_BLOCKED)
|
||||
* - checkinId:签到 id(服务端未提供时为空;新签到成功时生成本地 UUID 便于追溯)
|
||||
* - credits:签到获得数量
|
||||
* - updatedAt:本地记录更新时间戳
|
||||
*
|
||||
* 「今日已签到」的判定 = checkinDate == 今天 && status ∈ {SUCCESS, ALREADY}。
|
||||
* 因此 FAILED/RETRYABLE/DEVICE_BLOCKED 的记录不会挡住当天重试:标识位和时间
|
||||
* 对不上时,重开 App 会再次唤起签到流程。
|
||||
*/
|
||||
@Singleton
|
||||
class CheckinStatusStore @Inject constructor(
|
||||
@ApplicationContext private val context: Context
|
||||
) {
|
||||
|
||||
private fun prefs() =
|
||||
context.getSharedPreferences("checkin_status", Context.MODE_PRIVATE)
|
||||
|
||||
/** 该服务今日是否已「成功」签到(本地时区 yyyy-MM-dd 比对)。 */
|
||||
fun isCheckedToday(service: ServiceType, accountId: String? = null): Boolean {
|
||||
val record = lastRecord(service, accountId) ?: return false
|
||||
return record.checkinDate == LocalDate.now().toString() &&
|
||||
(record.status == STATUS_SUCCESS || record.status == STATUS_ALREADY)
|
||||
}
|
||||
|
||||
/** 最近一次签到记录(含失败记录),无记录返回 null。 */
|
||||
fun lastRecord(service: ServiceType, accountId: String? = null): CheckinRecord? {
|
||||
val raw = prefs().getString(recordKey(service, accountId), null)
|
||||
if (raw != null) {
|
||||
return runCatching {
|
||||
val obj = JSONObject(raw)
|
||||
val id = if (obj.isNull(KEY_ID)) null else obj.optString(KEY_ID).takeIf { it.isNotBlank() }
|
||||
CheckinRecord(
|
||||
service = service.storageKey,
|
||||
accountId = accountId,
|
||||
checkinDate = obj.optString(KEY_DATE),
|
||||
status = obj.optString(KEY_STATUS),
|
||||
checkinId = id,
|
||||
credits = obj.optLong(KEY_CREDITS),
|
||||
updatedAt = obj.optLong(KEY_UPDATED_AT)
|
||||
)
|
||||
}.getOrNull()
|
||||
}
|
||||
// 旧版 date-only 记录迁移:没有 JSON 记录时回退读旧键
|
||||
val legacyDate = legacyDate(service, accountId) ?: return null
|
||||
return CheckinRecord(
|
||||
service = service.storageKey,
|
||||
accountId = accountId,
|
||||
checkinDate = legacyDate,
|
||||
status = STATUS_SUCCESS,
|
||||
checkinId = null,
|
||||
credits = 0,
|
||||
updatedAt = 0
|
||||
)
|
||||
}
|
||||
|
||||
/** 最近一次成功签到日期(yyyy-MM-dd),无记录返回 null。 */
|
||||
fun lastCheckinDate(service: ServiceType, accountId: String? = null): String? =
|
||||
lastRecord(service, accountId)
|
||||
?.takeIf { it.status == STATUS_SUCCESS || it.status == STATUS_ALREADY }
|
||||
?.checkinDate
|
||||
|
||||
/** 记录一条签到结果(成功或失败都会落库,便于当天重试/统计)。 */
|
||||
fun record(
|
||||
service: ServiceType,
|
||||
accountId: String? = null,
|
||||
status: String,
|
||||
checkinId: String? = null,
|
||||
credits: Long = 0,
|
||||
updatedAt: Long = System.currentTimeMillis()
|
||||
) {
|
||||
val obj = JSONObject()
|
||||
obj.put(KEY_DATE, LocalDate.now().toString())
|
||||
obj.put(KEY_STATUS, status)
|
||||
obj.put(KEY_ID, checkinId ?: JSONObject.NULL)
|
||||
obj.put(KEY_CREDITS, credits)
|
||||
obj.put(KEY_UPDATED_AT, updatedAt)
|
||||
prefs().edit().putString(recordKey(service, accountId), obj.toString()).apply()
|
||||
}
|
||||
|
||||
/** 旧版「已成功签到」快捷写入(服务端确认已签/手动已签场景)。 */
|
||||
fun markChecked(service: ServiceType, accountId: String? = null) {
|
||||
record(service, accountId, STATUS_ALREADY)
|
||||
}
|
||||
|
||||
private fun recordKey(service: ServiceType, accountId: String?): String =
|
||||
"checkin_record_${service.storageKey}" + if (accountId.isNullOrBlank()) "" else ":$accountId"
|
||||
|
||||
private fun legacyDate(service: ServiceType, accountId: String?): String? {
|
||||
val p = prefs()
|
||||
val scopedKey = "last_checkin_${service.storageKey}" + if (accountId.isNullOrBlank()) "" else ":$accountId"
|
||||
val scoped = p.getString(scopedKey, null)
|
||||
if (scoped != null) return scoped
|
||||
// 仅旧版迁移来的 “default” 账号回退读旧版单账号键(其他账号没有旧记录,不能共用),
|
||||
// 避免升级当日重复签到请求
|
||||
return if (accountId == CredentialRepository.LEGACY_DEFAULT_ACCOUNT_ID) {
|
||||
p.getString("last_checkin_${service.storageKey}", null)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** 单条签到记录。 */
|
||||
data class CheckinRecord(
|
||||
val service: String,
|
||||
val accountId: String?,
|
||||
val checkinDate: String,
|
||||
val status: String,
|
||||
val checkinId: String?,
|
||||
val credits: Long,
|
||||
val updatedAt: Long
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val STATUS_SUCCESS = "SUCCESS"
|
||||
const val STATUS_ALREADY = "ALREADY"
|
||||
const val STATUS_FAILED = "FAILED"
|
||||
const val STATUS_RETRYABLE = "RETRYABLE"
|
||||
const val STATUS_DEVICE_BLOCKED = "DEVICE_BLOCKED"
|
||||
|
||||
private const val KEY_DATE = "date"
|
||||
private const val KEY_STATUS = "status"
|
||||
private const val KEY_ID = "id"
|
||||
private const val KEY_CREDITS = "credits"
|
||||
private const val KEY_UPDATED_AT = "updated_at"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,175 @@
|
||||
package com.rainy.token.data.repository
|
||||
|
||||
import android.util.Base64
|
||||
import com.rainy.token.data.debug.DebugLog
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
|
||||
/**
|
||||
* Codex / ChatGPT OAuth PKCE 辅助工具。
|
||||
*
|
||||
* 参考:7shi/codex-oauth 的 Python 实现,移植到 Kotlin/Android。
|
||||
* 流程:
|
||||
* 1. 生成 code_verifier(随机 96 字节 base64url)
|
||||
* 2. 计算 code_challenge = SHA256(code_verifier) base64url 无填充
|
||||
* 3. 构建 auth URL → 用户在 WebView 中登录
|
||||
* 4. 拦截 localhost:1455/auth/callback?code=xxx&state=xxx
|
||||
* 5. POST token endpoint 用 code + code_verifier 换 access/refresh token
|
||||
* 6. 从 JWT (id_token / access_token) 提取 chatgpt_account_id
|
||||
*/
|
||||
object CodexOAuthHelper {
|
||||
|
||||
private const val TAG = "CodexOAuth"
|
||||
|
||||
const val AUTH_URL = "https://auth.openai.com/oauth/authorize"
|
||||
const val TOKEN_URL = "https://auth.openai.com/oauth/token"
|
||||
const val CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
||||
const val REDIRECT_URI = "http://localhost:1455/auth/callback"
|
||||
const val SCOPE = "openid profile email offline_access"
|
||||
const val CALLBACK_PREFIX = "http://localhost:1455/auth/callback"
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
/** PKCE 参数对 */
|
||||
data class PkcePair(val codeVerifier: String, val codeChallenge: String)
|
||||
|
||||
/** 生成 PKCE code_verifier 和 code_challenge (S256) */
|
||||
fun generatePkce(): PkcePair {
|
||||
val randomBytes = ByteArray(96)
|
||||
SecureRandom().nextBytes(randomBytes)
|
||||
val codeVerifier = Base64.encodeToString(
|
||||
randomBytes,
|
||||
Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP
|
||||
)
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(codeVerifier.toByteArray())
|
||||
val codeChallenge = Base64.encodeToString(
|
||||
digest,
|
||||
Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP
|
||||
)
|
||||
return PkcePair(codeVerifier, codeChallenge)
|
||||
}
|
||||
|
||||
/** 生成随机 state(CSRF 防护) */
|
||||
fun generateState(): String {
|
||||
val bytes = ByteArray(32)
|
||||
SecureRandom().nextBytes(bytes)
|
||||
return Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
/** 构建授权 URL */
|
||||
fun buildAuthUrl(codeChallenge: String, state: String): String {
|
||||
val params = mapOf(
|
||||
"response_type" to "code",
|
||||
"client_id" to CLIENT_ID,
|
||||
"redirect_uri" to REDIRECT_URI,
|
||||
"scope" to SCOPE,
|
||||
"code_challenge" to codeChallenge,
|
||||
"code_challenge_method" to "S256",
|
||||
"state" to state,
|
||||
"id_token_add_organizations" to "true",
|
||||
"codex_cli_simplified_flow" to "true",
|
||||
"originator" to "opencode"
|
||||
)
|
||||
// 手动拼 URL(不用 URLEncoder.encode,因为 OAuth 参数不需要编码特殊字符)
|
||||
val query = params.entries.joinToString("&") { (k, v) ->
|
||||
"$k=${java.net.URLEncoder.encode(v, "UTF-8")}"
|
||||
}
|
||||
return "$AUTH_URL?$query"
|
||||
}
|
||||
|
||||
/** Token 交换响应 */
|
||||
@Serializable
|
||||
data class TokenResponse(
|
||||
@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("id_token") val idToken: String? = null,
|
||||
@kotlinx.serialization.SerialName("token_type") val tokenType: String? = null
|
||||
)
|
||||
|
||||
/** 用 authorization code 换 token */
|
||||
fun exchangeCode(
|
||||
okHttpClient: OkHttpClient,
|
||||
code: String,
|
||||
codeVerifier: String
|
||||
): TokenResponse? {
|
||||
val formBody = buildString {
|
||||
append("grant_type=authorization_code")
|
||||
append("&code=").append(java.net.URLEncoder.encode(code, "UTF-8"))
|
||||
append("&redirect_uri=").append(java.net.URLEncoder.encode(REDIRECT_URI, "UTF-8"))
|
||||
append("&client_id=").append(CLIENT_ID)
|
||||
append("&code_verifier=").append(codeVerifier)
|
||||
}.toRequestBody("application/x-www-form-urlencoded".toMediaType())
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(TOKEN_URL)
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.post(formBody)
|
||||
.build()
|
||||
|
||||
return try {
|
||||
okHttpClient.newCall(request).execute().use { resp ->
|
||||
if (!resp.isSuccessful) {
|
||||
val errorBody = resp.body?.string()
|
||||
DebugLog.e(TAG, "token exchange failed: HTTP ${resp.code} | $errorBody")
|
||||
return@use null
|
||||
}
|
||||
val body = resp.body?.string() ?: return@use null
|
||||
json.decodeFromString(TokenResponse.serializer(), body)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
DebugLog.e(TAG, "token exchange exception: ${e::class.simpleName}: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 JWT 中提取 chatgpt_account_id(不验证签名,只读 payload)。
|
||||
* 3 级回退:
|
||||
* 1. payload.chatgpt_account_id
|
||||
* 2. payload["https://api.openai.com/auth"].chatgpt_account_id
|
||||
* 3. payload.organizations[0].id
|
||||
*/
|
||||
fun extractAccountId(idToken: String?, accessToken: String?): String? {
|
||||
for (token in listOfNotNull(idToken, accessToken)) {
|
||||
val payload = decodeJWTPayload(token) ?: continue
|
||||
// 1. top-level
|
||||
payload["chatgpt_account_id"]?.jsonPrimitive?.content?.let { return it }
|
||||
// 2. nested namespace
|
||||
payload["https://api.openai.com/auth"]?.jsonObject
|
||||
?.get("chatgpt_account_id")?.jsonPrimitive?.content?.let { return it }
|
||||
// 3. organizations[0].id
|
||||
(payload["organizations"] as? kotlinx.serialization.json.JsonArray)
|
||||
?.firstOrNull()
|
||||
?.let { it as? JsonObject }
|
||||
?.get("id")?.jsonPrimitive?.content?.let { return it }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 解码 JWT payload(第二段),不验证签名 */
|
||||
private fun decodeJWTPayload(jwt: String): JsonObject? {
|
||||
return try {
|
||||
val parts = jwt.split(".")
|
||||
if (parts.size < 2) return null
|
||||
val payloadB64 = parts[1]
|
||||
// base64url decode,补 padding
|
||||
val decoded = Base64.decode(
|
||||
payloadB64,
|
||||
Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP
|
||||
)
|
||||
json.parseToJsonElement(String(decoded)).jsonObject
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,479 @@
|
||||
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
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.floatOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import android.util.Log
|
||||
import com.rainy.token.data.debug.DebugLog
|
||||
import java.io.IOException
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class CodexRepository(
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val credentialRepository: CredentialRepository,
|
||||
private val balanceCache: BalanceCache
|
||||
) {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
/** 用量窗口来源分组:CODEX = 普通 Codex 限额,SPARK = GPT-5.3-Codex-Spark 独立限额 */
|
||||
internal enum class WindowGroup { CODEX, SPARK }
|
||||
|
||||
internal data class UsageWindow(
|
||||
val label: String,
|
||||
val remainingPct: Int,
|
||||
val resetAt: Long?,
|
||||
val group: WindowGroup = WindowGroup.CODEX
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val WHAM_USAGE = "https://chatgpt.com/backend-api/wham/usage"
|
||||
private const val RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses"
|
||||
private const val MODELS_API = "https://models.dev/api.json"
|
||||
private const val OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token"
|
||||
private const val CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
||||
private const val REFRESH_BUFFER_MS = 60L * 60 * 1000
|
||||
private const val TAG = "Codex"
|
||||
|
||||
internal fun parseUsageWindows(data: JsonObject): List<UsageWindow> {
|
||||
val result = mutableListOf<UsageWindow>()
|
||||
fun addWindows(rl: JsonObject?, group: WindowGroup) {
|
||||
if (rl == null) return
|
||||
for (key in listOf("primary_window", "secondary_window")) {
|
||||
val w = rl[key] as? JsonObject ?: continue
|
||||
val usedPct = (w["used_percent"] as? JsonPrimitive)?.floatOrNull ?: continue
|
||||
val remaining = (100 - usedPct).toInt().coerceIn(0, 100)
|
||||
result.add(UsageWindow(durationLabel((w["limit_window_seconds"] as? JsonPrimitive)?.longOrNull), remaining, (w["reset_at"] as? JsonPrimitive)?.longOrNull?.times(1000L), group))
|
||||
}
|
||||
}
|
||||
// 顶层 rate_limit 始终属于普通 Codex 限额
|
||||
addWindows(data["rate_limit"] as? JsonObject, WindowGroup.CODEX)
|
||||
// additional_rate_limits 每项可携带 limit_name,含 "spark" 的归入 Spark 独立限额组
|
||||
(data["additional_rate_limits"] as? kotlinx.serialization.json.JsonArray)?.forEach { item ->
|
||||
if (item is JsonObject) {
|
||||
val name = (item["limit_name"] as? JsonPrimitive)?.contentOrNull.orEmpty()
|
||||
val group = if (name.contains("spark", ignoreCase = true)) WindowGroup.SPARK else WindowGroup.CODEX
|
||||
addWindows(item["rate_limit"] as? JsonObject, group)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择主余额窗口:
|
||||
* 优先普通 Codex 组的 5h 窗口(与旧逻辑一致);
|
||||
* 普通组没有 5h 时取普通组第一个(如仅 weekly 的 prolite 账号);
|
||||
* 普通组为空才 fallback 到任意第一个(避免 Spark 5h 抢走主余额)。
|
||||
*/
|
||||
internal fun selectPrimaryWindow(windows: List<UsageWindow>): UsageWindow =
|
||||
windows.firstOrNull { it.group == WindowGroup.CODEX && it.label.contains("h") }
|
||||
?: windows.firstOrNull { it.group == WindowGroup.CODEX }
|
||||
?: windows.first()
|
||||
|
||||
/** 窗口时长 → 中性标识(weekly/monthly/usage/数字标签),由 UI 层按语言本地化 */
|
||||
internal fun durationLabel(seconds: Long?): String = when {
|
||||
seconds == null -> "usage"
|
||||
seconds / 60.0 >= 10079 -> "weekly"
|
||||
seconds / 60.0 >= 1439 -> "${(seconds / 86400).toInt()}d"
|
||||
seconds / 60.0 >= 60 -> "${(seconds / 3600).toInt()}h"
|
||||
else -> "${maxOf(1, (seconds / 60).toInt())}m"
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchBalance(): Result<ServiceBalance> = withContext(Dispatchers.IO) {
|
||||
val credential = credentialRepository.get(ServiceType.CODEX)
|
||||
?: return@withContext Result.failure(RepositoryError.InvalidCredential("未找到 Codex 凭据"))
|
||||
if (credential !is Credential.CodexCredential)
|
||||
return@withContext Result.failure(RepositoryError.InvalidCredential("凭据类型不匹配"))
|
||||
|
||||
val effectiveCred = if (tokenNeedsRefresh(credential)) {
|
||||
DebugLog.i(TAG, "access_token 即将过期,尝试刷新(expiresAt=${credential.expiresAt})")
|
||||
when (val r = refreshToken(credential)) {
|
||||
is RefreshResult.Success -> {
|
||||
DebugLog.i(TAG, "token 刷新成功,新 expiresAt=${r.cred.expiresAt}")
|
||||
credentialRepository.save(r.cred); r.cred
|
||||
}
|
||||
is RefreshResult.Failure -> {
|
||||
DebugLog.e(TAG, "token 主动刷新失败: ${r.reason}")
|
||||
credential
|
||||
}
|
||||
}
|
||||
} else credential
|
||||
|
||||
val usageResult = try {
|
||||
fetchJson(WHAM_USAGE, effectiveCred.accessToken)
|
||||
} catch (e: IOException) {
|
||||
DebugLog.e(TAG, "网络异常: ${e.message}")
|
||||
return@withContext Result.failure(RepositoryError.Network(e))
|
||||
} catch (e: RepositoryError) {
|
||||
if (e is RepositoryError.InvalidCredential && effectiveCred == credential) {
|
||||
DebugLog.w(TAG, "401 收到,尝试用 refresh_token 二次刷新")
|
||||
when (val r = refreshToken(credential)) {
|
||||
is RefreshResult.Success -> {
|
||||
DebugLog.i(TAG, "二次刷新成功")
|
||||
credentialRepository.save(r.cred)
|
||||
try { fetchJson(WHAM_USAGE, r.cred.accessToken) }
|
||||
catch (e2: RepositoryError) { return@withContext Result.failure(e2) }
|
||||
catch (e2: IOException) { return@withContext Result.failure(RepositoryError.Network(e2)) }
|
||||
catch (e2: Throwable) { return@withContext Result.failure(RepositoryError.Unknown(e2)) }
|
||||
}
|
||||
is RefreshResult.Failure -> {
|
||||
DebugLog.e(TAG, "二次刷新也失败: ${r.reason}")
|
||||
return@withContext Result.failure(RepositoryError.InvalidCredential(r.reason))
|
||||
}
|
||||
}
|
||||
} else return@withContext Result.failure(e)
|
||||
} catch (e: Throwable) { return@withContext Result.failure(RepositoryError.Unknown(e)) }
|
||||
|
||||
val windows = try {
|
||||
parseUsageWindows(usageResult)
|
||||
} catch (e: Exception) {
|
||||
DebugLog.e(TAG, "解析 Codex 用量失败: ${e::class.simpleName}: ${e.message}")
|
||||
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(RepositoryError.ParseErrorReason.NO_WINDOWS, "未找到 Codex 用量窗口数据"))
|
||||
|
||||
val config = ServiceConfigProvider.get(ServiceType.CODEX)
|
||||
val primary = selectPrimaryWindow(windows)
|
||||
val extras = buildMap {
|
||||
windows.forEachIndexed { i, w ->
|
||||
put("window_$i.label", w.label)
|
||||
put("window_$i.remainingPct", w.remainingPct.toString())
|
||||
put("window_$i.resetAt", w.resetAt?.toString() ?: "")
|
||||
put("window_$i.group", w.group.name)
|
||||
}
|
||||
put("primary.label", primary.label)
|
||||
(usageResult["plan_type"] as? JsonPrimitive)?.contentOrNull?.let { put("plan", it) }
|
||||
(usageResult["credits"] as? JsonObject)?.let { c ->
|
||||
(c["balance"] as? JsonPrimitive)?.floatOrNull?.let { put("usageCredits", it.toString()) }
|
||||
}
|
||||
}
|
||||
val balance = ServiceBalance(ServiceType.CODEX, (100 - primary.remainingPct).coerceIn(0, 100).toDouble(), config.displayUnit, true, null, null, primary.resetAt, extras)
|
||||
balanceCache.put(ServiceType.CODEX, balance)
|
||||
credentialRepository.save(effectiveCred.copy(lastVerifiedAt = System.currentTimeMillis()))
|
||||
Result.success(balance)
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 models.dev/api.json 获取 OpenAI 可用模型列表。
|
||||
* 该 API 不需要认证,返回所有 provider 的模型目录。
|
||||
* 参考 OpenCode 的 ModelsDev 服务实现。
|
||||
*/
|
||||
suspend fun fetchModels(): Result<List<String>> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request = Request.Builder().url(MODELS_API)
|
||||
.header("Accept", "application/json")
|
||||
.header("User-Agent", "codex-reset-tracker/0.1")
|
||||
.get().build()
|
||||
val models = okHttpClient.newCall(request).execute().use { resp ->
|
||||
if (!resp.isSuccessful) {
|
||||
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(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(RepositoryError.ParseErrorReason.NO_MODELS, "未找到 OpenAI 模型列表")
|
||||
}
|
||||
if (models.isEmpty()) {
|
||||
return@withContext Result.failure(RepositoryError.ParseError(RepositoryError.ParseErrorReason.MODELS_EMPTY, "模型列表为空"))
|
||||
}
|
||||
DebugLog.i(TAG, "fetchModels: 获取到 ${models.size} 个模型")
|
||||
Result.success(models)
|
||||
} catch (e: IOException) {
|
||||
DebugLog.e(TAG, "fetchModels 网络异常: ${e.message}")
|
||||
Result.failure(RepositoryError.Network(e))
|
||||
} catch (e: RepositoryError) {
|
||||
Result.failure(e)
|
||||
} catch (e: Throwable) {
|
||||
DebugLog.e(TAG, "fetchModels 异常: ${e::class.simpleName}: ${e.message}")
|
||||
Result.failure(RepositoryError.Unknown(e))
|
||||
}
|
||||
}
|
||||
|
||||
/** 从 access_token JWT 中解析 chatgpt_account_id */
|
||||
private fun extractAccountId(accessToken: String): String? {
|
||||
return try {
|
||||
val parts = accessToken.split(".")
|
||||
if (parts.size != 3) return null
|
||||
// JWT payload 是 Base64Url 编码
|
||||
val payload = android.util.Base64.decode(
|
||||
parts[1].replace("-", "+").replace("_", "/").padEnd(4, '='),
|
||||
android.util.Base64.DEFAULT
|
||||
).toString(Charsets.UTF_8)
|
||||
val json = this.json.parseToJsonElement(payload) as? JsonObject ?: return null
|
||||
(json["chatgpt_account_id"] as? JsonPrimitive)?.contentOrNull
|
||||
?: ((json["https://api.openai.com/auth"] as? JsonObject)?.get("chatgpt_account_id") as? JsonPrimitive)?.contentOrNull
|
||||
?: (json["organizations"] as? kotlinx.serialization.json.JsonArray)
|
||||
?.firstOrNull()?.let { (it as? JsonObject)?.get("id") as? JsonPrimitive }?.contentOrNull
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 一键激活用量:向 ChatGPT responses API 发送一条请求。
|
||||
* @param model 用户选择的模型 slug
|
||||
* @return Result.success(响应体原文) — 供 UI 展示
|
||||
*/
|
||||
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)
|
||||
return@withContext Result.failure(RepositoryError.InvalidCredential("凭据类型不匹配"))
|
||||
|
||||
val effectiveCred = ensureToken(credential)
|
||||
?: return@withContext Result.failure(RepositoryError.InvalidCredential("token 刷新失败"))
|
||||
|
||||
val accountId = extractAccountId(effectiveCred.accessToken)
|
||||
DebugLog.i(TAG, "triggerUsage: accountId=$accountId, model=$model")
|
||||
|
||||
val requestBody = """{"model":"$model","input":[{"role":"user","content":[{"type":"input_text","text":"hello"}]}],"stream":true,"store":false}"""
|
||||
.toRequestBody("application/json".toMediaType())
|
||||
|
||||
val request = buildPostRequest(RESPONSES_URL, effectiveCred.accessToken, requestBody, accountId)
|
||||
|
||||
try {
|
||||
okHttpClient.newCall(request).execute().use { resp ->
|
||||
val bodyStr = resp.body?.string() ?: ""
|
||||
DebugLog.i(TAG, "triggerUsage: HTTP ${resp.code}, body=${bodyStr.take(500)}")
|
||||
if (!resp.isSuccessful) {
|
||||
DebugLog.e(TAG, "triggerUsage failed: HTTP ${resp.code} body=${bodyStr.take(200)}")
|
||||
if (resp.code in listOf(401, 403)) {
|
||||
// token 过期,二次刷新重试
|
||||
DebugLog.w(TAG, "triggerUsage: 401,尝试二次刷新")
|
||||
when (val r = refreshToken(credential)) {
|
||||
is RefreshResult.Success -> {
|
||||
credentialRepository.save(r.cred)
|
||||
val retryAccountId = extractAccountId(r.cred.accessToken)
|
||||
val retryRequest = buildPostRequest(RESPONSES_URL, r.cred.accessToken, requestBody, retryAccountId)
|
||||
okHttpClient.newCall(retryRequest).execute().use { resp2 ->
|
||||
val body2 = resp2.body?.string() ?: ""
|
||||
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<TriggerSummary>(
|
||||
TriggerError(
|
||||
"HTTP ${resp2.code}",
|
||||
body2.ifBlank { "" }
|
||||
)
|
||||
)
|
||||
}
|
||||
Result.success(parseSseResponse(body2, model))
|
||||
}
|
||||
}
|
||||
is RefreshResult.Failure -> {
|
||||
DebugLog.e(TAG, "triggerUsage: 二次刷新失败: ${r.reason}")
|
||||
return@use Result.failure(TriggerError("token 刷新失败: ${r.reason}", "", TriggerErrorReason.TOKEN_REFRESH))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return@use Result.failure<TriggerSummary>(
|
||||
TriggerError("HTTP ${resp.code}", bodyStr.ifBlank { "" })
|
||||
)
|
||||
}
|
||||
} else {
|
||||
DebugLog.i(TAG, "triggerUsage: 请求成功,模型=$model")
|
||||
val formatted = parseSseResponse(bodyStr, model)
|
||||
Result.success(formatted)
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
DebugLog.e(TAG, "triggerUsage 网络异常: ${e.message}")
|
||||
Result.failure(RepositoryError.Network(e))
|
||||
} catch (e: Throwable) {
|
||||
DebugLog.e(TAG, "triggerUsage 异常: ${e::class.simpleName}: ${e.message}")
|
||||
Result.failure(RepositoryError.Unknown(e))
|
||||
}
|
||||
}
|
||||
|
||||
/** 确保 access_token 有效,返回刷新后的凭据或 null(刷新失败时) */
|
||||
private suspend fun ensureToken(credential: Credential.CodexCredential): Credential.CodexCredential? {
|
||||
if (!tokenNeedsRefresh(credential)) return credential
|
||||
DebugLog.i(TAG, "ensureToken: access_token 即将过期,尝试刷新")
|
||||
return when (val r = refreshToken(credential)) {
|
||||
is RefreshResult.Success -> {
|
||||
credentialRepository.save(r.cred); r.cred
|
||||
}
|
||||
is RefreshResult.Failure -> {
|
||||
DebugLog.e(TAG, "ensureToken: token 刷新失败: ${r.reason}")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 构建 responses POST 请求 */
|
||||
private fun buildPostRequest(url: String, token: String, body: okhttp3.RequestBody, accountId: String? = null): Request =
|
||||
Request.Builder().url(url)
|
||||
.header("Accept", "application/json")
|
||||
.header("Content-Type", "application/json")
|
||||
.header("OpenAI-Beta", "codex-1")
|
||||
.header("OAI-Language", "en")
|
||||
.header("User-Agent", "codex-reset-tracker/0.1")
|
||||
.header("originator", "codex-reset-tracker")
|
||||
.header("Authorization", "Bearer $token")
|
||||
.apply {
|
||||
if (accountId != null) header("ChatGPT-Account-Id", accountId)
|
||||
}
|
||||
.post(body).build()
|
||||
|
||||
private fun tokenNeedsRefresh(cred: Credential.CodexCredential): Boolean =
|
||||
System.currentTimeMillis() >= cred.expiresAt - REFRESH_BUFFER_MS
|
||||
|
||||
private sealed class RefreshResult {
|
||||
data class Success(val cred: Credential.CodexCredential) : RefreshResult()
|
||||
data class Failure(val reason: String) : RefreshResult()
|
||||
}
|
||||
|
||||
private fun refreshToken(cred: Credential.CodexCredential): RefreshResult {
|
||||
// OpenAI auth endpoint 要求 form-urlencoded,不能用 JSON(否则返回 401)
|
||||
val formBody = "grant_type=refresh_token&refresh_token=${cred.refreshToken}&client_id=$CLIENT_ID"
|
||||
.toRequestBody("application/x-www-form-urlencoded".toMediaType())
|
||||
val request = Request.Builder().url(OAUTH_TOKEN_URL)
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.post(formBody).build()
|
||||
return try {
|
||||
okHttpClient.newCall(request).execute().use { resp ->
|
||||
if (!resp.isSuccessful) {
|
||||
val errorBody = resp.body?.string()
|
||||
Log.w("CodexRepository", "token refresh failed: HTTP ${resp.code} ${resp.message} body=$errorBody")
|
||||
DebugLog.e(TAG, "token refresh failed: HTTP ${resp.code} ${resp.message}" +
|
||||
(errorBody?.take(200)?.let { " | $it" } ?: ""))
|
||||
// 解析 OpenAI 错误响应,给出用户可读的提示
|
||||
val reason = parseRefreshError(resp.code, errorBody)
|
||||
return@use RefreshResult.Failure(reason)
|
||||
}
|
||||
val bodyStr = resp.body?.string() ?: return@use RefreshResult.Failure("响应体为空")
|
||||
val tr = json.decodeFromString(OAuthRefreshResponse.serializer(), bodyStr)
|
||||
// OpenAI 轮换 refresh_token:响应中可能含新 refresh_token,也可能不含(不轮换时)
|
||||
RefreshResult.Success(cred.copy(
|
||||
accessToken = tr.accessToken,
|
||||
refreshToken = tr.refreshToken ?: cred.refreshToken,
|
||||
expiresAt = System.currentTimeMillis() + tr.expiresIn * 1000L,
|
||||
lastVerifiedAt = System.currentTimeMillis()
|
||||
))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w("CodexRepository", "token refresh exception: ${e::class.simpleName}: ${e.message}")
|
||||
DebugLog.e(TAG, "token refresh exception: ${e::class.simpleName}: ${e.message}")
|
||||
RefreshResult.Failure("网络异常: ${e::class.simpleName}: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/** 把 OpenAI token endpoint 的错误响应翻译成用户可读的中文提示 */
|
||||
private fun parseRefreshError(httpCode: Int, errorBody: String?): String {
|
||||
if (errorBody == null) return "HTTP $httpCode,无错误详情"
|
||||
// 尝试提取 error.message 字段
|
||||
val msg = try {
|
||||
(json.parseToJsonElement(errorBody) as? JsonObject)?.let { root ->
|
||||
(root["error"] as? JsonObject)?.let { err ->
|
||||
(err["message"] as? JsonPrimitive)?.contentOrNull
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) { null }
|
||||
return when {
|
||||
msg != null && msg.contains("already been used") ->
|
||||
"refresh_token 已被使用(被其他工具轮换),请重新导出 auth.json 并导入"
|
||||
msg != null && msg.contains("sign in", ignoreCase = true) ->
|
||||
"refresh_token 已失效,请重新登录获取新 auth.json"
|
||||
httpCode == 401 && msg != null -> "认证失败: $msg"
|
||||
httpCode == 401 -> "认证失败 (HTTP 401),refresh_token 可能已过期"
|
||||
httpCode == 400 && msg != null -> "请求参数错误: $msg"
|
||||
else -> "HTTP $httpCode: ${msg ?: errorBody.take(100)}"
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchJson(url: String, token: String): JsonObject {
|
||||
val request = Request.Builder().url(url)
|
||||
.header("Accept", "application/json")
|
||||
.header("OpenAI-Beta", "codex-1")
|
||||
.header("OAI-Language", "en")
|
||||
.header("User-Agent", "codex-reset-tracker/0.1")
|
||||
.header("originator", "codex-reset-tracker")
|
||||
.header("Authorization", "Bearer $token").get().build()
|
||||
val resp = okHttpClient.newCall(request).execute()
|
||||
resp.use {
|
||||
if (!it.isSuccessful) {
|
||||
Log.w("CodexRepository", "fetchJson failed: HTTP ${it.code} ${it.message} url=$url")
|
||||
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(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, val reason: TriggerErrorReason? = null) : Exception(summary)
|
||||
|
||||
/**
|
||||
* 将 SSE 流响应解析为简洁的文本摘要。
|
||||
* 提取:模型回复文本、用量统计(input/output tokens)。
|
||||
*/
|
||||
internal fun parseSseResponse(sseText: String, model: String): TriggerSummary {
|
||||
val sseJson = Json { ignoreUnknownKeys = true }
|
||||
val outputText = StringBuilder()
|
||||
var inputTokens: String? = null
|
||||
var outputTokens: String? = null
|
||||
var responseId: String? = null
|
||||
|
||||
for (line in sseText.lines()) {
|
||||
if (!line.startsWith("data: ")) continue
|
||||
val jsonStr = line.removePrefix("data: ").trim()
|
||||
if (jsonStr == "[DONE]") continue
|
||||
try {
|
||||
val obj = sseJson.parseToJsonElement(jsonStr) as? JsonObject ?: continue
|
||||
when (obj["type"]?.toString()?.trim('"')) {
|
||||
"response.output_text.done" -> {
|
||||
// 最终文本
|
||||
(obj["text"] as? JsonPrimitive)?.contentOrNull?.let { outputText.clear(); outputText.append(it) }
|
||||
}
|
||||
"response.completed" -> {
|
||||
val resp = obj["response"] as? JsonObject
|
||||
responseId = (resp?.get("id") as? JsonPrimitive)?.contentOrNull
|
||||
val usage = resp?.get("usage") as? JsonObject
|
||||
inputTokens = (usage?.get("input_tokens") as? JsonPrimitive)?.contentOrNull
|
||||
outputTokens = (usage?.get("output_tokens") as? JsonPrimitive)?.contentOrNull
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
return TriggerSummary(
|
||||
model = model,
|
||||
reply = outputText.toString(),
|
||||
inputTokens = inputTokens,
|
||||
outputTokens = outputTokens,
|
||||
responseId = responseId
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,249 @@
|
||||
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.service.ServiceConfigProvider
|
||||
import com.rainy.token.domain.service.ServiceType
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.long
|
||||
import kotlinx.serialization.json.double
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.IOException
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* CommandCode 余额仓库。
|
||||
*
|
||||
* 调 JSON API 获取月度配额余额 + 用量窗口信息:
|
||||
* GET https://api.commandcode.ai/alpha/billing/credits
|
||||
* Authorization: Bearer <API Key>
|
||||
*
|
||||
* 调 subscription 端点获取计划信息(用来算已用/总量百分比):
|
||||
* GET https://api.commandcode.ai/alpha/billing/subscriptions
|
||||
* Authorization: Bearer <API Key>
|
||||
*/
|
||||
@Singleton
|
||||
class CommandCodeGoRepository(
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val credentialRepository: CredentialRepository,
|
||||
private val balanceCache: BalanceCache
|
||||
) {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val apiBase = "https://api.commandcode.ai"
|
||||
|
||||
suspend fun fetchBalance(): Result<ServiceBalance> = withContext(Dispatchers.IO) {
|
||||
val credential = credentialRepository.get(ServiceType.COMMANDCODE_GO)
|
||||
?: return@withContext Result.failure(RepositoryError.InvalidCredential())
|
||||
|
||||
if (credential !is Credential.SessionCredential) {
|
||||
return@withContext Result.failure(RepositoryError.InvalidCredential())
|
||||
}
|
||||
|
||||
// token 字段存 API Key
|
||||
val apiKey = credential.token
|
||||
if (apiKey.isNullOrBlank()) {
|
||||
return@withContext Result.failure(RepositoryError.InvalidCredential())
|
||||
}
|
||||
|
||||
// 顺序拉取 credits + subscriptions(credits 是主要数据源,subscription 用于补充计划信息)
|
||||
val creditsResult = runCatching { fetchCredits(apiKey) }
|
||||
val subResult = runCatching { fetchSubscription(apiKey) }
|
||||
|
||||
val creditsPayload = creditsResult.getOrElse { e ->
|
||||
return@withContext Result.failure(
|
||||
if (e is IOException) RepositoryError.Network(e)
|
||||
else RepositoryError.Unknown(e)
|
||||
)
|
||||
}
|
||||
|
||||
// 从订阅信息拿计划名称,查 plan catalog 拿总量
|
||||
val monthlyTotal = subResult.getOrNull()?.let { sub ->
|
||||
planMonthlyQuota(sub.planId)
|
||||
}
|
||||
val billingPeriodEndMillis = subResult.getOrNull()?.let { parseIsoToEpoch(it.currentPeriodEnd) }
|
||||
|
||||
val config = ServiceConfigProvider.get(ServiceType.COMMANDCODE_GO)
|
||||
|
||||
val used = monthlyTotal?.let { total ->
|
||||
maxOf(0.0, total - creditsPayload.monthlyCredits)
|
||||
}
|
||||
|
||||
val extras = buildMap {
|
||||
put("monthlyRemaining", creditsPayload.monthlyCredits.toString())
|
||||
put("purchasedCredits", creditsPayload.purchasedCredits.toString())
|
||||
put("freeCredits", creditsPayload.freeCredits.toString())
|
||||
monthlyTotal?.let { put("monthlyTotal", it.toString()) }
|
||||
used?.let { put("monthlyUsed", it.toString()) }
|
||||
creditsPayload.fiveHourUsed?.let { put("fiveHour.used", it.toString()) }
|
||||
creditsPayload.fiveHourCap?.let { put("fiveHour.cap", it.toString()) }
|
||||
creditsPayload.fiveHourResetAt?.let { put("fiveHour.resetInSec", epochToRemainingSec(it).toString()) }
|
||||
creditsPayload.weeklyUsed?.let { put("weekly.used", it.toString()) }
|
||||
creditsPayload.weeklyCap?.let { put("weekly.cap", it.toString()) }
|
||||
creditsPayload.weeklyResetAt?.let { put("weekly.resetInSec", epochToRemainingSec(it).toString()) }
|
||||
billingPeriodEndMillis?.let {
|
||||
put("billingPeriodEnd", it.toString())
|
||||
put("monthly.resetInSec", epochToRemainingSec(it).toString())
|
||||
}
|
||||
subResult.getOrNull()?.planId?.let { put("planId", it) }
|
||||
subResult.getOrNull()?.planId?.let { put("planName", planDisplayName(it)) }
|
||||
}
|
||||
|
||||
val balance = ServiceBalance(
|
||||
service = ServiceType.COMMANDCODE_GO,
|
||||
amount = creditsPayload.monthlyCredits,
|
||||
unit = config.displayUnit,
|
||||
isAvailable = true,
|
||||
monthlySpent = used,
|
||||
totalQuota = monthlyTotal,
|
||||
nextResetAt = billingPeriodEndMillis,
|
||||
extras = extras
|
||||
)
|
||||
|
||||
balanceCache.put(ServiceType.COMMANDCODE_GO, balance)
|
||||
credentialRepository.save(credential.copy(lastVerifiedAt = System.currentTimeMillis()))
|
||||
|
||||
Result.success(balance)
|
||||
}
|
||||
|
||||
private fun fetchCredits(apiKey: String): CreditsPayload {
|
||||
val request = Request.Builder()
|
||||
.url("$apiBase/alpha/billing/credits")
|
||||
.header("Authorization", "Bearer $apiKey")
|
||||
.header("Accept", "application/json")
|
||||
.get()
|
||||
.build()
|
||||
|
||||
val response = okHttpClient.newCall(request).execute()
|
||||
if (!response.isSuccessful) {
|
||||
throw IOException("HTTP ${response.code}: ${response.body?.string().orEmpty()}")
|
||||
}
|
||||
|
||||
val body = response.body?.string() ?: throw IOException("空响应体")
|
||||
val root = json.parseToJsonElement(body).jsonObject
|
||||
val credits = root["credits"]?.jsonObject ?: throw IOException("缺少 credits 字段")
|
||||
|
||||
fun getDouble(obj: JsonObject, key: String): Double =
|
||||
obj[key]?.jsonPrimitive?.double ?: 0.0
|
||||
|
||||
fun getLong(obj: JsonObject, key: String): Long? =
|
||||
obj[key]?.jsonPrimitive?.long
|
||||
|
||||
val fiveHour = root["windowLimits"]?.jsonObject?.let { limits ->
|
||||
if (limits["limited"]?.jsonPrimitive?.content == "true") {
|
||||
limits["fiveHour"]?.jsonObject
|
||||
} else null
|
||||
}
|
||||
val weekly = root["windowLimits"]?.jsonObject?.let { limits ->
|
||||
if (limits["limited"]?.jsonPrimitive?.content == "true") {
|
||||
limits["weekly"]?.jsonObject
|
||||
} else null
|
||||
}
|
||||
|
||||
return CreditsPayload(
|
||||
monthlyCredits = getDouble(credits, "monthlyCredits"),
|
||||
purchasedCredits = getDouble(credits, "purchasedCredits"),
|
||||
freeCredits = getDouble(credits, "freeCredits"),
|
||||
fiveHourUsed = fiveHour?.let { getDouble(it, "used") },
|
||||
fiveHourCap = fiveHour?.let { getDouble(it, "cap") },
|
||||
fiveHourResetAt = fiveHour?.let { getLong(it, "resetAt") },
|
||||
weeklyUsed = weekly?.let { getDouble(it, "used") },
|
||||
weeklyCap = weekly?.let { getDouble(it, "cap") },
|
||||
weeklyResetAt = weekly?.let { getLong(it, "resetAt") }
|
||||
)
|
||||
}
|
||||
|
||||
private fun fetchSubscription(apiKey: String): SubscriptionPayload? {
|
||||
val request = Request.Builder()
|
||||
.url("$apiBase/alpha/billing/subscriptions")
|
||||
.header("Authorization", "Bearer $apiKey")
|
||||
.header("Accept", "application/json")
|
||||
.get()
|
||||
.build()
|
||||
|
||||
val response = try {
|
||||
okHttpClient.newCall(request).execute()
|
||||
} catch (_: Exception) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!response.isSuccessful) return null
|
||||
val body = response.body?.string() ?: return null
|
||||
|
||||
return try {
|
||||
val root = json.parseToJsonElement(body).jsonObject
|
||||
if (root["success"]?.jsonPrimitive?.content != "true") return null
|
||||
val data = root["data"]?.jsonObject ?: return null
|
||||
SubscriptionPayload(
|
||||
planId = data["planId"]?.jsonPrimitive?.content.orEmpty(),
|
||||
currentPeriodEnd = data["currentPeriodEnd"]?.jsonPrimitive?.content
|
||||
)
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** 计划 → 月度额度(美元)。2026-08 起官网新计划体系:
|
||||
* Go $1 → $10、GOAT $10 → $70、Pro $20 → $80、Max 10× $100 → $150、Max 20× $200 → $300。
|
||||
* 旧值 individual-go=10 与 individual-goat=70 并存(goat 是当前主流计划)。
|
||||
*/
|
||||
private val PLANS = mapOf(
|
||||
"individual-go" to 10.0,
|
||||
"individual-goat" to 70.0,
|
||||
"individual-pro" to 80.0,
|
||||
"individual-max" to 150.0,
|
||||
"individual-ultra" to 300.0
|
||||
)
|
||||
|
||||
private val PLAN_NAMES = mapOf(
|
||||
"individual-go" to "Go",
|
||||
"individual-goat" to "GOAT",
|
||||
"individual-pro" to "Pro",
|
||||
"individual-max" to "Max",
|
||||
"individual-ultra" to "Ultra"
|
||||
)
|
||||
|
||||
/** 按 planId 查月度额度(美元);未知计划返回 null(调用方安全降级)。 */
|
||||
internal fun planMonthlyQuota(planId: String?): Double? =
|
||||
planId?.lowercase()?.let { PLANS[it] }
|
||||
|
||||
/** 按 planId 查展示名;未知计划原样返回。 */
|
||||
internal fun planDisplayName(planId: String?): String =
|
||||
planId?.lowercase()?.let { PLAN_NAMES[it] } ?: planId ?: ""
|
||||
|
||||
/** API 返回的是 epoch millis,转为距现在的剩余秒数 */
|
||||
private fun epochToRemainingSec(epochMillis: Long): Long =
|
||||
maxOf(0L, (epochMillis - System.currentTimeMillis()) / 1000)
|
||||
|
||||
private fun parseIsoToEpoch(isoStr: String?): Long? {
|
||||
if (isoStr == null) return null
|
||||
return try {
|
||||
val sdf = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", java.util.Locale.US)
|
||||
sdf.timeZone = java.util.TimeZone.getTimeZone("UTC")
|
||||
sdf.parse(isoStr.take(19))?.time
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
}
|
||||
|
||||
private data class CreditsPayload(
|
||||
val monthlyCredits: Double,
|
||||
val purchasedCredits: Double,
|
||||
val freeCredits: Double,
|
||||
val fiveHourUsed: Double?,
|
||||
val fiveHourCap: Double?,
|
||||
val fiveHourResetAt: Long?,
|
||||
val weeklyUsed: Double?,
|
||||
val weeklyCap: Double?,
|
||||
val weeklyResetAt: Long?
|
||||
)
|
||||
|
||||
private data class SubscriptionPayload(
|
||||
val planId: String,
|
||||
val currentPeriodEnd: String?
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,231 @@
|
||||
package com.rainy.token.data.repository
|
||||
|
||||
import com.rainy.token.data.local.UsageRecord
|
||||
import com.rainy.token.domain.model.Credential
|
||||
import com.rainy.token.domain.model.CookieEntry
|
||||
import com.rainy.token.domain.service.ServiceType
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.IOException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Base64
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* CommandCode 用量记录仓库。
|
||||
*
|
||||
* 调 JSON API 分页抓取 usage 记录:
|
||||
* GET https://api.commandcode.ai/internal/usage?limit=50
|
||||
* GET https://api.commandcode.ai/internal/usage?limit=50&cursor=<base64>
|
||||
*
|
||||
* cursor 是末条记录的 { createdAt, id } 的 base64 编码。
|
||||
* 第一页不用 cursor。
|
||||
*/
|
||||
@Singleton
|
||||
class CommandCodeUsageRepository(
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val credentialRepository: CredentialRepository
|
||||
) {
|
||||
private val apiBase = "https://api.commandcode.ai"
|
||||
|
||||
companion object {
|
||||
const val PAGE_SIZE = 100
|
||||
/** cost / DENOM = USD */
|
||||
const val COST_DENOM = 100_000_000L
|
||||
/** CCGO 用量数据在 UsageCache 中的 workspaceId 区分键 */
|
||||
const val CCGO_WORKSPACE_ID = "commandcode"
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
// ===== 纯解析逻辑(无网络依赖,可直接单测)=====
|
||||
|
||||
/**
|
||||
* 解析 JSON 响应。
|
||||
*
|
||||
* 后端 2026-08 起改为新格式:
|
||||
* - 顶层新增 nextCursor / limit / periodBasis / window;
|
||||
* 分页游标由服务端 nextCursor 直接给出(缺失时回退按末条自编码,兼容旧格式)。
|
||||
* - 每条记录新增 durationTotal / status / message / type / mode;
|
||||
* tokensTotal / creditsTotal 已移除。
|
||||
* - meta 新增 totalCost / inputCost / outputCost / cacheCost / traceId;
|
||||
* provider / cacheReadInputTokens 已移除。
|
||||
*/
|
||||
internal fun parseUsageResponse(body: String): Pair<List<UsageRecord>, String?> {
|
||||
val root = json.parseToJsonElement(body).jsonObject
|
||||
val usages = root["usages"]?.jsonArray ?: return emptyList<UsageRecord>() to null
|
||||
|
||||
val records = usages.mapNotNull { elem ->
|
||||
parseUsageObject(elem.jsonObject)
|
||||
}
|
||||
|
||||
// 新格式:优先用服务端游标;缺失或显式 null 时回退按末条自编码(兼容旧格式)
|
||||
// 注意:JSON null 的 jsonPrimitive.content 是字符串 "null",必须用 is JsonNull 拦截,
|
||||
// 否则会把 "null" 当游标传给服务端(被忽略→返回第一页),导致 fullSync 死循环。
|
||||
val serverCursor = root["nextCursor"]?.takeIf { it !is JsonNull }
|
||||
?.jsonPrimitive?.content?.takeIf { it.isNotEmpty() }
|
||||
val nextCursor = serverCursor ?: if (records.size >= PAGE_SIZE) {
|
||||
val last = records.last()
|
||||
encodeCursor(last.id, last.timeCreated)
|
||||
} else null
|
||||
|
||||
return records to nextCursor
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析单条 usage 对象。
|
||||
*
|
||||
* cost 来源:新格式 meta.totalCost(美元数值);旧格式顶层 creditsTotal(兼容)。
|
||||
* provider:新格式已移除,缺失时为空字符串;旧格式 meta.provider 兼容读取。
|
||||
* cacheReadInputTokens:新格式已移除,缺失时按 0(tokensIn 不再拆分缓存命中)。
|
||||
*/
|
||||
internal fun parseUsageObject(obj: JsonObject): UsageRecord? {
|
||||
val id = obj["id"]?.jsonPrimitive?.content ?: return null
|
||||
val createdAt = obj["createdAt"]?.jsonPrimitive?.content ?: return null
|
||||
val timeCreated = parseIsoDate(createdAt) ?: return null
|
||||
|
||||
val tokensIn = obj["tokensIn"]?.jsonPrimitive?.content?.toLongOrNull() ?: 0L
|
||||
val tokensOut = obj["tokensOut"]?.jsonPrimitive?.content?.toLongOrNull() ?: 0L
|
||||
|
||||
val meta = obj["meta"]?.jsonObject
|
||||
val creditsTotal = meta?.get("totalCost")?.jsonPrimitive?.content?.toDoubleOrNull()
|
||||
?: obj["creditsTotal"]?.jsonPrimitive?.content?.toDoubleOrNull()
|
||||
?: 0.0
|
||||
val cost = (creditsTotal * COST_DENOM).toLong()
|
||||
|
||||
val model = meta?.get("model")?.jsonPrimitive?.content ?: ""
|
||||
val provider = meta?.get("provider")?.jsonPrimitive?.content ?: ""
|
||||
val cacheReadInputTokens = meta?.get("cacheReadInputTokens")?.jsonPrimitive?.content?.toLongOrNull() ?: 0L
|
||||
|
||||
// CCGO 的 tokensIn 是总输入(缓存命中 + 未命中),按 OCGO 惯例拆分为 inputTokens(未命中)和 cacheReadTokens(命中)
|
||||
// 新格式无缓存 token 数时无法拆分,inputTokens 直接取 tokensIn
|
||||
val inputMissTokens = (tokensIn - cacheReadInputTokens).coerceAtLeast(0)
|
||||
|
||||
return UsageRecord(
|
||||
id = id,
|
||||
workspaceId = CCGO_WORKSPACE_ID,
|
||||
timeCreated = timeCreated,
|
||||
timeUpdated = timeCreated,
|
||||
model = model,
|
||||
provider = provider,
|
||||
inputTokens = inputMissTokens,
|
||||
outputTokens = tokensOut,
|
||||
reasoningTokens = 0L,
|
||||
cacheReadTokens = cacheReadInputTokens,
|
||||
cacheWrite5mTokens = 0L,
|
||||
cacheWrite1hTokens = 0L,
|
||||
cost = cost,
|
||||
keyId = "",
|
||||
sessionId = "",
|
||||
enrichmentPlan = ""
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseIsoDate(iso: String): Long? {
|
||||
// 处理末尾 Z 和时区偏移
|
||||
val normalized = iso
|
||||
.replace("Z", "X")
|
||||
.replace(Regex("""[+-]\d{2}:\d{2}$"""), "X")
|
||||
return try {
|
||||
// SimpleDateFormat 非线程安全,每次创建新实例
|
||||
val sdf = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'X'", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
sdf.parse(normalized)?.time
|
||||
?: run {
|
||||
val sdf2 = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'X'", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
sdf2.parse(normalized)?.time
|
||||
}
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
/** 从记录信息编码为 base64 cursor */
|
||||
private fun encodeCursor(id: String, timeCreated: Long): String {
|
||||
val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
val createdAt = sdf.format(java.util.Date(timeCreated))
|
||||
val cursorJson = """{"createdAt":"$createdAt","id":"$id"}"""
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(cursorJson.toByteArray())
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getCookieHeader(): String {
|
||||
val c = credentialRepository.get(ServiceType.COMMANDCODE_GO)
|
||||
?: throw RepositoryError.InvalidCredential()
|
||||
if (c !is Credential.SessionCredential) {
|
||||
throw RepositoryError.InvalidCredential()
|
||||
}
|
||||
// 优先用 cookies 列表,否则尝试从 authCookie 字段解析
|
||||
if (c.cookies.isNotEmpty()) {
|
||||
return c.cookies.joinToString("; ") { "${it.name}=${it.value}" }
|
||||
}
|
||||
// fallback: 如果用户通过旧版接口存了 authCookie,尝试恢复
|
||||
throw RepositoryError.InvalidCredential()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定游标页的用量记录。
|
||||
* cursor=null 为最新页。
|
||||
* 返回 (记录列表, 下一页游标)。如果返回的列表长度 < PAGE_SIZE,表示到底。
|
||||
*/
|
||||
suspend fun fetchPage(cursor: String?): Result<Pair<List<UsageRecord>, String?>> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val cookieHeader = try {
|
||||
getCookieHeader()
|
||||
} catch (e: RepositoryError) {
|
||||
return@withContext Result.failure(e)
|
||||
}
|
||||
|
||||
val url = buildString {
|
||||
append("$apiBase/internal/usage?limit=$PAGE_SIZE")
|
||||
if (cursor != null) append("&cursor=$cursor")
|
||||
}
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.header("Cookie", cookieHeader)
|
||||
.header("Accept", "application/json")
|
||||
.header("Origin", "https://commandcode.ai")
|
||||
.header("Referer", "https://commandcode.ai/")
|
||||
.get()
|
||||
.build()
|
||||
|
||||
val response = try {
|
||||
okHttpClient.newCall(request).execute()
|
||||
} catch (e: IOException) {
|
||||
return@withContext Result.failure(RepositoryError.Network(e))
|
||||
}
|
||||
|
||||
response.use { resp ->
|
||||
val body = resp.body?.string()
|
||||
if (!resp.isSuccessful) {
|
||||
if (resp.code == 401 || resp.code == 403) {
|
||||
val detail = if (body != null && body.length < 200) ":$body" else ""
|
||||
return@withContext Result.failure(RepositoryError.InvalidCredential(
|
||||
"HTTP ${resp.code}$detail"
|
||||
))
|
||||
}
|
||||
return@withContext Result.failure(RepositoryError.ServerError(resp.code))
|
||||
}
|
||||
|
||||
if (body == null) return@withContext Result.failure(
|
||||
RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空")
|
||||
)
|
||||
|
||||
val records = parseUsageResponse(body)
|
||||
Result.success(records)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,66 +1,902 @@
|
||||
package com.rainy.token.data.repository
|
||||
|
||||
import com.rainy.token.data.cache.BalanceCache
|
||||
import com.rainy.token.data.cache.CachedBalance
|
||||
import com.rainy.token.data.local.SecureStorage
|
||||
import com.rainy.token.domain.model.AccountStore
|
||||
import com.rainy.token.domain.model.Credential
|
||||
import com.rainy.token.domain.model.CredentialStatus
|
||||
import com.rainy.token.domain.model.ServiceAccountInfo
|
||||
import com.rainy.token.domain.service.ServiceConfigProvider
|
||||
import com.rainy.token.domain.service.ServiceType
|
||||
import kotlinx.serialization.builtins.serializer
|
||||
import java.security.MessageDigest
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
/**
|
||||
* 凭据的统一读写入口。封装 SecureStorage 的 key 命名规则(用 [ServiceType.storageKey]),
|
||||
* 把 JSON 反序列化成密封类 [Credential]。
|
||||
* 凭据的统一读写入口。
|
||||
*
|
||||
* 所有凭据变更都经过 [mutationMutex] 串行化,并维护进程内 revision。网络刷新先取得
|
||||
* [CredentialSnapshot];请求结束后只有快照仍为当前版本时,暂存的凭据与余额才会提交。
|
||||
*
|
||||
* 多账号服务(Trae / WorkBuddy):
|
||||
* - 账号元数据([ServiceAccountInfo])存 `accounts_{storageKey}`(加密 DataStore);
|
||||
* - 每个账号的凭据独立存 `credential_{storageKey}_{accountId}`;
|
||||
* - 旧版本单凭据存 `credential_{storageKey}`,首次读取时自动迁移为默认账号
|
||||
* (固定 id = [LEGACY_DEFAULT_ACCOUNT_ID]),旧键保留作为兼容回退。
|
||||
*/
|
||||
@Singleton
|
||||
class CredentialRepository @Inject constructor(
|
||||
private val secureStorage: SecureStorage
|
||||
private val secureStorage: SecureStorage,
|
||||
private val balanceCache: BalanceCache
|
||||
) {
|
||||
|
||||
private fun keyFor(service: ServiceType): String = "credential_${service.storageKey}"
|
||||
|
||||
suspend fun save(credential: Credential) {
|
||||
secureStorage.put(
|
||||
key = keyFor(credential.service),
|
||||
value = credential,
|
||||
serializer = Credential.serializer()
|
||||
)
|
||||
internal data class CredentialSnapshot(
|
||||
val credential: Credential,
|
||||
val revision: Long,
|
||||
val fingerprint: String,
|
||||
/** 快照所属账号;单账号服务为 null。 */
|
||||
val accountId: String? = null,
|
||||
/** 创建快照时调用方请求的账号 ID(多账号服务可能是 null=默认账号),用于会话内精确匹配。 */
|
||||
val requestedAccountId: String? = accountId
|
||||
) {
|
||||
val service: ServiceType get() = credential.service
|
||||
}
|
||||
|
||||
suspend fun get(service: ServiceType): Credential? =
|
||||
secureStorage.get(
|
||||
key = keyFor(service),
|
||||
serializer = Credential.serializer()
|
||||
)
|
||||
internal data class LocalState(
|
||||
val status: CredentialStatus,
|
||||
val fingerprint: String?,
|
||||
val cachedBalance: CachedBalance?,
|
||||
/** 当前凭据对象(首页卡片可能需要读取如 Sub2API 站点 Logo 等附加字段)。 */
|
||||
val credential: Credential? = null
|
||||
)
|
||||
|
||||
suspend fun remove(service: ServiceType) {
|
||||
secureStorage.remove(keyFor(service))
|
||||
/** 按账号展开的本地状态(多账号服务一个账号一条;单账号服务一条 [account]=null)。 */
|
||||
data class AccountLocalState(
|
||||
val service: ServiceType,
|
||||
val account: ServiceAccountInfo?,
|
||||
val status: CredentialStatus,
|
||||
val fingerprint: String?,
|
||||
val cachedBalance: CachedBalance?,
|
||||
val credential: Credential?
|
||||
)
|
||||
|
||||
private data class CacheRollbackEntry(
|
||||
val testedFingerprint: String,
|
||||
val previousIdentity: String?,
|
||||
val cachedBalance: CachedBalance?
|
||||
)
|
||||
|
||||
private val mutationMutex = Mutex()
|
||||
private val revisions = mutableMapOf<String, Long>()
|
||||
private val cacheRollbackEntries = mutableMapOf<String, CacheRollbackEntry>()
|
||||
|
||||
private fun accountDocKeyFor(service: ServiceType): String = "accounts_${service.storageKey}"
|
||||
|
||||
/** 凭据存储键:单账号(或兼容回退)为 `credential_{storageKey}`。 */
|
||||
private fun keyFor(service: ServiceType, accountId: String?): String =
|
||||
if (accountId.isNullOrBlank()) {
|
||||
"credential_${service.storageKey}"
|
||||
} else {
|
||||
"credential_${service.storageKey}_$accountId"
|
||||
}
|
||||
|
||||
private fun accountCacheKey(service: ServiceType, accountId: String): String =
|
||||
"${service.storageKey}:$accountId"
|
||||
|
||||
private fun revisionKey(service: ServiceType, accountId: String?): String =
|
||||
"${service.storageKey}:${accountId ?: ""}"
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// 公开 API(多账号 + 单账号统一入口)
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
/** 列出某服务的账号(多账号服务;单账号服务恒为空列表)。 */
|
||||
suspend fun accountsFor(service: ServiceType): List<ServiceAccountInfo> =
|
||||
mutationMutex.withLock { readAccountsUnlocked(service) }
|
||||
|
||||
/** 当前默认账号;无账号返回 null。 */
|
||||
suspend fun getDefaultAccount(service: ServiceType): ServiceAccountInfo? =
|
||||
mutationMutex.withLock { resolveAccountUnlocked(service, null) }
|
||||
|
||||
/**
|
||||
* 新建账号(不写入凭据)。返回新账号;非多账号服务返回 null。
|
||||
* 首个账号自动设为默认账号,label 为空时 UI 按“账号 N”兜底显示。
|
||||
*/
|
||||
suspend fun createAccount(service: ServiceType, label: String? = null): ServiceAccountInfo? =
|
||||
mutationMutex.withLock {
|
||||
if (!ServiceType.isMultiAccount(service)) return@withLock null
|
||||
createAccountUnlocked(service, label)
|
||||
}
|
||||
|
||||
/** 设置默认账号(账号不存在时忽略)。切换默认账号时清除旧结构镜像缓存,避免新默认账号读到旧默认账号的余额。 */
|
||||
suspend fun setDefaultAccount(service: ServiceType, accountId: String) {
|
||||
mutationMutex.withLock {
|
||||
val accounts = readAccountsUnlocked(service)
|
||||
if (accounts.none { it.id == accountId }) return@withLock
|
||||
val previousDefault = accounts.firstOrNull { it.isDefault } ?: accounts.firstOrNull()
|
||||
putAccountsUnlocked(
|
||||
service,
|
||||
accounts.map { it.copy(isDefault = it.id == accountId) }
|
||||
)
|
||||
if (previousDefault?.id != accountId) {
|
||||
balanceCache.removeLegacy(service)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 重命名账号(空白 label 视为清除自定义名称)。 */
|
||||
suspend fun renameAccount(service: ServiceType, accountId: String, label: String) {
|
||||
mutationMutex.withLock {
|
||||
val accounts = readAccountsUnlocked(service)
|
||||
putAccountsUnlocked(
|
||||
service,
|
||||
accounts.map {
|
||||
if (it.id == accountId) it.copy(label = label.takeIf { l -> l.isNotBlank() }) else it
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取并转换为 UI 用的 [CredentialStatus]。未配置/已删除都返回 NOT_CONFIGURED。
|
||||
* 删除单个账号:删除该账号凭据 + 独立缓存;若删除的是默认账号则提升第一个
|
||||
* 剩余账号为默认;最后一个账号删除后整个服务回到未配置状态。
|
||||
*/
|
||||
suspend fun statusFor(service: ServiceType): CredentialStatus {
|
||||
val credential = get(service) ?: return CredentialStatus(
|
||||
suspend fun removeAccount(service: ServiceType, accountId: String) {
|
||||
mutationMutex.withLock {
|
||||
if (!ServiceType.isMultiAccount(service)) return@withLock
|
||||
val accounts = readAccountsUnlocked(service)
|
||||
val removed = accounts.firstOrNull { it.id == accountId } ?: return@withLock
|
||||
val remaining = accounts.filterNot { it.id == accountId }
|
||||
|
||||
bumpRevision(service, accountId)
|
||||
cacheRollbackEntries.remove(accountCacheKey(service, accountId))
|
||||
balanceCache.removeAccount(service, accountId)
|
||||
secureStorage.remove(keyFor(service, accountId))
|
||||
|
||||
if (remaining.isEmpty()) {
|
||||
secureStorage.remove(accountDocKeyFor(service))
|
||||
secureStorage.remove(keyFor(service, null))
|
||||
balanceCache.remove(service)
|
||||
clearRollbacksFor(service)
|
||||
} else {
|
||||
var fixed = remaining
|
||||
if (fixed.none { it.isDefault }) {
|
||||
val first = fixed.first()
|
||||
fixed = fixed.map { if (it.id == first.id) it.copy(isDefault = true) else it }
|
||||
}
|
||||
putAccountsUnlocked(service, fixed)
|
||||
// 删除的是默认账号时,旧结构镜像缓存属于被删账号,必须只清镜像(保留其余账号的独立分片缓存,
|
||||
// 避免全部账号缓存被误清导致都要重新联网拉取)
|
||||
if (removed.isDefault) {
|
||||
balanceCache.removeLegacy(service)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存凭据。
|
||||
*
|
||||
* - 多账号服务:[accountId] 为 null 时**总是新建账号**(导航层保证 null 只来自
|
||||
* 「添加账号」流程),首个新建账号自动成为默认账号;显式 [accountId] 覆盖对应账号。
|
||||
* 返回实际写入的账号 ID(用于新建账号后继续编辑)。
|
||||
* - 单账号服务:忽略 [accountId],行为与旧版一致,返回空串。
|
||||
* - 在 [RefreshWriteSession] 上下文中只暂存,返回快照账号 ID。
|
||||
*/
|
||||
suspend fun save(credential: Credential, accountId: String? = null): String {
|
||||
currentCoroutineContext()[RefreshWriteSession]?.let { session ->
|
||||
session.stageCredential(credential)
|
||||
return session.snapshot.accountId ?: ""
|
||||
}
|
||||
|
||||
mutationMutex.withLock {
|
||||
val service = credential.service
|
||||
val effectiveId = if (ServiceType.isMultiAccount(service)) {
|
||||
if (accountId.isNullOrBlank()) {
|
||||
// 多账号服务的 null accountId = “新建账号”模式(导航层保证),总是新建,
|
||||
// 避免覆盖默认账号;首个账号自动成为默认账号。
|
||||
createAccountUnlocked(service, null).id
|
||||
} else {
|
||||
resolveAccountUnlocked(service, accountId)?.id
|
||||
?: createAccountUnlocked(service, null).id
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val key = revisionKey(service, effectiveId)
|
||||
val current = getUnlocked(service, effectiveId)
|
||||
val currentIdentity = cacheIdentityFingerprint(current)
|
||||
val newIdentity = cacheIdentityFingerprint(credential)
|
||||
val cacheIdentityChanged = currentIdentity != newIdentity
|
||||
|
||||
bumpRevision(service, effectiveId)
|
||||
if (cacheIdentityChanged) {
|
||||
cacheRollbackEntries[key] = CacheRollbackEntry(
|
||||
testedFingerprint = credentialFingerprint(credential)!!,
|
||||
previousIdentity = currentIdentity,
|
||||
cachedBalance = if (current != null) {
|
||||
accountCacheFor(service, effectiveId)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
)
|
||||
removeCacheFor(service, effectiveId)
|
||||
} else {
|
||||
cacheRollbackEntries.remove(key)
|
||||
}
|
||||
putUnlocked(credential, effectiveId)
|
||||
return effectiveId ?: ""
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun get(service: ServiceType, accountId: String? = null): Credential? {
|
||||
currentCoroutineContext()[RefreshWriteSession]?.let { session ->
|
||||
if (session.snapshot.service == service) {
|
||||
val matches = if (ServiceType.isMultiAccount(service)) {
|
||||
val requested = accountId?.takeIf { it.isNotBlank() }
|
||||
// 只允许读取与快照同一账号的暂存凭据;null=默认账号也要看快照当初请求的是不是默认账号,
|
||||
// 避免 A 账号刷新期间把 A 的暂存凭据误交给默认账号(B)的读请求。
|
||||
requested == session.snapshot.requestedAccountId
|
||||
} else {
|
||||
true
|
||||
}
|
||||
if (matches) return session.credentialForRead()
|
||||
}
|
||||
}
|
||||
return mutationMutex.withLock { getUnlocked(service, accountId) }
|
||||
}
|
||||
|
||||
internal suspend fun readLocalState(
|
||||
service: ServiceType,
|
||||
accountId: String? = null
|
||||
): LocalState =
|
||||
mutationMutex.withLock {
|
||||
val credential = getUnlocked(service, accountId)
|
||||
localStateOf(service, credential, accountCacheFor(service, accountId))
|
||||
}
|
||||
|
||||
/** 全部服务的按账号本地状态(保留单账号服务旧语义,多账号展开一条/账号)。 */
|
||||
suspend fun readAccountStates(): List<AccountLocalState> =
|
||||
mutationMutex.withLock { readAccountStatesUnlocked() }
|
||||
|
||||
internal suspend fun readLocalStates(): Map<ServiceType, LocalState> =
|
||||
mutationMutex.withLock {
|
||||
val accountStates = readAccountStatesUnlocked()
|
||||
val legacyCached = balanceCache.getAll()
|
||||
ServiceType.entries.associateWith { service ->
|
||||
// 默认账号优先(多账号服务中自动签到/小组件使用的账号)
|
||||
val entry = accountStates
|
||||
.filter { it.service == service }
|
||||
.minByOrNull { if (it.account?.isDefault == true) 0 else 1 }
|
||||
if (entry != null) {
|
||||
localStateOf(service, entry.credential, entry.cachedBalance)
|
||||
} else {
|
||||
val credential = readLegacyCredentialUnlocked(service)
|
||||
localStateOf(service, credential, legacyCached[service])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun remove(service: ServiceType) {
|
||||
mutationMutex.withLock {
|
||||
val accounts = if (ServiceType.isMultiAccount(service)) {
|
||||
readAccountsUnlocked(service)
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
clearRollbacksFor(service)
|
||||
bumpRevision(service, null)
|
||||
accounts.forEach { account ->
|
||||
bumpRevision(service, account.id)
|
||||
secureStorage.remove(keyFor(service, account.id))
|
||||
}
|
||||
secureStorage.remove(keyFor(service, null))
|
||||
secureStorage.remove(accountDocKeyFor(service))
|
||||
balanceCache.remove(service)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅当测试期间凭据 revision 与认证指纹都未变化时恢复旧凭据。
|
||||
* 若替换凭据时清除了旧账户缓存,这里会一并恢复原缓存和原 fetchedAt。
|
||||
*/
|
||||
internal suspend fun restoreIfCurrent(
|
||||
testedSnapshot: CredentialSnapshot,
|
||||
previous: Credential?
|
||||
): Boolean = mutationMutex.withLock {
|
||||
val service = testedSnapshot.service
|
||||
val accountId = testedSnapshot.accountId
|
||||
val current = getUnlocked(service, accountId) ?: return@withLock false
|
||||
if (
|
||||
!snapshotMatches(
|
||||
snapshotRevision = testedSnapshot.revision,
|
||||
snapshotFingerprint = testedSnapshot.fingerprint,
|
||||
currentRevision = revisionFor(service, accountId),
|
||||
currentFingerprint = credentialFingerprint(current)
|
||||
)
|
||||
) return@withLock false
|
||||
|
||||
val currentIdentity = cacheIdentityFingerprint(current)
|
||||
val previousIdentity = cacheIdentityFingerprint(previous)
|
||||
val identityChanged = currentIdentity != previousIdentity
|
||||
val rollbackEntry = cacheRollbackEntries[revisionKey(service, accountId)]?.takeIf {
|
||||
it.testedFingerprint == testedSnapshot.fingerprint &&
|
||||
it.previousIdentity == previousIdentity
|
||||
}
|
||||
|
||||
bumpRevision(service, accountId)
|
||||
if (identityChanged) {
|
||||
removeCacheFor(service, accountId)
|
||||
}
|
||||
if (previous == null) {
|
||||
secureStorage.remove(keyFor(service, accountId))
|
||||
} else {
|
||||
require(previous.service == service) { "回滚凭据服务不匹配" }
|
||||
putUnlocked(previous, accountId)
|
||||
}
|
||||
if (identityChanged) {
|
||||
rollbackEntry?.cachedBalance?.let {
|
||||
if (accountId.isNullOrBlank()) {
|
||||
balanceCache.putCached(service, it)
|
||||
} else {
|
||||
balanceCache.putAccountCached(service, accountId, it)
|
||||
if (resolveAccountUnlocked(service, null)?.id == accountId) {
|
||||
balanceCache.putCached(service, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cacheRollbackEntries.remove(revisionKey(service, accountId))
|
||||
true
|
||||
}
|
||||
|
||||
internal suspend fun snapshot(
|
||||
service: ServiceType,
|
||||
accountId: String? = null
|
||||
): CredentialSnapshot? =
|
||||
mutationMutex.withLock {
|
||||
val resolvedId = if (ServiceType.isMultiAccount(service)) {
|
||||
resolveAccountUnlocked(service, accountId)?.id
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val credential = getUnlocked(service, resolvedId) ?: return@withLock null
|
||||
CredentialSnapshot(
|
||||
credential = credential,
|
||||
revision = revisionFor(service, resolvedId),
|
||||
fingerprint = credentialFingerprint(credential)!!,
|
||||
accountId = resolvedId,
|
||||
requestedAccountId = accountId?.takeIf { it.isNotBlank() }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交 [RefreshWriteSession] 中暂存的写入。
|
||||
*
|
||||
* 完全只读的请求忽略同一凭据的 revision-only 变化,但成功和失败都必须确认认证
|
||||
* 指纹仍与请求起点一致,避免把旧账户结果发布到用户刚替换的新凭据。存在写入时
|
||||
* 通常要求快照完全匹配;认证字段合法轮换时允许越过同账户纯元数据版本变化。
|
||||
*/
|
||||
internal suspend fun commit(
|
||||
session: RefreshWriteSession,
|
||||
includeBalance: Boolean
|
||||
): Boolean = mutationMutex.withLock {
|
||||
val pendingCredential = session.stagedCredential()
|
||||
val pendingBalance = session.stagedBalance().takeIf { includeBalance }
|
||||
val hasPendingWrites = pendingCredential != null || pendingBalance != null
|
||||
|
||||
val snapshot = session.snapshot
|
||||
val service = snapshot.service
|
||||
val accountId = snapshot.accountId
|
||||
val current = getUnlocked(service, accountId) ?: return@withLock false
|
||||
val currentFingerprint = credentialFingerprint(current)
|
||||
|
||||
if (!hasPendingWrites) {
|
||||
return@withLock currentFingerprint == snapshot.fingerprint
|
||||
}
|
||||
|
||||
val snapshotStillCurrent = snapshotMatches(
|
||||
snapshotRevision = snapshot.revision,
|
||||
snapshotFingerprint = snapshot.fingerprint,
|
||||
currentRevision = revisionFor(service, accountId),
|
||||
currentFingerprint = currentFingerprint
|
||||
)
|
||||
val canMergeRotatedCredential = pendingCredential != null &&
|
||||
credentialFingerprint(pendingCredential) != snapshot.fingerprint &&
|
||||
currentFingerprint == snapshot.fingerprint &&
|
||||
sameRefreshLineage(snapshot.credential, pendingCredential)
|
||||
|
||||
if (!snapshotStillCurrent && !canMergeRotatedCredential) return@withLock false
|
||||
|
||||
val finalCredential = pendingCredential ?: current
|
||||
val sameLineage = sameRefreshLineage(current, finalCredential)
|
||||
val cacheIdentityChanged =
|
||||
cacheIdentityFingerprint(current) != cacheIdentityFingerprint(finalCredential) &&
|
||||
!sameLineage
|
||||
|
||||
bumpRevision(service, accountId)
|
||||
if (cacheIdentityChanged) {
|
||||
removeCacheFor(service, accountId)
|
||||
}
|
||||
if (pendingCredential != null) {
|
||||
putUnlocked(pendingCredential, accountId)
|
||||
}
|
||||
if (pendingBalance != null) {
|
||||
val cached = CachedBalance(balance = pendingBalance, fetchedAt = System.currentTimeMillis())
|
||||
if (accountId.isNullOrBlank()) {
|
||||
balanceCache.putCached(service, cached)
|
||||
} else {
|
||||
balanceCache.putAccountCached(service, accountId, cached)
|
||||
if (resolveAccountUnlocked(service, null)?.id == accountId) {
|
||||
balanceCache.putCached(service, cached)
|
||||
}
|
||||
}
|
||||
cacheRollbackEntries.remove(revisionKey(service, accountId))
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
suspend fun statusFor(
|
||||
service: ServiceType,
|
||||
accountId: String? = null
|
||||
): CredentialStatus {
|
||||
val credential = get(service, accountId) ?: return CredentialStatus(
|
||||
service = service,
|
||||
state = CredentialStatus.State.NOT_CONFIGURED,
|
||||
lastVerifiedAt = 0L
|
||||
)
|
||||
val now = System.currentTimeMillis()
|
||||
// 简单启发:最近 7 天内有验证 → OK;否则按 lastVerifiedAt 是否为 0 判断
|
||||
val state = when {
|
||||
credential.lastVerifiedAt == 0L -> CredentialStatus.State.WARNING
|
||||
now - credential.lastVerifiedAt > 7L * 24 * 3600 * 1000 -> CredentialStatus.State.WARNING
|
||||
else -> CredentialStatus.State.OK
|
||||
}
|
||||
return CredentialStatus(
|
||||
service = service,
|
||||
state = state,
|
||||
state = determineCredentialState(
|
||||
credential.lastVerifiedAt,
|
||||
System.currentTimeMillis()
|
||||
),
|
||||
lastVerifiedAt = credential.lastVerifiedAt
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun statusForAll(): List<CredentialStatus> =
|
||||
ServiceConfigProvider.all().map { statusFor(it.type) }
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// 实现细节
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
private suspend fun readAccountStatesUnlocked(): List<AccountLocalState> {
|
||||
val cached = balanceCache.getAllAccounts()
|
||||
val legacyCached = balanceCache.getAll()
|
||||
return ServiceType.entries.flatMap { service ->
|
||||
if (ServiceType.isMultiAccount(service)) {
|
||||
val accounts = readAccountsUnlocked(service)
|
||||
if (accounts.isEmpty()) {
|
||||
val legacy = readLegacyCredentialUnlocked(service)
|
||||
if (legacy == null) {
|
||||
emptyList()
|
||||
} else {
|
||||
listOf(accountLocalStateOf(service, null, legacy, legacyCached[service]))
|
||||
}
|
||||
} else {
|
||||
accounts.map { account ->
|
||||
val credential = getUnlocked(service, account.id)
|
||||
val cache = cached[accountCacheKey(service, account.id)]
|
||||
?: if (account.isDefault) legacyCached[service] else null
|
||||
accountLocalStateOf(service, account, credential, cache)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val credential = readLegacyCredentialUnlocked(service)
|
||||
if (credential == null) {
|
||||
emptyList()
|
||||
} else {
|
||||
listOf(accountLocalStateOf(service, null, credential, legacyCached[service]))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun accountLocalStateOf(
|
||||
service: ServiceType,
|
||||
account: ServiceAccountInfo?,
|
||||
credential: Credential?,
|
||||
cached: CachedBalance?
|
||||
): AccountLocalState {
|
||||
val status = if (credential == null) {
|
||||
CredentialStatus(
|
||||
service = service,
|
||||
state = CredentialStatus.State.NOT_CONFIGURED,
|
||||
lastVerifiedAt = 0L
|
||||
)
|
||||
} else {
|
||||
CredentialStatus(
|
||||
service = service,
|
||||
state = determineCredentialState(
|
||||
credential.lastVerifiedAt,
|
||||
System.currentTimeMillis()
|
||||
),
|
||||
lastVerifiedAt = credential.lastVerifiedAt
|
||||
)
|
||||
}
|
||||
return AccountLocalState(
|
||||
service = service,
|
||||
account = account,
|
||||
status = status,
|
||||
fingerprint = credentialFingerprint(credential),
|
||||
cachedBalance = cached,
|
||||
credential = credential
|
||||
)
|
||||
}
|
||||
|
||||
private fun localStateOf(
|
||||
service: ServiceType,
|
||||
credential: Credential?,
|
||||
cached: CachedBalance?
|
||||
): LocalState {
|
||||
val status = if (credential == null) {
|
||||
CredentialStatus(
|
||||
service = service,
|
||||
state = CredentialStatus.State.NOT_CONFIGURED,
|
||||
lastVerifiedAt = 0L
|
||||
)
|
||||
} else {
|
||||
CredentialStatus(
|
||||
service = service,
|
||||
state = determineCredentialState(
|
||||
credential.lastVerifiedAt,
|
||||
System.currentTimeMillis()
|
||||
),
|
||||
lastVerifiedAt = credential.lastVerifiedAt
|
||||
)
|
||||
}
|
||||
return LocalState(
|
||||
status = status,
|
||||
fingerprint = credentialFingerprint(credential),
|
||||
cachedBalance = cached,
|
||||
credential = credential
|
||||
)
|
||||
}
|
||||
|
||||
/** 读取账号元数据;多账号服务且文档不存在时尝试从旧单凭据迁移。 */
|
||||
private suspend fun readAccountsUnlocked(service: ServiceType): List<ServiceAccountInfo> {
|
||||
if (!ServiceType.isMultiAccount(service)) return emptyList()
|
||||
val stored = secureStorage.get(accountDocKeyFor(service), AccountStore.serializer())
|
||||
?: return migrateLegacyUnlocked(service)
|
||||
return stored.accounts
|
||||
}
|
||||
|
||||
/** 旧版本单凭据迁移为默认账号(幂等,失败重试安全)。 */
|
||||
private suspend fun migrateLegacyUnlocked(service: ServiceType): List<ServiceAccountInfo> {
|
||||
val legacy = readLegacyCredentialUnlocked(service) ?: return emptyList()
|
||||
val account = ServiceAccountInfo(
|
||||
id = LEGACY_DEFAULT_ACCOUNT_ID,
|
||||
label = null,
|
||||
isDefault = true,
|
||||
createdAt = System.currentTimeMillis()
|
||||
)
|
||||
// 先复制凭据、再写账号文档;半途失败时下次读到旧键会重新迁移
|
||||
secureStorage.put(keyFor(service, account.id), legacy, Credential.serializer())
|
||||
putAccountsUnlocked(service, listOf(account))
|
||||
return listOf(account)
|
||||
}
|
||||
|
||||
private suspend fun putAccountsUnlocked(
|
||||
service: ServiceType,
|
||||
accounts: List<ServiceAccountInfo>
|
||||
) {
|
||||
secureStorage.put(
|
||||
key = accountDocKeyFor(service),
|
||||
value = AccountStore(service = service, accounts = accounts),
|
||||
serializer = AccountStore.serializer()
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun createAccountUnlocked(
|
||||
service: ServiceType,
|
||||
label: String?
|
||||
): ServiceAccountInfo {
|
||||
val accounts = readAccountsUnlocked(service).toMutableList()
|
||||
val account = ServiceAccountInfo(
|
||||
id = "acct_" + UUID.randomUUID().toString().replace("-", "").take(12),
|
||||
label = label?.takeIf { it.isNotBlank() },
|
||||
isDefault = accounts.isEmpty(),
|
||||
createdAt = System.currentTimeMillis()
|
||||
)
|
||||
accounts += account
|
||||
putAccountsUnlocked(service, accounts)
|
||||
return account
|
||||
}
|
||||
|
||||
/** 解析目标账号:显式 ID → 默认账号(无显式时)。 */
|
||||
private suspend fun resolveAccountUnlocked(
|
||||
service: ServiceType,
|
||||
accountId: String?
|
||||
): ServiceAccountInfo? {
|
||||
if (!ServiceType.isMultiAccount(service)) return null
|
||||
val accounts = readAccountsUnlocked(service)
|
||||
if (accounts.isEmpty()) return null
|
||||
val id = accountId?.takeIf { it.isNotBlank() }
|
||||
return if (id != null) {
|
||||
accounts.firstOrNull { it.id == id }
|
||||
} else {
|
||||
accounts.firstOrNull { it.isDefault } ?: accounts.first()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun accountsDocExistsUnlocked(service: ServiceType): Boolean =
|
||||
secureStorage.get(accountDocKeyFor(service), AccountStore.serializer()) != null
|
||||
|
||||
/** 读取旧结构单凭据(多账号服务的旧版本数据 / 单账号服务的标准凭据)。 */
|
||||
private suspend fun readLegacyCredentialUnlocked(service: ServiceType): Credential? =
|
||||
secureStorage.get(keyFor(service, null), Credential.serializer())
|
||||
|
||||
/**
|
||||
* 按账号读取凭据:
|
||||
* - 多账号服务:解析账号后读独立键;无账号文档时回退旧键;
|
||||
* - 单账号服务:恒读旧键。
|
||||
*/
|
||||
private suspend fun getUnlocked(
|
||||
service: ServiceType,
|
||||
accountId: String? = null
|
||||
): Credential? {
|
||||
if (ServiceType.isMultiAccount(service)) {
|
||||
val resolved = resolveAccountUnlocked(service, accountId)
|
||||
if (resolved != null) {
|
||||
return secureStorage.get(keyFor(service, resolved.id), Credential.serializer())
|
||||
}
|
||||
return if (accountId.isNullOrBlank() && !accountsDocExistsUnlocked(service)) {
|
||||
readLegacyCredentialUnlocked(service)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
return readLegacyCredentialUnlocked(service)
|
||||
}
|
||||
|
||||
private suspend fun putUnlocked(credential: Credential, accountId: String?) {
|
||||
secureStorage.put(
|
||||
key = keyFor(credential.service, accountId),
|
||||
value = credential,
|
||||
serializer = Credential.serializer()
|
||||
)
|
||||
}
|
||||
|
||||
/** 按账号读缓存:优先账号分片,默认账号回退旧结构镜像。 */
|
||||
private suspend fun accountCacheFor(
|
||||
service: ServiceType,
|
||||
accountId: String?
|
||||
): CachedBalance? {
|
||||
if (!ServiceType.isMultiAccount(service)) return balanceCache.get(service)
|
||||
val id = accountId?.takeIf { it.isNotBlank() }
|
||||
return if (id != null) {
|
||||
balanceCache.getAccountCached(service, id)
|
||||
?: if (resolveAccountUnlocked(service, null)?.id == id) balanceCache.get(service) else null
|
||||
} else {
|
||||
resolveAccountUnlocked(service, null)?.let { account ->
|
||||
balanceCache.getAccountCached(service, account.id) ?: balanceCache.get(service)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除某账号(或单账号服务)关联的余额缓存;默认账号会一并清理旧结构镜像。 */
|
||||
private suspend fun removeCacheFor(service: ServiceType, accountId: String?) {
|
||||
if (ServiceType.isMultiAccount(service)) {
|
||||
if (accountId.isNullOrBlank()) {
|
||||
balanceCache.removeLegacy(service)
|
||||
} else {
|
||||
balanceCache.removeAccount(service, accountId)
|
||||
if (resolveAccountUnlocked(service, null)?.id == accountId) {
|
||||
// 默认账号被替换:旧结构镜像属于旧默认账号,只清镜像,保留其余账号分片缓存
|
||||
balanceCache.removeLegacy(service)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
balanceCache.remove(service)
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearRollbacksFor(service: ServiceType) {
|
||||
val prefix = service.storageKey + ":"
|
||||
cacheRollbackEntries.keys
|
||||
.filter { it == service.storageKey || it.startsWith(prefix) }
|
||||
.forEach { cacheRollbackEntries.remove(it) }
|
||||
}
|
||||
|
||||
private fun revisionFor(service: ServiceType, accountId: String?): Long =
|
||||
revisions[revisionKey(service, accountId)] ?: 0L
|
||||
|
||||
private fun bumpRevision(service: ServiceType, accountId: String?) {
|
||||
revisions[revisionKey(service, accountId)] = revisionFor(service, accountId) + 1L
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** 旧单凭据迁移为默认账号时使用的固定账号 ID。 */
|
||||
const val LEGACY_DEFAULT_ACCOUNT_ID = "default"
|
||||
|
||||
internal fun determineCredentialState(
|
||||
lastVerifiedAt: Long,
|
||||
now: Long
|
||||
): CredentialStatus.State = when {
|
||||
lastVerifiedAt == 0L -> CredentialStatus.State.WARNING
|
||||
now - lastVerifiedAt > 7L * 24 * 3600 * 1000 -> CredentialStatus.State.WARNING
|
||||
else -> CredentialStatus.State.OK
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断认证更新是否来自同一条合法刷新链。Codex 的 refresh 响应由旧凭据 copy
|
||||
* 产生,因此 accountId(即使为空)必须保持一致;其他类型要求余额账户身份一致。
|
||||
*/
|
||||
internal fun sameRefreshLineage(
|
||||
original: Credential,
|
||||
updated: Credential
|
||||
): Boolean = when {
|
||||
original is Credential.CodexCredential && updated is Credential.CodexCredential ->
|
||||
original.service == updated.service && original.accountId == updated.accountId
|
||||
else -> cacheIdentityFingerprint(original) == cacheIdentityFingerprint(updated)
|
||||
}
|
||||
|
||||
/**
|
||||
* 余额缓存所属账户的不可逆标识。Codex 的短期 Token 轮换不会改变账户标识;
|
||||
* SessionCredential 的触发用 API Key 也不会影响余额账户。
|
||||
*/
|
||||
internal fun cacheIdentityFingerprint(credential: Credential?): String? {
|
||||
if (credential == null) return null
|
||||
val material = buildString {
|
||||
field("service", credential.service.storageKey)
|
||||
when (credential) {
|
||||
is Credential.ApiKeyCredential -> {
|
||||
field("type", "api-key")
|
||||
field("key", credential.key)
|
||||
}
|
||||
is Credential.SessionCredential -> {
|
||||
field("type", "session")
|
||||
field("token", credential.token)
|
||||
field("authCookie", credential.authCookie)
|
||||
field("workspaceId", credential.workspaceId)
|
||||
field("ollamaCookie", credential.ollamaCookie)
|
||||
credential.cookies.sortedForFingerprint().forEachIndexed { index, cookie ->
|
||||
field("cookie[$index].name", cookie.name)
|
||||
field("cookie[$index].value", cookie.value)
|
||||
field("cookie[$index].domain", cookie.domain)
|
||||
field("cookie[$index].path", cookie.path)
|
||||
}
|
||||
}
|
||||
is Credential.CodexCredential -> {
|
||||
field("type", "codex")
|
||||
if (credential.accountId.isNotBlank()) {
|
||||
field("accountId", credential.accountId)
|
||||
} else {
|
||||
field("accessToken", credential.accessToken)
|
||||
field("refreshToken", credential.refreshToken)
|
||||
}
|
||||
}
|
||||
is Credential.TraeCredential -> {
|
||||
field("type", "trae")
|
||||
field("jwt", credential.jwt)
|
||||
field("region", credential.region)
|
||||
}
|
||||
is Credential.WorkBuddyCredential -> {
|
||||
// WorkBuddy 无公开稳定账户 ID;access/refresh token 会话内会轮换,
|
||||
// 若把 token 纳入身份,每次轮换都会误清余额缓存。这里用固定身份:
|
||||
// 余额缓存归属同一账号(换账号场景由下一次成功刷新直接覆盖)。
|
||||
field("type", "workbuddy")
|
||||
field("identity", "account")
|
||||
}
|
||||
is Credential.Sub2ApiCredential -> {
|
||||
field("type", "sub2api")
|
||||
field("baseUrl", credential.baseUrl)
|
||||
field("email", credential.email)
|
||||
}
|
||||
}
|
||||
}
|
||||
return sha256(material)
|
||||
}
|
||||
|
||||
internal fun snapshotMatches(
|
||||
snapshotRevision: Long,
|
||||
snapshotFingerprint: String,
|
||||
currentRevision: Long,
|
||||
currentFingerprint: String?
|
||||
): Boolean =
|
||||
snapshotRevision == currentRevision && snapshotFingerprint == currentFingerprint
|
||||
|
||||
/** 对认证相关字段生成不可逆 SHA-256 指纹;不包含 lastVerifiedAt。 */
|
||||
internal fun credentialFingerprint(credential: Credential?): String? {
|
||||
if (credential == null) return null
|
||||
val material = buildString {
|
||||
field("service", credential.service.storageKey)
|
||||
when (credential) {
|
||||
is Credential.ApiKeyCredential -> {
|
||||
field("type", "api-key")
|
||||
field("key", credential.key)
|
||||
}
|
||||
is Credential.SessionCredential -> {
|
||||
field("type", "session")
|
||||
field("token", credential.token)
|
||||
field("authCookie", credential.authCookie)
|
||||
field("workspaceId", credential.workspaceId)
|
||||
field("ollamaCookie", credential.ollamaCookie)
|
||||
field("apiKey", credential.apiKey)
|
||||
credential.cookies.sortedForFingerprint().forEachIndexed { index, cookie ->
|
||||
field("cookie[$index].name", cookie.name)
|
||||
field("cookie[$index].value", cookie.value)
|
||||
field("cookie[$index].domain", cookie.domain)
|
||||
field("cookie[$index].path", cookie.path)
|
||||
field("cookie[$index].expiresAt", cookie.expiresAt?.toString())
|
||||
field("cookie[$index].secure", cookie.isSecure.toString())
|
||||
field("cookie[$index].httpOnly", cookie.isHttpOnly.toString())
|
||||
}
|
||||
}
|
||||
is Credential.CodexCredential -> {
|
||||
field("type", "codex")
|
||||
field("accessToken", credential.accessToken)
|
||||
field("refreshToken", credential.refreshToken)
|
||||
field("accountId", credential.accountId)
|
||||
}
|
||||
is Credential.TraeCredential -> {
|
||||
field("type", "trae")
|
||||
field("jwt", credential.jwt)
|
||||
field("region", credential.region)
|
||||
}
|
||||
is Credential.WorkBuddyCredential -> {
|
||||
field("type", "workbuddy")
|
||||
field("accessToken", credential.accessToken)
|
||||
field("refreshToken", credential.refreshToken)
|
||||
field("expiresAt", credential.expiresAt.toString())
|
||||
}
|
||||
is Credential.Sub2ApiCredential -> {
|
||||
field("type", "sub2api")
|
||||
field("baseUrl", credential.baseUrl)
|
||||
field("email", credential.email)
|
||||
field("password", credential.password)
|
||||
field("apiKey", credential.apiKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
return sha256(material)
|
||||
}
|
||||
|
||||
/** 账号存储键(与 BalanceCache 的复合键格式一致,供测试断言)。 */
|
||||
internal fun accountCacheKeyFor(service: ServiceType, accountId: String): String =
|
||||
"${service.storageKey}:$accountId"
|
||||
|
||||
private fun List<com.rainy.token.domain.model.CookieEntry>.sortedForFingerprint() =
|
||||
sortedWith(
|
||||
compareBy(
|
||||
{ it.name },
|
||||
{ it.domain.orEmpty() },
|
||||
{ it.path.orEmpty() },
|
||||
{ it.value }
|
||||
)
|
||||
)
|
||||
|
||||
private fun StringBuilder.field(name: String, value: String?) {
|
||||
append(name)
|
||||
append('=')
|
||||
if (value == null) {
|
||||
append("-1:")
|
||||
} else {
|
||||
append(value.length)
|
||||
append(':')
|
||||
append(value)
|
||||
}
|
||||
append(';')
|
||||
}
|
||||
|
||||
private fun sha256(value: String): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
.digest(value.toByteArray(Charsets.UTF_8))
|
||||
val chars = CharArray(digest.size * 2)
|
||||
val hex = "0123456789abcdef"
|
||||
digest.forEachIndexed { index, byte ->
|
||||
val unsigned = byte.toInt() and 0xff
|
||||
chars[index * 2] = hex[unsigned ushr 4]
|
||||
chars[index * 2 + 1] = hex[unsigned and 0x0f]
|
||||
}
|
||||
return String(chars)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -76,7 +76,7 @@ class DeepSeekRepository @Inject constructor(
|
||||
}
|
||||
|
||||
private fun mapHttpError(e: HttpException): RepositoryError = when (e.code()) {
|
||||
401, 403 -> RepositoryError.InvalidCredential(e)
|
||||
401, 403 -> RepositoryError.InvalidCredential(cause = e)
|
||||
429 -> {
|
||||
val retryAfter = e.response()?.headers()?.get("Retry-After")?.toLongOrNull()
|
||||
RepositoryError.RateLimited(retryAfter)
|
||||
|
||||
@ -0,0 +1,345 @@
|
||||
package com.rainy.token.data.repository
|
||||
|
||||
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
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.IOException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Ollama Cloud 用量仓库。
|
||||
*
|
||||
* Ollama 没有公开的 Usage API(见 ollama/ollama#12532),
|
||||
* 本 Repository 通过 OkHttp 搓 Cookie 请求 https://ollama.com/settings,
|
||||
* 解析返回 HTML 中的用量数据:
|
||||
*
|
||||
* - Plan 层级(Pro / Max / Free):Cloud usage 标题旁的 rounded-full badge
|
||||
* - Session usage 百分比:Session usage 区域的文本
|
||||
* - Weekly usage 百分比:Weekly usage 区域的文本
|
||||
* - 重置时间:data-time 属性(第 1 个=Session,第 2 个=Weekly)
|
||||
* - 模型级请求次数:data-model + data-requests 属性
|
||||
*
|
||||
* 认证方式:用户从浏览器 DevTools 复制完整 Cookie 字符串(至少含 `__Secure-session`)。
|
||||
*/
|
||||
@Singleton
|
||||
class OllamaRepository(
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val credentialRepository: CredentialRepository,
|
||||
private val balanceCache: BalanceCache
|
||||
) {
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
companion object {
|
||||
private const val TAG = "Ollama"
|
||||
private const val SETTINGS_URL = "https://ollama.com/settings"
|
||||
private const val MODELS_API = "https://models.dev/api.json"
|
||||
private const val CHAT_API = "https://ollama.com/v1/chat/completions"
|
||||
private const val USER_AGENT =
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36"
|
||||
|
||||
/**
|
||||
* 解析 Ollama settings 页面 HTML,提取用量数据。
|
||||
* 正则方案与 hermes-ollama-cloud-usage 和 CodexBar 一致。
|
||||
*/
|
||||
internal fun parseUsage(html: String): ParsedUsage? {
|
||||
// Plan: "Cloud usage" 后的 rounded-full badge 文本
|
||||
val planMatch = Regex(
|
||||
"""Cloud usage[^<]*</span>\s*<span[^>]*class="[^"]*rounded-full[^"]*"[^>]*>\s*(pro|max|free)\s*</span""",
|
||||
RegexOption.IGNORE_CASE
|
||||
).find(html)
|
||||
val plan = planMatch?.groupValues?.get(1)?.trim()?.replaceFirstChar { it.uppercaseChar() }
|
||||
?: run {
|
||||
// Fallback: 任意 rounded-full + text-neutral badge
|
||||
val fallback = Regex(
|
||||
"""<span[^>]*class="[^"]*rounded-full[^"]*text-neutral[^"]*"[^>]*>\s*\n?\s*(pro|max|free)\s*</span""",
|
||||
RegexOption.IGNORE_CASE
|
||||
).find(html)
|
||||
fallback?.groupValues?.get(1)?.trim()?.replaceFirstChar { it.uppercaseChar() }
|
||||
}
|
||||
|
||||
// Session usage: 先找 aria-label,再找 visible text
|
||||
val sessionPct = Regex("""aria-label="Session usage\s+([\d.]+)%\s+used""", RegexOption.IGNORE_CASE)
|
||||
.find(html)?.groupValues?.get(1)?.toFloatOrNull()
|
||||
?: Regex("""Session usage[^<]*>[\s\S]*?([\d.]+)%\s*used""", RegexOption.IGNORE_CASE)
|
||||
.find(html)?.groupValues?.get(1)?.toFloatOrNull()
|
||||
|
||||
// Weekly usage: 同上
|
||||
val weeklyPct = Regex("""aria-label="Weekly usage\s+([\d.]+)%\s+used""", RegexOption.IGNORE_CASE)
|
||||
.find(html)?.groupValues?.get(1)?.toFloatOrNull()
|
||||
?: Regex("""Weekly usage[^<]*>[\s\S]*?([\d.]+)%\s*used""", RegexOption.IGNORE_CASE)
|
||||
.find(html)?.groupValues?.get(1)?.toFloatOrNull()
|
||||
|
||||
// data-time 属性 → 重置时间戳
|
||||
val dataTimes = Regex("""data-time="([^"]+)"""").findAll(html).map { it.groupValues[1] }.toList()
|
||||
val sessionResetAt = dataTimes.getOrNull(0)?.let { parseIsoTime(it) }
|
||||
val weeklyResetAt = dataTimes.getOrNull(1)?.let { parseIsoTime(it) }
|
||||
|
||||
// 模型级请求次数: data-model + data-requests
|
||||
val weeklyStart = html.indexOf("Weekly usage")
|
||||
val sessionModels = mutableListOf<Pair<String, Int>>()
|
||||
val weeklyModels = mutableListOf<Pair<String, Int>>()
|
||||
Regex("""data-model="([^"]+)"\s+data-requests="(\d+)"""").findAll(html).forEach { match ->
|
||||
val model = match.groupValues[1]
|
||||
val requests = match.groupValues[2].toIntOrNull() ?: 0
|
||||
if (match.range.first < weeklyStart) {
|
||||
sessionModels.add(model to requests)
|
||||
} else {
|
||||
weeklyModels.add(model to requests)
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionPct == null && weeklyPct == null) return null
|
||||
|
||||
return ParsedUsage(
|
||||
plan = plan.orEmpty(), // 空串由 UI 层兜底显示 "—",避免英文 "Unknown" 出现在中文 UI
|
||||
sessionPercent = sessionPct ?: 0f,
|
||||
weeklyPercent = weeklyPct ?: 0f,
|
||||
sessionResetAt = sessionResetAt,
|
||||
weeklyResetAt = weeklyResetAt,
|
||||
sessionModels = sessionModels,
|
||||
weeklyModels = weeklyModels
|
||||
)
|
||||
}
|
||||
|
||||
internal fun parseIsoTime(iso: String): Long? {
|
||||
return try {
|
||||
val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US)
|
||||
sdf.timeZone = TimeZone.getTimeZone("UTC")
|
||||
sdf.parse(iso)?.time
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
internal data class ParsedUsage(
|
||||
val plan: String,
|
||||
val sessionPercent: Float,
|
||||
val weeklyPercent: Float,
|
||||
val sessionResetAt: Long?,
|
||||
val weeklyResetAt: Long?,
|
||||
val sessionModels: List<Pair<String, Int>>,
|
||||
val weeklyModels: List<Pair<String, Int>>
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun fetchBalance(): Result<ServiceBalance> = withContext(Dispatchers.IO) {
|
||||
val credential = credentialRepository.get(ServiceType.OLLAMA)
|
||||
?: return@withContext Result.failure(RepositoryError.InvalidCredential())
|
||||
|
||||
if (credential !is Credential.SessionCredential) {
|
||||
return@withContext Result.failure(RepositoryError.InvalidCredential())
|
||||
}
|
||||
|
||||
val cookie = credential.ollamaCookie
|
||||
if (cookie.isNullOrBlank()) {
|
||||
return@withContext Result.failure(RepositoryError.InvalidCredential())
|
||||
}
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(SETTINGS_URL)
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||
.header("Accept-Language", "en-US,en;q=0.9")
|
||||
.header("Cookie", cookie)
|
||||
.get()
|
||||
.build()
|
||||
|
||||
val response = try {
|
||||
okHttpClient.newCall(request).execute()
|
||||
} catch (e: IOException) {
|
||||
return@withContext Result.failure(RepositoryError.Network(e))
|
||||
} catch (e: Throwable) {
|
||||
return@withContext Result.failure(RepositoryError.Unknown(e))
|
||||
}
|
||||
|
||||
response.use { resp ->
|
||||
if (!resp.isSuccessful) {
|
||||
if (resp.code == 401 || resp.code == 403) {
|
||||
return@withContext Result.failure(RepositoryError.InvalidCredential())
|
||||
}
|
||||
return@withContext Result.failure(RepositoryError.ServerError(resp.code))
|
||||
}
|
||||
|
||||
val html = resp.body?.string() ?: return@withContext Result.failure(
|
||||
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 可能已过期。"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Session 是主要用量窗口(类似 OCGO 的 rollingUsage)
|
||||
val sessionPct = parsed.sessionPercent
|
||||
val weeklyPct = parsed.weeklyPercent
|
||||
|
||||
val extras = buildMap {
|
||||
put("plan", parsed.plan)
|
||||
put("session.pct", sessionPct.toString())
|
||||
put("weekly.pct", weeklyPct.toString())
|
||||
parsed.sessionResetAt?.let { put("session.resetAt", it.toString()) }
|
||||
parsed.weeklyResetAt?.let { put("weekly.resetAt", it.toString()) }
|
||||
// 模型级数据序列化为分号分隔
|
||||
if (parsed.sessionModels.isNotEmpty()) {
|
||||
put("session.models", parsed.sessionModels.joinToString(";") { "${it.first},${it.second}" })
|
||||
}
|
||||
if (parsed.weeklyModels.isNotEmpty()) {
|
||||
put("weekly.models", parsed.weeklyModels.joinToString(";") { "${it.first},${it.second}" })
|
||||
}
|
||||
}
|
||||
|
||||
val balance = ServiceBalance(
|
||||
service = ServiceType.OLLAMA,
|
||||
amount = sessionPct.toDouble(),
|
||||
unit = "%",
|
||||
isAvailable = true,
|
||||
totalQuota = null,
|
||||
nextResetAt = parsed.sessionResetAt,
|
||||
extras = extras
|
||||
)
|
||||
|
||||
balanceCache.put(ServiceType.OLLAMA, balance)
|
||||
credentialRepository.save(credential.copy(lastVerifiedAt = System.currentTimeMillis()))
|
||||
|
||||
Result.success(balance)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 models.dev/api.json 获取 Ollama Cloud 可用模型列表(provider key = "ollama-cloud")。
|
||||
* 该 API 不需要认证。
|
||||
*/
|
||||
suspend fun fetchModels(): Result<List<String>> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request = Request.Builder().url(MODELS_API)
|
||||
.header("Accept", "application/json")
|
||||
.header("User-Agent", "rainy-token/0.1")
|
||||
.get().build()
|
||||
val models = okHttpClient.newCall(request).execute().use { resp ->
|
||||
if (!resp.isSuccessful) {
|
||||
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(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(RepositoryError.ParseErrorReason.NO_MODELS, "未找到 Ollama Cloud 模型列表")
|
||||
}
|
||||
if (models.isEmpty()) {
|
||||
return@withContext Result.failure(RepositoryError.ParseError(RepositoryError.ParseErrorReason.MODELS_EMPTY, "模型列表为空"))
|
||||
}
|
||||
DebugLog.i(TAG, "fetchModels: 获取到 ${models.size} 个模型")
|
||||
Result.success(models)
|
||||
} catch (e: IOException) {
|
||||
DebugLog.e(TAG, "fetchModels 网络异常: ${e.message}")
|
||||
Result.failure(RepositoryError.Network(e))
|
||||
} catch (e: RepositoryError) {
|
||||
Result.failure(e)
|
||||
} catch (e: Throwable) {
|
||||
DebugLog.e(TAG, "fetchModels 异常: ${e::class.simpleName}: ${e.message}")
|
||||
Result.failure(RepositoryError.Unknown(e))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 一键激活用量:用 API Key 向 Ollama Cloud chat completions API 发送简短请求。
|
||||
* @param model 用户选择的模型 slug
|
||||
* @return Result.success(响应摘要文本) — 供 UI 展示
|
||||
*/
|
||||
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)
|
||||
return@withContext Result.failure(RepositoryError.InvalidCredential("凭据类型不匹配"))
|
||||
|
||||
val apiKey = credential.apiKey
|
||||
if (apiKey.isNullOrBlank()) {
|
||||
return@withContext Result.failure(RepositoryError.InvalidCredential("未配置 API Key,请在设置中填写"))
|
||||
}
|
||||
|
||||
DebugLog.i(TAG, "triggerUsage: model=$model")
|
||||
|
||||
val requestBody = """{"model":"$model","messages":[{"role":"user","content":"hello"}],"max_tokens":50}"""
|
||||
.toRequestBody("application/json".toMediaType())
|
||||
|
||||
val request = Request.Builder().url(CHAT_API)
|
||||
.header("Accept", "application/json")
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Authorization", "Bearer $apiKey")
|
||||
.header("User-Agent", "rainy-token/0.1")
|
||||
.post(requestBody).build()
|
||||
|
||||
try {
|
||||
okHttpClient.newCall(request).execute().use { resp ->
|
||||
val bodyStr = resp.body?.string() ?: ""
|
||||
DebugLog.i(TAG, "triggerUsage: HTTP ${resp.code}, body=${bodyStr.take(500)}")
|
||||
if (!resp.isSuccessful) {
|
||||
return@withContext Result.failure<TriggerSummary>(
|
||||
TriggerError("HTTP ${resp.code}", bodyStr.ifBlank { "" })
|
||||
)
|
||||
}
|
||||
DebugLog.i(TAG, "triggerUsage: 请求成功,模型=$model")
|
||||
Result.success(parseOllamaChatResponse(bodyStr, model))
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
DebugLog.e(TAG, "triggerUsage 网络异常: ${e.message}")
|
||||
Result.failure(RepositoryError.Network(e))
|
||||
} catch (e: Throwable) {
|
||||
DebugLog.e(TAG, "triggerUsage 异常: ${e::class.simpleName}: ${e.message}")
|
||||
Result.failure(RepositoryError.Unknown(e))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 Ollama Cloud chat completions 响应,提取回复文本和用量统计。
|
||||
*/
|
||||
internal fun parseOllamaChatResponse(responseBody: String, model: String): TriggerSummary {
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
return try {
|
||||
val root = json.parseToJsonElement(responseBody) as? JsonObject
|
||||
val choices = root?.get("choices") as? kotlinx.serialization.json.JsonArray
|
||||
val firstChoice = choices?.firstOrNull() as? JsonObject
|
||||
val message = firstChoice?.get("message") as? JsonObject
|
||||
val content = (message?.get("content") as? JsonPrimitive)?.contentOrNull
|
||||
val usage = root?.get("usage") as? JsonObject
|
||||
val promptTokens = (usage?.get("prompt_tokens") as? JsonPrimitive)?.contentOrNull
|
||||
val completionTokens = (usage?.get("completion_tokens") as? JsonPrimitive)?.contentOrNull
|
||||
val totalTokens = (usage?.get("total_tokens") as? JsonPrimitive)?.contentOrNull
|
||||
|
||||
TriggerSummary(
|
||||
model = model,
|
||||
reply = content,
|
||||
inputTokens = promptTokens,
|
||||
outputTokens = completionTokens,
|
||||
totalTokens = totalTokens
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
TriggerSummary(model = model, reply = null, parseFailed = true)
|
||||
}
|
||||
}
|
||||
@ -1,16 +1,32 @@
|
||||
package com.rainy.token.data.repository
|
||||
|
||||
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
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.IOException
|
||||
import java.net.URLEncoder
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
@ -33,6 +49,8 @@ class OpenCodeGoRepository(
|
||||
private val balanceCache: BalanceCache
|
||||
) {
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
suspend fun fetchBalance(): Result<ServiceBalance> = withContext(Dispatchers.IO) {
|
||||
val credential = credentialRepository.get(ServiceType.OPENCODE_GO)
|
||||
?: return@withContext Result.failure(RepositoryError.InvalidCredential())
|
||||
@ -74,13 +92,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} 字节。"
|
||||
)
|
||||
)
|
||||
@ -91,21 +110,29 @@ class OpenCodeGoRepository(
|
||||
val config = ServiceConfigProvider.get(ServiceType.OPENCODE_GO)
|
||||
|
||||
// 把 3 个窗口的用量百分比 + 重置时间全部塞进 extras(详情页按窗口渲染)
|
||||
// usage/limit 为页面新增的用量与限额字段(单位以服务端定义为准,保留供详情页展示,UI 兼容缺失场景)
|
||||
val extras = buildMap {
|
||||
windows["rollingUsage"]?.let { w ->
|
||||
put("rolling.pct", w.usagePercent.toString())
|
||||
put("rolling.resetInSec", w.resetInSec.toString())
|
||||
w.usage?.let { put("rolling.usage", it.toString()) }
|
||||
w.limit?.let { put("rolling.limit", it.toString()) }
|
||||
}
|
||||
windows["weeklyUsage"]?.let { w ->
|
||||
put("weekly.pct", w.usagePercent.toString())
|
||||
put("weekly.resetInSec", w.resetInSec.toString())
|
||||
w.usage?.let { put("weekly.usage", it.toString()) }
|
||||
w.limit?.let { put("weekly.limit", it.toString()) }
|
||||
}
|
||||
windows["monthlyUsage"]?.let { w ->
|
||||
put("monthly.pct", w.usagePercent.toString())
|
||||
put("monthly.resetInSec", w.resetInSec.toString())
|
||||
w.usage?.let { put("monthly.usage", it.toString()) }
|
||||
w.limit?.let { put("monthly.limit", it.toString()) }
|
||||
}
|
||||
}
|
||||
|
||||
// 主窗口数据先落缓存并返回(关键路径只依赖 HTML 解析一次请求)
|
||||
val balance = ServiceBalance(
|
||||
service = ServiceType.OPENCODE_GO,
|
||||
amount = primary.usagePercent.toDouble(),
|
||||
@ -116,100 +143,422 @@ class OpenCodeGoRepository(
|
||||
nextResetAt = System.currentTimeMillis() + primary.resetInSec * 1000L,
|
||||
extras = extras
|
||||
)
|
||||
|
||||
balanceCache.put(ServiceType.OPENCODE_GO, balance)
|
||||
credentialRepository.save(credential.copy(lastVerifiedAt = System.currentTimeMillis()))
|
||||
|
||||
Result.success(balance)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 SolidJS SSR hydration 输出。匹配模式形如:
|
||||
* `rollingUsage:$R[0]={usagePercent:42,resetInSec:12345}`
|
||||
*
|
||||
* 用精确前缀 "field:$R[" 定位 hydration 数据中的字段声明,
|
||||
* 避免命中 HTML 其他位置(如 JS 代码注释、模板字符串)的同名文本。
|
||||
* 用括号计数匹配闭合 "}",正确处理嵌套对象。
|
||||
*/
|
||||
private fun parseWindows(html: String): Map<String, ScrapedWindow> {
|
||||
val result = mutableMapOf<String, ScrapedWindow>()
|
||||
val fields = listOf("rollingUsage", "weeklyUsage", "monthlyUsage")
|
||||
|
||||
for (field in fields) {
|
||||
// 精确模式:找 "field:$R[N]={" —— 这是 hydration 数据块的独有格式
|
||||
val keyIdx = html.indexOf("$field:\$R[")
|
||||
if (keyIdx < 0) continue
|
||||
|
||||
// 找 '=' 然后找 '{'
|
||||
val eqIdx = html.indexOf('=', keyIdx)
|
||||
if (eqIdx < 0 || eqIdx - keyIdx > 60) continue
|
||||
val braceStart = html.indexOf('{', eqIdx)
|
||||
if (braceStart < 0 || braceStart - eqIdx > 10) continue
|
||||
|
||||
// 括号计数找正确的闭合 "}"(处理嵌套对象)
|
||||
val braceEnd = findMatchingBrace(html, braceStart) ?: continue
|
||||
val body = html.substring(braceStart, braceEnd + 1)
|
||||
|
||||
val pct = extractNumberAfterKey(body, "usagePercent")?.toIntOrNull()
|
||||
val reset = extractNumberAfterKey(body, "resetInSec")?.toLongOrNull()
|
||||
if (pct != null && reset != null) {
|
||||
result[field] = ScrapedWindow(pct, reset)
|
||||
// 模型级用量作为增量增强:并行拉取 + 短超时,失败/超时不影响主窗口数据与缓存
|
||||
val modelUsage = fetchModelWindows(workspaceId, authCookie)
|
||||
if (modelUsage.isEmpty()) {
|
||||
return@withContext Result.success(balance)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 openIdx('{' 的位置)开始,用深度计数找匹配的闭合 '}'。
|
||||
* 正确处理嵌套对象:`{status:"ok", sub:{...}, usagePercent:34}`。
|
||||
*/
|
||||
private fun findMatchingBrace(s: String, openIdx: Int): Int? {
|
||||
var depth = 0
|
||||
for (i in openIdx until s.length) {
|
||||
when (s[i]) {
|
||||
'{' -> depth++
|
||||
'}' -> {
|
||||
depth--
|
||||
if (depth == 0) return i
|
||||
val enriched = balance.copy(
|
||||
extras = buildMap {
|
||||
putAll(extras)
|
||||
modelUsage.forEach { (window, usage) -> put("$window.models", json.encodeToString(usage)) }
|
||||
}
|
||||
}
|
||||
)
|
||||
balanceCache.put(ServiceType.OPENCODE_GO, enriched)
|
||||
Result.success(enriched)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 body 字符串中找 "key:" 后面紧跟的数字(含可选小数)。返回数字字符串,未找到返回 null。
|
||||
* 跳过 status 字符串值("ok" 之类)。
|
||||
* 从 models.dev/api.json 获取 OpenCode Go 可用模型列表(provider key = "opencode-go")。
|
||||
* 该 API 不需要认证。
|
||||
*/
|
||||
private fun extractNumberAfterKey(body: String, key: String): String? {
|
||||
val keyIdx = body.indexOf(key)
|
||||
if (keyIdx < 0) return null
|
||||
var i = keyIdx + key.length
|
||||
// 跳过 ":" 后面所有非数字、非负号、非小数点字符
|
||||
while (i < body.length) {
|
||||
val c = body[i]
|
||||
if (c.isDigit() || c == '-' || c == '.') break
|
||||
i++
|
||||
}
|
||||
if (i >= body.length) return null
|
||||
// 收集数字
|
||||
val start = i
|
||||
while (i < body.length) {
|
||||
val c = body[i]
|
||||
if (c.isDigit() || c == '.' || (c == '-' && i == start)) {
|
||||
i++
|
||||
} else {
|
||||
break
|
||||
suspend fun fetchModels(): Result<List<String>> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request = Request.Builder().url(MODELS_API)
|
||||
.header("Accept", "application/json")
|
||||
.header("User-Agent", "rainy-token/0.1")
|
||||
.get().build()
|
||||
val models = okHttpClient.newCall(request).execute().use { resp ->
|
||||
if (!resp.isSuccessful) {
|
||||
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(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(RepositoryError.ParseErrorReason.NO_MODELS, "未找到 OpenCode Go 模型列表")
|
||||
}
|
||||
if (models.isEmpty()) {
|
||||
return@withContext Result.failure(RepositoryError.ParseError(RepositoryError.ParseErrorReason.MODELS_EMPTY, "模型列表为空"))
|
||||
}
|
||||
DebugLog.i(TAG, "fetchModels: 获取到 ${models.size} 个模型")
|
||||
Result.success(models)
|
||||
} catch (e: IOException) {
|
||||
DebugLog.e(TAG, "fetchModels 网络异常: ${e.message}")
|
||||
Result.failure(RepositoryError.Network(e))
|
||||
} catch (e: RepositoryError) {
|
||||
Result.failure(e)
|
||||
} catch (e: Throwable) {
|
||||
DebugLog.e(TAG, "fetchModels 异常: ${e::class.simpleName}: ${e.message}")
|
||||
Result.failure(RepositoryError.Unknown(e))
|
||||
}
|
||||
return body.substring(start, i).ifEmpty { null }
|
||||
}
|
||||
|
||||
private data class ScrapedWindow(val usagePercent: Int, val resetInSec: Long)
|
||||
/**
|
||||
* 一键激活用量:用 API Key 向 OpenCode chat completions API 发送简短请求。
|
||||
* @param model 用户选择的模型 slug
|
||||
* @return Result.success(响应摘要文本) — 供 UI 展示
|
||||
*/
|
||||
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)
|
||||
return@withContext Result.failure(RepositoryError.InvalidCredential("凭据类型不匹配"))
|
||||
|
||||
val apiKey = credential.apiKey
|
||||
if (apiKey.isNullOrBlank()) {
|
||||
return@withContext Result.failure(RepositoryError.InvalidCredential("未配置 API Key,请在设置中填写"))
|
||||
}
|
||||
|
||||
DebugLog.i(TAG, "triggerUsage: model=$model")
|
||||
|
||||
val requestBody = """{"model":"$model","messages":[{"role":"user","content":"hello"}],"max_tokens":50}"""
|
||||
.toRequestBody("application/json".toMediaType())
|
||||
|
||||
val request = Request.Builder().url(CHAT_API)
|
||||
.header("Accept", "application/json")
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Authorization", "Bearer $apiKey")
|
||||
.header("User-Agent", "rainy-token/0.1")
|
||||
.post(requestBody).build()
|
||||
|
||||
try {
|
||||
okHttpClient.newCall(request).execute().use { resp ->
|
||||
val bodyStr = resp.body?.string() ?: ""
|
||||
DebugLog.i(TAG, "triggerUsage: HTTP ${resp.code}, body=${bodyStr.take(500)}")
|
||||
if (!resp.isSuccessful) {
|
||||
return@withContext Result.failure<TriggerSummary>(
|
||||
TriggerError("HTTP ${resp.code}", bodyStr.ifBlank { "" })
|
||||
)
|
||||
}
|
||||
DebugLog.i(TAG, "triggerUsage: 请求成功,模型=$model")
|
||||
Result.success(parseChatResponse(bodyStr, model))
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
DebugLog.e(TAG, "triggerUsage 网络异常: ${e.message}")
|
||||
Result.failure(RepositoryError.Network(e))
|
||||
} catch (e: Throwable) {
|
||||
DebugLog.e(TAG, "triggerUsage 异常: ${e::class.simpleName}: ${e.message}")
|
||||
Result.failure(RepositoryError.Unknown(e))
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "OCGO"
|
||||
private const val MODELS_API = "https://models.dev/api.json"
|
||||
private const val CHAT_API = "https://opencode.ai/zen/go/v1/chat/completions"
|
||||
/** _server 端点:模型级用量接口(f:31 + 窗口参数,需 x-server-id / x-server-instance 头) */
|
||||
private const val SERVER_ENDPOINT = "https://opencode.ai/_server"
|
||||
/** 窗口模型用量的 server function id(与请求头 x-server-id 一致,取自网页端实测) */
|
||||
private const val MODEL_USAGE_SERVER_ID = "ba154d05c4028a885b8c753f9def7e45d87eb982e65fa8b14254cbe636168914"
|
||||
/** 模型级用量单请求超时(毫秒):只影响增强数据,避免拖慢主窗口刷新 */
|
||||
private const val MODEL_REQUEST_TIMEOUT_MS = 5_000L
|
||||
private val SCRAPED_FIELDS = listOf("rollingUsage", "weeklyUsage", "monthlyUsage")
|
||||
|
||||
/**
|
||||
* 解析 SolidJS SSR hydration 输出。匹配模式形如:
|
||||
* `rollingUsage:$R[0]={usagePercent:42,resetInSec:12345}`
|
||||
*
|
||||
* 用精确前缀 "field:$R[" 定位 hydration 数据中的字段声明,
|
||||
* 避免命中 HTML 其他位置(如 JS 代码注释、模板字符串)的同名文本。
|
||||
* 用括号计数匹配闭合 "}",正确处理嵌套对象。
|
||||
*/
|
||||
internal fun parseWindows(html: String): Map<String, ScrapedWindow> {
|
||||
val result = mutableMapOf<String, ScrapedWindow>()
|
||||
val fields = listOf("rollingUsage", "weeklyUsage", "monthlyUsage")
|
||||
|
||||
for (field in fields) {
|
||||
// 精确模式:找 "field:$R[N]={" —— 这是 hydration 数据块的独有格式
|
||||
val keyIdx = html.indexOf("$field:\$R[")
|
||||
if (keyIdx < 0) continue
|
||||
|
||||
// 找 '=' 然后找 '{'
|
||||
val eqIdx = html.indexOf('=', keyIdx)
|
||||
if (eqIdx < 0 || eqIdx - keyIdx > 60) continue
|
||||
val braceStart = html.indexOf('{', eqIdx)
|
||||
if (braceStart < 0 || braceStart - eqIdx > 10) continue
|
||||
|
||||
// 括号计数找正确的闭合 "}"(处理嵌套对象)
|
||||
val braceEnd = findMatchingBrace(html, braceStart) ?: continue
|
||||
val body = html.substring(braceStart, braceEnd + 1)
|
||||
|
||||
val pct = extractNumberAfterKey(body, "usagePercent")?.toFloatOrNull()
|
||||
val reset = extractNumberAfterKey(body, "resetInSec")?.toLongOrNull()
|
||||
// usage/limit 是页面新增的用量与限额字段(单位以服务端定义为准,缺失不影响窗口识别)
|
||||
// exactKey=true:避免 "usage" 误命中 "usagePercent" 前缀
|
||||
val usage = extractNumberAfterKey(body, "usage", exactKey = true)?.toLongOrNull()
|
||||
val limit = extractNumberAfterKey(body, "limit", exactKey = true)?.toLongOrNull()
|
||||
if (pct != null && reset != null) {
|
||||
result[field] = ScrapedWindow(pct, reset, usage, limit)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 openIdx('{' 的位置)开始,用深度计数找匹配的闭合 '}'。
|
||||
* 正确处理嵌套对象:`{status:"ok", sub:{...}, usagePercent:34}`。
|
||||
*/
|
||||
private fun findMatchingBrace(s: String, openIdx: Int): Int? {
|
||||
var depth = 0
|
||||
for (i in openIdx until s.length) {
|
||||
when (s[i]) {
|
||||
'{' -> depth++
|
||||
'}' -> {
|
||||
depth--
|
||||
if (depth == 0) return i
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 body 字符串中找 "key:" 后面紧跟的数字(含可选小数)。返回数字字符串,未找到返回 null。
|
||||
* 跳过 status 字符串值("ok" 之类)。
|
||||
*
|
||||
* @param exactKey 为 true 时要求 key 后紧跟 ':'(精确字段匹配),
|
||||
* 避免 "usage" 误命中 "usagePercent" 这类前缀字段。
|
||||
*/
|
||||
private fun extractNumberAfterKey(body: String, key: String, exactKey: Boolean = false): String? {
|
||||
val keyIdx = if (exactKey) {
|
||||
// 精确匹配:key 后必须紧跟 ':'(如 "usage:"),不能是 "usagePercent"
|
||||
var idx = body.indexOf(key)
|
||||
while (idx >= 0) {
|
||||
val after = idx + key.length
|
||||
if (after < body.length && body[after] == ':') break
|
||||
idx = body.indexOf(key, idx + 1)
|
||||
}
|
||||
idx
|
||||
} else {
|
||||
body.indexOf(key)
|
||||
}
|
||||
if (keyIdx < 0) return null
|
||||
var i = keyIdx + key.length
|
||||
// 跳过 ":" 后面所有非数字、非负号、非小数点字符
|
||||
while (i < body.length) {
|
||||
val c = body[i]
|
||||
if (c.isDigit() || c == '-' || c == '.') break
|
||||
i++
|
||||
}
|
||||
if (i >= body.length) return null
|
||||
// 收集数字
|
||||
val start = i
|
||||
while (i < body.length) {
|
||||
val c = body[i]
|
||||
if (c.isDigit() || c == '.' || (c == '-' && i == start)) {
|
||||
i++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return body.substring(start, i).ifEmpty { null }
|
||||
}
|
||||
|
||||
internal data class ScrapedWindow(
|
||||
val usagePercent: Float,
|
||||
val resetInSec: Long,
|
||||
val usage: Long? = null,
|
||||
val limit: Long? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 解析 _server 返回的模型级用量 flight 响应。
|
||||
*
|
||||
* 实际响应形如:
|
||||
* ```
|
||||
* ;0x000003bc;((self.$R=self.$R||{})["server-fn:2"]=[],($R=>$R[0]={
|
||||
* usage:1889462439,limit:6000000000,usagePercent:31.5,
|
||||
* rows:$R[1]=[$R[2]={model:"deepseek-v4-flash",name:"DeepSeek V4 Flash",
|
||||
* cost:1002607535,quotaCost:2005215070,multiplier:2,
|
||||
* estimated:!0,contributionPercent:30.5},...]
|
||||
* })($R["server-fn:2"]))
|
||||
* ```
|
||||
*
|
||||
* 返回窗口模型用量(usage/limit/usagePercent + 每模型行),解析失败返回 null。
|
||||
* cost/quotaCost 单位为 1e-8 美元(cost × 10⁻⁸ = 美元)。
|
||||
*/
|
||||
internal fun parseModelRows(flight: String): WindowModelUsage? {
|
||||
// 定位 "$R[0]={" 数据块(窗口模型用量的固定 hydration 格式)
|
||||
val rootIdx = flight.indexOf("\$R[0]={")
|
||||
if (rootIdx < 0) return null
|
||||
val braceStart = rootIdx + "\$R[0]=".length
|
||||
val braceEnd = findMatchingBrace(flight, braceStart) ?: return null
|
||||
val body = flight.substring(braceStart, braceEnd + 1)
|
||||
|
||||
val usage = extractNumberAfterKey(body, "usage", exactKey = true)?.toLongOrNull()
|
||||
val limit = extractNumberAfterKey(body, "limit", exactKey = true)?.toLongOrNull()
|
||||
val usagePercent = extractNumberAfterKey(body, "usagePercent")?.toFloatOrNull()
|
||||
|
||||
return WindowModelUsage(
|
||||
usage = usage,
|
||||
limit = limit,
|
||||
usagePercent = usagePercent,
|
||||
rows = extractModelRows(body)
|
||||
)
|
||||
}
|
||||
|
||||
/** 从外层对象 body 中提取所有 {model:"..."} 模型行对象。 */
|
||||
private fun extractModelRows(body: String): List<ModelUsageRow> {
|
||||
val rows = mutableListOf<ModelUsageRow>()
|
||||
var idx = body.indexOf("{model:")
|
||||
while (idx >= 0) {
|
||||
val braceEnd = findMatchingBrace(body, idx) ?: break
|
||||
val obj = body.substring(idx, braceEnd + 1)
|
||||
val model = extractQuoted(obj, "model") ?: ""
|
||||
val name = extractQuoted(obj, "name") ?: ""
|
||||
val cost = extractNumberAfterKey(obj, "cost")?.toLongOrNull() ?: 0L
|
||||
val quotaCost = extractNumberAfterKey(obj, "quotaCost")?.toLongOrNull() ?: 0L
|
||||
val multiplier = extractNumberAfterKey(obj, "multiplier")?.toDoubleOrNull() ?: 1.0
|
||||
val estimated = obj.contains("estimated:!0")
|
||||
val contributionPercent = extractNumberAfterKey(obj, "contributionPercent")?.toDoubleOrNull() ?: 0.0
|
||||
rows += ModelUsageRow(
|
||||
model = model,
|
||||
name = name,
|
||||
cost = cost,
|
||||
quotaCost = quotaCost,
|
||||
multiplier = multiplier,
|
||||
estimated = estimated,
|
||||
contributionPercent = contributionPercent
|
||||
)
|
||||
idx = body.indexOf("{model:", braceEnd + 1)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
/** 提取 "key:" 后面紧跟的 "..." 引号字符串值。 */
|
||||
private fun extractQuoted(body: String, key: String): String? {
|
||||
val keyIdx = body.indexOf("$key:\"")
|
||||
if (keyIdx < 0) return null
|
||||
val start = keyIdx + key.length + 2
|
||||
val end = body.indexOf('"', start)
|
||||
if (end < 0) return null
|
||||
return body.substring(start, end)
|
||||
}
|
||||
|
||||
/**
|
||||
* 窗口配额(1e-8 美元整数):窗口 limit(token 数)÷ multiplier。
|
||||
* 与网页端各窗口"配额"列口径一致(实测:5h=12 亿÷2=$6、周=30 亿÷2=$15、月=60 亿÷2=$30;hy3 月=60 亿÷0.125=$480)。
|
||||
* multiplier 缺失或非正时安全降级为月度基础 $60(与网页默认一致)。
|
||||
*/
|
||||
internal fun windowQuotaRaw(windowLimit: Long, multiplier: Double): Long {
|
||||
if (multiplier <= 0.0) return 60L * 100_000_000L
|
||||
return (windowLimit.toDouble() / multiplier).toLong()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取三个窗口(rolling/weekly/monthly)的模型级用量。
|
||||
* 并行发起 + 每个请求独立短超时(MODEL_REQUEST_TIMEOUT_MS),
|
||||
* 任一窗口失败不影响其他窗口;整体失败返回空 Map(主窗口数据不受影响)。
|
||||
*/
|
||||
private suspend fun fetchModelWindows(workspaceId: String, authCookie: String): Map<String, WindowModelUsage> =
|
||||
coroutineScope {
|
||||
listOf("rolling", "weekly", "monthly").map { window ->
|
||||
async {
|
||||
try {
|
||||
requestModelWindow(workspaceId, authCookie, window)?.let { parseModelRows(it) }
|
||||
} catch (e: Exception) {
|
||||
DebugLog.e(TAG, "fetchModelWindows($window) 异常: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
}.awaitAll().let { results ->
|
||||
listOf("rolling", "weekly", "monthly").zip(results).mapNotNull { (window, usage) ->
|
||||
usage?.let { window to it }
|
||||
}.toMap()
|
||||
}
|
||||
}
|
||||
|
||||
/** 请求 _server 的窗口模型用量接口,返回 flight 文本;HTTP 失败/超时返回 null。 */
|
||||
private fun requestModelWindow(workspaceId: String, authCookie: String, window: String): String? {
|
||||
val args = """{"t":{"t":9,"i":0,"l":2,"a":[{"t":1,"s":"$workspaceId"},{"t":1,"s":"$window"}],"o":0},"f":31,"m":[]}"""
|
||||
val url = "$SERVER_ENDPOINT?id=$MODEL_USAGE_SERVER_ID&args=${URLEncoder.encode(args, "UTF-8")}"
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.header("Accept", "*/*")
|
||||
.header("Cookie", "auth=$authCookie")
|
||||
.header("x-server-id", MODEL_USAGE_SERVER_ID)
|
||||
.header("x-server-instance", "server-fn:2")
|
||||
.header("Referer", "https://opencode.ai/workspace/$workspaceId/go")
|
||||
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/152.0.0.0 Safari/537.36")
|
||||
.get().build()
|
||||
val call = okHttpClient.newCall(request)
|
||||
call.timeout().timeout(MODEL_REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
return try {
|
||||
call.execute().use { resp ->
|
||||
if (!resp.isSuccessful) {
|
||||
DebugLog.e(TAG, "requestModelWindow($window): HTTP ${resp.code}")
|
||||
null
|
||||
} else {
|
||||
resp.body?.string()
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
DebugLog.e(TAG, "requestModelWindow($window) 网络异常: ${e.message}")
|
||||
null
|
||||
} catch (e: Throwable) {
|
||||
DebugLog.e(TAG, "requestModelWindow($window) 异常: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 窗口模型级用量(_server 接口 payload)。
|
||||
* cost/quotaCost 单位为 1e-8 美元;multiplier 为计费倍率(quotaCost = cost × multiplier)。
|
||||
*/
|
||||
@Serializable
|
||||
internal data class WindowModelUsage(
|
||||
val usage: Long? = null,
|
||||
val limit: Long? = null,
|
||||
val usagePercent: Float? = null,
|
||||
val rows: List<ModelUsageRow> = emptyList()
|
||||
)
|
||||
|
||||
/** 单模型用量行。contributionPercent 为该模型占窗口用量的百分比(如 30.5 = 30.5%)。 */
|
||||
@Serializable
|
||||
internal data class ModelUsageRow(
|
||||
val model: String = "",
|
||||
val name: String = "",
|
||||
val cost: Long = 0L,
|
||||
val quotaCost: Long = 0L,
|
||||
val multiplier: Double = 1.0,
|
||||
val estimated: Boolean = false,
|
||||
val contributionPercent: Double = 0.0
|
||||
)
|
||||
|
||||
/**
|
||||
* 解析 OpenAI 兼容的 chat completions 响应,提取回复文本和用量统计。
|
||||
*/
|
||||
internal fun parseChatResponse(responseBody: String, model: String): TriggerSummary {
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
return try {
|
||||
val root = json.parseToJsonElement(responseBody) as? JsonObject
|
||||
val choices = root?.get("choices") as? kotlinx.serialization.json.JsonArray
|
||||
val firstChoice = choices?.firstOrNull() as? JsonObject
|
||||
val message = firstChoice?.get("message") as? JsonObject
|
||||
val content = (message?.get("content") as? JsonPrimitive)?.contentOrNull
|
||||
val usage = root?.get("usage") as? JsonObject
|
||||
val promptTokens = (usage?.get("prompt_tokens") as? JsonPrimitive)?.contentOrNull
|
||||
val completionTokens = (usage?.get("completion_tokens") as? JsonPrimitive)?.contentOrNull
|
||||
val totalTokens = (usage?.get("total_tokens") as? JsonPrimitive)?.contentOrNull
|
||||
|
||||
TriggerSummary(
|
||||
model = model,
|
||||
reply = content,
|
||||
inputTokens = promptTokens,
|
||||
outputTokens = completionTokens,
|
||||
totalTokens = totalTokens
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
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)
|
||||
@ -211,15 +211,16 @@ class OpenCodeUsageRepository(
|
||||
else num
|
||||
}
|
||||
|
||||
private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
|
||||
private fun extractDate(obj: String, key: String): Long? {
|
||||
// 匹配模式: key:new Date("2026-06-15T20:46:31.000Z")
|
||||
val pattern = Regex("""["']?${Regex.escape(key)}["']?\s*:\s*(?:\$\w+\[?\d*\]?=\s*)?new Date\("([^"]+)"\)""")
|
||||
return pattern.find(obj)?.groupValues?.get(1)?.let {
|
||||
runCatching { dateFormat.parse(it)?.time }.getOrNull()
|
||||
// SimpleDateFormat 非线程安全,每次创建新实例
|
||||
runCatching {
|
||||
java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}.parse(it)?.time
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,81 @@
|
||||
package com.rainy.token.data.repository
|
||||
|
||||
import com.rainy.token.domain.model.Credential
|
||||
import com.rainy.token.domain.model.ServiceBalance
|
||||
import com.rainy.token.domain.service.ServiceType
|
||||
import kotlin.coroutines.AbstractCoroutineContextElement
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
/**
|
||||
* 单次余额/用量请求的延迟写入会话。
|
||||
*
|
||||
* Repository 仍可按原有顺序调用 CredentialRepository.save() 与 BalanceCache.put(),
|
||||
* 但在该上下文中写入只会暂存。请求结束后由 CredentialRepository 在同一互斥区内
|
||||
* 校验凭据快照并一次性提交,避免旧请求覆盖刚保存或删除的新凭据。
|
||||
*/
|
||||
internal class RefreshWriteSession(
|
||||
val snapshot: CredentialRepository.CredentialSnapshot
|
||||
) : AbstractCoroutineContextElement(Key) {
|
||||
|
||||
companion object Key : CoroutineContext.Key<RefreshWriteSession>
|
||||
|
||||
/** 快照对应的账号 ID;单账号服务为 null。 */
|
||||
val accountId: String? get() = snapshot.accountId
|
||||
|
||||
private val stateLock = Any()
|
||||
private var pendingCredential: Credential? = null
|
||||
private var pendingBalance: ServiceBalance? = null
|
||||
|
||||
fun stageCredential(credential: Credential) {
|
||||
require(credential.service == snapshot.service) {
|
||||
"刷新会话服务不匹配:expected=${snapshot.service}, actual=${credential.service}"
|
||||
}
|
||||
synchronized(stateLock) {
|
||||
pendingCredential = mergeCredential(pendingCredential, credential)
|
||||
}
|
||||
}
|
||||
|
||||
fun stageBalance(service: ServiceType, balance: ServiceBalance) {
|
||||
require(service == snapshot.service && balance.service == snapshot.service) {
|
||||
"刷新缓存服务不匹配:expected=${snapshot.service}, key=$service, balance=${balance.service}"
|
||||
}
|
||||
synchronized(stateLock) {
|
||||
pendingBalance = balance
|
||||
}
|
||||
}
|
||||
|
||||
/** 同一刷新会话内始终读取快照或已暂存的新凭据,保证重试不会退回旧 Token。 */
|
||||
fun credentialForRead(): Credential = synchronized(stateLock) {
|
||||
pendingCredential ?: snapshot.credential
|
||||
}
|
||||
|
||||
fun stagedCredential(): Credential? = synchronized(stateLock) { pendingCredential }
|
||||
|
||||
fun stagedBalance(): ServiceBalance? = synchronized(stateLock) { pendingBalance }
|
||||
|
||||
private fun mergeCredential(current: Credential?, candidate: Credential): Credential {
|
||||
if (current == null) return candidate
|
||||
|
||||
val baseFingerprint = snapshot.fingerprint
|
||||
val currentChanged = CredentialRepository.credentialFingerprint(current) != baseFingerprint
|
||||
val candidateChanged = CredentialRepository.credentialFingerprint(candidate) != baseFingerprint
|
||||
|
||||
// Codex 可能先暂存轮换后的 token,随后又用请求开始时的旧 credential
|
||||
// 仅更新 lastVerifiedAt。此时必须保留已轮换的认证字段,不能退回旧 token。
|
||||
val selected = when {
|
||||
currentChanged && !candidateChanged -> current
|
||||
!currentChanged && candidateChanged -> candidate
|
||||
else -> candidate
|
||||
}
|
||||
return selected.withLastVerifiedAt(maxOf(current.lastVerifiedAt, candidate.lastVerifiedAt))
|
||||
}
|
||||
}
|
||||
|
||||
private fun Credential.withLastVerifiedAt(value: Long): Credential = when (this) {
|
||||
is Credential.ApiKeyCredential -> copy(lastVerifiedAt = value)
|
||||
is Credential.SessionCredential -> copy(lastVerifiedAt = value)
|
||||
is Credential.CodexCredential -> copy(lastVerifiedAt = value)
|
||||
is Credential.TraeCredential -> copy(lastVerifiedAt = value)
|
||||
is Credential.WorkBuddyCredential -> copy(lastVerifiedAt = value)
|
||||
is Credential.Sub2ApiCredential -> copy(lastVerifiedAt = value)
|
||||
}
|
||||
@ -5,8 +5,15 @@ package com.rainy.token.data.repository
|
||||
*/
|
||||
sealed class RepositoryError(message: String, cause: Throwable? = null) : Exception(message, cause) {
|
||||
|
||||
/** 凭据无效(如 401 Unauthorized) */
|
||||
class InvalidCredential(cause: Throwable? = null) : RepositoryError("凭据无效", cause)
|
||||
/** 凭据无效(如 401 Unauthorized),detail 为具体原因 */
|
||||
class InvalidCredential(detail: String? = null, cause: Throwable? = null) :
|
||||
RepositoryError("凭据无效" + (detail?.let { ": $it" } ?: ""), cause)
|
||||
|
||||
/** 服务端 CheckLogin 确认登录态已失效(未登录或 JWT 过期),需重新登录 */
|
||||
class SessionExpired : RepositoryError("登录已过期,请重新登录")
|
||||
|
||||
/** 请求期间凭据被保存、替换或删除;旧结果已安全丢弃。 */
|
||||
class CredentialChanged : RepositoryError("凭据已变更,本次请求结果已丢弃")
|
||||
|
||||
/** 限流(429 Too Many Requests) */
|
||||
class RateLimited(val retryAfterSeconds: Long? = null) :
|
||||
@ -19,13 +26,29 @@ 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(
|
||||
"未知错误" + (cause?.let { ": ${it::class.simpleName}: ${it.message ?: "(no message)"}" } ?: ""),
|
||||
cause
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,39 @@
|
||||
package com.rainy.token.data.repository
|
||||
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.math.pow
|
||||
|
||||
/**
|
||||
* 通用重试包装。
|
||||
*
|
||||
* 对 [RepositoryError.Network] 和 [RepositoryError.ServerError] 执行指数退避重试。
|
||||
* 401/403(InvalidCredential)、429(RateLimited)、ParseError 等不重试。
|
||||
*
|
||||
* @param maxRetries 最大重试次数(不含首次),默认 2
|
||||
* @param baseDelayMs 退避基数,默认 1000ms(第二次 = 2000ms)
|
||||
* @param block 实际请求逻辑
|
||||
*/
|
||||
suspend fun <T> retryOnTransientError(
|
||||
maxRetries: Int = 2,
|
||||
baseDelayMs: Long = 1000L,
|
||||
block: suspend () -> Result<T>
|
||||
): Result<T> {
|
||||
var lastResult = block()
|
||||
if (lastResult.isSuccess) return lastResult
|
||||
|
||||
repeat(maxRetries) { attempt ->
|
||||
val error = lastResult.exceptionOrNull()
|
||||
val shouldRetry = when (error) {
|
||||
is RepositoryError.Network -> true
|
||||
is RepositoryError.ServerError -> error.code in 500..599
|
||||
else -> false
|
||||
}
|
||||
if (!shouldRetry) return lastResult
|
||||
|
||||
val backoffMs = baseDelayMs * (2.0.pow(attempt).toLong())
|
||||
delay(backoffMs)
|
||||
lastResult = block()
|
||||
if (lastResult.isSuccess) return lastResult
|
||||
}
|
||||
return lastResult
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,738 @@
|
||||
package com.rainy.token.data.repository
|
||||
|
||||
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.service.ServiceType
|
||||
import java.io.IOException
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.doubleOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
|
||||
/**
|
||||
* Trae(CN / Intl)余额仓库。
|
||||
*
|
||||
* 认证:官方 IDE/Web 用的 `Authorization: Cloud-IDE-JWT <jwt>`(RS256,~14 天)。区域只决定域名:
|
||||
* - CN :POST https://api.trae.cn/trae/api/v2/pay/ide_user_ent_usage
|
||||
* - INTL:POST https://grow-normal.trae.ai/trae/api/v1/pay/ide_user_ent_usage
|
||||
* 2026-09 用真实登录态实测(api.trae.cn):**仅带 Authorization + Content-Type 即成功**,
|
||||
* X-User-Region / X-Device-Id 均非必需(官方 Web 不带),故不再发送,避免设备绑定误伤。
|
||||
*
|
||||
* 真实响应(实测结构):
|
||||
* ```json
|
||||
* {
|
||||
* "is_credits_billing": true,
|
||||
* "usage_summary": {"consumed_amount":3341.17,"consumption_ratio":0.613,"total_amount":5450},
|
||||
* "user_entitlement_pack_list": [ {
|
||||
* "display_desc":"签到奖励",
|
||||
* "entitlement_base_info":{"quota":{"credits_limit":150}, ...},
|
||||
* "usage":{"credits_amount":142.18}, ...
|
||||
* } ]
|
||||
* }
|
||||
* ```
|
||||
* 余额权威口径 = `usage_summary.total_amount - consumed_amount`(>=0);usage_summary 缺失时
|
||||
* 才退回包级求和(credits_limit - credits_amount,限流/无限包不计)。字段名防御式扫描。
|
||||
*/
|
||||
@Singleton
|
||||
class TraeRepository constructor(
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val credentialRepository: CredentialRepository,
|
||||
private val balanceCache: BalanceCache
|
||||
) {
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
suspend fun fetchBalance(accountId: String? = null): Result<ServiceBalance> = withContext(Dispatchers.IO) {
|
||||
val credential = credentialRepository.get(ServiceType.TRAE, accountId)
|
||||
?: return@withContext Result.failure(RepositoryError.InvalidCredential())
|
||||
if (credential !is Credential.TraeCredential || credential.jwt.isBlank()) {
|
||||
return@withContext Result.failure(RepositoryError.InvalidCredential())
|
||||
}
|
||||
|
||||
val regionKey = credential.region.uppercase().takeIf { it == "INTL" } ?: "CN"
|
||||
val url = when (regionKey) {
|
||||
"INTL" -> "https://grow-normal.trae.ai/trae/api/v1/pay/ide_user_ent_usage"
|
||||
else -> "https://api.trae.cn/trae/api/v2/pay/ide_user_ent_usage"
|
||||
}
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.addHeader("Authorization", "Cloud-IDE-JWT ${credential.jwt.trim()}")
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.post("{}".toRequestBody("application/json".toMediaType()))
|
||||
.build()
|
||||
|
||||
val body = try {
|
||||
okHttpClient.newCall(request).execute().use { resp ->
|
||||
val text = resp.body?.string().orEmpty()
|
||||
DebugLog.i(TAG, "fetchBalance: http=${resp.code} body=${text.take(300)}")
|
||||
if (!resp.isSuccessful) {
|
||||
return@withContext Result.failure(
|
||||
if (resp.code == 401 || resp.code == 403) {
|
||||
authErrorFor(credential.jwt.trim())
|
||||
} else {
|
||||
mapHttpError(resp.code)
|
||||
}
|
||||
)
|
||||
}
|
||||
text
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
return@withContext Result.failure(RepositoryError.Network(e))
|
||||
} catch (e: Throwable) {
|
||||
return@withContext Result.failure(RepositoryError.Unknown(e))
|
||||
}
|
||||
|
||||
val parsed = runCatching { parseBody(body) }
|
||||
.getOrElse { e ->
|
||||
return@withContext Result.failure(
|
||||
RepositoryError.ParseError(RepositoryError.ParseErrorReason.MALFORMED_RESPONSE, "trae 解析失败: ${e.message}")
|
||||
)
|
||||
}
|
||||
|
||||
val balance = ServiceBalance(
|
||||
service = ServiceType.TRAE,
|
||||
amount = parsed.amount,
|
||||
unit = "Credits",
|
||||
totalQuota = parsed.totalQuota,
|
||||
extras = parsed.extras
|
||||
)
|
||||
balanceCache.put(ServiceType.TRAE, balance, accountId)
|
||||
credentialRepository.save(credential.copy(lastVerifiedAt = System.currentTimeMillis()), accountId)
|
||||
Result.success(balance)
|
||||
}
|
||||
|
||||
/** 解析:usage_summary 权威优先;缺失时回退权益包求和。字段缺失跳过,不抛异常。 */
|
||||
private fun parseBody(body: String): ParsedTraeUsage {
|
||||
val root = json.parseToJsonElement(body).jsonObject
|
||||
val extras = linkedMapOf<String, String>()
|
||||
|
||||
root["is_credits_billing"]?.jsonPrimitive?.contentOrNull?.let { extras["is_credits_billing"] = it }
|
||||
root["is_dollar_usage_billing"]?.jsonPrimitive?.contentOrNull?.let { extras["is_dollar_usage_billing"] = it }
|
||||
root["is_pay_freshman"]?.jsonPrimitive?.contentOrNull?.let { extras["is_new_user"] = it }
|
||||
|
||||
// 1) 权威汇总
|
||||
val summary = root["usage_summary"]?.jsonObject
|
||||
val total = summary?.numberOr("total_amount")
|
||||
val consumed = summary?.numberOr("consumed_amount")
|
||||
summary?.let { s ->
|
||||
s.numberOr("consumed_amount")?.let { extras["consumed_credits"] = trimNum(it) }
|
||||
s.numberOr("consumption_ratio")?.let { extras["consumption_ratio"] = trimNum(it) }
|
||||
s.numberOr("total_amount")?.let { extras["total_credits"] = trimNum(it) }
|
||||
}
|
||||
|
||||
// 2) 权益包明细(前若干条 + 计数),同时累计兜底求和
|
||||
// 用固定前缀键 entitlement.$i.* 输出,供 TraePacksCard 拆开渲染(每包的
|
||||
// 总额/已用/剩余)。旧「中文名.limit/.used」键形式存在歧义且被
|
||||
// GenericExtrasCard 的过滤规则吞掉,导致权益明细从未展示(2026-10 用户反馈)。
|
||||
var fallbackSum = 0.0
|
||||
var generalSum = 0.0 // available_endpoint=0:通用积分(TraeCode/TraeWork 均可用)
|
||||
var workSum = 0.0 // available_endpoint=1:Work 专属积分(仅 TraeWork 可用)
|
||||
var packShown = 0
|
||||
var packCount = 0
|
||||
val packs = root["user_entitlement_pack_list"]?.jsonArray
|
||||
packs?.forEachIndexed { index, packEl ->
|
||||
val pack = packEl.jsonObject
|
||||
val base = pack["entitlement_base_info"]?.jsonObject
|
||||
val quota = base?.get("quota")?.jsonObject
|
||||
val name = pack.getString("display_desc")
|
||||
?: pack.getString("group_name")
|
||||
?: base?.getString("name")
|
||||
?: base?.get("product_extra")?.jsonObject
|
||||
?.get("package_extra")?.jsonObject?.getString("package_name")
|
||||
?: "pack[$index]"
|
||||
val limit = quota?.getNumber("credits_limit")?.toLong()
|
||||
val used = pack.getNumber("usage", "credits_amount")
|
||||
?: base?.get("usage")?.jsonObject?.get("credits_amount")?.safeDouble()
|
||||
// available_endpoint 是通用 / Work 专属的唯一判别位(实测 2026-09 对照官方用量页:
|
||||
// 0=通用积分,TraeCode/TraeWork 均可用;1=Work 专属,仅 TraeWork)。缺失按 0(通用)。
|
||||
val isWork = (base?.get("available_endpoint")?.safeDouble() ?: 0.0) >= 1.0
|
||||
val endpointKey = if (isWork) "1" else "0"
|
||||
packCount++
|
||||
when {
|
||||
limit == null -> { /* 结构变化:跳过 */ }
|
||||
limit < 0 -> {
|
||||
extras["entitlement.$index.name"] = name
|
||||
extras["entitlement.$index.unlimited"] = "true"
|
||||
extras["entitlement.$index.endpoint"] = endpointKey
|
||||
}
|
||||
else -> {
|
||||
val remaining = (limit - (used ?: 0.0)).coerceAtLeast(0.0)
|
||||
fallbackSum += remaining
|
||||
if (isWork) workSum += remaining else generalSum += remaining
|
||||
if (packShown < 8) {
|
||||
extras["entitlement.$index.name"] = name
|
||||
extras["entitlement.$index.limit"] = limit.toString()
|
||||
extras["entitlement.$index.used"] = used?.let { trimNum(it) } ?: "0"
|
||||
extras["entitlement.$index.remaining"] = trimNum(remaining)
|
||||
extras["entitlement.$index.endpoint"] = endpointKey
|
||||
packShown++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (packCount > 0) {
|
||||
extras["pack_count"] = packCount.toString()
|
||||
extras["general_credits"] = trimNum(generalSum)
|
||||
extras["work_credits"] = trimNum(workSum)
|
||||
}
|
||||
|
||||
// 3) 金额:汇总优先,缺省回退包级
|
||||
val amount = if (total != null && consumed != null) {
|
||||
(total - consumed).coerceAtLeast(0.0)
|
||||
} else fallbackSum
|
||||
val totalQuota = total?.takeIf { it >= 0 }
|
||||
|
||||
return ParsedTraeUsage(amount = amount, totalQuota = totalQuota, extras = extras)
|
||||
}
|
||||
|
||||
private fun JsonObject.getString(key: String): String? = this[key]?.jsonPrimitive?.contentOrNull
|
||||
|
||||
private fun JsonElement.safeDouble(): Double? =
|
||||
runCatching { jsonPrimitive.content.toDoubleOrNull() }.getOrNull()
|
||||
|
||||
/** 按路径逐层取数:如 getNumber("usage","credits_amount") 取 this.usage.credits_amount。 */
|
||||
private fun JsonObject.getNumber(vararg keys: String): Double? {
|
||||
if (keys.isEmpty()) return null
|
||||
var node: JsonObject? = this
|
||||
for (i in keys.indices) {
|
||||
val el = node?.get(keys[i]) ?: return null
|
||||
if (i == keys.lastIndex) return el.safeDouble()
|
||||
node = runCatching { el.jsonObject }.getOrNull()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 平铺多候选数值扫描。 */
|
||||
private fun JsonObject.numberOr(vararg keys: String): Double? {
|
||||
for (key in keys) {
|
||||
this[key]?.let { el ->
|
||||
(el as? JsonPrimitive)?.doubleOrNull?.let { return it }
|
||||
(el as? JsonPrimitive)?.contentOrNull?.toDoubleOrNull()?.let { return it }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ---------- 每日签到(checkin_credits)----------
|
||||
|
||||
/**
|
||||
* 轻量查询「今日是否已签到」(只读,不发起 claim)。
|
||||
*
|
||||
* 供详情页进入时预取状态用:先查本地记录(credential.lastCheckinDate /
|
||||
* [CheckinStatusStore]),未命中再调 status 接口(服务端权威,跨设备已签也能识别)。
|
||||
* 返回 true=今日已签;false=未签/活动未开放;失败=网络/协议异常(调用方按未知处理)。
|
||||
*/
|
||||
suspend fun fetchCheckinStatus(accountId: String? = null): Result<Boolean> = withContext(Dispatchers.IO) {
|
||||
val credential = credentialRepository.get(ServiceType.TRAE, accountId)
|
||||
?: return@withContext Result.failure(RepositoryError.InvalidCredential())
|
||||
if (credential !is Credential.TraeCredential || credential.jwt.isBlank()) {
|
||||
return@withContext Result.failure(RepositoryError.InvalidCredential())
|
||||
}
|
||||
if (credential.region.uppercase() == "INTL") {
|
||||
// INTL 区签到接口未实测,status 同样不可用
|
||||
return@withContext Result.success(false)
|
||||
}
|
||||
val jwt = credential.jwt.trim()
|
||||
if (credential.lastCheckinDate == java.time.LocalDate.now().toString()) {
|
||||
return@withContext Result.success(true)
|
||||
}
|
||||
val deviceId = credential.checkinDeviceId?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?: ACCOUNT_BOUND_DEVICE_ID
|
||||
val body = checkinRequest(jwt, deviceId, "bound", CHECKIN_STATUS_PATH)
|
||||
body.fold(
|
||||
onSuccess = { text ->
|
||||
val info = parseStatus(text).getOrNull()
|
||||
Result.success(info?.checkedIn == true)
|
||||
},
|
||||
onFailure = { Result.failure(it) }
|
||||
)
|
||||
}
|
||||
|
||||
/** 签到动作语义结果(UI 层据此本地化,不直接展示 Repository 中文串)。 */
|
||||
enum class CheckinOutcome {
|
||||
/** 签到成功 */
|
||||
SUCCESS,
|
||||
/** 今日已签到(账号级,幂等) */
|
||||
ALREADY,
|
||||
/** 设备级拦截(提示而非报错) */
|
||||
DEVICE_BLOCKED,
|
||||
/** 服务端临时限流(如「参与用户太多,请稍后重试」):提示而非报错,可稍后重试 */
|
||||
RETRYABLE,
|
||||
/** INTL 区签到接口未实测,暂不支持 */
|
||||
INTL_UNSUPPORTED,
|
||||
/** 其他失败(服务端给了原因/错误码) */
|
||||
FAILED
|
||||
}
|
||||
|
||||
/** 签到结果(来源:trae-check 实测字段与语义)。 */
|
||||
data class CheckinResult(
|
||||
val success: Boolean,
|
||||
/** 今日已签(账号级,非设备拦截) */
|
||||
val alreadyChecked: Boolean,
|
||||
val points: Long,
|
||||
/** 服务端原始提示文案(仅作 [outcome] 之外的补充透传) */
|
||||
val message: String,
|
||||
/** 动作语义结果:UI 按此本地化,不依赖 [message] 语言 */
|
||||
val outcome: CheckinOutcome,
|
||||
/** 服务端错误码(outcome=FAILED 时可能有;-1 表示未提供) */
|
||||
val code: Long = -1L
|
||||
)
|
||||
|
||||
/**
|
||||
* 执行 Trae 每日签到(CN 区),流程对齐社区实测工具(trae-daily-checkin /
|
||||
* trae-checkin-helper 逆向口径):
|
||||
* 1. POST /trae/api/v2/ug/checkin_credits/status(body {})查状态:
|
||||
* - enable=false → 签到功能暂未开放(中性提示,非错误)
|
||||
* - checked_in=true → 今日已签(幂等短路,不重复 claim)
|
||||
* 2. POST /trae/api/v2/ug/checkin_credits/claim(body {})执行签到:
|
||||
* code 0/200 成功;code 10001 或文案含「已签到/already」=已签。
|
||||
* 头:Cloud-IDE-JWT <jwt> + x-device-id(登录会话绑定的 App 持久化设备 ID;
|
||||
* 不可用 userJwt.ClientID——固定客户端标识,所有用户相同,会被按设备风控拒绝)。
|
||||
* INTL 区签到接口未实测,暂不支持(返回明确提示)。
|
||||
*/
|
||||
suspend fun checkIn(accountId: String? = null): Result<CheckinResult> = withContext(Dispatchers.IO) {
|
||||
val credential = credentialRepository.get(ServiceType.TRAE, accountId)
|
||||
?: return@withContext Result.failure(RepositoryError.InvalidCredential())
|
||||
if (credential !is Credential.TraeCredential || credential.jwt.isBlank()) {
|
||||
return@withContext Result.failure(RepositoryError.InvalidCredential())
|
||||
}
|
||||
val regionKey = credential.region.uppercase().takeIf { it == "INTL" } ?: "CN"
|
||||
if (regionKey == "INTL") {
|
||||
return@withContext Result.success(
|
||||
CheckinResult(
|
||||
success = false,
|
||||
alreadyChecked = false,
|
||||
points = 0,
|
||||
message = "",
|
||||
outcome = CheckinOutcome.INTL_UNSUPPORTED
|
||||
)
|
||||
)
|
||||
}
|
||||
val jwt = credential.jwt.trim()
|
||||
// 本地签到状态短路:今天已成功签到则不再发起任何网络请求(防重复签到/重复 claim)
|
||||
val today = java.time.LocalDate.now().toString()
|
||||
if (credential.lastCheckinDate == today) {
|
||||
DebugLog.i(TAG, "checkin: 本地已记录今日签到,短路跳过网络请求")
|
||||
return@withContext Result.success(
|
||||
CheckinResult(
|
||||
success = true,
|
||||
alreadyChecked = true,
|
||||
points = 0,
|
||||
message = "",
|
||||
outcome = CheckinOutcome.ALREADY
|
||||
)
|
||||
)
|
||||
}
|
||||
// 签到设备(x-device-id):必须使用账号绑定的官方客户端设备——服务端按设备
|
||||
// 做签到风控/去重,App 自生成的随机设备 ID 会稳定触发 9074「参与用户太多」。
|
||||
// 实测(2026-09-13):改用 PC 客户端真实 did 后 claim 成功。优先级:
|
||||
// 用户设置页填写的 did(换设备后可自行更新)> 内置默认(同账号客户端 did)。
|
||||
val deviceId = credential.checkinDeviceId?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?: ACCOUNT_BOUND_DEVICE_ID
|
||||
val deviceSrc = "bound"
|
||||
// Step 1:先查签到状态(enable 活动开关 + checked_in 防重复短路,
|
||||
// 对齐 trae-checkin-helper 实测口径:enable/checked_in 必须是布尔才算协议正常)
|
||||
val statusBody = checkinRequest(jwt, deviceId, deviceSrc, CHECKIN_STATUS_PATH)
|
||||
if (statusBody.isFailure) {
|
||||
return@withContext Result.failure(
|
||||
statusBody.exceptionOrNull() ?: RepositoryError.Unknown()
|
||||
)
|
||||
}
|
||||
// status 解析失败(协议异常/被风控设备无状态可读)记录日志后继续走 claim 兜底
|
||||
val statusParsed = parseStatus(statusBody.getOrThrow())
|
||||
if (statusParsed.isFailure) {
|
||||
DebugLog.w(
|
||||
TAG,
|
||||
"checkin: status 解析失败,走 claim 兜底: ${statusParsed.exceptionOrNull()?.message}"
|
||||
)
|
||||
}
|
||||
statusParsed.getOrNull()?.let { status ->
|
||||
when {
|
||||
status.enable == false -> {
|
||||
// 签到活动未开放/间歇期:中性提示,非错误(enable=false 属正常现象)
|
||||
credentialRepository.save(
|
||||
credential.copy(lastVerifiedAt = System.currentTimeMillis()),
|
||||
accountId
|
||||
)
|
||||
return@withContext Result.success(
|
||||
CheckinResult(
|
||||
success = false,
|
||||
alreadyChecked = false,
|
||||
points = 0,
|
||||
message = status.message.ifBlank { "签到功能暂未开放,请稍后再试" },
|
||||
outcome = CheckinOutcome.RETRYABLE
|
||||
)
|
||||
)
|
||||
}
|
||||
status.checkedIn == true -> {
|
||||
// 今日已签:幂等短路,不再发起 claim;落本地签到日期防重复
|
||||
credentialRepository.save(
|
||||
credential.copy(
|
||||
lastVerifiedAt = System.currentTimeMillis(),
|
||||
lastCheckinDate = today
|
||||
),
|
||||
accountId
|
||||
)
|
||||
return@withContext Result.success(
|
||||
CheckinResult(
|
||||
success = true,
|
||||
alreadyChecked = true,
|
||||
points = status.credits?.toLong() ?: 0L,
|
||||
message = "",
|
||||
outcome = CheckinOutcome.ALREADY
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// status 解析失败(协议变化等情况)继续走 claim——claim 的 code 判定仍权威
|
||||
|
||||
// Step 2:执行签到。claim body 按官方契约探测(对齐 cpa-multi-plugins v0.12.41/43:
|
||||
// 09-04 上游收紧活动校验后,body 必须与 token 谱系匹配;官方契约空 {} 实测到账,
|
||||
// SOLO/TraeWork 官方同款 req_source=2,TraeCode 用 req_source=1;9074 时顺次换 body)。
|
||||
val claimBodies = listOf("{}", """{"req_source":2}""", """{"req_source":1}""")
|
||||
var claimResult: Result<CheckinResult>? = null
|
||||
for (body in claimBodies) {
|
||||
val cl = checkinRequest(jwt, deviceId, deviceSrc, CHECKIN_CLAIM_PATH, body)
|
||||
if (cl.isFailure) {
|
||||
claimResult = Result.failure(
|
||||
cl.exceptionOrNull() ?: RepositoryError.Unknown()
|
||||
)
|
||||
break
|
||||
}
|
||||
val parsed = parseCheckin(cl.getOrThrow())
|
||||
claimResult = parsed
|
||||
// 9074 已解析为 RETRYABLE;换 body 再试,其它语义(成功/已签/设备拦截/硬错误)直接定稿
|
||||
if (parsed.getOrNull()?.outcome != CheckinOutcome.RETRYABLE) break
|
||||
}
|
||||
val parsed = claimResult
|
||||
?: return@withContext Result.failure(RepositoryError.Unknown())
|
||||
// 仅「凭据被服务端接受」时才算已验证:SUCCESS/ALREADY/DEVICE_BLOCKED/RETRYABLE 都说明
|
||||
// JWT 有效(401/403 已在 checkinRequest 提前失败);FAILED(含 HTTP 400 业务失败)不标记,
|
||||
// 避免把无效凭据误标为已验证。SUCCESS/ALREADY 同时落本地签到日期(防当日重复签到)。
|
||||
val outcome = parsed.getOrNull()?.outcome
|
||||
if (outcome != null && outcome != CheckinOutcome.FAILED) {
|
||||
val checkedToday =
|
||||
outcome == CheckinOutcome.SUCCESS || outcome == CheckinOutcome.ALREADY
|
||||
credentialRepository.save(
|
||||
credential.copy(
|
||||
lastVerifiedAt = System.currentTimeMillis(),
|
||||
lastCheckinDate = if (checkedToday) today else credential.lastCheckinDate
|
||||
),
|
||||
accountId
|
||||
)
|
||||
}
|
||||
parsed
|
||||
}
|
||||
|
||||
/**
|
||||
* 签到系 POST(status/claim),请求头逐头对齐 cpa-multi-plugins v0.12.45
|
||||
* 抓包指纹(2026-09-03 成功签到请求复刻,用户账号实测:旧头多日 9074,
|
||||
* 新头当日 claim code=0):
|
||||
* - UA 必须是 VSCode 插件进程身份 "VSCode 1.107.1 (TRAE SOLO CN)"——签到走
|
||||
* VSCode 插件进程,与 IDE 主进程 Trae/0.1.x / web 均不同,混用身份 = 风控画像对不上;
|
||||
* - X-User-Region / X-Request-Id / X-TT-Trace-Id / Package-Type / App-Version
|
||||
* 等均随真实客户端;Authorization: Cloud-IDE-JWT <jwt>;X-Device-Id 为签到设备。
|
||||
* body 固定 `{}`(官方口径,加任何业务参数都会导致业务失败)。
|
||||
* 401/403 → [RepositoryError.InvalidCredential];其它非 200/400 → [mapHttpError];
|
||||
* 400 或 2xx → 返回 body 交给业务解析(400 也可能是已签/设备拦截)。
|
||||
*/
|
||||
private fun checkinRequest(
|
||||
jwt: String,
|
||||
deviceId: String,
|
||||
deviceSrc: String,
|
||||
path: String,
|
||||
bodyJson: String = "{}"
|
||||
): Result<String> {
|
||||
val requestId = java.util.UUID.randomUUID().toString()
|
||||
val request = Request.Builder()
|
||||
.url("https://api.trae.cn" + path)
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.addHeader("Accept", "*/*")
|
||||
.addHeader("User-Agent", "VSCode 1.107.1 (TRAE SOLO CN)")
|
||||
.addHeader("Authorization", "Cloud-IDE-JWT " + jwt)
|
||||
.addHeader("X-User-Region", "CN")
|
||||
.addHeader("Accept-Language", "zh-CN")
|
||||
.addHeader("Package-Type", "stable_cn")
|
||||
.addHeader("X-Lgw-Req-Sdk-Type", "3")
|
||||
.addHeader("X-Market-Client-Id", "VSCode 1.107.1")
|
||||
.addHeader("X-Device-Brand", "83DG")
|
||||
.addHeader("X-Device-Type", "windows")
|
||||
.addHeader("X-OS-Version", "Windows 11 Pro")
|
||||
.addHeader("App-Version", "0.1.61")
|
||||
.addHeader("X-Request-Id", requestId)
|
||||
.addHeader("X-TT-Trace-Id", ttTraceId(requestId))
|
||||
.addHeader("X-Device-Id", deviceId)
|
||||
.addHeader("Sec-Fetch-Dest", "empty")
|
||||
.addHeader("Sec-Fetch-Mode", "no-cors")
|
||||
.addHeader("Sec-Fetch-Site", "none")
|
||||
.post(bodyJson.toRequestBody("application/json".toMediaType()))
|
||||
.build()
|
||||
return try {
|
||||
okHttpClient.newCall(request).execute().use { resp ->
|
||||
val text = resp.body?.string().orEmpty()
|
||||
// 本地调试日志:HTTP 码、路径与 deviceId 来源(bound=服务端绑定/real=持久化/fallback=伪ID)。
|
||||
// 不记录 Authorization 与 deviceId 明文(敏感)。
|
||||
DebugLog.i(
|
||||
TAG,
|
||||
"checkin: path=$path body=$bodyJson device=$deviceSrc http=${resp.code} body=${text.take(200)}"
|
||||
)
|
||||
when {
|
||||
resp.code == 401 || resp.code == 403 ->
|
||||
Result.failure(authErrorFor(jwt))
|
||||
!resp.isSuccessful && resp.code != 400 ->
|
||||
Result.failure(mapHttpError(resp.code))
|
||||
else -> Result.success(text)
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Result.failure(RepositoryError.Network(e))
|
||||
} catch (e: Throwable) {
|
||||
Result.failure(RepositoryError.Unknown(e))
|
||||
}
|
||||
}
|
||||
|
||||
/** 生成抓包格式 tt-trace-id:00-<32hex>-<16hex>-01,16hex 取自请求 ID。 */
|
||||
private fun ttTraceId(requestId: String): String {
|
||||
val bytes = ByteArray(16)
|
||||
java.security.SecureRandom().nextBytes(bytes)
|
||||
val hex32 = bytes.joinToString("") { "%02x".format(it) }
|
||||
return "00-$hex32-${requestId.replace("-", "").take(16)}-01"
|
||||
}
|
||||
|
||||
private fun mapHttpError(code: Int): RepositoryError = when (code) {
|
||||
401, 403 -> RepositoryError.InvalidCredential()
|
||||
429 -> RepositoryError.RateLimited()
|
||||
in 500..599 -> RepositoryError.ServerError(code)
|
||||
else -> RepositoryError.Unknown()
|
||||
}
|
||||
|
||||
/**
|
||||
* 401/403 时用官方 CheckLogin 鉴别真实原因(服务端权威,非启发式猜测):
|
||||
* - IsLogin=false 或 ExpiredAt 已过 → [RepositoryError.SessionExpired](提示重新登录)
|
||||
* - 其它情况(含 CheckLogin 自身失败)→ [RepositoryError.InvalidCredential](保持原语义)
|
||||
*/
|
||||
private fun authErrorFor(jwt: String): RepositoryError {
|
||||
val state = runCatching {
|
||||
val request = Request.Builder()
|
||||
.url("https://api.trae.cn/cloudide/api/v3/trae/CheckLogin")
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.addHeader("Accept", "application/json")
|
||||
.addHeader("User-Agent", "Trae/0.1.43")
|
||||
.addHeader("X-Cloudide-Token", jwt)
|
||||
.post("""{"IDEVersion":"0.1.43"}""".toRequestBody("application/json".toMediaType()))
|
||||
.build()
|
||||
okHttpClient.newCall(request).execute().use { resp ->
|
||||
if (!resp.isSuccessful) return@runCatching null
|
||||
parseCheckLogin(resp.body?.string().orEmpty())
|
||||
}
|
||||
}.getOrNull()
|
||||
val expired = state != null &&
|
||||
(state.isLogin == false || (state.expiredAt in 1 until System.currentTimeMillis()))
|
||||
return if (expired) RepositoryError.SessionExpired() else RepositoryError.InvalidCredential()
|
||||
}
|
||||
|
||||
/** CheckLogin 结果:isLogin 为服务端权威登录态;expiredAt 为 JWT 过期时间戳(ms)。 */
|
||||
internal data class CheckLoginState(val isLogin: Boolean?, val expiredAt: Long?)
|
||||
|
||||
/** 解析 CheckLogin 响应(Result.IsLogin / Result.ExpiredAt),协议异常返回 null。 */
|
||||
internal fun parseCheckLogin(body: String): CheckLoginState? = runCatching {
|
||||
val root = parseJson.parseToJsonElement(body).jsonObject
|
||||
val result = root["Result"] as? JsonObject ?: return null
|
||||
CheckLoginState(
|
||||
isLogin = (result["IsLogin"] as? JsonPrimitive)?.booleanOrNull,
|
||||
expiredAt = (result["ExpiredAt"] as? JsonPrimitive)?.longOrNull
|
||||
)
|
||||
}.getOrNull()
|
||||
|
||||
/**
|
||||
* 数值转简短显示串(去掉多余的 0 与小数点)。
|
||||
* 注意不能用 v.toString().trimEnd('0','.'):trimEnd 会连续剥离集合内任意字符,
|
||||
* "50.0" 会被剪成 "5"、"100.0" 剪成 "1"(余额/签到金额会显示错一位)。
|
||||
*/
|
||||
private fun trimNum(v: Double): String =
|
||||
java.math.BigDecimal.valueOf(v).stripTrailingZeros().toPlainString()
|
||||
|
||||
private data class ParsedTraeUsage(
|
||||
val amount: Double,
|
||||
val totalQuota: Double?,
|
||||
val extras: Map<String, String>
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val TAG = "Trae"
|
||||
|
||||
/** 官方客户端固定 ClientID(所有用户相同,不可作 x-device-id,会被按设备风控拒绝)。 */
|
||||
const val TRAE_CLIENT_ID = "en1oxy7wnw8j9n"
|
||||
|
||||
/**
|
||||
* 账号绑定的官方 PC 客户端设备 ID(x-device-id)。
|
||||
* 实测根因(2026-09-13):签到 claim 的 9074「当前参与用户太多」是服务端设备
|
||||
* 风控——App 自生成设备 ID 必被拒,改用官方客户端真实 did 后 claim 成功。
|
||||
* 来源:客户端请求日志(api.trae.com.cn icube config/query did=…)。
|
||||
* ⚠️ 账号级:换账号或官方端重置设备标识后需更新此值。
|
||||
*/
|
||||
const val ACCOUNT_BOUND_DEVICE_ID = "895482260673817"
|
||||
|
||||
/** 解析专用 Json(companion 内独立持有,使 parseCheckin 可静态调用/单测)。 */
|
||||
private val parseJson = Json { ignoreUnknownKeys = true }
|
||||
|
||||
/**
|
||||
* 解析 claim 响应(code/message/points 多候选)。
|
||||
*
|
||||
* 语义判定(实测口径):
|
||||
* - code 0/200 → SUCCESS;code 1001 或文案含「已签到/already/claimed」→ ALREADY;
|
||||
* - 文案含「设备/device/machine」→ DEVICE_BLOCKED(设备级拦截,优先级高于 already,避免误报已签);
|
||||
* - 其余 → FAILED 并回传 code。
|
||||
*
|
||||
* 放在 companion 内以便单测直接调用(不依赖 OkHttp/凭据)。
|
||||
*/
|
||||
internal fun parseCheckin(body: String): Result<CheckinResult> = runCatching {
|
||||
val root = parseJson.parseToJsonElement(body).jsonObject
|
||||
// data 可能为 null/非对象(如 {"code":0}),安全转换后退回根层
|
||||
val data = root["data"] as? JsonObject ?: root
|
||||
val code = data.numberOr("code")?.toLong() ?: root.numberOr("code")?.toLong() ?: -1L
|
||||
val msg = data.scalarOr("message", "msg") ?: root.scalarOr("message", "msg") ?: ""
|
||||
// points 与 credit 双候选(社区实测:claim 成功响应可能用 credit 字段)
|
||||
val points = (data.numberOr("points", "credit")
|
||||
?: root.numberOr("points", "credit"))?.toLong() ?: 0L
|
||||
|
||||
val succeeded = code == 0L || code == 200L
|
||||
val lowerMsg = msg.lowercase()
|
||||
val deviceBlock = msg.contains("设备") || lowerMsg.contains("device") || lowerMsg.contains("machine")
|
||||
// 权威口径幂等兜底码是 10001(trae-checkin-helper 逆向);历史实现用 1001,
|
||||
// 两者都认,避免把已签误判为 FAILED 硬错误。
|
||||
val already = (code == 1001L || code == 10001L) || (!deviceBlock && (
|
||||
msg.contains("已签到") || msg.contains("已经签到") || msg.contains("明日再来") ||
|
||||
msg.contains("今日已完成") || msg.contains("已领取") ||
|
||||
lowerMsg.contains("already") || lowerMsg.contains("checked") || lowerMsg.contains("claimed")
|
||||
))
|
||||
// 服务端临时限流/排队(账号、设备或活动人数限制):提示可稍后重试,不算硬错误。
|
||||
val retryable = !already && !deviceBlock && (
|
||||
msg.contains("参与") || msg.contains("稍后重试") || msg.contains("火爆") ||
|
||||
msg.contains("繁忙") || msg.contains("限流") || msg.contains("排队") ||
|
||||
lowerMsg.contains("too many") || lowerMsg.contains("retry") || lowerMsg.contains("busy") ||
|
||||
lowerMsg.contains("popular") || lowerMsg.contains("later")
|
||||
)
|
||||
|
||||
DebugLog.i(
|
||||
TAG,
|
||||
"checkIn 解析: code=$code msg=$msg points=$points -> " +
|
||||
when {
|
||||
succeeded -> "SUCCESS"
|
||||
already -> "ALREADY"
|
||||
deviceBlock -> "DEVICE_BLOCKED"
|
||||
retryable -> "RETRYABLE"
|
||||
else -> "FAILED"
|
||||
}
|
||||
)
|
||||
|
||||
when {
|
||||
succeeded -> Result.success(
|
||||
CheckinResult(
|
||||
success = true,
|
||||
alreadyChecked = false,
|
||||
points = points.takeIf { it > 0 } ?: 200L,
|
||||
message = msg,
|
||||
outcome = CheckinOutcome.SUCCESS
|
||||
)
|
||||
)
|
||||
already -> Result.success(
|
||||
CheckinResult(true, true, 0L, msg, CheckinOutcome.ALREADY)
|
||||
)
|
||||
deviceBlock -> Result.success(
|
||||
CheckinResult(false, false, 0L, msg, CheckinOutcome.DEVICE_BLOCKED)
|
||||
)
|
||||
retryable -> Result.success(
|
||||
CheckinResult(false, false, 0L, msg, CheckinOutcome.RETRYABLE)
|
||||
)
|
||||
else -> Result.success(
|
||||
CheckinResult(false, false, 0L, msg, CheckinOutcome.FAILED, code)
|
||||
)
|
||||
}
|
||||
}.getOrElse { Result.failure(RepositoryError.Unknown(it)) }
|
||||
|
||||
/** 官方签到接口路径(trae-checkin-helper 逆向口径)。 */
|
||||
const val CHECKIN_STATUS_PATH = "/trae/api/v2/ug/checkin_credits/status"
|
||||
const val CHECKIN_CLAIM_PATH = "/trae/api/v2/ug/checkin_credits/claim"
|
||||
|
||||
/** status 接口解析结果(字段缺失为 null;enable/checked_in 以布尔为权威)。 */
|
||||
internal data class TraeStatusInfo(
|
||||
val enable: Boolean?,
|
||||
val checkedIn: Boolean?,
|
||||
val credits: Double?,
|
||||
val message: String
|
||||
)
|
||||
|
||||
/**
|
||||
* 解析 status 响应(对齐 trae-checkin-helper 实测口径):
|
||||
* 响应为平铺结构(code/enable/checked_in/credits 同层),个别版本包一层 data,兼容两种;
|
||||
* 业务码非 0/200 或 enable/checked_in 非布尔 → 解析失败(调用方继续走 claim 兜底)。
|
||||
*/
|
||||
internal fun parseStatus(body: String): Result<TraeStatusInfo> = runCatching {
|
||||
val root = parseJson.parseToJsonElement(body).jsonObject
|
||||
val data = root["data"] as? JsonObject ?: root
|
||||
val code = data.numberOr("code")?.toLong()
|
||||
?: root.numberOr("code")?.toLong()
|
||||
if (code != null && code != 0L && code != 200L) {
|
||||
error("status 业务失败 code=$code")
|
||||
}
|
||||
val enable = data.boolOr("enable") ?: root.boolOr("enable")
|
||||
val checkedIn = data.boolOr("checked_in") ?: root.boolOr("checked_in")
|
||||
if (enable == null || checkedIn == null) {
|
||||
error("status 响应缺 enable/checked_in(协议可能已变更)")
|
||||
}
|
||||
TraeStatusInfo(
|
||||
enable = enable,
|
||||
checkedIn = checkedIn,
|
||||
credits = data.numberOr("credits") ?: root.numberOr("credits"),
|
||||
message = data.scalarOr("message", "msg")
|
||||
?: root.scalarOr("message", "msg") ?: ""
|
||||
)
|
||||
}
|
||||
|
||||
/** 布尔字段解析(兼容原生 JSON boolean 与 "true"/"false" 字符串,非布尔返回 null)。 */
|
||||
private fun JsonObject.boolOr(key: String): Boolean? {
|
||||
val prim = this[key] as? JsonPrimitive ?: return null
|
||||
return when (prim.content.lowercase()) {
|
||||
"true" -> true
|
||||
"false" -> false
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonObject.scalarOr(vararg keys: String): String? {
|
||||
for (key in keys) {
|
||||
this[key]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }?.let { return it }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 平铺多候选数值扫描。 */
|
||||
private fun JsonObject.numberOr(vararg keys: String): Double? {
|
||||
for (key in keys) {
|
||||
this[key]?.let { el ->
|
||||
(el as? JsonPrimitive)?.doubleOrNull?.let { return it }
|
||||
(el as? JsonPrimitive)?.contentOrNull?.toDoubleOrNull()?.let { return it }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,112 @@
|
||||
package com.rainy.token.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import com.rainy.token.BuildConfig
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
|
||||
/**
|
||||
* 应用更新仓库:从 Gitea 仓库的 update.json 读取版本清单,
|
||||
* 下载 APK 到应用缓存目录供 FileProvider 安装。
|
||||
*/
|
||||
@Singleton
|
||||
class UpdateRepository @Inject constructor(
|
||||
private val okHttpClient: OkHttpClient,
|
||||
@ApplicationContext context: Context
|
||||
) {
|
||||
private val appContext = context.applicationContext
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
/** 检查是否有新版本。null = 已是最新;非空 = 远端有更新。 */
|
||||
suspend fun checkForUpdate(): Result<UpdateInfo?> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.url(UPDATE_MANIFEST_URL)
|
||||
.get()
|
||||
.build()
|
||||
val text = okHttpClient.newCall(request).execute().use { resp ->
|
||||
if (!resp.isSuccessful) {
|
||||
return@withContext Result.failure(IOException("HTTP " + resp.code))
|
||||
}
|
||||
resp.body?.string().orEmpty()
|
||||
}
|
||||
val manifest = json.decodeFromString<UpdateManifest>(text)
|
||||
val hasUpdate = manifest.versionCode > BuildConfig.VERSION_CODE
|
||||
Result.success(if (hasUpdate) {
|
||||
UpdateInfo(
|
||||
versionCode = manifest.versionCode,
|
||||
versionName = manifest.versionName,
|
||||
apkUrl = manifest.apkUrl,
|
||||
notes = manifest.notes
|
||||
)
|
||||
} else {
|
||||
null
|
||||
})
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 下载 APK 到缓存目录。onProgress 回调下载字节数/总字节数(total 可能为 -1)。 */
|
||||
suspend fun downloadApk(
|
||||
info: UpdateInfo,
|
||||
onProgress: (downloaded: Long, total: Long) -> Unit
|
||||
): Result<File> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val dir = File(appContext.cacheDir, "apk_downloads").apply { mkdirs() }
|
||||
val file = File(dir, "rainytoken-" + info.versionName + ".apk")
|
||||
val request = Request.Builder().url(info.apkUrl).get().build()
|
||||
okHttpClient.newCall(request).execute().use { resp ->
|
||||
if (!resp.isSuccessful) {
|
||||
return@withContext Result.failure(IOException("HTTP " + resp.code))
|
||||
}
|
||||
val body = resp.body ?: return@withContext Result.failure(IOException("empty body"))
|
||||
val total = body.contentLength()
|
||||
file.outputStream().buffered().use { output ->
|
||||
body.byteStream().use { input ->
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
var downloaded = 0L
|
||||
var read: Int
|
||||
while (input.read(buffer).also { read = it } != -1) {
|
||||
output.write(buffer, 0, read)
|
||||
downloaded += read
|
||||
onProgress(downloaded, total)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Result.success(file)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class UpdateManifest(
|
||||
val versionCode: Int,
|
||||
val versionName: String,
|
||||
val apkUrl: String,
|
||||
val notes: String = ""
|
||||
)
|
||||
|
||||
data class UpdateInfo(
|
||||
val versionCode: Int,
|
||||
val versionName: String,
|
||||
val apkUrl: String,
|
||||
val notes: String
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val UPDATE_MANIFEST_URL =
|
||||
"http://124.222.97.212:8443/maya/Rainytoken/raw/branch/dev/update.json"
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
package com.rainy.token.data.repository
|
||||
|
||||
import android.webkit.CookieManager
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import com.rainy.token.domain.model.CookieEntry
|
||||
import com.rainy.token.domain.model.Credential
|
||||
import com.rainy.token.domain.service.ServiceType
|
||||
@ -21,10 +22,16 @@ class WebViewSessionSaver @Inject constructor(
|
||||
private val credentialRepository: CredentialRepository
|
||||
) {
|
||||
|
||||
/** Sub2API 面板登录保存结果:凭据 + 实际写入的账号 ID(新建账号时返回新 ID)。 */
|
||||
data class SavedSub2PanelSession(
|
||||
val credential: Credential.Sub2ApiCredential,
|
||||
val accountId: String
|
||||
)
|
||||
|
||||
/**
|
||||
* 提取并保存 Cookie 列表。返回保存后的凭据。
|
||||
*/
|
||||
fun saveFromCookieManager(
|
||||
suspend fun saveFromCookieManager(
|
||||
service: ServiceType,
|
||||
url: String,
|
||||
token: String? = null,
|
||||
@ -41,13 +48,97 @@ class WebViewSessionSaver @Inject constructor(
|
||||
expiresAt = expiresAt,
|
||||
lastVerifiedAt = System.currentTimeMillis()
|
||||
)
|
||||
// 用 runBlocking 写凭据 —— 调用方在 Composable 中,可以接受
|
||||
kotlinx.coroutines.runBlocking {
|
||||
credentialRepository.save(session)
|
||||
}
|
||||
// 挂起写凭据:调用方在 viewModelScope 中,避免在主线程做 Keystore 加解密 + DataStore 写盘
|
||||
credentialRepository.save(session)
|
||||
return session
|
||||
}
|
||||
|
||||
/**
|
||||
* 面板登录成功后自动写入 Sub2API 凭据。
|
||||
*
|
||||
* 面板把登录态放在 localStorage.auth_token(JWT),所以 WebView 登录完成后
|
||||
* 注入 JS 读取该键,连同站点基址一起落库,用户无需再手工粘贴。
|
||||
*
|
||||
* @param baseUrl 面板基址(登录页所在 origin,自动补 https 前缀)
|
||||
* @param authToken localStorage.auth_token 的值;为空表示没抓到,返回 null 不覆盖旧凭据
|
||||
* @param siteIcon 站点 Logo(可选,登录页 favicon/og:image)
|
||||
* @param email 登录邮箱(可选,仅用于展示与回退登录)
|
||||
* @param accountId 目标账号(多账号服务;null=默认账号/新建账号)
|
||||
*/
|
||||
suspend fun saveSub2ApiPanelSession(
|
||||
baseUrl: String,
|
||||
authToken: String,
|
||||
siteIcon: String? = null,
|
||||
email: String? = null,
|
||||
accountId: String? = null
|
||||
): SavedSub2PanelSession? {
|
||||
val token = authToken.trim()
|
||||
if (token.isEmpty()) return null
|
||||
val base = normalizeBaseUrl(baseUrl) ?: return null
|
||||
val existing = credentialRepository.get(ServiceType.SUB2API, accountId) as? Credential.Sub2ApiCredential
|
||||
val credential = Credential.Sub2ApiCredential(
|
||||
service = ServiceType.SUB2API,
|
||||
baseUrl = base,
|
||||
email = email?.takeIf { it.isNotBlank() } ?: existing?.email.orEmpty(),
|
||||
password = existing?.password.orEmpty(),
|
||||
apiKey = existing?.apiKey,
|
||||
authToken = token,
|
||||
siteIcon = siteIcon?.takeIf { it.isNotBlank() } ?: existing?.siteIcon,
|
||||
lastVerifiedAt = System.currentTimeMillis()
|
||||
)
|
||||
// 返回实际写入的账号 ID:新建账号 / 默认账号统一回填,避免 UI 停留在「新建」空表单
|
||||
val effectiveId = credentialRepository.save(credential, accountId)
|
||||
return SavedSub2PanelSession(credential = credential, accountId = effectiveId)
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenCode Go 应用内登录成功后自动落库。
|
||||
*
|
||||
* WebView 里完成 GitHub/Google OAuth 后落到 dashboard,登录态表现为
|
||||
* `auth` cookie + URL 中的 workspaceId——正是 [OpenCodeGoRepository] 需要的
|
||||
* 两个凭据字段,用户不再需要手动开 DevTools 抄 cookie。
|
||||
*
|
||||
* @param authCookie dashboard 的 `auth` cookie 值(CookieManager 抓取)
|
||||
* @param workspaceId dashboard URL `/workspace/{id}/go` 中的 id
|
||||
*/
|
||||
suspend fun saveOpenCodeGoSession(
|
||||
authCookie: String,
|
||||
workspaceId: String
|
||||
): Credential.SessionCredential? {
|
||||
val auth = authCookie.trim()
|
||||
val ws = workspaceId.trim()
|
||||
if (auth.isEmpty() || ws.isEmpty()) return null
|
||||
// 与手动保存一致:基于既有凭据合并,保留 cookies/token/apiKey 等字段不丢
|
||||
val existing = credentialRepository.get(ServiceType.OPENCODE_GO) as? Credential.SessionCredential
|
||||
val session = (existing ?: Credential.SessionCredential(service = ServiceType.OPENCODE_GO)).copy(
|
||||
authCookie = auth,
|
||||
workspaceId = ws,
|
||||
lastVerifiedAt = System.currentTimeMillis()
|
||||
)
|
||||
credentialRepository.save(session)
|
||||
return session
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化面板基址:补 scheme、去尾斜杠,只保留 scheme://host[:port]。
|
||||
*
|
||||
* 用 [okhttp3.HttpUrl] 真正解析而不是字符串长度判断——否则 "not a url" 这类垃圾
|
||||
* 会被拼成 "https://not a url" 通过校验并落库,后续拼请求 URL 时 OkHttp 直接抛异常。
|
||||
*/
|
||||
private fun normalizeBaseUrl(raw: String): String? {
|
||||
val trimmed = raw.trim()
|
||||
if (trimmed.isEmpty()) return null
|
||||
val withScheme = if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
|
||||
trimmed
|
||||
} else {
|
||||
"https://" + trimmed
|
||||
}
|
||||
val parsed = withScheme.toHttpUrlOrNull() ?: return null
|
||||
// 只保留 origin,丢掉 path/query(面板基址就是 origin)
|
||||
return parsed.newBuilder().encodedPath("/").query(null).fragment(null).build()
|
||||
.toString().trimEnd('/')
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 `name1=value1; name2=value2` 解析为 [CookieEntry] 列表。
|
||||
* 完整属性(domain/path/expires/secure/httpOnly)暂不可用,因为 Android CookieManager
|
||||
|
||||
@ -0,0 +1,745 @@
|
||||
package com.rainy.token.data.repository
|
||||
|
||||
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.service.ServiceType
|
||||
import java.io.IOException
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.doubleOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
import kotlinx.serialization.json.put
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
|
||||
/**
|
||||
* 腾讯 WorkBuddy 余额仓库。
|
||||
*
|
||||
* 认证:`Authorization: Bearer <accessToken>`;accessToken 过期时用 refreshToken 轮换一次并重试
|
||||
* (同 Codex 语义):POST https://copilot.tencent.com/v2/plugin/auth/token/refresh(X-Refresh-Token 头)。
|
||||
*
|
||||
* 资源余额(2026-09 用真实登录态在官网个人中心实测,workbuddy.cn):
|
||||
* - 主端点:POST https://www.workbuddy.cn/billing/meter/get-user-resource-summary body `{}`
|
||||
* 真实响应:{"code":0,"data":{"Packages":[{"PackageCode":"TCACA_...","CycleTotalCapacity":"7826",
|
||||
* "CycleRemainCapacity":"2671.96","CycleUsedCapacity":"5154.04","CycleFrozenCapacity":"0",
|
||||
* "CapacityUnit":"credits"}, ...], "IsPaidUser":false}}
|
||||
* - 兜底:POST https://www.codebuddy.cn/v2/billing/meter/get-user-resource(官方 CLI 插件模型,
|
||||
* ProductCode p_tcaca / Status 0,3),汇总 Packages/Accounts[] 同族字段。
|
||||
* 余额 = 各包 CycleRemainCapacity 之和;单位取 CapacityUnit(credits)。
|
||||
* 字段名随官方迭代可能变化,解析保持防御式(多候选扫描)。
|
||||
*/
|
||||
@Singleton
|
||||
class WorkBuddyRepository constructor(
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val credentialRepository: CredentialRepository,
|
||||
private val balanceCache: BalanceCache
|
||||
) {
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private val jsonMedia = "application/json".toMediaType()
|
||||
|
||||
private val codebuddyBody = buildJsonObject {
|
||||
put("PageNumber", 1)
|
||||
put("PageSize", 100)
|
||||
put("ProductCode", "p_tcaca")
|
||||
put("Status", buildJsonArray { add(JsonPrimitive(0)); add(JsonPrimitive(3)) })
|
||||
put("Channel", "P")
|
||||
put("Unsubmitted", false)
|
||||
put("Unit", "")
|
||||
put("QueryResourceId", buildJsonArray { })
|
||||
}.toString()
|
||||
|
||||
suspend fun fetchBalance(accountId: String? = null): Result<ServiceBalance> = withContext(Dispatchers.IO) {
|
||||
val credential = credentialRepository.get(ServiceType.WORKBUDDY, accountId)
|
||||
?: return@withContext Result.failure(RepositoryError.InvalidCredential())
|
||||
if (credential !is Credential.WorkBuddyCredential) {
|
||||
return@withContext Result.failure(RepositoryError.InvalidCredential())
|
||||
}
|
||||
|
||||
var access = credential.accessToken
|
||||
var refresh = credential.refreshToken
|
||||
// hostMode 0 = workbuddy.cn summary(实测主路径);1 = codebuddy.cn 兜底
|
||||
var hostMode = 0
|
||||
var attempts = 0
|
||||
var lastBody = ""
|
||||
while (attempts < 3) {
|
||||
attempts++
|
||||
val request = if (hostMode == 0) summaryRequest(access) else codebuddyRequest(access)
|
||||
val resp = try {
|
||||
okHttpClient.newCall(request).execute()
|
||||
} catch (e: IOException) {
|
||||
return@withContext Result.failure(RepositoryError.Network(e))
|
||||
} catch (e: Throwable) {
|
||||
return@withContext Result.failure(RepositoryError.Unknown(e))
|
||||
}
|
||||
val code = resp.code
|
||||
val body = runCatching { resp.body?.string().orEmpty() }.getOrDefault("")
|
||||
resp.close()
|
||||
|
||||
if (code in 200..299) {
|
||||
lastBody = body
|
||||
break
|
||||
}
|
||||
when (code) {
|
||||
404 -> {
|
||||
if (hostMode == 0) { hostMode = 1; continue }
|
||||
return@withContext Result.failure(mapHttpError(code, body))
|
||||
}
|
||||
401, 403 -> {
|
||||
val refreshed = tryRefesh(refresh) ?: return@withContext Result.failure(
|
||||
RepositoryError.InvalidCredential()
|
||||
)
|
||||
// 暂存轮换后的凭据,随本次成功提交(RefreshWriteSession 机制)
|
||||
credentialRepository.save(
|
||||
credential.copy(
|
||||
accessToken = refreshed.accessToken,
|
||||
refreshToken = refreshed.refreshToken,
|
||||
expiresAt = if (refreshed.expiresIn > 0) {
|
||||
System.currentTimeMillis() + refreshed.expiresIn * 1000
|
||||
} else {
|
||||
credential.expiresAt.coerceAtLeast(System.currentTimeMillis())
|
||||
}
|
||||
),
|
||||
accountId
|
||||
)
|
||||
access = refreshed.accessToken
|
||||
refresh = refreshed.refreshToken
|
||||
continue
|
||||
}
|
||||
else -> return@withContext Result.failure(mapHttpError(code, body))
|
||||
}
|
||||
}
|
||||
|
||||
if (lastBody.isBlank()) {
|
||||
return@withContext Result.failure(
|
||||
RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "workbuddy 响应为空")
|
||||
)
|
||||
}
|
||||
val parsed = runCatching { parseResource(lastBody) }.getOrElse { e ->
|
||||
return@withContext Result.failure(
|
||||
RepositoryError.ParseError(
|
||||
RepositoryError.ParseErrorReason.MALFORMED_RESPONSE,
|
||||
"workbuddy 资源解析失败: ${e.message}"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// 细粒度账户明细(官网个人中心同源数据):每个资源包的已用/总量/剩余/到期时间。
|
||||
// 独立请求,失败仅降级(保留 summary 的简化 extras),不影响余额主流程。
|
||||
val detailExtras = runCatching { fetchResourceDetailExtras(access) }.getOrNull()
|
||||
val mergedExtras = if (detailExtras != null) {
|
||||
parsed.extras + detailExtras
|
||||
} else {
|
||||
parsed.extras
|
||||
}
|
||||
|
||||
val balance = ServiceBalance(
|
||||
service = ServiceType.WORKBUDDY,
|
||||
amount = parsed.amount,
|
||||
unit = parsed.unit ?: "Credits",
|
||||
monthlySpent = parsed.used.takeIf { it > 0 },
|
||||
totalQuota = parsed.size.takeIf { it > 0 },
|
||||
isAvailable = parsed.amount > 0 || parsed.size > 0,
|
||||
extras = mergedExtras
|
||||
)
|
||||
balanceCache.put(ServiceType.WORKBUDDY, balance, accountId)
|
||||
credentialRepository.save(credential.copy(lastVerifiedAt = System.currentTimeMillis()), accountId)
|
||||
Result.success(balance)
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取细粒度账户明细并转成 extras 增量([KEY_DETAIL_RAW] / [KEY_PLAN_CODE])。
|
||||
* 请求失败或解析失败返回 null(调用方降级到 summary 的简化 extras)。
|
||||
*/
|
||||
private fun fetchResourceDetailExtras(accessToken: String): Map<String, String>? {
|
||||
val request = Request.Builder()
|
||||
.url("https://www.codebuddy.cn/v2/billing/meter/get-user-resource")
|
||||
.addHeader("Authorization", "Bearer $accessToken")
|
||||
.addHeader("Accept", "application/json")
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.post(codebuddyBody.toRequestBody(jsonMedia))
|
||||
.build()
|
||||
val body = try {
|
||||
okHttpClient.newCall(request).execute().use { resp ->
|
||||
if (!resp.isSuccessful) return null
|
||||
resp.body?.string().orEmpty()
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
return null
|
||||
}
|
||||
if (body.isBlank()) return null
|
||||
return parseResourceDetail(body)
|
||||
}
|
||||
|
||||
/** workbuddy.cn 官网个人中心实测端点(2026-09 确认 200)。 */
|
||||
private fun summaryRequest(accessToken: String): Request =
|
||||
Request.Builder()
|
||||
.url("https://www.workbuddy.cn/billing/meter/get-user-resource-summary")
|
||||
.addHeader("Authorization", "Bearer $accessToken")
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.post("{}".toRequestBody(jsonMedia))
|
||||
.build()
|
||||
|
||||
/** codebuddy.cn 官方 CLI 插件模型(桌面/CLI accessToken 兜底)。 */
|
||||
private fun codebuddyRequest(accessToken: String): Request =
|
||||
Request.Builder()
|
||||
.url("https://www.codebuddy.cn/v2/billing/meter/get-user-resource")
|
||||
.addHeader("Authorization", "Bearer $accessToken")
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.post(codebuddyBody.toRequestBody(jsonMedia))
|
||||
.build()
|
||||
|
||||
/**
|
||||
* refresh_token 单次轮换:POST token/refresh(X-Refresh-Token 头)。
|
||||
* 只读取并返回新凭据;轮换后的提交交给主流程的 save(同一成功才落库)。
|
||||
*/
|
||||
private fun tryRefesh(refreshToken: String): RefreshedWorkBuddy? {
|
||||
val request = Request.Builder()
|
||||
.url("https://copilot.tencent.com/v2/plugin/auth/token/refresh")
|
||||
.addHeader("X-Refresh-Token", refreshToken)
|
||||
.post("{}".toRequestBody(jsonMedia))
|
||||
.build()
|
||||
return try {
|
||||
okHttpClient.newCall(request).execute().use { resp ->
|
||||
if (!resp.isSuccessful) return@use null
|
||||
val text = resp.body?.string().orEmpty()
|
||||
val obj = json.parseToJsonElement(text).jsonObject
|
||||
val access = obj.readToken("access_token", "accessToken")
|
||||
?: obj["data"]?.jsonObject?.readToken("access_token", "accessToken")
|
||||
?: return@use null
|
||||
val refresh = obj.readToken("refresh_token", "refreshToken")
|
||||
?: obj["data"]?.jsonObject?.readToken("refresh_token", "refreshToken")
|
||||
?: refreshToken
|
||||
val expiresIn = obj["expires_in"]?.jsonPrimitive?.longOrNull ?: 0L
|
||||
RefreshedWorkBuddy(access, refresh, expiresIn)
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonObject.readToken(vararg keys: String): String? {
|
||||
for (key in keys) {
|
||||
this[key]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }?.let { return it }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 codebuddy.cn `/v2/billing/meter/get-user-resource` 的细粒度账户明细。
|
||||
*
|
||||
* 响应:`data.Response.Data.Accounts[]`,每个账户是一个独立资源包,字段含
|
||||
* PackageCode / PackageName / CycleCapacityRemainPrecise / CycleCapacityUsedPrecise /
|
||||
* CycleCapacitySizePrecise / CycleStartTime / CycleEndTime / Status / ExpiredTime。
|
||||
*
|
||||
* 按 PackageCode 分组:n==1 且非运营裂变包 → 套餐积分;其余(n>1 的裂变包)→ 平台奖励积分。
|
||||
* 返回 extras 增量(键见 [KEY_DETAIL_RAW])+ 是否解析成功。失败时调用方保留 summary 的 extras。
|
||||
*/
|
||||
private fun parseResourceDetail(body: String): Map<String, String>? {
|
||||
val root = runCatching { json.parseToJsonElement(body).jsonObject }.getOrNull() ?: return null
|
||||
val data = root["data"]?.jsonObject?.let { d ->
|
||||
(d["Response"] as? JsonObject)?.let { r -> (r["Data"] as? JsonObject) ?: d } ?: d
|
||||
} ?: root
|
||||
val accounts = data["Accounts"]?.jsonArray ?: return null
|
||||
val list = accounts.mapNotNull { it.jsonObject.takeIf { o -> o.isNotEmpty() } }
|
||||
if (list.isEmpty()) return null
|
||||
|
||||
val out = linkedMapOf<String, String>()
|
||||
val detail = buildJsonArray {
|
||||
for (a in list) {
|
||||
val code = a.scalar("PackageCode") ?: continue
|
||||
val name = a.scalar("PackageName") ?: code
|
||||
val remain = a.scalar("CycleCapacityRemainPrecise")
|
||||
?: a.number("CycleCapacityRemain", "CapacityRemainPrecise", "CapacityRemain")?.let { trimNum(it) }
|
||||
?: continue
|
||||
val used = a.scalar("CycleCapacityUsedPrecise")
|
||||
?: a.number("CycleCapacityUsed", "CapacityUsedPrecise", "CapacityUsed")?.let { trimNum(it) }
|
||||
?: "0"
|
||||
val total = a.scalar("CycleCapacitySizePrecise")
|
||||
?: a.number("CycleCapacitySize", "CapacitySizePrecise", "CapacitySize")?.let { trimNum(it) }
|
||||
?: "0"
|
||||
val cycleEnd = a.scalar("CycleEndTime")?.takeIf { it.isNotBlank() }
|
||||
val expiredAt = a.scalar("ExpiredTime")?.takeIf { it.isNotBlank() }
|
||||
val status = a.number("Status")?.toInt()
|
||||
add(buildJsonObject {
|
||||
put("code", JsonPrimitive(code))
|
||||
put("name", JsonPrimitive(name))
|
||||
put("remain", JsonPrimitive(remain))
|
||||
put("used", JsonPrimitive(used))
|
||||
put("total", JsonPrimitive(total))
|
||||
put("cycleEnd", JsonPrimitive(cycleEnd ?: ""))
|
||||
put("expiredAt", JsonPrimitive(expiredAt ?: ""))
|
||||
put("status", JsonPrimitive(status ?: -1))
|
||||
})
|
||||
}
|
||||
}
|
||||
if (detail.isEmpty()) return null
|
||||
out[KEY_DETAIL_RAW] = detail.toString()
|
||||
|
||||
// 分组:每个 PackageCode 的账户数 + 包名
|
||||
val groups = list.groupBy { it.scalar("PackageCode").orEmpty() }
|
||||
.filterKeys { it.isNotBlank() }
|
||||
// 套餐候选:账户数最少且不是「裂变/奖励」字样的包;奖励池:账户数多或名字带裂变/奖励
|
||||
val planCode = groups.entries
|
||||
.filter { (_, v) -> v.size == 1 }
|
||||
.filter { (k, _) -> list.firstOrNull { it.scalar("PackageCode") == k }
|
||||
?.scalar("PackageName")?.let { n -> !n.contains("裂变") && !n.contains("奖励") } == true }
|
||||
.minByOrNull { it.key }?.key
|
||||
if (planCode != null) out[KEY_PLAN_CODE] = planCode
|
||||
return out
|
||||
}
|
||||
|
||||
/** 解析 Packages/Accounts:CycleRemain/Used/TotalCapacity(多候选)。无任何包/数据时抛异常。 */
|
||||
private fun parseResource(body: String): ParsedWorkBuddy {
|
||||
val root = json.parseToJsonElement(body).jsonObject
|
||||
val data = root["data"]?.jsonObject ?: root
|
||||
val packages = data["Packages"]?.jsonArray ?: data["Accounts"]?.jsonArray ?: root["Packages"]?.jsonArray
|
||||
val list = packages?.mapNotNull { it.jsonObject.takeIf { o -> o.isNotEmpty() } } ?: emptyList()
|
||||
if (list.isEmpty()) {
|
||||
throw IllegalArgumentException("workbuddy 响应无 Packages/Accounts 数据")
|
||||
}
|
||||
|
||||
var amount = 0.0
|
||||
var used = 0.0
|
||||
var size = 0.0
|
||||
var frozen = 0.0
|
||||
var unitRaw: String? = null
|
||||
val extras = linkedMapOf<String, String>()
|
||||
// 结构化包明细(名称/编码/已用/总量/剩余/到期/周期重置),详情页按官方语义分组展示。
|
||||
// 键名带 raw 以复用通用卡的「技术键过滤」,不被用量页/通用明细卡当普通键展示。
|
||||
val detail = buildJsonArray {
|
||||
for (item in list.take(20)) {
|
||||
val remain = item.number("CycleRemainCapacity", "CycleCapacityRemain", "Remain", "balance") ?: 0.0
|
||||
val u = item.number("CycleUsedCapacity", "CycleCapacityUsed", "Used") ?: 0.0
|
||||
val s = item.number("CycleTotalCapacity", "CycleCapacitySize", "Size", "Total") ?: 0.0
|
||||
val f = item.number("CycleFrozenCapacity", "Frozen") ?: 0.0
|
||||
amount += remain
|
||||
used += u
|
||||
size += s
|
||||
frozen += f
|
||||
unitRaw = unitRaw ?: item.scalar("CapacityUnit", "unit", "currency", "Unit")
|
||||
val name = item.scalar("ProductName", "PackageCode", "ResourceName", "name", "ResourceId")
|
||||
if (!name.isNullOrBlank() && extras.size < 3) {
|
||||
extras[name] = "${trimNum(remain)} ${normalizeUnit(unitRaw) ?: "Credits"}"
|
||||
}
|
||||
add(buildJsonObject {
|
||||
put("name", JsonPrimitive(name ?: ""))
|
||||
put("code", JsonPrimitive(item.scalar("PackageCode", "ResourceCode", "Code") ?: ""))
|
||||
put("remain", JsonPrimitive(trimNum(remain)))
|
||||
put("used", JsonPrimitive(trimNum(u)))
|
||||
put("total", JsonPrimitive(trimNum(s)))
|
||||
put(
|
||||
"expireAt",
|
||||
JsonPrimitive(
|
||||
item.timeField("ExpireTime", "ExpireAt", "ExpiredAt", "InvalidTime", "ExpireDate") ?: ""
|
||||
)
|
||||
)
|
||||
put(
|
||||
"cycleReset",
|
||||
JsonPrimitive(
|
||||
item.timeField("NextResetTime", "CycleResetTime", "NextCycleTime", "ResetTime", "ResetAt") ?: ""
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
if (list.size > 0) extras["packages"] = list.size.toString()
|
||||
if (frozen > 0) extras["frozen"] = trimNum(frozen)
|
||||
extras["wb_detail_raw"] = detail.toString()
|
||||
data["IsPaidUser"]?.jsonPrimitive?.contentOrNull?.let { extras["is_paid_user"] = it }
|
||||
data["SubscriptionPackageCode"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }
|
||||
?.let {
|
||||
extras["subscription"] = "true"
|
||||
// 订阅套餐的包编码:详情页据此把该包归入「套餐积分」区,其余归「平台奖励积分」
|
||||
extras["subscription_code"] = it
|
||||
}
|
||||
|
||||
return ParsedWorkBuddy(
|
||||
amount = amount,
|
||||
used = used,
|
||||
size = size,
|
||||
unit = normalizeUnit(unitRaw),
|
||||
extras = extras
|
||||
)
|
||||
}
|
||||
|
||||
/** 官方字段 credits → 展示单位 Credits;其它原样透传。 */
|
||||
private fun normalizeUnit(raw: String?): String? {
|
||||
if (raw.isNullOrBlank()) return null
|
||||
return if (raw.equals("credits", ignoreCase = true)) "Credits" else raw
|
||||
}
|
||||
|
||||
/**
|
||||
* 数值转简短显示串(去掉多余的 0 与小数点)。
|
||||
* 注意不能用 v.toString().trimEnd('0','.'):trimEnd 会连续剥离集合内任意字符,
|
||||
* "50.0" 会被剪成 "5"、"100.0" 剪成 "1"(余额/签到金额会显示错一位)。
|
||||
*/
|
||||
private fun trimNum(v: Double): String =
|
||||
java.math.BigDecimal.valueOf(v).stripTrailingZeros().toPlainString()
|
||||
|
||||
private fun JsonObject.number(vararg keys: String): Double? {
|
||||
for (key in keys) {
|
||||
this[key]?.jsonPrimitive?.doubleOrNull?.let { return it }
|
||||
this[key]?.jsonPrimitive?.contentOrNull?.toDoubleOrNull()?.let { return it }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun JsonObject.scalar(vararg keys: String): String? {
|
||||
for (key in keys) {
|
||||
this[key]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }?.let { return it }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间字段读取:兼容字符串日期(原样返回)与秒/毫秒时间戳(格式化为本地 yyyy-MM-dd HH:mm:ss)。
|
||||
* 找不到或解析失败返回 null(展示层隐藏该行)。
|
||||
*/
|
||||
private fun JsonObject.timeField(vararg keys: String): String? {
|
||||
for (key in keys) {
|
||||
val prim = this[key]?.jsonPrimitive ?: continue
|
||||
val text = prim.contentOrNull?.takeIf { it.isNotBlank() } ?: continue
|
||||
prim.doubleOrNull?.takeIf { it > 0 }?.let { epoch ->
|
||||
val millis = if (epoch > 1e12) epoch.toLong() else (epoch * 1000).toLong()
|
||||
return runCatching {
|
||||
java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.getDefault())
|
||||
.format(java.util.Date(millis))
|
||||
}.getOrNull()
|
||||
}
|
||||
// 纯字符串日期(如 "2026-10-01 16:32:41")直接返回
|
||||
return text
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ---------- 每日签到(Buddy 加油站 / codebuddy.cn)----------
|
||||
|
||||
/** 签到动作语义结果(UI 层据此本地化,不直接展示 Repository 中文串)。 */
|
||||
enum class CheckinOutcome {
|
||||
/** 查询模式:只读状态,无动作结果 */
|
||||
QUERY,
|
||||
/** 签到成功 */
|
||||
SUCCESS,
|
||||
/** 今日已签到(幂等) */
|
||||
ALREADY,
|
||||
/** 签到失败(服务端给了原因,见 message) */
|
||||
FAILED
|
||||
}
|
||||
|
||||
/**
|
||||
* 签到状态摘要(来源:cpa-multi-plugins workbuddy 插件实测字段)。
|
||||
* active=活动进行中;todayCheckedIn=今日已签;streakDays=连签;daily/todayCredit=每日/今日积分。
|
||||
*/
|
||||
data class CheckinStatus(
|
||||
val active: Boolean,
|
||||
val todayCheckedIn: Boolean,
|
||||
val streakDays: Long,
|
||||
val dailyCredit: Long,
|
||||
val todayCredit: Long,
|
||||
val activityName: String,
|
||||
/** 服务端原始提示文案(可能是中文/英文;仅作 [outcome] 之外的补充透传) */
|
||||
val message: String = "",
|
||||
/** 动作语义结果:UI 按此本地化,不依赖 [message] 语言 */
|
||||
val outcome: CheckinOutcome = CheckinOutcome.QUERY
|
||||
)
|
||||
|
||||
/** 查询今日签到状态。uid 未知时省略 X-User-Id 头(源码确认该头可选)。 */
|
||||
suspend fun fetchCheckinStatus(accountId: String? = null): Result<CheckinStatus> = withContext(Dispatchers.IO) {
|
||||
val credential = credentialRepository.get(ServiceType.WORKBUDDY, accountId)
|
||||
?: return@withContext Result.failure(RepositoryError.InvalidCredential())
|
||||
if (credential !is Credential.WorkBuddyCredential) {
|
||||
return@withContext Result.failure(RepositoryError.InvalidCredential())
|
||||
}
|
||||
// 两条候选路径 fallback(cpa-plugin 实测:activity-status 优先,旧路径 checkin-status 兜底)
|
||||
// 共享轮换状态:前一条路径轮换出的新 token 会用于下一条,且失败也先落库。
|
||||
val body = authedPostFirstSuccessful(
|
||||
credential,
|
||||
accountId,
|
||||
"/v2/billing/meter/checkin-activity-status",
|
||||
"/v2/billing/meter/checkin-status"
|
||||
) ?: return@withContext Result.failure(RepositoryError.Unknown())
|
||||
runCatching { parseCheckin(body) }.fold(
|
||||
onSuccess = { Result.success(it) },
|
||||
onFailure = {
|
||||
Result.failure(
|
||||
RepositoryError.ParseError(
|
||||
RepositoryError.ParseErrorReason.MALFORMED_RESPONSE,
|
||||
"workbuddy checkin status 解析失败: " + it.message
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行今日签到(幂等:code=0 成功 / code=10001 已签,两种都算成功;源码确认 HTTP 400 也可能是已签)。
|
||||
* 成功/已签后刷新一次状态并附带 message 提示。
|
||||
*/
|
||||
suspend fun checkIn(accountId: String? = null): Result<CheckinStatus> = withContext(Dispatchers.IO) {
|
||||
val credential = credentialRepository.get(ServiceType.WORKBUDDY, accountId)
|
||||
?: return@withContext Result.failure(RepositoryError.InvalidCredential())
|
||||
if (credential !is Credential.WorkBuddyCredential) {
|
||||
return@withContext Result.failure(RepositoryError.InvalidCredential())
|
||||
}
|
||||
val body = when (val r = authedPost(credential, "/v2/billing/meter/daily-checkin")) {
|
||||
is AuthedPostResult.Ok -> {
|
||||
persistCheckinOutcome(credential, accountId, r.rotatedCredential)
|
||||
r.body
|
||||
}
|
||||
is AuthedPostResult.Failed -> {
|
||||
// 即便签到请求失败,也必须先落库轮换出的新 token,否则账号卡在死 token 上;
|
||||
// 但未发生轮换(纯网络失败)时不写入——避免把失败请求误标为「刚验证通过」。
|
||||
if (r.rotatedCredential != null) {
|
||||
persistCheckinOutcome(credential, accountId, r.rotatedCredential)
|
||||
}
|
||||
// HTTP 400 也可能是业务失败且 body 携带签到语义(如已签),透传交给下方解析。
|
||||
if (r.body.isBlank()) {
|
||||
return@withContext Result.failure(RepositoryError.Unknown())
|
||||
}
|
||||
r.body
|
||||
}
|
||||
}
|
||||
// 签到响应为官方 envelope:{"code":0,"msg":"...","data":{...}},code/msg 在根层,
|
||||
// data 为可选明细。必须根层优先 + data 层兜底——否则 data 存在时根层 code 被漏读
|
||||
// 成 -1,导致「实际签到成功却显示失败」(2026-10 实测回归)。
|
||||
// 整体包 runCatching 兜底非法 JSON/"data":null 等边界,避免强转异常冒泡。
|
||||
val code = runCatching {
|
||||
val r = json.parseToJsonElement(body).jsonObject
|
||||
r.scalar("code", "Code")?.toLongOrNull()
|
||||
?: (r["data"] as? JsonObject)?.scalar("code", "Code")?.toLongOrNull()
|
||||
}.getOrNull() ?: -1L
|
||||
val msg = runCatching {
|
||||
val r = json.parseToJsonElement(body).jsonObject
|
||||
r.scalar("msg", "message", "Message")
|
||||
?: (r["data"] as? JsonObject)?.scalar("msg", "message", "Message")
|
||||
}.getOrNull().orEmpty()
|
||||
val status = fetchCheckinStatus(accountId).getOrNull()
|
||||
// 已签到语义与上游 cpa-multi-plugins 对齐:code=10001 或文案含「已签/今日/already」。
|
||||
val lowerMsg = msg.lowercase()
|
||||
val already = code == 10001L || msg.contains("已签") || msg.contains("已经签到") ||
|
||||
msg.contains("今日") || lowerMsg.contains("already") || lowerMsg.contains("checked") ||
|
||||
lowerMsg.contains("claimed")
|
||||
val statusInfo = if (status != null) " checked=${status.todayCheckedIn}/streak=${status.streakDays}" else ""
|
||||
DebugLog.i(
|
||||
TAG,
|
||||
"checkIn 判定: code=$code msg=$msg already=$already statusOk=${status != null}$statusInfo"
|
||||
)
|
||||
when {
|
||||
code == 0L -> Result.success(
|
||||
(status ?: CheckinStatus(true, true, 1, 0, 0, "")).copy(
|
||||
outcome = CheckinOutcome.SUCCESS
|
||||
)
|
||||
)
|
||||
already -> Result.success(
|
||||
(status ?: CheckinStatus(true, true, 0, 0, 0, "")).copy(
|
||||
outcome = CheckinOutcome.ALREADY
|
||||
)
|
||||
)
|
||||
else -> {
|
||||
if (status != null) {
|
||||
Result.success(status.copy(message = msg, outcome = CheckinOutcome.FAILED))
|
||||
} else {
|
||||
Result.failure(RepositoryError.Unknown())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [authedPost] 的结果。
|
||||
*
|
||||
* [rotatedCredential] 一旦非空,表示服务端已作废旧 accessToken(新的 access/refresh
|
||||
* 已轮换),调用方**必须**先落库再决定后续动作——即使本次请求最终失败,也不能丢弃它,
|
||||
* 否则旧 refreshToken 单次消费后失效,账号会卡在死 token 上(永久 401)。
|
||||
*/
|
||||
private sealed interface AuthedPostResult {
|
||||
val rotatedCredential: Credential.WorkBuddyCredential?
|
||||
|
||||
/** 请求成功(2xx),[body] 为响应体。 */
|
||||
data class Ok(
|
||||
val body: String,
|
||||
override val rotatedCredential: Credential.WorkBuddyCredential?
|
||||
) : AuthedPostResult
|
||||
|
||||
/** 请求失败,但过程中发生过 token 轮换(新凭据必须落库)。[body] 携带业务失败响应体
|
||||
* (如 HTTP 400 也返回签到语义),可能为空。 */
|
||||
data class Failed(
|
||||
override val rotatedCredential: Credential.WorkBuddyCredential?,
|
||||
val body: String = ""
|
||||
) : AuthedPostResult
|
||||
}
|
||||
|
||||
/**
|
||||
* 带自动 refresh 一次的 POST(Bearer)。
|
||||
* 失败时若已发生轮换,会以 [AuthedPostResult.Failed] 回传新凭据(不丢轮换结果)。
|
||||
*/
|
||||
private fun authedPost(credential: Credential.WorkBuddyCredential, path: String): AuthedPostResult {
|
||||
var access = credential.accessToken
|
||||
var refresh = credential.refreshToken
|
||||
var rotated: Credential.WorkBuddyCredential? = null
|
||||
repeat(2) { attempt ->
|
||||
val request = Request.Builder()
|
||||
.url("https://www.codebuddy.cn" + path)
|
||||
.addHeader("Authorization", "Bearer " + access)
|
||||
.addHeader("Accept", "application/json")
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.post("{}".toRequestBody(jsonMedia))
|
||||
.build()
|
||||
val resp = try {
|
||||
okHttpClient.newCall(request).execute()
|
||||
} catch (e: Throwable) {
|
||||
DebugLog.w(TAG, "authedPost 异常: path=$path err=${e::class.simpleName}: ${e.message}")
|
||||
return AuthedPostResult.Failed(rotated)
|
||||
}
|
||||
val code = resp.code
|
||||
val body = runCatching { resp.body?.string().orEmpty() }.getOrDefault("")
|
||||
resp.close()
|
||||
// 本地调试日志:记录请求路径/HTTP 码/响应摘要,不记录 Authorization(敏感)。
|
||||
DebugLog.i(TAG, "authedPost: path=$path attempt=$attempt http=$code body=${body.take(200)}")
|
||||
when {
|
||||
code in 200..299 -> return AuthedPostResult.Ok(body, rotated)
|
||||
(code == 401 || code == 403) && attempt == 0 -> {
|
||||
val refreshed = tryRefesh(refresh) ?: return AuthedPostResult.Failed(rotated)
|
||||
val expiresAt = if (refreshed.expiresIn > 0) {
|
||||
System.currentTimeMillis() + refreshed.expiresIn * 1000
|
||||
} else {
|
||||
credential.expiresAt.coerceAtLeast(System.currentTimeMillis())
|
||||
}
|
||||
rotated = credential.copy(
|
||||
accessToken = refreshed.accessToken,
|
||||
refreshToken = refreshed.refreshToken,
|
||||
expiresAt = expiresAt
|
||||
)
|
||||
access = refreshed.accessToken
|
||||
refresh = refreshed.refreshToken
|
||||
}
|
||||
else -> return AuthedPostResult.Failed(rotated, body)
|
||||
}
|
||||
}
|
||||
return AuthedPostResult.Failed(rotated)
|
||||
}
|
||||
|
||||
/**
|
||||
* 候选路径 fallback 专用:依次尝试各路径,但**同一次查询内共享轮换状态**——
|
||||
* 若前一条路径已轮换 token,后续路径必须用新 accessToken 请求,并把轮换结果
|
||||
* 落库后再返回,避免旧 token 重试直接失败。
|
||||
*/
|
||||
private suspend fun authedPostFirstSuccessful(
|
||||
credential: Credential.WorkBuddyCredential,
|
||||
accountId: String?,
|
||||
vararg paths: String
|
||||
): String? {
|
||||
var current = credential
|
||||
for (path in paths) {
|
||||
when (val r = authedPost(current, path)) {
|
||||
is AuthedPostResult.Ok -> {
|
||||
persistCheckinOutcome(credential, accountId, r.rotatedCredential)
|
||||
return r.body
|
||||
}
|
||||
is AuthedPostResult.Failed -> {
|
||||
// 即便本路径失败,也要先把轮换出的新 token 落库并继续用新 token 试下一条
|
||||
if (r.rotatedCredential != null) {
|
||||
persistCheckinOutcome(credential, accountId, r.rotatedCredential)
|
||||
current = r.rotatedCredential
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 落库本次签到/状态查询产出:轮换后的 token(若有)+ lastVerifiedAt。
|
||||
* 与 [fetchBalance] 同款语义——一次成功请求即视为凭据已验证。
|
||||
*/
|
||||
private suspend fun persistCheckinOutcome(
|
||||
credential: Credential.WorkBuddyCredential,
|
||||
accountId: String?,
|
||||
rotated: Credential.WorkBuddyCredential?
|
||||
) {
|
||||
val base = rotated ?: credential
|
||||
credentialRepository.save(base.copy(lastVerifiedAt = System.currentTimeMillis()), accountId)
|
||||
}
|
||||
|
||||
private fun mapHttpError(code: Int, body: String): RepositoryError = when (code) {
|
||||
401, 403 -> RepositoryError.InvalidCredential()
|
||||
429 -> RepositoryError.RateLimited()
|
||||
423 -> RepositoryError.ServerError(code)
|
||||
in 500..599 -> RepositoryError.ServerError(code)
|
||||
else -> RepositoryError.Unknown()
|
||||
}
|
||||
|
||||
private data class RefreshedWorkBuddy(
|
||||
val accessToken: String,
|
||||
val refreshToken: String,
|
||||
val expiresIn: Long
|
||||
)
|
||||
|
||||
private data class ParsedWorkBuddy(
|
||||
val amount: Double,
|
||||
val used: Double,
|
||||
val size: Double,
|
||||
val unit: String?,
|
||||
val extras: Map<String, String>
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val TAG = "WorkBuddy"
|
||||
|
||||
/** extras 键:细粒度账户明细 JSON(结构见 [parseResourceDetail]),详情页消费。 */
|
||||
const val KEY_DETAIL_RAW = "wb_detail_raw"
|
||||
|
||||
/** extras 键:套餐积分的 PackageCode(详情页据此区分套餐 vs 奖励积分)。 */
|
||||
const val KEY_PLAN_CODE = "wb_plan_code"
|
||||
|
||||
/** 解析专用 Json(companion 内独立持有,使 parseCheckin 可静态调用/单测)。 */
|
||||
private val parseJson = Json { ignoreUnknownKeys = true }
|
||||
|
||||
/**
|
||||
* 解析 checkin 响应(data 平铺或 data 包裹,防御式多候选)。
|
||||
* 放在 companion 内以便单测直接调用(不依赖 OkHttp/凭据)。
|
||||
*/
|
||||
internal fun parseCheckin(body: String): CheckinStatus {
|
||||
val root = parseJson.parseToJsonElement(body).jsonObject
|
||||
val obj = root["data"]?.jsonObject ?: root
|
||||
return CheckinStatus(
|
||||
active = obj.scalar("active", "Active")?.toBoolean() ?: true,
|
||||
todayCheckedIn = obj.scalar("today_checked_in", "todayCheckedIn")?.toBoolean() ?: false,
|
||||
streakDays = obj.longOr("streak_days", "streakDays") ?: 0,
|
||||
dailyCredit = obj.longOr("daily_credit", "dailyCredit") ?: 0,
|
||||
todayCredit = obj.longOr("today_credit", "todayCredit") ?: 0,
|
||||
activityName = obj.scalar("activity_name", "activityName", "theme_name") ?: ""
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonObject.scalar(vararg keys: String): String? {
|
||||
for (key in keys) {
|
||||
this[key]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }?.let { return it }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun JsonObject.longOr(vararg keys: String): Long? {
|
||||
for (key in keys) {
|
||||
this[key]?.jsonPrimitive?.contentOrNull?.toLongOrNull()?.let { return it }
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -6,11 +6,21 @@ import com.rainy.token.data.cache.balanceCacheDataStore
|
||||
import com.rainy.token.data.remote.DeepSeekApi
|
||||
import com.rainy.token.data.local.SecureStorage
|
||||
import com.rainy.token.data.local.UsageCache
|
||||
import com.rainy.token.data.local.UsageDao
|
||||
import com.rainy.token.data.local.UsageDatabase
|
||||
import com.rainy.token.data.local.usageCacheDataStore
|
||||
import com.rainy.token.data.repository.CredentialRepository
|
||||
import com.rainy.token.data.repository.DeepSeekRepository
|
||||
import com.rainy.token.data.repository.CodexRepository
|
||||
import com.rainy.token.data.repository.CommandCodeGoRepository
|
||||
import com.rainy.token.data.repository.CommandCodeUsageRepository
|
||||
import com.rainy.token.data.repository.OpenCodeGoRepository
|
||||
import com.rainy.token.data.repository.OpenCodeUsageRepository
|
||||
import com.rainy.token.data.repository.OllamaRepository
|
||||
import com.rainy.token.data.repository.Sub2ApiRepository
|
||||
import com.rainy.token.data.repository.TraeRepository
|
||||
import com.rainy.token.data.repository.UpdateRepository
|
||||
import com.rainy.token.data.repository.WorkBuddyRepository
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
@ -74,12 +84,26 @@ object NetworkModule {
|
||||
|
||||
// ---- Room ----
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideUsageDatabase(
|
||||
@ApplicationContext context: Context
|
||||
): UsageDatabase = UsageDatabase.getInstance(context)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideUsageDao(
|
||||
database: UsageDatabase
|
||||
): UsageDao = database.usageDao()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideUsageCache(
|
||||
@ApplicationContext context: Context,
|
||||
dao: UsageDao,
|
||||
@Named(DataStoreQualifiers.USAGE_CACHE) dataStore: DataStore<Preferences>,
|
||||
json: Json
|
||||
): UsageCache = UsageCache(dataStore, json)
|
||||
): UsageCache = UsageCache(context, dao, dataStore, json)
|
||||
|
||||
// ---- Repositories ----
|
||||
|
||||
@ -101,6 +125,27 @@ object NetworkModule {
|
||||
balanceCache: BalanceCache
|
||||
): OpenCodeGoRepository = OpenCodeGoRepository(okHttpClient, credentialRepository, balanceCache)
|
||||
|
||||
/**
|
||||
* CommandCode 用量仓库。
|
||||
*/
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideCommandCodeUsageRepository(
|
||||
okHttpClient: OkHttpClient,
|
||||
credentialRepository: CredentialRepository
|
||||
): CommandCodeUsageRepository = CommandCodeUsageRepository(okHttpClient, credentialRepository)
|
||||
|
||||
/**
|
||||
* CommandCode 仓库:API Key 认证,调 JSON API。
|
||||
*/
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideCommandCodeGoRepository(
|
||||
okHttpClient: OkHttpClient,
|
||||
credentialRepository: CredentialRepository,
|
||||
balanceCache: BalanceCache
|
||||
): CommandCodeGoRepository = CommandCodeGoRepository(okHttpClient, credentialRepository, balanceCache)
|
||||
|
||||
/**
|
||||
* DeepSeek 仓库:同上。注意即使 [DeepSeekRepository] 自己有 @Inject constructor,
|
||||
* 在 3+ Repository 同模式时 KSP 2.x 仍会误报,这里统一改 @Provides 杜绝隐患。
|
||||
@ -113,6 +158,71 @@ object NetworkModule {
|
||||
balanceCache: BalanceCache
|
||||
): DeepSeekRepository = DeepSeekRepository(deepSeekApi, credentialRepository, balanceCache)
|
||||
|
||||
/**
|
||||
* Codex / ChatGPT 额度仓库:用 OkHttp 调 chatgpt.com 后台 API。
|
||||
*/
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideCodexRepository(
|
||||
okHttpClient: OkHttpClient,
|
||||
credentialRepository: CredentialRepository,
|
||||
balanceCache: BalanceCache
|
||||
): CodexRepository = CodexRepository(okHttpClient, credentialRepository, balanceCache)
|
||||
|
||||
/**
|
||||
* Ollama Cloud 用量仓库:Cookie 认证 + OkHttp 抓 settings HTML 解析。
|
||||
*/
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOllamaRepository(
|
||||
okHttpClient: OkHttpClient,
|
||||
credentialRepository: CredentialRepository,
|
||||
balanceCache: BalanceCache
|
||||
): OllamaRepository = OllamaRepository(okHttpClient, credentialRepository, balanceCache)
|
||||
|
||||
/**
|
||||
* Trae 仓库:Cloud-IDE-JWT 认证 + REST。
|
||||
*/
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideTraeRepository(
|
||||
okHttpClient: OkHttpClient,
|
||||
credentialRepository: CredentialRepository,
|
||||
balanceCache: BalanceCache
|
||||
): TraeRepository = TraeRepository(okHttpClient, credentialRepository, balanceCache)
|
||||
|
||||
/**
|
||||
* WorkBuddy 仓库:OAuth Bearer + get-user-resource。
|
||||
*/
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideWorkBuddyRepository(
|
||||
okHttpClient: OkHttpClient,
|
||||
credentialRepository: CredentialRepository,
|
||||
balanceCache: BalanceCache
|
||||
): WorkBuddyRepository = WorkBuddyRepository(okHttpClient, credentialRepository, balanceCache)
|
||||
|
||||
/**
|
||||
* Sub2API 仓库:自托管实例(凭据里的 baseUrl)+ 管理面板 API。
|
||||
*/
|
||||
/**
|
||||
* 更新仓库:读取 Gitea 上的 update.json 并下载 APK。
|
||||
*/
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideUpdateRepository(
|
||||
okHttpClient: OkHttpClient,
|
||||
@ApplicationContext context: Context
|
||||
): UpdateRepository = UpdateRepository(okHttpClient, context)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideSub2ApiRepository(
|
||||
okHttpClient: OkHttpClient,
|
||||
credentialRepository: CredentialRepository,
|
||||
balanceCache: BalanceCache
|
||||
): Sub2ApiRepository = Sub2ApiRepository(okHttpClient, credentialRepository, balanceCache)
|
||||
|
||||
/** 余额缓存 DataStore(计划 7.1) */
|
||||
@Provides
|
||||
@Singleton
|
||||
|
||||
@ -3,6 +3,8 @@ package com.rainy.token.di
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import com.rainy.token.data.local.ChartSettingsStore
|
||||
import com.rainy.token.data.local.chartSettingsStore
|
||||
import com.rainy.token.data.local.secureStorageDataStore
|
||||
import com.rainy.token.data.local.usageCacheDataStore
|
||||
import dagger.Module
|
||||
@ -48,4 +50,10 @@ object StorageModule {
|
||||
fun provideUsageCacheDataStore(
|
||||
@ApplicationContext context: Context
|
||||
): DataStore<Preferences> = context.usageCacheDataStore
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideChartSettingsStore(
|
||||
@ApplicationContext context: Context
|
||||
): ChartSettingsStore = context.chartSettingsStore
|
||||
}
|
||||
@ -8,6 +8,7 @@ import kotlinx.serialization.Serializable
|
||||
*
|
||||
* - [ApiKeyCredential] 用于 DeepSeek(API Key 形式)
|
||||
* - [SessionCredential] 用于 OpenCode Zen/Go(用户登录 dashboard 后粘贴的值)
|
||||
* - [CodexCredential] 用于 Codex / ChatGPT(完整 OAuth 凭据,含自动刷新)
|
||||
*/
|
||||
@Serializable
|
||||
sealed class Credential {
|
||||
@ -40,6 +41,102 @@ sealed class Credential {
|
||||
val authCookie: String? = null,
|
||||
/** OpenCode Go 专用:workspace ID */
|
||||
val workspaceId: String? = null,
|
||||
/** Ollama 专用:完整的 Cookie 字符串(用户从浏览器 DevTools 复制) */
|
||||
val ollamaCookie: String? = null,
|
||||
/** OpenCode Go / Ollama 专用:API Key,用于一键激活用量 */
|
||||
val apiKey: String? = null,
|
||||
override val lastVerifiedAt: Long = 0L
|
||||
) : Credential()
|
||||
|
||||
/**
|
||||
* 用于 Codex / ChatGPT:完整 OAuth 凭据,支持自动刷新。
|
||||
* 用户从 auth.json 粘贴整个 tokens 对象。
|
||||
*/
|
||||
@Serializable
|
||||
data class CodexCredential(
|
||||
override val service: ServiceType,
|
||||
val accessToken: String,
|
||||
val refreshToken: String,
|
||||
val accountId: String,
|
||||
/** 过期时间(epoch millis) */
|
||||
val expiresAt: Long,
|
||||
override val lastVerifiedAt: Long = 0L
|
||||
) : Credential()
|
||||
|
||||
/**
|
||||
* 用于 Trae(CN/Intl):官方 IDE 扩展使用的 Cloud-IDE-JWT。
|
||||
* 单个 JWT(~14 天 RS256),配 [region] 决定域名与请求头。
|
||||
*
|
||||
* [deviceId] 为本 App 持久化生成的每机唯一设备 ID(与登录授权 URL 中
|
||||
* device_id 一致,即 OAuth 会话绑定的设备)。Trae claim 签到接口按 x-device-id
|
||||
* 做设备级每日去重/风控,必须用与登录会话一致的设备 ID;不可用 userJwt.ClientID
|
||||
* (那是固定客户端标识,所有用户相同,会被服务端按设备风控拒绝)。
|
||||
* [userId] 为 OAuth 用户 ID(回调 userInfo),签到按 trae-check 口径拼
|
||||
* `${deviceId}-${userId}`。旧凭据无这些字段时退回由 JWT 派生的稳定伪 ID。
|
||||
*/
|
||||
@Serializable
|
||||
data class TraeCredential(
|
||||
override val service: ServiceType,
|
||||
/** `Authorization: Cloud-IDE-JWT <jwt>` 的 JWT 值 */
|
||||
val jwt: String,
|
||||
/** 区域:"CN"(api.trae.cn)/ "INTL"(grow-normal.trae.ai) */
|
||||
val region: String = "CN",
|
||||
/** 登录会话绑定的设备 ID(App 持久化每机唯一),null=旧凭据未保存 */
|
||||
val deviceId: String? = null,
|
||||
/** OAuth 用户 ID(回调 userInfo.userId),null=旧凭据未保存 */
|
||||
val userId: String? = null,
|
||||
/** 最近一次成功签到日期(yyyy-MM-dd,本地时区);当天命中则本地短路,不重复发起签到请求 */
|
||||
val lastCheckinDate: String? = null,
|
||||
/**
|
||||
* 用户在设置页手动填写的签到设备 ID(官方客户端绑定的 did)。
|
||||
* 服务端按设备风控签到(9074 根因),官方客户端真实 did 才能通过;
|
||||
* null/空白 = 使用内置默认(ACCOUNT_BOUND_DEVICE_ID)。
|
||||
* 获取路径见设置页提示(客户端日志 main.log 搜索 did=)。
|
||||
*/
|
||||
val checkinDeviceId: String? = null,
|
||||
override val lastVerifiedAt: Long = 0L
|
||||
) : Credential()
|
||||
|
||||
/**
|
||||
* 用于腾讯 WorkBuddy(AI 办公 Agent,积分与 CodeBuddy 同体系)。
|
||||
* 复用 Codex 的 OAuth Bearer 模式:accessToken + refreshToken(单次轮换)。
|
||||
*/
|
||||
@Serializable
|
||||
data class WorkBuddyCredential(
|
||||
override val service: ServiceType,
|
||||
val accessToken: String,
|
||||
val refreshToken: String,
|
||||
/** 过期时间(epoch millis),0=未知 */
|
||||
val expiresAt: Long = 0L,
|
||||
override val lastVerifiedAt: Long = 0L
|
||||
) : Credential()
|
||||
|
||||
/**
|
||||
* 用于 Sub2API(自建 mid 层/聚合面板,任意用户填自己的实例地址)。
|
||||
* 走【用户级】查询:普通用户填实例分配给他的 sk- API Key 即可查余额,无需管理员。
|
||||
* 实测(xxcsn.site):GET {base}/v1/usage + Authorization: Bearer <apiKey>
|
||||
* 返回 remaining/balance/unit/usage.total.cost/model_stats 等。
|
||||
* 无 API Key 时可用面板邮箱+密码登录换取 Bearer token 作为备选。
|
||||
*/
|
||||
@Serializable
|
||||
data class Sub2ApiCredential(
|
||||
override val service: ServiceType,
|
||||
/** 自托管实例基址,如 https://api.example.com(自动去尾斜杠) */
|
||||
val baseUrl: String,
|
||||
/** 面板登录邮箱(无 API Key 的备选认证) */
|
||||
val email: String,
|
||||
/** 面板登录密码(无 API Key 的备选认证) */
|
||||
val password: String,
|
||||
/** 用户级 API Key(sk-…),存在时优先于 email/password 认证 */
|
||||
val apiKey: String? = null,
|
||||
/**
|
||||
* 面板登录 Token(App 内 WebView 登录后自动从 localStorage.auth_token 提取)。
|
||||
* 存在时走【面板模式】:全账号 usage/stats + dashboard/models + snapshot-v2
|
||||
* (数据与网页「使用记录」页一致,actual_cost 口径)。
|
||||
*/
|
||||
val authToken: String? = null,
|
||||
/** 站点 Logo URL(登录/探测时抓取;主页卡片显示,替换内置图标)。 */
|
||||
val siteIcon: String? = null,
|
||||
override val lastVerifiedAt: Long = 0L
|
||||
) : Credential()
|
||||
}
|
||||
|
||||
@ -0,0 +1,32 @@
|
||||
package com.rainy.token.domain.model
|
||||
|
||||
import com.rainy.token.domain.service.ServiceType
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* 单个服务的账号元信息(多账号服务专用,如 Trae / WorkBuddy)。
|
||||
*
|
||||
* 凭据本身按 [id] 独立加密存储,这里只保存展示与排序所需的元数据:
|
||||
* - [id]:稳定账号标识(迁移出的首个账号固定为 "default";新建账号为随机 ID)
|
||||
* - [label]:用户可见名称;null/空白时 UI 按“账号 N”兜底显示
|
||||
* - [isDefault]:是否为默认账号(小组件 / 每日自动签到仅作用于默认账号)
|
||||
*/
|
||||
@Serializable
|
||||
data class ServiceAccountInfo(
|
||||
val id: String,
|
||||
val label: String? = null,
|
||||
val isDefault: Boolean = false,
|
||||
val createdAt: Long = 0L
|
||||
)
|
||||
|
||||
/**
|
||||
* 单个服务的账号元数据集合,经 [com.rainy.token.data.local.SecureStorage] 加密持久化,
|
||||
* 存储键为 `accounts_{service.storageKey}`。
|
||||
*
|
||||
* 单账号服务不使用本结构,仍沿用旧的单凭据存储键。
|
||||
*/
|
||||
@Serializable
|
||||
data class AccountStore(
|
||||
val service: ServiceType,
|
||||
val accounts: List<ServiceAccountInfo> = emptyList()
|
||||
)
|
||||
@ -22,5 +22,7 @@ data class ServiceBalance(
|
||||
/** 配额周期下次重置时间(订阅型服务,epoch millis) */
|
||||
val nextResetAt: Long? = null,
|
||||
/** 服务特定的附加信息(如 Zen 充值 $20 起,Go 5h 配额) */
|
||||
val extras: Map<String, String> = emptyMap()
|
||||
val extras: Map<String, String> = emptyMap(),
|
||||
/** Sub2API 专有:网关 /v1/usage 返回的结构化用量明细(模型统计 + 每日明细)。其他服务为 null。 */
|
||||
val sub2UsageDetail: Sub2UsageDetail? = null
|
||||
)
|
||||
@ -0,0 +1,39 @@
|
||||
package com.rainy.token.domain.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Sub2API 网关 /v1/usage 返回的结构化用量明细。
|
||||
*
|
||||
* 不同网关版本字段名/是否返回都不同,因此全部字段防御式解析:
|
||||
* - [modelStats]:按模型聚合的统计(原来只取前 3 个塞进 extras 字符串,现全量结构化)
|
||||
* - [dailyUsage]:按日聚合的明细(date 形如 "2026-09-04",时区由请求的 timezone 参数决定)
|
||||
*/
|
||||
@Serializable
|
||||
data class Sub2UsageDetail(
|
||||
val modelStats: List<Sub2ModelStat> = emptyList(),
|
||||
val dailyUsage: List<Sub2DailyUsage> = emptyList()
|
||||
) {
|
||||
val isEmpty: Boolean get() = modelStats.isEmpty() && dailyUsage.isEmpty()
|
||||
val isNotEmpty: Boolean get() = !isEmpty
|
||||
}
|
||||
|
||||
/** 按模型聚合的用量统计。字段名来自官方前端 /v1/usage model_stats[]。 */
|
||||
@Serializable
|
||||
data class Sub2ModelStat(
|
||||
val model: String,
|
||||
val requests: Long = 0,
|
||||
val inputTokens: Long = 0,
|
||||
val outputTokens: Long = 0,
|
||||
val totalTokens: Long = 0,
|
||||
val cost: Double = 0.0
|
||||
)
|
||||
|
||||
/** 按日聚合的用量明细。 */
|
||||
@Serializable
|
||||
data class Sub2DailyUsage(
|
||||
val date: String,
|
||||
val requests: Long = 0,
|
||||
val totalTokens: Long = 0,
|
||||
val cost: Double = 0.0
|
||||
)
|
||||
@ -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,9 @@ enum class FetchMethod {
|
||||
/** WebView 登录后抓取 HTML/内部 JSON API */
|
||||
WEBVIEW_SCRAPER,
|
||||
|
||||
/** 用户自托管实例的 REST API(地址在凭据里,如 Sub2API) */
|
||||
SELF_HOSTED_REST,
|
||||
|
||||
/** 完全手动输入(已降级) */
|
||||
MANUAL
|
||||
}
|
||||
@ -24,6 +27,8 @@ data class ServiceConfig(
|
||||
val loginUrl: String,
|
||||
/** 余额/配额的展示单位(如 "¥" / "$" / "Credits" / "requests") */
|
||||
val displayUnit: String,
|
||||
/** 外部操作页 URL(如 DeepSeek 充值页),App 内 WebView 打开,可空 */
|
||||
val externalUrl: String? = null,
|
||||
/** 触发降级的连续失败次数阈值(计划 5.4 / 6.3) */
|
||||
val degradationFailureThreshold: Int = 3,
|
||||
/** Cookie 有效期 < 该值则触发降级(小时) */
|
||||
@ -40,7 +45,9 @@ object ServiceConfigProvider {
|
||||
type = ServiceType.DEEPSEEK,
|
||||
method = FetchMethod.REST_API,
|
||||
loginUrl = "", // REST 模式不需要登录 URL
|
||||
displayUnit = "¥"
|
||||
displayUnit = "¥",
|
||||
// App 内 WebView 打开的官方充值页
|
||||
externalUrl = "https://platform.deepseek.com/top_up"
|
||||
),
|
||||
ServiceType.OPENCODE_GO to ServiceConfig(
|
||||
type = ServiceType.OPENCODE_GO,
|
||||
@ -48,6 +55,47 @@ object ServiceConfigProvider {
|
||||
method = FetchMethod.WEBVIEW_SCRAPER,
|
||||
loginUrl = "https://opencode.ai/auth",
|
||||
displayUnit = "%"
|
||||
),
|
||||
ServiceType.COMMANDCODE_GO to ServiceConfig(
|
||||
type = ServiceType.COMMANDCODE_GO,
|
||||
method = FetchMethod.REST_API,
|
||||
loginUrl = "", // API Key 模式不需要登录 URL
|
||||
displayUnit = "$"
|
||||
),
|
||||
ServiceType.CODEX to ServiceConfig(
|
||||
type = ServiceType.CODEX,
|
||||
method = FetchMethod.REST_API,
|
||||
loginUrl = "",
|
||||
displayUnit = "%"
|
||||
),
|
||||
ServiceType.OLLAMA to ServiceConfig(
|
||||
type = ServiceType.OLLAMA,
|
||||
method = FetchMethod.WEBVIEW_SCRAPER,
|
||||
loginUrl = "https://ollama.com/settings",
|
||||
displayUnit = "%"
|
||||
),
|
||||
ServiceType.TRAE to ServiceConfig(
|
||||
type = ServiceType.TRAE,
|
||||
method = FetchMethod.REST_API,
|
||||
loginUrl = "",
|
||||
displayUnit = "Credits",
|
||||
// 官网定价页(docs.trae.cn 订阅管理:购买会员套餐/增购积分入口)
|
||||
externalUrl = "https://www.trae.cn/pricing"
|
||||
),
|
||||
ServiceType.WORKBUDDY to ServiceConfig(
|
||||
type = ServiceType.WORKBUDDY,
|
||||
method = FetchMethod.REST_API,
|
||||
loginUrl = "",
|
||||
displayUnit = "Credits",
|
||||
// 官网订阅定价页
|
||||
externalUrl = "https://www.workbuddy.cn/pricing/"
|
||||
),
|
||||
ServiceType.SUB2API to ServiceConfig(
|
||||
type = ServiceType.SUB2API,
|
||||
// 地址在用户填写的凭据里,按自托管实例调用
|
||||
method = FetchMethod.SELF_HOSTED_REST,
|
||||
loginUrl = "",
|
||||
displayUnit = "$"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@ -15,10 +15,27 @@ enum class ServiceType(
|
||||
val storageKey: String
|
||||
) {
|
||||
OPENCODE_GO("OpenCode Go", "opencode_go"),
|
||||
DEEPSEEK("DeepSeek", "deepseek");
|
||||
COMMANDCODE_GO("CommandCode", "commandcode_go"),
|
||||
DEEPSEEK("DeepSeek", "deepseek"),
|
||||
CODEX("Codex / ChatGPT", "codex"),
|
||||
OLLAMA("Ollama", "ollama"),
|
||||
TRAE("Trae", "trae"),
|
||||
WORKBUDDY("WorkBuddy", "workbuddy"),
|
||||
SUB2API("Sub2API", "sub2api");
|
||||
|
||||
companion object {
|
||||
fun fromStorageKey(key: String): ServiceType? =
|
||||
entries.firstOrNull { it.storageKey == key }
|
||||
|
||||
/** 支持多账号的服务(Trae / WorkBuddy / Sub2API 已启用,见需求范围)。 */
|
||||
val MULTI_ACCOUNT_SERVICES: Set<ServiceType> = setOf(
|
||||
ServiceType.TRAE,
|
||||
ServiceType.WORKBUDDY,
|
||||
ServiceType.SUB2API
|
||||
)
|
||||
|
||||
/** 该服务是否启用多账号。 */
|
||||
fun isMultiAccount(service: ServiceType): Boolean =
|
||||
service in MULTI_ACCOUNT_SERVICES
|
||||
}
|
||||
}
|
||||
@ -1,27 +1,225 @@
|
||||
package com.rainy.token.domain.usecase
|
||||
|
||||
import com.rainy.token.data.repository.CodexRepository
|
||||
import com.rainy.token.data.repository.CommandCodeGoRepository
|
||||
import com.rainy.token.data.repository.CredentialRepository
|
||||
import com.rainy.token.data.repository.CheckinStatusStore
|
||||
import com.rainy.token.data.repository.DeepSeekRepository
|
||||
import com.rainy.token.data.repository.OllamaRepository
|
||||
import com.rainy.token.data.repository.OpenCodeGoRepository
|
||||
import com.rainy.token.data.repository.RefreshWriteSession
|
||||
import com.rainy.token.data.repository.RepositoryError
|
||||
import com.rainy.token.data.repository.Sub2ApiRepository
|
||||
import com.rainy.token.data.repository.TraeRepository
|
||||
import com.rainy.token.data.repository.WorkBuddyRepository
|
||||
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
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* 唯一的 UseCase。计划架构补充说明:
|
||||
* - 内部按 ServiceType 分发到对应 Repository
|
||||
* - 现阶段实现:DeepSeek(REST API) + OpenCode Go(OkHttp 抓 dashboard)
|
||||
* - **使用 Provider 注入**——规避 KSP 2.x 在多个 @Inject constructor Repository
|
||||
* 注入同一 UseCase 时的"could not be resolved"误报(KSP 已知 issue)。
|
||||
* Provider 让 Hilt 推迟创建 Repository 实例到第一次 .get() 时,KSP 不需要在
|
||||
* 编译期解析所有构造器签名。
|
||||
* 唯一的余额刷新 UseCase。
|
||||
*
|
||||
* Repository 中的凭据/缓存写入先暂存;请求结束后校验起始凭据快照仍为当前版本才提交,
|
||||
* 避免旧请求覆盖用户刚保存或删除的新凭据。
|
||||
*/
|
||||
class RefreshBalanceUseCase @Inject constructor(
|
||||
private val credentialRepository: CredentialRepository,
|
||||
private val checkinStatusStore: com.rainy.token.data.repository.CheckinStatusStore,
|
||||
private val deepSeekRepositoryProvider: Provider<DeepSeekRepository>,
|
||||
private val openCodeGoRepositoryProvider: Provider<OpenCodeGoRepository>
|
||||
private val openCodeGoRepositoryProvider: Provider<OpenCodeGoRepository>,
|
||||
private val commandCodeGoRepositoryProvider: Provider<CommandCodeGoRepository>,
|
||||
private val codexRepositoryProvider: Provider<CodexRepository>,
|
||||
private val ollamaRepositoryProvider: Provider<OllamaRepository>,
|
||||
private val traeRepositoryProvider: Provider<TraeRepository>,
|
||||
private val workBuddyRepositoryProvider: Provider<WorkBuddyRepository>,
|
||||
private val sub2ApiRepositoryProvider: Provider<Sub2ApiRepository>
|
||||
) {
|
||||
suspend operator fun invoke(service: ServiceType): Result<ServiceBalance> = when (service) {
|
||||
ServiceType.DEEPSEEK -> deepSeekRepositoryProvider.get().fetchBalance()
|
||||
ServiceType.OPENCODE_GO -> openCodeGoRepositoryProvider.get().fetchBalance()
|
||||
suspend operator fun invoke(
|
||||
service: ServiceType,
|
||||
accountId: String? = null
|
||||
): Result<ServiceBalance> =
|
||||
withCredentialSession(service, accountId) {
|
||||
when (service) {
|
||||
ServiceType.DEEPSEEK -> retryOnTransientError {
|
||||
deepSeekRepositoryProvider.get().fetchBalance()
|
||||
}
|
||||
ServiceType.OPENCODE_GO -> retryOnTransientError {
|
||||
openCodeGoRepositoryProvider.get().fetchBalance()
|
||||
}
|
||||
ServiceType.COMMANDCODE_GO -> retryOnTransientError {
|
||||
commandCodeGoRepositoryProvider.get().fetchBalance()
|
||||
}
|
||||
ServiceType.CODEX -> retryOnTransientError {
|
||||
codexRepositoryProvider.get().fetchBalance()
|
||||
}
|
||||
ServiceType.OLLAMA -> retryOnTransientError {
|
||||
ollamaRepositoryProvider.get().fetchBalance()
|
||||
}
|
||||
ServiceType.TRAE -> retryOnTransientError {
|
||||
traeRepositoryProvider.get().fetchBalance(accountId)
|
||||
}
|
||||
ServiceType.WORKBUDDY -> retryOnTransientError {
|
||||
workBuddyRepositoryProvider.get().fetchBalance(accountId)
|
||||
}
|
||||
ServiceType.SUB2API -> retryOnTransientError {
|
||||
sub2ApiRepositoryProvider.get().fetchBalance(accountId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchCodexModels(): Result<List<String>> =
|
||||
codexRepositoryProvider.get().fetchModels()
|
||||
|
||||
suspend fun triggerCodexUsage(model: String): Result<TriggerSummary> =
|
||||
withCredentialSession(ServiceType.CODEX) {
|
||||
codexRepositoryProvider.get().triggerUsage(model)
|
||||
}
|
||||
|
||||
suspend fun fetchOpenCodeGoModels(): Result<List<String>> =
|
||||
openCodeGoRepositoryProvider.get().fetchModels()
|
||||
|
||||
suspend fun triggerOpenCodeGoUsage(model: String): Result<TriggerSummary> =
|
||||
withCredentialSession(ServiceType.OPENCODE_GO) {
|
||||
openCodeGoRepositoryProvider.get().triggerUsage(model)
|
||||
}
|
||||
|
||||
suspend fun fetchOllamaModels(): Result<List<String>> =
|
||||
ollamaRepositoryProvider.get().fetchModels()
|
||||
|
||||
suspend fun triggerOllamaUsage(model: String): Result<TriggerSummary> =
|
||||
withCredentialSession(ServiceType.OLLAMA) {
|
||||
ollamaRepositoryProvider.get().triggerUsage(model)
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行每日签到(TRAE / WORKBUDDY),返回各自的语义结果类型。
|
||||
*
|
||||
* 必须走 [withCredentialSession]:签到会触发 OAuth 轮换(WorkBuddy 401/403 时刷新
|
||||
* access/refresh),轮换结果需按凭据快照校验后提交,避免并发改凭据被旧快照覆盖;
|
||||
* 同时会话内重试才能读到已轮换的新 token(否则旧 refreshToken 单次消费后直接失效)。
|
||||
*/
|
||||
suspend fun checkInWorkBuddy(accountId: String? = null): Result<WorkBuddyRepository.CheckinStatus> =
|
||||
withCredentialSession(ServiceType.WORKBUDDY, accountId) {
|
||||
workBuddyRepositoryProvider.get().checkIn(accountId).also { result ->
|
||||
result.getOrNull()?.let { s ->
|
||||
val isChecked = s.outcome == WorkBuddyRepository.CheckinOutcome.SUCCESS ||
|
||||
s.outcome == WorkBuddyRepository.CheckinOutcome.ALREADY ||
|
||||
s.outcome == WorkBuddyRepository.CheckinOutcome.QUERY
|
||||
if (isChecked) {
|
||||
checkinStatusStore.record(
|
||||
service = ServiceType.WORKBUDDY,
|
||||
accountId = accountId,
|
||||
status = if (s.outcome == WorkBuddyRepository.CheckinOutcome.QUERY) {
|
||||
CheckinStatusStore.STATUS_ALREADY
|
||||
} else {
|
||||
s.outcome.name
|
||||
},
|
||||
checkinId = if (s.outcome == WorkBuddyRepository.CheckinOutcome.SUCCESS) {
|
||||
java.util.UUID.randomUUID().toString()
|
||||
} else {
|
||||
null
|
||||
},
|
||||
credits = if (s.outcome == WorkBuddyRepository.CheckinOutcome.SUCCESS) {
|
||||
s.todayCredit.takeIf { it > 0 } ?: s.dailyCredit
|
||||
} else {
|
||||
0
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun checkInTrae(accountId: String? = null): Result<TraeRepository.CheckinResult> =
|
||||
withCredentialSession(ServiceType.TRAE, accountId) {
|
||||
traeRepositoryProvider.get().checkIn(accountId).also { result ->
|
||||
result.getOrNull()?.let { r ->
|
||||
if (r.outcome == TraeRepository.CheckinOutcome.SUCCESS ||
|
||||
r.outcome == TraeRepository.CheckinOutcome.ALREADY
|
||||
) {
|
||||
checkinStatusStore.record(
|
||||
service = ServiceType.TRAE,
|
||||
accountId = accountId,
|
||||
status = r.outcome.name,
|
||||
checkinId = if (r.outcome == TraeRepository.CheckinOutcome.SUCCESS) {
|
||||
java.util.UUID.randomUUID().toString()
|
||||
} else {
|
||||
null
|
||||
},
|
||||
credits = if (r.outcome == TraeRepository.CheckinOutcome.SUCCESS) r.points else 0
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 查询今日签到状态(仅 WORKBUDDY 有该接口)。走会话以保证状态查询的凭据写入不覆盖并发编辑。 */
|
||||
suspend fun fetchCheckinStatus(accountId: String? = null): Result<WorkBuddyRepository.CheckinStatus> =
|
||||
withCredentialSession(ServiceType.WORKBUDDY, accountId) {
|
||||
workBuddyRepositoryProvider.get().fetchCheckinStatus(accountId)
|
||||
}
|
||||
|
||||
/** TRAE 今日签到状态(只读 status 接口,供详情页进入时预取「已签到」展示)。 */
|
||||
suspend fun fetchTraeCheckinStatus(accountId: String? = null): Result<Boolean> =
|
||||
withCredentialSession(ServiceType.TRAE, accountId) {
|
||||
traeRepositoryProvider.get().fetchCheckinStatus(accountId)
|
||||
}
|
||||
|
||||
private suspend fun <T> withCredentialSession(
|
||||
service: ServiceType,
|
||||
accountId: String? = null,
|
||||
block: suspend () -> Result<T>
|
||||
): Result<T> {
|
||||
val snapshot = credentialRepository.snapshot(service, accountId)
|
||||
?: return Result.failure(RepositoryError.InvalidCredential())
|
||||
val session = RefreshWriteSession(snapshot)
|
||||
|
||||
val result = try {
|
||||
withContext(session) { block() }
|
||||
} catch (cancelled: CancellationException) {
|
||||
// Codex refresh_token 是单次轮换的:服务端可能已经作废旧 token,而新 token
|
||||
// 已暂存在 session。即使 Widget 超时取消,也必须先在不可取消区提交凭据;
|
||||
// 余额不提交,因为原业务请求没有正常完成。
|
||||
commitIgnoringCancellation(session, includeBalance = false)
|
||||
throw cancelled
|
||||
} catch (error: Throwable) {
|
||||
Result.failure(RepositoryError.Unknown(error))
|
||||
}
|
||||
|
||||
val committed = try {
|
||||
withContext(NonCancellable) {
|
||||
credentialRepository.commit(
|
||||
session = session,
|
||||
includeBalance = result.isSuccess
|
||||
)
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
return Result.failure(RepositoryError.Unknown(error))
|
||||
}
|
||||
|
||||
return if (committed) {
|
||||
result
|
||||
} else {
|
||||
Result.failure(RepositoryError.CredentialChanged())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun commitIgnoringCancellation(
|
||||
session: RefreshWriteSession,
|
||||
includeBalance: Boolean
|
||||
) {
|
||||
withContext(NonCancellable) {
|
||||
try {
|
||||
credentialRepository.commit(session, includeBalance)
|
||||
} catch (_: Throwable) {
|
||||
// 保持原始 CancellationException 语义;提交失败不会伪装成业务成功。
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,94 @@
|
||||
package com.rainy.token.domain.usecase
|
||||
|
||||
import com.rainy.token.data.local.UsageCache
|
||||
import com.rainy.token.data.repository.CommandCodeUsageRepository
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
|
||||
/**
|
||||
* CommandCode 用量同步 UseCase。
|
||||
*
|
||||
* 游标协议:每页返回 (记录列表, 下一页游标)。
|
||||
* - cursor=null → 最新页
|
||||
* - 返回的列表长度 < PAGE_SIZE → 到底
|
||||
*
|
||||
* ## 全量同步
|
||||
* 从 cursor=null 逐页抓取直至最后一页。
|
||||
*
|
||||
* ## 增量同步
|
||||
* 从 cursor=null 逐页抓取,每页比对本地已有 ID,
|
||||
* 当某页全部记录都已存在时停止。
|
||||
*/
|
||||
class SyncCommandCodeUsageUseCase @Inject constructor(
|
||||
private val usageRepoProvider: Provider<CommandCodeUsageRepository>,
|
||||
private val cacheProvider: Provider<UsageCache>
|
||||
) {
|
||||
/** 防御性页数上限:正常窗口(1 天 ≤ 数十页)远不会触及,防止游标异常导致死循环 */
|
||||
private companion object {
|
||||
const val MAX_PAGES = 500
|
||||
}
|
||||
|
||||
suspend fun fullSync(): Result<SyncResult> {
|
||||
val repo = usageRepoProvider.get()
|
||||
val cache = cacheProvider.get()
|
||||
var cursor: String? = null
|
||||
var totalInserted = 0
|
||||
val errors = mutableListOf<String>()
|
||||
var pages = 0
|
||||
|
||||
while (true) {
|
||||
val pageResult = repo.fetchPage(cursor)
|
||||
if (pageResult.isFailure) {
|
||||
errors.add("cursor=${cursor?.take(20)}: ${pageResult.exceptionOrNull()?.message}")
|
||||
break
|
||||
}
|
||||
val (records, nextCursor) = pageResult.getOrThrow()
|
||||
if (records.isEmpty()) break
|
||||
if (nextCursor == cursor) break // 游标未前进,防死循环
|
||||
|
||||
val before = cache.count()
|
||||
cache.insertAll(records)
|
||||
totalInserted += (cache.count() - before)
|
||||
|
||||
if (records.size < CommandCodeUsageRepository.PAGE_SIZE) break
|
||||
cursor = nextCursor
|
||||
if (++pages >= MAX_PAGES) break // 防御性上限,正常窗口不会触及
|
||||
}
|
||||
|
||||
return if (errors.isEmpty()) Result.success(SyncResult(inserted = totalInserted))
|
||||
else Result.failure(SyncError.PartialSync(totalInserted, errors))
|
||||
}
|
||||
|
||||
suspend fun incrementalSync(): Result<SyncResult> {
|
||||
val repo = usageRepoProvider.get()
|
||||
val cache = cacheProvider.get()
|
||||
var cursor: String? = null
|
||||
var totalInserted = 0
|
||||
var pages = 0
|
||||
|
||||
while (true) {
|
||||
val pageResult = repo.fetchPage(cursor)
|
||||
if (pageResult.isFailure) return Result.failure(pageResult.exceptionOrNull()!!)
|
||||
|
||||
val (records, nextCursor) = pageResult.getOrThrow()
|
||||
if (records.isEmpty()) break
|
||||
if (nextCursor == cursor) break // 游标未前进,防死循环
|
||||
|
||||
// 按 workspace 过滤本地已有 ID,避免跨 workspace 碰撞
|
||||
val workspaceId = records.firstOrNull()?.workspaceId ?: CommandCodeUsageRepository.CCGO_WORKSPACE_ID
|
||||
val existingIds = cache.getIdsByWorkspace(workspaceId)
|
||||
val newRecords = records.filter { it.id !in existingIds }
|
||||
|
||||
if (newRecords.isEmpty()) break
|
||||
|
||||
cache.insertAll(newRecords)
|
||||
totalInserted += newRecords.size
|
||||
|
||||
if (records.size < CommandCodeUsageRepository.PAGE_SIZE) break
|
||||
cursor = nextCursor
|
||||
if (++pages >= MAX_PAGES) break // 防御性上限
|
||||
}
|
||||
|
||||
return Result.success(SyncResult(inserted = totalInserted))
|
||||
}
|
||||
}
|
||||
@ -61,7 +61,9 @@ class SyncUsageUseCase @Inject constructor(
|
||||
val records = pageResult.getOrThrow()
|
||||
if (records.isEmpty()) break
|
||||
|
||||
val existingIds = cache.getAllIds() // 本地已有 ID 集合
|
||||
// 用第一条记录的 workspaceId 过滤本地已有 ID,避免跨 workspace 碰撞
|
||||
val workspaceId = records.firstOrNull()?.workspaceId ?: ""
|
||||
val existingIds = if (workspaceId.isNotEmpty()) cache.getIdsByWorkspace(workspaceId) else cache.getAllIds()
|
||||
val newRecords = records.filter { it.id !in existingIds }
|
||||
|
||||
if (newRecords.isEmpty()) break // 整页都已存在 → 接到旧数据
|
||||
@ -83,7 +85,10 @@ data class SyncResult(
|
||||
)
|
||||
|
||||
sealed class SyncError : Exception() {
|
||||
class PartialSync(val inserted: Int, val errors: List<String>) : SyncError() {
|
||||
override val message: String = "部分同步完成:插入 $inserted 条,${errors.size} 页失败"
|
||||
}
|
||||
class PartialSync(val inserted: Int, val errors: List<String>) : SyncError() {
|
||||
override val message: String = buildString {
|
||||
append("部分同步完成:插入 ${inserted}条,${errors.size}页失败")
|
||||
if (errors.isNotEmpty()) append("。${errors.first().take(200)}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,38 +1,119 @@
|
||||
package com.rainy.token.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedContentTransitionScope
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.VerticalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
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.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
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.ServiceConfigProvider
|
||||
import com.rainy.token.domain.service.ServiceType
|
||||
import com.rainy.token.ui.components.rememberWindowSizeClass
|
||||
import com.rainy.token.ui.components.DebugLogScreen
|
||||
import com.rainy.token.ui.components.TipsScreen
|
||||
import com.rainy.token.ui.dashboard.DashboardScreen
|
||||
import com.rainy.token.ui.dashboard.DashboardViewModel
|
||||
import com.rainy.token.ui.dashboard.UsageChartViewModel
|
||||
import com.rainy.token.ui.dashboard.UsageDataScreen
|
||||
import com.rainy.token.ui.dashboard.UsageDataViewModel
|
||||
import com.rainy.token.ui.dashboard.UsageDetailScreen
|
||||
import com.rainy.token.ui.dashboard.UsageOverviewScreen
|
||||
import com.rainy.token.ui.dashboard.UsageViewModel
|
||||
import com.rainy.token.ui.heatmap.HeatmapScreen
|
||||
import com.rainy.token.ui.account.ApiManagementScreen
|
||||
import com.rainy.token.ui.servicedetail.ServiceDetailScreen
|
||||
import com.rainy.token.ui.settings.AccountManagementScreen
|
||||
import com.rainy.token.ui.settings.CredentialEditScreen
|
||||
import com.rainy.token.ui.settings.CredentialEditViewModel
|
||||
import com.rainy.token.ui.settings.SettingsScreen
|
||||
import com.rainy.token.ui.theme.inkMuted
|
||||
import com.rainy.token.ui.theme.StrawberryPink
|
||||
import com.rainy.token.ui.webview.CodexOAuthScreen
|
||||
import com.rainy.token.ui.webview.WebViewLoginScreen
|
||||
import com.rainy.token.ui.webview.WebViewPageScreen
|
||||
import android.net.Uri
|
||||
|
||||
/**
|
||||
* 应用导航图。当前阶段 4 接入了完整路径:
|
||||
* Dashboard → Settings → CredentialEdit → WebViewLogin
|
||||
* Dashboard → ServiceDetail
|
||||
* 应用导航图。
|
||||
*
|
||||
* 自适应策略:
|
||||
* - **Compact**(< 600dp,手机):单栈 NavHost。guardedPop 时间戳围栏(200ms)防连点栈损坏,
|
||||
* enter/exit/popEnter/popExit 显式使用同一套滑动过渡,避免默认淡入淡出与返回动画混用。
|
||||
* - **Expanded**(≥ 840dp,平板横屏):左侧 Dashboard + 右侧 when 分支原子切换,
|
||||
* 用量子路由用局部 NavHost。
|
||||
*/
|
||||
object Routes {
|
||||
const val DASHBOARD = "dashboard"
|
||||
const val SETTINGS = "settings"
|
||||
const val CREDENTIAL_EDIT = "credential_edit/{type}"
|
||||
fun credentialEdit(type: ServiceType) = "credential_edit/${type.name}"
|
||||
const val WEBVIEW_LOGIN = "webview_login/{type}"
|
||||
fun webviewLogin(type: ServiceType) = "webview_login/${type.name}"
|
||||
const val SERVICE_DETAIL = "service/{type}"
|
||||
fun serviceDetail(type: ServiceType) = "service/${type.name}"
|
||||
const val API_MANAGEMENT = "api_management"
|
||||
const val TIPS = "tips"
|
||||
const val DEBUG_LOG = "debug_log"
|
||||
const val CODEX_OAUTH = "codex_oauth"
|
||||
const val CREDENTIAL_EDIT = "credential_edit/{type}?account={account}"
|
||||
fun credentialEdit(type: ServiceType, accountId: String? = null): String =
|
||||
if (accountId.isNullOrBlank()) {
|
||||
"credential_edit/${type.name}"
|
||||
} else {
|
||||
"credential_edit/${type.name}?account=${Uri.encode(accountId)}"
|
||||
}
|
||||
const val ACCOUNTS = "accounts/{type}"
|
||||
fun accounts(type: ServiceType) = "accounts/${type.name}"
|
||||
const val WEBVIEW_LOGIN = "webview_login/{type}?account={account}"
|
||||
fun webviewLogin(type: ServiceType, accountId: String? = null): String =
|
||||
if (accountId.isNullOrBlank()) {
|
||||
"webview_login/${type.name}"
|
||||
} else {
|
||||
"webview_login/${type.name}?account=${Uri.encode(accountId)}"
|
||||
}
|
||||
const val WEBVIEW_PAGE = "webview_page?url={url}&title={title}"
|
||||
fun webviewPage(url: String, title: String) =
|
||||
"webview_page?url=${Uri.encode(url)}&title=${Uri.encode(title)}"
|
||||
const val SERVICE_DETAIL = "service/{type}?account={account}"
|
||||
fun serviceDetail(type: ServiceType, accountId: String? = null): String =
|
||||
if (accountId.isNullOrBlank()) {
|
||||
"service/${type.name}"
|
||||
} else {
|
||||
"service/${type.name}?account=${Uri.encode(accountId)}"
|
||||
}
|
||||
const val USAGE_DETAIL = "usage_detail"
|
||||
const val USAGE_OVERVIEW = "usage_overview"
|
||||
const val USAGE_DATA = "usage_data"
|
||||
const val CCGO_USAGE_DETAIL = "ccgo_usage_detail"
|
||||
const val CCGO_USAGE_OVERVIEW = "ccgo_usage_overview"
|
||||
const val CCGO_USAGE_DATA = "ccgo_usage_data"
|
||||
const val HEATMAP = "heatmap"
|
||||
}
|
||||
|
||||
private fun parseServiceType(typeName: String?): ServiceType =
|
||||
@ -40,77 +121,737 @@ private fun parseServiceType(typeName: String?): ServiceType =
|
||||
ServiceType.fromStorageKey(it) ?: runCatching { ServiceType.valueOf(it) }.getOrNull()
|
||||
} ?: ServiceType.DEEPSEEK
|
||||
|
||||
/** 凭据入口:多账号服务先进账号管理页,其余直达凭据编辑页。 */
|
||||
private fun credentialEntry(type: ServiceType): String =
|
||||
if (ServiceType.isMultiAccount(type)) Routes.accounts(type) else Routes.credentialEdit(type)
|
||||
|
||||
private sealed class DetailPane {
|
||||
object Empty : DetailPane()
|
||||
data class ServiceDetail(val type: ServiceType, val accountId: String? = null) : DetailPane()
|
||||
object OCGOUsage : DetailPane()
|
||||
object CCGOUsage : DetailPane()
|
||||
object Settings : DetailPane()
|
||||
object ApiManagement : DetailPane()
|
||||
object Heatmap : DetailPane()
|
||||
/** 通用外部页面(充值/订阅页),App 内 WebView 打开。 */
|
||||
data class WebPage(val url: String, val title: String) : DetailPane()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RainyTokenNavHost() {
|
||||
val windowSize = rememberWindowSizeClass()
|
||||
val isExpanded = windowSize.widthSizeClass ==
|
||||
androidx.compose.material3.windowsizeclass.WindowWidthSizeClass.Expanded
|
||||
|
||||
if (isExpanded) {
|
||||
ExpandedLayout()
|
||||
} else {
|
||||
CompactNavHost()
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// Compact:单栈 NavHost —— guardedPop 围栏 + popExit/popEnter=None
|
||||
// ═══════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* 非 Compose State 的时间戳围栏。
|
||||
*
|
||||
* 为什么不用 mutableStateOf:
|
||||
* mutableStateOf 写入会触发 CompactNavHost → NavHost 重组。若重组发生在
|
||||
* AnimatedContent 过渡动画期间,会干扰内部 MutableTransitionState 状态机,
|
||||
* 导致旧 composable 已移除、新 composable 进入动画被中断后未正确启动 → 空白页。
|
||||
*
|
||||
* 连点保护:cooldownMs 内只放行一次 pop。popBackStack() 返回 false 时 reset。
|
||||
*/
|
||||
private class PopGuard(
|
||||
private val cooldownMs: Long = 200
|
||||
) {
|
||||
private var lastPopTime: Long = 0L
|
||||
|
||||
fun tryAcquire(): Boolean {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastPopTime < cooldownMs) return false
|
||||
lastPopTime = now
|
||||
return true
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
lastPopTime = 0L
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CompactNavHost() {
|
||||
val navController = rememberNavController()
|
||||
NavHost(navController = navController, startDestination = Routes.DASHBOARD) {
|
||||
// 围栏:200ms 内只允许一次 pop;普通对象持有时间戳,不触发 NavHost 重组
|
||||
val popGuard = remember { PopGuard() }
|
||||
val guardedPop: () -> Unit = {
|
||||
// 起始页无 previousBackStackEntry,直接跳过,避免无意义的 popBackStack() 调用
|
||||
if (navController.previousBackStackEntry != null && popGuard.tryAcquire()) {
|
||||
// popBackStack() 返回 false = 已在起始页,reset 围栏不浪费冷却
|
||||
if (!navController.popBackStack()) {
|
||||
popGuard.reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Routes.DASHBOARD,
|
||||
enterTransition = {
|
||||
slideIntoContainer(
|
||||
AnimatedContentTransitionScope.SlideDirection.Left,
|
||||
animationSpec = tween(220)
|
||||
) + fadeIn(animationSpec = tween(90))
|
||||
},
|
||||
exitTransition = {
|
||||
slideOutOfContainer(
|
||||
AnimatedContentTransitionScope.SlideDirection.Left,
|
||||
animationSpec = tween(220)
|
||||
) + fadeOut(animationSpec = tween(90))
|
||||
},
|
||||
// Navigation Compose 会把 pop 过渡绑定到 Android 13+ predictive back 手势进度,
|
||||
// 因此这里使用同一套滑动动画,避免默认淡入淡出与返回动画混用。
|
||||
popExitTransition = {
|
||||
slideOutOfContainer(
|
||||
AnimatedContentTransitionScope.SlideDirection.Right,
|
||||
animationSpec = tween(220)
|
||||
) + fadeOut(animationSpec = tween(90))
|
||||
},
|
||||
popEnterTransition = {
|
||||
slideIntoContainer(
|
||||
AnimatedContentTransitionScope.SlideDirection.Right,
|
||||
animationSpec = tween(220)
|
||||
) + fadeIn(animationSpec = tween(90))
|
||||
}
|
||||
) {
|
||||
composable(Routes.DASHBOARD) {
|
||||
val rechargeLabel = stringResource(R.string.action_recharge)
|
||||
DashboardScreen(
|
||||
onOpenSettings = { navController.navigate(Routes.SETTINGS) },
|
||||
onOpenService = { type -> navController.navigate(Routes.serviceDetail(type)) },
|
||||
onOpenUsageDetail = { navController.navigate(Routes.USAGE_DETAIL) }
|
||||
onOpenApiManagement = { navController.navigate(Routes.API_MANAGEMENT) },
|
||||
onOpenService = { type, accountId ->
|
||||
navController.navigate(Routes.serviceDetail(type, accountId))
|
||||
},
|
||||
onOpenUsageDetail = { navController.navigate(Routes.USAGE_DETAIL) },
|
||||
onOpenCcgoUsageDetail = { navController.navigate(Routes.CCGO_USAGE_DETAIL) },
|
||||
onOpenHeatmap = { navController.navigate(Routes.HEATMAP) },
|
||||
onOpenRecharge = { type ->
|
||||
ServiceConfigProvider.get(type).externalUrl?.let { url ->
|
||||
navController.navigate(Routes.webviewPage(url, rechargeLabel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
composable(Routes.API_MANAGEMENT) {
|
||||
val rechargeLabel = stringResource(R.string.action_recharge)
|
||||
ApiManagementScreen(
|
||||
onBack = guardedPop,
|
||||
onConfigure = { type -> navController.navigate(credentialEntry(type)) },
|
||||
onRecharge = { type ->
|
||||
ServiceConfigProvider.get(type).externalUrl?.let { url ->
|
||||
navController.navigate(Routes.webviewPage(url, rechargeLabel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
composable(Routes.USAGE_DETAIL) {
|
||||
UsageDetailScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
onBack = guardedPop,
|
||||
onOpenOverview = { navController.navigate(Routes.USAGE_OVERVIEW) },
|
||||
onOpenData = { navController.navigate(Routes.USAGE_DATA) }
|
||||
)
|
||||
}
|
||||
composable(Routes.USAGE_OVERVIEW) {
|
||||
UsageOverviewScreen(
|
||||
onBack = { navController.popBackStack() }
|
||||
onBack = guardedPop
|
||||
)
|
||||
}
|
||||
composable(Routes.USAGE_DATA) {
|
||||
UsageDataScreen(
|
||||
onBack = { navController.popBackStack() }
|
||||
onBack = guardedPop
|
||||
)
|
||||
}
|
||||
composable(Routes.CCGO_USAGE_DETAIL) {
|
||||
val wid = CommandCodeUsageRepository.CCGO_WORKSPACE_ID
|
||||
val chartVm: UsageChartViewModel = hiltViewModel(key = "ccgo_chart_$wid")
|
||||
val usageVm: UsageViewModel = hiltViewModel(key = "ccgo_$wid")
|
||||
LaunchedEffect(Unit) {
|
||||
usageVm.setWorkspace(wid)
|
||||
chartVm.setWorkspace(wid)
|
||||
}
|
||||
UsageDetailScreen(
|
||||
onBack = guardedPop,
|
||||
onOpenOverview = { navController.navigate(Routes.CCGO_USAGE_OVERVIEW) },
|
||||
onOpenData = { navController.navigate(Routes.CCGO_USAGE_DATA) },
|
||||
viewModel = chartVm,
|
||||
clearViewModel = usageVm
|
||||
)
|
||||
}
|
||||
composable(Routes.CCGO_USAGE_OVERVIEW) {
|
||||
val key = "ccgo_${CommandCodeUsageRepository.CCGO_WORKSPACE_ID}"
|
||||
val ovVm: UsageViewModel = hiltViewModel(key = key)
|
||||
LaunchedEffect(Unit) { ovVm.setWorkspace(CommandCodeUsageRepository.CCGO_WORKSPACE_ID) }
|
||||
UsageOverviewScreen(
|
||||
onBack = guardedPop,
|
||||
viewModel = ovVm,
|
||||
autoLoad = false
|
||||
)
|
||||
}
|
||||
composable(Routes.CCGO_USAGE_DATA) {
|
||||
val key = "ccgo_${CommandCodeUsageRepository.CCGO_WORKSPACE_ID}"
|
||||
val dataVm: UsageDataViewModel = hiltViewModel(key = key)
|
||||
LaunchedEffect(Unit) { dataVm.setWorkspace(CommandCodeUsageRepository.CCGO_WORKSPACE_ID) }
|
||||
UsageDataScreen(
|
||||
onBack = guardedPop,
|
||||
viewModel = dataVm,
|
||||
autoLoad = false
|
||||
)
|
||||
}
|
||||
composable(Routes.SETTINGS) {
|
||||
SettingsScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
onEditCredential = { type -> navController.navigate(Routes.credentialEdit(type)) }
|
||||
onBack = guardedPop,
|
||||
onEditCredential = { type -> navController.navigate(credentialEntry(type)) },
|
||||
onManageAccounts = { type -> navController.navigate(Routes.accounts(type)) },
|
||||
onOpenTips = { navController.navigate(Routes.TIPS) },
|
||||
onOpenDebugLog = { navController.navigate(Routes.DEBUG_LOG) }
|
||||
)
|
||||
}
|
||||
composable(Routes.ACCOUNTS) { backStackEntry ->
|
||||
val type = parseServiceType(backStackEntry.arguments?.getString("type"))
|
||||
AccountManagementScreen(
|
||||
service = type,
|
||||
onBack = guardedPop,
|
||||
onEditAccount = { svc, accountId ->
|
||||
navController.navigate(Routes.credentialEdit(svc, accountId))
|
||||
}
|
||||
)
|
||||
}
|
||||
composable(Routes.TIPS) {
|
||||
TipsScreen(onBack = guardedPop)
|
||||
}
|
||||
composable(Routes.DEBUG_LOG) {
|
||||
DebugLogScreen(onBack = guardedPop)
|
||||
}
|
||||
composable(Routes.CODEX_OAUTH) {
|
||||
CodexOAuthScreen(
|
||||
onBack = guardedPop,
|
||||
onSuccess = guardedPop
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Routes.CREDENTIAL_EDIT,
|
||||
arguments = listOf(navArgument("type") { type = NavType.StringType })
|
||||
arguments = listOf(
|
||||
navArgument("type") { type = NavType.StringType },
|
||||
navArgument("account") {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
defaultValue = null
|
||||
}
|
||||
)
|
||||
) { backStackEntry ->
|
||||
val type = parseServiceType(backStackEntry.arguments?.getString("type"))
|
||||
val accountId = backStackEntry.arguments?.getString("account")
|
||||
CredentialEditScreen(
|
||||
service = type,
|
||||
onBack = { navController.popBackStack() },
|
||||
onStartWebViewLogin = { service -> navController.navigate(Routes.webviewLogin(service)) },
|
||||
onWebViewLoginSuccess = { /* 登录后回到编辑页,由 LaunchedEffect 触发刷新 */ }
|
||||
accountId = accountId,
|
||||
onBack = guardedPop,
|
||||
onStartWebViewLogin = { svc, acc -> navController.navigate(Routes.webviewLogin(svc, acc)) },
|
||||
onWebViewLoginSuccess = { },
|
||||
onStartCodexOAuth = { navController.navigate(Routes.CODEX_OAUTH) }
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Routes.WEBVIEW_LOGIN,
|
||||
arguments = listOf(navArgument("type") { type = NavType.StringType })
|
||||
arguments = listOf(
|
||||
navArgument("type") { type = NavType.StringType },
|
||||
navArgument("account") {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
defaultValue = null
|
||||
}
|
||||
)
|
||||
) { backStackEntry ->
|
||||
val type = parseServiceType(backStackEntry.arguments?.getString("type"))
|
||||
val accountId = backStackEntry.arguments?.getString("account")
|
||||
WebViewLoginScreen(
|
||||
service = type,
|
||||
onBack = { navController.popBackStack() },
|
||||
onLoginSucceeded = {
|
||||
// 登录成功直接回退到 CredentialEdit 页
|
||||
navController.popBackStack()
|
||||
accountId = accountId,
|
||||
onBack = guardedPop,
|
||||
onLoginSucceeded = { _, effectiveId ->
|
||||
// 把实际写入的账号 ID 回传给上一层(凭据编辑页),新建账号模式用于回填
|
||||
if (!effectiveId.isNullOrBlank()) {
|
||||
navController.previousBackStackEntry?.savedStateHandle?.set(
|
||||
CredentialEditViewModel.KEY_LOGIN_ACCOUNT_ID, effectiveId
|
||||
)
|
||||
}
|
||||
// 登录成功后返回上一层(详情页或凭据编辑页),由 ON_RESUME 刷新内容
|
||||
if (!navController.popBackStack(Routes.SERVICE_DETAIL, inclusive = false)) {
|
||||
guardedPop()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Routes.SERVICE_DETAIL,
|
||||
arguments = listOf(navArgument("type") { type = NavType.StringType })
|
||||
arguments = listOf(
|
||||
navArgument("type") { type = NavType.StringType },
|
||||
navArgument("account") {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
defaultValue = null
|
||||
}
|
||||
)
|
||||
) { backStackEntry ->
|
||||
val type = parseServiceType(backStackEntry.arguments?.getString("type"))
|
||||
val accountId = backStackEntry.arguments?.getString("account")
|
||||
val rechargeLabel = stringResource(R.string.action_recharge)
|
||||
ServiceDetailScreen(
|
||||
service = type,
|
||||
onBack = { navController.popBackStack() },
|
||||
onConfigureCredential = { svc -> navController.navigate(Routes.credentialEdit(svc)) },
|
||||
onStartWebViewLogin = { svc -> navController.navigate(Routes.webviewLogin(svc)) }
|
||||
accountId = accountId,
|
||||
onBack = guardedPop,
|
||||
onSwitchAccount = { svc, acc ->
|
||||
// 切换账号时替换当前详情页(避免同路由栈堆积)
|
||||
navController.navigate(Routes.serviceDetail(svc, acc)) {
|
||||
popUpTo(Routes.SERVICE_DETAIL) { inclusive = true }
|
||||
}
|
||||
},
|
||||
onConfigureCredential = { svc -> navController.navigate(credentialEntry(svc)) },
|
||||
onStartWebViewLogin = { svc, acc -> navController.navigate(Routes.webviewLogin(svc, acc)) },
|
||||
onOpenRecharge = { svc ->
|
||||
ServiceConfigProvider.get(svc).externalUrl?.let { url ->
|
||||
navController.navigate(Routes.webviewPage(url, rechargeLabel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Routes.WEBVIEW_PAGE,
|
||||
arguments = listOf(
|
||||
navArgument("url") { type = NavType.StringType },
|
||||
navArgument("title") { type = NavType.StringType }
|
||||
)
|
||||
) { backStackEntry ->
|
||||
val url = backStackEntry.arguments?.getString("url").orEmpty()
|
||||
val title = backStackEntry.arguments?.getString("title").orEmpty()
|
||||
WebViewPageScreen(url = url, title = title, onBack = guardedPop)
|
||||
}
|
||||
composable(Routes.HEATMAP) {
|
||||
HeatmapScreen(onBack = guardedPop)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// Expanded:手动双窗格
|
||||
// ═══════════════════════════════════════════════
|
||||
|
||||
@Composable
|
||||
private fun ExpandedLayout() {
|
||||
var detailPane by remember { mutableStateOf<DetailPane>(DetailPane.Empty) }
|
||||
val dashboardVm: DashboardViewModel = hiltViewModel()
|
||||
|
||||
// 宽屏布局下 DashboardScreen 永久挂载,不会收到 ON_RESUME。
|
||||
// 当详情面板关闭时手动触发 Dashboard 刷新凭据状态。
|
||||
var prevPane by remember { mutableStateOf<DetailPane>(DetailPane.Empty) }
|
||||
LaunchedEffect(detailPane) {
|
||||
if (prevPane != DetailPane.Empty && detailPane == DetailPane.Empty) {
|
||||
dashboardVm.reloadLocalState()
|
||||
}
|
||||
prevPane = detailPane
|
||||
}
|
||||
|
||||
val rechargeLabel = stringResource(R.string.action_recharge)
|
||||
|
||||
Row(modifier = Modifier.fillMaxSize()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(0.35f)
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
DashboardScreen(
|
||||
onOpenSettings = { detailPane = DetailPane.Settings },
|
||||
onOpenApiManagement = { detailPane = DetailPane.ApiManagement },
|
||||
onOpenService = { type, accountId ->
|
||||
detailPane = DetailPane.ServiceDetail(type, accountId)
|
||||
},
|
||||
onOpenUsageDetail = { detailPane = DetailPane.OCGOUsage },
|
||||
onOpenCcgoUsageDetail = { detailPane = DetailPane.CCGOUsage },
|
||||
onOpenHeatmap = { detailPane = DetailPane.Heatmap },
|
||||
onOpenRecharge = { type ->
|
||||
ServiceConfigProvider.get(type).externalUrl?.let { url ->
|
||||
detailPane = DetailPane.WebPage(url = url, title = rechargeLabel)
|
||||
}
|
||||
},
|
||||
viewModel = dashboardVm
|
||||
)
|
||||
}
|
||||
|
||||
VerticalDivider(
|
||||
thickness = 1.dp,
|
||||
color = MaterialTheme.colorScheme.outlineVariant
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(0.65f)
|
||||
.fillMaxHeight()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
) {
|
||||
ExpandedDetailPane(
|
||||
pane = detailPane,
|
||||
onClose = { detailPane = DetailPane.Empty },
|
||||
onSwitchAccount = { svc, acc ->
|
||||
detailPane = DetailPane.ServiceDetail(svc, acc)
|
||||
},
|
||||
onCredentialEditClosed = { dashboardVm.reloadLocalState() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExpandedDetailPane(
|
||||
pane: DetailPane,
|
||||
onClose: () -> Unit,
|
||||
onSwitchAccount: (ServiceType, String?) -> Unit = { _, _ -> },
|
||||
onCredentialEditClosed: () -> Unit = {}
|
||||
) {
|
||||
when (pane) {
|
||||
is DetailPane.Empty -> {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
is DetailPane.ServiceDetail -> {
|
||||
// key(pane.type + accountId):切换服务/账号时重建嵌套导航栈,避免停留在上一个页面
|
||||
key("${pane.type.name}:${pane.accountId.orEmpty()}") {
|
||||
val navController = rememberNavController()
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = "detail",
|
||||
popExitTransition = { ExitTransition.None }
|
||||
) {
|
||||
composable("detail") {
|
||||
val rechargeLabel = LocalContext.current.getString(R.string.action_recharge)
|
||||
ServiceDetailScreen(
|
||||
service = pane.type,
|
||||
accountId = pane.accountId,
|
||||
onBack = onClose,
|
||||
onSwitchAccount = onSwitchAccount,
|
||||
onConfigureCredential = { onClose() },
|
||||
onStartWebViewLogin = { _, _ -> },
|
||||
onOpenRecharge = { svc ->
|
||||
ServiceConfigProvider.get(svc).externalUrl?.let { url ->
|
||||
navController.navigate(Routes.webviewPage(url, rechargeLabel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Routes.WEBVIEW_PAGE,
|
||||
arguments = listOf(
|
||||
navArgument("url") { type = NavType.StringType },
|
||||
navArgument("title") { type = NavType.StringType }
|
||||
)
|
||||
) { entry ->
|
||||
WebViewPageScreen(
|
||||
url = entry.arguments?.getString("url").orEmpty(),
|
||||
title = entry.arguments?.getString("title").orEmpty(),
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
is DetailPane.OCGOUsage -> {
|
||||
val navController = rememberNavController()
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = "chart",
|
||||
popExitTransition = { ExitTransition.None }
|
||||
) {
|
||||
composable("chart") {
|
||||
UsageDetailScreen(
|
||||
onBack = onClose,
|
||||
onOpenOverview = { navController.navigate("overview") },
|
||||
onOpenData = { navController.navigate("data") }
|
||||
)
|
||||
}
|
||||
composable("overview") {
|
||||
UsageOverviewScreen(onBack = { navController.popBackStack() }, autoLoad = true)
|
||||
}
|
||||
composable("data") {
|
||||
UsageDataScreen(onBack = { navController.popBackStack() }, autoLoad = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
is DetailPane.CCGOUsage -> {
|
||||
val navController = rememberNavController()
|
||||
val wid = CommandCodeUsageRepository.CCGO_WORKSPACE_ID
|
||||
val chartVm: UsageChartViewModel = hiltViewModel(key = "ccgo_chart_$wid")
|
||||
val usageVm: UsageViewModel = hiltViewModel(key = "ccgo_$wid")
|
||||
LaunchedEffect(Unit) {
|
||||
usageVm.setWorkspace(wid)
|
||||
chartVm.setWorkspace(wid)
|
||||
}
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = "chart",
|
||||
popExitTransition = { ExitTransition.None }
|
||||
) {
|
||||
composable("chart") {
|
||||
UsageDetailScreen(
|
||||
onBack = onClose,
|
||||
onOpenOverview = { navController.navigate("overview") },
|
||||
onOpenData = { navController.navigate("data") },
|
||||
viewModel = chartVm,
|
||||
clearViewModel = usageVm
|
||||
)
|
||||
}
|
||||
composable("overview") {
|
||||
val key = "ccgo_$wid"
|
||||
val ovVm: UsageViewModel = hiltViewModel(key = key)
|
||||
LaunchedEffect(Unit) { ovVm.setWorkspace(wid) }
|
||||
UsageOverviewScreen(onBack = { navController.popBackStack() }, viewModel = ovVm, autoLoad = false)
|
||||
}
|
||||
composable("data") {
|
||||
val key = "ccgo_$wid"
|
||||
val dataVm: UsageDataViewModel = hiltViewModel(key = key)
|
||||
LaunchedEffect(Unit) { dataVm.setWorkspace(wid) }
|
||||
UsageDataScreen(onBack = { navController.popBackStack() }, viewModel = dataVm, autoLoad = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
is DetailPane.Heatmap -> {
|
||||
HeatmapScreen(onBack = onClose)
|
||||
}
|
||||
is DetailPane.WebPage -> {
|
||||
WebViewPageScreen(
|
||||
url = pane.url,
|
||||
title = pane.title,
|
||||
onBack = onClose
|
||||
)
|
||||
}
|
||||
is DetailPane.ApiManagement -> {
|
||||
val apiNavController = rememberNavController()
|
||||
// 从凭据编辑/WebView 返回到 api_mgmt_main 时刷新 Dashboard 凭据状态
|
||||
val routeStack by apiNavController.currentBackStackEntryAsState()
|
||||
val currentRoute = routeStack?.destination?.route
|
||||
var prevRoute by remember { mutableStateOf("api_mgmt_main") }
|
||||
LaunchedEffect(currentRoute) {
|
||||
if (prevRoute != currentRoute && currentRoute == "api_mgmt_main") {
|
||||
onCredentialEditClosed()
|
||||
}
|
||||
prevRoute = currentRoute ?: "api_mgmt_main"
|
||||
}
|
||||
NavHost(
|
||||
navController = apiNavController,
|
||||
startDestination = "api_mgmt_main",
|
||||
popExitTransition = { ExitTransition.None }
|
||||
) {
|
||||
composable("api_mgmt_main") {
|
||||
val rechargeLabel = LocalContext.current.getString(R.string.action_recharge)
|
||||
ApiManagementScreen(
|
||||
onBack = onClose,
|
||||
onConfigure = { type -> apiNavController.navigate(credentialEntry(type)) },
|
||||
onRecharge = { type ->
|
||||
ServiceConfigProvider.get(type).externalUrl?.let { url ->
|
||||
apiNavController.navigate(Routes.webviewPage(url, rechargeLabel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
composable(Routes.ACCOUNTS) { entry ->
|
||||
val type = parseServiceType(entry.arguments?.getString("type"))
|
||||
AccountManagementScreen(
|
||||
service = type,
|
||||
onBack = { apiNavController.popBackStack() },
|
||||
onEditAccount = { svc, accountId ->
|
||||
apiNavController.navigate(Routes.credentialEdit(svc, accountId))
|
||||
}
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Routes.CREDENTIAL_EDIT,
|
||||
arguments = listOf(
|
||||
navArgument("type") { type = NavType.StringType },
|
||||
navArgument("account") {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
defaultValue = null
|
||||
}
|
||||
)
|
||||
) { entry ->
|
||||
val type = parseServiceType(entry.arguments?.getString("type"))
|
||||
val accountId = entry.arguments?.getString("account")
|
||||
CredentialEditScreen(
|
||||
service = type,
|
||||
accountId = accountId,
|
||||
onBack = { apiNavController.popBackStack() },
|
||||
onStartWebViewLogin = { svc, acc ->
|
||||
apiNavController.navigate(Routes.webviewLogin(svc, acc))
|
||||
},
|
||||
onWebViewLoginSuccess = { },
|
||||
onStartCodexOAuth = { apiNavController.navigate(Routes.CODEX_OAUTH) }
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Routes.WEBVIEW_LOGIN,
|
||||
arguments = listOf(
|
||||
navArgument("type") { type = NavType.StringType },
|
||||
navArgument("account") {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
defaultValue = null
|
||||
}
|
||||
)
|
||||
) { entry ->
|
||||
val type = parseServiceType(entry.arguments?.getString("type"))
|
||||
val accountId = entry.arguments?.getString("account")
|
||||
WebViewLoginScreen(
|
||||
service = type,
|
||||
accountId = accountId,
|
||||
onBack = { apiNavController.popBackStack() },
|
||||
onLoginSucceeded = { _, effectiveId ->
|
||||
if (!effectiveId.isNullOrBlank()) {
|
||||
apiNavController.previousBackStackEntry?.savedStateHandle?.set(
|
||||
CredentialEditViewModel.KEY_LOGIN_ACCOUNT_ID, effectiveId
|
||||
)
|
||||
}
|
||||
apiNavController.popBackStack()
|
||||
}
|
||||
)
|
||||
}
|
||||
composable(Routes.CODEX_OAUTH) {
|
||||
CodexOAuthScreen(
|
||||
onBack = { apiNavController.popBackStack() },
|
||||
onSuccess = { apiNavController.popBackStack() }
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Routes.WEBVIEW_PAGE,
|
||||
arguments = listOf(
|
||||
navArgument("url") { type = NavType.StringType },
|
||||
navArgument("title") { type = NavType.StringType }
|
||||
)
|
||||
) { entry ->
|
||||
WebViewPageScreen(
|
||||
url = entry.arguments?.getString("url").orEmpty(),
|
||||
title = entry.arguments?.getString("title").orEmpty(),
|
||||
onBack = { apiNavController.popBackStack() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
is DetailPane.Settings -> {
|
||||
val settingsNavController = rememberNavController()
|
||||
// 监听嵌套 NavHost 路由变化:当从凭据编辑页/WebView 返回 settings_main 时
|
||||
// 通知 Dashboard 刷新凭据状态(宽屏布局 Dashboard 永久挂载不产生 ON_RESUME)
|
||||
val routeStack by settingsNavController.currentBackStackEntryAsState()
|
||||
val currentRoute = routeStack?.destination?.route
|
||||
var prevRoute by remember { mutableStateOf("settings_main") }
|
||||
LaunchedEffect(currentRoute) {
|
||||
if (prevRoute != currentRoute && currentRoute == "settings_main") {
|
||||
onCredentialEditClosed()
|
||||
}
|
||||
prevRoute = currentRoute ?: "settings_main"
|
||||
}
|
||||
NavHost(
|
||||
navController = settingsNavController,
|
||||
startDestination = "settings_main",
|
||||
popExitTransition = { ExitTransition.None }
|
||||
) {
|
||||
composable("settings_main") {
|
||||
SettingsScreen(
|
||||
onBack = onClose,
|
||||
onEditCredential = { type ->
|
||||
settingsNavController.navigate(credentialEntry(type))
|
||||
},
|
||||
onManageAccounts = { type ->
|
||||
settingsNavController.navigate(Routes.accounts(type))
|
||||
},
|
||||
onOpenTips = { settingsNavController.navigate("tips") },
|
||||
onOpenDebugLog = { settingsNavController.navigate("debug_log") }
|
||||
)
|
||||
}
|
||||
composable(Routes.ACCOUNTS) { entry ->
|
||||
val type = parseServiceType(entry.arguments?.getString("type"))
|
||||
AccountManagementScreen(
|
||||
service = type,
|
||||
onBack = { settingsNavController.popBackStack() },
|
||||
onEditAccount = { svc, accountId ->
|
||||
settingsNavController.navigate(Routes.credentialEdit(svc, accountId))
|
||||
}
|
||||
)
|
||||
}
|
||||
composable("tips") {
|
||||
TipsScreen(onBack = { settingsNavController.popBackStack() })
|
||||
}
|
||||
composable("debug_log") {
|
||||
DebugLogScreen(onBack = { settingsNavController.popBackStack() })
|
||||
}
|
||||
composable(Routes.CODEX_OAUTH) {
|
||||
CodexOAuthScreen(
|
||||
onBack = { settingsNavController.popBackStack() },
|
||||
onSuccess = { settingsNavController.popBackStack() }
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Routes.CREDENTIAL_EDIT,
|
||||
arguments = listOf(
|
||||
navArgument("type") { type = NavType.StringType },
|
||||
navArgument("account") {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
defaultValue = null
|
||||
}
|
||||
)
|
||||
) { entry ->
|
||||
val type = parseServiceType(entry.arguments?.getString("type"))
|
||||
val accountId = entry.arguments?.getString("account")
|
||||
CredentialEditScreen(
|
||||
service = type,
|
||||
accountId = accountId,
|
||||
onBack = { settingsNavController.popBackStack() },
|
||||
onStartWebViewLogin = { svc, acc ->
|
||||
settingsNavController.navigate(Routes.webviewLogin(svc, acc))
|
||||
},
|
||||
onWebViewLoginSuccess = { },
|
||||
onStartCodexOAuth = { settingsNavController.navigate(Routes.CODEX_OAUTH) }
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Routes.WEBVIEW_LOGIN,
|
||||
arguments = listOf(
|
||||
navArgument("type") { type = NavType.StringType },
|
||||
navArgument("account") {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
defaultValue = null
|
||||
}
|
||||
)
|
||||
) { entry ->
|
||||
val type = parseServiceType(entry.arguments?.getString("type"))
|
||||
val accountId = entry.arguments?.getString("account")
|
||||
WebViewLoginScreen(
|
||||
service = type,
|
||||
accountId = accountId,
|
||||
onBack = { settingsNavController.popBackStack() },
|
||||
onLoginSucceeded = { _, effectiveId ->
|
||||
if (!effectiveId.isNullOrBlank()) {
|
||||
settingsNavController.previousBackStackEntry?.savedStateHandle?.set(
|
||||
CredentialEditViewModel.KEY_LOGIN_ACCOUNT_ID, effectiveId
|
||||
)
|
||||
}
|
||||
settingsNavController.popBackStack()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,442 @@
|
||||
package com.rainy.token.ui.account
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Snackbar
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.draw.clip
|
||||
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.text.style.TextOverflow
|
||||
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.CredentialStatus
|
||||
import com.rainy.token.domain.service.ServiceConfigProvider
|
||||
import com.rainy.token.domain.service.ServiceType
|
||||
import com.rainy.token.ui.components.ServiceIcon
|
||||
import com.rainy.token.ui.components.formatAmount
|
||||
import com.rainy.token.ui.components.resolve
|
||||
import com.rainy.token.ui.theme.StrawberryPink
|
||||
import com.rainy.token.ui.theme.StatusGreen
|
||||
import com.rainy.token.ui.theme.StatusOrange
|
||||
import com.rainy.token.ui.theme.inkMuted
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* API 管理页(记事本式)。
|
||||
*
|
||||
* 心智模型像记事本:正文只有「已添加」的服务条目,右上角 **+** 从剩余服务里选一个
|
||||
* 添加(进入对应配置流程);未配置的服务不占卡片。条目点击即编辑,行内提供
|
||||
* 测试连接 / 删除(需确认),有官方充值/订阅页的服务额外给入口。
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ApiManagementScreen(
|
||||
onBack: () -> Unit,
|
||||
onConfigure: (ServiceType) -> Unit,
|
||||
onRecharge: (ServiceType) -> Unit,
|
||||
viewModel: ApiManagementViewModel = hiltViewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val message by viewModel.message.collectAsStateWithLifecycle()
|
||||
val testing by viewModel.testing.collectAsStateWithLifecycle()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val context = LocalContext.current
|
||||
var pendingDelete by remember { mutableStateOf<ServiceType?>(null) }
|
||||
var showAddPicker by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(message) {
|
||||
message?.let {
|
||||
snackbarHostState.showSnackbar(it.resolve(context))
|
||||
viewModel.clearMessage()
|
||||
}
|
||||
}
|
||||
|
||||
// 从凭据编辑页/WebView/充值页返回时刷新列表(新增/编辑/删除即时可见,覆盖 Compact 与 Expanded)
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
|
||||
viewModel.load()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.api_mgmt_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Icons.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.action_back)
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { showAddPicker = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Add,
|
||||
contentDescription = stringResource(R.string.api_mgmt_add),
|
||||
tint = StrawberryPink
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) { data -> Snackbar(snackbarData = data) } }
|
||||
) { innerPadding ->
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
if (uiState.rows.isEmpty()) {
|
||||
item { EmptyNotesHint(onAdd = { showAddPicker = true }) }
|
||||
}
|
||||
items(uiState.rows, key = { it.service.storageKey }) { row ->
|
||||
ApiRowCard(
|
||||
row = row,
|
||||
testing = row.service in testing,
|
||||
onEdit = { onConfigure(row.service) },
|
||||
onTest = { viewModel.test(row.service) },
|
||||
onDelete = { pendingDelete = row.service },
|
||||
onRecharge = { onRecharge(row.service) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showAddPicker) {
|
||||
AddServicePickerDialog(
|
||||
addable = uiState.addable,
|
||||
onPick = { service ->
|
||||
showAddPicker = false
|
||||
onConfigure(service)
|
||||
},
|
||||
onDismiss = { showAddPicker = false }
|
||||
)
|
||||
}
|
||||
|
||||
pendingDelete?.let { service ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { pendingDelete = null },
|
||||
title = { Text(stringResource(R.string.api_mgmt_delete_title)) },
|
||||
text = {
|
||||
Text(stringResource(R.string.api_mgmt_delete_body, service.displayName))
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
viewModel.delete(service)
|
||||
pendingDelete = null
|
||||
}
|
||||
) {
|
||||
Text(stringResource(R.string.action_delete), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { pendingDelete = null }) {
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 空态引导:还没有添加任何条目时展示,给出「+ 添加」动作。 */
|
||||
@Composable
|
||||
private fun EmptyNotesHint(onAdd: () -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.api_mgmt_empty_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.api_mgmt_empty_body),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = inkMuted()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Button(
|
||||
onClick = onAdd,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = StrawberryPink,
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Icon(imageVector = Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(stringResource(R.string.api_mgmt_add))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 「+ 添加」服务选择弹窗:列出尚未添加的服务,点选即进入配置流程。 */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun AddServicePickerDialog(
|
||||
addable: List<ServiceType>,
|
||||
onPick: (ServiceType) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.api_mgmt_add_picker_title)) },
|
||||
text = {
|
||||
if (addable.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.api_mgmt_all_added),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = inkMuted()
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.heightIn(max = 320.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
items(addable, key = { it.storageKey }) { service ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.clickable { onPick(service) }
|
||||
.padding(vertical = 8.dp, horizontal = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
ServiceIcon(service = service, size = 36)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = service.displayName,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Text(
|
||||
text = stringResource(serviceDescriptionRes(service)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = inkMuted()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/** 添加弹窗里的服务一句话说明(与首页卡片副标题同源,保持口径一致)。 */
|
||||
@Composable
|
||||
private fun serviceDescriptionRes(service: ServiceType): Int = when (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
|
||||
ServiceType.TRAE -> R.string.service_desc_trae
|
||||
ServiceType.WORKBUDDY -> R.string.service_desc_workbuddy
|
||||
ServiceType.SUB2API -> R.string.service_desc_sub2api
|
||||
}
|
||||
|
||||
/** 已添加条目卡:整卡点击进编辑,行内给测试 / 删除,有官方充值页的服务附充值按钮。 */
|
||||
@Composable
|
||||
private fun ApiRowCard(
|
||||
row: ApiRowUi,
|
||||
testing: Boolean,
|
||||
onEdit: () -> Unit,
|
||||
onTest: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onRecharge: () -> Unit
|
||||
) {
|
||||
// 有官方充值/订阅页的服务(ServiceConfig.externalUrl)才展示充值入口
|
||||
val rechargeUrl = remember(row.service) {
|
||||
runCatching { ServiceConfigProvider.get(row.service).externalUrl }.getOrNull()
|
||||
}
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onEdit() },
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
ServiceIcon(service = row.service, size = 44)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = row.service.displayName,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Text(
|
||||
text = if (row.isMultiAccountRow) {
|
||||
stringResource(R.string.api_mgmt_accounts_subtitle, row.accountCount)
|
||||
} else {
|
||||
row.maskedCredential ?: stringResource(R.string.api_mgmt_unconfigured)
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = inkMuted()
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
StatusDot(state = row.state)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// 最后验证时间 / 缓存余额
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(R.string.api_mgmt_last_verified, formatVerified(row.lastVerifiedAt)),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = inkMuted()
|
||||
)
|
||||
row.cachedBalance?.let { cached ->
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.api_mgmt_cached_balance,
|
||||
formatAmount(cached.balance.amount),
|
||||
cached.balance.unit
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
// 测试连接(进行中转圈)+ 删除;多账号服务行内不提供(避免只对默认账号操作 / 误删全部账号)
|
||||
if (!row.isMultiAccountRow) {
|
||||
if (testing) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
color = StrawberryPink,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
TextButton(onClick = onTest) {
|
||||
Text(
|
||||
stringResource(R.string.api_mgmt_test),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Delete,
|
||||
contentDescription = stringResource(R.string.action_delete),
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
if (rechargeUrl != null) {
|
||||
TextButton(onClick = onRecharge) {
|
||||
Text(
|
||||
stringResource(R.string.action_recharge),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusDot(state: CredentialStatus.State) {
|
||||
val color = when (state) {
|
||||
CredentialStatus.State.OK -> StatusGreen
|
||||
CredentialStatus.State.WARNING -> StatusOrange
|
||||
CredentialStatus.State.EXPIRED -> MaterialTheme.colorScheme.error
|
||||
CredentialStatus.State.NOT_CONFIGURED -> inkMuted().copy(alpha = 0.5f)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(10.dp)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(color = color)
|
||||
)
|
||||
}
|
||||
|
||||
private fun formatVerified(ts: Long): String {
|
||||
if (ts <= 0L) return "—"
|
||||
return runCatching {
|
||||
SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()).format(Date(ts))
|
||||
}.getOrDefault("—")
|
||||
}
|
||||
@ -0,0 +1,174 @@
|
||||
package com.rainy.token.ui.account
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.rainy.token.data.cache.CachedBalance
|
||||
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.model.CredentialStatus
|
||||
import com.rainy.token.domain.service.ServiceType
|
||||
import com.rainy.token.domain.usecase.RefreshBalanceUseCase
|
||||
import com.rainy.token.ui.components.UiText
|
||||
import com.rainy.token.ui.components.formatAmount
|
||||
import com.rainy.token.R
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* API 管理页 ViewModel(记事本式)。
|
||||
*
|
||||
* 列表只展示「已添加」的凭据条目(Key 掩码、最后验证、缓存余额),
|
||||
* 未配置的服务不占位;右上角 + 从 [addable] 里选服务新增。
|
||||
* 提供测试连接 / 删除操作,编辑走既有 credential_edit 页面。
|
||||
*/
|
||||
@HiltViewModel
|
||||
class ApiManagementViewModel @Inject constructor(
|
||||
private val credentialRepository: CredentialRepository,
|
||||
private val refreshBalanceUseCase: RefreshBalanceUseCase
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(ApiManagementUiState())
|
||||
val uiState: StateFlow<ApiManagementUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val _message = MutableStateFlow<UiText?>(null)
|
||||
val message: StateFlow<UiText?> = _message
|
||||
|
||||
/** 正在测试连接的服务集合(支持并发多测,各自独立转圈)。 */
|
||||
private val _testing = MutableStateFlow<Set<ServiceType>>(emptySet())
|
||||
val testing: StateFlow<Set<ServiceType>> = _testing
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
val states = credentialRepository.readLocalStates()
|
||||
val all = ServiceType.entries.map { type ->
|
||||
val local = states.getValue(type)
|
||||
val cred = credentialRepository.get(type)
|
||||
ApiRowUi(
|
||||
service = type,
|
||||
state = local.status.state,
|
||||
lastVerifiedAt = local.status.lastVerifiedAt,
|
||||
cachedBalance = local.cachedBalance,
|
||||
maskedCredential = maskCredential(cred),
|
||||
accountCount = if (ServiceType.isMultiAccount(type)) {
|
||||
credentialRepository.accountsFor(type).size
|
||||
} else {
|
||||
0
|
||||
}
|
||||
)
|
||||
}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
// 记事本式:列表只放已添加的条目,未配置的服务收进「+」选择列表
|
||||
rows = all.filter { row -> row.isConfigured },
|
||||
addable = all.filterNot { row -> row.isConfigured }.map { row -> row.service }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun delete(service: ServiceType) {
|
||||
viewModelScope.launch {
|
||||
credentialRepository.remove(service)
|
||||
_message.value = UiText.Resource(R.string.msg_credential_deleted)
|
||||
load()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试连接:拉一次余额并回应用户;成功/失败只透传摘要(本地化),不改动凭据之外的东西。
|
||||
*/
|
||||
fun test(service: ServiceType) {
|
||||
_testing.update { it + service }
|
||||
viewModelScope.launch {
|
||||
val text: UiText = refreshBalanceUseCase(service).fold(
|
||||
onSuccess = { bal ->
|
||||
UiText.Resource(
|
||||
R.string.api_mgmt_test_success,
|
||||
listOf(service.displayName, formatAmount(bal.amount), bal.unit)
|
||||
)
|
||||
},
|
||||
onFailure = { err ->
|
||||
// errorSummary 返回 UiText,resolve 会把嵌套 UiText 参数扁平化本地化
|
||||
UiText.Resource(
|
||||
R.string.api_mgmt_test_fail,
|
||||
listOf(service.displayName, errorSummary(err))
|
||||
)
|
||||
}
|
||||
)
|
||||
_testing.update { it - service }
|
||||
_message.value = text
|
||||
load()
|
||||
}
|
||||
}
|
||||
|
||||
fun clearMessage() {
|
||||
_message.value = null
|
||||
}
|
||||
|
||||
/** 把凭据的核心值打成掩码(前1后1/中间星号);无凭据返回 null。 */
|
||||
private fun maskCredential(credential: Credential?): String? = when (credential) {
|
||||
null -> null
|
||||
is Credential.ApiKeyCredential -> mask(credential.key)
|
||||
is Credential.SessionCredential -> {
|
||||
credential.apiKey?.let { mask(it) }
|
||||
?: credential.token?.let { mask(it) }
|
||||
?: credential.authCookie?.let { mask(it) }
|
||||
?: credential.cookies.firstOrNull()?.let { mask("${it.name}=${it.value}") }
|
||||
?: credential.ollamaCookie?.take(8)?.let { mask(it) }
|
||||
}
|
||||
is Credential.CodexCredential -> mask(credential.accessToken)
|
||||
is Credential.TraeCredential -> mask(credential.jwt)
|
||||
is Credential.WorkBuddyCredential -> mask(credential.accessToken)
|
||||
// Sub2API:显示实例基址作为身份标识(非机密),便于区分多个自托管实例;
|
||||
// 真正的机密(password/apiKey)不做掩码展示。
|
||||
is Credential.Sub2ApiCredential -> credential.baseUrl
|
||||
}
|
||||
|
||||
private fun mask(value: String): String {
|
||||
if (value.length <= 8) return "••••"
|
||||
return "${value.take(4)}••••${value.takeLast(4)}"
|
||||
}
|
||||
|
||||
private fun errorSummary(error: Throwable?): UiText = when (error) {
|
||||
is RepositoryError.SessionExpired -> UiText.Resource(R.string.error_credential_session_expired)
|
||||
is RepositoryError.InvalidCredential -> UiText.Resource(R.string.api_mgmt_err_invalid)
|
||||
is RepositoryError.CredentialChanged -> UiText.Resource(R.string.api_mgmt_err_changed)
|
||||
is RepositoryError.RateLimited -> UiText.Resource(R.string.api_mgmt_err_ratelimited)
|
||||
is RepositoryError.Network -> UiText.Resource(R.string.api_mgmt_err_network)
|
||||
is RepositoryError.ServerError -> UiText.Resource(R.string.api_mgmt_err_server, listOf(error.code))
|
||||
is RepositoryError.ParseError -> UiText.Resource(R.string.api_mgmt_err_parse)
|
||||
else -> UiText.Resource(R.string.common_unknown)
|
||||
}
|
||||
}
|
||||
|
||||
data class ApiRowUi(
|
||||
val service: ServiceType,
|
||||
val state: CredentialStatus.State,
|
||||
val lastVerifiedAt: Long,
|
||||
val cachedBalance: CachedBalance?,
|
||||
val maskedCredential: String?,
|
||||
/** 多账号服务的账号个数;单账号服务为 0。 */
|
||||
val accountCount: Int = 0
|
||||
) {
|
||||
val isConfigured: Boolean get() = state != CredentialStatus.State.NOT_CONFIGURED
|
||||
|
||||
/** 多账号服务且已有账号:行内不提供单服务的测试/删除,引导去账号管理页。 */
|
||||
val isMultiAccountRow: Boolean get() = ServiceType.isMultiAccount(service) && accountCount > 0
|
||||
}
|
||||
|
||||
data class ApiManagementUiState(
|
||||
/** 已添加的凭据条目(记事本正文)。 */
|
||||
val rows: List<ApiRowUi> = emptyList(),
|
||||
/** 尚未添加的服务(右上角 + 的可选列表)。 */
|
||||
val addable: List<ServiceType> = emptyList()
|
||||
)
|
||||
151
app/src/main/java/com/rainy/token/ui/components/AppTips.kt
Normal file
151
app/src/main/java/com/rainy/token/ui/components/AppTips.kt
Normal file
@ -0,0 +1,151 @@
|
||||
package com.rainy.token.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
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(
|
||||
@StringRes val titleRes: Int,
|
||||
@StringRes val hintRes: Int,
|
||||
@StringRes val detailRes: Int
|
||||
)
|
||||
|
||||
val tips: List<Tip> = listOf(
|
||||
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),
|
||||
)
|
||||
|
||||
/** 首页轮换用的一句话文案(资源 ID 形式)。 */
|
||||
val shortHintRes: List<Int> = tips.map { it.hintRes }
|
||||
|
||||
/** 随机取一条短文案的资源 ID。 */
|
||||
fun randomHintRes(): Int = shortHintRes.random()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TipsScreen(onBack: () -> Unit) {
|
||||
Scaffold(
|
||||
containerColor = Color.Transparent,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
stringResource(R.string.title_tips),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Icons.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.action_back),
|
||||
tint = StrawberryPink
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = Color.Transparent
|
||||
)
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
itemsIndexed(AppTips.tips) { idx, tip ->
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "${idx + 1}",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = StrawberryPink
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringResource(tip.titleRes),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Text(
|
||||
text = stringResource(tip.detailRes),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = InkMuted
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,154 @@
|
||||
package com.rainy.token.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
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.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
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.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.StatusOrange
|
||||
import com.rainy.token.ui.theme.StatusRed
|
||||
import com.rainy.token.ui.theme.StrawberryPink
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DebugLogScreen(onBack: () -> Unit) {
|
||||
val entries by DebugLog.entries.collectAsStateWithLifecycle()
|
||||
|
||||
Scaffold(
|
||||
containerColor = Color.Transparent,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
stringResource(R.string.title_debug_log),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Icons.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.action_back),
|
||||
tint = StrawberryPink
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
TextButton(onClick = { DebugLog.clear() }) {
|
||||
Text(stringResource(R.string.action_clear), color = StrawberryPink)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = Color.Transparent
|
||||
)
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
if (entries.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.debug_log_empty),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = InkMuted,
|
||||
modifier = Modifier.padding(32.dp)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.padding(horizontal = 16.dp),
|
||||
contentPadding = PaddingValues(vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
items(entries, key = { it.timestamp.toString() + it.tag + it.message }) { entry ->
|
||||
val levelColor = when (entry.level) {
|
||||
DebugLog.Level.INFO -> InkMuted
|
||||
DebugLog.Level.WARN -> StatusOrange
|
||||
DebugLog.Level.ERROR -> StatusRed
|
||||
}
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = entry.level.label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = levelColor
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = entry.tag,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = InkMuted
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
text = entry.toString().substringBefore(' '),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = InkMuted.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.padding(top = 4.dp))
|
||||
Text(
|
||||
text = entry.message,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,84 @@
|
||||
package com.rainy.token.ui.components
|
||||
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* 共享格式化工具函数。
|
||||
*
|
||||
* 从 DashboardScreen / ServiceDetailScreen / WidgetProvider 提取,消除三处重复。
|
||||
*
|
||||
* 本地化策略:函数保持纯 JVM(不依赖 Context / Resources,可被单元测试直接调用),
|
||||
* 本地化标签通过 [DurationText] / 字符串参数注入。调用方(Compose / Widget)用
|
||||
* `context.getString(...)` 取得当前语言标签后传入;测试继续使用默认中文标签。
|
||||
*/
|
||||
|
||||
/** 时长标签(用于 "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, 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 ${text.day} $hours ${text.hour}"
|
||||
hours > 0 -> "$hours ${text.hour} $minutes ${text.minute}"
|
||||
else -> "$minutes ${text.minute}"
|
||||
}
|
||||
}
|
||||
|
||||
/** Widget 版本:逻辑相同但空值返回空串(非 "—"),适配 RemoteViews 布局 */
|
||||
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 ${text.day} $hours ${text.hour}"
|
||||
hours > 0 -> "$hours ${text.hour} $minutes ${text.minute}"
|
||||
else -> "$minutes ${text.minute}"
|
||||
}
|
||||
}
|
||||
|
||||
/** 标准化窗口标签:weekly/每周 → 每周标签,monthly/每月 → 每月标签,usage/用量 → 用量标签,其他原样返回 */
|
||||
fun normalizeWindowLabel(
|
||||
label: String,
|
||||
weeklyLabel: String = "Weekly",
|
||||
monthlyLabel: String = "Monthly",
|
||||
usageLabel: String = "Usage"
|
||||
): String = when (label.lowercase()) {
|
||||
"weekly", "每周" -> weeklyLabel
|
||||
"monthly", "每月" -> monthlyLabel
|
||||
"usage", "用量" -> usageLabel
|
||||
else -> label
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断窗口标签是否为 5h 窗口(与语言无关):
|
||||
* - 匹配英文缩写(5h/5H)
|
||||
* - 匹配本地化标签(如 "5小时"/"5 小时"/"5小時")
|
||||
* - 兼容历史中文数据(含 "5" 且含 "小时")
|
||||
*/
|
||||
fun isFiveHourLabel(label: String, fiveHourLabel: String, fiveHourShort: String): Boolean {
|
||||
val normalized = label.trim()
|
||||
return normalized.equals(fiveHourShort, ignoreCase = true) ||
|
||||
normalized == fiveHourLabel ||
|
||||
(normalized.contains("5") && normalized.contains("小时")) ||
|
||||
(normalized.contains("5") && normalized.contains("小時"))
|
||||
}
|
||||
@ -13,10 +13,10 @@ import com.rainy.token.ui.theme.DarkSurface
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
|
||||
/**
|
||||
* 雨晴风格全局背景。
|
||||
* 雨晴风格全局背景(精致粉调)。
|
||||
*
|
||||
* Light 模式:樱粉渐变(#FFF0F5 → #FFD1DC,从左上到右下)
|
||||
* Dark 模式:暖深渐变(#1F1419 → #2A1F25)
|
||||
* Light 模式:低饱和粉白渐变(#FFF9FB → #FDEFF4,从左上到右下)
|
||||
* Dark 模式:暖深渐变(#1A1417 → #251C21)
|
||||
*
|
||||
* 放在 Scaffold 容器层,所有页面共享同一层渐变。
|
||||
*/
|
||||
|
||||
@ -4,6 +4,7 @@ import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
@ -12,8 +13,10 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.material3.Text
|
||||
import com.rainy.token.R
|
||||
import com.rainy.token.domain.service.ServiceType
|
||||
|
||||
@ -41,7 +44,7 @@ fun ServiceIcon(
|
||||
modifier = modifier
|
||||
.size(size.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(if (isSystemInDarkTheme()) Color(0xFF352329) else Color.White),
|
||||
.background(if (isSystemInDarkTheme()) Color(0xFF32262C) else Color.White),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Image(
|
||||
@ -51,20 +54,135 @@ fun ServiceIcon(
|
||||
)
|
||||
}
|
||||
}
|
||||
ServiceType.COMMANDCODE_GO -> {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(size.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(if (isSystemInDarkTheme()) Color(0xFF32262C) else Color.White),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.ic_opencode_go_logo),
|
||||
contentDescription = "CommandCode",
|
||||
modifier = Modifier.size((size * 0.85).dp, ((size * 0.85f * 30f / 54f)).dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
ServiceType.DEEPSEEK -> {
|
||||
// DeepSeek 官方无开放 logo 资源 —— 用蓝鲸 emoji 占位
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(size.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(Color.White),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.ic_deepseek_logo),
|
||||
contentDescription = "DeepSeek",
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
ServiceType.CODEX -> {
|
||||
// ChatGPT 官方 favicon logo
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(size.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(Color(0xFF4A6CF7)),
|
||||
.background(if (isSystemInDarkTheme()) Color(0xFF2D3748) else Color(0xFF10A37F)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
androidx.compose.material3.Text(
|
||||
text = "🐋",
|
||||
fontSize = (size * 0.5).sp
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.ic_codex_logo),
|
||||
contentDescription = "Codex / ChatGPT",
|
||||
modifier = Modifier
|
||||
.size((size * 0.6).dp),
|
||||
colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(
|
||||
if (isSystemInDarkTheme()) Color.White else Color.White
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
ServiceType.OLLAMA -> {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(size.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(if (isSystemInDarkTheme()) Color(0xFFF5F5F5) else Color.White),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.ic_ollama_logo),
|
||||
contentDescription = "Ollama",
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
ServiceType.TRAE -> {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(size.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(if (isSystemInDarkTheme()) Color(0xFF20242E) else Color.White),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.ic_trae_logo),
|
||||
contentDescription = "Trae",
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
ServiceType.WORKBUDDY -> {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(size.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(Color.White),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.ic_workbuddy_logo),
|
||||
contentDescription = "WorkBuddy",
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
ServiceType.SUB2API -> {
|
||||
LetterIcon(
|
||||
letter = "S",
|
||||
background = Color(0xFF6C5CE7),
|
||||
modifier = modifier,
|
||||
size = size
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 品牌色字母占位图标。正式 logo(Trae / WorkBuddy / Sub2API)落地后
|
||||
* 在此替换为 drawable 分支(同 DeepSeek 模式)。
|
||||
*/
|
||||
@Composable
|
||||
private fun LetterIcon(
|
||||
letter: String,
|
||||
background: Color,
|
||||
modifier: Modifier,
|
||||
size: Int
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(size.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(background),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = letter,
|
||||
color = Color.White,
|
||||
fontSize = (size * 0.5f).sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
65
app/src/main/java/com/rainy/token/ui/components/SiteIcon.kt
Normal file
65
app/src/main/java/com/rainy/token/ui/components/SiteIcon.kt
Normal file
@ -0,0 +1,65 @@
|
||||
package com.rainy.token.ui.components
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import com.caverock.androidsvg.SVG
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
|
||||
/**
|
||||
* 轻量站点图标加载(OkHttp 拉取,无三方图片库)。
|
||||
* 支持 PNG/JPEG/WebP(BitmapFactory)与 SVG(androidsvg 渲染);失败静默不渲染。
|
||||
* 用于 Sub2API 凭据页站点预览与主页卡片 Logo。
|
||||
*/
|
||||
@Composable
|
||||
fun SiteIcon(url: String, size: Dp) {
|
||||
val icon by produceState<ImageBitmap?>(initialValue = null, url) {
|
||||
// 网络 + SVG 解码放到 IO 线程:produceState 默认在 UI 调度器执行,
|
||||
// 直接在生产者里阻塞请求会造成主页卡片掉帧 / StrictMode 告警。
|
||||
value = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
if (!url.startsWith("http")) return@runCatching null
|
||||
val resp = OkHttpClient.Builder()
|
||||
.build()
|
||||
.newCall(Request.Builder().url(url).build())
|
||||
.execute()
|
||||
resp.use {
|
||||
val bytes = it.body?.bytes() ?: return@runCatching null
|
||||
decodeIcon(bytes)
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
val bitmap = icon ?: return
|
||||
Image(
|
||||
painter = BitmapPainter(bitmap),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(size)
|
||||
)
|
||||
}
|
||||
|
||||
/** 位图解码:先按 PNG/JPEG 解,失败再按 SVG 渲染(BitmapFactory 不支持 SVG)。 */
|
||||
private fun decodeIcon(bytes: ByteArray): ImageBitmap? {
|
||||
val raster = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||
if (raster != null) return raster.asImageBitmap()
|
||||
return runCatching {
|
||||
val svg = SVG.getFromString(String(bytes, Charsets.UTF_8))
|
||||
val w = svg.documentWidth.toInt().coerceIn(16, 1024)
|
||||
val h = svg.documentHeight.toInt().coerceIn(16, 1024)
|
||||
val bmp = android.graphics.Bitmap.createBitmap(w, h, android.graphics.Bitmap.Config.ARGB_8888)
|
||||
val c = android.graphics.Canvas(bmp)
|
||||
svg.renderToCanvas(c)
|
||||
bmp.asImageBitmap()
|
||||
}.getOrNull()
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
package com.rainy.token.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@ -9,6 +10,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 +18,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 +36,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
|
||||
)
|
||||
|
||||
@ -42,11 +45,14 @@ fun StatusChip(
|
||||
style: StatusStyle,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
// 暗色模式下胶囊底色是状态色 12% alpha 叠加暗色卡片,
|
||||
// 深色文字几乎不可读,故暗色使用同色系亮色文字。
|
||||
val dark = isSystemInDarkTheme()
|
||||
val (dotColor, textColor) = when (style.level) {
|
||||
StatusLevel.OK -> StatusGreen to Color(0xFF1B5E20)
|
||||
StatusLevel.WARNING -> StatusOrange to Color(0xFF8A4A00)
|
||||
StatusLevel.ERROR -> StatusRed to Color(0xFF8B0033)
|
||||
StatusLevel.INFO -> StatusBlue to Color(0xFF0D47A1)
|
||||
StatusLevel.OK -> StatusGreen to if (dark) Color(0xFF9CE8BC) else Color(0xFF1B5E20)
|
||||
StatusLevel.WARNING -> StatusOrange to if (dark) Color(0xFFFFDBA6) else Color(0xFF8A4A00)
|
||||
StatusLevel.ERROR -> StatusRed to if (dark) Color(0xFFFFB6CA) else Color(0xFF8A1F40)
|
||||
StatusLevel.INFO -> StatusBlue to if (dark) Color(0xFFAED7FF) else Color(0xFF0D47A1)
|
||||
}
|
||||
val bgColor = dotColor.copy(alpha = 0.12f)
|
||||
Row(
|
||||
@ -64,7 +70,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
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
package com.rainy.token.ui.components
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
|
||||
import androidx.compose.material3.windowsizeclass.WindowSizeClass
|
||||
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
/**
|
||||
* 获取当前窗口大小等级,用于自适应布局(手机/平板/折叠屏)。
|
||||
*
|
||||
* 在各 Screen Composable 顶部调用:
|
||||
* ```
|
||||
* val windowSize = rememberWindowSizeClass()
|
||||
* when (windowSize.widthSizeClass) {
|
||||
* WindowWidthSizeClass.Compact → // 手机竖屏(宽度 < 600dp)
|
||||
* WindowWidthSizeClass.Medium → // 小平板/折叠屏展开(600-840dp)
|
||||
* WindowWidthSizeClass.Expanded → // 大平板横屏(> 840dp)
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
|
||||
@Composable
|
||||
fun rememberWindowSizeClass(): WindowSizeClass {
|
||||
val activity = LocalContext.current as Activity
|
||||
return calculateWindowSizeClass(activity)
|
||||
}
|
||||
@ -0,0 +1,211 @@
|
||||
package com.rainy.token.ui.dashboard
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
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
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* CommandCode 用量统计主卡片 —— 风格与 UsageStatsCard(OCGO)完全一致。
|
||||
* 仅展示核心指标:输入 Token(含 Cache)、输出 Token、总花费,
|
||||
* 外加同步按钮和"查看详情"入口。详细统计在 CCGO 专属详情页。
|
||||
*/
|
||||
@Composable
|
||||
fun CommandCodeUsageStatsCard(
|
||||
onOpenDetail: () -> Unit,
|
||||
refreshTrigger: Int = 0
|
||||
) {
|
||||
val wid = com.rainy.token.data.repository.CommandCodeUsageRepository.CCGO_WORKSPACE_ID
|
||||
val key = "ccgo_$wid"
|
||||
val viewModel: UsageViewModel = hiltViewModel(key = key)
|
||||
// 初始化 workspace,后续刷新自动走 CCGO 的 sync use case
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.setWorkspace(wid)
|
||||
}
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
// 全局刷新触发用量同步(跳过首次 0)
|
||||
LaunchedEffect(refreshTrigger) {
|
||||
if (refreshTrigger > 0) viewModel.sync()
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
// ─── 头部:标题 + 同步按钮 ───
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(R.string.usage_title_commandcode),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Text(
|
||||
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
|
||||
)
|
||||
}
|
||||
if (uiState.syncing) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.padding(4.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = StrawberryPink
|
||||
)
|
||||
} else {
|
||||
IconButton(onClick = { viewModel.sync() }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Refresh,
|
||||
contentDescription = stringResource(R.string.action_sync_usage),
|
||||
tint = StrawberryPink
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 核心指标 ───
|
||||
uiState.overview?.let { overview ->
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// 输入 Token = input + cacheRead
|
||||
val inputTotal = overview.inputTokens + overview.cacheReadTokens
|
||||
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(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,
|
||||
color = InkMuted,
|
||||
modifier = Modifier.padding(start = 4.dp, top = 2.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
// 输出 Token + 总花费 同一排
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 空状态 ───
|
||||
if (uiState.overview == null && !uiState.syncing) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = if (uiState.loading) stringResource(R.string.common_loading) else stringResource(R.string.usage_no_data_sync),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = InkMuted
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 查看详情 ───
|
||||
if (uiState.overview != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
TextButton(onClick = onOpenDetail) {
|
||||
Text(stringResource(R.string.action_view_detail), color = StrawberryPink)
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = StrawberryPink,
|
||||
modifier = Modifier.padding(top = 1.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 同步结果反馈 ───
|
||||
if (uiState.lastSyncResult > 0) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.usage_new_records, uiState.lastSyncResult),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = StrawberryPink
|
||||
)
|
||||
}
|
||||
uiState.lastSyncError?.let { err ->
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "✗ ${err.asString()}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 刷新时间(与其余服务卡片底部一致) ───
|
||||
if (uiState.lastSyncAt > 0) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = usageUpdatedAtText(uiState.lastSyncAt),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = InkMuted
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MetricRow(label: String, value: String, modifier: Modifier = Modifier) {
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = InkMuted
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = StrawberryPink
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatTokenCount(tokens: Long): String {
|
||||
return when {
|
||||
tokens >= 1_000_000 -> "${"%.1f".format(Locale.US, tokens / 1_000_000.0)}M"
|
||||
tokens >= 1_000 -> "${"%.1f".format(Locale.US, tokens / 1_000.0)}K"
|
||||
else -> "$tokens"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -3,16 +3,22 @@ package com.rainy.token.ui.dashboard
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.rainy.token.data.cache.BalanceCache
|
||||
import com.rainy.token.data.cache.CachedBalance
|
||||
import com.rainy.token.data.repository.CredentialRepository
|
||||
import com.rainy.token.data.repository.RepositoryError
|
||||
import com.rainy.token.domain.model.CredentialStatus
|
||||
import com.rainy.token.domain.model.Credential
|
||||
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.dashboard.autocheckin.CHECKIN_SERVICES
|
||||
import com.rainy.token.ui.widget.OpenCodeGoWidgetProvider
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
@ -21,86 +27,417 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
|
||||
/**
|
||||
* 仪表盘 ViewModel。
|
||||
*
|
||||
* 状态聚合:凭据状态 + 余额缓存 + 在线刷新
|
||||
* - 启动时读缓存展示(无网时也能看)
|
||||
* - 启动时读缓存展示(无网时也能看旧数据)
|
||||
* - refresh() 并行拉取所有服务的最新余额(任一失败不影响其他)
|
||||
* - 下拉刷新触发同一 refresh()
|
||||
*/
|
||||
@HiltViewModel
|
||||
class DashboardViewModel @Inject constructor(
|
||||
private val credentialRepository: CredentialRepository,
|
||||
private val balanceCache: BalanceCache,
|
||||
private val refreshBalanceUseCase: RefreshBalanceUseCase,
|
||||
@param:ApplicationContext private val appContext: Context
|
||||
private val checkinStatusStore: com.rainy.token.data.repository.CheckinStatusStore,
|
||||
private val autoCheckinManager: com.rainy.token.ui.dashboard.autocheckin.AutoCheckinManager,
|
||||
@param:ApplicationContext private val appContext: Context,
|
||||
private val sub2ApiRepository: com.rainy.token.data.repository.Sub2ApiRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(DashboardUiState())
|
||||
private val _uiState = MutableStateFlow(DashboardUiState(loading = true))
|
||||
val uiState: StateFlow<DashboardUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
loadFromCache()
|
||||
refresh()
|
||||
}
|
||||
/** 防止并发 refresh() 调用交错覆盖 UI。 */
|
||||
private val refreshMutex = Mutex()
|
||||
|
||||
/** 从本地缓存快速填充一次(不阻塞) */
|
||||
private fun loadFromCache() {
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
val cached = balanceCache.getAll()
|
||||
val cards = ServiceType.entries.map { type ->
|
||||
buildCard(type, cachedBalance = cached[type], lastFetchError = null)
|
||||
// 1) 先从缓存快速填充 UI(不阻塞首帧,断网时也能看旧数据)
|
||||
loadFromCache()
|
||||
// 2) 每日自动签到:必须先于 refresh() 串行完成。两者并发会对同一服务取
|
||||
// 相同凭据快照,后提交者因快照过期被丢弃(CredentialChanged),而签到
|
||||
// 在服务端已成功、角标也已落库 → 弹窗报「凭据」错误但积分实际到账,
|
||||
// 展示自相矛盾。串行后签到先落库,refresh 再拉到含新积分的余额。
|
||||
// 用 try/catch 而非 runCatching:后者会吞掉 CancellationException,
|
||||
// 导致 ViewModel 销毁取消后协程仍继续往下跑。
|
||||
// 2.5) 打开 App 时把零点后台闹钟重排到下次零点(开机/升级/后台被杀后的兜底)
|
||||
autoCheckinManager.refreshSchedule()
|
||||
try {
|
||||
autoCheckinManager.runDailyIfDue()
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} catch (_: Exception) {
|
||||
// 签到失败不阻塞余额刷新
|
||||
}
|
||||
_uiState.update { it.copy(loading = false, refreshing = false, cards = cards) }
|
||||
// 3) 发起网络刷新——此时 UI 已有缓存兜底,不会闪回旧值
|
||||
refresh()
|
||||
// 4) 同步签到状态到本地记录并重建角标(跨设备/旧版本签过的也能显示)
|
||||
syncCheckinStatus()
|
||||
}
|
||||
}
|
||||
|
||||
/** 拉取所有服务最新余额,更新缓存。失败的服务保留旧数据并把错误信息带上 */
|
||||
/**
|
||||
* 首页自己查询签到状态(不依赖进详情页触发):
|
||||
* 本地无当日记录的账号问一次服务端 status,已签的回写 [CheckinStatusStore] 后重建卡片角标。
|
||||
*/
|
||||
private suspend fun syncCheckinStatus() {
|
||||
val accountStates = credentialRepository.readAccountStates()
|
||||
val toQuery = accountStates.filter { entry ->
|
||||
entry.service in CHECKIN_SERVICES &&
|
||||
entry.credential != null &&
|
||||
!checkinStatusStore.isCheckedToday(entry.service, entry.account?.id)
|
||||
}
|
||||
if (toQuery.isEmpty()) return
|
||||
val newlyChecked = toQuery.filter { entry ->
|
||||
runCatching {
|
||||
when (entry.service) {
|
||||
ServiceType.TRAE ->
|
||||
refreshBalanceUseCase.fetchTraeCheckinStatus(entry.account?.id).getOrNull() == true
|
||||
ServiceType.WORKBUDDY ->
|
||||
refreshBalanceUseCase.fetchCheckinStatus(entry.account?.id).getOrNull()?.todayCheckedIn == true
|
||||
else -> false
|
||||
}
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
if (newlyChecked.isEmpty()) return
|
||||
newlyChecked.forEach { checkinStatusStore.markChecked(it.service, it.account?.id) }
|
||||
// 用最新记录更新卡片角标:按 store 真值重算(已有记录的账号不被误清)
|
||||
_uiState.update { state ->
|
||||
state.copy(cards = state.cards.map { card ->
|
||||
card.copy(
|
||||
checkedInToday = card.service in CHECKIN_SERVICES &&
|
||||
checkinStatusStore.isCheckedToday(card.service, card.accountId)
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** 自动签到结果(非 null 时首页弹「xx 已签到」汇报弹窗;关闭后置 null)。 */
|
||||
val autoCheckinReport: StateFlow<List<com.rainy.token.ui.dashboard.autocheckin.AutoCheckinManager.ServiceResult>?> =
|
||||
autoCheckinManager.report
|
||||
|
||||
/** 自动签到开关状态(首页顶部提示行右侧 Switch)。 */
|
||||
private val _autoCheckinEnabled = MutableStateFlow(autoCheckinManager.enabled)
|
||||
val autoCheckinEnabled: StateFlow<Boolean> = _autoCheckinEnabled.asStateFlow()
|
||||
|
||||
fun setAutoCheckinEnabled(value: Boolean) {
|
||||
autoCheckinManager.enabled = value
|
||||
_autoCheckinEnabled.value = value
|
||||
}
|
||||
|
||||
fun consumeAutoCheckinReport() = autoCheckinManager.consumeReport()
|
||||
|
||||
/**
|
||||
* 重新读取本地凭据状态 + 余额缓存,不自动发起网络请求(除非发现已配置
|
||||
* 但没有余额缓存的"新账号",此时拉一次余额让首页立刻有数据,不用等进详情页)。
|
||||
*
|
||||
* 卡片记录不含明文密钥的 SHA-256 指纹。更新时始终基于 _uiState 的最新卡片:
|
||||
* - 以当前已配置服务(configuredTypes)为准重建,新增服务即时出现、删除服务即时消失;
|
||||
* - 仍存在的服务复用旧卡片做增量合并:
|
||||
* - 凭据变化时采用已清理过的当前账户缓存;
|
||||
* - 凭据未变时按 fetchedAt 采用更新缓存,支持 Widget 后台刷新结果回显;
|
||||
* - 不修改 refreshing,旧缓存快照也不能回滚新数据。
|
||||
*/
|
||||
fun reloadLocalState() {
|
||||
viewModelScope.launch {
|
||||
val accountStates = credentialRepository.readAccountStates()
|
||||
_uiState.update { state ->
|
||||
state.copy(cards = rebuildCardsPreservingOrder(state, accountStates))
|
||||
}
|
||||
// 登录/导入后刚出现的账号还没有余额缓存:立即拉一次,首页即刻看到
|
||||
// 余额与角标,不用等用户点进详情页才触发刷新(刷新内部有 Mutex 防并发)。
|
||||
val hasFreshAccountWithoutCache = accountStates.any { entry ->
|
||||
entry.status.state != CredentialStatus.State.NOT_CONFIGURED &&
|
||||
entry.cachedBalance == null
|
||||
}
|
||||
if (hasFreshAccountWithoutCache) refresh()
|
||||
}
|
||||
}
|
||||
|
||||
/** 从本地缓存快速填充一次(不阻塞)。挂起函数,供调用方控制执行顺序。 */
|
||||
private suspend fun loadFromCache() {
|
||||
val accountStates = credentialRepository.readAccountStates()
|
||||
val entries = orderAccountEntries(accountStates)
|
||||
val cards = entries.map { entry ->
|
||||
buildCard(entry, lastFetchError = null)
|
||||
}
|
||||
_uiState.update { it.copy(loading = false, refreshing = false, cards = cards) }
|
||||
}
|
||||
|
||||
/** 拉取所有已配置账号的最新余额,更新缓存。失败保留旧数据并把错误信息带上。 */
|
||||
fun refresh() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(refreshing = true) }
|
||||
val results: Map<ServiceType, Result<ServiceBalance>?> = coroutineScope {
|
||||
ServiceType.entries.map { type ->
|
||||
async {
|
||||
val status = credentialRepository.statusFor(type)
|
||||
if (status.state == CredentialStatus.State.NOT_CONFIGURED) {
|
||||
type to null // 未配置的服务不拉
|
||||
} else {
|
||||
type to refreshBalanceUseCase(type)
|
||||
// Mutex 防并发:如果已有 refresh 在跑,后来的直接跳过
|
||||
if (!refreshMutex.tryLock()) return@launch
|
||||
try {
|
||||
_uiState.update { it.copy(refreshing = true) }
|
||||
val results: Map<AccountKey, Result<ServiceBalance>?> = coroutineScope {
|
||||
val entries = orderAccountEntries(credentialRepository.readAccountStates())
|
||||
entries.map { entry ->
|
||||
async {
|
||||
val key = AccountKey(entry.service, entry.account?.id)
|
||||
val result: Result<ServiceBalance>? =
|
||||
if (entry.status.state == CredentialStatus.State.NOT_CONFIGURED) {
|
||||
null // 未配置的账号不拉
|
||||
} else {
|
||||
refreshBalanceUseCase(entry.service, entry.account?.id)
|
||||
}
|
||||
key to result
|
||||
}
|
||||
}
|
||||
}.awaitAll().toMap()
|
||||
}
|
||||
val newCache = balanceCache.getAll()
|
||||
val cards = ServiceType.entries.map { type ->
|
||||
val result = results[type]
|
||||
val errMsg = result?.exceptionOrNull()?.message
|
||||
buildCard(type, cachedBalance = newCache[type], lastFetchError = errMsg)
|
||||
}
|
||||
_uiState.update { it.copy(refreshing = false, cards = cards) }
|
||||
// 刷新成功后更新桌面小组件
|
||||
if (results[ServiceType.OPENCODE_GO]?.isSuccess == true) {
|
||||
OpenCodeGoWidgetProvider.notifyDataChanged(appContext)
|
||||
}.awaitAll().associateBy({ it.first }, { it.second })
|
||||
}
|
||||
val accountStates = credentialRepository.readAccountStates()
|
||||
val cards = orderAccountEntries(accountStates).map { entry ->
|
||||
val key = AccountKey(entry.service, entry.account?.id)
|
||||
val error = results[key]?.exceptionOrNull()
|
||||
val errorUi = error
|
||||
?.takeUnless { it is RepositoryError.CredentialChanged }
|
||||
?.let { errorToUiText(it) }
|
||||
buildCard(entry, lastFetchError = errorUi)
|
||||
}
|
||||
_uiState.update { it.copy(refreshing = false, cards = cards) }
|
||||
// 刷新成功后更新桌面小组件
|
||||
if (results[AccountKey(ServiceType.OPENCODE_GO, null)]?.isSuccess == true) {
|
||||
OpenCodeGoWidgetProvider.notifyDataChanged(appContext)
|
||||
}
|
||||
} finally {
|
||||
refreshMutex.unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun buildCard(
|
||||
type: ServiceType,
|
||||
cachedBalance: CachedBalance?,
|
||||
lastFetchError: String?
|
||||
): DashboardCardUi {
|
||||
val status = credentialRepository.statusFor(type)
|
||||
return DashboardCardUi(
|
||||
service = type,
|
||||
credentialState = status.state,
|
||||
cachedBalance = cachedBalance,
|
||||
lastFetchError = lastFetchError
|
||||
/**
|
||||
* 基于最新账号状态重建卡片列表,尽量保留旧顺序:
|
||||
* - 同一服务内默认账号在前,其余按账号创建顺序;
|
||||
* - 新增账号追加到同服务现有卡片之后;服务整体顺序沿用旧顺序,新服务按枚举追加。
|
||||
*/
|
||||
private fun rebuildCardsPreservingOrder(
|
||||
state: DashboardUiState,
|
||||
accountStates: List<CredentialRepository.AccountLocalState>
|
||||
): List<DashboardCardUi> {
|
||||
val entries = orderAccountEntries(accountStates)
|
||||
val previousByKey = state.cards.associateBy { it.key }
|
||||
return entries.mapIndexed { _, entry ->
|
||||
val key = entry.service.storageKey + ":" + entry.account?.id.orEmpty()
|
||||
val previous = previousByKey[key]
|
||||
if (previous == null) {
|
||||
buildCard(entry, lastFetchError = null)
|
||||
} else {
|
||||
val credentialChanged = entry.fingerprint != previous.credentialFingerprint
|
||||
val cacheAdvanced = !credentialChanged && isNewer(
|
||||
candidate = entry.cachedBalance,
|
||||
current = previous.cachedBalance
|
||||
)
|
||||
previous.copy(
|
||||
credentialState = entry.status.state,
|
||||
credentialFingerprint = entry.fingerprint,
|
||||
cachedBalance = if (credentialChanged) {
|
||||
entry.cachedBalance
|
||||
} else {
|
||||
newerOf(previous.cachedBalance, entry.cachedBalance)
|
||||
},
|
||||
lastFetchError = if (credentialChanged || cacheAdvanced) {
|
||||
null
|
||||
} else {
|
||||
previous.lastFetchError
|
||||
},
|
||||
// 账号默认状态在设置页改动后返回时也要同步
|
||||
isDefaultAccount = entry.account?.isDefault == true,
|
||||
// 签到角标每次 resume 重算:详情页手动签到后返回首页要立刻看到 ✓
|
||||
checkedInToday = entry.service in CHECKIN_SERVICES &&
|
||||
checkinStatusStore.isCheckedToday(entry.service, entry.account?.id)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 账号条目排序:服务按旧顺序/枚举,服务内默认账号优先、其余按存储顺序。 */
|
||||
private fun orderAccountEntries(
|
||||
accountStates: List<CredentialRepository.AccountLocalState>
|
||||
): List<CredentialRepository.AccountLocalState> {
|
||||
val existingOrder = _uiState.value.cards
|
||||
.mapIndexed { index, card -> card.service to index }
|
||||
.toMap()
|
||||
return accountStates.sortedWith(
|
||||
compareBy(
|
||||
{ existingOrder[it.service] ?: Int.MAX_VALUE },
|
||||
{ it.service.ordinal },
|
||||
{ if (it.account?.isDefault == true) 0 else 1 },
|
||||
{ it.account?.createdAt ?: 0L }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildCard(
|
||||
local: CredentialRepository.AccountLocalState,
|
||||
lastFetchError: UiText?
|
||||
): DashboardCardUi = DashboardCardUi(
|
||||
service = local.service,
|
||||
accountId = local.account?.id,
|
||||
accountLabel = local.account?.label,
|
||||
accountIndex = 0, // 展示兜底名称时由 UI 根据同服务卡片顺序计算
|
||||
isDefaultAccount = local.account?.isDefault == true,
|
||||
credentialState = local.status.state,
|
||||
credentialFingerprint = local.fingerprint,
|
||||
cachedBalance = local.cachedBalance,
|
||||
lastFetchError = lastFetchError,
|
||||
siteIcon = (local.credential as? Credential.Sub2ApiCredential)?.siteIcon,
|
||||
// 可签到服务(Trae/WorkBuddy)各账号的今日签到角标;其它服务恒 false 不显示
|
||||
checkedInToday = local.service in CHECKIN_SERVICES &&
|
||||
checkinStatusStore.isCheckedToday(local.service, local.account?.id)
|
||||
)
|
||||
|
||||
private fun newerOf(
|
||||
current: CachedBalance?,
|
||||
candidate: CachedBalance?
|
||||
): CachedBalance? = when {
|
||||
current == null -> candidate
|
||||
candidate == null -> current
|
||||
candidate.fetchedAt > current.fetchedAt -> candidate
|
||||
else -> current
|
||||
}
|
||||
|
||||
private fun isNewer(
|
||||
candidate: CachedBalance?,
|
||||
current: CachedBalance?
|
||||
): Boolean = candidate != null && (current == null || candidate.fetchedAt > current.fetchedAt)
|
||||
|
||||
// ---------- Sub2API 面板管理(Key 列表/新建/分组/兑换) ----------
|
||||
|
||||
/** Key 列表状态。 */
|
||||
private val _panelKeysState = MutableStateFlow<com.rainy.token.ui.servicedetail.PanelKeysState>(
|
||||
com.rainy.token.ui.servicedetail.PanelKeysState.Idle
|
||||
)
|
||||
val panelKeysState: StateFlow<com.rainy.token.ui.servicedetail.PanelKeysState> = _panelKeysState.asStateFlow()
|
||||
|
||||
/** 可用分组(新建 Key / 切分组下拉)。 */
|
||||
private val _panelGroups = MutableStateFlow<List<com.rainy.token.data.repository.Sub2ApiRepository.PanelGroup>>(emptyList())
|
||||
val panelGroups: StateFlow<List<com.rainy.token.data.repository.Sub2ApiRepository.PanelGroup>> = _panelGroups.asStateFlow()
|
||||
|
||||
/** 一次性操作结果消息(新建/切组/兑换)。 */
|
||||
private val _panelActionMessage = MutableStateFlow<String?>(null)
|
||||
val panelActionMessage: StateFlow<String?> = _panelActionMessage.asStateFlow()
|
||||
|
||||
fun consumePanelActionMessage() { _panelActionMessage.value = null }
|
||||
|
||||
/** 首页面板弹窗当前管理的 Sub2API 账号(null=默认账号)。 */
|
||||
private val _sub2PanelAccountId = MutableStateFlow<String?>(null)
|
||||
val sub2PanelAccountId: StateFlow<String?> = _sub2PanelAccountId.asStateFlow()
|
||||
|
||||
/** 打开首页面板弹窗并绑定目标账号,同时重置为未加载状态。 */
|
||||
fun openSub2Panel(accountId: String?) {
|
||||
_sub2PanelAccountId.value = accountId
|
||||
_panelKeysState.value = com.rainy.token.ui.servicedetail.PanelKeysState.Idle
|
||||
_panelGroups.value = emptyList()
|
||||
_panelGroupsAccountId = null
|
||||
_panelActionMessage.value = null
|
||||
_recentlyCreatedKey.value = null
|
||||
}
|
||||
|
||||
/** 最近一次新建成功的明文 Key(弹窗立即复制用;查看后清除)。 */
|
||||
private val _recentlyCreatedKey = MutableStateFlow<String?>(null)
|
||||
val recentlyCreatedKey: StateFlow<String?> = _recentlyCreatedKey.asStateFlow()
|
||||
|
||||
fun consumeRecentlyCreatedKey() { _recentlyCreatedKey.value = null }
|
||||
|
||||
/** 刷新面板 Key 列表(仅 SUB2API 且有面板 Token)。 */
|
||||
fun loadPanelKeys() {
|
||||
viewModelScope.launch {
|
||||
if (_panelKeysState.value is com.rainy.token.ui.servicedetail.PanelKeysState.Loading) return@launch
|
||||
_panelKeysState.value = com.rainy.token.ui.servicedetail.PanelKeysState.Loading
|
||||
sub2ApiRepository.fetchPanelKeys(_sub2PanelAccountId.value).fold(
|
||||
onSuccess = { list ->
|
||||
_panelKeysState.value = com.rainy.token.ui.servicedetail.PanelKeysState.Success(list)
|
||||
},
|
||||
onFailure = { e ->
|
||||
_panelKeysState.value = com.rainy.token.ui.servicedetail.PanelKeysState.Error(e.message ?: "加载 Key 列表失败")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 面板分组缓存所属账号(防止切换账号后仍显示上一个账号的分组)。 */
|
||||
private var _panelGroupsAccountId: String? = null
|
||||
|
||||
/** 加载可用分组;同一账号加载过一次则跳过,切换账号后强制重拉。 */
|
||||
fun loadPanelGroups() {
|
||||
viewModelScope.launch {
|
||||
val accountId = _sub2PanelAccountId.value
|
||||
if (_panelGroupsAccountId == accountId && _panelGroups.value.isNotEmpty()) return@launch
|
||||
sub2ApiRepository.fetchPanelGroups(accountId).fold(
|
||||
onSuccess = {
|
||||
_panelGroups.value = it
|
||||
_panelGroupsAccountId = accountId
|
||||
},
|
||||
onFailure = {
|
||||
// 分组加载失败不阻塞主流程(新建 Key 时可不选分组)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 新建 API Key:成功后展示掩码消息并刷新列表。 */
|
||||
fun createPanelKey(name: String, groupId: Long?) {
|
||||
viewModelScope.launch {
|
||||
sub2ApiRepository.createPanelKey(name, groupId, _sub2PanelAccountId.value).fold(
|
||||
onSuccess = { key ->
|
||||
_recentlyCreatedKey.value = key
|
||||
_panelActionMessage.value = "已创建 Key:" + maskPanelKey(key) + "(请在弹出的窗口复制完整 Key)"
|
||||
loadPanelKeys()
|
||||
},
|
||||
onFailure = { e -> _panelActionMessage.value = "创建失败:" + (e.message ?: "") }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换 Key 所属分组:成功后刷新列表。 */
|
||||
fun switchPanelGroup(keyId: Long, groupId: Long) {
|
||||
viewModelScope.launch {
|
||||
sub2ApiRepository.updatePanelKey(keyId, groupId, accountId = _sub2PanelAccountId.value).fold(
|
||||
onSuccess = {
|
||||
_panelActionMessage.value = "分组已切换"
|
||||
loadPanelKeys()
|
||||
},
|
||||
onFailure = { e -> _panelActionMessage.value = "切换失败:" + (e.message ?: "") }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除 Key:成功后刷新列表。 */
|
||||
fun deletePanelKey(keyId: Long) {
|
||||
viewModelScope.launch {
|
||||
sub2ApiRepository.deletePanelKey(keyId, _sub2PanelAccountId.value).fold(
|
||||
onSuccess = {
|
||||
_panelActionMessage.value = "已删除 Key"
|
||||
loadPanelKeys()
|
||||
},
|
||||
onFailure = { e -> _panelActionMessage.value = "删除失败:" + (e.message ?: "") }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 兑换卡密:成功后刷新余额与 Key 列表。 */
|
||||
fun redeemPanelCode(code: String) {
|
||||
viewModelScope.launch {
|
||||
sub2ApiRepository.redeemPanelCode(code, _sub2PanelAccountId.value).fold(
|
||||
onSuccess = { msg ->
|
||||
_panelActionMessage.value = msg
|
||||
refreshBalanceUseCase(ServiceType.SUB2API, _sub2PanelAccountId.value)
|
||||
loadPanelKeys()
|
||||
},
|
||||
onFailure = { e -> _panelActionMessage.value = "兑换失败:" + (e.message ?: "") }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 面板 API Key 展示用掩码:只留前 8 + 后 4 位,避免页面显示过长。 */
|
||||
private fun maskPanelKey(key: String): String = when {
|
||||
key.length <= 16 -> key
|
||||
else -> key.take(8) + "…" + key.takeLast(4)
|
||||
}
|
||||
}
|
||||
|
||||
data class DashboardUiState(
|
||||
@ -111,18 +448,63 @@ data class DashboardUiState(
|
||||
|
||||
data class DashboardCardUi(
|
||||
val service: ServiceType,
|
||||
/** 账号 ID(多账号服务);null=单账号服务。 */
|
||||
val accountId: String? = null,
|
||||
/** 账号自定义名称(可能为 null,UI 按“账号 N”兜底)。 */
|
||||
val accountLabel: String? = null,
|
||||
/** 同服务内的账号序号(0 起,用于兜底名称;由 UI 层计算后回写展示)。 */
|
||||
val accountIndex: Int = 0,
|
||||
/** 是否为默认账号(多账号服务)。 */
|
||||
val isDefaultAccount: Boolean = false,
|
||||
val credentialState: CredentialStatus.State,
|
||||
val credentialFingerprint: String?,
|
||||
val cachedBalance: CachedBalance?,
|
||||
val lastFetchError: String?
|
||||
val lastFetchError: UiText?,
|
||||
/** 站点 Logo URL(Sub2API 面板登录后抓取;非空时主页卡片用它替换内置图标)。 */
|
||||
val siteIcon: String? = null,
|
||||
/** 今日已签到角标(仅可签到服务有值,见 [CHECKIN_SERVICES])。 */
|
||||
val checkedInToday: Boolean = false
|
||||
) {
|
||||
/** 余额展示主数字。优先取缓存,错误时也展示(不隐藏,让用户看到旧值 + 红点提示) */
|
||||
/** 余额展示主数字。优先取缓存,错误时也展示(不隐藏,让用户看到旧值 + 红点提示)。 */
|
||||
val displayBalance: ServiceBalance? get() = cachedBalance?.balance
|
||||
|
||||
/** 卡片顶部状态徽章 */
|
||||
val statusBadge: String get() = when {
|
||||
credentialState == CredentialStatus.State.NOT_CONFIGURED -> "未配置"
|
||||
lastFetchError != null -> "刷新失败"
|
||||
cachedBalance == null -> "未获取"
|
||||
else -> "正常"
|
||||
/** 卡片唯一键:`{storageKey}:{accountId}`,单账号服务为 `{storageKey}:`。 */
|
||||
val key: String get() = "${service.storageKey}:${accountId.orEmpty()}"
|
||||
}
|
||||
|
||||
/** 内部请求/结果键:账号粒度(多账号服务逐账号拉取)。 */
|
||||
private data class AccountKey(
|
||||
val service: ServiceType,
|
||||
val accountId: String? = null
|
||||
)
|
||||
|
||||
/** 把 Repository 错误映射为本地化文案(ParseError 按 reason 映射资源,detail 仅作日志,不流向 UI)。 */
|
||||
private fun errorToUiText(error: Throwable): UiText = when (error) {
|
||||
is RepositoryError.SessionExpired ->
|
||||
UiText.Resource(R.string.error_credential_session_expired)
|
||||
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)
|
||||
}
|
||||
|
||||
@ -0,0 +1,79 @@
|
||||
package com.rainy.token.ui.dashboard
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.DatePickerDialog
|
||||
import androidx.compose.material3.DateRangePicker
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberDateRangePickerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.rainy.token.R
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneOffset
|
||||
|
||||
/**
|
||||
* 日期范围选择弹窗 —— 单弹窗内一次选出起止日期(连续高亮)。
|
||||
*
|
||||
* - 状态通过 "if (show) Dialog(...)" 条件组合创建:每次打开都会用最新预填值重新初始化;
|
||||
* - 预填毫秒一律按 UTC 零点解释(与项目既有 UTC 语义一致);
|
||||
* - 两端日期齐备前,确认按钮保持禁用(M3 范围选择:同一天点两次 = 单日区间)。
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun DateRangePickerDialog(
|
||||
title: String,
|
||||
initialStart: LocalDate?,
|
||||
initialEnd: LocalDate?,
|
||||
onConfirm: (LocalDate, LocalDate) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
// 预填数据 sanitize:无起点时忽略终点;终点早于起点时忽略终点
|
||||
val sanitizedEnd = initialEnd?.takeIf { initialStart != null && !it.isBefore(initialStart) }
|
||||
val state = rememberDateRangePickerState(
|
||||
initialSelectedStartDateMillis = initialStart?.toUtcMillis(),
|
||||
initialSelectedEndDateMillis = sanitizedEnd?.toUtcMillis()
|
||||
)
|
||||
DatePickerDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
confirmButton = {
|
||||
val complete = state.selectedStartDateMillis != null && state.selectedEndDateMillis != null
|
||||
TextButton(
|
||||
onClick = {
|
||||
val startMillis = state.selectedStartDateMillis
|
||||
val endMillis = state.selectedEndDateMillis
|
||||
if (startMillis != null && endMillis != null) {
|
||||
onConfirm(startMillis.toUtcLocalDate(), endMillis.toUtcLocalDate())
|
||||
}
|
||||
},
|
||||
enabled = complete
|
||||
) {
|
||||
Text(stringResource(R.string.action_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
}
|
||||
) {
|
||||
DateRangePicker(
|
||||
state = state,
|
||||
modifier = Modifier.fillMaxWidth().height(500.dp).padding(16.dp),
|
||||
title = { Text(title) },
|
||||
showModeToggle = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun LocalDate.toUtcMillis(): Long =
|
||||
atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
||||
|
||||
private fun Long.toUtcLocalDate(): LocalDate =
|
||||
Instant.ofEpochMilli(this).atOffset(ZoneOffset.UTC).toLocalDate()
|
||||
@ -0,0 +1,894 @@
|
||||
package com.rainy.token.ui.dashboard
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
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.model.Sub2DailyUsage
|
||||
import com.rainy.token.domain.model.Sub2UsageDetail
|
||||
import java.time.LocalDate
|
||||
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.isFiveHourLabel
|
||||
import com.rainy.token.ui.components.normalizeWindowLabel
|
||||
import com.rainy.token.ui.theme.StrawberryPink
|
||||
import com.rainy.token.ui.theme.StatusOrange
|
||||
import com.rainy.token.ui.theme.inkMuted
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import kotlin.math.roundToInt
|
||||
import java.util.Locale
|
||||
|
||||
// ── Service-specific balance Composables ──
|
||||
|
||||
@Composable
|
||||
internal fun BalanceMainArea(card: DashboardCardUi) {
|
||||
val balance = card.displayBalance
|
||||
when {
|
||||
card.credentialState == CredentialStatus.State.NOT_CONFIGURED -> {
|
||||
Text(
|
||||
text = "—",
|
||||
style = MaterialTheme.typography.displayMedium,
|
||||
color = inkMuted()
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.service_click_to_configure),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = inkMuted()
|
||||
)
|
||||
}
|
||||
balance == null -> {
|
||||
Text(
|
||||
text = "—",
|
||||
style = MaterialTheme.typography.displayMedium,
|
||||
color = inkMuted()
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.service_pull_to_refresh),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = inkMuted()
|
||||
)
|
||||
}
|
||||
card.service == ServiceType.OPENCODE_GO -> {
|
||||
OpenCodeGoMainBalance(balance)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
OpenCodeGoUsageWindows(balance)
|
||||
}
|
||||
card.service == ServiceType.COMMANDCODE_GO -> {
|
||||
CommandCodeGoMainBalance(balance)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
CommandCodeGoUsageWindows(balance)
|
||||
}
|
||||
card.service == ServiceType.CODEX -> {
|
||||
CodexMainBalance(balance)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
CodexUsageWindows(balance)
|
||||
}
|
||||
card.service == ServiceType.OLLAMA -> {
|
||||
OllamaMainBalance(balance)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
OllamaUsageWindows(balance)
|
||||
}
|
||||
card.service == ServiceType.SUB2API -> {
|
||||
Sub2ApiBalanceWithUsage(balance)
|
||||
}
|
||||
card.service == ServiceType.TRAE -> {
|
||||
TraeSplitMainBalance(balance)
|
||||
}
|
||||
else -> {
|
||||
Row(verticalAlignment = Alignment.Bottom) {
|
||||
Text(
|
||||
text = formatAmount(balance.amount),
|
||||
style = MaterialTheme.typography.displayMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = balance.unit,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = inkMuted(),
|
||||
modifier = Modifier.padding(bottom = 6.dp)
|
||||
)
|
||||
}
|
||||
if (!balance.isAvailable) {
|
||||
Text(
|
||||
text = stringResource(R.string.service_unavailable),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
balance.monthlySpent?.let { spent ->
|
||||
Text(
|
||||
text = stringResource(R.string.service_monthly_used, formatAmount(spent), balance.unit),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = inkMuted()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trae 首页卡片主区:不显示总积分,分开显示「通用积分」(TraeCode/TraeWork 均可用)
|
||||
* 与「Work 专属积分」(仅 TraeWork),取 extras 中 general_credits/work_credits 小计
|
||||
* (与详情页 TraePacksCard 同口径)。旧缓存缺小计键时回退总积分展示,刷新后恢复。
|
||||
*/
|
||||
@Composable
|
||||
private fun TraeSplitMainBalance(balance: ServiceBalance) {
|
||||
val extras = balance.extras
|
||||
val general = extras?.get("general_credits")?.toDoubleOrNull()
|
||||
val work = extras?.get("work_credits")?.toDoubleOrNull()
|
||||
if (general == null && work == null) {
|
||||
// 旧缓存无小计键:回退原总积分展示
|
||||
Row(verticalAlignment = Alignment.Bottom) {
|
||||
Text(
|
||||
text = formatAmount(balance.amount),
|
||||
style = MaterialTheme.typography.displayMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = balance.unit,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = inkMuted(),
|
||||
modifier = Modifier.padding(bottom = 6.dp)
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
val generalTitle = stringResource(R.string.trae_general_credits_title)
|
||||
val workTitle = stringResource(R.string.trae_work_credits_title)
|
||||
TraeSplitLine(generalTitle, general)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
TraeSplitLine(workTitle, work)
|
||||
}
|
||||
|
||||
/** 首页 Trae 分区一行:左侧分区名,右侧小计 + 单位。 */
|
||||
@Composable
|
||||
private fun TraeSplitLine(label: String, value: Double?) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = inkMuted()
|
||||
)
|
||||
Row(verticalAlignment = Alignment.Bottom) {
|
||||
Text(
|
||||
text = value?.let { formatAmount(it) } ?: "—",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = "Credits",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = inkMuted(),
|
||||
modifier = Modifier.padding(bottom = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun OpenCodeGoMainBalance(balance: ServiceBalance) {
|
||||
Row(verticalAlignment = Alignment.Bottom) {
|
||||
Text(
|
||||
text = formatAmount(balance.amount),
|
||||
style = MaterialTheme.typography.displayMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = "%",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = inkMuted(),
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(bottom = 6.dp, start = 2.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.window_5h_usage),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = inkMuted(),
|
||||
modifier = Modifier.padding(bottom = 6.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun OpenCodeGoUsageWindows(balance: ServiceBalance) {
|
||||
val windows = listOf(
|
||||
Triple(stringResource(R.string.window_5h), balance.extras["rolling.pct"]?.toFloatOrNull()?.roundToInt(), balance.extras["rolling.resetInSec"]?.toLongOrNull()),
|
||||
Triple(stringResource(R.string.window_weekly), balance.extras["weekly.pct"]?.toFloatOrNull()?.roundToInt(), balance.extras["weekly.resetInSec"]?.toLongOrNull()),
|
||||
Triple(stringResource(R.string.window_monthly), balance.extras["monthly.pct"]?.toFloatOrNull()?.roundToInt(), balance.extras["monthly.resetInSec"]?.toLongOrNull())
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
windows.forEach { (label, pct, resetSec) ->
|
||||
if (pct != null) {
|
||||
CompactUsageRow(label = label, pct = pct, resetInSec = resetSec)
|
||||
} else {
|
||||
CompactUsageRowEmpty(label = label, resetInSec = resetSec)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun CommandCodeGoMainBalance(balance: ServiceBalance) {
|
||||
val total = balance.totalQuota
|
||||
Row(verticalAlignment = Alignment.Bottom) {
|
||||
Text(
|
||||
text = formatAmount(balance.amount),
|
||||
style = MaterialTheme.typography.displayMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = "$",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = inkMuted(),
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(bottom = 6.dp, start = 2.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.service_remaining),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = inkMuted(),
|
||||
modifier = Modifier.padding(bottom = 6.dp)
|
||||
)
|
||||
if (total != null && total > 0) {
|
||||
val used = total - balance.amount
|
||||
Text(
|
||||
text = stringResource(R.string.service_used_total, formatAmount(used), formatAmount(total)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = inkMuted(),
|
||||
modifier = Modifier.padding(bottom = 6.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun CommandCodeGoUsageWindows(balance: ServiceBalance) {
|
||||
val extras = balance.extras
|
||||
fun calcPct(used: Double?, cap: Double?): Int? {
|
||||
if (used == null || cap == null || cap <= 0) return null
|
||||
return ((used / cap) * 100).toInt().coerceIn(0, 100)
|
||||
}
|
||||
val windows = listOf(
|
||||
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) ->
|
||||
if (pct != null) {
|
||||
CompactUsageRow(label = label, pct = pct, resetInSec = resetSec)
|
||||
} else {
|
||||
CompactUsageRowEmpty(label = label, resetInSec = resetSec)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun CodexMainBalance(balance: ServiceBalance) {
|
||||
val plan = balance.extras["plan"]?.takeIf { it.isNotBlank() }?.let {
|
||||
when (it) { "plus" -> "Plus"; "pro" -> "Pro"; "free" -> "Free"; else -> it.replaceFirstChar { c -> c.uppercaseChar() } }
|
||||
} ?: "—"
|
||||
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),
|
||||
style = MaterialTheme.typography.displayMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = "%",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = inkMuted(),
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(bottom = 6.dp, start = 2.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
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 = stringResource(R.string.service_plan_suffix, plan),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = inkMuted(),
|
||||
modifier = Modifier.padding(bottom = 6.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 Codex API 返回的 primary label 转成本地化短标签。
|
||||
* 默认中文标签保持测试兼容;UI 调用方传入 stringResource 结果。
|
||||
*/
|
||||
internal fun formatCodexPrimaryLabel(
|
||||
raw: String?,
|
||||
weeklyLabel: String = "Weekly",
|
||||
monthlyLabel: String = "Monthly",
|
||||
usageLabel: String = "Usage"
|
||||
): String = when (raw?.lowercase()) {
|
||||
"5h" -> "5h"
|
||||
"7d", "weekly", "每周", weeklyLabel -> weeklyLabel
|
||||
"30d", "monthly", "每月", monthlyLabel -> monthlyLabel
|
||||
"usage" -> usageLabel
|
||||
null -> "5h"
|
||||
else -> raw ?: "5h"
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun CodexUsageWindows(balance: ServiceBalance) {
|
||||
val extras = balance.extras
|
||||
val windowCount = extras.keys
|
||||
.mapNotNull { key -> key.removePrefix("window_").substringBefore('.').toIntOrNull() }
|
||||
.distinct()
|
||||
.maxOrNull()?.plus(1) ?: 0
|
||||
|
||||
val weeklyLabel = stringResource(R.string.window_every_week)
|
||||
val monthlyLabel = stringResource(R.string.window_every_month)
|
||||
val usageLabel = stringResource(R.string.window_usage)
|
||||
val sparkTitle = stringResource(R.string.window_spark)
|
||||
|
||||
data class UiWindow(val label: String, val isSpark: Boolean, val remainingPct: Int?, val resetAt: Long?)
|
||||
|
||||
val windows = (0 until windowCount).map { i ->
|
||||
val rawLabel = extras["window_$i.label"] ?: "usage"
|
||||
val label = normalizeWindowLabel(
|
||||
rawLabel,
|
||||
weeklyLabel = weeklyLabel,
|
||||
monthlyLabel = monthlyLabel,
|
||||
usageLabel = usageLabel
|
||||
)
|
||||
UiWindow(
|
||||
label = label,
|
||||
isSpark = extras["window_$i.group"] == "SPARK",
|
||||
remainingPct = extras["window_$i.remainingPct"]?.toIntOrNull(),
|
||||
resetAt = extras["window_$i.resetAt"]?.toLongOrNull()?.takeIf { it > 0 }
|
||||
)
|
||||
}
|
||||
|
||||
// 主模型(普通 Codex)窗口独占全部进度行;Spark 独立限额是次要通道,只留一行小字摘要
|
||||
val mainWindows = windows.filter { !it.isSpark }
|
||||
val sparkWindows = windows.filter { it.isSpark }
|
||||
|
||||
// 判断是否有 5h 窗口(与语言无关:匹配 "5h"/"5H" 或本地化标签,仅看主模型窗口)
|
||||
val fiveHourLabel = stringResource(R.string.window_5h)
|
||||
val has5h = mainWindows.any { isFiveHourLabel(it.label, fiveHourLabel, stringResource(R.string.window_5h_short)) }
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (windows.isEmpty()) {
|
||||
CompactUsageRowEmpty(label = stringResource(R.string.usage_window_title_plain), resetInSec = null)
|
||||
} else {
|
||||
// 如果没有 5h 窗口,在顶部插入一个空的 5h 槽位(保留位置,等恢复后自动填充)
|
||||
if (!has5h) {
|
||||
CompactUsageRowEmpty(label = fiveHourLabel, resetInSec = null)
|
||||
}
|
||||
mainWindows.forEach { w ->
|
||||
val resetSec = w.resetAt?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 }
|
||||
if (w.remainingPct != null) {
|
||||
CompactUsageRow(label = w.label, pct = (100 - w.remainingPct).coerceIn(0, 100), resetInSec = resetSec)
|
||||
} else {
|
||||
CompactUsageRowEmpty(label = w.label, resetInSec = resetSec)
|
||||
}
|
||||
}
|
||||
// Spark 次要限额:单行摘要,不与主模型争夺视觉焦点
|
||||
if (sparkWindows.isNotEmpty()) {
|
||||
val summary = sparkWindows.joinToString(" · ") { w ->
|
||||
val used = w.remainingPct?.let { (100 - it).coerceIn(0, 100) }
|
||||
if (used != null) "${w.label} $used%" else "${w.label} —"
|
||||
}
|
||||
Text(
|
||||
text = "$sparkTitle · $summary",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = inkMuted()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun OllamaMainBalance(balance: ServiceBalance) {
|
||||
val plan = balance.extras["plan"]?.takeIf { it.isNotBlank() }?.let {
|
||||
when (it.lowercase()) { "pro" -> "Pro"; "max" -> "Max"; "free" -> "Free"; else -> it }
|
||||
} ?: "—"
|
||||
Row(verticalAlignment = Alignment.Bottom) {
|
||||
Text(
|
||||
text = formatAmount(balance.amount),
|
||||
style = MaterialTheme.typography.displayMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = "%",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = inkMuted(),
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(bottom = 6.dp, start = 2.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
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 = stringResource(R.string.service_plan_suffix, plan),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = inkMuted(),
|
||||
modifier = Modifier.padding(bottom = 6.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun OllamaUsageWindows(balance: ServiceBalance) {
|
||||
val extras = balance.extras
|
||||
val sessionPct = extras["session.pct"]?.toFloatOrNull()
|
||||
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 = 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 = stringResource(R.string.window_5h_short), resetInSec = null)
|
||||
}
|
||||
if (weeklyPct != null) {
|
||||
CompactUsageRow(
|
||||
label = weeklyLabel,
|
||||
pct = weeklyPct.toInt().coerceIn(0, 100),
|
||||
resetInSec = weeklyResetAt?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 }
|
||||
)
|
||||
} else {
|
||||
CompactUsageRowEmpty(label = weeklyLabel, resetInSec = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shared UI components ──
|
||||
|
||||
@Composable
|
||||
internal fun CompactUsageRowEmpty(label: String, resetInSec: Long?) {
|
||||
val durationText = rememberDurationText()
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = inkMuted(),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = "—",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = inkMuted()
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
LinearProgressIndicator(
|
||||
progress = { 0f },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(4.dp)
|
||||
.clip(RoundedCornerShape(2.dp)),
|
||||
color = inkMuted().copy(alpha = 0.3f),
|
||||
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
strokeCap = StrokeCap.Butt
|
||||
)
|
||||
if (resetInSec != null && resetInSec > 0) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.service_reset_in, formatResetInSec(resetInSec, durationText)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = inkMuted()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun CompactUsageRow(label: String, pct: Int, resetInSec: Long?) {
|
||||
val durationText = rememberDurationText()
|
||||
val pctValue = pct.coerceIn(0, 100).toFloat()
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = inkMuted(),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = "$pct%",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = when {
|
||||
pct >= 80 -> MaterialTheme.colorScheme.error
|
||||
pct >= 50 -> StatusOrange
|
||||
else -> MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
LinearProgressIndicator(
|
||||
progress = { pctValue / 100f },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(4.dp)
|
||||
.clip(RoundedCornerShape(2.dp)),
|
||||
color = when {
|
||||
pctValue >= 80f -> MaterialTheme.colorScheme.error
|
||||
pctValue >= 50f -> StatusOrange
|
||||
else -> StrawberryPink
|
||||
},
|
||||
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
strokeCap = StrokeCap.Butt
|
||||
)
|
||||
if (resetInSec != null && resetInSec > 0) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.service_reset_in, formatResetInSec(resetInSec, durationText)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = inkMuted()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 当前语言环境的时长标签("天 / 小时 / 分")。 */
|
||||
@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(R.string.status_not_configured, StatusLevel.WARNING)
|
||||
lastFetchError != null ->
|
||||
StatusStyle(R.string.status_refresh_failed, StatusLevel.ERROR)
|
||||
credentialState == CredentialStatus.State.EXPIRED ->
|
||||
StatusStyle(R.string.status_expired, StatusLevel.ERROR)
|
||||
credentialState == CredentialStatus.State.WARNING ->
|
||||
StatusStyle(R.string.status_relogin, StatusLevel.WARNING)
|
||||
cachedBalance == null ->
|
||||
StatusStyle(R.string.status_waiting, StatusLevel.INFO)
|
||||
else ->
|
||||
StatusStyle(R.string.status_normal, StatusLevel.OK)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sub2API 副标题:显示登录方式(邮箱登录 / API 登录),而不是笼统的「自托管面板 · 用户级 sk- Key 余额」。
|
||||
* 依据 UsageDetail/余额 extras 里的 auth_mode(由 Repository 写入):
|
||||
* panel = 面板 Token(邮箱/账号登录面板)
|
||||
* apikey = 仅配置了 sk- API Key
|
||||
* 无数据时回退到通用描述。
|
||||
*/
|
||||
@Composable
|
||||
internal fun sub2AuthModeText(card: DashboardCardUi): String {
|
||||
val balance = card.displayBalance
|
||||
val mode = balance?.extras?.get("auth_mode")
|
||||
return when (mode) {
|
||||
"panel" -> stringResource(R.string.sub2_auth_panel)
|
||||
"apikey" -> stringResource(R.string.sub2_auth_apikey)
|
||||
else -> stringResource(R.string.service_desc_sub2api)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trae / WorkBuddy 卡片副标题:显示订阅状态(不再展示登录方式)。
|
||||
*
|
||||
* 取自 extras 的订阅字段(付费用户 / 订阅中),与详情页「账户信息」区同一数据源。
|
||||
* 无缓存余额或字段缺失时返回 null,调用方回落到 [secondaryLineRes] 的通用描述。
|
||||
*/
|
||||
@Composable
|
||||
internal fun subscriptionLine(card: DashboardCardUi): String? {
|
||||
// 仅 Trae / WorkBuddy 使用本函数;其余服务回落通用描述
|
||||
if (card.service != ServiceType.TRAE && card.service != ServiceType.WORKBUDDY) return null
|
||||
val extras = card.displayBalance?.extras ?: return null
|
||||
|
||||
// WorkBuddy:订阅 / 付费状态
|
||||
if ("is_paid_user" in extras || "subscription" in extras) {
|
||||
val isPaid = extras["is_paid_user"] == "true"
|
||||
val isSubscribed = extras["subscription"] == "true"
|
||||
return when {
|
||||
isSubscribed -> stringResource(R.string.extra_value_subscription_active)
|
||||
isPaid -> stringResource(R.string.extra_value_paid_user)
|
||||
else -> stringResource(R.string.extra_value_free_user)
|
||||
}
|
||||
}
|
||||
|
||||
// Trae:计费模式(积分计费 / 美元计费),无则不显示
|
||||
val billing = when {
|
||||
extras["is_credits_billing"] == "true" -> stringResource(R.string.extra_value_billing_credits)
|
||||
extras["is_dollar_usage_billing"] == "true" -> stringResource(R.string.extra_value_billing_dollar)
|
||||
else -> null
|
||||
}
|
||||
if (billing != null) {
|
||||
return stringResource(R.string.extra_label_billing_mode_desc, billing)
|
||||
}
|
||||
|
||||
// 字段完全缺失时返回 null,回落通用描述
|
||||
return null
|
||||
}
|
||||
|
||||
/** 服务副标题(资源 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
|
||||
ServiceType.TRAE -> R.string.service_desc_trae
|
||||
ServiceType.WORKBUDDY -> R.string.service_desc_workbuddy
|
||||
ServiceType.SUB2API -> R.string.service_desc_sub2api
|
||||
}
|
||||
|
||||
/**
|
||||
* 底部更新时间/错误文案(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 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 -> 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))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用量卡片(OCGO / CCGO)底部刷新时间文案,规则与 [footerText] 完全一致:
|
||||
* <1 分钟"刚刚更新"、<1 小时"X 分钟前"、<24 小时"X 小时前"、更早显示 "MM-dd HH:mm 更新"。
|
||||
*/
|
||||
@Composable
|
||||
internal fun usageUpdatedAtText(updatedAt: Long): String {
|
||||
val now = System.currentTimeMillis()
|
||||
val diffMin = (now - updatedAt) / 60_000
|
||||
val sdf = SimpleDateFormat("MM-dd HH:mm", Locale.getDefault())
|
||||
return when {
|
||||
diffMin < 1 -> stringResource(R.string.footer_just_updated)
|
||||
diffMin < 60 -> stringResource(R.string.footer_minutes_ago, diffMin)
|
||||
diffMin < 1440 -> stringResource(R.string.footer_hours_ago, diffMin / 60)
|
||||
else -> stringResource(R.string.footer_updated_at, sdf.format(Date(updatedAt)))
|
||||
}
|
||||
}
|
||||
// ── Sub2API 首页卡片:余额 + 日/周/月/本月 用量汇总 ──
|
||||
|
||||
/**
|
||||
* 首页 Sub2API 卡主体。
|
||||
*
|
||||
* 布局:
|
||||
* ① 余额区——「余额」小标签 + 大号数字 + 单位(带品牌色圆点的左侧强调条)
|
||||
* ② 用量区——「今日 / 近7天」两块等宽磁贴,每块显示 请求次数 与 Token 数
|
||||
*
|
||||
* 磁贴等宽 + 两行固定结构,保证 360dp 窄屏不换行、不同周期数值长度不一会错位。
|
||||
*/
|
||||
@Composable
|
||||
internal fun Sub2ApiBalanceWithUsage(balance: ServiceBalance) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Sub2ApiBalanceHero(balance)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
Sub2ApiUsageTiles(balance)
|
||||
}
|
||||
}
|
||||
|
||||
/** 余额区:小标签 + 大数字 + 单位。 */
|
||||
@Composable
|
||||
internal fun Sub2ApiMainBalance(balance: ServiceBalance, modifier: Modifier = Modifier) {
|
||||
Sub2ApiBalanceHero(balance, modifier)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Sub2ApiBalanceHero(balance: ServiceBalance, modifier: Modifier = Modifier) {
|
||||
// 「余额 357.06 USD」单行排列:小标签 + 大数字 + 单位
|
||||
Row(verticalAlignment = Alignment.Bottom, modifier = modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = stringResource(R.string.sub2_balance_label),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = inkMuted(),
|
||||
modifier = Modifier.padding(bottom = 6.dp),
|
||||
maxLines = 1
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = formatAmount(balance.amount),
|
||||
style = MaterialTheme.typography.displaySmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = balance.unit,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = inkMuted(),
|
||||
modifier = Modifier.padding(bottom = 4.dp),
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 用量区:今日 / 近7天 两块等宽磁贴。 */
|
||||
@Composable
|
||||
private fun Sub2ApiUsageTiles(balance: ServiceBalance) {
|
||||
val detail = balance.sub2UsageDetail
|
||||
val windows = remember(detail) { buildSub2SummaryWindows(detail) }
|
||||
if (windows.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.sub2_home_no_usage),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = inkMuted()
|
||||
)
|
||||
return
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
windows.forEach { w ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
// 第一行:周期名 + 消耗金额
|
||||
Row(verticalAlignment = Alignment.Bottom) {
|
||||
Text(
|
||||
text = stringResource(w.labelRes),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = inkMuted(),
|
||||
maxLines = 1
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
text = stringResource(R.string.sub2_home_spend, w.cost),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = inkMuted(),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(3.dp))
|
||||
Text(
|
||||
text = w.tokens,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 聚合 dailyUsage 计算各窗口汇总行文案;无数据返回空列表。 */
|
||||
private fun buildSub2SummaryWindows(detail: Sub2UsageDetail?): List<Sub2WindowSummary> {
|
||||
val days = detail?.dailyUsage.orEmpty()
|
||||
if (days.isEmpty()) return emptyList()
|
||||
val today = LocalDate.now()
|
||||
fun sum(from: LocalDate): Triple<Long, Long, Double> {
|
||||
var req = 0L; var tok = 0L; var cost = 0.0
|
||||
for (d in days) {
|
||||
val date = runCatching { LocalDate.parse(d.date) }.getOrNull() ?: continue
|
||||
if (!date.isBefore(from) && !date.isAfter(today)) {
|
||||
req += d.requests; tok += d.totalTokens; cost += d.cost
|
||||
}
|
||||
}
|
||||
return Triple(req, tok, cost)
|
||||
}
|
||||
// 首页仅显示 日/周 两个周期(近30天/本月 明细在详情页时间窗口里查看)
|
||||
val windows = listOf(R.string.sub2_window_today to today, R.string.sub2_window_7d to today.minusDays(6))
|
||||
return windows.map { (labelRes, from) ->
|
||||
val (req, tok, cost) = sum(from)
|
||||
val tokens = when {
|
||||
tok >= 1_000_000_000 -> (tok / 1_000_000_000.0).formatLocale("%.1fB")
|
||||
tok >= 1_000_000 -> (tok / 1_000_000.0).formatLocale("%.1fM")
|
||||
tok >= 1_000 -> (tok / 1_000.0).formatLocale("%.1fK")
|
||||
else -> tok.toString()
|
||||
}
|
||||
Sub2WindowSummary(labelRes = labelRes, requests = req, cost = cost, tokens = tokens)
|
||||
}
|
||||
}
|
||||
|
||||
/** 首页周期汇总(今日 / 近7天)。 */
|
||||
private data class Sub2WindowSummary(
|
||||
@androidx.annotation.StringRes val labelRes: Int,
|
||||
val requests: Long,
|
||||
val cost: Double,
|
||||
val tokens: String
|
||||
)
|
||||
|
||||
private fun Double.formatLocale(pattern: String): String =
|
||||
String.format(java.util.Locale.getDefault(), pattern, this)
|
||||
@ -53,24 +53,30 @@ 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
|
||||
import com.rainy.token.ui.theme.StrawberryPink
|
||||
import java.text.SimpleDateFormat
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneOffset
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
// 图表用色板(每个模型一个颜色,12 色 Material Design 500 色阶,足够区分 12 个模型)
|
||||
internal val modelColors = listOf(
|
||||
Color(0xFFF44336), // Red
|
||||
Color(0xFFE91E63), // Pink
|
||||
Color(0xFFC9436C), // Pink(玫瑰主色)
|
||||
Color(0xFF9C27B0), // Purple
|
||||
Color(0xFF673AB7), // Deep Purple
|
||||
Color(0xFF3F51B5), // Indigo
|
||||
@ -83,11 +89,11 @@ internal val modelColors = listOf(
|
||||
Color(0xFF607D8B) // Blue Grey
|
||||
)
|
||||
|
||||
// Token 堆叠色板:命中缓存(浅粉·顶)、未命中输入(粉·中)、输出(深粉·底)
|
||||
// Token 堆叠色板:命中缓存(浅樱粉·顶)、未命中输入(玫瑰粉·中)、输出(深玫瑰·底)
|
||||
internal val tokenColors = listOf(
|
||||
Color(0xFFFFD1DC), // cache hit — 浅粉(上面)
|
||||
Color(0xFFFF85A2), // cache miss input — 粉色(中间)
|
||||
Color(0xFFE91E63) // output — 深粉(下面)
|
||||
Color(0xFFFFE3EC), // cache hit — 浅樱粉(上面)
|
||||
Color(0xFFFFA3BC), // cache miss input — 玫瑰粉(中间)
|
||||
Color(0xFFC9436C) // output — 深玫瑰(主色,下面)
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@ -97,13 +103,14 @@ 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) }
|
||||
var showCustomMonthPicker by remember { mutableStateOf(false) }
|
||||
var showCustomRangeStart by remember { mutableStateOf(false) }
|
||||
var showCustomRangeEnd by remember { mutableStateOf(false) }
|
||||
var customRangeStartMs by remember { mutableStateOf(0L) }
|
||||
var customRangeStartDate by remember { mutableStateOf<LocalDate?>(null) }
|
||||
var showCostDetail by remember { mutableStateOf(false) }
|
||||
var showReqDetail by remember { mutableStateOf(false) }
|
||||
var showTokenDetail by remember { mutableStateOf(false) }
|
||||
@ -112,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)
|
||||
)
|
||||
@ -122,16 +129,17 @@ 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 {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize().padding(innerPadding)) {
|
||||
// 容器自身宽度 > 700dp 时图表并排
|
||||
val wideEnough = maxWidth > 700.dp
|
||||
val hPad = if (wideEnough) 24.dp else 16.dp
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().padding(horizontal = hPad),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// ─── 顶部控制:粒度 + 模型 ───
|
||||
item {
|
||||
Row(
|
||||
@ -142,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) {
|
||||
@ -165,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 ->
|
||||
@ -195,102 +203,181 @@ fun UsageChartScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 图表1:消耗金额(按模型堆叠柱状图) ───
|
||||
item {
|
||||
val models = state.selectedModels.ifEmpty { state.allModels.toSet() }
|
||||
val costTotal = state.buckets.sumOf { it.totalCost.toDouble() / 100_000_000.0 }
|
||||
ChartCard(
|
||||
title = "消耗金额 (USD)",
|
||||
summary = "$${String.format(Locale.US, "%.4f", costTotal)}",
|
||||
onSummaryClick = { showCostDetail = true }
|
||||
) {
|
||||
StackedBarChart(
|
||||
buckets = state.buckets,
|
||||
valueSelector = { it.totalCost.toDouble() / 100_000_000.0 },
|
||||
stackSelector = { bucket ->
|
||||
models.mapIndexedNotNull { idx, model ->
|
||||
val v = bucket.byModel[model]?.cost ?: return@mapIndexedNotNull null
|
||||
v.toDouble() / 100_000_000.0 to modelColors[idx % modelColors.size]
|
||||
if (wideEnough) {
|
||||
// 面板够宽:前两张图表并排,第三张独立
|
||||
item {
|
||||
val models = state.selectedModels.ifEmpty { state.allModels.toSet() }
|
||||
val costTotal = state.buckets.sumOf { it.totalCost.toDouble() / 100_000_000.0 }
|
||||
val reqTotal = state.buckets.sumOf { it.totalRequests }
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
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 },
|
||||
stackSelector = { bucket ->
|
||||
models.mapIndexedNotNull { idx, model ->
|
||||
val v = bucket.byModel[model]?.cost ?: return@mapIndexedNotNull null
|
||||
v.toDouble() / 100_000_000.0 to modelColors[idx % modelColors.size]
|
||||
}
|
||||
},
|
||||
stackLabels = { bucket ->
|
||||
models.mapNotNull { model ->
|
||||
if (bucket.byModel[model] != null) model else null
|
||||
}
|
||||
},
|
||||
formatValue = { "$${String.format(Locale.US, "%.4f", it)}" },
|
||||
granularity = state.granularity,
|
||||
legendItems = models.mapIndexed { idx, m ->
|
||||
m to modelColors[idx % modelColors.size]
|
||||
},
|
||||
useUtc8 = state.useUtc8
|
||||
)
|
||||
}
|
||||
},
|
||||
stackLabels = { bucket ->
|
||||
models.mapNotNull { model ->
|
||||
if (bucket.byModel[model] != null) model else null
|
||||
}
|
||||
},
|
||||
formatValue = { "$${String.format(Locale.US, "%.4f", it)}" },
|
||||
granularity = state.granularity,
|
||||
legendItems = models.mapIndexed { idx, m ->
|
||||
m to modelColors[idx % modelColors.size]
|
||||
}
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
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 = { context.getString(R.string.count_times, it.toInt()) },
|
||||
granularity = state.granularity,
|
||||
useUtc8 = state.useUtc8
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 第三张图表独立一行
|
||||
item {
|
||||
val tokTotal = state.buckets.sumOf { it.cacheHitTokens + it.inputTokens + it.outputTokens }
|
||||
ChartCard(stringResource(R.string.chart_tokens), formatTokenComma(tokTotal), { showTokenDetail = true }) {
|
||||
StackedBarChart(
|
||||
buckets = state.buckets,
|
||||
valueSelector = { (it.cacheHitTokens + it.inputTokens + it.outputTokens).toDouble() },
|
||||
stackSelector = { bucket ->
|
||||
listOfNotNull(
|
||||
bucket.outputTokens.toDouble() to tokenColors[2],
|
||||
bucket.inputTokens.toDouble() to tokenColors[1],
|
||||
bucket.cacheHitTokens.toDouble() to tokenColors[0]
|
||||
)
|
||||
},
|
||||
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,
|
||||
useUtc8 = state.useUtc8
|
||||
)
|
||||
}
|
||||
ChartLegend(
|
||||
items = 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(
|
||||
title = stringResource(R.string.chart_cost),
|
||||
summary = "$${String.format(Locale.US, "%.4f", costTotal)}",
|
||||
onSummaryClick = { showCostDetail = true }
|
||||
) {
|
||||
StackedBarChart(
|
||||
buckets = state.buckets,
|
||||
valueSelector = { it.totalCost.toDouble() / 100_000_000.0 },
|
||||
stackSelector = { bucket ->
|
||||
models.mapIndexedNotNull { idx, model ->
|
||||
val v = bucket.byModel[model]?.cost ?: return@mapIndexedNotNull null
|
||||
v.toDouble() / 100_000_000.0 to modelColors[idx % modelColors.size]
|
||||
}
|
||||
},
|
||||
stackLabels = { bucket ->
|
||||
models.mapNotNull { model ->
|
||||
if (bucket.byModel[model] != null) model else null
|
||||
}
|
||||
},
|
||||
formatValue = { "$${String.format(Locale.US, "%.4f", it)}" },
|
||||
granularity = state.granularity,
|
||||
legendItems = models.mapIndexed { idx, m ->
|
||||
m to modelColors[idx % modelColors.size]
|
||||
},
|
||||
useUtc8 = state.useUtc8
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 图表2:API 请求次数 ───
|
||||
item {
|
||||
val reqTotal = state.buckets.sumOf { it.totalRequests }
|
||||
ChartCard(
|
||||
title = "API 请求次数",
|
||||
summary = "${reqTotal}次",
|
||||
onSummaryClick = { showReqDetail = true }
|
||||
) {
|
||||
LineChart(
|
||||
buckets = state.buckets,
|
||||
valueSelector = { it.totalRequests.toFloat() },
|
||||
lineColor = StrawberryPink,
|
||||
formatValue = { "${it.toInt()}次" },
|
||||
granularity = state.granularity
|
||||
)
|
||||
item {
|
||||
val reqTotal = state.buckets.sumOf { it.totalRequests }
|
||||
ChartCard(
|
||||
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 = { context.getString(R.string.count_times, it.toInt()) },
|
||||
granularity = state.granularity,
|
||||
useUtc8 = state.useUtc8
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 图表3:Token 消耗(底→顶:输出 / 未命中输入 / 命中缓存) ───
|
||||
item {
|
||||
val tokTotal = state.buckets.sumOf { it.cacheHitTokens + it.inputTokens + it.outputTokens }
|
||||
ChartCard(
|
||||
title = "Token 消耗",
|
||||
summary = formatTokenComma(tokTotal),
|
||||
onSummaryClick = { showTokenDetail = true }
|
||||
) {
|
||||
StackedBarChart(
|
||||
buckets = state.buckets,
|
||||
valueSelector = { (it.cacheHitTokens + it.inputTokens + it.outputTokens).toDouble() },
|
||||
stackSelector = { bucket ->
|
||||
listOfNotNull(
|
||||
bucket.outputTokens.toDouble() to tokenColors[2], // 输出 — 底
|
||||
bucket.inputTokens.toDouble() to tokenColors[1], // 输入(未命中) — 中
|
||||
bucket.cacheHitTokens.toDouble() to tokenColors[0] // 命中缓存 — 顶
|
||||
)
|
||||
},
|
||||
stackLabels = { listOf("输出", "输入(未命中)", "命中缓存") },
|
||||
formatValue = { formatTokenComma(it.toLong()) },
|
||||
granularity = state.granularity,
|
||||
tooltipReversed = true
|
||||
item {
|
||||
val tokTotal = state.buckets.sumOf { it.cacheHitTokens + it.inputTokens + it.outputTokens }
|
||||
ChartCard(
|
||||
title = stringResource(R.string.chart_tokens),
|
||||
summary = formatTokenComma(tokTotal),
|
||||
onSummaryClick = { showTokenDetail = true }
|
||||
) {
|
||||
StackedBarChart(
|
||||
buckets = state.buckets,
|
||||
valueSelector = { (it.cacheHitTokens + it.inputTokens + it.outputTokens).toDouble() },
|
||||
stackSelector = { bucket ->
|
||||
listOfNotNull(
|
||||
bucket.outputTokens.toDouble() to tokenColors[2],
|
||||
bucket.inputTokens.toDouble() to tokenColors[1],
|
||||
bucket.cacheHitTokens.toDouble() to tokenColors[0]
|
||||
)
|
||||
},
|
||||
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,
|
||||
useUtc8 = state.useUtc8
|
||||
)
|
||||
}
|
||||
ChartLegend(
|
||||
items = 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]
|
||||
)
|
||||
)
|
||||
}
|
||||
ChartLegend(
|
||||
items = listOf(
|
||||
"输入(未命中)" to tokenColors[1],
|
||||
"命中缓存" to tokenColors[0],
|
||||
"输出" to tokenColors[2]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
item { Spacer(Modifier.height(32.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 自定义日期选择器 ───
|
||||
if (showCustomDayPicker) {
|
||||
DateTimePickerDialog(
|
||||
title = "选择日期",
|
||||
title = stringResource(R.string.date_select_day),
|
||||
onConfirm = { ms ->
|
||||
val utc = java.time.ZoneOffset.UTC
|
||||
val dayStart = java.time.Instant.ofEpochMilli(ms).atOffset(utc).toLocalDate()
|
||||
.atStartOfDay(utc).toInstant().toEpochMilli()
|
||||
viewModel.setCustomDayRange(dayStart, dayStart + 86400_000L - 1)
|
||||
viewModel.setCustomDay(ms.toUtcLocalDate())
|
||||
showCustomDayPicker = false
|
||||
},
|
||||
onDismiss = { showCustomDayPicker = false }
|
||||
@ -298,9 +385,9 @@ fun UsageChartScreen(
|
||||
}
|
||||
if (showCustomMonthPicker) {
|
||||
DateTimePickerDialog(
|
||||
title = "选择月份(任意一天)",
|
||||
title = stringResource(R.string.date_select_month),
|
||||
onConfirm = { ms ->
|
||||
viewModel.setCustomMonth(ms)
|
||||
viewModel.setCustomMonth(ms.toUtcLocalDate())
|
||||
showCustomMonthPicker = false
|
||||
},
|
||||
onDismiss = { showCustomMonthPicker = false }
|
||||
@ -308,9 +395,9 @@ fun UsageChartScreen(
|
||||
}
|
||||
if (showCustomRangeStart) {
|
||||
DateTimePickerDialog(
|
||||
title = "开始日期",
|
||||
title = stringResource(R.string.date_pick_start),
|
||||
onConfirm = { ms ->
|
||||
customRangeStartMs = ms
|
||||
customRangeStartDate = ms.toUtcLocalDate()
|
||||
showCustomRangeStart = false
|
||||
showCustomRangeEnd = true
|
||||
},
|
||||
@ -319,15 +406,14 @@ fun UsageChartScreen(
|
||||
}
|
||||
if (showCustomRangeEnd) {
|
||||
DateTimePickerDialog(
|
||||
title = "结束日期",
|
||||
title = stringResource(R.string.date_pick_end),
|
||||
onConfirm = { ms ->
|
||||
val utc = java.time.ZoneOffset.UTC
|
||||
val startDay = java.time.Instant.ofEpochMilli(customRangeStartMs).atOffset(utc).toLocalDate()
|
||||
.atStartOfDay(utc).toInstant().toEpochMilli()
|
||||
val endDay = java.time.Instant.ofEpochMilli(ms).atOffset(utc).toLocalDate()
|
||||
.atStartOfDay(utc).toInstant().toEpochMilli() + 86400_000L - 1
|
||||
viewModel.setCustomRange(startDay, endDay)
|
||||
showCustomRangeEnd = false
|
||||
val startDate = customRangeStartDate ?: return@DateTimePickerDialog
|
||||
val endDate = ms.toUtcLocalDate()
|
||||
if (!endDate.isBefore(startDate)) {
|
||||
viewModel.setCustomRange(startDate, endDate)
|
||||
showCustomRangeEnd = false
|
||||
}
|
||||
},
|
||||
onDismiss = { showCustomRangeEnd = false }
|
||||
)
|
||||
@ -338,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 ->
|
||||
@ -353,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 ->
|
||||
@ -367,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) {
|
||||
@ -382,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)) } }
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -409,6 +495,9 @@ fun UsageChartScreen(
|
||||
// 图表卡片容器
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
private fun Long.toUtcLocalDate(): LocalDate =
|
||||
Instant.ofEpochMilli(this).atOffset(ZoneOffset.UTC).toLocalDate()
|
||||
|
||||
@Composable
|
||||
internal fun ChartCard(
|
||||
title: String,
|
||||
@ -455,20 +544,22 @@ internal fun StackedBarChart(
|
||||
tooltipReversed: Boolean = false,
|
||||
formatValue: (Double) -> String,
|
||||
granularity: ChartGranularity,
|
||||
legendItems: List<Pair<String, Color>> = emptyList()
|
||||
legendItems: List<Pair<String, Color>> = emptyList(),
|
||||
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<ChartBucket?>(null) }
|
||||
// key = buckets:切换时间范围/筛选后重置 tooltip,避免残留旧数据
|
||||
var tooltipBucket by remember(buckets) { mutableStateOf(buckets.lastOrNull()) }
|
||||
val density = LocalDensity.current
|
||||
val d = density.density
|
||||
val barCount = buckets.size
|
||||
val chartHPx = 160f * d
|
||||
val labelHPx = 20f * d
|
||||
|
||||
val maxVal = buckets.maxOf { valueSelector(it) }.coerceAtLeast(1.0)
|
||||
val maxVal = buckets.maxOf { valueSelector(it) }.coerceAtLeast(0.0)
|
||||
val refTop = niceCeil(maxVal)
|
||||
val refHalf = refTop / 2.0
|
||||
// 以 refTop 为满刻度,柱子不会触顶
|
||||
@ -512,7 +603,7 @@ internal fun StackedBarChart(
|
||||
drawRect(color, Offset(x, yBase - h), Size(barW, h))
|
||||
yBase -= h
|
||||
}
|
||||
val (label, show) = formatChartTime(bucket.ts, granularity, i, barCount)
|
||||
val (label, show) = formatChartTime(bucket.ts, granularity, i, barCount, useUtc8)
|
||||
if (show) {
|
||||
drawContext.canvas.nativeCanvas.drawText(
|
||||
label, x + barW / 2, chartHPx + labelHPx - 4f * d,
|
||||
@ -552,8 +643,8 @@ internal fun StackedBarChart(
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
|
||||
) {
|
||||
Column(Modifier.padding(8.dp)) {
|
||||
Text(formatChartTime(bucket.ts, granularity, 0, 1).first, style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold)
|
||||
Text("合计: ${formatValue(total)}", style = MaterialTheme.typography.bodySmall, color = StrawberryPink)
|
||||
Text(formatChartTime(bucket.ts, granularity, 0, 1, useUtc8).first, style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold)
|
||||
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()
|
||||
@ -584,19 +675,21 @@ internal fun LineChart(
|
||||
valueSelector: (ChartBucket) -> Float,
|
||||
lineColor: Color,
|
||||
formatValue: (Float) -> String,
|
||||
granularity: ChartGranularity
|
||||
granularity: ChartGranularity,
|
||||
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<ChartBucket?>(null) }
|
||||
// key = buckets:切换时间范围/筛选后重置 tooltip,避免残留旧数据
|
||||
var tooltipBucket by remember(buckets) { mutableStateOf(buckets.lastOrNull()) }
|
||||
val density = LocalDensity.current
|
||||
val d = density.density
|
||||
val barCount = buckets.size
|
||||
val chartHPx = 140f * d
|
||||
val labelHPx = 20f * d
|
||||
val maxVal = buckets.maxOf { valueSelector(it) }.coerceAtLeast(1f)
|
||||
val maxVal = buckets.maxOf { valueSelector(it) }.coerceAtLeast(0f)
|
||||
val refTop = niceCeil(maxVal.toDouble()).toFloat()
|
||||
val refHalf = refTop / 2f
|
||||
val scale = refTop
|
||||
@ -641,7 +734,7 @@ internal fun LineChart(
|
||||
drawCircle(lineColor, radius = 3f * d, center = p)
|
||||
}
|
||||
buckets.forEachIndexed { i, b ->
|
||||
val (label, show) = formatChartTime(b.ts, granularity, i, barCount)
|
||||
val (label, show) = formatChartTime(b.ts, granularity, i, barCount, useUtc8)
|
||||
if (show) {
|
||||
drawContext.canvas.nativeCanvas.drawText(
|
||||
label, i * barAreaPx + barAreaPx / 2, chartHPx + labelHPx - 4f * d,
|
||||
@ -675,7 +768,7 @@ internal fun LineChart(
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
|
||||
) {
|
||||
Row(Modifier.padding(8.dp), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(formatChartTime(bucket.ts, granularity, 0, 1).first, style = MaterialTheme.typography.labelSmall)
|
||||
Text(formatChartTime(bucket.ts, granularity, 0, 1, useUtc8).first, style = MaterialTheme.typography.labelSmall)
|
||||
Text(formatValue(valueSelector(bucket)), style = MaterialTheme.typography.bodySmall, color = StrawberryPink)
|
||||
}
|
||||
}
|
||||
@ -711,34 +804,39 @@ internal fun ChartLegend(items: List<Pair<String, Color>>) {
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
/** 返回格式化标签 + 是否应该绘制 */
|
||||
private fun formatChartTime(ts: Long, granularity: ChartGranularity, index: Int, total: Int): Pair<String, Boolean> {
|
||||
val show = when (granularity) {
|
||||
ChartGranularity.LAST_24H_HOURLY ->
|
||||
index == 0 || index == total - 1 || index % 3 == 0 // 每3小时
|
||||
ChartGranularity.TODAY_HOURLY,
|
||||
ChartGranularity.YESTERDAY_HOURLY,
|
||||
ChartGranularity.CUSTOM_DAY_HOURLY ->
|
||||
index == 0 || index == total - 1 // 只显示首尾小时
|
||||
ChartGranularity.LAST_7D_DAILY,
|
||||
ChartGranularity.THIS_MONTH_DAILY,
|
||||
ChartGranularity.CUSTOM_MONTH_DAILY,
|
||||
ChartGranularity.CUSTOM_RANGE_DAILY ->
|
||||
index == 0 || index == total - 1 // 只显示首尾日期
|
||||
else -> true // 5小时全显示
|
||||
}
|
||||
val fmt = when (granularity) {
|
||||
ChartGranularity.LAST_5H_HOURLY,
|
||||
ChartGranularity.LAST_24H_HOURLY,
|
||||
ChartGranularity.TODAY_HOURLY,
|
||||
ChartGranularity.YESTERDAY_HOURLY,
|
||||
ChartGranularity.CUSTOM_DAY_HOURLY -> SimpleDateFormat("HH:mm", Locale.getDefault()).apply {
|
||||
timeZone = java.util.TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
else -> SimpleDateFormat("MM/dd", Locale.getDefault()).apply {
|
||||
timeZone = java.util.TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
}
|
||||
return fmt.format(Date(ts)) to show
|
||||
private fun formatChartTime(ts: Long, granularity: ChartGranularity, index: Int, total: Int, useUtc8: Boolean = false): Pair<String, Boolean> {
|
||||
val show = when (granularity) {
|
||||
ChartGranularity.LAST_12H_10MIN ->
|
||||
index == 0 || index == total - 1 || index % 12 == 0 // 每2小时(每12个10分钟桶)
|
||||
ChartGranularity.LAST_24H_HOURLY ->
|
||||
index == 0 || index == total - 1 || index % 3 == 0 // 每3小时
|
||||
ChartGranularity.TODAY_HOURLY,
|
||||
ChartGranularity.YESTERDAY_HOURLY,
|
||||
ChartGranularity.CUSTOM_DAY_HOURLY ->
|
||||
index == 0 || index == total - 1 // 只显示首尾小时
|
||||
ChartGranularity.LAST_7D_DAILY,
|
||||
ChartGranularity.LAST_30D_DAILY,
|
||||
ChartGranularity.THIS_MONTH_DAILY,
|
||||
ChartGranularity.CUSTOM_MONTH_DAILY,
|
||||
ChartGranularity.CUSTOM_RANGE_DAILY ->
|
||||
index == 0 || index == total - 1 // 只显示首尾日期
|
||||
else -> true // 5小时全显示
|
||||
}
|
||||
val tz = if (useUtc8) java.util.TimeZone.getTimeZone("Asia/Shanghai") else java.util.TimeZone.getTimeZone("UTC")
|
||||
val fmt = when (granularity) {
|
||||
ChartGranularity.LAST_12H_10MIN,
|
||||
ChartGranularity.LAST_5H_HOURLY,
|
||||
ChartGranularity.LAST_24H_HOURLY,
|
||||
ChartGranularity.TODAY_HOURLY,
|
||||
ChartGranularity.YESTERDAY_HOURLY,
|
||||
ChartGranularity.CUSTOM_DAY_HOURLY -> SimpleDateFormat("HH:mm", Locale.getDefault()).apply {
|
||||
timeZone = tz
|
||||
}
|
||||
else -> SimpleDateFormat("MM/dd", Locale.getDefault()).apply {
|
||||
timeZone = tz
|
||||
}
|
||||
}
|
||||
return fmt.format(Date(ts)) to show
|
||||
}
|
||||
|
||||
private fun formatTokenCount(tokens: Long): String = when {
|
||||
@ -760,9 +858,15 @@ private fun niceCeil(v: Double): Double {
|
||||
val normalized = v / magnitude // 1..10
|
||||
val nice = when {
|
||||
normalized <= 1.0 -> 1.0
|
||||
normalized <= 1.15 -> 1.15 // 15% 余量
|
||||
normalized <= 1.25 -> 1.25 // 25% 余量
|
||||
normalized <= 1.5 -> 1.5 // 50% 余量
|
||||
normalized <= 2.0 -> 2.0
|
||||
normalized <= 2.5 -> 2.5
|
||||
normalized <= 3.0 -> 3.0
|
||||
normalized <= 4.0 -> 4.0
|
||||
normalized <= 5.0 -> 5.0
|
||||
normalized <= 7.5 -> 7.5
|
||||
else -> 10.0
|
||||
}
|
||||
return nice * magnitude
|
||||
|
||||
@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.rainy.token.data.local.ChartAggregator
|
||||
import com.rainy.token.data.local.ChartBucket
|
||||
import com.rainy.token.data.local.ChartGranularity
|
||||
import com.rainy.token.data.local.ChartSettingsStore
|
||||
import com.rainy.token.data.local.UsageCache
|
||||
import com.rainy.token.data.repository.CredentialRepository
|
||||
import com.rainy.token.domain.model.Credential
|
||||
@ -14,8 +15,10 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneOffset
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
@ -23,22 +26,37 @@ import javax.inject.Provider
|
||||
@HiltViewModel
|
||||
class UsageChartViewModel @Inject constructor(
|
||||
private val cacheProvider: Provider<UsageCache>,
|
||||
private val credentialRepository: CredentialRepository
|
||||
private val credentialRepository: CredentialRepository,
|
||||
private val chartSettingsStore: ChartSettingsStore
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(ChartUiState())
|
||||
private val _state = MutableStateFlow(ChartUiState(useUtc8 = false))
|
||||
val state: StateFlow<ChartUiState> = _state.asStateFlow()
|
||||
|
||||
private var workspaceIdOverride: String? = null
|
||||
private var loadGeneration = 0
|
||||
/** 是否允许自动降级到更大时间窗口(仅初始加载时允许,用户手动选粒度后关闭) */
|
||||
private var allowFallback = true
|
||||
|
||||
/** 覆盖 workspaceId,用于 CCGO 等非 OCGO 服务。必须在 load() 前调用。 */
|
||||
fun setWorkspace(wid: String) {
|
||||
workspaceIdOverride = wid
|
||||
loadGeneration++
|
||||
allowFallback = true
|
||||
_state.value = ChartUiState(useUtc8 = _state.value.useUtc8)
|
||||
load()
|
||||
}
|
||||
|
||||
private suspend fun workspaceId(): String? {
|
||||
workspaceIdOverride?.let { return it }
|
||||
val c = credentialRepository.get(ServiceType.OPENCODE_GO)
|
||||
return (c as? Credential.SessionCredential)?.workspaceId?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
// 不在 init 自动加载,由 Composable 层显式触发 load()(OCGO)或 setWorkspace()(CCGO)
|
||||
|
||||
fun setGranularity(g: ChartGranularity) {
|
||||
allowFallback = false // 用户手动选择,关闭自动降级
|
||||
_state.update { it.copy(granularity = g) }
|
||||
load()
|
||||
}
|
||||
@ -62,9 +80,23 @@ class UsageChartViewModel @Inject constructor(
|
||||
load()
|
||||
}
|
||||
|
||||
fun toggleUtc8() {
|
||||
_state.update { it.copy(useUtc8 = !it.useUtc8) }
|
||||
viewModelScope.launch {
|
||||
chartSettingsStore.setUseUtc8(_state.value.useUtc8)
|
||||
}
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
// 首次加载时同步读取 UTC 偏好,避免用默认值 false 加载后再切换
|
||||
if (!_state.value.useUtc8Initialized) {
|
||||
val saved = chartSettingsStore.useUtc8Flow.first()
|
||||
_state.update { it.copy(useUtc8 = saved, useUtc8Initialized = true) }
|
||||
}
|
||||
val wid = workspaceId() ?: return@launch
|
||||
val genAtStart = loadGeneration
|
||||
val cache = cacheProvider.get()
|
||||
val allModels = cache.getDistinctModels(wid)
|
||||
|
||||
@ -76,18 +108,22 @@ class UsageChartViewModel @Inject constructor(
|
||||
val filtered = if (selected.isEmpty()) records
|
||||
else records.filter { it.model in selected }
|
||||
|
||||
val offsetHours = if (_state.value.useUtc8) 8 else 0
|
||||
val buckets = when (_state.value.granularity) {
|
||||
ChartGranularity.LAST_12H_10MIN ->
|
||||
ChartAggregator.aggregate10Min(filtered, offsetHours)
|
||||
ChartGranularity.LAST_5H_HOURLY,
|
||||
ChartGranularity.LAST_24H_HOURLY,
|
||||
ChartGranularity.TODAY_HOURLY,
|
||||
ChartGranularity.YESTERDAY_HOURLY,
|
||||
ChartGranularity.CUSTOM_DAY_HOURLY ->
|
||||
ChartAggregator.aggregateHourly(filtered)
|
||||
ChartAggregator.aggregateHourly(filtered, offsetHours)
|
||||
ChartGranularity.THIS_MONTH_DAILY,
|
||||
ChartGranularity.LAST_7D_DAILY,
|
||||
ChartGranularity.LAST_30D_DAILY,
|
||||
ChartGranularity.CUSTOM_MONTH_DAILY,
|
||||
ChartGranularity.CUSTOM_RANGE_DAILY ->
|
||||
ChartAggregator.aggregateDaily(filtered)
|
||||
ChartAggregator.aggregateDaily(filtered, offsetHours)
|
||||
}
|
||||
|
||||
// 填充可能空缺的小时/天桶
|
||||
@ -96,79 +132,106 @@ class UsageChartViewModel @Inject constructor(
|
||||
val result = if (_state.value.granularity == ChartGranularity.LAST_5H_HOURLY)
|
||||
filled.takeLast(5) else filled
|
||||
|
||||
// 如果 loadGeneration 已经变化(setWorkspace 被调用),丢弃这次结果
|
||||
if (loadGeneration != genAtStart) return@launch
|
||||
|
||||
// 自动降级:当前粒度无实际数据且允许降级 → 尝试更大窗口
|
||||
val hasData = result.any { it.totalRequests > 0 }
|
||||
if (allowFallback && !hasData) {
|
||||
val fallbackTo = when (_state.value.granularity) {
|
||||
ChartGranularity.LAST_5H_HOURLY -> ChartGranularity.LAST_12H_10MIN
|
||||
ChartGranularity.LAST_12H_10MIN -> ChartGranularity.LAST_7D_DAILY
|
||||
ChartGranularity.LAST_7D_DAILY -> ChartGranularity.THIS_MONTH_DAILY
|
||||
else -> null
|
||||
}
|
||||
if (fallbackTo != null) {
|
||||
_state.update { it.copy(granularity = fallbackTo) }
|
||||
load() // 递归重新加载
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
// 降级后或用户手动选择 → 关闭降级,正常显示
|
||||
allowFallback = false
|
||||
|
||||
// 图例数据源:只保留当前显示范围内实际出现过的模型
|
||||
val rangeModels = result.flatMap { it.byModel.keys }.distinct().sorted()
|
||||
|
||||
_state.update {
|
||||
it.copy(
|
||||
buckets = result,
|
||||
allModels = allModels,
|
||||
rangeModels = rangeModels,
|
||||
loading = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setCustomDayRange(from: Long, to: Long) {
|
||||
_state.update { it.copy(granularity = ChartGranularity.CUSTOM_DAY_HOURLY) }
|
||||
_customDayRange = from to to
|
||||
fun setCustomDay(date: LocalDate) {
|
||||
_state.update { it.copy(granularity = ChartGranularity.CUSTOM_DAY_HOURLY, customDay = date) }
|
||||
_customDay = date
|
||||
load()
|
||||
}
|
||||
|
||||
fun setCustomMonth(monthStartMs: Long) {
|
||||
_state.update { it.copy(granularity = ChartGranularity.CUSTOM_MONTH_DAILY) }
|
||||
_customMonthStart = monthStartMs
|
||||
fun setCustomMonth(monthDate: LocalDate) {
|
||||
val month = monthDate.withDayOfMonth(1)
|
||||
_state.update { it.copy(granularity = ChartGranularity.CUSTOM_MONTH_DAILY, customMonth = month) }
|
||||
_customMonth = month
|
||||
load()
|
||||
}
|
||||
|
||||
fun setCustomRange(from: Long, to: Long) {
|
||||
_state.update { it.copy(granularity = ChartGranularity.CUSTOM_RANGE_DAILY) }
|
||||
_customRange = from to to
|
||||
fun setCustomRange(fromDate: LocalDate, toDate: LocalDate) {
|
||||
if (toDate.isBefore(fromDate)) return
|
||||
_state.update { it.copy(granularity = ChartGranularity.CUSTOM_RANGE_DAILY, customRange = fromDate to toDate) }
|
||||
_customRange = fromDate to toDate
|
||||
load()
|
||||
}
|
||||
|
||||
private var _customDayRange: Pair<Long, Long> = 0L to 0L
|
||||
private var _customMonthStart: Long = 0L
|
||||
private var _customRange: Pair<Long, Long> = 0L to 0L
|
||||
private var _customDay: LocalDate? = null
|
||||
private var _customMonth: LocalDate? = null
|
||||
private var _customRange: Pair<LocalDate, LocalDate>? = null
|
||||
|
||||
private fun timeRangeFor(g: ChartGranularity): Pair<Long?, Long?> {
|
||||
val now = System.currentTimeMillis()
|
||||
val utc = ZoneOffset.UTC
|
||||
val today = Instant.ofEpochMilli(now).atOffset(utc).toLocalDate()
|
||||
val todayMidnight = today.atStartOfDay(utc).toInstant().toEpochMilli()
|
||||
val zoneOffset = if (_state.value.useUtc8) ZoneOffset.ofHours(8) else ZoneOffset.UTC
|
||||
val today = Instant.ofEpochMilli(now).atOffset(zoneOffset).toLocalDate()
|
||||
val todayMidnight = today.atStartOfDay(zoneOffset).toInstant().toEpochMilli()
|
||||
return when (g) {
|
||||
ChartGranularity.LAST_12H_10MIN -> now - 12 * 3600_000L to null
|
||||
ChartGranularity.LAST_5H_HOURLY -> now - 5 * 3600_000L to null
|
||||
ChartGranularity.LAST_24H_HOURLY -> now - 24 * 3600_000L to null
|
||||
ChartGranularity.TODAY_HOURLY -> todayMidnight to todayMidnight + 86400_000L - 1
|
||||
ChartGranularity.YESTERDAY_HOURLY -> todayMidnight - 86400_000L to todayMidnight - 1
|
||||
ChartGranularity.LAST_7D_DAILY -> {
|
||||
todayMidnight - 6 * 86400_000L to todayMidnight + 86400_000L - 1
|
||||
}
|
||||
ChartGranularity.CUSTOM_DAY_HOURLY -> {
|
||||
if (_customDayRange.first == 0L) todayMidnight to todayMidnight + 86400_000L - 1
|
||||
else _customDayRange.first to _customDayRange.second
|
||||
}
|
||||
ChartGranularity.THIS_MONTH_DAILY -> {
|
||||
val firstOfMonth = today.withDayOfMonth(1).atStartOfDay(utc).toInstant().toEpochMilli()
|
||||
val lastOfMonth = today.withDayOfMonth(today.lengthOfMonth())
|
||||
.atStartOfDay(utc).toInstant().toEpochMilli() + 86400_000L - 1
|
||||
firstOfMonth to lastOfMonth
|
||||
}
|
||||
ChartGranularity.LAST_7D_DAILY -> todayMidnight - 6 * 86400_000L to todayMidnight + 86400_000L - 1
|
||||
ChartGranularity.LAST_30D_DAILY -> todayMidnight - 29 * 86400_000L to todayMidnight + 86400_000L - 1
|
||||
ChartGranularity.CUSTOM_DAY_HOURLY -> dayRange(_customDay ?: today, zoneOffset)
|
||||
ChartGranularity.THIS_MONTH_DAILY -> monthRange(today.withDayOfMonth(1), zoneOffset)
|
||||
ChartGranularity.CUSTOM_MONTH_DAILY -> {
|
||||
if (_customMonthStart == 0L) null to null
|
||||
else {
|
||||
val targetMonth = Instant.ofEpochMilli(_customMonthStart).atOffset(utc).toLocalDate()
|
||||
.withDayOfMonth(1)
|
||||
val firstOfMonth = targetMonth.atStartOfDay(utc).toInstant().toEpochMilli()
|
||||
val lastOfMonth = targetMonth.withDayOfMonth(targetMonth.lengthOfMonth())
|
||||
.atStartOfDay(utc).toInstant().toEpochMilli() + 86400_000L - 1
|
||||
firstOfMonth to lastOfMonth
|
||||
}
|
||||
val month = _customMonth ?: return null to null
|
||||
monthRange(month, zoneOffset)
|
||||
}
|
||||
ChartGranularity.CUSTOM_RANGE_DAILY -> {
|
||||
if (_customRange.first == 0L) null to null
|
||||
else _customRange.first to _customRange.second
|
||||
val range = _customRange ?: return null to null
|
||||
val from = range.first.atStartOfDay(zoneOffset).toInstant().toEpochMilli()
|
||||
val to = range.second.plusDays(1).atStartOfDay(zoneOffset).toInstant().toEpochMilli() - 1
|
||||
from to to
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun dayRange(date: LocalDate, zoneOffset: ZoneOffset): Pair<Long, Long> {
|
||||
val from = date.atStartOfDay(zoneOffset).toInstant().toEpochMilli()
|
||||
val to = date.plusDays(1).atStartOfDay(zoneOffset).toInstant().toEpochMilli() - 1
|
||||
return from to to
|
||||
}
|
||||
|
||||
private fun monthRange(month: LocalDate, zoneOffset: ZoneOffset): Pair<Long, Long> {
|
||||
val firstDay = month.withDayOfMonth(1)
|
||||
val from = firstDay.atStartOfDay(zoneOffset).toInstant().toEpochMilli()
|
||||
val to = firstDay.plusMonths(1).atStartOfDay(zoneOffset).toInstant().toEpochMilli() - 1
|
||||
return from to to
|
||||
}
|
||||
|
||||
|
||||
private fun fillGaps(
|
||||
buckets: List<ChartBucket>,
|
||||
granularity: ChartGranularity,
|
||||
@ -177,6 +240,7 @@ class UsageChartViewModel @Inject constructor(
|
||||
): List<ChartBucket> {
|
||||
if (buckets.isEmpty() || from == null) return buckets
|
||||
val interval = when (granularity) {
|
||||
ChartGranularity.LAST_12H_10MIN -> 600_000L // 10分钟
|
||||
ChartGranularity.LAST_5H_HOURLY,
|
||||
ChartGranularity.LAST_24H_HOURLY,
|
||||
ChartGranularity.TODAY_HOURLY,
|
||||
@ -187,7 +251,9 @@ class UsageChartViewModel @Inject constructor(
|
||||
val end = to ?: (System.currentTimeMillis() / interval * interval)
|
||||
val result = mutableListOf<ChartBucket>()
|
||||
val map = buckets.associateBy { it.ts }
|
||||
var t = from / interval * interval
|
||||
// 日粒度:from 已由 timeRangeFor 按当前时区对齐到午夜,直接用
|
||||
// 小时粒度:from 是滑动窗口起点,需截断到 interval 边界
|
||||
var t = if (interval == 86_400_000L) from else from / interval * interval
|
||||
while (t <= end) {
|
||||
result.add(map[t] ?: ChartBucket(ts = t, totalCost = 0, totalRequests = 0,
|
||||
inputTokens = 0, cacheHitTokens = 0, outputTokens = 0))
|
||||
@ -201,6 +267,14 @@ data class ChartUiState(
|
||||
val loading: Boolean = true,
|
||||
val granularity: ChartGranularity = ChartGranularity.LAST_5H_HOURLY,
|
||||
val allModels: List<String> = emptyList(),
|
||||
/** 当前显示范围内实际出现过的模型(图例只显示这些) */
|
||||
val rangeModels: List<String> = emptyList(),
|
||||
/** 自定义选择的回显值(选择器打开时预填用) */
|
||||
val customDay: LocalDate? = null,
|
||||
val customMonth: LocalDate? = null,
|
||||
val customRange: Pair<LocalDate, LocalDate>? = null,
|
||||
val selectedModels: Set<String> = emptySet(), // empty = all
|
||||
val buckets: List<ChartBucket> = emptyList()
|
||||
val buckets: List<ChartBucket> = emptyList(),
|
||||
val useUtc8: Boolean = false,
|
||||
val useUtc8Initialized: Boolean = false
|
||||
)
|
||||
@ -38,6 +38,7 @@ import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@ -45,17 +46,20 @@ 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
|
||||
import java.text.SimpleDateFormat
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneOffset
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
@ -67,15 +71,19 @@ import java.util.Locale
|
||||
@Composable
|
||||
fun UsageDataScreen(
|
||||
onBack: () -> Unit,
|
||||
viewModel: UsageDataViewModel = hiltViewModel()
|
||||
viewModel: UsageDataViewModel = hiltViewModel(),
|
||||
autoLoad: Boolean = true
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
// OCGO 首次加载(CCGO 由 setWorkspace 触发,不重复 load)
|
||||
LaunchedEffect(Unit) {
|
||||
if (autoLoad) viewModel.loadData()
|
||||
}
|
||||
|
||||
var timeMenuExpanded by remember { mutableStateOf(false) }
|
||||
var modelMenuExpanded by remember { mutableStateOf(false) }
|
||||
var showStartPicker by remember { mutableStateOf(false) }
|
||||
var showEndPicker by remember { mutableStateOf(false) }
|
||||
var customStartMs by remember { mutableStateOf(0L) }
|
||||
var customEndMs by remember { mutableStateOf(0L) }
|
||||
var showRangePicker by remember { mutableStateOf(false) }
|
||||
var showCustomDayPicker by remember { mutableStateOf(false) }
|
||||
var showCustomMonthPicker by remember { mutableStateOf(false) }
|
||||
var rawRecord by remember { mutableStateOf<UsageRecord?>(null) }
|
||||
@ -85,8 +93,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)
|
||||
)
|
||||
}
|
||||
@ -97,7 +105,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 }) {
|
||||
@ -106,18 +114,18 @@ 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 = {
|
||||
timeMenuExpanded = false; viewModel.setTimeFilter(TimeFilter.Custom(0L, 0L))
|
||||
DropdownMenuItem(text = { Text(stringResource(R.string.time_custom_range)) }, onClick = {
|
||||
timeMenuExpanded = false; showRangePicker = true
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -125,13 +133,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 = {
|
||||
@ -145,17 +153,14 @@ fun UsageDataScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义时间范围
|
||||
if (state.timeFilter is TimeFilter.Custom && (state.timeFilter as TimeFilter.Custom).from == 0L) {
|
||||
CustomTimeRangeRow(customStartMs, customEndMs,
|
||||
{ showStartPicker = true }, { showEndPicker = true },
|
||||
{ viewModel.setTimeFilter(TimeFilter.Custom(customStartMs, customEndMs)) })
|
||||
Text("所有时间按 UTC+0 计算", style = MaterialTheme.typography.bodySmall, color = inkMuted())
|
||||
// 自定义时间范围:确认选择后显示 UTC 说明
|
||||
if (state.timeFilter is TimeFilter.Custom) {
|
||||
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) {
|
||||
@ -180,7 +185,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -190,27 +195,27 @@ 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))
|
||||
HeaderCell("模型", Modifier.weight(1.8f))
|
||||
HeaderCell("输入(缓存)", Modifier.weight(1.5f))
|
||||
HeaderCell("输出", Modifier.weight(1.2f))
|
||||
HeaderCell("成本", Modifier.weight(1.0f))
|
||||
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)) {
|
||||
itemsIndexed(state.records, key = { _, r -> r.id }) { idx, record ->
|
||||
DataRecordRow(record, idx, { rawRecord = it })
|
||||
DataRecordRow(record, idx, { rawRecord = it }, false)
|
||||
}
|
||||
item { Spacer(Modifier.height(8.dp)) }
|
||||
}
|
||||
@ -218,75 +223,103 @@ fun UsageDataScreen(
|
||||
}
|
||||
|
||||
// 日期选择器
|
||||
if (showCustomDayPicker) DateTimePickerDialog("选择日期", { 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 ->
|
||||
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 ->
|
||||
viewModel.setTimeFilter(TimeFilter.Custom(customStartMs, ms)); showEndPicker = false
|
||||
}, { showEndPicker = false })
|
||||
if (showCustomDayPicker) DateOnlyPickerDialog(
|
||||
title = stringResource(R.string.date_select_day),
|
||||
initialDate = (state.timeFilter as? TimeFilter.Custom)?.from?.takeIf { it > 0 }?.toUtcLocalDate(),
|
||||
onConfirm = { date ->
|
||||
val ds = date.toUtcStartOfDayMillis()
|
||||
viewModel.setTimeFilter(TimeFilter.Custom(ds, ds + 86400_000L - 1))
|
||||
showCustomDayPicker = false
|
||||
},
|
||||
onDismiss = { showCustomDayPicker = false }
|
||||
)
|
||||
if (showCustomMonthPicker) DateOnlyPickerDialog(
|
||||
title = stringResource(R.string.date_select_month),
|
||||
initialDate = (state.timeFilter as? TimeFilter.Custom)?.from?.takeIf { it > 0 }?.toUtcLocalDate(),
|
||||
onConfirm = { date ->
|
||||
val utc = ZoneOffset.UTC
|
||||
val msStart = date.withDayOfMonth(1).atStartOfDay(utc).toInstant().toEpochMilli()
|
||||
val msEnd = date.withDayOfMonth(date.lengthOfMonth()).plusDays(1).atStartOfDay(utc).toInstant().toEpochMilli() - 1
|
||||
viewModel.setTimeFilter(TimeFilter.Custom(msStart, msEnd))
|
||||
showCustomMonthPicker = false
|
||||
},
|
||||
onDismiss = { showCustomMonthPicker = false }
|
||||
)
|
||||
if (showRangePicker) {
|
||||
val current = state.timeFilter as? TimeFilter.Custom
|
||||
DateRangePickerDialog(
|
||||
title = stringResource(R.string.date_pick_range),
|
||||
initialStart = current?.from?.takeIf { it > 0 }?.toUtcLocalDate(),
|
||||
initialEnd = current?.to?.takeIf { current.from > 0 }?.toUtcLocalDate(),
|
||||
onConfirm = { from, to ->
|
||||
val fromMs = from.toUtcStartOfDayMillis()
|
||||
val toMs = to.toUtcStartOfDayMillis()
|
||||
viewModel.setTimeFilter(TimeFilter.Custom(fromMs, toMs + 86400_000L - 1))
|
||||
showRangePicker = false
|
||||
},
|
||||
onDismiss = { showRangePicker = false }
|
||||
)
|
||||
}
|
||||
|
||||
// 原始数据弹窗
|
||||
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)) } }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeaderCell(text: String, modifier: Modifier) {
|
||||
Text(text, modifier = modifier, fontSize = 12.sp, fontWeight = FontWeight.Bold,
|
||||
private fun HeaderCell(text: String, modifier: Modifier, expanded: Boolean = false) {
|
||||
Text(text, modifier = modifier,
|
||||
fontSize = if (expanded) 14.sp else 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = inkMuted(), textAlign = TextAlign.Center)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DataRecordRow(record: UsageRecord, idx: Int, onClick: (UsageRecord) -> Unit) {
|
||||
private fun DataRecordRow(record: UsageRecord, idx: Int, onClick: (UsageRecord) -> Unit, expanded: Boolean = false) {
|
||||
val bg = if (idx % 2 == 0) Color.Transparent else MaterialTheme.colorScheme.surface.copy(alpha = 0.3f)
|
||||
val rowPad = if (expanded) 12.dp else 8.dp
|
||||
Card(Modifier.fillMaxWidth().clickable { onClick(record) },
|
||||
RoundedCornerShape(0.dp), CardDefaults.cardColors(containerColor = bg)) {
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp),
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = rowPad),
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
DataCell(formatUtcTimeShort(record.timeCreated), Modifier.weight(1.5f))
|
||||
DataCell(record.model, Modifier.weight(1.8f))
|
||||
DataCell(formatInputWithCache(record), Modifier.weight(1.5f))
|
||||
DataCell("%,d".format(Locale.US, record.outputTokens), Modifier.weight(1.2f))
|
||||
DataCell("$${String.format(Locale.US, "%.4f", record.costUsd)}", Modifier.weight(1.0f))
|
||||
DataCell(formatUtcTimeShort(record.timeCreated), Modifier.weight(1.5f), expanded)
|
||||
DataCell(record.model, Modifier.weight(1.8f), expanded)
|
||||
DataCell(formatInputWithCache(record), Modifier.weight(1.5f), expanded)
|
||||
DataCell("%,d".format(Locale.US, record.outputTokens), Modifier.weight(1.2f), expanded)
|
||||
DataCell("$${String.format(Locale.US, "%.4f", record.costUsd)}", Modifier.weight(1.0f), expanded)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DataCell(text: String, modifier: Modifier) {
|
||||
Text(text, modifier = modifier, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurface,
|
||||
private fun DataCell(text: String, modifier: Modifier, expanded: Boolean = false) {
|
||||
Text(text, modifier = modifier,
|
||||
fontSize = if (expanded) 13.sp else 11.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center, maxLines = 1)
|
||||
}
|
||||
|
||||
@ -298,6 +331,12 @@ private fun RawField(label: String, value: String) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun Long.toUtcLocalDate(): LocalDate =
|
||||
Instant.ofEpochMilli(this).atOffset(ZoneOffset.UTC).toLocalDate()
|
||||
|
||||
private fun LocalDate.toUtcStartOfDayMillis(): Long =
|
||||
atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
||||
|
||||
private fun formatInputWithCache(record: UsageRecord): String {
|
||||
val input = record.inputTokens + record.cacheReadTokens
|
||||
val cache = record.cacheReadTokens
|
||||
|
||||
@ -27,22 +27,36 @@ class UsageDataViewModel @Inject constructor(
|
||||
private val _state = MutableStateFlow(UsageDataState())
|
||||
val state: StateFlow<UsageDataState> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
loadModelsAndData()
|
||||
private var workspaceIdOverride: String? = null
|
||||
private var loadGeneration = 0
|
||||
|
||||
/** 覆盖 workspaceId,用于 CCGO 等非 OCGO 服务。必须在 loadModelsAndData() 前调用。 */
|
||||
fun setWorkspace(wid: String) {
|
||||
workspaceIdOverride = wid
|
||||
loadGeneration++
|
||||
// 先清空数据防止 init 自动加载的 OCGO 数据闪一下
|
||||
_state.value = UsageDataState()
|
||||
loadData()
|
||||
}
|
||||
|
||||
// 不在 init 自动加载,由 Composable 层显式触发 loadData()(OCGO)或 setWorkspace()(CCGO)
|
||||
|
||||
private fun loadModelsAndData() {
|
||||
viewModelScope.launch {
|
||||
val wid = workspaceId() ?: return@launch
|
||||
val genAtStart = loadGeneration
|
||||
val cache = cacheProvider.get()
|
||||
val models = cache.getDistinctModels(wid)
|
||||
_state.update { it.copy(allModels = models) }
|
||||
// 如果 loadGeneration 已经变化,丢弃这次结果
|
||||
if (loadGeneration != genAtStart) return@launch
|
||||
// 紧接着加载数据
|
||||
loadDataInternal(cache, wid)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun workspaceId(): String? {
|
||||
workspaceIdOverride?.let { return it }
|
||||
val c = credentialRepository.get(ServiceType.OPENCODE_GO)
|
||||
return (c as? Credential.SessionCredential)?.workspaceId?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
@ -51,8 +65,11 @@ class UsageDataViewModel @Inject constructor(
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(loading = true) }
|
||||
val wid = workspaceId() ?: return@launch
|
||||
val genAtStart = loadGeneration
|
||||
val cache = cacheProvider.get()
|
||||
loadDataInternal(cache, wid)
|
||||
// 如果 loadGeneration 已经变化,丢弃这次结果
|
||||
if (loadGeneration != genAtStart) return@launch
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -2,13 +2,17 @@ package com.rainy.token.ui.dashboard
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
@ -25,6 +29,10 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SelectableDates
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TimePicker
|
||||
@ -33,21 +41,28 @@ import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberDatePickerState
|
||||
import androidx.compose.material3.rememberTimePickerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
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.text.style.TextOverflow
|
||||
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
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.SimpleDateFormat
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
@ -59,35 +74,67 @@ import java.util.Locale
|
||||
/**
|
||||
* 用量详情页 —— 统计图表。
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun UsageDetailScreen(
|
||||
onBack: () -> Unit,
|
||||
onOpenOverview: () -> Unit,
|
||||
onOpenData: () -> Unit = {},
|
||||
viewModel: UsageChartViewModel = hiltViewModel()
|
||||
viewModel: UsageChartViewModel = hiltViewModel(),
|
||||
clearViewModel: UsageViewModel? = null // non-null = CCGO, 显示清除按钮
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
|
||||
// OCGO 首次加载(CCGO 由 NavHost 的 setWorkspace 触发,不重复 load)
|
||||
LaunchedEffect(Unit) {
|
||||
if (clearViewModel == null) viewModel.load()
|
||||
}
|
||||
|
||||
var showClearDialog by remember { mutableStateOf(false) }
|
||||
var clearCountdown by remember { mutableStateOf(0) }
|
||||
var granularityExpanded by remember { mutableStateOf(false) }
|
||||
var modelExpanded by remember { mutableStateOf(false) }
|
||||
var showCustomDayPicker by remember { mutableStateOf(false) }
|
||||
var showCustomMonthPicker by remember { mutableStateOf(false) }
|
||||
var showCustomRangeStart by remember { mutableStateOf(false) }
|
||||
var showCustomRangeEnd by remember { mutableStateOf(false) }
|
||||
var customRangeStartMs by remember { mutableStateOf(0L) }
|
||||
var showCustomRangePicker by remember { mutableStateOf(false) }
|
||||
var showCostDetail by remember { mutableStateOf(false) }
|
||||
var showReqDetail by remember { mutableStateOf(false) }
|
||||
var showTokenDetail by remember { mutableStateOf(false) }
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Scaffold(
|
||||
containerColor = Color.Transparent,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("用量详情") },
|
||||
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, "返回") } },
|
||||
title = {
|
||||
val serviceLabel = if (clearViewModel == null) "OCGO" else "CommandCode"
|
||||
Column {
|
||||
Text(
|
||||
stringResource(R.string.title_usage_detail),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
serviceLabel,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = InkMuted,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
},
|
||||
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, stringResource(R.string.action_back)) } },
|
||||
actions = {
|
||||
if (clearViewModel != null) {
|
||||
TextButton(onClick = { showClearDialog = true }) {
|
||||
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)
|
||||
@ -96,125 +143,267 @@ 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 {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().padding(innerPadding).padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
item {
|
||||
Row(Modifier.fillMaxWidth(), Arrangement.spacedBy(8.dp), Alignment.CenterVertically) {
|
||||
Box {
|
||||
TextButton(onClick = { granularityExpanded = true }) {
|
||||
Text(state.granularity.label, color = StrawberryPink)
|
||||
Icon(Icons.Filled.ArrowDropDown, null, tint = StrawberryPink)
|
||||
}
|
||||
DropdownMenu(granularityExpanded, { granularityExpanded = false }) {
|
||||
ChartGranularity.entries.forEach { g ->
|
||||
DropdownMenuItem(text = { Text(g.label) }, onClick = {
|
||||
granularityExpanded = false
|
||||
when (g) {
|
||||
ChartGranularity.CUSTOM_DAY_HOURLY -> showCustomDayPicker = true
|
||||
ChartGranularity.CUSTOM_MONTH_DAILY -> showCustomMonthPicker = true
|
||||
ChartGranularity.CUSTOM_RANGE_DAILY -> showCustomRangeStart = true
|
||||
else -> viewModel.setGranularity(g)
|
||||
}
|
||||
})
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize().padding(innerPadding)) {
|
||||
// 容器自身宽度 > 700dp 时图表并排(自适应父容器,而非全局窗口)
|
||||
val wideEnough = maxWidth > 700.dp
|
||||
val hPad = if (wideEnough) 24.dp else 16.dp
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().padding(horizontal = hPad),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
item {
|
||||
Row(Modifier.fillMaxWidth(), Arrangement.spacedBy(8.dp), Alignment.CenterVertically) {
|
||||
Box {
|
||||
TextButton(onClick = { granularityExpanded = true }) {
|
||||
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(stringResource(g.labelRes)) }, onClick = {
|
||||
granularityExpanded = false
|
||||
when (g) {
|
||||
ChartGranularity.CUSTOM_DAY_HOURLY -> showCustomDayPicker = true
|
||||
ChartGranularity.CUSTOM_MONTH_DAILY -> showCustomMonthPicker = true
|
||||
ChartGranularity.CUSTOM_RANGE_DAILY -> showCustomRangePicker = true
|
||||
else -> viewModel.setGranularity(g)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Text("UTC+0", style = MaterialTheme.typography.bodySmall, color = InkMuted)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Box {
|
||||
val label = if (state.selectedModels.isEmpty()) "全部模型" else "${state.selectedModels.size} 个模型"
|
||||
TextButton(onClick = { modelExpanded = true }) {
|
||||
Text(label, color = StrawberryPink, style = MaterialTheme.typography.bodySmall)
|
||||
Icon(Icons.Filled.ArrowDropDown, null, tint = StrawberryPink)
|
||||
TextButton(onClick = {
|
||||
viewModel.toggleUtc8()
|
||||
scope.launch {
|
||||
snackbarHostState.showSnackbar(
|
||||
if (state.useUtc8) context.getString(R.string.msg_utc8_on) else context.getString(R.string.msg_utc0_on),
|
||||
duration = SnackbarDuration.Short
|
||||
)
|
||||
}
|
||||
}) {
|
||||
Text(
|
||||
if (state.useUtc8) "UTC+8」" else "UTC+0」",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (state.useUtc8) StrawberryPink else InkMuted,
|
||||
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
|
||||
)
|
||||
}
|
||||
DropdownMenu(modelExpanded, { modelExpanded = false }) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("全部模型", fontWeight = FontWeight.Bold) },
|
||||
onClick = { modelExpanded = false; viewModel.selectAllModels() })
|
||||
state.allModels.forEach { model ->
|
||||
DropdownMenuItem(text = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(state.selectedModels.isEmpty() || model in state.selectedModels, { viewModel.toggleModel(model) })
|
||||
Text(model, modifier = Modifier.padding(start = 4.dp))
|
||||
}
|
||||
}, onClick = { viewModel.toggleModel(model) })
|
||||
Spacer(Modifier.weight(1f))
|
||||
Box {
|
||||
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(stringResource(R.string.common_all_models), fontWeight = FontWeight.Bold) },
|
||||
onClick = { modelExpanded = false; viewModel.selectAllModels() })
|
||||
state.allModels.forEach { model ->
|
||||
DropdownMenuItem(text = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(state.selectedModels.isEmpty() || model in state.selectedModels, { viewModel.toggleModel(model) })
|
||||
Text(model, modifier = Modifier.padding(start = 4.dp))
|
||||
}
|
||||
}, onClick = { viewModel.toggleModel(model) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 图表1
|
||||
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 }) {
|
||||
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] } },
|
||||
{ bucket -> models.mapNotNull { if (bucket.byModel[it] != null) it else null } },
|
||||
formatValue = { "$${String.format(Locale.US, "%.4f", it)}" },
|
||||
granularity = state.granularity,
|
||||
legendItems = models.mapIndexed { idx, m -> m to modelColors[idx % modelColors.size] })
|
||||
if (wideEnough) {
|
||||
// 面板够宽:前两张图表并排
|
||||
item {
|
||||
val models = state.rangeModels
|
||||
val costTotal = state.buckets.sumOf { it.totalCost.toDouble() / 100_000_000.0 }
|
||||
val reqTotal = state.buckets.sumOf { it.totalRequests }
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
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] } },
|
||||
{ bucket -> models.mapNotNull { if (bucket.byModel[it] != null) it else null } },
|
||||
formatValue = { "$${String.format(Locale.US, "%.4f", it)}" },
|
||||
granularity = state.granularity,
|
||||
legendItems = models.mapIndexed { idx, m ->
|
||||
m to modelColors[idx % modelColors.size]
|
||||
},
|
||||
useUtc8 = state.useUtc8
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
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(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(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(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.rangeModels
|
||||
val costTotal = state.buckets.sumOf { it.totalCost.toDouble() / 100_000_000.0 }
|
||||
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] } },
|
||||
{ bucket -> models.mapNotNull { if (bucket.byModel[it] != null) it else null } },
|
||||
formatValue = { "$${String.format(Locale.US, "%.4f", it)}" },
|
||||
granularity = state.granularity,
|
||||
legendItems = models.mapIndexed { idx, m ->
|
||||
m to modelColors[idx % modelColors.size]
|
||||
},
|
||||
useUtc8 = state.useUtc8
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
val reqTotal = state.buckets.sumOf { it.totalRequests }
|
||||
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(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(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(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]))
|
||||
}
|
||||
}
|
||||
}
|
||||
// 图表2
|
||||
item {
|
||||
val reqTotal = state.buckets.sumOf { it.totalRequests }
|
||||
ChartCard("API 请求次数", "${reqTotal}次", { showReqDetail = true }) {
|
||||
LineChart(state.buckets, { it.totalRequests.toFloat() }, StrawberryPink, { "${it.toInt()}次" }, state.granularity)
|
||||
item {
|
||||
TextButton(onClick = onOpenOverview, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("📋 " + stringResource(R.string.title_overview), color = StrawberryPink, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
item { Spacer(Modifier.height(32.dp)) }
|
||||
}
|
||||
// 图表3
|
||||
item {
|
||||
val tokTotal = state.buckets.sumOf { it.cacheHitTokens + it.inputTokens + it.outputTokens }
|
||||
ChartCard("Token 消耗", 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("输出", "输入(未命中)", "命中缓存") },
|
||||
tooltipReversed = true,
|
||||
formatValue = { formatTokenComma(it.toLong()) },
|
||||
granularity = state.granularity)
|
||||
}
|
||||
ChartLegend(listOf("输入(未命中)" to tokenColors[1], "命中缓存" to tokenColors[0], "输出" to tokenColors[2]))
|
||||
}
|
||||
// 总统计入口
|
||||
item {
|
||||
TextButton(onClick = onOpenOverview, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("📋 总统计", color = StrawberryPink, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
item { Spacer(Modifier.height(32.dp)) }
|
||||
}
|
||||
}
|
||||
// 自定义日期选择器
|
||||
if (showCustomDayPicker) DateTimePickerDialog("选择日期", { ms ->
|
||||
val utc = ZoneOffset.UTC; val ds = Instant.ofEpochMilli(ms).atOffset(utc).toLocalDate().atStartOfDay(utc).toInstant().toEpochMilli()
|
||||
viewModel.setCustomDayRange(ds, ds + 86400_000L - 1); showCustomDayPicker = false }, { showCustomDayPicker = false })
|
||||
if (showCustomMonthPicker) DateTimePickerDialog("选择月份(任意一天)", { viewModel.setCustomMonth(it); showCustomMonthPicker = false }, { showCustomMonthPicker = false })
|
||||
if (showCustomRangeStart) DateTimePickerDialog("开始日期", { customRangeStartMs = it; showCustomRangeStart = false; showCustomRangeEnd = true }, { showCustomRangeStart = false })
|
||||
if (showCustomRangeEnd) DateTimePickerDialog("结束日期", { ms ->
|
||||
val utc = ZoneOffset.UTC; val sd = Instant.ofEpochMilli(customRangeStartMs).atOffset(utc).toLocalDate().atStartOfDay(utc).toInstant().toEpochMilli()
|
||||
val ed = Instant.ofEpochMilli(ms).atOffset(utc).toLocalDate().atStartOfDay(utc).toInstant().toEpochMilli() + 86400_000L - 1
|
||||
viewModel.setCustomRange(sd, ed); showCustomRangeEnd = false }, { showCustomRangeEnd = false })
|
||||
// 详情弹窗
|
||||
val models = state.selectedModels.ifEmpty { state.allModels.toSet() }
|
||||
if (showCostDetail) ChartDetailDialog("消费明细", { showCostDetail = false }) {
|
||||
if (showCustomDayPicker) DateOnlyPickerDialog(
|
||||
title = stringResource(R.string.date_select_day),
|
||||
initialDate = state.customDay,
|
||||
onConfirm = { date ->
|
||||
viewModel.setCustomDay(date)
|
||||
showCustomDayPicker = false
|
||||
},
|
||||
onDismiss = { showCustomDayPicker = false }
|
||||
)
|
||||
if (showCustomMonthPicker) DateOnlyPickerDialog(
|
||||
title = stringResource(R.string.date_select_month),
|
||||
initialDate = state.customMonth,
|
||||
onConfirm = { date ->
|
||||
viewModel.setCustomMonth(date)
|
||||
showCustomMonthPicker = false
|
||||
},
|
||||
onDismiss = { showCustomMonthPicker = false }
|
||||
)
|
||||
if (showCustomRangePicker) {
|
||||
val currentRange = state.customRange
|
||||
DateRangePickerDialog(
|
||||
title = stringResource(R.string.date_pick_range),
|
||||
initialStart = currentRange?.first,
|
||||
initialEnd = currentRange?.second,
|
||||
onConfirm = { from, to ->
|
||||
viewModel.setCustomRange(from, to)
|
||||
showCustomRangePicker = false
|
||||
},
|
||||
onDismiss = { showCustomRangePicker = false }
|
||||
)
|
||||
}
|
||||
val models = state.rangeModels
|
||||
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("⚠️ " + stringResource(R.string.dialog_clear_title), fontWeight = FontWeight.Bold) },
|
||||
text = {
|
||||
Column {
|
||||
Text(stringResource(R.string.dialog_clear_body))
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(stringResource(R.string.dialog_clear_confirm_question), fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showClearDialog = false
|
||||
clearCountdown = 0
|
||||
clearViewModel?.clearAndResync()
|
||||
onBack()
|
||||
},
|
||||
enabled = cd == 0
|
||||
) {
|
||||
Text(
|
||||
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(stringResource(R.string.action_cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
LaunchedEffect(showClearDialog) {
|
||||
if (!showClearDialog) return@LaunchedEffect
|
||||
clearCountdown = 3
|
||||
for (i in 3 downTo 1) {
|
||||
kotlinx.coroutines.delay(1000)
|
||||
clearCountdown = i - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -225,7 +414,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) {
|
||||
@ -255,14 +444,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
|
||||
)
|
||||
@ -270,12 +459,73 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun DateOnlyPickerDialog(
|
||||
title: String,
|
||||
initialDate: LocalDate? = null,
|
||||
onConfirm: (LocalDate) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
minDate: LocalDate? = null
|
||||
) {
|
||||
val utc = remember { ZoneOffset.UTC }
|
||||
val minDateStartMs = remember(minDate) { minDate?.atStartOfDay(utc)?.toInstant()?.toEpochMilli() }
|
||||
val selectableDates = remember(minDateStartMs) {
|
||||
object : SelectableDates {
|
||||
override fun isSelectableDate(utcTimeMillis: Long): Boolean {
|
||||
return minDateStartMs == null || utcTimeMillis >= minDateStartMs
|
||||
}
|
||||
}
|
||||
}
|
||||
val dateState = rememberDatePickerState(
|
||||
initialSelectedDateMillis = initialDate?.atStartOfDay(utc)?.toInstant()?.toEpochMilli(),
|
||||
selectableDates = selectableDates
|
||||
)
|
||||
|
||||
DatePickerDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
val millis = dateState.selectedDateMillis ?: return@TextButton
|
||||
onConfirm(Instant.ofEpochMilli(millis).atOffset(utc).toLocalDate())
|
||||
},
|
||||
enabled = dateState.selectedDateMillis != null
|
||||
) {
|
||||
Text(stringResource(R.string.action_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } }
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = title,
|
||||
modifier = Modifier.padding(start = 24.dp, top = 20.dp, end = 24.dp),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
if (minDate != null) {
|
||||
Text(
|
||||
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
|
||||
)
|
||||
}
|
||||
DatePicker(state = dateState, title = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun LocalDate.formatDateLabel(): String =
|
||||
String.format(Locale.US, "%04d-%02d-%02d", year, monthValue, dayOfMonth)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun DateTimePickerDialog(
|
||||
@ -302,24 +552,24 @@ 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
|
||||
)
|
||||
TimePicker(state = timeState)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -3,6 +3,8 @@ package com.rainy.token.ui.dashboard
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@ -30,6 +32,7 @@ import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@ -37,42 +40,52 @@ 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
|
||||
import com.rainy.token.ui.theme.StrawberryPink
|
||||
import java.text.SimpleDateFormat
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneOffset
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* 总统计页 —— 总览、按模型、按天统计。
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun UsageOverviewScreen(
|
||||
onBack: () -> Unit,
|
||||
viewModel: UsageViewModel = hiltViewModel()
|
||||
viewModel: UsageViewModel = hiltViewModel(),
|
||||
autoLoad: Boolean = true
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
// OCGO 首次加载(CCGO 由 setWorkspace 触发,不重复 load)
|
||||
LaunchedEffect(Unit) {
|
||||
if (autoLoad) viewModel.loadStats()
|
||||
}
|
||||
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
var modelMenuExpanded by remember { mutableStateOf(false) }
|
||||
var showStartPicker by remember { mutableStateOf(false) }
|
||||
var showEndPicker by remember { mutableStateOf(false) }
|
||||
var customStartMs by remember { mutableStateOf(0L) }
|
||||
var customEndMs by remember { mutableStateOf(0L) }
|
||||
var showRangePicker by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
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)
|
||||
)
|
||||
@ -86,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))
|
||||
@ -96,35 +109,32 @@ 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("自定义") },
|
||||
onClick = { menuExpanded = false; viewModel.setTimeFilter(TimeFilter.Custom(0L, 0L)) }
|
||||
text = { Text(stringResource(R.string.time_custom)) },
|
||||
onClick = { menuExpanded = false; showRangePicker = true }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (uiState.timeFilter is TimeFilter.Custom) {
|
||||
item {
|
||||
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())
|
||||
}
|
||||
}
|
||||
// 总览
|
||||
uiState.overview?.let { overview ->
|
||||
item { OverviewCard(overview) }
|
||||
item { OverviewCard(overview, false) }
|
||||
if (overview.cacheReadTokens > 0 || overview.cacheWriteTokens > 0) {
|
||||
item { CacheBreakdownCard(overview) }
|
||||
}
|
||||
}
|
||||
// 按模型
|
||||
if (uiState.modelStats.isNotEmpty()) {
|
||||
item { SectionHeader("按模型统计") }
|
||||
item { SectionHeader(stringResource(R.string.section_by_model)) }
|
||||
items(uiState.modelStats, key = { it.model }) { ModelDetailRow(it) }
|
||||
}
|
||||
// 按天
|
||||
@ -134,14 +144,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) },
|
||||
@ -156,35 +166,65 @@ 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 (showRangePicker) {
|
||||
val current = uiState.timeFilter as? TimeFilter.Custom
|
||||
DateRangePickerDialog(
|
||||
title = stringResource(R.string.date_pick_range),
|
||||
initialStart = current?.from?.takeIf { it > 0 }?.toUtcLocalDate(),
|
||||
initialEnd = current?.to?.takeIf { current.from > 0 }?.toUtcLocalDate(),
|
||||
onConfirm = { from, to ->
|
||||
val fromMs = from.toUtcStartOfDayMillis()
|
||||
val toMs = to.toUtcStartOfDayMillis()
|
||||
viewModel.setTimeFilter(TimeFilter.Custom(fromMs, toMs + 86400_000L - 1))
|
||||
showRangePicker = false
|
||||
},
|
||||
onDismiss = { showRangePicker = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable private fun OverviewCard(overview: OverviewStats) {
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable private fun OverviewCard(overview: OverviewStats, isExpanded: Boolean = false) {
|
||||
val inputTotal = overview.inputTokens + overview.cacheReadTokens
|
||||
Card(Modifier.fillMaxWidth(), RoundedCornerShape(20.dp), CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), elevation = CardDefaults.cardElevation(0.dp)) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), Arrangement.SpaceEvenly) {
|
||||
StatItem("输入 Token", formatTokenCount(inputTotal))
|
||||
StatItem("输出 Token", formatTokenCount(overview.outputTokens))
|
||||
StatItem("推理 Token", 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}")
|
||||
if (isExpanded) {
|
||||
// 平板:FlowRow 横向排列 6 项统计
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
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(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(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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -193,15 +233,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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -216,7 +256,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)
|
||||
@ -227,13 +267,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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -246,6 +291,12 @@ fun UsageOverviewScreen(
|
||||
}
|
||||
}
|
||||
|
||||
private fun Long.toUtcLocalDate(): LocalDate =
|
||||
Instant.ofEpochMilli(this).atOffset(ZoneOffset.UTC).toLocalDate()
|
||||
|
||||
private fun LocalDate.toUtcStartOfDayMillis(): Long =
|
||||
atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
||||
|
||||
private fun formatTokenCount(tokens: Long): String = when {
|
||||
tokens >= 1_000_000 -> "${"%.1f".format(Locale.US, tokens / 1_000_000.0)}M"
|
||||
tokens >= 1_000 -> "${"%.1f".format(Locale.US, tokens / 1_000.0)}K"
|
||||
|
||||
@ -24,11 +24,15 @@ 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.text.style.TextOverflow
|
||||
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
|
||||
@ -41,11 +45,17 @@ import java.util.Locale
|
||||
@Composable
|
||||
fun UsageStatsCard(
|
||||
onOpenDetail: () -> Unit,
|
||||
onOpenHeatmap: () -> Unit = {},
|
||||
refreshTrigger: Int = 0,
|
||||
viewModel: UsageViewModel = hiltViewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
// OCGO 首次加载(init 不再自动加载)
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loadStats()
|
||||
}
|
||||
|
||||
// 全局刷新触发用量同步(跳过首次 0)
|
||||
LaunchedEffect(refreshTrigger) {
|
||||
if (refreshTrigger > 0) viewModel.sync()
|
||||
@ -62,12 +72,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
|
||||
)
|
||||
@ -82,7 +92,7 @@ fun UsageStatsCard(
|
||||
IconButton(onClick = { viewModel.sync() }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Refresh,
|
||||
contentDescription = "同步用量数据",
|
||||
contentDescription = stringResource(R.string.action_sync_usage),
|
||||
tint = StrawberryPink
|
||||
)
|
||||
}
|
||||
@ -95,13 +105,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,
|
||||
@ -114,8 +124,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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -123,24 +133,49 @@ 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
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 查看详情 ───
|
||||
// ─── 查看详情 + Token 活动快捷入口 ───
|
||||
if (uiState.overview != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
TextButton(onClick = onOpenDetail) {
|
||||
Text("查看详情", color = StrawberryPink)
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = StrawberryPink,
|
||||
modifier = Modifier.padding(top = 1.dp)
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
TextButton(onClick = onOpenDetail) {
|
||||
Text(
|
||||
stringResource(R.string.action_view_detail),
|
||||
color = StrawberryPink,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = StrawberryPink,
|
||||
modifier = Modifier.padding(top = 1.dp)
|
||||
)
|
||||
}
|
||||
// 弹性间距:窄屏/英文长文案时优先压缩间距,避免按钮折行变形
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
// OCGO Token 活动热力图快捷入口(与"查看详情"并列,右对齐)
|
||||
TextButton(onClick = onOpenHeatmap) {
|
||||
Text(
|
||||
stringResource(R.string.heatmap_quick_entry),
|
||||
color = StrawberryPink,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = StrawberryPink,
|
||||
modifier = Modifier.padding(top = 1.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -148,7 +183,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
|
||||
)
|
||||
@ -156,11 +191,21 @@ 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
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 刷新时间(与其余服务卡片底部一致) ───
|
||||
if (uiState.lastSyncAt > 0) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = usageUpdatedAtText(uiState.lastSyncAt),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = InkMuted
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,15 +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
|
||||
@ -25,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) 表示不限制。 */
|
||||
@ -72,27 +78,45 @@ sealed class TimeFilter(val label: String) {
|
||||
class UsageViewModel @Inject constructor(
|
||||
private val cacheProvider: Provider<UsageCache>,
|
||||
private val syncUseCaseProvider: Provider<SyncUsageUseCase>,
|
||||
private val syncCommandCodeUseCaseProvider: Provider<SyncCommandCodeUsageUseCase>,
|
||||
private val credentialRepository: CredentialRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(UsageUiState())
|
||||
val uiState: StateFlow<UsageUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
loadStatsAndSyncIfNeeded()
|
||||
private var workspaceIdOverride: String? = null
|
||||
private var loadGeneration = 0 // 递增:过时的 loadStatsInternal 结果自动丢弃
|
||||
|
||||
/** 用量同步时间持久化(按 workspace 区分 OCGO / CCGO),重启后卡片仍能显示刷新时间。 */
|
||||
private fun syncAtPrefs(): android.content.SharedPreferences =
|
||||
com.rainy.token.RainyTokenApplication.appContext.getSharedPreferences(
|
||||
"usage_sync_at",
|
||||
android.content.Context.MODE_PRIVATE
|
||||
)
|
||||
|
||||
private fun syncAtKey(wid: String): String = "sync_at_$wid"
|
||||
|
||||
private fun loadLastSyncAt(wid: String): Long =
|
||||
syncAtPrefs().getLong(syncAtKey(wid), 0L)
|
||||
|
||||
private fun saveLastSyncAt(wid: String, ts: Long) {
|
||||
syncAtPrefs().edit().putLong(syncAtKey(wid), ts).apply()
|
||||
}
|
||||
|
||||
private fun loadStatsAndSyncIfNeeded() {
|
||||
viewModelScope.launch {
|
||||
loadStatsInternal()
|
||||
// 无缓存数据 → 自动触发同步
|
||||
if (_uiState.value.overview == null && _uiState.value.recordCount == 0) {
|
||||
sync()
|
||||
}
|
||||
}
|
||||
/** 覆盖 workspaceId,用于 CCGO 等非 OCGO 服务。必须在 loadStats() 前调用。 */
|
||||
fun setWorkspace(wid: String) {
|
||||
workspaceIdOverride = wid
|
||||
loadGeneration++
|
||||
// 先清空数据防止 init 自动加载的 OCGO 数据闪一下
|
||||
_uiState.value = UsageUiState()
|
||||
loadStats()
|
||||
}
|
||||
|
||||
// 不在 init 自动加载,由 Composable 层显式触发 loadStats()(OCGO)或 setWorkspace()(CCGO)
|
||||
|
||||
private suspend fun workspaceId(): String? {
|
||||
workspaceIdOverride?.let { return it }
|
||||
val c = credentialRepository.get(ServiceType.OPENCODE_GO)
|
||||
return (c as? Credential.SessionCredential)?.workspaceId?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
@ -104,6 +128,7 @@ class UsageViewModel @Inject constructor(
|
||||
/** 只调一次 getRecords(),所有统计从同一次结果派生。重 IO/CPU 操作跑在 Default 上。 */
|
||||
private suspend fun loadStatsInternal() {
|
||||
val wid = workspaceId() ?: return
|
||||
val genAtStart = loadGeneration // 记下发起时的世代
|
||||
val cache = cacheProvider.get()
|
||||
val filter = _uiState.value.timeFilter
|
||||
val (fromTs, toTs) = filter.toRange()
|
||||
@ -139,6 +164,9 @@ class UsageViewModel @Inject constructor(
|
||||
LoadResult(ov, ms, ds, records.size)
|
||||
}
|
||||
|
||||
// 如果 loadGeneration 已经变化(setWorkspace 被调用),丢弃这次结果
|
||||
if (loadGeneration != genAtStart) return
|
||||
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
overview = overview,
|
||||
@ -146,7 +174,8 @@ class UsageViewModel @Inject constructor(
|
||||
dailyStats = dailyStats,
|
||||
recordCount = totalCount,
|
||||
loading = false,
|
||||
dailyPage = 1
|
||||
dailyPage = 1,
|
||||
lastSyncAt = loadLastSyncAt(wid)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -177,22 +206,60 @@ class UsageViewModel @Inject constructor(
|
||||
fun sync() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(syncing = true) }
|
||||
val wid = workspaceIdOverride ?: workspaceId()
|
||||
val result = withContext(Dispatchers.Default) {
|
||||
val useCase = syncUseCaseProvider.get()
|
||||
val cache = cacheProvider.get()
|
||||
val count = cache.count()
|
||||
if (count == 0) useCase.fullSync() else useCase.incrementalSync()
|
||||
if (workspaceIdOverride == com.rainy.token.data.repository.CommandCodeUsageRepository.CCGO_WORKSPACE_ID) {
|
||||
val useCase = syncCommandCodeUseCaseProvider.get()
|
||||
val count = cache.count(com.rainy.token.data.repository.CommandCodeUsageRepository.CCGO_WORKSPACE_ID)
|
||||
if (count == 0) useCase.fullSync() else useCase.incrementalSync()
|
||||
} else {
|
||||
val useCase = syncUseCaseProvider.get()
|
||||
val count = cache.count()
|
||||
if (count == 0) useCase.fullSync() else useCase.incrementalSync()
|
||||
}
|
||||
}
|
||||
val syncedAt = System.currentTimeMillis()
|
||||
if (result.isSuccess && wid != null) saveLastSyncAt(wid, syncedAt)
|
||||
result.onSuccess { loadStats() }
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
syncing = false,
|
||||
lastSyncResult = result.getOrNull()?.inserted ?: 0,
|
||||
lastSyncError = result.exceptionOrNull()?.message
|
||||
lastSyncError = result.exceptionOrNull()?.let { syncErrorToUiText(it) },
|
||||
lastSyncAt = if (result.isSuccess && wid != null) syncedAt else it.lastSyncAt
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 清除当前 workspace 的缓存数据并全量重新同步。 */
|
||||
fun clearAndResync() {
|
||||
viewModelScope.launch {
|
||||
val wid = workspaceIdOverride ?: return@launch
|
||||
_uiState.update { it.copy(syncing = true) }
|
||||
val cache = cacheProvider.get()
|
||||
cache.deleteByWorkspaceId(wid)
|
||||
invalidateData()
|
||||
val useCase = syncCommandCodeUseCaseProvider.get()
|
||||
val result = useCase.fullSync()
|
||||
val syncedAt = System.currentTimeMillis()
|
||||
if (result.isSuccess) saveLastSyncAt(wid, syncedAt)
|
||||
result.onSuccess { loadStats() }
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
syncing = false,
|
||||
lastSyncResult = result.getOrNull()?.inserted ?: 0,
|
||||
lastSyncError = result.exceptionOrNull()?.let { syncErrorToUiText(it) },
|
||||
lastSyncAt = if (result.isSuccess) syncedAt else it.lastSyncAt
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun invalidateData() {
|
||||
_uiState.value = UsageUiState()
|
||||
}
|
||||
}
|
||||
|
||||
data class UsageUiState(
|
||||
@ -203,7 +270,8 @@ 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 lastSyncAt: Long = 0, // 最近一次成功同步的 epoch ms(0 = 从未同步)
|
||||
val timeFilter: TimeFilter = TimeFilter.All,
|
||||
val dailyPage: Int = 1,
|
||||
val modelFilter: String? = null // null = 全部模型
|
||||
@ -215,4 +283,37 @@ private data class LoadResult(
|
||||
val modelStats: List<ModelStats>,
|
||||
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.SessionExpired ->
|
||||
UiText.Resource(R.string.error_credential_session_expired)
|
||||
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)
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
package com.rainy.token.ui.dashboard.autocheckin
|
||||
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
|
||||
/**
|
||||
* 后台广播(自动签到闹钟 / 开机重排)获取 Hilt 单例的入口。
|
||||
* 与 ui.widget.WidgetRefreshEntryPoint 同一模式:广播接收器不依赖
|
||||
* @AndroidEntryPoint,通过 EntryPointAccessors 拿 SingletonComponent 里的依赖。
|
||||
*/
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface AutoCheckinEntryPoint {
|
||||
fun autoCheckinManager(): AutoCheckinManager
|
||||
}
|
||||
@ -0,0 +1,296 @@
|
||||
package com.rainy.token.ui.dashboard.autocheckin
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.rainy.token.data.repository.CheckinStatusStore
|
||||
import com.rainy.token.data.repository.CredentialRepository
|
||||
import com.rainy.token.data.repository.RepositoryError
|
||||
import com.rainy.token.data.repository.TraeRepository
|
||||
import com.rainy.token.data.repository.WorkBuddyRepository
|
||||
import com.rainy.token.domain.model.CredentialStatus
|
||||
import com.rainy.token.domain.model.ServiceAccountInfo
|
||||
import com.rainy.token.domain.service.ServiceType
|
||||
import com.rainy.token.domain.usecase.RefreshBalanceUseCase
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.time.ZonedDateTime
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
/**
|
||||
* 每日自动签到编排器(小组件开关控制启用)。
|
||||
*
|
||||
* 行为契约(用户需求 2026-09-13 / 多账号版 2026-10 / 零点后台版 2026-11):
|
||||
* - 开启后,每天零点后由 AlarmManager 自动触发([AutoCheckinReceiver]),
|
||||
* 为所有「已配置凭据」的可签服务的【全部账号】各签一次;
|
||||
* - 兜底:每天首次打开 App(DashboardViewModel)也会补签一次(错过闹钟/被杀进程场景);
|
||||
* - 当日每账号只签一次:本地有「今日已成功签到」记录的账号直接跳过(手动签过也算);
|
||||
* 失败/限流/设备拦截等非成功记录不占位,下次打开 App 会再次唤起签到流程;
|
||||
* - 结果汇报:签完把每个账号的结果暴露为 [report],首页据此弹「xx 已签到,积分增加 xx」;
|
||||
* - 开关持久化在 SharedPreferences "auto_checkin"(小组件与 App 内共用同一份)。
|
||||
*
|
||||
* 仅负责编排与记录,不做 UI:签到复用 RefreshBalanceUseCase(带凭据快照会话)。
|
||||
*
|
||||
* 调度说明:使用框架自带的 AlarmManager(非精确 setAndAllowWhileIdle)实现「每日零点后
|
||||
* 后台签到」,零新增依赖;重启设备后闹钟由 BOOT_COMPLETED 广播重排。国产 ROM(如 vivo/
|
||||
* iQOO)可能因电池优化延迟或抑制该闹钟,届时由「首次打开 App」兜底补签。
|
||||
*/
|
||||
@Singleton
|
||||
class AutoCheckinManager @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val refreshBalanceUseCase: RefreshBalanceUseCase,
|
||||
private val credentialRepository: CredentialRepository,
|
||||
private val checkinStatusStore: CheckinStatusStore
|
||||
) {
|
||||
|
||||
/** 单个账号的自动签到结果(UI 层本地化展示)。 */
|
||||
data class ServiceResult(
|
||||
val service: ServiceType,
|
||||
/** 该账号的自定义名称;null=单账号服务/未命名的默认账号。 */
|
||||
val accountLabel: String? = null,
|
||||
/** true=本次签到成功(新签到账);false=已签/失败/跳过 */
|
||||
val newlyCheckedIn: Boolean,
|
||||
/** 今日已签(成功或本来已签),弹窗「已签到」行的判定依据 */
|
||||
val checkedInToday: Boolean,
|
||||
/** 本次新增积分(仅 SUCCESS 时有值;ALREADY/失败为 0) */
|
||||
val creditsGained: Long,
|
||||
/** 失败/提示原因(服务端原文,仅 failed 时非空) */
|
||||
val message: String,
|
||||
val failed: Boolean
|
||||
)
|
||||
|
||||
/**
|
||||
* 自动签到汇总结果。null=没有可展示的内容(未开启/无候选账号/全部账号已签或静默跳过)。
|
||||
* 弹窗消费后由 [consumeReport] 置 null。
|
||||
*/
|
||||
private val _report = MutableStateFlow<List<ServiceResult>?>(null)
|
||||
val report: StateFlow<List<ServiceResult>?> = _report.asStateFlow()
|
||||
|
||||
/** 防同日并发重复签到:闹钟广播(IO 线程)与 Dashboard init(viewModelScope)可能同时进入。 */
|
||||
private val runMutex = Mutex()
|
||||
|
||||
private fun prefs() =
|
||||
context.getSharedPreferences("auto_checkin", Context.MODE_PRIVATE)
|
||||
|
||||
/** 自动签到开关(小组件 CheckBox 与 App 内共用)。开关变更时同步排/撤零点闹钟。 */
|
||||
var enabled: Boolean
|
||||
get() = prefs().getBoolean(KEY_ENABLED, false)
|
||||
set(value) {
|
||||
prefs().edit().putBoolean(KEY_ENABLED, value).apply()
|
||||
refreshSchedule()
|
||||
}
|
||||
|
||||
/**
|
||||
* 每日签到执行入口(后台闹钟与首次打开 App 共用):
|
||||
* 开启时 → 为已配置凭据的可签服务中【今日尚未成功签到】的账号各签一次。
|
||||
* 返回 true 表示本次实际执行了签到(无论结果)。
|
||||
*/
|
||||
suspend fun runDailyIfDue(): Boolean = runMutex.withLock {
|
||||
if (!enabled) return false
|
||||
// 不再使用全局 lastRunDate 占位:只有真正「今日签到成功」的账号才跳过,
|
||||
// 失败/限流/设备拦截等不会消费当天机会,重开 App 可再次唤起签到流程。
|
||||
val results = mutableListOf<ServiceResult>()
|
||||
for (service in CHECKIN_SERVICES) {
|
||||
// 多账号服务遍历全部账号;无账号文档时 getDefaultAccount 同样返回 null,
|
||||
// 候选为空 = 不签,与旧版「只签默认账号」的边界行为一致,不会误签旧凭据
|
||||
val accounts = credentialRepository.accountsFor(service)
|
||||
val candidates = accounts.ifEmpty {
|
||||
listOfNotNull(credentialRepository.getDefaultAccount(service))
|
||||
}
|
||||
for (account in candidates) {
|
||||
if (credentialRepository.statusFor(service, account.id).state ==
|
||||
CredentialStatus.State.NOT_CONFIGURED
|
||||
) {
|
||||
continue
|
||||
}
|
||||
// 今日已有成功签到记录(手动签过也算)→ 跳过,不再发网络请求
|
||||
if (checkinStatusStore.isCheckedToday(service, account.id)) continue
|
||||
// runOne 返回 null = 无需汇报(如 INTL 不支持签到),不弹误导性条目
|
||||
runOne(service, account)?.let { results += it }
|
||||
}
|
||||
}
|
||||
if (results.isNotEmpty()) {
|
||||
_report.value = results
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册/取消每日零点后台闹钟(非精确 setAndAllowWhileIdle,零点后几分钟内触发)。
|
||||
* 设备重启后闹钟丢失,由 BOOT_COMPLETED 广播再次调用本方法重排。
|
||||
*/
|
||||
fun refreshSchedule() {
|
||||
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
val intent = Intent(context, AutoCheckinReceiver::class.java)
|
||||
.setAction(ACTION_DAILY_CHECKIN)
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
0,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
if (!enabled) {
|
||||
alarmManager.cancel(pendingIntent)
|
||||
return
|
||||
}
|
||||
alarmManager.setAndAllowWhileIdle(
|
||||
AlarmManager.RTC_WAKEUP,
|
||||
nextRunTimeMillis(),
|
||||
pendingIntent
|
||||
)
|
||||
}
|
||||
|
||||
/** 下一次零点(00:05,本地时区)的运行时间戳。 */
|
||||
private fun nextRunTimeMillis(): Long {
|
||||
val now = ZonedDateTime.now()
|
||||
var next = now.toLocalDate().atTime(CHECKIN_HOUR, CHECKIN_MINUTE).atZone(now.zone)
|
||||
if (!next.isAfter(now)) next = next.plusDays(1)
|
||||
return next.toInstant().toEpochMilli()
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回 null 表示该账号无需出现在汇报弹窗里(如 INTL 不支持签到、静默跳过)。
|
||||
*/
|
||||
private suspend fun runOne(
|
||||
service: ServiceType,
|
||||
account: ServiceAccountInfo
|
||||
): ServiceResult? {
|
||||
val label = account.label?.takeIf { it.isNotBlank() }
|
||||
return when (service) {
|
||||
ServiceType.TRAE -> refreshBalanceUseCase.checkInTrae(account.id).fold(
|
||||
onSuccess = { r ->
|
||||
when (r.outcome) {
|
||||
TraeRepository.CheckinOutcome.SUCCESS -> ServiceResult(
|
||||
service = service,
|
||||
accountLabel = label,
|
||||
newlyCheckedIn = true,
|
||||
checkedInToday = true,
|
||||
creditsGained = r.points,
|
||||
message = "",
|
||||
failed = false
|
||||
)
|
||||
TraeRepository.CheckinOutcome.ALREADY ->
|
||||
ServiceResult(service, label, false, true, 0, "", false)
|
||||
// INTL 不支持签到:静默跳过,不进弹窗(避免误报「已签到」)
|
||||
TraeRepository.CheckinOutcome.INTL_UNSUPPORTED -> null
|
||||
TraeRepository.CheckinOutcome.DEVICE_BLOCKED -> {
|
||||
recordFailure(service, account.id, CheckinStatusStore.STATUS_DEVICE_BLOCKED)
|
||||
ServiceResult(service, label, false, false, 0, r.message, failed = true)
|
||||
}
|
||||
TraeRepository.CheckinOutcome.RETRYABLE -> {
|
||||
recordFailure(service, account.id, CheckinStatusStore.STATUS_RETRYABLE)
|
||||
ServiceResult(service, label, false, false, 0, r.message, failed = true)
|
||||
}
|
||||
TraeRepository.CheckinOutcome.FAILED -> {
|
||||
recordFailure(service, account.id, CheckinStatusStore.STATUS_FAILED)
|
||||
ServiceResult(service, label, false, false, 0, r.message, failed = true)
|
||||
}
|
||||
}
|
||||
},
|
||||
onFailure = { e ->
|
||||
recordFailure(service, account.id, CheckinStatusStore.STATUS_FAILED)
|
||||
failureResult(service, label, account.id, e)
|
||||
}
|
||||
)
|
||||
ServiceType.WORKBUDDY -> refreshBalanceUseCase.checkInWorkBuddy(account.id).fold(
|
||||
onSuccess = { s ->
|
||||
when (s.outcome) {
|
||||
WorkBuddyRepository.CheckinOutcome.SUCCESS -> ServiceResult(
|
||||
service = service,
|
||||
accountLabel = label,
|
||||
newlyCheckedIn = true,
|
||||
checkedInToday = true,
|
||||
creditsGained = (s.todayCredit.takeIf { it > 0 } ?: s.dailyCredit),
|
||||
message = "",
|
||||
failed = false
|
||||
)
|
||||
WorkBuddyRepository.CheckinOutcome.ALREADY ->
|
||||
ServiceResult(service, label, false, true, 0, "", false)
|
||||
WorkBuddyRepository.CheckinOutcome.QUERY ->
|
||||
ServiceResult(service, label, false, true, 0, "", false)
|
||||
WorkBuddyRepository.CheckinOutcome.FAILED -> {
|
||||
recordFailure(service, account.id, CheckinStatusStore.STATUS_FAILED)
|
||||
ServiceResult(service, label, false, false, 0, s.message, failed = true)
|
||||
}
|
||||
}
|
||||
},
|
||||
onFailure = { e ->
|
||||
recordFailure(service, account.id, CheckinStatusStore.STATUS_FAILED)
|
||||
failureResult(service, label, account.id, e)
|
||||
}
|
||||
)
|
||||
// CHECKIN_SERVICES 只含上述两者;穷尽兜底(未来加服务忘了实现时安全返回失败)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 签到请求失败的汇报结果。
|
||||
*
|
||||
* 竞态兜底:签到请求在服务端已成功([RefreshBalanceUseCase.checkInTrae] 的 also 块
|
||||
* 已写 CheckinStatusStore 成功记录),但与并发余额刷新争抢凭据快照导致结果被 [RepositoryError.CredentialChanged]
|
||||
* 丢弃时——若本地记录确认今日已签,按「今日已签到」汇报,而不是报「凭据」错误
|
||||
* (用户会看到积分实际到账却提示失败的矛盾)。
|
||||
*/
|
||||
private fun failureResult(
|
||||
service: ServiceType,
|
||||
accountLabel: String?,
|
||||
accountId: String,
|
||||
error: Throwable
|
||||
): ServiceResult =
|
||||
if (error is RepositoryError.CredentialChanged &&
|
||||
checkinStatusStore.isCheckedToday(service, accountId)
|
||||
) {
|
||||
ServiceResult(service, accountLabel, false, true, 0, "", false)
|
||||
} else {
|
||||
ServiceResult(
|
||||
service,
|
||||
accountLabel,
|
||||
false,
|
||||
false,
|
||||
0,
|
||||
error.message.orEmpty(),
|
||||
failed = true
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 落一条非成功签到记录(仅当今天还没有成功记录时写入)。
|
||||
* 失败/限流/设备拦截不消费当天签到机会,重开 App 仍会重试;
|
||||
* 但若在一次 CredentialChanged 竞态中服务端实际已成功,保留成功记录不被覆盖。
|
||||
*/
|
||||
private fun recordFailure(
|
||||
service: ServiceType,
|
||||
accountId: String,
|
||||
status: String
|
||||
) {
|
||||
if (!checkinStatusStore.isCheckedToday(service, accountId)) {
|
||||
checkinStatusStore.record(service, accountId, status)
|
||||
}
|
||||
}
|
||||
|
||||
/** 弹窗关闭时调用,清除汇报状态(下次执行前不再重复弹)。 */
|
||||
fun consumeReport() {
|
||||
_report.value = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val KEY_ENABLED = "enabled"
|
||||
|
||||
/** 每日自动签到闹钟的广播 action(供 [AutoCheckinReceiver] 分发)。 */
|
||||
const val ACTION_DAILY_CHECKIN = "com.rainy.token.action.DAILY_AUTO_CHECKIN"
|
||||
|
||||
/** 每日签到目标时间:零点后 5 分钟(避开日切边界),本地时区。 */
|
||||
const val CHECKIN_HOUR = 0
|
||||
const val CHECKIN_MINUTE = 5
|
||||
}
|
||||
}
|
||||
|
||||
/** 支持每日签到的服务(DashboardViewModel 与自动签到共用的唯一来源,避免两处漂移)。 */
|
||||
internal val CHECKIN_SERVICES = listOf(ServiceType.TRAE, ServiceType.WORKBUDDY)
|
||||
@ -0,0 +1,60 @@
|
||||
package com.rainy.token.ui.dashboard.autocheckin
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* 每日自动签到后台触发点。
|
||||
*
|
||||
* 两种入口共用同一个接收器:
|
||||
* - [AutoCheckinManager.ACTION_DAILY_CHECKIN]:AlarmManager 每日零点后发出的闹钟广播,
|
||||
* 到点就为该服务的全部已配置账号签到;
|
||||
* - ACTION_BOOT_COMPLETED:设备重启后闹钟会被系统清空,这里只负责重新排程,不抢签。
|
||||
*
|
||||
* 通过 EntryPointAccessors 获取 Hilt 单例,不依赖 @AndroidEntryPoint。
|
||||
*/
|
||||
class AutoCheckinReceiver : BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val action = intent.action
|
||||
val appContext = context.applicationContext
|
||||
val pendingResult = goAsync()
|
||||
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||
try {
|
||||
val manager = EntryPointAccessors.fromApplication(
|
||||
appContext, AutoCheckinEntryPoint::class.java
|
||||
).autoCheckinManager()
|
||||
|
||||
when (action) {
|
||||
AutoCheckinManager.ACTION_DAILY_CHECKIN -> {
|
||||
// 到点签到(关闭时 runDailyIfDue 自身会短路返回);
|
||||
// 无论本次签到是否抛异常,都必须把一次性闹钟重排到明天
|
||||
try {
|
||||
manager.runDailyIfDue()
|
||||
} finally {
|
||||
manager.refreshSchedule()
|
||||
}
|
||||
}
|
||||
Intent.ACTION_BOOT_COMPLETED -> {
|
||||
// 重启后闹钟丢失,重新排程(今天是否签到由首次打开 App 兜底)
|
||||
manager.refreshSchedule()
|
||||
}
|
||||
}
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} catch (_: Exception) {
|
||||
// 后台静默:签到/排程失败不阻塞下次闹钟,等下次打开 App 再补救
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
748
app/src/main/java/com/rainy/token/ui/heatmap/HeatmapCanvas.kt
Normal file
748
app/src/main/java/com/rainy/token/ui/heatmap/HeatmapCanvas.kt
Normal file
@ -0,0 +1,748 @@
|
||||
package com.rainy.token.ui.heatmap
|
||||
|
||||
import android.graphics.Paint
|
||||
import android.os.SystemClock
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInWindow
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.round
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.rainy.token.R
|
||||
import com.rainy.token.ui.theme.inkMuted
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
// ── 热力图颜色 ──────────────────────────────────────────────
|
||||
|
||||
/** 亮色模式 6 级玫瑰粉梯度(精致粉调,最高级与主色一致) */
|
||||
private val LightHeatmapColors = listOf(
|
||||
Color(0xFFF3E9EE), // Level 0: 极浅暖灰
|
||||
Color(0xFFFFE3EC), // Level 1: 浅樱粉
|
||||
Color(0xFFFFC9D9), // Level 2: 浅玫瑰柔
|
||||
Color(0xFFFFA3BC), // Level 3: 玫瑰粉
|
||||
Color(0xFFF27E9F), // Level 4: 玫瑰粉深
|
||||
Color(0xFFC9436C), // Level 5: 深玫瑰(主色)
|
||||
)
|
||||
|
||||
/** 暗色模式 6 级玫瑰粉梯度 */
|
||||
private val DarkHeatmapColors = listOf(
|
||||
Color(0xFF3A2B33), // Level 0: 暖深灰
|
||||
Color(0xFF4A3239), // Level 1
|
||||
Color(0xFF643D4A), // Level 2
|
||||
Color(0xFF855062), // Level 3
|
||||
Color(0xFFA9627A), // Level 4
|
||||
Color(0xFFE07C9A), // Level 5
|
||||
)
|
||||
|
||||
/** 月份名称资源(按月索引 1~12,locale-aware) */
|
||||
private val monthNameRes = intArrayOf(
|
||||
R.string.month_1, R.string.month_2, R.string.month_3, R.string.month_4,
|
||||
R.string.month_5, R.string.month_6, R.string.month_7, R.string.month_8,
|
||||
R.string.month_9, R.string.month_10, R.string.month_11, R.string.month_12,
|
||||
)
|
||||
|
||||
// ── 格式化辅助函数(HeatmapScreen 浮层与滑动查看读数条共用)──
|
||||
|
||||
/** 是否为繁体中文 locale(数字单位用 萬/億) */
|
||||
private fun isTraditionalChinese(locale: Locale): Boolean =
|
||||
locale.language == "zh" &&
|
||||
(locale.country == "TW" || locale.country == "HK" || locale.country == "MO")
|
||||
|
||||
/**
|
||||
* 将 Token 数按数量级格式化(locale-aware):
|
||||
* - 中文(zh):亿级 1.9亿 / 万级 11.2万(繁体用 萬/億)
|
||||
* - 其他语言:≥1e9 → 1.9B,≥1e6 → 11.2M,≥1e3 → 16.8K,否则原数字
|
||||
*
|
||||
* 注意:中文万级先舍入到一位小数再判断是否升亿(如 99999999 → "1.0亿",而非 "10000.0万")。
|
||||
*/
|
||||
internal fun formatTokenChinese(tokens: Long, locale: Locale = Locale.getDefault()): String {
|
||||
if (locale.language != "zh") {
|
||||
return when {
|
||||
tokens >= 1_000_000_000L -> "${"%.1f".format(tokens / 1_000_000_000.0)}B"
|
||||
tokens >= 1_000_000L -> "${"%.1f".format(tokens / 1_000_000.0)}M"
|
||||
tokens >= 1_000L -> "${"%.1f".format(tokens / 1_000.0)}K"
|
||||
else -> "$tokens"
|
||||
}
|
||||
}
|
||||
val wan = if (isTraditionalChinese(locale)) "萬" else "万"
|
||||
val yi = if (isTraditionalChinese(locale)) "億" else "亿"
|
||||
return when {
|
||||
tokens >= 1_0000_0000 -> {
|
||||
val v = tokens / 1_0000_0000.0
|
||||
if (v >= 100) "${v.roundToInt()}$yi"
|
||||
else "${"%.1f".format(v)}$yi"
|
||||
}
|
||||
tokens >= 1_0000 -> {
|
||||
val v = tokens / 1_0000.0
|
||||
// 先舍入到一位小数,避免 "99999999 → 10000.0万"
|
||||
val rounded = (v * 10).roundToInt() / 10.0
|
||||
if (rounded >= 10000) {
|
||||
// 万级溢出升亿:9999.99万 → 1.0亿
|
||||
val v2 = rounded / 10000.0
|
||||
if (v2 >= 100) "${v2.roundToInt()}$yi"
|
||||
else "${"%.1f".format(v2)}$yi"
|
||||
} else {
|
||||
"${"%.1f".format(v)}$wan" // 始终保留一位小数
|
||||
}
|
||||
}
|
||||
else -> "$tokens"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将时间戳格式化为日期文本(locale-aware),按与数据分桶一致的时区:
|
||||
* - 中文(zh):"7月9日",跨年前缀 "2024年7月9日"
|
||||
* - 其他语言:按 locale 的 "MMM d",跨年 "MMM d, yyyy"(如 "Jul 9, 2024")
|
||||
*
|
||||
* 日浮层传 [currentYear](查看往年时带年份前缀);周浮层两端传 [selectedYear],
|
||||
* 使跨年周(如 12月28日-次年1月3日)的结束日带年份前缀,避免范围倒挂。
|
||||
*/
|
||||
internal fun formatDateChinese(ts: Long, useUtc8: Boolean, yearContext: Int, locale: Locale = Locale.getDefault()): String {
|
||||
val zone = if (useUtc8) ZoneOffset.ofHours(8) else ZoneOffset.UTC
|
||||
val date = Instant.ofEpochMilli(ts).atOffset(zone).toLocalDate()
|
||||
return if (locale.language == "zh") {
|
||||
val yearPrefix = if (date.year != yearContext) "${date.year}年" else ""
|
||||
"$yearPrefix${date.monthValue}月${date.dayOfMonth}日"
|
||||
} else {
|
||||
val fmt = if (date.year != yearContext) "MMM d, yyyy" else "MMM d"
|
||||
DateTimeFormatter.ofPattern(fmt, locale).format(date)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将周数据格式化为范围文本:"12月28日-12月31日"(不含"使用了X token",
|
||||
* 句子由调用方用 stringResource 按语言拼接)。
|
||||
*
|
||||
* - 末周只显示到该周落在所选窗口内的有效日期([dataEndTs],数据最后一天),
|
||||
* 避免出现未绘制的未来/次年空位日期;数据同源,不依赖实时时钟
|
||||
* - 首列跨年(start 在上一年)时,end 与 start 不同年 → end 强制带年份前缀,避免范围倒挂
|
||||
* - [RECENT_YEAR](最近 365 天窗口)时按周自身年份判断:start 不带前缀,
|
||||
* end 仅在与 start 跨年时带前缀(窗口可能跨年,如 "1月5日-2026年1月3日")
|
||||
*/
|
||||
internal fun formatWeekRangeText(
|
||||
week: HeatmapWeekData,
|
||||
useUtc8: Boolean,
|
||||
selectedYear: Int,
|
||||
dataEndTs: Long?,
|
||||
locale: Locale = Locale.getDefault(),
|
||||
): String {
|
||||
val zone = if (useUtc8) ZoneOffset.ofHours(8) else ZoneOffset.UTC
|
||||
val startDate = Instant.ofEpochMilli(week.weekStartTs).atOffset(zone).toLocalDate()
|
||||
// 最近模式按周自身年份判断前缀;年份模式用所选年作上下文(与既有行为一致)
|
||||
val startContext = if (selectedYear == RECENT_YEAR) startDate.year else selectedYear
|
||||
val start = formatDateChinese(week.weekStartTs, useUtc8, startContext, locale)
|
||||
val endTs = minOf(week.weekStartTs + 6L * 86_400_000L, dataEndTs ?: (week.weekStartTs + 6L * 86_400_000L))
|
||||
if (locale.language == "zh") {
|
||||
val endDate = Instant.ofEpochMilli(endTs).atOffset(zone).toLocalDate()
|
||||
val end = if (endDate.year != startDate.year) {
|
||||
"${endDate.year}年${endDate.monthValue}月${endDate.dayOfMonth}日"
|
||||
} else {
|
||||
formatDateChinese(endTs, useUtc8, startContext, locale)
|
||||
}
|
||||
return "$start-$end"
|
||||
}
|
||||
// 非中文:end 用 start 的年份作上下文(同 startDate.year 时无前缀;跨年时带年份)
|
||||
return "$start-${formatDateChinese(endTs, useUtc8, startContext, locale)}"
|
||||
}
|
||||
|
||||
// ── 辅助类型与函数 ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 水平拖动手势的仲裁模式:
|
||||
* - VIEW:慢速横向拖动 → 滑动查看模式(显示手指所在天的信息,图表锁定不滚动)
|
||||
* - SCROLL:快速横向拖动或纵向拖动 → 放行给 scrollable 正常滚动
|
||||
*/
|
||||
private enum class DragMode { VIEW, SCROLL }
|
||||
|
||||
/** 慢速/快速拖动分界速度(dp/s,密度无关;低于=查看拖动,高于=滚动拖动) */
|
||||
private const val VIEW_SPEED_DP_PER_S = 150f
|
||||
|
||||
/** 月份标签 */
|
||||
private data class MonthLabel(
|
||||
val weekIndex: Int,
|
||||
val label: String,
|
||||
)
|
||||
|
||||
/** 从时间戳获取星期几 (0=Sunday, 6=Saturday),按指定时区 */
|
||||
private fun dayOfWeekFromTs(ts: Long, zone: ZoneOffset): Int {
|
||||
val dayOfWeek = Instant.ofEpochMilli(ts).atOffset(zone).dayOfWeek.value
|
||||
return dayOfWeek % 7 // Sunday=7 → 0
|
||||
}
|
||||
|
||||
/** 从时间戳获取月份 (0=January),按指定时区 */
|
||||
private fun monthFromTs(ts: Long, zone: ZoneOffset): Int {
|
||||
return Instant.ofEpochMilli(ts).atOffset(zone).monthValue - 1
|
||||
}
|
||||
|
||||
/**
|
||||
* 将每日数据按周分组(参考 react-activity-calendar 的 groupByWeeks 逻辑)。
|
||||
* 周起始日为周日(weekStart = 0)。
|
||||
* 返回 List<List<HeatmapDayData?>>,外层为周,内层为星期 0-6,null 表示空位。
|
||||
*/
|
||||
private fun groupByWeeks(dailyData: List<HeatmapDayData>, zone: ZoneOffset): List<List<HeatmapDayData?>> {
|
||||
if (dailyData.isEmpty()) return emptyList()
|
||||
|
||||
val firstDow = dayOfWeekFromTs(dailyData[0].dayTs, zone)
|
||||
|
||||
// 左侧填充空位,使第一周从周日开始
|
||||
val padded = ArrayList<HeatmapDayData?>(firstDow + dailyData.size)
|
||||
repeat(firstDow) { padded.add(null) }
|
||||
padded.addAll(dailyData)
|
||||
|
||||
val numWeeks = (padded.size + 6) / 7
|
||||
return (0 until numWeeks).map { w ->
|
||||
(0 until 7).map { d ->
|
||||
val idx = w * 7 + d
|
||||
if (idx < padded.size) padded[idx] else null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 从按周分组的每日数据计算月份标签([monthNames] 为当前 locale 的月份名,索引 0~11) */
|
||||
private fun getMonthLabels(
|
||||
weeks: List<List<HeatmapDayData?>>,
|
||||
zone: ZoneOffset,
|
||||
monthNames: List<String>,
|
||||
): List<MonthLabel> {
|
||||
if (weeks.isEmpty()) return emptyList()
|
||||
|
||||
val rawLabels = mutableListOf<MonthLabel>()
|
||||
var prevMonth = -1
|
||||
|
||||
for (weekIndex in weeks.indices) {
|
||||
val firstDay = weeks[weekIndex].firstOrNull { it != null } ?: continue
|
||||
val month = monthFromTs(firstDay.dayTs, zone)
|
||||
|
||||
if (weekIndex == 0 || month != prevMonth) {
|
||||
rawLabels.add(MonthLabel(weekIndex, monthNames[month]))
|
||||
prevMonth = month
|
||||
}
|
||||
}
|
||||
|
||||
return filterMonthLabels(rawLabels, weeks.size)
|
||||
}
|
||||
|
||||
/**
|
||||
* 过滤月份标签:跳过间距不足的标签(参考 react-activity-calendar 的 label.ts)。
|
||||
* - 第一个标签:若与第二个标签间距 < minWeeks,跳过
|
||||
* - 最后一个标签:若剩余周数 < minWeeks,跳过
|
||||
*/
|
||||
private fun filterMonthLabels(rawLabels: List<MonthLabel>, totalWeeks: Int): List<MonthLabel> {
|
||||
val minWeeks = 3
|
||||
return rawLabels.filterIndexed { index, label ->
|
||||
when {
|
||||
index == 0 -> {
|
||||
rawLabels.getOrNull(1)?.let { it.weekIndex - label.weekIndex >= minWeeks } ?: false
|
||||
}
|
||||
index == rawLabels.lastIndex -> {
|
||||
totalWeeks - label.weekIndex >= minWeeks
|
||||
}
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── HeatmapCanvas Composable ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Token 活动热力图 Canvas 绘制组件。
|
||||
*
|
||||
* 支持三种视图:
|
||||
* - [HeatmapViewMode.DAILY]:约 53 列 × 7 行(列数按年浮动 52~54),每日 Token 使用
|
||||
* - [HeatmapViewMode.WEEKLY]:约 53 列 × 7 行,**离散柱状图**——列=自然周,
|
||||
* 整列 7 格全部绘制:底部 barHeight 格 = 该周等级颜色(0 用量周 1 格、非零周 2~7 格
|
||||
* 按 token 排名比例),其余格 = Level 0 浅色(空白格不隐藏,网格完整);
|
||||
* 选中周整列加主题色描边强调;列内格子不表示日期
|
||||
* - [HeatmapViewMode.CUMULATIVE]:约 53 列 × 7 行,累计 Token
|
||||
*
|
||||
* 三种视图列数与网格尺寸一致(约 52~54 列 × 7 行),切换视图时布局保持一致。
|
||||
*
|
||||
* 使用 Compose Canvas 绘制圆角矩形格子,底部显示中文月份标签,
|
||||
* 支持水平滚动和格子点击。
|
||||
*
|
||||
* @param state UI 状态,包含数据与视图模式
|
||||
* @param modifier 布局修饰符
|
||||
* @param selectedWeek 当前选中的周(每周视图绘制强调边框用,null=未选中)
|
||||
* @param onDayClick 每日/累计视图格子点击回调
|
||||
* @param onWeekClick 每周视图格子点击回调
|
||||
*/
|
||||
@Composable
|
||||
fun HeatmapCanvas(
|
||||
state: HeatmapUiState,
|
||||
modifier: Modifier = Modifier,
|
||||
noDataText: String = "",
|
||||
lessText: String = "",
|
||||
moreText: String = "",
|
||||
accessibilityDesc: String = "",
|
||||
selectedWeek: HeatmapWeekData? = null,
|
||||
onDayClick: (HeatmapDayData, IntOffset) -> Unit = { _, _ -> },
|
||||
onWeekClick: (HeatmapWeekData, IntOffset) -> Unit = { _, _ -> },
|
||||
) {
|
||||
val darkTheme = isSystemInDarkTheme()
|
||||
val colors = if (darkTheme) DarkHeatmapColors else LightHeatmapColors
|
||||
val mutedColor = inkMuted()
|
||||
val density = LocalDensity.current
|
||||
// 应用内语言(LocaleManager 只覆写配置、不改变 Locale.getDefault(),故从配置取;
|
||||
// 与 UsageOverviewScreen 的 date_format_md 同一做法)
|
||||
val activeLocale = LocalConfiguration.current.locales[0]
|
||||
// 与全局图表设置一致的时区(分桶与显示口径统一)
|
||||
val zone = if (state.useUtc8) ZoneOffset.ofHours(8) else ZoneOffset.UTC
|
||||
|
||||
// 格子参数
|
||||
val blockSize = 11.dp
|
||||
val gap = 3.dp
|
||||
val cornerRadius = 3.dp
|
||||
|
||||
// 预计算 px 值(供 Canvas 绘制和点击检测共用)
|
||||
val blockSizePx = with(density) { blockSize.toPx() }
|
||||
val gapPx = with(density) { gap.toPx() }
|
||||
val cellStepPx = blockSizePx + gapPx
|
||||
val cornerRadiusPx = with(density) { cornerRadius.toPx() }
|
||||
|
||||
val isWeekly = state.viewMode == HeatmapViewMode.WEEKLY
|
||||
|
||||
// 选中周强调样式(主题主色描边,明暗模式均可见)
|
||||
val selectionColor = MaterialTheme.colorScheme.primary
|
||||
val selectionStrokePx = with(density) { 1.5.dp.toPx() }
|
||||
|
||||
// 滑动查看指示器(三种视图):慢速拖动或长按时"所见即所得"——手指所在格子高亮预览,
|
||||
// 底部读数条显示该格数据(每日/累计=当天,每周=该周);抬起后保留最后预览,切换视图/年份时清除
|
||||
var indicatorDay by remember { mutableStateOf<HeatmapDayData?>(null) }
|
||||
var indicatorWeek by remember { mutableStateOf<HeatmapWeekData?>(null) }
|
||||
var indicatorCol by remember { mutableStateOf(-1) }
|
||||
var indicatorRow by remember { mutableStateOf(-1) }
|
||||
|
||||
// Canvas 在窗口中的位置(浮层锚点定位用)
|
||||
var canvasWindowPos by remember { mutableStateOf(IntOffset.Zero) }
|
||||
|
||||
// 构建网格数据(每日/累计视图用于绘制与点击;每周视图仅用于列数与月份标签,
|
||||
// 柱状图绘制/点击直接基于 weeklyData)
|
||||
val gridWeeks = remember(state.dailyData, state.cumulativeData, state.viewMode, zone) {
|
||||
when (state.viewMode) {
|
||||
HeatmapViewMode.DAILY, HeatmapViewMode.WEEKLY -> groupByWeeks(state.dailyData, zone)
|
||||
HeatmapViewMode.CUMULATIVE -> groupByWeeks(state.cumulativeData, zone)
|
||||
}
|
||||
}
|
||||
|
||||
// 月份标签(三种视图共用同一计算,位置与每日视图完全一致;月份名按当前 locale)
|
||||
val monthNames = List(12) { stringResource(monthNameRes[it]) }
|
||||
val monthLabels = remember(gridWeeks, zone, monthNames) {
|
||||
getMonthLabels(gridWeeks, zone, monthNames)
|
||||
}
|
||||
|
||||
val cols = gridWeeks.size
|
||||
val rows = 7
|
||||
|
||||
// 加载中或无数据
|
||||
if (state.loading || cols == 0) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(if (state.loading) 200.dp else 120.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (state.loading) {
|
||||
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
|
||||
} else {
|
||||
Text(noDataText.ifEmpty { "No data" }, color = mutedColor, fontSize = 14.sp)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Canvas 尺寸计算
|
||||
val canvasWidthDp = (blockSize + gap) * cols - gap
|
||||
val gridHeightDp = (blockSize + gap) * rows - gap
|
||||
val monthLabelHeightDp = 20.dp
|
||||
val totalHeightDp = gridHeightDp + monthLabelHeightDp
|
||||
|
||||
val gridHeightPx = with(density) { gridHeightDp.toPx() }
|
||||
val labelGapPx = with(density) { 4.dp.toPx() }
|
||||
|
||||
// 月份标签画笔
|
||||
val monthLabelPaint = remember(mutedColor, density) {
|
||||
Paint().apply {
|
||||
textSize = with(density) { 12.sp.toPx() }
|
||||
color = mutedColor.toArgb()
|
||||
isAntiAlias = true
|
||||
}
|
||||
}
|
||||
|
||||
// 横向滚动状态(图例固定在外层,不随热力图滚动)
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
// 打开页面 / 切换视图 / 切换年份时,横向滚动自动定位到最右(最新数据),并清除滑动查看指示器
|
||||
LaunchedEffect(cols, state.viewMode, state.selectedYear) {
|
||||
indicatorDay = null
|
||||
indicatorWeek = null
|
||||
indicatorCol = -1
|
||||
indicatorRow = -1
|
||||
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
||||
scrollState.scrollTo(scrollState.maxValue)
|
||||
}
|
||||
|
||||
Column(modifier = modifier) {
|
||||
// ── 热力图网格 + 月份标签(可横向滚动)──
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.horizontalScroll(scrollState)
|
||||
.semantics { contentDescription = accessibilityDesc.ifEmpty {
|
||||
if (isWeekly) "Token activity heatmap, ${state.weeklyData.size} weeks"
|
||||
else "Token activity heatmap, ${state.dailyData.size} days"
|
||||
} },
|
||||
) {
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.size(canvasWidthDp, totalHeightDp)
|
||||
.onGloballyPositioned { canvasWindowPos = it.positionInWindow().round() }
|
||||
// 手势仲裁(与点击共存):慢速横向拖动/长按=滑动查看(锁定图表、指示器跟手),
|
||||
// 快速横向拖动或纵向拖动=放行给 scrollable 正常滚动;三种视图均启用。
|
||||
// ⚠️ 必须声明在下方 detectTapGestures 之前:仲裁先收到 Main pass 事件,
|
||||
// VIEW 模式先 consume 可阻止 tap 误触发(顺序承重,勿调换)
|
||||
.pointerInput(state, cellStepPx, blockSizePx, gridWeeks, state.viewMode) {
|
||||
// 慢速/快速分界:密度无关(dp/s),保证不同设备手感一致
|
||||
val viewSpeedThresholdPxPerMs = with(density) { VIEW_SPEED_DP_PER_S.dp.toPx() } / 1000f
|
||||
// 指示器更新:手指所在列/行的格子(所见即所得)。
|
||||
// change.position 已是滚动内容内的本地坐标(与 detectTapGestures 命中一致),
|
||||
// 不能再加 scrollState.value,否则滚动后列索引整体偏右
|
||||
fun updateIndicator(pos: Offset) {
|
||||
val col = (pos.x / cellStepPx).toInt().coerceIn(0, cols - 1)
|
||||
val row = (pos.y / cellStepPx).toInt().coerceIn(0, rows - 1)
|
||||
if (isWeekly) {
|
||||
// 每周视图:整列=同一周(柱状图),读数条显示该周范围与总量
|
||||
val week = state.weeklyData.getOrNull(col)
|
||||
if (week != null) {
|
||||
indicatorDay = null
|
||||
indicatorWeek = week
|
||||
indicatorCol = col
|
||||
indicatorRow = row
|
||||
} else {
|
||||
indicatorDay = null
|
||||
indicatorWeek = null
|
||||
indicatorCol = -1
|
||||
indicatorRow = -1
|
||||
}
|
||||
} else {
|
||||
// 每日/累计视图:格子=一天(累计视图 tokens 为累计值)
|
||||
val cell = gridWeeks.getOrNull(col)?.getOrNull(row)
|
||||
if (cell != null) {
|
||||
indicatorWeek = null
|
||||
indicatorDay = cell
|
||||
indicatorCol = col
|
||||
indicatorRow = row
|
||||
} else {
|
||||
indicatorWeek = null
|
||||
indicatorDay = null
|
||||
indicatorCol = -1
|
||||
indicatorRow = -1
|
||||
}
|
||||
}
|
||||
}
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
// 模式判定(本次手势内锁定):
|
||||
// - 按住不动/慢速横向拖动 → VIEW:指示器跟手、消费事件锁定图表滚动
|
||||
// - 快速横向拖动或纵向主导 → SCROLL:不消费,放行给 scrollable 正常滚动
|
||||
var mode: DragMode? = null
|
||||
var lastPos = down.position
|
||||
var lastTime = SystemClock.uptimeMillis()
|
||||
var totalDx = 0f
|
||||
var totalDy = 0f
|
||||
val slop = viewConfiguration.touchSlop
|
||||
val downTime = lastTime
|
||||
// 长按阈值:按住不动(含微移未超 slop)超过系统长按时长 → 直接进入查看模式。
|
||||
// 静止时系统不一定派发 move 事件,故用 withTimeoutOrNull 做超时心跳,不依赖事件频率。
|
||||
// ⚠️ 此符号解析为 AwaitPointerEventScope 的框架成员(非 kotlinx 协程版),
|
||||
// 勿补 import kotlinx.coroutines.withTimeoutOrNull(会被接口成员遮蔽且语义不同)
|
||||
val longPressMs = viewConfiguration.longPressTimeoutMillis
|
||||
|
||||
// 阶段一:模式判定。位移超 slop 按帧速度判 VIEW/SCROLL;提前抬起 = 点击放行给 tap;
|
||||
// 长按超时(手指未动)→ VIEW
|
||||
while (mode == null) {
|
||||
val remaining = longPressMs - (SystemClock.uptimeMillis() - downTime)
|
||||
val event = if (remaining > 0) {
|
||||
withTimeoutOrNull(remaining) { awaitPointerEvent() }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
if (event == null) {
|
||||
// 长按成立:按住不动(或微移 < slop)→ 查看模式
|
||||
mode = DragMode.VIEW
|
||||
break
|
||||
}
|
||||
val change = event.changes.firstOrNull { it.id == down.id } ?: break
|
||||
if (!change.pressed) break // 快速抬起 = 点击,交给 detectTapGestures
|
||||
val cur = change.position
|
||||
val now = SystemClock.uptimeMillis()
|
||||
// 帧瞬时速度:停顿后快甩的帧速度大 → 判为滚动;慢速拖动各帧速度都小 → 判为查看
|
||||
val dxFrame = cur.x - lastPos.x
|
||||
val dyFrame = cur.y - lastPos.y
|
||||
val dtFrame = (now - lastTime).coerceAtLeast(1L)
|
||||
val speedX = abs(dxFrame) / dtFrame
|
||||
lastPos = cur
|
||||
lastTime = now
|
||||
totalDx += dxFrame
|
||||
totalDy += dyFrame
|
||||
// 首次超过触摸阈值时判定模式(本次手势内锁定):
|
||||
// 横向主导且慢速 → VIEW(查看);否则 → SCROLL(滚动)
|
||||
if (abs(totalDx) > slop || abs(totalDy) > slop) {
|
||||
mode = if (abs(totalDx) > abs(totalDy) && speedX < viewSpeedThresholdPxPerMs) {
|
||||
DragMode.VIEW
|
||||
} else {
|
||||
DragMode.SCROLL
|
||||
}
|
||||
if (mode == DragMode.VIEW) {
|
||||
// 判决事件立即消费:否则同帧(Main pass leaf→root)会漏到父 scrollable,
|
||||
// 其越过自身 slop 先滚一帧(约 0~4px 抖动)后才被 phase2 消费取消
|
||||
change.consume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mode == DragMode.VIEW) {
|
||||
// 进入查看模式立即用最后已知位置更新指示器(长按超时瞬间就要显示,不等下一个事件)
|
||||
updateIndicator(lastPos)
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val change = event.changes.firstOrNull { it.id == down.id } ?: break
|
||||
// 先消费再判抬起:up 若不消费会漏到 detectTapGestures 误触发 onTap 浮层
|
||||
// (waitForUpOrCancellation 对 consumed up 返回 null → tap 取消),
|
||||
// 与慢拖路径行为保持一致
|
||||
updateIndicator(change.position)
|
||||
change.consume() // 消费事件锁定图表滚动
|
||||
if (!change.pressed) break // 抬起:保留最后预览的格子
|
||||
}
|
||||
} else if (mode == DragMode.SCROLL) {
|
||||
// 滚动模式:清除上次预览的指示器,避免滚动后残留过期信息;
|
||||
// 不消费事件,scrollable 正常滚动(含惯性)
|
||||
indicatorDay = null
|
||||
indicatorWeek = null
|
||||
indicatorCol = -1
|
||||
indicatorRow = -1
|
||||
}
|
||||
}
|
||||
}
|
||||
.pointerInput(state, cellStepPx, blockSizePx, gridWeeks) {
|
||||
detectTapGestures(onTap = { offset ->
|
||||
// 点击时清除滑动查看残留的预览(抬起后保留的指示器),避免与浮层同时显示
|
||||
indicatorDay = null
|
||||
indicatorWeek = null
|
||||
indicatorCol = -1
|
||||
indicatorRow = -1
|
||||
// 点击落在格子间隙内时不触发(Minor:命中精度)
|
||||
if (offset.x % cellStepPx >= blockSizePx || offset.y % cellStepPx >= blockSizePx) {
|
||||
return@detectTapGestures
|
||||
}
|
||||
val col = (offset.x / cellStepPx).toInt()
|
||||
val row = (offset.y / cellStepPx).toInt()
|
||||
if (col < 0 || col >= gridWeeks.size || row < 0 || row >= rows) {
|
||||
return@detectTapGestures
|
||||
}
|
||||
val cellCenterX = (col * cellStepPx + blockSizePx / 2f).roundToInt()
|
||||
val cellCenterY = (row * cellStepPx + blockSizePx / 2f).roundToInt()
|
||||
val anchor = IntOffset(canvasWindowPos.x + cellCenterX, canvasWindowPos.y + cellCenterY)
|
||||
|
||||
if (isWeekly) {
|
||||
// 每周视图(柱状图):该列任意格子都对应同一周 → 点击触发该周浮层;
|
||||
// 0 用量周也有 1 格 Level 0 浅色格,同样可点击(显示「使用了0token」)
|
||||
val weekData = state.weeklyData.getOrNull(col) ?: return@detectTapGestures
|
||||
onWeekClick(weekData, anchor)
|
||||
} else {
|
||||
// 每日/累计视图:点击 (col, row) 对应格子(空位不可点)
|
||||
val cell = gridWeeks[col][row] ?: return@detectTapGestures
|
||||
onDayClick(cell, anchor)
|
||||
}
|
||||
})
|
||||
},
|
||||
) {
|
||||
// 绘制格子:
|
||||
// - 每日/累计:7 行网格按天等级绘制,null 空位不绘制
|
||||
// - 每周:离散柱状图——列=自然周,整列 7 格全部绘制:
|
||||
// 底部 barHeight 格 = 该周等级颜色(柱:0 用量周 1 格 Level 0、非零周 2~7 格),
|
||||
// 其余格 = Level 0 浅色(空白格不隐藏,网格完整);
|
||||
// 选中周整列加主题色描边强调
|
||||
if (isWeekly) {
|
||||
state.weeklyData.forEachIndexed { weekIndex, weekData ->
|
||||
val level = weekData.level.coerceIn(0, 5)
|
||||
val barHeight = weekData.barHeight.coerceIn(1, 7)
|
||||
val isSelected = weekData == selectedWeek
|
||||
val x = weekIndex * cellStepPx
|
||||
for (row in 0 until rows) {
|
||||
val inBar = row >= rows - barHeight
|
||||
val y = row * cellStepPx
|
||||
drawRoundRect(
|
||||
color = if (inBar) colors[level] else colors[0],
|
||||
topLeft = Offset(x, y),
|
||||
size = Size(blockSizePx, blockSizePx),
|
||||
cornerRadius = CornerRadius(cornerRadiusPx, cornerRadiusPx),
|
||||
)
|
||||
if (isSelected) {
|
||||
drawRoundRect(
|
||||
color = selectionColor,
|
||||
topLeft = Offset(x, y),
|
||||
size = Size(blockSizePx, blockSizePx),
|
||||
cornerRadius = CornerRadius(cornerRadiusPx, cornerRadiusPx),
|
||||
style = Stroke(width = selectionStrokePx),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
gridWeeks.forEachIndexed { weekIndex, week ->
|
||||
week.forEachIndexed { dayIndex, dayData ->
|
||||
if (dayData == null) return@forEachIndexed
|
||||
val x = weekIndex * cellStepPx
|
||||
val y = dayIndex * cellStepPx
|
||||
drawRoundRect(
|
||||
color = colors[dayData.level.coerceIn(0, 5)],
|
||||
topLeft = Offset(x, y),
|
||||
size = Size(blockSizePx, blockSizePx),
|
||||
cornerRadius = CornerRadius(cornerRadiusPx, cornerRadiusPx),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 绘制月份标签(底部)
|
||||
drawIntoCanvas { canvas ->
|
||||
val nativeCanvas = canvas.nativeCanvas
|
||||
// labelY = 网格底部 + 间距 + 文字基线偏移
|
||||
val labelY = gridHeightPx + labelGapPx - monthLabelPaint.ascent()
|
||||
monthLabels.forEach { label ->
|
||||
val x = label.weekIndex * cellStepPx
|
||||
nativeCanvas.drawText(label.label, x, labelY, monthLabelPaint)
|
||||
}
|
||||
}
|
||||
|
||||
// 预览格子高亮(三种视图,所见即所得):手指所在格子加主题色边框。
|
||||
// 防御性校验:指示器指向的格子数据仍存在(防未来"同一年份数据刷新"后残留过期高亮)
|
||||
val indicatorValid = when {
|
||||
indicatorWeek != null -> state.weeklyData.getOrNull(indicatorCol) != null
|
||||
indicatorDay != null -> gridWeeks.getOrNull(indicatorCol)?.getOrNull(indicatorRow) != null
|
||||
else -> false
|
||||
}
|
||||
if (indicatorValid &&
|
||||
indicatorCol in 0 until cols && indicatorRow in 0 until rows
|
||||
) {
|
||||
drawRoundRect(
|
||||
color = selectionColor,
|
||||
topLeft = Offset(indicatorCol * cellStepPx, indicatorRow * cellStepPx),
|
||||
size = Size(blockSizePx, blockSizePx),
|
||||
cornerRadius = CornerRadius(cornerRadiusPx, cornerRadiusPx),
|
||||
style = Stroke(width = selectionStrokePx * 1.5f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 滑动查看读数条(三种视图):显示手指所在格子的数据(每日/累计=当天,每周=该周范围),
|
||||
// 抬起后保留;固定高度占位,避免出现/消失导致图例上下跳动
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(20.dp),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
val d = indicatorDay
|
||||
val w = indicatorWeek
|
||||
val text = when {
|
||||
d != null -> stringResource(
|
||||
R.string.heatmap_day_used,
|
||||
formatDateChinese(d.dayTs, state.useUtc8, state.currentYear, activeLocale),
|
||||
formatTokenChinese(d.tokens, activeLocale),
|
||||
)
|
||||
w != null -> stringResource(
|
||||
R.string.heatmap_week_used,
|
||||
formatWeekRangeText(w, state.useUtc8, state.selectedYear, state.dailyData.lastOrNull()?.dayTs, activeLocale),
|
||||
formatTokenChinese(w.tokens, activeLocale),
|
||||
)
|
||||
else -> null
|
||||
}
|
||||
if (text != null) {
|
||||
Text(
|
||||
text = text,
|
||||
fontSize = 12.sp,
|
||||
color = mutedColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
// ── 颜色图例(右下角,固定不随热力图横向滚动)──
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(lessText.ifEmpty { "Less" }, color = mutedColor, fontSize = 12.sp)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
for (level in 0..5) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(blockSize)
|
||||
.background(colors[level], RoundedCornerShape(cornerRadius)),
|
||||
)
|
||||
if (level < 5) {
|
||||
Spacer(Modifier.width(gap))
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(moreText.ifEmpty { "More" }, color = mutedColor, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
808
app/src/main/java/com/rainy/token/ui/heatmap/HeatmapScreen.kt
Normal file
808
app/src/main/java/com/rainy/token/ui/heatmap/HeatmapScreen.kt
Normal file
@ -0,0 +1,808 @@
|
||||
package com.rainy.token.ui.heatmap
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.BitmapFactory
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
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.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
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.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.selected
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Popup
|
||||
import androidx.compose.ui.window.PopupProperties
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.rainy.token.R
|
||||
import com.rainy.token.ui.theme.inkMuted
|
||||
import java.io.File
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Token 活动热力图页面。
|
||||
*
|
||||
* Scaffold + TopAppBar 布局,包含视图切换器(每日/每周/累计)和 HeatmapCanvas。
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HeatmapScreen(
|
||||
onBack: () -> Unit,
|
||||
viewModel: HeatmapViewModel = hiltViewModel()
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
// 应用内语言(LocaleManager 只覆写配置、不改变 Locale.getDefault(),故从配置取)
|
||||
val activeLocale = LocalConfiguration.current.locales[0]
|
||||
|
||||
// ── 浮层状态 ──
|
||||
var selectedDay: HeatmapDayData? by remember { mutableStateOf(null) }
|
||||
var selectedWeek: HeatmapWeekData? by remember { mutableStateOf(null) }
|
||||
// 浮层锚点:被点击格子中心在窗口中的位置
|
||||
var popupAnchor by remember { mutableStateOf<IntOffset?>(null) }
|
||||
// 浮层自身尺寸(用于锚点偏移计算)
|
||||
var popupSize by remember { mutableStateOf(IntSize.Zero) }
|
||||
val density = LocalDensity.current
|
||||
|
||||
// ── i18n 字符串 ──
|
||||
val noDataText = stringResource(R.string.heatmap_no_data)
|
||||
val lessText = stringResource(R.string.heatmap_legend_less)
|
||||
val moreText = stringResource(R.string.heatmap_legend_more)
|
||||
|
||||
// ── 年份选择器状态 ──
|
||||
var yearMenuExpanded by remember { mutableStateOf(false) }
|
||||
val selectYearDesc = stringResource(R.string.heatmap_select_year)
|
||||
|
||||
// ── 个人资料状态(昵称/邮箱/头像路径,SharedPreferences 持久化)──
|
||||
val context = LocalContext.current
|
||||
val profilePrefs = remember { context.getSharedPreferences(PROFILE_PREFS, Context.MODE_PRIVATE) }
|
||||
var nickname by remember { mutableStateOf("") }
|
||||
var email by remember { mutableStateOf("") }
|
||||
var avatarPath by remember { mutableStateOf<String?>(null) }
|
||||
// 换头像后路径不变(固定文件名),用版本号强制触发头像重载
|
||||
var avatarVersion by remember { mutableStateOf(0) }
|
||||
var showProfileEdit by remember { mutableStateOf(false) }
|
||||
var avatarMenuVisible by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) {
|
||||
nickname = profilePrefs.getString(KEY_NICKNAME, "").orEmpty()
|
||||
email = profilePrefs.getString(KEY_EMAIL, "").orEmpty()
|
||||
avatarPath = profilePrefs.getString(KEY_AVATAR_PATH, null)
|
||||
}
|
||||
// 系统相册选图 → 复制到应用私有目录(content URI 授权是临时的,必须保存持久化副本)
|
||||
// 复制在 IO 线程执行(相册原图可达数 MB,避免阻塞主线程);
|
||||
// 先写临时文件再 rename 覆盖,失败时不破坏已有头像
|
||||
val scope = rememberCoroutineScope()
|
||||
val avatarPicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||
if (uri == null) return@rememberLauncherForActivityResult
|
||||
scope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
val target = File(context.filesDir, AVATAR_FILE_NAME)
|
||||
val tmp = File(context.filesDir, AVATAR_FILE_NAME + ".tmp")
|
||||
val input = context.contentResolver.openInputStream(uri)
|
||||
?: error("cannot open picked image")
|
||||
input.use { i -> tmp.outputStream().use { o -> i.copyTo(o) } }
|
||||
if (!tmp.renameTo(target)) {
|
||||
tmp.delete()
|
||||
error("cannot persist avatar")
|
||||
}
|
||||
target.absolutePath
|
||||
}
|
||||
}.onSuccess { path ->
|
||||
avatarPath = path
|
||||
avatarVersion++ // 路径相同(固定文件名),必须递增版本号触发头像重载
|
||||
profilePrefs.edit().putString(KEY_AVATAR_PATH, path).apply()
|
||||
}.onFailure { tmp ->
|
||||
File(context.filesDir, AVATAR_FILE_NAME + ".tmp").delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.load()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
containerColor = Color.Transparent,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(stringResource(R.string.heatmap_title))
|
||||
if (state.availableYears.isNotEmpty()) {
|
||||
Spacer(Modifier.width(12.dp))
|
||||
// 年份下拉选择器:默认"最近 365 天"(置顶),下方为具体年份
|
||||
val selectRangeLabel = if (state.selectedYear == RECENT_YEAR) {
|
||||
stringResource(R.string.heatmap_select_recent)
|
||||
} else {
|
||||
"${state.selectedYear}"
|
||||
}
|
||||
Text(
|
||||
text = "$selectRangeLabel ▾",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.clickable { yearMenuExpanded = true }
|
||||
.padding(horizontal = 6.dp, vertical = 4.dp)
|
||||
.semantics { contentDescription = "$selectYearDesc: $selectRangeLabel" }
|
||||
)
|
||||
DropdownMenu(
|
||||
expanded = yearMenuExpanded,
|
||||
onDismissRequest = { yearMenuExpanded = false }
|
||||
) {
|
||||
// "最近 365 天"(默认选项,与具体年份区分:置顶 + 分隔线)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.heatmap_select_recent)) },
|
||||
trailingIcon = {
|
||||
if (state.selectedYear == RECENT_YEAR) {
|
||||
Icon(
|
||||
Icons.Filled.Check,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp).semantics { selected = true },
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
yearMenuExpanded = false
|
||||
viewModel.setYear(RECENT_YEAR)
|
||||
// 切换范围时关闭浮层
|
||||
selectedDay = null
|
||||
selectedWeek = null
|
||||
popupAnchor = null
|
||||
}
|
||||
)
|
||||
HorizontalDivider()
|
||||
state.availableYears.sortedDescending().forEach { year ->
|
||||
DropdownMenuItem(
|
||||
text = { Text("$year") },
|
||||
trailingIcon = {
|
||||
if (state.selectedYear == year) {
|
||||
Icon(
|
||||
Icons.Filled.Check,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp).semantics { selected = true },
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
yearMenuExpanded = false
|
||||
viewModel.setYear(year)
|
||||
// 切换年份时关闭浮层
|
||||
selectedDay = null
|
||||
selectedWeek = null
|
||||
popupAnchor = null
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent)
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
) {
|
||||
// ── 个人资料卡片(页面最顶部:自定义头像 + 昵称/邮箱)──
|
||||
ProfileCard(
|
||||
nickname = nickname,
|
||||
email = email,
|
||||
avatarPath = avatarPath,
|
||||
avatarVersion = avatarVersion,
|
||||
onEdit = { showProfileEdit = true },
|
||||
onAvatarClick = { avatarMenuVisible = true },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 12.dp),
|
||||
)
|
||||
|
||||
// ── 活动洞察卡片(个人资料下方:总请求次数 + 最多请求时段 Top3)──
|
||||
InsightsCard(
|
||||
totalRequests = state.insights.totalRequests,
|
||||
topHours = state.insights.topHours,
|
||||
loading = state.loading,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 12.dp),
|
||||
)
|
||||
|
||||
// ── 视图切换器 ──
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier.padding(start = 4.dp, end = 4.dp, bottom = 12.dp)
|
||||
) {
|
||||
HeatmapViewMode.entries.forEach { mode ->
|
||||
val label = when (mode) {
|
||||
HeatmapViewMode.DAILY -> stringResource(R.string.heatmap_view_daily)
|
||||
HeatmapViewMode.WEEKLY -> stringResource(R.string.heatmap_view_weekly)
|
||||
HeatmapViewMode.CUMULATIVE -> stringResource(R.string.heatmap_view_cumulative)
|
||||
}
|
||||
val isSelected = state.viewMode == mode
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = if (isSelected) FontWeight.Medium else FontWeight.Normal,
|
||||
color = if (isSelected) MaterialTheme.colorScheme.onSurface else inkMuted(),
|
||||
modifier = Modifier.clickable {
|
||||
viewModel.setViewMode(mode)
|
||||
// 切换视图时关闭浮层
|
||||
selectedDay = null
|
||||
selectedWeek = null
|
||||
popupAnchor = null
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 年度统计(按所选年份,切换年份跟随变化)──
|
||||
// loading 期间显示占位,避免全 0 造成"无数据"假象
|
||||
val dash = "–"
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
StatItem(
|
||||
label = stringResource(R.string.heatmap_stats_total),
|
||||
value = if (state.loading) dash else formatTokenChinese(state.stats.totalTokens, activeLocale),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
StatItem(
|
||||
label = stringResource(R.string.heatmap_stats_peak),
|
||||
value = if (state.loading) dash else formatTokenChinese(state.stats.peakTokens, activeLocale),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
StatItem(
|
||||
label = stringResource(R.string.heatmap_stats_current_streak),
|
||||
value = if (state.loading) {
|
||||
dash
|
||||
} else {
|
||||
pluralStringResource(R.plurals.heatmap_stats_days, state.stats.currentStreak, state.stats.currentStreak)
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
StatItem(
|
||||
label = stringResource(R.string.heatmap_stats_max_streak),
|
||||
value = if (state.loading) {
|
||||
dash
|
||||
} else {
|
||||
pluralStringResource(R.plurals.heatmap_stats_days, state.stats.maxStreak, state.stats.maxStreak)
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 热力图 Canvas(带 Crossfade 淡入淡出动画)──
|
||||
Crossfade(
|
||||
targetState = state.viewMode,
|
||||
animationSpec = tween(150),
|
||||
label = "heatmap_view"
|
||||
) { mode ->
|
||||
// 用 mode 参数构造视图快照:过渡期间新旧两个分支渲染各自模式,
|
||||
// 否则两者都读最新 viewMode,动画会变成空操作
|
||||
// 无障碍描述也在分支内按 mode 计算,避免淡出分支播报错误的模式
|
||||
val desc = if (mode == HeatmapViewMode.WEEKLY) {
|
||||
stringResource(R.string.heatmap_accessibility_desc_weekly, state.weeklyData.size)
|
||||
} else {
|
||||
stringResource(R.string.heatmap_accessibility_desc, state.dailyData.size)
|
||||
}
|
||||
HeatmapCanvas(
|
||||
state = state.copy(viewMode = mode),
|
||||
noDataText = noDataText,
|
||||
lessText = lessText,
|
||||
moreText = moreText,
|
||||
accessibilityDesc = desc,
|
||||
selectedWeek = selectedWeek,
|
||||
onDayClick = { dayData, anchor ->
|
||||
// 再次点击同一格子则关闭浮层(0 token 的天也可点击查看)
|
||||
if (selectedDay == dayData) {
|
||||
selectedDay = null
|
||||
popupAnchor = null
|
||||
} else {
|
||||
selectedDay = dayData
|
||||
selectedWeek = null
|
||||
popupAnchor = anchor
|
||||
}
|
||||
},
|
||||
onWeekClick = { weekData, anchor ->
|
||||
if (selectedWeek == weekData) {
|
||||
selectedWeek = null
|
||||
popupAnchor = null
|
||||
} else {
|
||||
selectedWeek = weekData
|
||||
selectedDay = null
|
||||
popupAnchor = anchor
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
|
||||
// ── 个人资料编辑对话框(昵称/邮箱均可完全自定义,留空=清除)──
|
||||
if (showProfileEdit) {
|
||||
var editNickname by remember { mutableStateOf(nickname) }
|
||||
var editEmail by remember { mutableStateOf(email) }
|
||||
ProfileEditDialog(
|
||||
nickname = editNickname,
|
||||
email = editEmail,
|
||||
onNicknameChange = { editNickname = it },
|
||||
onEmailChange = { editEmail = it },
|
||||
onSave = {
|
||||
nickname = editNickname
|
||||
email = editEmail
|
||||
profilePrefs.edit()
|
||||
.putString(KEY_NICKNAME, editNickname)
|
||||
.putString(KEY_EMAIL, editEmail)
|
||||
.apply()
|
||||
showProfileEdit = false
|
||||
},
|
||||
onDismiss = { showProfileEdit = false },
|
||||
)
|
||||
}
|
||||
|
||||
// ── 头像操作对话框(更换/移除)──
|
||||
if (avatarMenuVisible) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { avatarMenuVisible = false },
|
||||
title = { Text(stringResource(R.string.heatmap_profile_avatar_change)) },
|
||||
text = {
|
||||
Column {
|
||||
TextButton(onClick = {
|
||||
avatarMenuVisible = false
|
||||
avatarPicker.launch("image/*")
|
||||
}) {
|
||||
Text(stringResource(R.string.heatmap_profile_avatar_pick))
|
||||
}
|
||||
if (avatarPath != null) {
|
||||
TextButton(onClick = {
|
||||
avatarMenuVisible = false
|
||||
avatarPath?.let { runCatching { File(it).delete() } }
|
||||
avatarPath = null
|
||||
profilePrefs.edit().remove(KEY_AVATAR_PATH).apply()
|
||||
}) {
|
||||
Text(stringResource(R.string.heatmap_profile_avatar_remove))
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { avatarMenuVisible = false }) {
|
||||
Text(stringResource(R.string.heatmap_profile_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// ── 浮层 Popup(锚定到被点击格子的中心位置)──
|
||||
val day = selectedDay
|
||||
val week = selectedWeek
|
||||
val anchor = popupAnchor
|
||||
if (day != null || week != null) {
|
||||
val useUtc8 = state.useUtc8
|
||||
val popupText = when {
|
||||
day != null -> stringResource(
|
||||
R.string.heatmap_day_used,
|
||||
formatDateChinese(day.dayTs, useUtc8, state.currentYear, activeLocale),
|
||||
formatTokenChinese(day.tokens, activeLocale),
|
||||
)
|
||||
week != null -> stringResource(
|
||||
R.string.heatmap_week_used,
|
||||
formatWeekRangeText(week, useUtc8, state.selectedYear, state.dailyData.lastOrNull()?.dayTs, activeLocale),
|
||||
formatTokenChinese(week.tokens, activeLocale),
|
||||
)
|
||||
else -> ""
|
||||
}
|
||||
// 浮层相对锚点的偏移:水平居中、优先显示在格子上方 8dp 处;
|
||||
// 上方空间不足时翻转到下方;x/y 均 clamp 在窗口内
|
||||
val gapPx = with(density) { 8.dp.toPx() }.roundToInt()
|
||||
val edgePaddingPx = with(density) { 8.dp.toPx() }.roundToInt()
|
||||
val configuration = LocalConfiguration.current
|
||||
val windowWidthPx = with(density) { configuration.screenWidthDp.dp.toPx() }.roundToInt()
|
||||
val windowHeightPx = with(density) { configuration.screenHeightDp.dp.toPx() }.roundToInt()
|
||||
val maxX = (windowWidthPx - popupSize.width - edgePaddingPx).coerceAtLeast(edgePaddingPx)
|
||||
val maxY = (windowHeightPx - popupSize.height - edgePaddingPx).coerceAtLeast(edgePaddingPx)
|
||||
val popupOffset = if (anchor != null) {
|
||||
val x = (anchor.x - popupSize.width / 2).coerceIn(edgePaddingPx, maxX)
|
||||
val y = if (anchor.y - popupSize.height - gapPx >= edgePaddingPx) {
|
||||
// 上方放得下:显示在格子上方
|
||||
(anchor.y - popupSize.height - gapPx).coerceIn(edgePaddingPx, maxY)
|
||||
} else {
|
||||
// 上方放不下:翻转到格子下方
|
||||
(anchor.y + gapPx).coerceIn(edgePaddingPx, maxY)
|
||||
}
|
||||
IntOffset(x, y)
|
||||
} else {
|
||||
IntOffset.Zero
|
||||
}
|
||||
Popup(
|
||||
alignment = Alignment.TopStart,
|
||||
offset = popupOffset,
|
||||
onDismissRequest = {
|
||||
selectedDay = null
|
||||
selectedWeek = null
|
||||
popupAnchor = null
|
||||
},
|
||||
// 不拦截图表交互:浮层不获取焦点,点击/滑动直接作用于图表,
|
||||
// 点击其他格子即切换浮层,再次点击同一格子关闭
|
||||
properties = PopupProperties(focusable = false)
|
||||
) {
|
||||
Surface(
|
||||
shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp),
|
||||
tonalElevation = 3.dp,
|
||||
shadowElevation = 6.dp,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp)
|
||||
.onGloballyPositioned { popupSize = it.size }
|
||||
) {
|
||||
Text(
|
||||
text = popupText,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.padding(12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 年度统计卡片(累计/峰值/当前连续/最长连续)──
|
||||
@Composable
|
||||
private fun StatItem(
|
||||
label: String,
|
||||
value: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = inkMuted(),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 个人资料卡片(页面最顶部:自定义头像 + 昵称/邮箱)──
|
||||
@Composable
|
||||
private fun ProfileCard(
|
||||
nickname: String,
|
||||
email: String,
|
||||
avatarPath: String?,
|
||||
avatarVersion: Int,
|
||||
onEdit: () -> Unit,
|
||||
onAvatarClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val emptyText = stringResource(R.string.heatmap_profile_empty)
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
) {
|
||||
Column(Modifier.padding(14.dp)) {
|
||||
Text(
|
||||
text = stringResource(R.string.heatmap_profile_title),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
ProfileAvatar(
|
||||
avatarPath = avatarPath,
|
||||
avatarVersion = avatarVersion,
|
||||
nickname = nickname,
|
||||
onClick = onAvatarClick,
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = nickname.ifBlank { emptyText },
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = email.ifBlank { emptyText },
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = inkMuted(),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onEdit) {
|
||||
Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.heatmap_profile_edit))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 自定义头像(圆形裁剪;未设置时显示昵称首字符占位)──
|
||||
@Composable
|
||||
private fun ProfileAvatar(
|
||||
avatarPath: String?,
|
||||
avatarVersion: Int,
|
||||
nickname: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
// 本地文件解码在 IO 线程;key=(路径,版本号):换头像后路径不变,靠版本号触发重载
|
||||
// 先读尺寸再降采样解码(头像仅 56dp 显示,全尺寸解码相册大图会 OOM)
|
||||
val avatar by produceState<ImageBitmap?>(initialValue = null, avatarPath, avatarVersion) {
|
||||
value = avatarPath?.let { path ->
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeFile(path, bounds)
|
||||
var sample = 1
|
||||
while (bounds.outWidth / sample > 512 || bounds.outHeight / sample > 512) sample *= 2
|
||||
val opts = BitmapFactory.Options().apply { inSampleSize = sample }
|
||||
BitmapFactory.decodeFile(path, opts)?.asImageBitmap()
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
val avatarDesc = stringResource(R.string.heatmap_profile_avatar_change)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(56.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.clickable(onClick = onClick)
|
||||
.semantics { contentDescription = avatarDesc },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val bmp = avatar
|
||||
if (bmp != null) {
|
||||
Image(
|
||||
bitmap = bmp,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
} else {
|
||||
// 按 code point 取首字符,避免 emoji(代理对)被截成半个字符显示乱码
|
||||
val trimmed = nickname.trim()
|
||||
val initial = if (trimmed.isEmpty()) null else String(Character.toChars(trimmed.codePointAt(0)))
|
||||
if (initial != null) {
|
||||
Text(
|
||||
text = initial,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = inkMuted(),
|
||||
)
|
||||
} else {
|
||||
Icon(Icons.Filled.Person, contentDescription = null, tint = inkMuted())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 个人资料编辑对话框(昵称/邮箱均可完全自定义)──
|
||||
@Composable
|
||||
private fun ProfileEditDialog(
|
||||
nickname: String,
|
||||
email: String,
|
||||
onNicknameChange: (String) -> Unit,
|
||||
onEmailChange: (String) -> Unit,
|
||||
onSave: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.heatmap_profile_dialog_title)) },
|
||||
text = {
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
value = nickname,
|
||||
onValueChange = onNicknameChange,
|
||||
label = { Text(stringResource(R.string.heatmap_profile_nickname)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = email,
|
||||
onValueChange = onEmailChange,
|
||||
label = { Text(stringResource(R.string.heatmap_profile_email)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onSave) { Text(stringResource(R.string.heatmap_profile_save)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.heatmap_profile_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// ── 活动洞察卡片(总请求次数 + 最多请求时段 Top3,全量历史口径)──
|
||||
@Composable
|
||||
private fun InsightsCard(
|
||||
totalRequests: Int,
|
||||
topHours: List<Int>,
|
||||
loading: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val dash = "–"
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
) {
|
||||
Column(Modifier.padding(14.dp)) {
|
||||
Text(
|
||||
text = stringResource(R.string.heatmap_insights_title),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = stringResource(R.string.heatmap_insights_requests),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = inkMuted(),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
text = if (loading) {
|
||||
dash
|
||||
} else {
|
||||
pluralStringResource(R.plurals.heatmap_insights_request_count, totalRequests, totalRequests)
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.heatmap_insights_peak_hours),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = inkMuted(),
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
// 固定 3 个时段槽位,不足的显示占位,避免布局跳动
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
repeat(3) { i ->
|
||||
val hour = topHours.getOrNull(i)
|
||||
Text(
|
||||
text = if (loading || hour == null) {
|
||||
dash
|
||||
} else {
|
||||
stringResource(R.string.heatmap_insight_hour_range, hour, hour + 1)
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 个人资料持久化 ──
|
||||
private const val PROFILE_PREFS = "heatmap_profile"
|
||||
private const val KEY_NICKNAME = "nickname"
|
||||
private const val KEY_EMAIL = "email"
|
||||
private const val KEY_AVATAR_PATH = "avatar_path"
|
||||
private const val AVATAR_FILE_NAME = "heatmap_avatar.jpg"
|
||||
593
app/src/main/java/com/rainy/token/ui/heatmap/HeatmapViewModel.kt
Normal file
593
app/src/main/java/com/rainy/token/ui/heatmap/HeatmapViewModel.kt
Normal file
@ -0,0 +1,593 @@
|
||||
package com.rainy.token.ui.heatmap
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.rainy.token.data.local.ChartSettingsStore
|
||||
import com.rainy.token.data.local.UsageCache
|
||||
import com.rainy.token.data.local.UsageRecord
|
||||
import com.rainy.token.data.repository.CredentialRepository
|
||||
import com.rainy.token.domain.model.Credential
|
||||
import com.rainy.token.domain.service.ServiceType
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
|
||||
// ── 公共数据类 ──────────────────────────────────────────────
|
||||
|
||||
/** 热力图视图模式 */
|
||||
enum class HeatmapViewMode { DAILY, WEEKLY, CUMULATIVE }
|
||||
|
||||
/** 年份选择器中的"最近"窗口(最近 365 天)sentinel:selectedYear == RECENT_YEAR 表示最近模式 */
|
||||
internal const val RECENT_YEAR = -1
|
||||
|
||||
/** 每日 / 累计视图的单格数据 */
|
||||
data class HeatmapDayData(
|
||||
val dayTs: Long, // UTC 日期时间戳(即天分桶的时间戳)
|
||||
val tokens: Long, // 当天 token 数(DAILY)或累计 token 数(CUMULATIVE)
|
||||
val level: Int, // 0-5 颜色等级
|
||||
)
|
||||
|
||||
/** 每周视图的单格数据 */
|
||||
data class HeatmapWeekData(
|
||||
val weekStartTs: Long, // 该周起始日时间戳
|
||||
val tokens: Long, // 该周 7 天累计 token 数
|
||||
val level: Int, // 0-5 颜色等级
|
||||
val barHeight: Int, // 柱状图高度(格数):0 用量周=1(Level 0 浅色格,可见可点),非零周=2~7(按 token 排名比例)
|
||||
)
|
||||
|
||||
/** 年度统计指标(按所选年份的每日数据计算,切换年份跟随变化) */
|
||||
data class HeatmapStats(
|
||||
val totalTokens: Long = 0L, // 累计 token 数(当年全部天之和)
|
||||
val peakTokens: Long = 0L, // 峰值 token 数(单日最大;当年无数据=0)
|
||||
val currentStreak: Int = 0, // 当前连续天数(从最后一天往前数连续 >0 的天数;末尾为 0 的天跳过,从最后一个非零天往前数)
|
||||
val maxStreak: Int = 0, // 最长连续天数(当年内连续 >0 的最大天数)
|
||||
)
|
||||
|
||||
/**
|
||||
* 活动洞察(基于全部历史记录,不随年份切换变化)。
|
||||
*
|
||||
* - totalRequests:总请求次数(= 记录条数,一条 UsageRecord 即一次请求)
|
||||
* - topHours:请求最多的时段(按小时 0-23),降序取前 3;
|
||||
* 并列时小时数小的优先;不足 3 个时列表较短
|
||||
*/
|
||||
data class HeatmapInsights(
|
||||
val totalRequests: Int = 0,
|
||||
val topHours: List<Int> = emptyList(),
|
||||
)
|
||||
|
||||
/** UI 状态 */
|
||||
data class HeatmapUiState(
|
||||
val loading: Boolean = true,
|
||||
val viewMode: HeatmapViewMode = HeatmapViewMode.DAILY,
|
||||
val selectedYear: Int = 0, // 当前选中范围:0=尚未加载,RECENT_YEAR(-1)=最近365天,>0=具体年份
|
||||
val currentYear: Int = 0, // 今年(浮层日期判断是否显示年份用)
|
||||
val availableYears: List<Int> = emptyList(), // 可选年份(最早数据年份..今年,降序展示)
|
||||
val dailyData: List<HeatmapDayData> = emptyList(),
|
||||
val weeklyData: List<HeatmapWeekData> = emptyList(),
|
||||
val cumulativeData: List<HeatmapDayData> = emptyList(),
|
||||
val colorLevels: IntArray = IntArray(6), // [0, p25, p50, p75, p95, max]
|
||||
val stats: HeatmapStats = HeatmapStats(), // 年度统计(累计/峰值/当前连续/最长连续)
|
||||
val insights: HeatmapInsights = HeatmapInsights(), // 活动洞察(全量记录:总请求次数 + 最多请求时段 Top3)
|
||||
val useUtc8: Boolean = false, // 与全局图表设置一致:true=UTC+8 分桶,false=UTC
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is HeatmapUiState) return false
|
||||
return loading == other.loading &&
|
||||
viewMode == other.viewMode &&
|
||||
selectedYear == other.selectedYear &&
|
||||
currentYear == other.currentYear &&
|
||||
availableYears == other.availableYears &&
|
||||
dailyData == other.dailyData &&
|
||||
weeklyData == other.weeklyData &&
|
||||
cumulativeData == other.cumulativeData &&
|
||||
colorLevels.contentEquals(other.colorLevels) &&
|
||||
stats == other.stats &&
|
||||
insights == other.insights &&
|
||||
useUtc8 == other.useUtc8
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = loading.hashCode()
|
||||
result = 31 * result + viewMode.hashCode()
|
||||
result = 31 * result + selectedYear
|
||||
result = 31 * result + currentYear
|
||||
result = 31 * result + availableYears.hashCode()
|
||||
result = 31 * result + dailyData.hashCode()
|
||||
result = 31 * result + weeklyData.hashCode()
|
||||
result = 31 * result + cumulativeData.hashCode()
|
||||
result = 31 * result + colorLevels.contentHashCode()
|
||||
result = 31 * result + stats.hashCode()
|
||||
result = 31 * result + insights.hashCode()
|
||||
result = 31 * result + useUtc8.hashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 分位阈值与颜色等级映射。
|
||||
*
|
||||
* thresholds[0..5] 存 Long 阈值:
|
||||
* - Level 0: value == 0
|
||||
* - Level 1: 0 < value ≤ P25
|
||||
* - Level 2: P25 < value ≤ P50
|
||||
* - Level 3: P50 < value ≤ P75
|
||||
* - Level 4: P75 < value ≤ P95
|
||||
* - Level 5: value > P95
|
||||
*
|
||||
* 边界情况(非零天数 < 2 或所有非零值相同)时,所有非零值设为 Level 3。
|
||||
*/
|
||||
internal data class QuantileLevels(val thresholds: LongArray) {
|
||||
fun getColorLevel(value: Long): Int {
|
||||
if (value <= 0L) return 0
|
||||
for (level in 1..5) {
|
||||
if (value <= thresholds[level]) return level
|
||||
}
|
||||
return 5
|
||||
}
|
||||
|
||||
fun toIntArray(): IntArray = IntArray(6) { idx ->
|
||||
val v = thresholds[idx]
|
||||
if (v > Int.MAX_VALUE) Int.MAX_VALUE else v.toInt()
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is QuantileLevels) return false
|
||||
return thresholds.contentEquals(other.thresholds)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int = thresholds.contentHashCode()
|
||||
}
|
||||
|
||||
// ── ViewModel ──────────────────────────────────────────────
|
||||
|
||||
@HiltViewModel
|
||||
class HeatmapViewModel @Inject constructor(
|
||||
private val cacheProvider: Provider<UsageCache>,
|
||||
private val credentialRepository: CredentialRepository,
|
||||
private val chartSettingsStore: ChartSettingsStore,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(HeatmapUiState())
|
||||
val uiState: StateFlow<HeatmapUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var loaded = false
|
||||
// 全量记录缓存(仅在 load 时拉取一次,切换年份/视图时复用)
|
||||
private var records: List<UsageRecord> = emptyList()
|
||||
private var useUtc8 = false
|
||||
// 按年缓存三视图计算结果,切换年份不必重复计算(跨线程读写,用并发容器)
|
||||
private val yearResults = ConcurrentHashMap<Int, AllViewsResult>()
|
||||
// 最近一次请求的年份(防止快速连续切换时旧协程后完成覆盖新选择)
|
||||
private var latestRequestedYear = 0
|
||||
|
||||
/**
|
||||
* Composable 层在 LaunchedEffect 中调用一次即可。
|
||||
* 首次加载拉取全量记录并计算默认年份(今年)的三视图数据。
|
||||
*/
|
||||
fun load() {
|
||||
if (loaded) return
|
||||
loaded = true
|
||||
viewModelScope.launch { loadInternal() }
|
||||
}
|
||||
|
||||
/** 切换视图模式(仅改 State,不重新加载数据) */
|
||||
fun setViewMode(mode: HeatmapViewMode) {
|
||||
val result = yearResults[_uiState.value.selectedYear]
|
||||
val levels = result?.let {
|
||||
when (mode) {
|
||||
HeatmapViewMode.DAILY -> it.dailyLevels
|
||||
HeatmapViewMode.WEEKLY -> it.weeklyLevels
|
||||
HeatmapViewMode.CUMULATIVE -> it.cumulativeLevels
|
||||
}
|
||||
}
|
||||
_uiState.update { it.copy(viewMode = mode, colorLevels = levels?.toIntArray() ?: it.colorLevels) }
|
||||
}
|
||||
|
||||
/** 切换窗口范围(年份或最近365天):用缓存的记录按所选范围重新聚合三视图 */
|
||||
fun setYear(year: Int) {
|
||||
// guard 用 latestRequestedYear(最后一次请求意图)而非已提交的 selectedYear:
|
||||
// 避免"在途切换未提交时重复点击导致最后一次意图被丢弃"的竞态
|
||||
if (year == latestRequestedYear || records.isEmpty()) return
|
||||
latestRequestedYear = year
|
||||
viewModelScope.launch {
|
||||
val cached = yearResults[year]
|
||||
// 最近 365 天窗口以计算时刻的 todayTs 为锚:页面跨天常驻时缓存已过期(窗口末日在昨天),需重算
|
||||
val stale = cached != null && year == RECENT_YEAR && cached.dailyData.lastOrNull()?.dayTs != todayTsOf()
|
||||
val result = if (cached == null || stale) {
|
||||
withContext(Dispatchers.Default) {
|
||||
computeAllViews(records, useUtc8, year).also { yearResults[year] = it }
|
||||
}
|
||||
} else {
|
||||
cached
|
||||
}
|
||||
// 期间用户又切了别的范围:丢弃过期结果,避免旧协程覆盖新选择
|
||||
if (year != latestRequestedYear) return@launch
|
||||
val mode = _uiState.value.viewMode
|
||||
val levels = when (mode) {
|
||||
HeatmapViewMode.DAILY -> result.dailyLevels
|
||||
HeatmapViewMode.WEEKLY -> result.weeklyLevels
|
||||
HeatmapViewMode.CUMULATIVE -> result.cumulativeLevels
|
||||
}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
selectedYear = year,
|
||||
dailyData = result.dailyData,
|
||||
weeklyData = result.weeklyData,
|
||||
cumulativeData = result.cumulativeData,
|
||||
colorLevels = levels.toIntArray(),
|
||||
stats = computeStats(result.dailyData),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 当前时区下的今天自然日零点(缓存新鲜度校验用) */
|
||||
private fun todayTsOf(): Long {
|
||||
val zone = if (useUtc8) ZoneOffset.ofHours(8) else ZoneOffset.UTC
|
||||
return startOfDayTs(System.currentTimeMillis(), zone)
|
||||
}
|
||||
|
||||
// ── 内部逻辑 ────────────────────────────────────────────
|
||||
|
||||
private suspend fun workspaceId(): String? {
|
||||
val c = credentialRepository.get(ServiceType.OPENCODE_GO)
|
||||
return (c as? Credential.SessionCredential)?.workspaceId?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
private suspend fun loadInternal() {
|
||||
try {
|
||||
val wid = workspaceId() ?: run {
|
||||
_uiState.update { it.copy(loading = false) }
|
||||
return
|
||||
}
|
||||
val cache = cacheProvider.get()
|
||||
// 与全局图表设置一致:读取 UTC+8 / UTC 分桶偏好
|
||||
useUtc8 = chartSettingsStore.useUtc8Flow.first()
|
||||
|
||||
// 拉取全量记录 + 计算可选年份(在 IO 线程,避免主线程遍历大列表)
|
||||
val zone = if (useUtc8) ZoneOffset.ofHours(8) else ZoneOffset.UTC
|
||||
val currentYear = Instant.now().atOffset(zone).year
|
||||
val (fetched, availableYears) = withContext(Dispatchers.Default) {
|
||||
val all = cache.getRecords(wid)
|
||||
all to computeAvailableYears(all, zone, currentYear)
|
||||
}
|
||||
records = fetched
|
||||
latestRequestedYear = RECENT_YEAR
|
||||
|
||||
// 活动洞察基于全量历史记录(不随年份变化),在后台线程计算(万级记录仅数 ms)
|
||||
val insights = withContext(Dispatchers.Default) { computeInsights(fetched, useUtc8) }
|
||||
|
||||
// 所有重操作放在 Dispatchers.Default 上;默认窗口 = 最近 365 天(RECENT_YEAR)
|
||||
val result = withContext(Dispatchers.Default) {
|
||||
computeAllViews(fetched, useUtc8, RECENT_YEAR).also { yearResults[RECENT_YEAR] = it }
|
||||
}
|
||||
|
||||
// 重操作完成后按当前视图模式写入对应的分位阈值(加载期间可能已切换视图)
|
||||
val mode = _uiState.value.viewMode
|
||||
val levels = when (mode) {
|
||||
HeatmapViewMode.DAILY -> result.dailyLevels
|
||||
HeatmapViewMode.WEEKLY -> result.weeklyLevels
|
||||
HeatmapViewMode.CUMULATIVE -> result.cumulativeLevels
|
||||
}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
loading = false,
|
||||
selectedYear = RECENT_YEAR,
|
||||
currentYear = currentYear,
|
||||
availableYears = availableYears,
|
||||
useUtc8 = useUtc8,
|
||||
dailyData = result.dailyData,
|
||||
weeklyData = result.weeklyData,
|
||||
cumulativeData = result.cumulativeData,
|
||||
colorLevels = levels.toIntArray(),
|
||||
stats = computeStats(result.dailyData),
|
||||
insights = insights,
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_uiState.update { it.copy(loading = false) }
|
||||
}
|
||||
}
|
||||
|
||||
/** 可选年份列表:最早有数据的年份 ~ 今年;无数据时只有今年 */
|
||||
private fun computeAvailableYears(records: List<UsageRecord>, zone: ZoneOffset, currentYear: Int): List<Int> {
|
||||
val minYear = records.minOfOrNull { Instant.ofEpochMilli(it.timeCreated).atOffset(zone).year }
|
||||
?: currentYear
|
||||
return (minYear..currentYear).toList()
|
||||
}
|
||||
|
||||
// ── 聚合计算 ────────────────────────────────────────────
|
||||
|
||||
companion object {
|
||||
private const val DAY_MS = 86_400_000L
|
||||
|
||||
/**
|
||||
* 按指定窗口从全部记录计算三种视图数据 + 颜色等级阈值。
|
||||
*
|
||||
* 窗口类型由 [year] 决定:
|
||||
* - [RECENT_YEAR](-1):最近 365 天(今天往前 364 天 ~ 今天,含今天共 365 天),默认窗口
|
||||
* - 今年(year == 当前自然年):1月1日 ~ 今天
|
||||
* - 往年:1月1日 ~ 12月31日(完整自然年)
|
||||
* - 周视图:周日对齐,第一列为窗口首日所在周(可能含窗口前空位),
|
||||
* 最后一列为结束日所在周(最近/今年=今天所在周;往年可能跨到次年 1 月初),
|
||||
* 列数按窗口浮动(约 52~54 列),不丢数据
|
||||
* - 三种视图的分位数均按所选窗口独立计算(颜色随窗口数据变化是预期行为)
|
||||
*
|
||||
* @param useUtc8 true=按 UTC+8 零点分桶(与全局图表设置一致),false=按 UTC 零点分桶
|
||||
* @param year 目标自然年,或 [RECENT_YEAR] 表示最近 365 天
|
||||
*/
|
||||
internal fun computeAllViews(records: List<UsageRecord>, useUtc8: Boolean = false, year: Int): AllViewsResult {
|
||||
val zone = if (useUtc8) ZoneOffset.ofHours(8) else ZoneOffset.UTC
|
||||
val now = System.currentTimeMillis()
|
||||
val todayTs = startOfDayTs(now, zone)
|
||||
val currentYear = Instant.ofEpochMilli(now).atOffset(zone).year
|
||||
val isCurrentYear = year == currentYear
|
||||
val isRecent = year == RECENT_YEAR
|
||||
|
||||
// 1. 窗口边界(该时区下的自然日零点)
|
||||
val windowStartTs = if (isRecent) {
|
||||
todayTs - 364 * DAY_MS
|
||||
} else {
|
||||
Instant.parse("${year}-01-01T00:00:00Z")
|
||||
.atOffset(zone).toLocalDate().atStartOfDay(zone).toInstant().toEpochMilli()
|
||||
}
|
||||
val windowEndTs = if (isRecent || isCurrentYear) {
|
||||
todayTs
|
||||
} else {
|
||||
Instant.parse("${year}-12-31T00:00:00Z")
|
||||
.atOffset(zone).toLocalDate().atStartOfDay(zone).toInstant().toEpochMilli()
|
||||
}
|
||||
val totalDays = ((windowEndTs - windowStartTs) / DAY_MS).toInt() + 1
|
||||
|
||||
// 2. 按天分桶聚合 token 数(按所选时区的自然日零点切日)
|
||||
val dayTokenMap = HashMap<Long, Long>()
|
||||
for (r in records) {
|
||||
val dayTs = startOfDayTs(r.timeCreated, zone)
|
||||
if (dayTs < windowStartTs || dayTs > windowEndTs) continue
|
||||
val tokens = r.inputTokens + r.cacheReadTokens + r.reasoningTokens + r.outputTokens
|
||||
dayTokenMap.merge(dayTs, tokens, Long::plus)
|
||||
}
|
||||
|
||||
// 3. 构建完整的每日数据列表(窗口首日 ~ 结束日)
|
||||
val dailyRaw = ArrayList<HeatmapDayData>(totalDays)
|
||||
for (i in 0 until totalDays) {
|
||||
val ts = windowStartTs + i * DAY_MS
|
||||
val tokens = dayTokenMap[ts] ?: 0L
|
||||
dailyRaw.add(HeatmapDayData(dayTs = ts, tokens = tokens, level = 0))
|
||||
}
|
||||
|
||||
// 4. 计算每日视图颜色等级
|
||||
val dailyLevels = computeQuantileLevels(dailyRaw.map { it.tokens })
|
||||
val dailyData = dailyRaw.map { it.copy(level = dailyLevels.getColorLevel(it.tokens)) }
|
||||
|
||||
// 5. 每周视图:周日对齐,第一列=窗口首日所在周,最后一列=结束日所在周
|
||||
val firstWeekStart = startOfWeekTs(windowStartTs, zone)
|
||||
val lastWeekStart = startOfWeekTs(windowEndTs, zone)
|
||||
val (weeklyData, weeklyLevels) = buildWeeklyData(dailyRaw, firstWeekStart, lastWeekStart)
|
||||
|
||||
// 6. 累计视图:从窗口首日到结束日的累计总和
|
||||
var cumulativeSum = 0L
|
||||
val cumulativeRaw = ArrayList<HeatmapDayData>(totalDays)
|
||||
for (d in dailyRaw) {
|
||||
cumulativeSum += d.tokens
|
||||
cumulativeRaw.add(HeatmapDayData(dayTs = d.dayTs, tokens = cumulativeSum, level = 0))
|
||||
}
|
||||
val cumulativeLevels = computeQuantileLevels(cumulativeRaw.map { it.tokens })
|
||||
val cumulativeData = cumulativeRaw.map { it.copy(level = cumulativeLevels.getColorLevel(it.tokens)) }
|
||||
|
||||
// 保存三种视图各自的分位阈值,供切换视图时更新图例
|
||||
return AllViewsResult(dailyData, weeklyData, cumulativeData, dailyLevels, weeklyLevels, cumulativeLevels)
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建每周视图数据:周日对齐,从 firstWeekStart 到 lastWeekStart(含)每周一列。
|
||||
*
|
||||
* 每列聚合该周落在 [yearStartTs, yearEndTs] 范围内的天数(dailyRaw 只含年内天数,
|
||||
* 首列跨前一年 12 月的部分、末列跨次年 1 月初的部分天然不计入),
|
||||
* 周总量决定该列统一的热度等级;渲染层(HeatmapCanvas)按 barHeight
|
||||
* 从底部向上绘制柱状图(0 用量周=1 格浅色,非零周=2~7 格)。
|
||||
*/
|
||||
private fun buildWeeklyData(
|
||||
dailyRaw: List<HeatmapDayData>,
|
||||
firstWeekStart: Long,
|
||||
lastWeekStart: Long,
|
||||
): Pair<List<HeatmapWeekData>, QuantileLevels> {
|
||||
val numWeeks = ((lastWeekStart - firstWeekStart) / (7L * DAY_MS)).toInt() + 1
|
||||
val weeks = ArrayList<HeatmapWeekData>(numWeeks)
|
||||
|
||||
for (w in 0 until numWeeks) {
|
||||
val ws = firstWeekStart + w * 7L * DAY_MS
|
||||
val we = ws + 7L * DAY_MS
|
||||
var weekTokens = 0L
|
||||
for (d in dailyRaw) {
|
||||
if (d.dayTs >= ws && d.dayTs < we) weekTokens += d.tokens
|
||||
}
|
||||
weeks.add(HeatmapWeekData(
|
||||
weekStartTs = ws,
|
||||
tokens = weekTokens,
|
||||
level = 0,
|
||||
barHeight = 1,
|
||||
))
|
||||
}
|
||||
|
||||
// 颜色等级:6 级分位数(供颜色与图例使用)
|
||||
val weekLevels = computeQuantileLevels(weeks.map { it.tokens })
|
||||
// 柱高:非零周按 token 去重排序的排名比例映射 2~7 格(排名不同高度必不同),
|
||||
// 0 用量周固定 1 格(Level 0 浅色,可见可点击)
|
||||
val barHeights = computeBarHeights(weeks.map { it.tokens })
|
||||
return weeks.mapIndexed { i, w ->
|
||||
w.copy(level = weekLevels.getColorLevel(w.tokens), barHeight = barHeights[i])
|
||||
} to weekLevels
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算每周视图的柱状图高度(格数)。
|
||||
*
|
||||
* - 0 用量周:1 格(Level 0 浅色格,作为「空白周」的可见标记,不隐藏)
|
||||
* - 非零周:按 token 值去重排序,排名比例映射到 2~7 格:
|
||||
* height = 2 + rankIndex * 5 / (uniqueCount - 1)(rankIndex 0-based)
|
||||
* 唯一非零值只有 1 个时该周为 7 格(满柱)
|
||||
*
|
||||
* 用排名比例而非分位档,保证不同量级的周(如 3.7亿 vs 10.7亿)高度不同。
|
||||
*/
|
||||
private fun computeBarHeights(tokens: List<Long>): IntArray {
|
||||
val uniqueNonZero = tokens.filter { it > 0L }.distinct().sorted()
|
||||
val n = uniqueNonZero.size
|
||||
return IntArray(tokens.size) { i ->
|
||||
val t = tokens[i]
|
||||
when {
|
||||
t <= 0L -> 1
|
||||
n == 1 -> 7
|
||||
else -> 2 + uniqueNonZero.indexOf(t) * 5 / (n - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算年度统计指标(基于所选年份的完整日序列,dailyRaw 含 0 token 的天)。
|
||||
*
|
||||
* - totalTokens:全部天之和
|
||||
* - peakTokens:单日最大 token(当年无数据=0)
|
||||
* - currentStreak:从最后一天往前数连续 >0 的天数;结尾为 0 的天(如当天尚未产生 token)跳过,
|
||||
* 从最后一个非零天往前数(全部为 0 则=0)
|
||||
* - maxStreak:当年内连续 >0 的最大天数
|
||||
*/
|
||||
internal fun computeStats(dailyData: List<HeatmapDayData>): HeatmapStats {
|
||||
var total = 0L
|
||||
var peak = 0L
|
||||
var maxStreak = 0
|
||||
var run = 0
|
||||
for (d in dailyData) {
|
||||
total += d.tokens
|
||||
if (d.tokens > peak) peak = d.tokens
|
||||
if (d.tokens > 0L) run++ else run = 0
|
||||
if (run > maxStreak) maxStreak = run
|
||||
}
|
||||
// 当前连续天数:最后一天即使为 0(当天尚未产生 token)也计入连续——跳过结尾的 0,
|
||||
// 从最后一个非零天往前数连续 >0 的天数;全部为 0 时=0
|
||||
var currentStreak = 0
|
||||
var i = dailyData.size - 1
|
||||
while (i >= 0 && dailyData[i].tokens == 0L) i--
|
||||
while (i >= 0 && dailyData[i].tokens > 0L) {
|
||||
currentStreak++
|
||||
i--
|
||||
}
|
||||
return HeatmapStats(total, peak, currentStreak, maxStreak)
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算活动洞察(基于全部历史记录,不随年份变化)。
|
||||
*
|
||||
* - totalRequests:总请求次数 = 记录条数(一条 UsageRecord 即一次请求)
|
||||
* - topHours:请求最多的时段(按小时 0-23),降序取前 3;
|
||||
* 并列时小时数小的优先;不足 3 个时列表较短;无数据时为空列表
|
||||
*
|
||||
* 小时按 useUtc8 对应的时区(UTC+8 / UTC)计算,与热力图分桶口径一致。
|
||||
*/
|
||||
internal fun computeInsights(records: List<UsageRecord>, useUtc8: Boolean = false): HeatmapInsights {
|
||||
if (records.isEmpty()) return HeatmapInsights()
|
||||
val offsetMs = if (useUtc8) 8 * 3_600_000L else 0L
|
||||
val hourCounts = IntArray(24)
|
||||
for (r in records) {
|
||||
// 纯算术取小时(避免逐条分配 Instant/OffsetDateTime):
|
||||
// (时间戳 + 时区偏移) 对一天取余 → 所在小时(ts 恒为正,无负数取模问题)
|
||||
val hour = (((r.timeCreated + offsetMs) % 86_400_000L) / 3_600_000L).toInt()
|
||||
hourCounts[hour]++
|
||||
}
|
||||
val topHours = hourCounts.indices
|
||||
.sortedWith(compareByDescending<Int> { hourCounts[it] }.thenBy { it })
|
||||
.filter { hourCounts[it] > 0 }
|
||||
.take(3)
|
||||
return HeatmapInsights(totalRequests = records.size, topHours = topHours)
|
||||
}
|
||||
|
||||
/** 返回时间戳在指定时区下的自然日零点(epoch 毫秒) */
|
||||
private fun startOfDayTs(ts: Long, zone: ZoneOffset): Long {
|
||||
return Instant.ofEpochMilli(ts).atOffset(zone).toLocalDate().atStartOfDay(zone).toInstant().toEpochMilli()
|
||||
}
|
||||
|
||||
/** 返回时间戳在指定时区下所在周的周日零点(0=Sunday) */
|
||||
private fun startOfWeekTs(ts: Long, zone: ZoneOffset): Long {
|
||||
val dow = dayOfWeekFromTs(ts, zone)
|
||||
return startOfDayTs(ts, zone) - dow * DAY_MS
|
||||
}
|
||||
|
||||
/** 时间戳在指定时区下的星期几 (0=Sunday, 6=Saturday) */
|
||||
private fun dayOfWeekFromTs(ts: Long, zone: ZoneOffset): Int {
|
||||
val dow = Instant.ofEpochMilli(ts).atOffset(zone).dayOfWeek.value
|
||||
return dow % 7 // Sunday=7 → 0
|
||||
}
|
||||
|
||||
// ── 分位数颜色等级计算 ──────────────────────────────
|
||||
|
||||
/**
|
||||
* 使用 nearest-rank 分位数法计算 P25/P50/P75/P95 阈值。
|
||||
*
|
||||
* 边界情况:
|
||||
* - 非零天数 < 2 → 全部设为 Level 3
|
||||
* - 所有非零值相同 → 全部设为 Level 3
|
||||
*
|
||||
* 返回 [QuantileLevels],内部 thresholds 为 LongArray(6):
|
||||
* index 0 = 0(Level 0),index 1-5 分别存 P25/P50/P75/P95/MaxValue。
|
||||
*/
|
||||
internal fun computeQuantileLevels(values: List<Long>): QuantileLevels {
|
||||
// 收集所有非零值
|
||||
val nonZero = values.filter { it > 0L }
|
||||
|
||||
// 边界:非零值 < 2 或全部相同 → 全部设为 Level 3
|
||||
if (nonZero.size < 2 || nonZero.toSet().size == 1) {
|
||||
// thresholds[3] = Long.MAX_VALUE 使得 0 < value ≤ MAX → Level 3
|
||||
val t = LongArray(6)
|
||||
t[3] = Long.MAX_VALUE
|
||||
return QuantileLevels(t)
|
||||
}
|
||||
|
||||
val sorted = nonZero.sorted()
|
||||
val n = sorted.size
|
||||
|
||||
val p25 = nearestRank(sorted, n, 0.25)
|
||||
val p50 = nearestRank(sorted, n, 0.50)
|
||||
val p75 = nearestRank(sorted, n, 0.75)
|
||||
val p95 = nearestRank(sorted, n, 0.95)
|
||||
|
||||
// LongArray(6): [0, p25, p50, p75, p95, MaxValue]
|
||||
val thresholds = LongArray(6)
|
||||
thresholds[0] = 0L
|
||||
thresholds[1] = p25
|
||||
thresholds[2] = p50
|
||||
thresholds[3] = p75
|
||||
thresholds[4] = p95
|
||||
thresholds[5] = Long.MAX_VALUE
|
||||
return QuantileLevels(thresholds)
|
||||
}
|
||||
|
||||
private fun nearestRank(sorted: List<Long>, n: Int, percentile: Double): Long {
|
||||
val rank = (Math.ceil(n * percentile).toInt() - 1).coerceIn(0, n - 1)
|
||||
return sorted[rank]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 辅助类型 ──────────────────────────────────────────────
|
||||
|
||||
/** computeAllViews 返回值 */
|
||||
internal data class AllViewsResult(
|
||||
val dailyData: List<HeatmapDayData>,
|
||||
val weeklyData: List<HeatmapWeekData>,
|
||||
val cumulativeData: List<HeatmapDayData>,
|
||||
val dailyLevels: QuantileLevels,
|
||||
val weeklyLevels: QuantileLevels,
|
||||
val cumulativeLevels: QuantileLevels,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,503 @@
|
||||
package com.rainy.token.ui.servicedetail
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.rainy.token.R
|
||||
import com.rainy.token.data.repository.Sub2ApiRepository
|
||||
import com.rainy.token.domain.model.ServiceBalance
|
||||
import com.rainy.token.ui.theme.inkMuted
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Sub2API 面板管理卡:Key 列表 / 新建 / 分组 / 兑换 / 低余额提醒。
|
||||
*
|
||||
* 首页 Sub2API 卡片与 Sub2API 详情页共用(有面板 Token 时才能加载;
|
||||
* API Key 模式只读余额,进入后提示先登录面板)。
|
||||
*
|
||||
* 交互细节:
|
||||
* - Key 列表只展示掩码(sk-…后缀),完整 Key 通过「复制」按钮复制并 Toast 反馈;
|
||||
* - 新建 Key 成功后弹窗展示完整 Key,避免把明文直接铺在长消息里;
|
||||
* - 兑换/切换分组等操作结果以 Toast + 行内文案双重反馈。
|
||||
*/
|
||||
@Composable
|
||||
internal fun Sub2ApiPanelManagement(
|
||||
balance: ServiceBalance?,
|
||||
keysState: PanelKeysState,
|
||||
groups: List<Sub2ApiRepository.PanelGroup>,
|
||||
actionMessage: String?,
|
||||
createdKey: String? = null,
|
||||
onLoadKeys: () -> Unit,
|
||||
onLoadGroups: () -> Unit,
|
||||
onCreateKey: (String, Long?) -> Unit,
|
||||
onSwitchGroup: (Long, Long) -> Unit,
|
||||
onDeleteKey: (Long) -> Unit,
|
||||
onRedeem: (String) -> Unit,
|
||||
onConsumeMessage: () -> Unit,
|
||||
onConsumeCreatedKey: () -> Unit = {},
|
||||
/** 弹窗场景由 AlertDialog 的 title 槽展示标题,可关闭内部标题避免重复 */
|
||||
showHeader: Boolean = true
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var showCreate by remember { mutableStateOf(false) }
|
||||
var showRedeem by remember { mutableStateOf(false) }
|
||||
var switchForKeyId by remember { mutableStateOf<Long?>(null) }
|
||||
// 待删除的 Key:先弹确认框(删除不可逆,避免列表里误触)
|
||||
var deleteForKeyId by remember { mutableStateOf<Long?>(null) }
|
||||
|
||||
val groupMap = remember(groups) { groups.associateBy { it.id } }
|
||||
val copiedToast = stringResource(R.string.sub2_panel_copied)
|
||||
|
||||
// 进入卡片即自动拉取一次 Key 列表与分组(Idle 态),省掉一次手动点击
|
||||
LaunchedEffect(Unit) {
|
||||
if (keysState is PanelKeysState.Idle) {
|
||||
onLoadKeys()
|
||||
onLoadGroups()
|
||||
}
|
||||
}
|
||||
|
||||
// 最近一次操作结果:用于行内保留展示(Toast 消费后消息会被置空,这里留一份快照)
|
||||
var lastResult by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// 操作结果(新建/兑换/切组)用 Toast 提醒:避免结果消息藏在长页面里看不见。
|
||||
// 消费后再置空,保证下一次「同样的结果」(如连续两次切换分组成功)仍能再次提示。
|
||||
LaunchedEffect(actionMessage) {
|
||||
val msg = actionMessage ?: return@LaunchedEffect
|
||||
lastResult = msg
|
||||
Toast.makeText(context, msg, Toast.LENGTH_LONG).show()
|
||||
onConsumeMessage()
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f))
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
|
||||
if (showHeader) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = stringResource(R.string.sub2_panel_manage_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
IconButton(onClick = { onLoadKeys(); onLoadGroups() }) {
|
||||
Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.action_refresh))
|
||||
}
|
||||
}
|
||||
}
|
||||
// showHeader=false 时不渲染任何标题/刷新行:
|
||||
// 弹窗场景由调用方把标题与刷新按钮放在同一行(见 DashboardScreen 的 title 槽),
|
||||
// 这里再放一行会让刷新按钮掉到标题下面,视觉上多出一条空行。
|
||||
|
||||
(actionMessage ?: lastResult)?.let { msg ->
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = msg.take(160) + if (msg.length > 160) "…" else "",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (msg.startsWith("已创建") || msg.startsWith("分组已") || msg.startsWith("兑换成功"))
|
||||
MaterialTheme.colorScheme.primary
|
||||
else
|
||||
MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
|
||||
val amount = balance?.amount ?: 0.0
|
||||
if (amount > 0.0 && amount < 5.0) {
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.sub2_panel_low_balance,
|
||||
String.format(Locale.getDefault(), "%.2f", amount)
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.35f))
|
||||
.padding(horizontal = 10.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// 主要操作(新建 Key / 兑换卡密)固定在列表上方:
|
||||
// Key 数量多时列表很长,放在底部会被埋在滚动区尽头,不易发现。
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = { onLoadGroups(); showCreate = true }, modifier = Modifier.weight(1f)) {
|
||||
Text(stringResource(R.string.sub2_panel_create))
|
||||
}
|
||||
OutlinedButton(onClick = { showRedeem = true }, modifier = Modifier.weight(1f)) {
|
||||
Text(stringResource(R.string.sub2_panel_redeem))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.sub2_panel_keys_header),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = inkMuted()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
when (keysState) {
|
||||
is PanelKeysState.Idle -> {
|
||||
OutlinedButton(onClick = onLoadKeys, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(stringResource(R.string.sub2_panel_load_keys))
|
||||
}
|
||||
}
|
||||
is PanelKeysState.Loading -> {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 8.dp)) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.common_loading), style = MaterialTheme.typography.bodySmall, color = inkMuted())
|
||||
}
|
||||
}
|
||||
is PanelKeysState.Error -> {
|
||||
Column {
|
||||
Text(keysState.message, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
|
||||
TextButton(onClick = onLoadKeys) { Text(stringResource(R.string.action_retry)) }
|
||||
}
|
||||
}
|
||||
is PanelKeysState.Success -> {
|
||||
if (keysState.keys.isEmpty()) {
|
||||
Text(stringResource(R.string.sub2_panel_no_keys), style = MaterialTheme.typography.bodySmall, color = inkMuted())
|
||||
} else {
|
||||
keysState.keys.forEach { key ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(R.string.sub2_panel_key_id, key.name, key.id),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
// API Key 只显示掩码,避免页面展示过长明文
|
||||
Text(
|
||||
text = buildString {
|
||||
append(maskKey(key.key))
|
||||
append(" · ")
|
||||
append(
|
||||
key.groupId?.let { groupMap[it]?.name }
|
||||
?: stringResource(R.string.sub2_panel_default_group)
|
||||
)
|
||||
append(" · ")
|
||||
append(key.status)
|
||||
append(" · ")
|
||||
append(
|
||||
key.lastUsedAt?.let { stringResource(R.string.sub2_panel_last_used, it.take(10)) }
|
||||
?: stringResource(R.string.sub2_panel_never_used)
|
||||
)
|
||||
},
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = inkMuted(),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
// 三个操作紧凑排布(用户反馈「间隔有点宽」)。
|
||||
// 注意:Material3 的 TextButton 会把 58dp 最小宽度与内边距加在
|
||||
// 其内层节点上,外层传 defaultMinSize(0)/contentPadding(0) 压不下去
|
||||
//(实测按钮可点区仍有 232px),所以这里直接用可点击 Text。
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.action_copy),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.clickable { copyToClipboard(context, key.key, copiedToast) }
|
||||
.padding(horizontal = 2.dp, vertical = 4.dp)
|
||||
)
|
||||
if (groups.isNotEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.sub2_panel_group_column),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.clickable { switchForKeyId = key.id }
|
||||
.padding(horizontal = 2.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
// 删除:用图标而非文字,避免一行挤下三个按钮在窄屏折行
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Delete,
|
||||
contentDescription = stringResource(R.string.action_delete),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.clickable { deleteForKeyId = key.id }
|
||||
.padding(2.dp)
|
||||
.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// 删除确认弹窗:删除不可逆,二次确认后才发请求
|
||||
deleteForKeyId?.let { keyId ->
|
||||
val targetName = (keysState as? PanelKeysState.Success)
|
||||
?.keys?.firstOrNull { it.id == keyId }?.name
|
||||
AlertDialog(
|
||||
onDismissRequest = { deleteForKeyId = null },
|
||||
title = { Text(stringResource(R.string.sub2_panel_delete_confirm_title), fontWeight = FontWeight.Bold) },
|
||||
text = {
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.sub2_panel_delete_confirm_body,
|
||||
targetName ?: ("#" + keyId)
|
||||
)
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
onDeleteKey(keyId)
|
||||
deleteForKeyId = null
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
contentColor = MaterialTheme.colorScheme.onError
|
||||
)
|
||||
) { Text(stringResource(R.string.action_delete)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { deleteForKeyId = null }) { Text(stringResource(R.string.action_cancel)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 新建 Key 弹窗
|
||||
if (showCreate) {
|
||||
var name by remember { mutableStateOf("") }
|
||||
var groupId by remember { mutableStateOf<Long?>(null) }
|
||||
AlertDialog(
|
||||
onDismissRequest = { showCreate = false },
|
||||
title = { Text(stringResource(R.string.sub2_panel_create_title), fontWeight = FontWeight.Bold) },
|
||||
text = {
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text(stringResource(R.string.sub2_panel_key_name)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.sub2_panel_group_optional),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = inkMuted()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
if (groups.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.sub2_panel_no_groups),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = inkMuted()
|
||||
)
|
||||
} else {
|
||||
groups.forEach { g ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = g.name + (if (g.rateMultiplier != 1.0) "(x" + String.format(Locale.getDefault(), "%.2f", g.rateMultiplier) + ")" else ""),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (groupId == g.id) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
TextButton(onClick = { groupId = if (groupId == g.id) null else g.id }) {
|
||||
Text(
|
||||
text = if (groupId == g.id) stringResource(R.string.action_cancel) else stringResource(R.string.sub2_panel_group_column),
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = { onCreateKey(name.trim(), groupId); showCreate = false },
|
||||
enabled = name.isNotBlank()
|
||||
) { Text(stringResource(R.string.sub2_panel_create)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showCreate = false }) { Text(stringResource(R.string.action_cancel)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 新建成功:弹窗展示完整 Key,方便一次性复制(避免明文铺在长消息里)
|
||||
createdKey?.let { key ->
|
||||
AlertDialog(
|
||||
onDismissRequest = onConsumeCreatedKey,
|
||||
title = { Text(stringResource(R.string.sub2_panel_create_success_title), fontWeight = FontWeight.Bold) },
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(R.string.sub2_panel_create_success_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = inkMuted()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = key,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
|
||||
.padding(10.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = { copyToClipboard(context, key, copiedToast); onConsumeCreatedKey() }) {
|
||||
Text(stringResource(R.string.sub2_panel_copy_and_close))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onConsumeCreatedKey) { Text(stringResource(R.string.action_close)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 兑换弹窗
|
||||
if (showRedeem) {
|
||||
var code by remember { mutableStateOf("") }
|
||||
AlertDialog(
|
||||
onDismissRequest = { showRedeem = false },
|
||||
title = { Text(stringResource(R.string.sub2_panel_redeem), fontWeight = FontWeight.Bold) },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = code,
|
||||
onValueChange = { code = it },
|
||||
label = { Text(stringResource(R.string.sub2_panel_redeem_code_label)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = { onRedeem(code.trim()); showRedeem = false },
|
||||
enabled = code.isNotBlank() && code.length >= 6
|
||||
) { Text(stringResource(R.string.sub2_panel_redeem)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showRedeem = false }) { Text(stringResource(R.string.action_cancel)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 切换分组弹窗
|
||||
switchForKeyId?.let { keyId ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { switchForKeyId = null },
|
||||
title = { Text(stringResource(R.string.sub2_panel_switch_group), fontWeight = FontWeight.Bold) },
|
||||
text = {
|
||||
Column {
|
||||
if (groups.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.sub2_panel_no_groups),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = inkMuted()
|
||||
)
|
||||
} else {
|
||||
groups.forEach { g ->
|
||||
TextButton(onClick = { onSwitchGroup(keyId, g.id); switchForKeyId = null }) {
|
||||
Text(
|
||||
text = g.name + (if (g.rateMultiplier != 1.0) "(x" + String.format(Locale.getDefault(), "%.2f", g.rateMultiplier) + ")" else ""),
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { switchForKeyId = null }) { Text(stringResource(R.string.action_cancel)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API Key 展示掩码:与面板网页保持一致的观感(sk-xxxx...yyyy)。
|
||||
* 列表接口虽然返回完整 key(实测 67 字符),但页面上不该铺满明文,
|
||||
* 复制按钮复制的仍是完整值(见 copyToClipboard(context, key.key, …))。
|
||||
*/
|
||||
private fun maskKey(key: String): String = when {
|
||||
key.length <= 12 -> key
|
||||
else -> key.take(7) + "..." + key.takeLast(4)
|
||||
}
|
||||
|
||||
/** 复制到剪贴板并 Toast 反馈(用户感知「复制按钮」有效)。 */
|
||||
private fun copyToClipboard(context: Context, text: String, toast: String) {
|
||||
val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
cm.setPrimaryClip(ClipData.newPlainText("API Key", text))
|
||||
Toast.makeText(context, toast, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
@ -0,0 +1,713 @@
|
||||
package com.rainy.token.ui.servicedetail
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.rainy.token.R
|
||||
import com.rainy.token.domain.model.Sub2DailyUsage
|
||||
import com.rainy.token.domain.model.Sub2ModelStat
|
||||
import com.rainy.token.domain.model.Sub2UsageDetail
|
||||
import com.rainy.token.ui.theme.inkMuted
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Sub2API 用量明细卡(替代原来的 GenericExtrasCard)。
|
||||
*
|
||||
* 数据来自 [Sub2UsageDetail](网关 /v1/usage 的 model_stats + daily_usage)。
|
||||
* 提供:
|
||||
* 1. 时间窗口筛选(今天 / 近7天 / 近30天 / 近90天 / 全部)——按日期对 daily_usage 切片;
|
||||
* 2. 模型筛选(全部 / 指定模型)——选中模型或切换时间窗口时,分模型统计按所选周期
|
||||
* 重新向网关拉取(model_stats 受 start_date/end_date 控制),不再只显示全时间累计;
|
||||
* 3. 窗口内汇总(请求数 / Tokens / 花费)+ 每日花费柱状图(高度封顶)+ 每日明细列表。
|
||||
*
|
||||
* @param windowStatsState 非「全部」窗口下的分模型统计异步状态(由 ViewModel 拉取)。
|
||||
* @param onLoadWindowModelStats 按窗口日期范围拉取分模型统计的回调;null 时跳过(旧用法)。
|
||||
*
|
||||
* 兼容:detail 为空(旧缓存/网关未返回明细)时回退到 [GenericExtrasCard]。
|
||||
*/
|
||||
@Composable
|
||||
internal fun Sub2ApiUsageCard(
|
||||
state: State,
|
||||
windowStatsState: PanelWindowStatsState = PanelWindowStatsState.Idle,
|
||||
onLoadWindowModelStats: ((from: LocalDate, to: LocalDate) -> Unit)? = null,
|
||||
/** 「今天」窗口的按小时趋势;非空时用小时柱替代按天柱(按天只有一根柱没有信息量)。 */
|
||||
hourlyTrend: List<Sub2DailyUsage> = emptyList(),
|
||||
onLoadHourlyTrend: ((LocalDate) -> Unit)? = null,
|
||||
/** 「按 Key」tab 的数据状态(按当前时间周期查询面板使用记录)。 */
|
||||
keyStatsState: PanelKeyStatsState = PanelKeyStatsState.Idle,
|
||||
onLoadKeyStats: ((from: LocalDate, to: LocalDate) -> Unit)? = null
|
||||
) {
|
||||
val balance = when (state) {
|
||||
is State.Fresh -> state.data
|
||||
is State.Stale -> state.data
|
||||
is State.Error -> state.cached
|
||||
else -> null
|
||||
} ?: return
|
||||
val detail = balance.sub2UsageDetail
|
||||
if (detail == null || detail.isEmpty) {
|
||||
// 无结构化明细:回退旧通用卡(旧缓存/网关未返回 model_stats/daily_usage)
|
||||
GenericExtrasCard(state)
|
||||
return
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(20.dp)) {
|
||||
Text(
|
||||
text = stringResource(R.string.sub2_usage_title),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.sub2_usage_subtitle),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = inkMuted()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// 默认选中「今天」(用户反馈:进入详情页应直接看当天用量)
|
||||
var window by remember { mutableStateOf(Sub2Window.TODAY) }
|
||||
// key=detail:刷新后模型列表变化时重置选中态,避免「每日区隐藏+无模型说明」的悬空状态
|
||||
var selectedModel by remember(detail) { mutableStateOf<String?>(null) }
|
||||
// 明细分区 tab:按模型 / 按 Key
|
||||
var detailTab by remember { mutableStateOf(DetailTab.MODEL) }
|
||||
|
||||
// ── 时间窗口选择 ──
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(Sub2Window.entries) { w ->
|
||||
FilterChip(
|
||||
selected = window == w,
|
||||
onClick = { window = w },
|
||||
label = { Text(stringResource(w.labelRes)) },
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = MaterialTheme.colorScheme.primary,
|
||||
selectedLabelColor = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val days = remember(detail, window) { window.slice(detail.dailyUsage) }
|
||||
val rangeLabel = remember(days) { formatRange(days) }
|
||||
|
||||
// 「今天」改为按小时展示:只有一根按天柱子看不出分布,故拉当天小时趋势
|
||||
LaunchedEffect(window) {
|
||||
if (window == Sub2Window.TODAY) {
|
||||
val (from, _) = window.range()
|
||||
onLoadHourlyTrend?.invoke(from)
|
||||
}
|
||||
}
|
||||
// 小时数据只在「今天」窗口且拿到 ≥2 个小时桶时才用。
|
||||
// 实测面板可能把整天聚合成单个 "00:00" 桶(时区/活动量原因),
|
||||
// 此时按小时展示两端刻度都是 00:00,反而不如按天清楚,故回退按天。
|
||||
val useHourly = window == Sub2Window.TODAY && hourlyTrend.size >= 2
|
||||
val chartSeries = if (useHourly) hourlyTrend else days
|
||||
|
||||
// 非「全部」窗口:按所选时间周期向网关拉取分模型统计(model_stats 受 start_date/end_date 控制)
|
||||
LaunchedEffect(window) {
|
||||
if (onLoadWindowModelStats != null && window != Sub2Window.ALL) {
|
||||
val (from, to) = window.range()
|
||||
onLoadWindowModelStats(from, to)
|
||||
}
|
||||
}
|
||||
|
||||
// 「按 Key」tab 打开或切换时间周期时,按当前周期拉取按 Key 用量
|
||||
LaunchedEffect(window, detailTab) {
|
||||
if (onLoadKeyStats != null && detailTab == DetailTab.KEY) {
|
||||
val (from, to) = window.range()
|
||||
onLoadKeyStats(from, to)
|
||||
}
|
||||
}
|
||||
if (rangeLabel != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = rangeLabel,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = inkMuted()
|
||||
)
|
||||
}
|
||||
|
||||
// 窗口分模型统计:「全部」用网关返回的全量 model_stats,其余按当前周期拉取结果
|
||||
// Key 模式下后端口径为「历史累计」时 allTime=true,下面的小标题会如实标注,避免被误读为周期数据
|
||||
// 「全部」窗口本身就用网关全量 model_stats,不需要再挂「累计」后缀;只在按周期拉取时才可能命中累计口径
|
||||
val windowStatsAllTime = window != Sub2Window.ALL &&
|
||||
(windowStatsState as? PanelWindowStatsState.Success)?.allTime == true
|
||||
val windowModelStats = if (window == Sub2Window.ALL) detail.modelStats
|
||||
else (windowStatsState as? PanelWindowStatsState.Success)?.stats.orEmpty()
|
||||
|
||||
// 切换窗口后若原选中模型在当前周期没有数据,自动取消选中,避免「无模型说明 + 日图隐藏」的悬空态
|
||||
LaunchedEffect(window, windowModelStats) {
|
||||
val model = selectedModel
|
||||
if (model != null && windowModelStats.isNotEmpty() &&
|
||||
windowModelStats.none { it.model == model }
|
||||
) {
|
||||
selectedModel = null
|
||||
}
|
||||
}
|
||||
|
||||
// 选中模型 → 汇总/列表均切换为该模型在当前周期内的累计;每日块隐藏(无模型维度,避免误导)
|
||||
val selectedStat = remember(detail, selectedModel, windowModelStats) {
|
||||
windowModelStats.firstOrNull { it.model == selectedModel }
|
||||
}
|
||||
|
||||
if (selectedStat != null) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.sub2_model_sel_summary, selectedStat.model),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = inkMuted()
|
||||
)
|
||||
}
|
||||
|
||||
// ── 汇总三格 ──
|
||||
if (days.isNotEmpty() || selectedStat != null) {
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
val sumRequests = selectedStat?.requests ?: days.sumOf { it.requests }
|
||||
val sumTokens = selectedStat?.totalTokens ?: days.sumOf { it.totalTokens }
|
||||
val sumCost = selectedStat?.cost ?: days.sumOf { it.cost }
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
StatCell(
|
||||
label = stringResource(R.string.sub2_stat_requests),
|
||||
value = compactNum(sumRequests),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
StatCell(
|
||||
label = stringResource(R.string.sub2_stat_tokens),
|
||||
value = compactNum(sumTokens),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
StatCell(
|
||||
label = stringResource(R.string.sub2_stat_cost),
|
||||
value = formatCost(sumCost),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 趋势区域:按天(或「今天」按小时)展示 ──
|
||||
// 说明:daily_usage 是账户整体、无模型维度,因此选中单个模型时这张图仍然是
|
||||
// 「账户整体趋势」——加一行标注说明口径,而不是像以前那样整块隐藏
|
||||
//(隐藏会让用户以为柱状图坏了)。
|
||||
run {
|
||||
val isHourly = useHourly
|
||||
if (chartSeries.isEmpty()) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.sub2_no_daily),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = inkMuted()
|
||||
)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
if (selectedModel != null) {
|
||||
// 选中模型时明确标注:趋势图仍是账户整体口径,避免误读为该模型趋势
|
||||
Text(
|
||||
text = stringResource(R.string.sub2_trend_account_scope),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = inkMuted()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
}
|
||||
DailyCostBars(chartSeries)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = shortDate(chartSeries.first().date),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = inkMuted()
|
||||
)
|
||||
Text(
|
||||
text = shortDate(chartSeries.last().date),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = inkMuted()
|
||||
)
|
||||
}
|
||||
|
||||
// 每日/每小时明细列表
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.sub2_daily_header),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = inkMuted()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
if (isHourly) {
|
||||
// 「今天」按小时:倒序展示每小时明细
|
||||
chartSeries.reversed().forEachIndexed { index, hour ->
|
||||
DailyRow(hour)
|
||||
if (index != chartSeries.lastIndex) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
days.reversed().forEachIndexed { index, day ->
|
||||
DailyRow(day)
|
||||
if (index != days.lastIndex) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 明细分区:「按模型 / 按 Key」两个 tab(数据都随所选时间周期变化)──
|
||||
val statsLoading = window != Sub2Window.ALL &&
|
||||
(windowStatsState is PanelWindowStatsState.Idle || windowStatsState is PanelWindowStatsState.Loading)
|
||||
val statsError = (windowStatsState as? PanelWindowStatsState.Error)?.message
|
||||
|
||||
if (detail.modelStats.isNotEmpty() || window != Sub2Window.ALL) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
// tab 切换:按模型 / 按 Key(纯文字 tab,非按钮)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(20.dp)) {
|
||||
Text(
|
||||
text = stringResource(R.string.sub2_tab_by_model),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = if (detailTab == DetailTab.MODEL) FontWeight.SemiBold else FontWeight.Normal,
|
||||
color = if (detailTab == DetailTab.MODEL) MaterialTheme.colorScheme.primary else inkMuted(),
|
||||
modifier = Modifier.clickable { detailTab = DetailTab.MODEL }
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.sub2_tab_by_key),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = if (detailTab == DetailTab.KEY) FontWeight.SemiBold else FontWeight.Normal,
|
||||
color = if (detailTab == DetailTab.KEY) MaterialTheme.colorScheme.primary else inkMuted(),
|
||||
modifier = Modifier.clickable { detailTab = DetailTab.KEY }
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
if (detailTab == DetailTab.KEY) {
|
||||
KeyUsageSection(keyStatsState)
|
||||
} else {
|
||||
// 模型筛选 chips(按当前周期内有数据的模型;「全部」时用网关全量)
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
item {
|
||||
FilterChip(
|
||||
selected = selectedModel == null,
|
||||
onClick = { selectedModel = null },
|
||||
label = { Text(stringResource(R.string.sub2_model_all)) },
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = MaterialTheme.colorScheme.primary,
|
||||
selectedLabelColor = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
)
|
||||
}
|
||||
items(windowModelStats) { stat ->
|
||||
FilterChip(
|
||||
selected = selectedModel == stat.model,
|
||||
onClick = { selectedModel = stat.model },
|
||||
label = { Text(stat.model) }
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
when {
|
||||
statsLoading -> Text(
|
||||
text = stringResource(R.string.common_loading),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = inkMuted()
|
||||
)
|
||||
statsError != null -> Text(
|
||||
text = "✗ " + statsError,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
windowModelStats.isEmpty() -> Text(
|
||||
text = stringResource(R.string.sub2_no_model_data),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = inkMuted()
|
||||
)
|
||||
else -> {
|
||||
val shown = windowModelStats.filter { selectedModel == null || it.model == selectedModel }
|
||||
shown.forEachIndexed { index, stat ->
|
||||
ModelRow(stat, highlighted = selectedModel == stat.model)
|
||||
if (index != shown.lastIndex) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 明细分区 tab:按模型 / 按 Key。 */
|
||||
private enum class DetailTab { MODEL, KEY }
|
||||
|
||||
/** 按 Key 用量区域:加载中/错误/空态/列表。 */
|
||||
@Composable
|
||||
private fun KeyUsageSection(state: PanelKeyStatsState) {
|
||||
when (state) {
|
||||
is PanelKeyStatsState.Idle,
|
||||
is PanelKeyStatsState.Loading -> Text(
|
||||
text = stringResource(R.string.common_loading),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = inkMuted()
|
||||
)
|
||||
is PanelKeyStatsState.Error -> Text(
|
||||
text = "✗ " + state.message,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
is PanelKeyStatsState.Success -> {
|
||||
if (state.stats.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.sub2_no_key_data),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = inkMuted()
|
||||
)
|
||||
} else {
|
||||
state.stats.forEachIndexed { index, stat ->
|
||||
KeyUsageRow(stat)
|
||||
if (index != state.stats.lastIndex) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Key 一行:名称(+分组) + 请求/tokens + cost;未使用的 Key 灰显。 */
|
||||
@Composable
|
||||
private fun KeyUsageRow(stat: PanelKeyStatsState.KeyStat) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stat.name.ifBlank { "#" + stat.keyId },
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = if (stat.unused) inkMuted() else MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = buildString {
|
||||
stat.groupName?.takeIf { it.isNotBlank() }?.let { append(it).append(" · ") }
|
||||
if (stat.unused) append(stringResource(R.string.sub2_key_unused))
|
||||
else append(
|
||||
stringResource(
|
||||
R.string.sub2_model_meta,
|
||||
compactNum(stat.requests),
|
||||
compactNum(stat.totalTokens)
|
||||
)
|
||||
)
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = inkMuted(),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = if (stat.unused) "-" else formatCost(stat.cost),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = if (stat.unused) inkMuted() else MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 时间窗口:按日期切片(近 N 天 = 今天及之前 N-1 天),不再按行数。 */
|
||||
private enum class Sub2Window(val labelRes: Int) {
|
||||
TODAY(R.string.sub2_window_today),
|
||||
DAYS_7(R.string.sub2_window_7d),
|
||||
DAYS_30(R.string.sub2_window_30d),
|
||||
DAYS_90(R.string.sub2_window_90d),
|
||||
ALL(R.string.sub2_window_all);
|
||||
|
||||
fun slice(all: List<Sub2DailyUsage>): List<Sub2DailyUsage> {
|
||||
val today = LocalDate.now()
|
||||
val startDay = when (this) {
|
||||
TODAY -> today
|
||||
DAYS_7 -> today.minusDays(6)
|
||||
DAYS_30 -> today.minusDays(29)
|
||||
DAYS_90 -> today.minusDays(89)
|
||||
ALL -> all.firstOrNull()?.let { runCatching { LocalDate.parse(it.date) }.getOrNull() } ?: today
|
||||
}
|
||||
// 按日期补零:网关只返回有用量的天(实测确认),filter 会日期跳空,
|
||||
// 逐日补齐 cost=0 空行保证柱状图/列表连续。ALL 从最早数据日补到今天。
|
||||
val byDate = all.associateBy { it.date }
|
||||
val out = mutableListOf<Sub2DailyUsage>()
|
||||
var d = startDay
|
||||
while (!d.isAfter(today)) {
|
||||
val key = d.toString()
|
||||
out.add(byDate[key] ?: Sub2DailyUsage(date = key, requests = 0, totalTokens = 0, cost = 0.0))
|
||||
d = d.plusDays(1)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 窗口对应日期范围 [start, end](end 恒为今天;「全部」与主页一致取近 90 天)。 */
|
||||
fun range(): Pair<LocalDate, LocalDate> {
|
||||
val today = LocalDate.now()
|
||||
return when (this) {
|
||||
TODAY -> today to today
|
||||
DAYS_7 -> today.minusDays(6) to today
|
||||
DAYS_30 -> today.minusDays(29) to today
|
||||
DAYS_90 -> today.minusDays(89) to today
|
||||
ALL -> today.minusDays(89) to today
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatCell(label: String, value: String, modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
|
||||
.padding(horizontal = 10.dp, vertical = 10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = inkMuted()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 每日花费迷你柱状图:按窗口内每天的 cost 等比画柱(全零/无数据时显示空态文案)。 */
|
||||
@Composable
|
||||
private fun DailyCostBars(days: List<Sub2DailyUsage>) {
|
||||
val maxCost = days.maxOfOrNull { it.cost }?.takeIf { it > 0 }
|
||||
if (maxCost == null) {
|
||||
// 全零或全空:空态提示(避免空白灰框)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(88.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.sub2_no_cost),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = inkMuted()
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
// 长周期(如近90天)柱数多:收窄柱间距避免 3dp×N 间距溢出屏幕导致柱子消失/错乱
|
||||
val barSpacing = when (days.size) {
|
||||
in 0..15 -> 3.dp
|
||||
in 16..44 -> 2.dp
|
||||
else -> 1.dp
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(88.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f))
|
||||
.padding(horizontal = 6.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
horizontalArrangement = Arrangement.spacedBy(barSpacing)
|
||||
) {
|
||||
days.forEach { day ->
|
||||
// 每日用量栏高度限制(0.95 最高),避免个别峰值日把柱子顶满、图形失真;
|
||||
// 零消耗日给 0.02f 最小占位,保证日期刻度对齐
|
||||
val fraction = ((day.cost / maxCost).toFloat()).coerceIn(0.02f, 0.95f)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Bottom
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.6f)
|
||||
.fillMaxHeight(fraction)
|
||||
.clip(RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp))
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
listOf(
|
||||
MaterialTheme.colorScheme.primary,
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 每日一行:日期(MM-dd)+ 请求数 + tokens + cost。 */
|
||||
@Composable
|
||||
private fun DailyRow(day: Sub2DailyUsage) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = shortDate(day.date),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier.weight(1.2f)
|
||||
)
|
||||
Text(
|
||||
text = compactNum(day.requests),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = inkMuted(),
|
||||
modifier = Modifier.weight(1f),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Text(
|
||||
text = compactNum(day.totalTokens),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = inkMuted(),
|
||||
modifier = Modifier.weight(1f),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Text(
|
||||
text = formatCost(day.cost),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.weight(0.8f),
|
||||
textAlign = TextAlign.End
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 模型一行:名称 + 请求/tokens + cost;选中模型时加粗高亮。 */
|
||||
@Composable
|
||||
private fun ModelRow(stat: Sub2ModelStat, highlighted: Boolean = false) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stat.model,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = if (highlighted) FontWeight.Bold else FontWeight.Medium,
|
||||
color = if (highlighted) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.sub2_model_meta,
|
||||
compactNum(stat.requests),
|
||||
compactNum(stat.totalTokens)
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = inkMuted()
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = formatCost(stat.cost),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = if (highlighted) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** "MM-dd" 短日期;"HH:00" 小时刻度原样返回;解析失败原样返回。 */
|
||||
private fun shortDate(iso: String): String {
|
||||
// 小时桶("14:00")不是日期,直接展示,别走 LocalDate.parse
|
||||
if (iso.length == 5 && iso[2] == ':') return iso
|
||||
return runCatching {
|
||||
LocalDate.parse(iso).format(DateTimeFormatter.ofPattern("MM-dd", Locale.US))
|
||||
}.getOrDefault(iso)
|
||||
}
|
||||
|
||||
/** 窗口起止标签:近 N 天 → "09-01 ~ 09-30",今天 → 只显示一天,全部 → null。 */
|
||||
private fun formatRange(days: List<Sub2DailyUsage>): String? {
|
||||
if (days.isEmpty()) return null
|
||||
val first = days.first().date
|
||||
val last = days.last().date
|
||||
return if (first == last) shortDate(first) else shortDate(first) + " ~ " + shortDate(last)
|
||||
}
|
||||
|
||||
/** 花费金额统一 "$x.xx"(0 显示 $0.00,钱不显示占位符)。 */
|
||||
private fun formatCost(v: Double): String = "$" + String.format(Locale.US, "%.2f", v)
|
||||
|
||||
/** 1000 → "1.0K",1000000 → "1.0M",0 → "-"。 */
|
||||
private fun compactNum(v: Long): String = when {
|
||||
v <= 0 -> "-"
|
||||
v >= 1_000_000 -> String.format(Locale.US, "%.1fM", v / 1_000_000.0)
|
||||
v >= 1_000 -> String.format(Locale.US, "%.1fK", v / 1_000.0)
|
||||
else -> v.toString()
|
||||
}
|
||||
@ -0,0 +1,342 @@
|
||||
package com.rainy.token.ui.settings
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
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.material.icons.filled.MoreVert
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Snackbar
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
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.LaunchedEffect
|
||||
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
|
||||
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.ServiceAccountInfo
|
||||
import com.rainy.token.domain.service.ServiceType
|
||||
import com.rainy.token.ui.components.ServiceIcon
|
||||
import com.rainy.token.ui.components.resolve
|
||||
import com.rainy.token.ui.theme.InkMuted
|
||||
import com.rainy.token.ui.theme.StrawberryPink
|
||||
|
||||
/**
|
||||
* 账号管理页(Trae / WorkBuddy 多账号)。
|
||||
*
|
||||
* - 列出该服务全部账号(默认账号带角标);
|
||||
* - 行内菜单:设为默认 / 重命名 / 删除;
|
||||
* - 「添加账号」导航到凭据编辑器(新建模式)。
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AccountManagementScreen(
|
||||
service: ServiceType,
|
||||
onBack: () -> Unit,
|
||||
onEditAccount: (ServiceType, String?) -> Unit,
|
||||
viewModel: AccountManagementViewModel = hiltViewModel()
|
||||
) {
|
||||
// 重新进入页面(从凭据编辑页保存/删除后返回)时刷新账号列表
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
|
||||
viewModel.refresh()
|
||||
}
|
||||
LaunchedEffect(service) { viewModel.bind(service) }
|
||||
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
// 行菜单:记录哪个账号的菜单展开
|
||||
var menuAccountId by remember { mutableStateOf<String?>(null) }
|
||||
// 重命名对话框
|
||||
var renamingAccount by remember { mutableStateOf<ServiceAccountInfo?>(null) }
|
||||
var renameInput by remember { mutableStateOf("") }
|
||||
// 删除确认对话框
|
||||
var deletingAccount by remember { mutableStateOf<ServiceAccountInfo?>(null) }
|
||||
|
||||
LaunchedEffect(uiState.message) {
|
||||
uiState.message?.let { msg ->
|
||||
snackbarHostState.showSnackbar(msg.resolve(context))
|
||||
viewModel.consumeMessage()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
containerColor = Color.Transparent,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.account_manager_title, service.displayName)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent)
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) { data -> Snackbar(snackbarData = data) } }
|
||||
) { innerPadding ->
|
||||
if (uiState.loading && uiState.accounts.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(color = StrawberryPink)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
if (uiState.accounts.isEmpty()) {
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.account_empty_hint),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = InkMuted,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(20.dp),
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
items(uiState.accounts, key = { it.id }) { account ->
|
||||
val ordinal = accountOrdinal(uiState.accounts, account)
|
||||
AccountRow(
|
||||
account = account,
|
||||
displayName = account.label?.takeIf { it.isNotBlank() }
|
||||
?: stringResource(R.string.account_fallback_name, ordinal),
|
||||
menuExpanded = menuAccountId == account.id,
|
||||
onMenuToggle = {
|
||||
menuAccountId = if (menuAccountId == account.id) null else account.id
|
||||
},
|
||||
onMenuDismiss = { menuAccountId = null },
|
||||
onEdit = { onEditAccount(service, account.id) },
|
||||
onSetDefault = {
|
||||
menuAccountId = null
|
||||
viewModel.setDefault(account.id)
|
||||
},
|
||||
onRename = {
|
||||
menuAccountId = null
|
||||
renamingAccount = account
|
||||
renameInput = account.label.orEmpty()
|
||||
},
|
||||
onDelete = {
|
||||
menuAccountId = null
|
||||
deletingAccount = account
|
||||
}
|
||||
)
|
||||
}
|
||||
item {
|
||||
Button(
|
||||
onClick = { onEditAccount(service, null) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(46.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = androidx.compose.material3.ButtonDefaults.buttonColors(
|
||||
containerColor = StrawberryPink,
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text(stringResource(R.string.account_add))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 重命名对话框
|
||||
renamingAccount?.let { account ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { renamingAccount = null },
|
||||
title = { Text(stringResource(R.string.account_rename_dialog_title)) },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = renameInput,
|
||||
onValueChange = { renameInput = it },
|
||||
label = { Text(stringResource(R.string.account_name_label)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
viewModel.rename(account.id, renameInput)
|
||||
renamingAccount = null
|
||||
}
|
||||
) {
|
||||
Text(stringResource(R.string.action_confirm), color = StrawberryPink)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { renamingAccount = null }) {
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 删除确认对话框
|
||||
deletingAccount?.let { account ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { deletingAccount = null },
|
||||
title = { Text(stringResource(R.string.account_delete_title)) },
|
||||
text = { Text(stringResource(R.string.account_delete_confirm)) },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
viewModel.delete(account.id)
|
||||
deletingAccount = null
|
||||
}
|
||||
) {
|
||||
Text(stringResource(R.string.action_delete_confirmed), color = StrawberryPink)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { deletingAccount = null }) {
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AccountRow(
|
||||
account: ServiceAccountInfo,
|
||||
displayName: String,
|
||||
menuExpanded: Boolean,
|
||||
onMenuToggle: () -> Unit,
|
||||
onMenuDismiss: () -> Unit,
|
||||
onEdit: () -> Unit,
|
||||
onSetDefault: () -> Unit,
|
||||
onRename: () -> Unit,
|
||||
onDelete: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onEdit() },
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 12.dp, end = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = displayName,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
if (account.isDefault) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.account_default_badge),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = StrawberryPink,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
.background(
|
||||
color = StrawberryPink.copy(alpha = 0.12f),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Box {
|
||||
IconButton(onClick = onMenuToggle) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.MoreVert,
|
||||
contentDescription = stringResource(R.string.account_actions),
|
||||
tint = InkMuted
|
||||
)
|
||||
}
|
||||
DropdownMenu(expanded = menuExpanded, onDismissRequest = onMenuDismiss) {
|
||||
if (!account.isDefault) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.account_set_default)) },
|
||||
onClick = onSetDefault
|
||||
)
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.account_rename)) },
|
||||
onClick = onRename
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.account_delete_title)) },
|
||||
onClick = onDelete
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 同服务内账号序号(1 起,用于“账号 N”兜底显示)。 */
|
||||
private fun accountOrdinal(accounts: List<ServiceAccountInfo>, account: ServiceAccountInfo): Int =
|
||||
accounts.indexOf(account) + 1
|
||||
@ -0,0 +1,97 @@
|
||||
package com.rainy.token.ui.settings
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.rainy.token.R
|
||||
import com.rainy.token.data.repository.CredentialRepository
|
||||
import com.rainy.token.domain.model.ServiceAccountInfo
|
||||
import com.rainy.token.domain.service.ServiceType
|
||||
import com.rainy.token.ui.components.UiText
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* 账号管理页 ViewModel(Trae / WorkBuddy 多账号)。
|
||||
*
|
||||
* 「添加账号」由页面回调导航到凭据编辑器(新建模式),首个账号保存时自动设为默认;
|
||||
* 本页负责列出现有账号、设默认、重命名、删除。
|
||||
*/
|
||||
@HiltViewModel
|
||||
class AccountManagementViewModel @Inject constructor(
|
||||
private val credentialRepository: CredentialRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _service = MutableStateFlow<ServiceType?>(null)
|
||||
val service: StateFlow<ServiceType?> = _service.asStateFlow()
|
||||
|
||||
private val _uiState = MutableStateFlow(AccountManagementUiState())
|
||||
val uiState: StateFlow<AccountManagementUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var loadGeneration = 0
|
||||
|
||||
fun bind(service: ServiceType) {
|
||||
if (_service.value == service) return
|
||||
_service.value = service
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
val type = _service.value ?: return
|
||||
val generation = ++loadGeneration
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(loading = true) }
|
||||
val accounts = credentialRepository.accountsFor(type)
|
||||
if (generation != loadGeneration) return@launch
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
loading = false,
|
||||
accounts = accounts,
|
||||
// 账号列表可能为空(用户刚删光),保持消息残留会误导,这里清掉
|
||||
message = if (accounts.isEmpty()) null else it.message
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setDefault(accountId: String) {
|
||||
val type = _service.value ?: return
|
||||
viewModelScope.launch {
|
||||
credentialRepository.setDefaultAccount(type, accountId)
|
||||
_uiState.update { it.copy(message = UiText.Resource(R.string.msg_account_default_set)) }
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
fun rename(accountId: String, label: String) {
|
||||
val type = _service.value ?: return
|
||||
viewModelScope.launch {
|
||||
credentialRepository.renameAccount(type, accountId, label.trim())
|
||||
_uiState.update { it.copy(message = UiText.Resource(R.string.msg_account_renamed)) }
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
fun delete(accountId: String) {
|
||||
val type = _service.value ?: return
|
||||
viewModelScope.launch {
|
||||
credentialRepository.removeAccount(type, accountId)
|
||||
_uiState.update { it.copy(message = UiText.Resource(R.string.msg_account_deleted)) }
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
fun consumeMessage() {
|
||||
_uiState.update { it.copy(message = null) }
|
||||
}
|
||||
}
|
||||
|
||||
data class AccountManagementUiState(
|
||||
val loading: Boolean = false,
|
||||
val accounts: List<ServiceAccountInfo> = emptyList(),
|
||||
val message: UiText? = null
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
@ -9,6 +11,7 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
@ -16,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
|
||||
@ -25,16 +29,24 @@ 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
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.rainy.token.domain.model.CredentialStatus
|
||||
import com.rainy.token.domain.service.ServiceType
|
||||
@ -42,8 +54,16 @@ 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 android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
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
|
||||
|
||||
/**
|
||||
* 设置页(雨晴风格重做版)。
|
||||
@ -58,9 +78,18 @@ import com.rainy.token.ui.theme.StrawberryPink
|
||||
fun SettingsScreen(
|
||||
onBack: () -> Unit,
|
||||
onEditCredential: (ServiceType) -> Unit,
|
||||
onManageAccounts: (ServiceType) -> Unit = {},
|
||||
onOpenTips: () -> Unit = {},
|
||||
onOpenDebugLog: () -> Unit = {},
|
||||
viewModel: SettingsViewModel = hiltViewModel()
|
||||
) {
|
||||
// 重新进入设置页时刷新凭据状态(从凭据编辑页保存/删除后返回)
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
|
||||
viewModel.refresh()
|
||||
}
|
||||
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
var showLanguageDialog by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
containerColor = Color.Transparent,
|
||||
@ -68,7 +97,7 @@ fun SettingsScreen(
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
"设置",
|
||||
stringResource(R.string.title_settings),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
@ -77,7 +106,7 @@ fun SettingsScreen(
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Icons.Filled.ArrowBack,
|
||||
contentDescription = "返回",
|
||||
contentDescription = stringResource(R.string.action_back),
|
||||
tint = StrawberryPink
|
||||
)
|
||||
}
|
||||
@ -107,19 +136,38 @@ fun SettingsScreen(
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
text = "凭据管理",
|
||||
text = stringResource(R.string.title_credentials),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = InkMuted,
|
||||
modifier = Modifier.padding(start = 4.dp, bottom = 4.dp)
|
||||
)
|
||||
}
|
||||
items(uiState.credentialStatuses, key = { it.service.name }) { status ->
|
||||
items(uiState.credentialStatuses, key = { it.status.service.name }) { line ->
|
||||
CredentialStatusCard(
|
||||
status = status,
|
||||
onClick = { onEditCredential(status.service) }
|
||||
status = line.status,
|
||||
accountCount = line.accountCount,
|
||||
onClick = {
|
||||
if (ServiceType.isMultiAccount(line.status.service)) {
|
||||
onManageAccounts(line.status.service)
|
||||
} else {
|
||||
onEditCredential(line.status.service)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
item {
|
||||
LanguageCard(onClick = { showLanguageDialog = true })
|
||||
}
|
||||
item {
|
||||
TipsCard(onClick = { onOpenTips() })
|
||||
}
|
||||
item {
|
||||
DebugLogCard(onClick = { onOpenDebugLog() })
|
||||
}
|
||||
item {
|
||||
UpdateCard()
|
||||
}
|
||||
item {
|
||||
Spacer(modifier = Modifier.padding(top = 8.dp))
|
||||
AboutCard()
|
||||
@ -127,10 +175,113 @@ fun SettingsScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showLanguageDialog) {
|
||||
LanguageDialog(onDismiss = { showLanguageDialog = false })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CredentialStatusCard(status: CredentialStatus, onClick: () -> Unit) {
|
||||
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
|
||||
private fun CredentialStatusCard(
|
||||
status: CredentialStatus,
|
||||
accountCount: Int = 0,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@ -152,7 +303,11 @@ private fun CredentialStatusCard(status: CredentialStatus, onClick: () -> Unit)
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Text(
|
||||
text = stateLabel(status.state),
|
||||
text = if (accountCount > 0) {
|
||||
stringResource(R.string.account_count_line, stateLabel(status.state), accountCount)
|
||||
} else {
|
||||
stateLabel(status.state)
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = InkMuted,
|
||||
modifier = Modifier.padding(top = 2.dp)
|
||||
@ -163,6 +318,142 @@ private fun CredentialStatusCard(status: CredentialStatus, onClick: () -> Unit)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UpdateCard(
|
||||
viewModel: UpdateViewModel = hiltViewModel()
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(20.dp)) {
|
||||
Text(
|
||||
text = stringResource(R.string.title_update),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Spacer(modifier = Modifier.padding(top = 8.dp))
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.update_current_version,
|
||||
state.currentVersionName,
|
||||
state.currentVersionCode
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = InkMuted
|
||||
)
|
||||
Spacer(modifier = Modifier.padding(top = 12.dp))
|
||||
when (val phase = state.phase) {
|
||||
is UpdateViewModel.Phase.Idle -> Unit
|
||||
is UpdateViewModel.Phase.Checking -> {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.width(18.dp).height(18.dp),
|
||||
color = StrawberryPink,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.update_checking),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = InkMuted
|
||||
)
|
||||
}
|
||||
}
|
||||
is UpdateViewModel.Phase.Latest -> {
|
||||
Text(
|
||||
text = stringResource(R.string.update_latest),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = InkMuted
|
||||
)
|
||||
Spacer(modifier = Modifier.padding(top = 8.dp))
|
||||
TextButton(onClick = { viewModel.checkForUpdate() }) {
|
||||
Text(stringResource(R.string.update_check))
|
||||
}
|
||||
}
|
||||
is UpdateViewModel.Phase.Available -> {
|
||||
Text(
|
||||
text = stringResource(R.string.update_available, phase.info.versionName),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = StrawberryPink,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
if (phase.info.notes.isNotBlank()) {
|
||||
Spacer(modifier = Modifier.padding(top = 4.dp))
|
||||
Text(
|
||||
text = phase.info.notes,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = InkMuted
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.padding(top = 12.dp))
|
||||
Button(onClick = { viewModel.downloadAndInstall(phase.info) }) {
|
||||
Text(stringResource(R.string.update_download))
|
||||
}
|
||||
}
|
||||
is UpdateViewModel.Phase.Downloading -> {
|
||||
LinearProgressIndicator(
|
||||
progress = { phase.progress },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.padding(top = 4.dp))
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.update_downloading,
|
||||
(phase.progress * 100).toInt()
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = InkMuted
|
||||
)
|
||||
}
|
||||
is UpdateViewModel.Phase.Ready -> {
|
||||
Text(
|
||||
text = stringResource(R.string.update_download_finished),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = InkMuted
|
||||
)
|
||||
Spacer(modifier = Modifier.padding(top = 8.dp))
|
||||
Button(onClick = { viewModel.install(phase.file) }) {
|
||||
Text(stringResource(R.string.update_install))
|
||||
}
|
||||
}
|
||||
is UpdateViewModel.Phase.NeedInstallPermission -> {
|
||||
Text(
|
||||
text = stringResource(R.string.update_install_permission_tip),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Spacer(modifier = Modifier.padding(top = 8.dp))
|
||||
Button(onClick = {
|
||||
val intent = Intent(
|
||||
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
|
||||
Uri.parse("package:" + context.packageName)
|
||||
)
|
||||
runCatching { context.startActivity(intent) }
|
||||
}) {
|
||||
Text(stringResource(R.string.update_enable_permission))
|
||||
}
|
||||
}
|
||||
is UpdateViewModel.Phase.Error -> {
|
||||
Text(
|
||||
text = stringResource(R.string.update_failed, phase.message),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Spacer(modifier = Modifier.padding(top = 8.dp))
|
||||
TextButton(onClick = { viewModel.checkForUpdate() }) {
|
||||
Text(stringResource(R.string.update_check))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AboutCard() {
|
||||
Card(
|
||||
@ -173,13 +464,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
|
||||
)
|
||||
@ -187,16 +478,91 @@ 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
|
||||
private fun TipsCard(onClick: () -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onClick() },
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = StrawberryPink.copy(alpha = 0.08f)),
|
||||
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_tips),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = StrawberryPink
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.tips_view_all, AppTips.tips.size),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = InkMuted,
|
||||
modifier = Modifier.padding(top = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DebugLogCard(onClick: () -> Unit) {
|
||||
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_debug_log),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = InkMuted
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.debug_log_desc),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = InkMuted,
|
||||
modifier = Modifier.padding(top = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.rainy.token.data.repository.CredentialRepository
|
||||
import com.rainy.token.domain.model.CredentialStatus
|
||||
import com.rainy.token.domain.service.ServiceType
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@ -32,8 +33,16 @@ class SettingsViewModel @Inject constructor(
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(loading = true) }
|
||||
val statuses = credentialRepository.statusForAll()
|
||||
val lines = statuses.map { status ->
|
||||
val accountCount = if (ServiceType.isMultiAccount(status.service)) {
|
||||
credentialRepository.accountsFor(status.service).size
|
||||
} else {
|
||||
0
|
||||
}
|
||||
CredentialStatusLine(status = status, accountCount = accountCount)
|
||||
}
|
||||
_uiState.update {
|
||||
it.copy(loading = false, credentialStatuses = statuses)
|
||||
it.copy(loading = false, credentialStatuses = lines)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -41,5 +50,11 @@ class SettingsViewModel @Inject constructor(
|
||||
|
||||
data class SettingsUiState(
|
||||
val loading: Boolean = false,
|
||||
val credentialStatuses: List<CredentialStatus> = emptyList()
|
||||
val credentialStatuses: List<CredentialStatusLine> = emptyList()
|
||||
)
|
||||
|
||||
/** 凭据状态行:单账号服务 [accountCount]=0;多账号服务为账号数(0 表示尚未配置)。 */
|
||||
data class CredentialStatusLine(
|
||||
val status: CredentialStatus,
|
||||
val accountCount: Int = 0
|
||||
)
|
||||
@ -0,0 +1,98 @@
|
||||
package com.rainy.token.ui.settings
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.rainy.token.BuildConfig
|
||||
import com.rainy.token.data.repository.UpdateRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** 设置页更新模块:检查更新 → 下载 APK → 拉起系统安装器。 */
|
||||
@HiltViewModel
|
||||
class UpdateViewModel @Inject constructor(
|
||||
private val updateRepository: UpdateRepository,
|
||||
@ApplicationContext private val context: Context
|
||||
) : ViewModel() {
|
||||
|
||||
sealed interface Phase {
|
||||
object Idle : Phase
|
||||
object Checking : Phase
|
||||
object Latest : Phase
|
||||
data class Available(val info: UpdateRepository.UpdateInfo) : Phase
|
||||
data class Downloading(val progress: Float) : Phase
|
||||
data class Ready(val file: File) : Phase
|
||||
object NeedInstallPermission : Phase
|
||||
data class Error(val message: String) : Phase
|
||||
}
|
||||
|
||||
data class UiState(
|
||||
val phase: Phase = Phase.Idle,
|
||||
val currentVersionName: String = BuildConfig.VERSION_NAME,
|
||||
val currentVersionCode: Int = BuildConfig.VERSION_CODE
|
||||
)
|
||||
|
||||
private val _uiState = MutableStateFlow(UiState())
|
||||
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
checkForUpdate()
|
||||
}
|
||||
|
||||
fun checkForUpdate() {
|
||||
_uiState.update { it.copy(phase = Phase.Checking) }
|
||||
viewModelScope.launch {
|
||||
updateRepository.checkForUpdate().fold(
|
||||
onSuccess = { info ->
|
||||
_uiState.update {
|
||||
it.copy(phase = if (info == null) Phase.Latest else Phase.Available(info))
|
||||
}
|
||||
},
|
||||
onFailure = { e ->
|
||||
_uiState.update { it.copy(phase = Phase.Error(e.message ?: "unknown")) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun downloadAndInstall(info: UpdateRepository.UpdateInfo) {
|
||||
_uiState.update { it.copy(phase = Phase.Downloading(0f)) }
|
||||
viewModelScope.launch {
|
||||
updateRepository.downloadApk(info) { downloaded, total ->
|
||||
val progress = if (total > 0) downloaded.toFloat() / total else 0f
|
||||
_uiState.update { it.copy(phase = Phase.Downloading(progress)) }
|
||||
}.fold(
|
||||
onSuccess = { file -> install(file) },
|
||||
onFailure = { e ->
|
||||
_uiState.update { it.copy(phase = Phase.Error(e.message ?: "unknown")) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun install(file: File) {
|
||||
if (context.packageManager.canRequestPackageInstalls()) {
|
||||
_uiState.update { it.copy(phase = Phase.Ready(file)) }
|
||||
val authority = context.packageName + ".fileprovider"
|
||||
val uri = FileProvider.getUriForFile(context, authority, file)
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
runCatching { context.startActivity(intent) }.onFailure { e ->
|
||||
_uiState.update { it.copy(phase = Phase.Error(e.message ?: "cannot start installer")) }
|
||||
}
|
||||
} else {
|
||||
_uiState.update { it.copy(phase = Phase.NeedInstallPermission) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -3,40 +3,41 @@ package com.rainy.token.ui.theme
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
/**
|
||||
* 雨晴风格配色(RainyStyle Palette)。
|
||||
* 雨晴风格配色(RainyStyle Palette)— 精致粉调。
|
||||
*
|
||||
* 视觉基调:元气樱粉 / 草莓粉主调,搭配纯白卡片与暖灰文本。
|
||||
* 视觉基调:低饱和樱粉白背景 + 纯白卡片 + 深玫瑰主色点缀,
|
||||
* 通过克制用色与留白呈现精致感,保留"雨晴粉"品牌识别度。
|
||||
* 状态色:草绿(正常)、暖橙(注意)、玫红(异常)。
|
||||
*
|
||||
* 背景使用樱粉渐变(#FFF0F5 → #FFD1DC),主元素采用草莓粉
|
||||
* (#FF85A2 / #FF6B8E),与白色形成层次。
|
||||
* 背景使用柔和粉白渐变(#FFF9FB → #FDEFF4),主元素采用深玫瑰
|
||||
* (#C9436C / #B3305B),与白色形成优雅层次。
|
||||
*/
|
||||
|
||||
// ─── 品牌主色 ───
|
||||
val CherryPinkLight = Color(0xFFFFF0F5) // 樱粉浅(背景起始)
|
||||
val CherryPinkDeep = Color(0xFFFFD1DC) // 樱粉深(背景结束)
|
||||
val StrawberryPink = Color(0xFFFF85A2) // 草莓粉(主色 / 强调)
|
||||
val StrawberryPinkDark = Color(0xFFFF6B8E)// 草莓粉深(按下态 / 主按钮)
|
||||
val StrawberryPinkSoft = Color(0xFFFFB3C6)// 草莓粉柔(hover / 副按钮)
|
||||
val CherryPinkLight = Color(0xFFFFF9FB) // 粉白(背景起始)
|
||||
val CherryPinkDeep = Color(0xFFFDEFF4) // 浅樱粉(背景结束)
|
||||
val StrawberryPink = Color(0xFFC9436C) // 深玫瑰(主色 / 强调,白底对比度 ≥ 4.5:1)
|
||||
val StrawberryPinkDark = Color(0xFFB3305B)// 深玫瑰按下态(主按钮)
|
||||
val StrawberryPinkSoft = Color(0xFFFFC9D8)// 浅玫瑰柔(容器 / 选中背景)
|
||||
|
||||
// ─── 中性色 ───
|
||||
val PureWhite = Color(0xFFFFFFFF)
|
||||
val SnowWhite = Color(0xFFFAF6F8) // 卡片次级背景
|
||||
val InkWarm = Color(0xFF3D2C35) // 暖黑(主文本)
|
||||
val InkMuted = Color(0xFF7A6B72) // 暖灰(次要文本)
|
||||
val InkOutline = Color(0xFFD9CFD3) // 暖灰(分隔线 / 描边)
|
||||
val SnowWhite = Color(0xFFFAF5F7) // 卡片次级背景
|
||||
val InkWarm = Color(0xFF3A2B31) // 暖黑(主文本)
|
||||
val InkMuted = Color(0xFF7A6870) // 暖灰(次要文本,白底 ≥ 4.5:1)
|
||||
val InkOutline = Color(0xFFE8DCE1) // 暖灰(分隔线 / 描边)
|
||||
|
||||
// ─── 状态色 ───
|
||||
val StatusGreen = Color(0xFF66BB6A) // 正常
|
||||
val StatusGreen = Color(0xFF4CAF50) // 正常
|
||||
val StatusOrange = Color(0xFFFFA726) // 注意 / 需重登
|
||||
val StatusRed = Color(0xFFE91E63) // 错误 / 异常
|
||||
val StatusRed = Color(0xFFD64A6E) // 错误 / 异常(玫红,与粉调统一)
|
||||
val StatusBlue = Color(0xFF64B5F6) // 信息(Stale 缓存)
|
||||
|
||||
// ─── 暗色模式 ───
|
||||
val DarkBackground = Color(0xFF1F1419) // 暖深背景
|
||||
val DarkSurface = Color(0xFF2A1F25) // 卡片
|
||||
val DarkOnSurface = Color(0xFFEFE0E5) // 卡片文本
|
||||
val DarkPrimary = Color(0xFFFFB3C6) // 暗色下主色更亮
|
||||
val DarkInkWarm = Color(0xFFEFE0E5) // 暗色下暖黑→浅粉白
|
||||
val DarkInkMuted = Color(0xFFC9B8BE) // 暗色下暖灰→浅灰粉
|
||||
val DarkInkOutline = Color(0xFF4D3A42) // 暗色下描边
|
||||
val DarkBackground = Color(0xFF1A1417) // 暖深背景
|
||||
val DarkSurface = Color(0xFF251C21) // 卡片
|
||||
val DarkOnSurface = Color(0xFFF2E6EB) // 卡片文本
|
||||
val DarkPrimary = Color(0xFFFFA9BF) // 暗色下主色更亮
|
||||
val DarkInkWarm = Color(0xFFF2E6EB) // 暗色下暖黑→浅粉白
|
||||
val DarkInkMuted = Color(0xFFCBB8C0) // 暗色下暖灰→浅灰粉
|
||||
val DarkInkOutline = Color(0xFF4C3B44) // 暗色下描边
|
||||
|
||||
@ -13,21 +13,22 @@ import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
|
||||
/**
|
||||
* 雨晴风格主题。
|
||||
* 雨晴风格主题(精致粉调)。
|
||||
*
|
||||
* - **不使用** dynamicColor(系统主题色),固定使用雨晴樱粉品牌色。
|
||||
* - **不使用** dynamicColor(系统主题色),固定使用雨晴玫瑰粉品牌色。
|
||||
* 理由:dynamicColor 会让 APP 视觉随系统变化,破坏品牌一致性;
|
||||
* 玩家一眼看到 APP icon + 粉色调就知道是 RainyToken 系列。
|
||||
* - 暗色模式:暖深背景 + 亮草莓粉强调,跟 Light 模式同色系不同明度。
|
||||
* - 主色:深玫瑰 #C9436C;容器色:浅玫瑰粉;背景:低饱和粉白渐变。
|
||||
* - 暗色模式:暖深背景 + 亮玫瑰粉强调,跟 Light 模式同色系不同明度。
|
||||
*/
|
||||
private val LightColors = lightColorScheme(
|
||||
primary = StrawberryPink,
|
||||
onPrimary = PureWhite,
|
||||
primaryContainer = StrawberryPinkSoft,
|
||||
onPrimaryContainer = InkWarm,
|
||||
secondary = StrawberryPinkDark,
|
||||
onPrimaryContainer = Color(0xFF5C112C),
|
||||
secondary = Color(0xFFB7698B),
|
||||
onSecondary = PureWhite,
|
||||
secondaryContainer = Color(0xFFFFE4EC),
|
||||
secondaryContainer = Color(0xFFFFE7EE),
|
||||
onSecondaryContainer = InkWarm,
|
||||
tertiary = StatusGreen,
|
||||
onTertiary = PureWhite,
|
||||
@ -38,32 +39,32 @@ private val LightColors = lightColorScheme(
|
||||
surfaceVariant = SnowWhite,
|
||||
onSurfaceVariant = InkMuted,
|
||||
outline = InkOutline,
|
||||
outlineVariant = Color(0xFFEFE0E5),
|
||||
outlineVariant = Color(0xFFF2E7EB),
|
||||
error = StatusRed,
|
||||
onError = PureWhite
|
||||
)
|
||||
|
||||
private val DarkColors = darkColorScheme(
|
||||
primary = DarkPrimary,
|
||||
onPrimary = DarkBackground,
|
||||
primaryContainer = StrawberryPinkDark,
|
||||
onPrimaryContainer = PureWhite,
|
||||
onPrimary = Color(0xFF4A1025),
|
||||
primaryContainer = Color(0xFF6B2C42),
|
||||
onPrimaryContainer = DarkOnSurface,
|
||||
secondary = StrawberryPink,
|
||||
onSecondary = DarkBackground,
|
||||
secondaryContainer = Color(0xFF4A2E3A),
|
||||
onSecondaryContainer = DarkOnSurface,
|
||||
tertiary = StatusGreen,
|
||||
onTertiary = DarkBackground,
|
||||
tertiary = Color(0xFF7BCF9E),
|
||||
onTertiary = Color(0xFF0D2B1C),
|
||||
background = DarkBackground,
|
||||
onBackground = DarkOnSurface,
|
||||
surface = DarkSurface,
|
||||
onSurface = DarkOnSurface,
|
||||
surfaceVariant = Color(0xFF352329),
|
||||
onSurfaceVariant = Color(0xFFC9B8BE),
|
||||
outline = Color(0xFF4D3A42),
|
||||
outlineVariant = Color(0xFF3A2A30),
|
||||
error = Color(0xFFFF6B8E),
|
||||
onError = PureWhite
|
||||
surfaceVariant = Color(0xFF32262C),
|
||||
onSurfaceVariant = Color(0xFFCBB8C0),
|
||||
outline = Color(0xFF4C3B44),
|
||||
outlineVariant = Color(0xFF3B2C33),
|
||||
error = Color(0xFFFF8194),
|
||||
onError = Color(0xFF4A1025)
|
||||
)
|
||||
|
||||
@Composable
|
||||
|
||||
308
app/src/main/java/com/rainy/token/ui/webview/CodexOAuthScreen.kt
Normal file
308
app/src/main/java/com/rainy/token/ui/webview/CodexOAuthScreen.kt
Normal file
@ -0,0 +1,308 @@
|
||||
package com.rainy.token.ui.webview
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.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
|
||||
|
||||
/**
|
||||
* Codex OAuth PKCE 登录页面。
|
||||
*
|
||||
* 两种模式:
|
||||
* - WEBVIEW:APP 内 WebView 打开 OpenAI 授权页,拦截 localhost:1455 回调
|
||||
* - HEADLESS:显示授权 URL + 复制按钮 + 在浏览器打开 + 粘贴回调 URL 输入框
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CodexOAuthScreen(
|
||||
onBack: () -> Unit,
|
||||
onSuccess: () -> Unit,
|
||||
viewModel: CodexOAuthViewModel = hiltViewModel()
|
||||
) {
|
||||
LaunchedEffect(Unit) { viewModel.start(CodexOAuthMode.HEADLESS) }
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(uiState.loginSucceeded) {
|
||||
if (uiState.loginSucceeded) onSuccess()
|
||||
}
|
||||
|
||||
BackHandler(enabled = !uiState.loginSucceeded) { onBack() }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.oauth_screen_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
) {
|
||||
when {
|
||||
uiState.exchanging -> {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
CircularProgressIndicator(color = StrawberryPink)
|
||||
Text(
|
||||
stringResource(R.string.oauth_exchanging),
|
||||
modifier = Modifier.padding(top = 16.dp),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
uiState.error != null && uiState.mode == CodexOAuthMode.WEBVIEW -> {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
uiState.error!!.asString(),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
TextButton(onClick = { viewModel.start(CodexOAuthMode.WEBVIEW) }) {
|
||||
Text(stringResource(R.string.action_retry), color = StrawberryPink)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 无头模式:显示授权 URL + 粘贴回调
|
||||
uiState.mode == CodexOAuthMode.HEADLESS && uiState.authUrl.isNotEmpty() -> {
|
||||
HeadlessOAuthContent(
|
||||
authUrl = uiState.authUrl,
|
||||
error = uiState.error,
|
||||
onCopyUrl = { copyToClipboard(context, uiState.authUrl) },
|
||||
onOpenInBrowser = { openInBrowser(context, uiState.authUrl) },
|
||||
onSubmit = { url -> viewModel.submitCallbackUrl(url) },
|
||||
onRetry = { viewModel.start(CodexOAuthMode.HEADLESS) }
|
||||
)
|
||||
}
|
||||
|
||||
// WebView 模式
|
||||
uiState.authUrl.isNotEmpty() -> {
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
WebView(ctx).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
settings.useWideViewPort = true
|
||||
settings.loadWithOverviewMode = true
|
||||
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(
|
||||
view: WebView?,
|
||||
request: WebResourceRequest?
|
||||
): Boolean {
|
||||
val url = request?.url?.toString()
|
||||
if (url != null && viewModel.onUrlChanged(url)) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
webChromeClient = WebChromeClient()
|
||||
loadUrl(uiState.authUrl)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
CircularProgressIndicator(color = StrawberryPink)
|
||||
Text(
|
||||
stringResource(R.string.oauth_preparing),
|
||||
modifier = Modifier.padding(top = 16.dp),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 无头模式 UI:授权 URL + 操作按钮 + 回调 URL 粘贴输入框
|
||||
*/
|
||||
@Composable
|
||||
private fun HeadlessOAuthContent(
|
||||
authUrl: String,
|
||||
error: UiText?,
|
||||
onCopyUrl: () -> Unit,
|
||||
onOpenInBrowser: () -> Unit,
|
||||
onSubmit: (String) -> Unit,
|
||||
onRetry: () -> Unit
|
||||
) {
|
||||
var callbackUrl by remember { mutableStateOf("") }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.oauth_headless_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = StrawberryPink
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.oauth_steps),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
|
||||
// 授权 URL 预览
|
||||
OutlinedTextField(
|
||||
value = authUrl,
|
||||
onValueChange = {},
|
||||
label = { Text(stringResource(R.string.oauth_auth_url_label)) },
|
||||
readOnly = true,
|
||||
minLines = 3,
|
||||
maxLines = 5,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onOpenInBrowser,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(stringResource(R.string.action_open_in_browser))
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = onCopyUrl,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(stringResource(R.string.action_copy_link))
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.oauth_paste_callback_hint),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = callbackUrl,
|
||||
onValueChange = { callbackUrl = it },
|
||||
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()
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = { onSubmit(callbackUrl) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = callbackUrl.isNotBlank()
|
||||
) {
|
||||
Text(stringResource(R.string.action_submit_callback))
|
||||
}
|
||||
|
||||
if (error != null) {
|
||||
Text(
|
||||
text = error.asString(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
TextButton(onClick = onRetry) {
|
||||
Text(stringResource(R.string.action_regenerate_link), color = StrawberryPink)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyToClipboard(context: Context, text: String) {
|
||||
val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
cm.setPrimaryClip(ClipData.newPlainText("auth_url", text))
|
||||
}
|
||||
|
||||
private fun openInBrowser(context: Context, url: String) {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
context.startActivity(intent)
|
||||
}
|
||||
@ -0,0 +1,169 @@
|
||||
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
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import javax.inject.Inject
|
||||
|
||||
enum class CodexOAuthMode { WEBVIEW, HEADLESS }
|
||||
|
||||
/**
|
||||
* Codex OAuth PKCE 登录 ViewModel。
|
||||
*
|
||||
* 两种模式:
|
||||
* - WEBVIEW:APP 内 WebView 打开授权页,拦截 localhost:1455 回调
|
||||
* - HEADLESS:生成授权 URL 让用户复制到外部浏览器,登录后粘贴回调 URL 回来
|
||||
*/
|
||||
@HiltViewModel
|
||||
class CodexOAuthViewModel @Inject constructor(
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val credentialRepository: CredentialRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(CodexOAuthUiState())
|
||||
val uiState: StateFlow<CodexOAuthUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val TAG = "CodexOAuth"
|
||||
|
||||
fun start(mode: CodexOAuthMode = CodexOAuthMode.WEBVIEW) {
|
||||
val pkce = CodexOAuthHelper.generatePkce()
|
||||
val state = CodexOAuthHelper.generateState()
|
||||
val authUrl = CodexOAuthHelper.buildAuthUrl(pkce.codeChallenge, state)
|
||||
DebugLog.i(TAG, "OAuth 流程启动 (${mode.name}),构建授权 URL")
|
||||
_uiState.update {
|
||||
CodexOAuthUiState(
|
||||
mode = mode,
|
||||
authUrl = authUrl,
|
||||
codeVerifier = pkce.codeVerifier,
|
||||
expectedState = state
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WebView 拦截到 URL 变化时调用。
|
||||
* 如果 URL 匹配 callback,返回 true 表示已处理。
|
||||
*/
|
||||
fun onUrlChanged(url: String?): Boolean {
|
||||
if (url == null) return false
|
||||
if (!url.startsWith(CodexOAuthHelper.CALLBACK_PREFIX)) return false
|
||||
handleCallbackUrl(url)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 无头模式:用户粘贴回调 URL 后调用。
|
||||
*/
|
||||
fun submitCallbackUrl(url: String) {
|
||||
handleCallbackUrl(url.trim())
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_uiState.update { it.copy(error = null) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理回调 URL:解析 code/state,交换 token,保存凭据。
|
||||
*/
|
||||
private fun handleCallbackUrl(url: String) {
|
||||
if (!url.startsWith(CodexOAuthHelper.CALLBACK_PREFIX)) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
error = UiText.Resource(
|
||||
R.string.error_oauth_url_prefix,
|
||||
listOf(CodexOAuthHelper.CALLBACK_PREFIX)
|
||||
)
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val uri = android.net.Uri.parse(url)
|
||||
val code = uri.getQueryParameter("code")
|
||||
val receivedState = uri.getQueryParameter("state")
|
||||
val error = uri.getQueryParameter("error")
|
||||
|
||||
if (error != null) {
|
||||
val errorDesc = uri.getQueryParameter("error_description") ?: error
|
||||
DebugLog.e(TAG, "OAuth 授权失败: $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 = UiText.Resource(R.string.error_oauth_state_mismatch)) }
|
||||
return
|
||||
}
|
||||
|
||||
if (code.isNullOrBlank()) {
|
||||
DebugLog.e(TAG, "OAuth 回调缺少 code 参数")
|
||||
_uiState.update { it.copy(error = UiText.Resource(R.string.error_oauth_missing_code)) }
|
||||
return
|
||||
}
|
||||
|
||||
_uiState.update { it.copy(exchanging = true, error = null) }
|
||||
val verifier = _uiState.value.codeVerifier ?: return
|
||||
|
||||
viewModelScope.launch {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
CodexOAuthHelper.exchangeCode(okHttpClient, code, verifier)
|
||||
}
|
||||
|
||||
if (result == null) {
|
||||
DebugLog.e(TAG, "token 交换失败")
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
exchanging = false,
|
||||
error = UiText.Resource(R.string.error_oauth_token_exchange)
|
||||
)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
val accountId = CodexOAuthHelper.extractAccountId(result.idToken, result.accessToken)
|
||||
val expiresAt = System.currentTimeMillis() + result.expiresIn * 1000L
|
||||
|
||||
DebugLog.i(TAG, "OAuth 登录成功,accountId=$accountId,expiresAt=$expiresAt")
|
||||
|
||||
val cred = Credential.CodexCredential(
|
||||
service = ServiceType.CODEX,
|
||||
accessToken = result.accessToken,
|
||||
refreshToken = result.refreshToken ?: "",
|
||||
accountId = accountId ?: "",
|
||||
expiresAt = expiresAt,
|
||||
lastVerifiedAt = System.currentTimeMillis()
|
||||
)
|
||||
credentialRepository.save(cred)
|
||||
|
||||
_uiState.update { it.copy(exchanging = false, loginSucceeded = true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class CodexOAuthUiState(
|
||||
val mode: CodexOAuthMode = CodexOAuthMode.WEBVIEW,
|
||||
val authUrl: String = "",
|
||||
val codeVerifier: String? = null,
|
||||
val expectedState: String? = null,
|
||||
val exchanging: Boolean = false,
|
||||
val loginSucceeded: Boolean = false,
|
||||
val error: UiText? = null
|
||||
)
|
||||
@ -13,11 +13,14 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
@ -26,12 +29,18 @@ import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
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.data.debug.DebugLog
|
||||
import com.rainy.token.domain.service.ServiceType
|
||||
import com.rainy.token.ui.components.asString
|
||||
|
||||
/**
|
||||
* 通用 WebView 登录容器。计划 4.1:
|
||||
@ -44,17 +53,34 @@ import com.rainy.token.domain.service.ServiceType
|
||||
@Composable
|
||||
fun WebViewLoginScreen(
|
||||
service: ServiceType,
|
||||
accountId: String? = null,
|
||||
onBack: () -> Unit,
|
||||
onLoginSucceeded: (ServiceType) -> Unit,
|
||||
onLoginSucceeded: (ServiceType, String?) -> Unit,
|
||||
/** 用户已保存的面板基址(Sub2API 的 loginUrl 为空,靠它提供登录入口与可信 host)。 */
|
||||
configuredBaseUrl: String? = null,
|
||||
viewModel: WebViewLoginViewModel = hiltViewModel()
|
||||
) {
|
||||
LaunchedEffect(service) { viewModel.bind(service) }
|
||||
// configuredBaseUrl 可选:不传时 ViewModel 自行从凭据仓库读取(Sub2API 自托管实例)
|
||||
LaunchedEffect(service, configuredBaseUrl, accountId) {
|
||||
viewModel.bind(service, configuredBaseUrl, accountId)
|
||||
}
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
// 登录成功自动回调
|
||||
// 登录成功自动回调(携带实际写入的账号 ID,供新建账号模式回填)
|
||||
LaunchedEffect(uiState.loginSucceeded) {
|
||||
if (uiState.loginSucceeded) {
|
||||
onLoginSucceeded(service)
|
||||
onLoginSucceeded(service, uiState.loginAccountId)
|
||||
}
|
||||
}
|
||||
|
||||
// 自动识别服务(WorkBuddy 轮询超时、bindWorkBuddy 网络失败等)没有手动确认弹窗兜底,
|
||||
// 错误通过 Snackbar 呈现,避免用户静默卡在登录页。
|
||||
// key 不含 pendingManualConfirm:错误文本不变时不重复提示(弹窗关闭不回放),且保持互斥。
|
||||
val errorText = uiState.error?.asString()
|
||||
LaunchedEffect(errorText, uiState.loginSucceeded) {
|
||||
if (errorText != null && !uiState.pendingManualConfirm && !uiState.loginSucceeded) {
|
||||
snackbarHostState.showSnackbar(errorText)
|
||||
}
|
||||
}
|
||||
|
||||
@ -63,12 +89,13 @@ fun WebViewLoginScreen(
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
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))
|
||||
}
|
||||
}
|
||||
)
|
||||
@ -97,9 +124,54 @@ fun WebViewLoginScreen(
|
||||
// 且 TLS fingerprinting 也会识别 WebView。此入口主要作为 fallback,
|
||||
// 主流程走"手动粘贴 Cookie"路线。
|
||||
|
||||
// 注入 JS 桥:面板登录后把 localStorage 里的登录态回传原生层,
|
||||
// 实现「网页登录一次即自动配置凭据」,无需手工粘贴 token
|
||||
addJavascriptInterface(
|
||||
LoginProbeBridge { json ->
|
||||
viewModel.onLocalStorageProbed(parseProbeJson(json))
|
||||
},
|
||||
"AndroidBridge"
|
||||
)
|
||||
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(
|
||||
view: WebView?,
|
||||
request: android.webkit.WebResourceRequest?
|
||||
): Boolean {
|
||||
val url = request?.url?.toString() ?: return false
|
||||
return handleOverrideUrl(url)
|
||||
}
|
||||
|
||||
@Deprecated("deprecated in API 24, kept for older devices")
|
||||
override fun shouldOverrideUrlLoading(
|
||||
view: WebView?,
|
||||
url: String?
|
||||
): Boolean {
|
||||
url ?: return false
|
||||
return handleOverrideUrl(url)
|
||||
}
|
||||
|
||||
fun handleOverrideUrl(url: String): Boolean {
|
||||
// Trae OAuth 回调:拦截 127.0.0.1:18080/authorize,提取 userJwt
|
||||
if (url.startsWith("http://127.0.0.1:18080/authorize")) {
|
||||
DebugLog.i("TraeLogin", "回调命中: ${url.toSafeLogUrl()}")
|
||||
viewModel.onTraeCallback(url)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onPageStarted(view: WebView?, url: String?, favicon: android.graphics.Bitmap?) {
|
||||
DebugLog.i("TraeLogin", "pageStarted: ${url?.toSafeLogUrl()}")
|
||||
super.onPageStarted(view, url, favicon)
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
url?.let { viewModel.onPageFinished(it) }
|
||||
url ?: return
|
||||
// 登录成功后面板会写入 localStorage.auth_token,抓到即自动保存
|
||||
view?.evaluateJavascript(viewModel.probeScript, null)
|
||||
viewModel.onPageFinished(url)
|
||||
DebugLog.i("TraeLogin", "pageFinished: ${url.toSafeLogUrl()}")
|
||||
}
|
||||
}
|
||||
webChromeClient = WebChromeClient()
|
||||
@ -109,13 +181,12 @@ fun WebViewLoginScreen(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
} else {
|
||||
Column(modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)) {
|
||||
Text(
|
||||
text = "未配置登录 URL",
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
// loginUrl 为空 = ViewModel 正在生成授权地址(Trae/WorkBuddy 需要片刻)
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -125,23 +196,61 @@ 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))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
/**
|
||||
* JS → 原生桥:页面脚本通过 window.AndroidBridge.onProbe(json) 回传 localStorage 登录态。
|
||||
*
|
||||
* 用 @JavascriptInterface 注解的方法只允许传基本类型,这里统一走 JSON 字符串,
|
||||
* 解析失败按空处理(页面脚本也可能被站点 CSP 拦掉)。
|
||||
*/
|
||||
private class LoginProbeBridge(private val onProbe: (String) -> Unit) {
|
||||
@android.webkit.JavascriptInterface
|
||||
fun onProbe(json: String) {
|
||||
runCatching { onProbe(json) }
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析探测脚本回传的 JSON(扁平 string→string 结构)。 */
|
||||
private fun parseProbeJson(json: String): Map<String, String> = runCatching {
|
||||
val obj = org.json.JSONObject(json)
|
||||
buildMap {
|
||||
obj.keys().forEach { k ->
|
||||
val v = obj.optString(k, "")
|
||||
if (v.isNotEmpty()) put(k, v)
|
||||
}
|
||||
}
|
||||
}.getOrDefault(emptyMap())
|
||||
|
||||
/**
|
||||
* 脱敏日志 URL:只保留 scheme://host/path,去掉 query/fragment(OAuth 回调的
|
||||
* userJwt/userInfo 等敏感参数不落调试日志,与 ViewModel 侧“只记参数名”口径一致)。
|
||||
* 解析失败时回退为截断原文。
|
||||
*/
|
||||
private fun String.toSafeLogUrl(maxLen: Int = 200): String {
|
||||
val parsed = runCatching { android.net.Uri.parse(this) }.getOrNull()
|
||||
val safe = parsed?.let { u ->
|
||||
val host = u.host ?: ""
|
||||
buildString {
|
||||
append(u.scheme ?: "").append("://").append(host).append(u.path.orEmpty())
|
||||
}
|
||||
}
|
||||
return if (safe.isNullOrBlank()) take(maxLen) else safe
|
||||
}
|
||||
@ -1,10 +1,32 @@
|
||||
package com.rainy.token.ui.webview
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
import com.rainy.token.R
|
||||
import com.rainy.token.data.debug.DebugLog
|
||||
import com.rainy.token.data.repository.WebViewSessionSaver
|
||||
import com.rainy.token.data.repository.CredentialRepository
|
||||
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
|
||||
@ -13,38 +35,356 @@ import kotlinx.coroutines.flow.update
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* WebView 登录容器 ViewModel。计划 4.1:
|
||||
* - 持有目标 URL(来自 ServiceConfig)
|
||||
* - 提供 [onLoginSuccess] 回调:抓 Cookie → 加密存 SecureStorage
|
||||
* - 跟踪"用户手动确认登录"状态(N 秒超时未识别成功时显示按钮)
|
||||
* WebView 登录容器 ViewModel。
|
||||
*
|
||||
* 支持三种服务的软件内登录:
|
||||
* - **Trae**:打开 trae.cn 授权页,用户登录后拦截 `http://127.0.0.1:18080/authorize`
|
||||
* 回调,解析 `userJwt` 中的 JWT 并保存为 [Credential.TraeCredential]。
|
||||
* - **WorkBuddy**:调用 copilot.tencent.com 设备授权 API 获取 QR 码 URL,
|
||||
* 在 WebView 中展示二维码,后台轮询直到用户扫码授权,保存为
|
||||
* [Credential.WorkBuddyCredential]。
|
||||
* - **Sub2API**:打开自托管面板登录页,注入 JS 读取 localStorage.auth_token。
|
||||
*/
|
||||
@HiltViewModel
|
||||
class WebViewLoginViewModel @Inject constructor(
|
||||
private val sessionSaver: WebViewSessionSaver
|
||||
private val sessionSaver: WebViewSessionSaver,
|
||||
private val credentialRepository: CredentialRepository,
|
||||
private val okHttpClient: OkHttpClient
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(WebViewLoginUiState())
|
||||
val uiState: StateFlow<WebViewLoginUiState> = _uiState.asStateFlow()
|
||||
|
||||
fun bind(service: ServiceType) {
|
||||
/** WorkBuddy 轮询协程,bind 新服务时取消。 */
|
||||
private var workBuddyPollingJob: Job? = null
|
||||
|
||||
/** 目标账号(多账号服务);null = 新建账号。 */
|
||||
private var accountId: String? = null
|
||||
|
||||
fun bind(service: ServiceType, configuredBaseUrl: String? = null, accountId: String? = null) {
|
||||
workBuddyPollingJob?.cancel()
|
||||
workBuddyPollingJob = null
|
||||
this.accountId = accountId?.takeIf { it.isNotBlank() }
|
||||
|
||||
val config = ServiceConfigProvider.get(service)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
service = service,
|
||||
loginUrl = config.loginUrl
|
||||
loginUrl = "",
|
||||
loginSucceeded = false,
|
||||
error = null,
|
||||
pendingManualConfirm = false
|
||||
)
|
||||
}
|
||||
|
||||
when (service) {
|
||||
ServiceType.TRAE -> bindTrae()
|
||||
ServiceType.WORKBUDDY -> bindWorkBuddy()
|
||||
ServiceType.OPENCODE_GO -> bindOpenCodeGo()
|
||||
else -> bindSub2Api(service, config, configuredBaseUrl)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── OpenCode Go ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 成功 URL 模式匹配(计划 4.1:精确匹配 + fallback)。
|
||||
*
|
||||
* 默认启发:URL 跳转到非登录域 + 路径不包含 /auth/login/signin 等关键词,
|
||||
* 即认为登录完成。具体规则各服务可在 [ServiceConfigProvider] 扩展。
|
||||
* OpenCode Go 应用内登录:WebView 打开 opencode.ai/auth(GitHub / Google OAuth)。
|
||||
* 登录后用户会落到 dashboard(`https://opencode.ai/workspace/{workspaceId}/go`),
|
||||
* 此时从 URL 提取 workspaceId、从 CookieManager 提取 `auth` cookie,
|
||||
* 两者即 [OpenCodeGoRepository.fetchBalance] 所需的全部凭据,自动落库免手抄。
|
||||
*/
|
||||
private fun bindOpenCodeGo() {
|
||||
_uiState.update {
|
||||
it.copy(loginUrl = "https://opencode.ai/auth", trustedHosts = setOf("opencode.ai"))
|
||||
}
|
||||
}
|
||||
|
||||
/** dashboard URL → 提取 workspaceId 并连同 auth cookie 保存;成功返回 true。 */
|
||||
private fun trySaveOpenCodeSession(url: String): Boolean {
|
||||
val workspaceId = OPENCODE_WORKSPACE_REGEX.find(url)?.groupValues?.get(1) ?: return false
|
||||
val cookieString = android.webkit.CookieManager.getInstance().getCookie(url) ?: return false
|
||||
val authCookie = cookieString.split(";")
|
||||
.mapNotNull { entry ->
|
||||
val parts = entry.trim().split("=", limit = 2)
|
||||
if (parts.size != 2 || parts[0] != "auth") null else parts[1].trim()
|
||||
}
|
||||
.firstOrNull { it.isNotBlank() } ?: return false
|
||||
viewModelScope.launch {
|
||||
val saved = sessionSaver.saveOpenCodeGoSession(
|
||||
authCookie = authCookie,
|
||||
workspaceId = workspaceId
|
||||
)
|
||||
if (saved != null) {
|
||||
_uiState.update {
|
||||
it.copy(loginSucceeded = true, savedSession = saved, pendingManualConfirm = false, error = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ─── Trae ──────────────────────────────────────────────────────────────
|
||||
|
||||
private fun bindTrae() {
|
||||
val traceId = randomHex(16)
|
||||
val machineId = randomHex(32)
|
||||
// 设备 ID:claim 签到接口按 x-device-id 做设备级每日去重/风控,要求与 OAuth
|
||||
// 会话绑定的设备一致。这里持久化生成每机唯一 ID(首次生成后跨登录复用),
|
||||
// 登录 URL 与签到 claim 用同一个值。不能每次随机(会话绑定不一致会被风控拒),
|
||||
// 也不可用 userJwt.ClientID(固定客户端标识,所有用户相同,必被按设备限流)。
|
||||
val deviceId = traeDeviceId()
|
||||
val callbackUrl = "http://127.0.0.1:18080/authorize"
|
||||
val authUrl = buildString {
|
||||
append("https://www.trae.cn/authorization?")
|
||||
append("login_version=1&auth_from=solo&login_channel=native_ide&")
|
||||
append("auth_type=local&client_id=en1oxy7wnw8j9n&redirect=0&")
|
||||
append("login_trace_id=$traceId&")
|
||||
append("auth_callback_url=").append(java.net.URLEncoder.encode(callbackUrl, "UTF-8")).append("&")
|
||||
append("machine_id=$machineId&")
|
||||
append("device_id=$deviceId&")
|
||||
append("x_device_id=$deviceId&")
|
||||
append("x_machine_id=$machineId&")
|
||||
append("x_device_brand=PC&x_device_type=PC&")
|
||||
append("x_os_version=1.0&x_app_version=0.1.43&")
|
||||
append("x_app_type=stable&plugin_version=2.3.62834")
|
||||
}
|
||||
DebugLog.i("TraeLogin", "bindTrae: 授权URL已生成 len=${authUrl.length} deviceId持久化=${deviceId.take(6)}…")
|
||||
_uiState.update { it.copy(loginUrl = authUrl) }
|
||||
}
|
||||
|
||||
/** Trae 签到用的持久化设备 ID:首次生成 19 位数字并保存,此后跨登录复用。 */
|
||||
private fun traeDeviceId(): String {
|
||||
val prefs = com.rainy.token.RainyTokenApplication.appContext
|
||||
.getSharedPreferences("trae_login", android.content.Context.MODE_PRIVATE)
|
||||
prefs.getString("device_id", null)?.takeIf { it.isNotBlank() }?.let { return it }
|
||||
val id = randomDigits(19)
|
||||
prefs.edit().putString("device_id", id).apply()
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* Trae 授权回调拦截。URL 形如:
|
||||
* `http://127.0.0.1:18080/authorize?userJwt=<JSON>&userInfo=<JSON>&...`
|
||||
*
|
||||
* `userJwt` 是 JSON 字符串,含 `{ Token, RefreshToken, ClientID, TokenExpireAt, ... }`。
|
||||
* 提取 `Token`(即 Cloud-IDE-JWT)保存为 [Credential.TraeCredential]。
|
||||
*/
|
||||
fun onTraeCallback(url: String) {
|
||||
val service = _uiState.value.service ?: return
|
||||
if (service != ServiceType.TRAE) return
|
||||
if (_uiState.value.loginSucceeded) return
|
||||
|
||||
val parsed = url.toHttpUrlOrNull() ?: run {
|
||||
DebugLog.w("TraeLogin", "onTraeCallback: URL 解析失败")
|
||||
return
|
||||
}
|
||||
// 只记录参数名(不含 token 值,避免把 userJwt 明文落调试日志)
|
||||
DebugLog.i("TraeLogin", "onTraeCallback: 收到回调,参数=${parsed.queryParameterNames}")
|
||||
// Trae 回调中参数名可能为 userJwt 或 user_jwt,值是 JSON 字符串
|
||||
val userJwtRaw = parsed.queryParameter("userJwt")
|
||||
?: parsed.queryParameter("user_jwt")
|
||||
?: run {
|
||||
DebugLog.w("TraeLogin", "onTraeCallback: 无 userJwt/user_jwt 参数")
|
||||
return
|
||||
}
|
||||
val userJwt = try {
|
||||
Json.parseToJsonElement(userJwtRaw).jsonObject
|
||||
} catch (e: Exception) {
|
||||
DebugLog.w("TraeLogin", "onTraeCallback: userJwt JSON 解析失败: ${e.message}")
|
||||
return
|
||||
}
|
||||
val token = userJwt["Token"]?.jsonPrimitive?.contentOrNull
|
||||
if (token.isNullOrBlank()) {
|
||||
DebugLog.w("TraeLogin", "onTraeCallback: 收到回调但 userJwt 无 Token(userJwtKeys=${userJwt.keys})")
|
||||
return
|
||||
}
|
||||
DebugLog.i("TraeLogin", "onTraeCallback: userJwt 解析成功 token非空=${token.isNotBlank()}")
|
||||
// 设备绑定 ID:登录会话绑定的 App 持久化设备 ID(与授权 URL device_id 一致)。
|
||||
// 注意不能用 userJwt.ClientID——那是固定客户端标识(en1oxy7wnw8j9n),所有
|
||||
// 用户相同,作 x-device-id 会被服务端按设备风控拒绝(「参与用户太多」)。
|
||||
val deviceId = traeDeviceId()
|
||||
// 用户 ID:从回调 userInfo 参数提取,签到按 trae-check 口径拼 ${deviceId}-${userId}。
|
||||
val userId = parsed.queryParameter("userInfo")?.let { raw ->
|
||||
runCatching {
|
||||
val u = Json.parseToJsonElement(raw).jsonObject
|
||||
u["userId"]?.jsonPrimitive?.contentOrNull
|
||||
?: u["user_id"]?.jsonPrimitive?.contentOrNull
|
||||
?: u["id"]?.jsonPrimitive?.contentOrNull
|
||||
?: u["accountId"]?.jsonPrimitive?.contentOrNull
|
||||
}.getOrNull()
|
||||
}
|
||||
DebugLog.i("TraeLogin", "onTraeCallback: userId=${if (userId.isNullOrBlank()) "null" else "ok"}")
|
||||
|
||||
viewModelScope.launch {
|
||||
// 刚成功登录的凭据视为已验证:lastVerifiedAt 记为当前时间,
|
||||
// 否则首页/列表会显示"未验证/未登录"直到进详情页拉余额后才正常
|
||||
val credential = Credential.TraeCredential(
|
||||
service = ServiceType.TRAE,
|
||||
jwt = token,
|
||||
region = "CN",
|
||||
deviceId = deviceId,
|
||||
userId = userId?.takeIf { it.isNotBlank() },
|
||||
lastVerifiedAt = System.currentTimeMillis()
|
||||
)
|
||||
val effectiveId = credentialRepository.save(credential, accountId)
|
||||
_uiState.update {
|
||||
it.copy(loginSucceeded = true, pendingManualConfirm = false, error = null, loginAccountId = effectiveId.ifBlank { null })
|
||||
}
|
||||
DebugLog.i("TraeLogin", "onTraeCallback: 凭据已保存,登录完成")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── WorkBuddy ─────────────────────────────────────────────────────────
|
||||
|
||||
private fun bindWorkBuddy() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.url("https://copilot.tencent.com/v2/plugin/auth/state?platform=CLI")
|
||||
.addHeader("User-Agent", "CLI/2.63.2 CodeBuddy/2.63.2")
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.post("{}".toRequestBody("application/json".toMediaTypeOrNull()))
|
||||
.build()
|
||||
val (body, ok) = withContext(Dispatchers.IO) {
|
||||
val resp = okHttpClient.newCall(request).execute()
|
||||
val s = resp.body?.string().orEmpty()
|
||||
s to resp.isSuccessful
|
||||
}
|
||||
if (!ok || body.isBlank()) {
|
||||
_uiState.update {
|
||||
it.copy(error = UiText.Resource(R.string.error_network_generic))
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
val json = Json.parseToJsonElement(body).jsonObject
|
||||
val data = json["data"]?.jsonObject
|
||||
val state = data?.get("state")?.jsonPrimitive?.contentOrNull
|
||||
val authURL = data?.get("authUrl")?.jsonPrimitive?.contentOrNull
|
||||
?: data?.get("authURL")?.jsonPrimitive?.contentOrNull
|
||||
if (state.isNullOrBlank() || authURL.isNullOrBlank()) {
|
||||
_uiState.update {
|
||||
it.copy(error = UiText.Resource(R.string.error_network_generic))
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
_uiState.update { it.copy(loginUrl = authURL) }
|
||||
workBuddyPollingJob = viewModelScope.launch { pollWorkBuddyToken(state) }
|
||||
} catch (_: Exception) {
|
||||
_uiState.update {
|
||||
it.copy(error = UiText.Resource(R.string.error_network_generic))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 每 2 秒轮询一次 token 接口,120 秒超时。 */
|
||||
private suspend fun pollWorkBuddyToken(state: String) {
|
||||
val timeoutMs = 120_000L
|
||||
val startTime = System.currentTimeMillis()
|
||||
while (System.currentTimeMillis() - startTime < timeoutMs) {
|
||||
if (_uiState.value.loginSucceeded) return
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.url("https://copilot.tencent.com/v2/plugin/auth/token?state=$state")
|
||||
.addHeader("User-Agent", "CLI/2.63.2 CodeBuddy/2.63.2")
|
||||
.get()
|
||||
.build()
|
||||
val body = withContext(Dispatchers.IO) {
|
||||
val resp = okHttpClient.newCall(request).execute()
|
||||
val s = resp.body?.string().orEmpty()
|
||||
resp.close()
|
||||
s
|
||||
}
|
||||
val json = Json.parseToJsonElement(body).jsonObject
|
||||
val code = json["code"]?.jsonPrimitive?.intOrNull ?: -1
|
||||
if (code == 0) {
|
||||
val data = json["data"]?.jsonObject
|
||||
val accessToken = data?.get("accessToken")?.jsonPrimitive?.contentOrNull
|
||||
?: data?.get("access_token")?.jsonPrimitive?.contentOrNull
|
||||
val refreshToken = data?.get("refreshToken")?.jsonPrimitive?.contentOrNull
|
||||
?: data?.get("refresh_token")?.jsonPrimitive?.contentOrNull
|
||||
val expiresIn = data?.get("expiresIn")?.jsonPrimitive?.longOrNull
|
||||
?: data?.get("expires_in")?.jsonPrimitive?.longOrNull ?: 7200L
|
||||
if (!accessToken.isNullOrBlank()) {
|
||||
val expiresAt = System.currentTimeMillis() + expiresIn * 1000
|
||||
// 刚成功登录的凭据视为已验证(见 Trae 同款注释)
|
||||
val credential = Credential.WorkBuddyCredential(
|
||||
service = ServiceType.WORKBUDDY,
|
||||
accessToken = accessToken,
|
||||
refreshToken = refreshToken ?: "",
|
||||
expiresAt = expiresAt,
|
||||
lastVerifiedAt = System.currentTimeMillis()
|
||||
)
|
||||
val effectiveId = credentialRepository.save(credential, accountId)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
loginSucceeded = true,
|
||||
pendingManualConfirm = false,
|
||||
error = null,
|
||||
loginAccountId = effectiveId.ifBlank { null }
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// 网络异常时继续轮询,直到超时
|
||||
}
|
||||
delay(2000)
|
||||
}
|
||||
_uiState.update { it.copy(error = UiText.Resource(R.string.error_login_timeout)) }
|
||||
}
|
||||
|
||||
// ─── Sub2API (existing) ────────────────────────────────────────────────
|
||||
|
||||
private fun bindSub2Api(
|
||||
service: ServiceType,
|
||||
config: com.rainy.token.domain.service.ServiceConfig,
|
||||
configuredBaseUrl: String?
|
||||
) {
|
||||
_uiState.update {
|
||||
it.copy(service = service, loginUrl = config.loginUrl, trustedHosts = emptySet())
|
||||
}
|
||||
viewModelScope.launch {
|
||||
val base = configuredBaseUrl
|
||||
?: (credentialRepository.get(service) as? Credential.Sub2ApiCredential)?.baseUrl
|
||||
val hosts = buildSet {
|
||||
base?.toHttpUrlOrNull()?.host?.let { add(it) }
|
||||
config.loginUrl.toHttpUrlOrNull()?.host?.let { add(it) }
|
||||
}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
loginUrl = config.loginUrl.ifBlank { base.orEmpty() },
|
||||
trustedHosts = hosts
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Page lifecycle ────────────────────────────────────────────────────
|
||||
|
||||
fun onPageFinished(url: String) {
|
||||
val current = _uiState.value
|
||||
if (current.loginSucceeded) return
|
||||
// OpenCode:URL 到 dashboard(/workspace/{id}/go)即自动提取凭据,无 dashboard
|
||||
// (如停在 /auth 登录页)则静默等待,不打扰用户操作。
|
||||
if (_uiState.value.service == ServiceType.OPENCODE_GO) {
|
||||
if (!trySaveOpenCodeSession(url) && !looksLikeLoggedInPage(url)) {
|
||||
_uiState.update { it.copy(pendingManualConfirm = false) }
|
||||
}
|
||||
return
|
||||
}
|
||||
// Trae 的登录完成由 OAuth 回调(127.0.0.1:18080/authorize?userJwt=...,onTraeCallback)
|
||||
// 自动判定,onPageFinished 的 URL 关键词启发式对 Trae 不适用:授权页 URL 含 /auth,
|
||||
// 会在用户尚在页面操作时就误弹「登录完成?」提示;且 saveSession 的 cookie 抓取
|
||||
// 对 JWT 凭据无效,反而可能覆盖已保存的凭据。Trae 流程静默等待回调即可,
|
||||
// 想放弃登录由 BackHandler 返回。
|
||||
if (_uiState.value.service == ServiceType.TRAE) return
|
||||
// WorkBuddy 的登录完成由 token 轮询(pollWorkBuddyToken)自动判定:扫码页面 URL
|
||||
// 含 /authorize,关键词启发式会误弹「登录完成?」;且确认后走 cookie 抓取会把
|
||||
// 错误的 SessionCredential 保存到 WORKBUDDY 服务。静默等待轮询结果即可,
|
||||
// 轮询成功自动落库并回调返回,超时由 pollWorkBuddyToken 置 error。
|
||||
if (_uiState.value.service == ServiceType.WORKBUDDY) return
|
||||
if (looksLikeLoggedInPage(url)) {
|
||||
saveSession(url)
|
||||
} else {
|
||||
@ -53,6 +393,17 @@ class WebViewLoginViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
fun confirmLoginManually() {
|
||||
val service = _uiState.value.service ?: return
|
||||
// 防御性守卫:Trae/WorkBuddy/OpenCode 均通过各自的自动识别(回调/轮询/URL 正则)
|
||||
// 完成登录,不应用 cookie 抓取兜底覆盖凭据;未来若有新入口误将 pendingManualConfirm
|
||||
// 置位,这里直接短路,避免把错误的 SessionCredential 写入这些服务。
|
||||
if (service == ServiceType.TRAE ||
|
||||
service == ServiceType.WORKBUDDY ||
|
||||
service == ServiceType.OPENCODE_GO
|
||||
) {
|
||||
dismissManualPrompt()
|
||||
return
|
||||
}
|
||||
val url = _uiState.value.loginUrl
|
||||
saveSession(url)
|
||||
}
|
||||
@ -61,40 +412,138 @@ class WebViewLoginViewModel @Inject constructor(
|
||||
_uiState.update { it.copy(pendingManualConfirm = false) }
|
||||
}
|
||||
|
||||
fun onLocalStorageProbed(values: Map<String, String>) {
|
||||
val service = _uiState.value.service ?: return
|
||||
if (_uiState.value.loginSucceeded) return
|
||||
if (!isValidProbe(values[SUB2_NONCE_KEY])) return
|
||||
if (service != ServiceType.SUB2API) return
|
||||
val token = values[SUB2_AUTH_TOKEN_KEY].orEmpty()
|
||||
if (token.isBlank()) return
|
||||
val origin = values[SUB2_ORIGIN_KEY].orEmpty()
|
||||
if (!isTrustedOrigin(origin)) return
|
||||
viewModelScope.launch {
|
||||
val saved = sessionSaver.saveSub2ApiPanelSession(
|
||||
baseUrl = origin.ifBlank { _uiState.value.loginUrl },
|
||||
authToken = token,
|
||||
siteIcon = values[SUB2_ICON_KEY],
|
||||
email = values[SUB2_EMAIL_KEY],
|
||||
accountId = this@WebViewLoginViewModel.accountId
|
||||
)
|
||||
if (saved != null) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
loginSucceeded = true,
|
||||
pendingManualConfirm = false,
|
||||
error = null,
|
||||
// 回填实际写入的账号 ID:新建账号流程返回编辑页时保持编辑该账号,
|
||||
// 避免用户再次点保存产生重复账号
|
||||
loginAccountId = saved.accountId.ifBlank { null }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isTrustedOrigin(origin: String): Boolean {
|
||||
val probeHost = origin.toHttpUrlOrNull()?.host ?: return false
|
||||
val allowed = _uiState.value.trustedHosts
|
||||
if (allowed.isEmpty()) return false
|
||||
return allowed.any { it.equals(probeHost, ignoreCase = true) }
|
||||
}
|
||||
|
||||
private val probeNonce: String = java.util.UUID.randomUUID().toString().replace("-", "")
|
||||
val probeScript: String get() = buildProbeScript(probeNonce)
|
||||
fun isValidProbe(nonce: String?): Boolean =
|
||||
nonce != null && nonce.isNotEmpty() && nonce == probeNonce
|
||||
|
||||
private fun saveSession(url: String) {
|
||||
val service = _uiState.value.service ?: return
|
||||
val saved = sessionSaver.saveFromCookieManager(service = service, url = url)
|
||||
if (saved != null) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
loginSucceeded = true,
|
||||
savedSession = saved,
|
||||
pendingManualConfirm = false
|
||||
)
|
||||
}
|
||||
} else {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
error = "未抓到 Cookie,请确认已登录",
|
||||
pendingManualConfirm = true
|
||||
)
|
||||
viewModelScope.launch {
|
||||
val saved = sessionSaver.saveFromCookieManager(service = service, url = url)
|
||||
if (saved != null) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
loginSucceeded = true,
|
||||
savedSession = saved,
|
||||
pendingManualConfirm = false
|
||||
)
|
||||
}
|
||||
} else {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
error = UiText.Resource(R.string.error_cookie_not_found),
|
||||
pendingManualConfirm = true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun looksLikeLoggedInPage(url: String): Boolean {
|
||||
val u = url.lowercase()
|
||||
// 简单启发:URL 不再包含登录路径关键字
|
||||
val loginKeywords = listOf("/auth", "/login", "/signin", "/oauth")
|
||||
return loginKeywords.none { u.contains(it) }
|
||||
}
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
private fun randomHex(length: Int): String {
|
||||
val bytes = ByteArray(length / 2)
|
||||
java.security.SecureRandom().nextBytes(bytes)
|
||||
return bytes.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
private fun randomDigits(length: Int): String {
|
||||
val sb = StringBuilder(length)
|
||||
val rnd = java.security.SecureRandom()
|
||||
repeat(length) { sb.append(rnd.nextInt(10)) }
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
workBuddyPollingJob?.cancel()
|
||||
super.onCleared()
|
||||
}
|
||||
}
|
||||
|
||||
private const val SUB2_AUTH_TOKEN_KEY = "auth_token"
|
||||
private const val SUB2_ORIGIN_KEY = "origin"
|
||||
private const val SUB2_ICON_KEY = "site_icon"
|
||||
private const val SUB2_EMAIL_KEY = "email"
|
||||
private const val SUB2_NONCE_KEY = "nonce"
|
||||
|
||||
/** OpenCode dashboard URL 中的 workspaceId:`/workspace/{id}/go`。 */
|
||||
private val OPENCODE_WORKSPACE_REGEX = Regex("""/workspace/([^/?#]+)/go""")
|
||||
|
||||
internal fun buildProbeScript(nonce: String): String = """
|
||||
(function () {
|
||||
try {
|
||||
var out = {};
|
||||
var t = window.localStorage.getItem('auth_token');
|
||||
if (t) out.auth_token = t;
|
||||
var u = window.localStorage.getItem('auth_user');
|
||||
if (u) {
|
||||
try { out.email = (JSON.parse(u) || {}).email || ''; } catch (e) {}
|
||||
}
|
||||
var icon = document.querySelector('link[rel~="icon"]');
|
||||
if (icon && icon.href) out.site_icon = icon.href;
|
||||
out.origin = window.location.origin;
|
||||
out.nonce = '$nonce';
|
||||
if (window.AndroidBridge && window.AndroidBridge.onProbe) {
|
||||
window.AndroidBridge.onProbe(JSON.stringify(out));
|
||||
}
|
||||
} catch (e) {}
|
||||
})();
|
||||
"""
|
||||
|
||||
data class WebViewLoginUiState(
|
||||
val service: ServiceType? = null,
|
||||
val loginUrl: String = "",
|
||||
val trustedHosts: Set<String> = emptySet(),
|
||||
val loginSucceeded: Boolean = false,
|
||||
val savedSession: Credential.SessionCredential? = null,
|
||||
val pendingManualConfirm: Boolean = false,
|
||||
val error: String? = null
|
||||
)
|
||||
val error: UiText? = null,
|
||||
/** Trae/WorkBuddy 登录实际写入的账号 ID(新建账号模式时用于回填信用编辑页)。 */
|
||||
val loginAccountId: String? = null
|
||||
)
|
||||
|
||||
@ -0,0 +1,118 @@
|
||||
package com.rainy.token.ui.webview
|
||||
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import com.rainy.token.R
|
||||
|
||||
/**
|
||||
* 通用 WebView 页面容器。用于在 App 内打开外部操作页(如 DeepSeek 充值页)。
|
||||
*
|
||||
* - 复用 [WebViewLoginScreen] 的 WebView 模式:JS/DOM 存储开启,适配移动视口;
|
||||
* - 页面加载后用页面标题替换顶栏标题(失败则保持传入标题);
|
||||
* - 返回键优先回退页内历史,无历史时退出页面。
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun WebViewPageScreen(
|
||||
url: String,
|
||||
title: String,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
var webView: WebView? = remember { null }
|
||||
var pageTitle by remember { mutableStateOf(title) }
|
||||
|
||||
// 顶栏返回与系统返回保持一致:优先回退页内历史,无历史才退出
|
||||
val goBack: () -> Unit = {
|
||||
if (webView?.canGoBack() == true) {
|
||||
webView?.goBack()
|
||||
} else {
|
||||
onBack()
|
||||
}
|
||||
}
|
||||
BackHandler(onBack = goBack)
|
||||
|
||||
// 页面销毁时释放 WebView 原生资源,避免内存泄漏;先移除父级再 destroy,规避
|
||||
// "Calling destroy while still attached" 的极端时序崩溃
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
webView?.let { view ->
|
||||
view.parent?.let { (it as? ViewGroup)?.removeView(view) }
|
||||
view.destroy()
|
||||
}
|
||||
webView = null
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(pageTitle) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = goBack) {
|
||||
Icon(
|
||||
Icons.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.action_back)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
) {
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
WebView(context).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
settings.useWideViewPort = true
|
||||
settings.loadWithOverviewMode = true
|
||||
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
// 用网页真实标题替换占位标题(忽略空白标题)
|
||||
view?.title?.takeIf { it.isNotBlank() }?.let { loaded ->
|
||||
if (loaded != pageTitle) pageTitle = loaded
|
||||
}
|
||||
}
|
||||
}
|
||||
webChromeClient = WebChromeClient()
|
||||
webView = this
|
||||
loadUrl(url)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user