Compare commits
226 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5488e3dc06 | |||
| 653ae1462a | |||
| 218278f14e | |||
| a858c4bce0 | |||
| f35bc6d3fc | |||
| 6cede91ec6 | |||
| 44057f3dc8 | |||
| 83dbf9e6ad | |||
| 18404d63e7 | |||
| f10d333cbf | |||
| 1ad89763ad | |||
| cfbbc371c3 | |||
| 2f9ce6ec82 | |||
| b0a85265c2 | |||
| f85b319083 | |||
| df357c1eeb | |||
| 38298ab944 | |||
| 5b7a4df868 | |||
| 029114b72d | |||
| f27eb7a8db | |||
| 75ccd2fe8e | |||
| a31dad6e9f | |||
| 027eb6185d | |||
| 618eecf676 | |||
| 8207285b23 | |||
| f917e5bfd6 | |||
| 82bd04597d | |||
| 5850fc452c | |||
| 809b8db2d3 | |||
| c6a75f8fce | |||
| a384309689 | |||
| 1dd72986f5 | |||
| 525b91f370 | |||
| 8692014340 | |||
| 15c6bc24c4 | |||
| dab9b7b272 | |||
| 460bed7279 | |||
| 2a7e06acf3 | |||
| 5a12f10d4e | |||
| ecbc86fdf2 | |||
| 94bb4de0b4 | |||
| 6dfb9ecb71 | |||
| 05b5107f46 | |||
| 8e22d1ab9f | |||
| 8062a54252 | |||
| bccd7bad83 | |||
| 0ca6ccd384 | |||
| cf6f426a36 | |||
| c5120c5952 | |||
| 4ae2aa01b6 | |||
| deea17bf6e | |||
| 2c5a3b2055 | |||
| df84aa94ab | |||
| 68cdbcf0b0 | |||
| c76193e892 | |||
| 101e2bc240 | |||
| 9db3e01d49 | |||
| 6edd68f80e | |||
| 57bd49f470 | |||
| 56969cb879 | |||
| 750b9d9f90 | |||
| b62dc90c40 | |||
| d5a152ef1e | |||
| 0f4d68033e | |||
| 62304ecc9b | |||
| 7574c3c7fb | |||
| 7c7a23f097 | |||
| b94e2f6994 | |||
| 46b4d457b8 | |||
| 7c3809e879 | |||
| 3c2b2ec16e | |||
| d6e4d34a42 | |||
| 7ad49e994d | |||
| e914633797 | |||
| eb4451c4de | |||
| 8f40360b9f | |||
| 1a96bcb105 | |||
| fee433d2fc | |||
| 597632dad3 | |||
| 08a3b42a82 | |||
| 73ff6b2ee7 | |||
| f13b38c02b | |||
| aab50bbe78 | |||
| 0283ce6bdc | |||
| 0241840f9c | |||
| faf7f40524 | |||
| 8a9d9432fe | |||
| fbbca4a03b | |||
| 342788056b | |||
| 7de7111b18 | |||
| dcd5cab2b2 | |||
| b479cb2a7a | |||
| a804d46acd | |||
| ada750df7e | |||
| 6e0d67afb7 | |||
| b5ef9249d8 | |||
| 481c57c535 | |||
| 8d68c3b280 | |||
| 571b270cb6 | |||
| fa796d57b5 | |||
| 71f3f8d554 | |||
| 8e692a0c6e | |||
| 4597fb0f8e | |||
| dfd91a5355 | |||
| 40af5db889 | |||
| 5adf053b6f | |||
| 149fc5f125 | |||
| e2bf924246 | |||
| d0b79cf1bd | |||
| 95cde916ca | |||
| 15408a6640 | |||
| 8da1f2b377 | |||
| a4d854271f | |||
| 67e1f060c5 | |||
| f5162bbf74 | |||
| de95013164 | |||
| 072f7efa55 | |||
| 1609e210bd | |||
| a1605e6993 | |||
| 5aa2d8f116 | |||
| 11b9065f82 | |||
| 85c6cf4283 | |||
| 9d3c004617 | |||
| 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 |
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
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@ -7,6 +7,7 @@
|
||||
/.idea/workspace.xml
|
||||
/.idea/navEditor.xml
|
||||
/.idea/assetWizardSettings.xml
|
||||
/.idea/
|
||||
.DS_Store
|
||||
/build
|
||||
app/build/
|
||||
@ -20,3 +21,9 @@ release.jks
|
||||
.kotlin/
|
||||
*.jks
|
||||
tmp_cc/
|
||||
goal.md
|
||||
/reference/
|
||||
|
||||
# 调试临时文件(可能包含真实接口响应/账号数据,禁止提交)
|
||||
_tmp_*
|
||||
build_compile.log
|
||||
|
||||
213
AGENTS.md
213
AGENTS.md
@ -1,212 +1,5 @@
|
||||
# 雨晴Token — AI 余额查询 APP
|
||||
任务开始前必须先读取 taste.md(协作偏好),任务全程遵守其中约定,包括:修改后派发独立 subagent 审计(无阻断问题 + 无影响用户体验问题),修复后用同一个 subagent 复审。
|
||||
|
||||
## 项目概述
|
||||
未经用户允许禁止提交推送
|
||||
|
||||
Android(Jetpack Compose + Kotlin)APP,统一查询 DeepSeek、OpenCode Go、CommandCode Go、Codex / ChatGPT Plus、Ollama Pro 的余额/配额。
|
||||
DeepSeek 走 REST API,OpenCode Go 通过 OkHttp 抓取 dashboard HTML 解析 SSR hydration 数据。
|
||||
CommandCode Go 走 JSON API 抓取用量数据,Codex / ChatGPT Plus 通过 auth.json 刷新 token 后查询 wham 用量。Ollama Pro 通过 Cookie 抓取 settings 页 HTML 解析用量百分比。APP 名为「雨晴Token」(粉色调品牌),配套桌面小组件。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Kotlin 100% · Jetpack Compose + Material 3
|
||||
- MVVM + Repository + `RefreshBalanceUseCase`(单一 UseCase,按 ServiceType 分发)
|
||||
- Hilt + KSP(DI)
|
||||
- Retrofit 2 + OkHttp 4 + Kotlinx Serialization
|
||||
- Room(用量数据,indexed on workspaceId+timeCreated)+ DataStore(余额缓存/图表偏好)+ Android Keystore(凭据加密,AES-256 GCM)
|
||||
- `minSdk=31`(Android 12+)
|
||||
- `material3-window-size-class` — 平板自适应布局
|
||||
- WorkManager(计划中)
|
||||
|
||||
## 当前实现状态
|
||||
|
||||
**服务**:
|
||||
- ✅ DeepSeek — REST API `GET /user/balance`,API Key 认证
|
||||
- ✅ OpenCode Go — OkHttp 抓 dashboard HTML,解析 `rollingUsage`/`weeklyUsage`/`monthlyUsage`
|
||||
- ✅ CommandCode Go — JSON API 抓取用量数据,`CommandCodeUsageRepository` 解析(workspaceId = `"commandcode"`)
|
||||
- ✅ Codex / ChatGPT Plus — 粘贴完整 auth.json(含 refresh_token),调 `chatgpt.com/backend-api/wham/usage`;token 过期前 60 分钟自动刷新
|
||||
- ✅ Ollama Pro — Cookie 认证,OkHttp 抓 `ollama.com/settings` HTML,正则解析 plan/session(5h)/weekly 百分比 + `data-time` 重置时间 + `data-model` 模型级请求次数;无官方 API(ollama/ollama#12532)
|
||||
- ✅ 文案统一:所有服务标签均使用中文("每周"统一代替 "weekly"/"Weekly"/"weekly")
|
||||
- ❌ OpenCode Zen / 小米 MiMo — 未实现
|
||||
|
||||
**用量统计系统**:
|
||||
- ✅ `UsageCache`(Room,indexed on workspaceId+timeCreated)— DAO 查询替代全量 JSON 序列化;首次启动自动从旧 DataStore JSON 迁移
|
||||
- ✅ `SyncUsageUseCase`(OCGO)/ `SyncCommandCodeUsageUseCase`(CCGO) — 首次全量同步(cursor 翻页)、增量同步(逐页比对本地 ID 集合)
|
||||
- ✅ `UsageViewModel` — `loadStatsInternal()` 单次 `getRecords()`→ 内存聚合 Overview/ModelStats/DailyStats,所有重操作包在 `withContext(Dispatchers.Default)` 避免主线程卡顿
|
||||
- ✅ `UsageChartViewModel` — 图表粒度(5h/**12h(10min)**/24h/今天/昨天/7天/当月/自定义日/月/范围),模型多选,3 张 Canvas 图表;支持 **UTC+0/UTC+8 时区切换**(桶边界+标签双感知);自定义日/月/范围保存 `LocalDate` 语义,切换 UTC 偏好时重新计算边界;**自动降级**(5h无数据→12h→7天→当月)
|
||||
- ✅ `ChartSettingsStore`(DataStore Preferences + StateFlow)— 持久化 UTC 偏好,`useUtc8Flow` 异步读取(已移除 runBlocking)
|
||||
- ✅ `UsageDataViewModel` — 原始记录分页浏览(20条/页),支持时间+模型筛选,页码输入跳转
|
||||
- ✅ 全局刷新绑定 — Dashboard 下拉刷新 → `DashboardViewModel.refresh()` → `UsageViewModel.sync()`(增量)
|
||||
|
||||
**凭据回显红线**:
|
||||
|
||||
> ⚠️ `CredentialEditViewModel.load()` 首次加载已有凭据时,需针对每种 `Credential` 子类显式编写回显分支。
|
||||
> 当前覆盖:`ApiKeyCredential`(API Key 输入框)、`SessionCredential`(Cookie 输入框 / `ollamaCookie` 字段)、`CodexCredential`(auth.json 输入框)。
|
||||
> 新增凭据类型(如 OpenCode Zen / MiMo)时必须同步添加对应的 `load()` 回显分支,否则用户保存后看不到已存内容。
|
||||
|
||||
**ViewModel 加载机制红线**:
|
||||
|
||||
> ⚠️ 三个 ViewModel 的 `init` 块**已移除**,不再自动加载。数据加载由 Composable 层的 `LaunchedEffect(Unit)` 显式触发:
|
||||
> - OCGO 页面:`LaunchedEffect(Unit) { viewModel.load() / loadStats() / loadData() }`
|
||||
> - CCGO 页面:`LaunchedEffect(Unit) { viewModel.setWorkspace(wid) }`(`setWorkspace` 内部调 `load()`)
|
||||
> - CCGO 页面通过 `autoLoad = false` 参数跳过 Screen 内的 `LaunchedEffect` 重复 load
|
||||
>
|
||||
> 原因:`init` 自动加载时 `workspaceIdOverride` 为 null,协程读到 OCGO 凭据,导致 CCGO 页面闪现 OCGO 数据。
|
||||
|
||||
**hiltViewModel key 红线**:
|
||||
|
||||
> ⚠️ `hiltViewModel(key = key)` 的 key 在 ViewModelStore 内全局唯一、不区分类型。
|
||||
> CCGO 路由中 `UsageChartViewModel` 和 `UsageViewModel` 用相同 key 会导致类型碰撞、加载失败。
|
||||
> 当前方案:`chartVm` 用 `"ccgo_chart_$wid"`,`usageVm` 用 `"ccgo_$wid"`(与 Dashboard 首页 `CommandCodeUsageStatsCard` 共享实例)。
|
||||
|
||||
**首页布局**:
|
||||
|
||||
> Dashboard 使用 `PullToRefreshBox` → `Column` + `verticalScroll`(非 `LazyColumn`)。
|
||||
> 页面仅 8 个 item,`LazyColumn` 的 dispose/recompose 会导致用量卡片的 `LaunchedEffect` 反复触发,产生卡顿。
|
||||
>
|
||||
> **自适应断点**:容器宽度 > 600dp 时卡片双列(`BoxWithConstraints`),≤600dp 时单列。
|
||||
> OCGO / CCGO 服务余额卡底部均提供「查看用量详情」入口,未配置凭据时不显示。
|
||||
>
|
||||
> **长按拖拽排序**:基于 Compose 原生 `detectDragGesturesAfterLongPress`,不引入第三方库。
|
||||
> 核心设计:拖动中不修改真实布局顺序(手势节点不动),其它卡片用 `displacementFor()` 计算的 `offset` 做视觉让位。
|
||||
> 换位判断采用"拖拽卡片中心点命中目标格"模型——累计手指偏移计算拖拽中心在窗口的坐标,
|
||||
> 与冻结的格子中心表 `itemCenterById` 比对,进入目标格激活区域(卡片短边的 45%)后才切换 `dragTargetIndex`。
|
||||
> 自动滚动时对浮动卡片和格子中心表同步做 `y - consumed` 补偿,防止飞走。
|
||||
> 松手后通过 `settleDraggedItem()` 落位并持久化到 SharedPreferences(`dashboard_card_order`)。
|
||||
>
|
||||
> 关键状态拆分:
|
||||
> - `visualDragOffsetX/Y` —— 视觉跟手偏移,持续累计
|
||||
> - `dragStartCenterX/YInWindow` —— 拖动开始时卡片中心在窗口位置,作为偏移基准
|
||||
> - `dragFromIndex` / `dragTargetIndex` —— 真实 index 和目标 index,不触发重组
|
||||
> - `itemCenterById` —— 拖动中冻结的格子中心坐标表;使用普通 `HashMap`,不要改回 Compose StateMap(滚动/返回动画期间 `onGloballyPositioned` 高频写入会触发重组卡顿)
|
||||
> - `displayOrder` —— 真实布局顺序,拖动中不改
|
||||
> - `cardOrder`(外层 SharedPreferences)—— 持久化的用户偏好顺序
|
||||
|
||||
**图表自适应**:
|
||||
|
||||
> `UsageDetailScreen` / `UsageChartScreen` 通过 `BoxWithConstraints` 判断容器宽度 > 700dp 时图表并排(前两张 50/50,第三张独占一行),反之纵向堆叠。阈值 700dp 高于 Dashboard 的 600dp,因为图表卡片需要更多空间。
|
||||
|
||||
**页面导航**:
|
||||
```
|
||||
Compact(手机):
|
||||
Dashboard → OCGO: UsageDetail(图表) → UsageOverview(总统计)
|
||||
↘ UsageData(原始数据)
|
||||
→ CCGO: CCGO_USAGE_DETAIL(图表) → CCGO_USAGE_OVERVIEW(总统计)
|
||||
↘ CCGO_USAGE_DATA(原始数据)
|
||||
→ Settings → Tips(使用小技巧)
|
||||
|
||||
返回用 guardedPop()(200ms 时间戳围栏,PopGuard 非 State 对象)+ Android predictive back。
|
||||
Manifest 开启 `android:enableOnBackInvokedCallback="true"`;`navigation-compose` 保持 2.9.x 以上,使用后续 predictive back 修复。
|
||||
Compact 根 `NavHost` 必须显式配置 `enterTransition` / `exitTransition` / `popEnterTransition` / `popExitTransition` 四项:
|
||||
前进统一左滑,返回统一右滑,避免只配置 pop 时短时间返回混入默认淡入淡出。
|
||||
当前页面背景/渐变层不适合 `scaleOut` 或长透明淡出类返回动画,容易出现透明背景和文字叠影;若要改动画,先处理 destination 的不透明背景层。
|
||||
PopGuard 额外检查 previousBackStackEntry != null,且 popBackStack() 返回 false 时 reset 围栏。
|
||||
|
||||
Expanded(平板,≥840dp):
|
||||
┌─ 左侧 35%: Dashboard(固定) ─┐ ┌─ 右侧 65%: when(pane) 原子切换 ─────┐
|
||||
│ │ │ ServiceDetail / OCGOUsage / CCGOUsage │
|
||||
│ │ │ Settings(内嵌 NavHost → Tips) │
|
||||
└────────────────────────────────┘ └────────────────────────────────────────┘
|
||||
右侧用量详情内部子路由:图表 → 总览 / 原始数据(OCGO/CCGO/Settings 各自用局部 NavHost)
|
||||
面板切换用 when(pane) 分支(同一帧原子重组,零穿透),子路由由局部 NavHost 的 popBackStack() 内置防护。
|
||||
```
|
||||
|
||||
**桌面小组件(Widget)**:
|
||||
- 显示当前选中服务的用量+DeepSeek 余额
|
||||
- 支持四服务切换:OCGO / CCGO / Codex / Ollama(右上角 ↻ 按钮旁的切换按钮循环切换)
|
||||
- 右上角 ↻ 手动刷新按钮(后台广播 → `WidgetRefreshReceiver` → EntryPoints 获取 `RefreshBalanceUseCase`)
|
||||
- 刷新逻辑:只刷新当前选中服务 + DeepSeek,不再串行刷全部服务;25s 超时保护;`isRefreshing` 互斥锁防连续点击
|
||||
- 点击刷新后立即更新 Widget 右上角时间为"刷新中..."(`showRefreshing()`),再后台请求网络
|
||||
- 进度条颜色按百分比动态变化(<50% 草莓粉 / 50-80% 暖橙 / >80% 玫红)
|
||||
- **MIUI Widget 适配**:`miuiWidget` 标识 → 可拖入负一屏;`miui.appwidget.action.APPWIDGET_UPDATE` 曝光刷新(划到即触发,20s 冷却);`@android:id/background` 根布局 ID(系统统一裁切圆角)
|
||||
- **自动刷新**:`onUpdate()` 内缓存为空或超过 5 分钟冷却时自动发送 `WidgetRefreshReceiver` 广播
|
||||
- **一键添桌面**:Dashboard 顶部栏 + 按钮 → 二次确认弹窗 → 权限检测(Manifest `INSTALL_SHORTCUT` + MIUI AppOps `android:install_shortcut`)→ `requestPinAppWidget`(有 fallback 到 `ACTION_APPWIDGET_PICK`)
|
||||
- 服务切换状态持久化到 SharedPreferences(`widget_auto_refresh` 中的 `display_service` key),切换后立即调用 `notifyDataChanged` 触发 `onUpdate()` 渲染
|
||||
|
||||
**小组件点击绑定**:
|
||||
|
||||
> `widget_wordmark` + `widget_open_hint`(`›` 箭头)→ 打开 APP;`widget_content` / `widget_switch` / `widget_service_title` → 切换服务广播 `ACTION_SWITCH_SERVICE`;`widget_refresh` → 刷新广播。
|
||||
> 切换 requestCode=2,刷新 requestCode=1。左上角 `›` 箭头是可点击进 APP 的视觉提示。
|
||||
|
||||
**使用小技巧系统**:
|
||||
|
||||
> - `AppTips`(`ui/components/AppTips.kt`)集中管理 13 条技巧,每条含 `title` / `hint`(一句话)/ `detail`(详细说明)
|
||||
> - Dashboard 卡片上方每次启动随机显示一条 `hint`(`remember { AppTips.randomHint() }`,不自动轮换)
|
||||
> - 设置页 `💡 使用小技巧` 卡片 → `TipsScreen` 独立页面(Route `tips`),LazyColumn 逐条展示
|
||||
> - 首次进入 Dashboard 显示一次性"长按卡片可拖拽排序"提示横幅,SharedPreferences `dashboard_ui_hints` 的 `drag_hint_shown` 标记
|
||||
|
||||
**图表默认 tooltip**:
|
||||
|
||||
> `StackedBarChart` / `LineChart` 的 `tooltipBucket` 初始值为 `buckets.lastOrNull()`,进入图表页即可看到最新时段数值详情,暗示图表可交互。
|
||||
|
||||
## 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) (retryOnTransientError: Network/5xx 指数退避重试 2 次)
|
||||
→ DeepSeekRepository.fetchBalance() / OpenCodeGoRepository.fetchBalance()
|
||||
→ CommandCodeGoRepository.fetchBalance() / CodexRepository.fetchBalance()
|
||||
→ OllamaRepository.fetchBalance()
|
||||
→ BalanceCache.put(service, result)
|
||||
→ OpenCodeGoWidgetProvider.notifyDataChanged(context)
|
||||
|
||||
Dashboard 下拉刷新 → usageSyncTrigger++ → UsageViewModel.sync()
|
||||
→ OCGO: SyncUsageUseCase.fullSync() / incrementalSync()
|
||||
→ OpenCodeUsageRepository.fetchPage(cursor) 逐页抓取
|
||||
→ UsageCache.insertAll() → Room DAO insert(IGNORE 策略,去重)
|
||||
→ CCGO: SyncCommandCodeUsageUseCase.fullSync() / incrementalSync()
|
||||
→ CommandCodeUsageRepository.fetchPage(cursor) 逐页抓取
|
||||
→ UsageViewModel.loadStats() → getRecords() → 内存聚合
|
||||
|
||||
CCGO 清除按钮(详情页顶栏):
|
||||
点击 → AlertDialog 警告弹窗 → 3s 倒计时确认
|
||||
→ UsageViewModel.clearAndResync()
|
||||
→ UsageCache.deleteByWorkspaceId("commandcode")
|
||||
→ SyncCommandCodeUsageUseCase.fullSync()
|
||||
→ loadStats() → onBack()
|
||||
|
||||
Widget 刷新按钮:
|
||||
↻ → PendingIntent.getBroadcast() → WidgetRefreshReceiver
|
||||
→ EntryPoints → RefreshBalanceUseCase(selectedService + DEEPSEEK)
|
||||
→ 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>
|
||||
68
README.md
68
README.md
@ -1,11 +1,12 @@
|
||||
# 🌧️ 雨晴Token (RainyToken)
|
||||
|
||||
> *"AI 用量,尽在掌握 — AI Balance & Usage at a Glance"* 🐱✨
|
||||
> *"AI 用量,尽在掌握 — AI Balance & Usage at a Glance"*
|
||||
|
||||
[](https://github.com/CATMIAOZHI/Rainytoken/actions/workflows/ci.yml)
|
||||
[](https://github.com/CATMIAOZHI/Rainytoken/actions)
|
||||
[](https://github.com/CATMIAOZHI/Rainytoken/releases)
|
||||
Android AI 余额与用量查询 APP —— 统一查看 DeepSeek、OpenCode Go、CommandCode Go、Codex / ChatGPT Plus、Ollama Pro 的余额与用量配额。粉色调品牌 UI,配套桌面小组件。
|
||||
🐱 雨晴Token — AI Balance & Usage Quota Query | the Rainy Family tools.
|
||||
Android AI 余额与用量查询 APP —— 统一查看 DeepSeek、OpenCode Go、CommandCode、Codex / ChatGPT、Ollama 的余额与用量配额。粉色调品牌 UI,配套桌面小组件。
|
||||
RainyToken(雨晴Token)— AI Balance & Usage Quota Query · the Rainy Family tools.
|
||||
|
||||
---
|
||||
|
||||
@ -15,11 +16,28 @@ Android AI 余额与用量查询 APP —— 统一查看 DeepSeek、OpenCode Go
|
||||
<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/widget.jpg" width="200" alt="桌面小组件" />
|
||||
<img src="docs/screenshots/ollama-detail.jpg" width="200" alt="Ollama 模型调用次数" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<em>仪表盘(亮色) · 仪表盘(深色) · 用量图表 · 桌面小组件</em>
|
||||
<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>
|
||||
|
||||
---
|
||||
@ -30,17 +48,23 @@ Android AI 余额与用量查询 APP —— 统一查看 DeepSeek、OpenCode Go
|
||||
|------|------|
|
||||
| 📊 **仪表盘** | 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小时 / 12小时(10分钟桶) / 24小时 / 今天 / 昨天 / 最近7天 / 当月 / 自定义日·月·范围 |
|
||||
| 🔍 **多粒度筛选** | 5小时 / 12小时(10分钟桶) / 24小时 / 今天 / 昨天 / 最近7天 / 最近30天 / 当月 / 自定义日·月·范围 |
|
||||
| 🏷️ **模型筛选** | 多选 / 单选 / 全选,动态图例自适应换行 |
|
||||
| 📱 **桌面小组件** | 不打开 APP 也能看用量;支持四服务切换(OCGO/CCGO/Codex/Ollama)+ DeepSeek 余额;左上角 › 进 APP、其他区域点切换、↻ 刷新;可拖入负一屏;划到即自动刷新(MIUI 曝光刷新) |
|
||||
| 📱 **桌面小组件** | 不打开 APP 也能看用量;支持四服务切换(OCGO/CCGO/Codex/Ollama)+ DeepSeek 余额;左上角 > 进 APP、其他区域点切换、↻ 刷新;可拖入负一屏;划到即自动刷新(MIUI 曝光刷新) |
|
||||
| 🔄 **自动同步** | 首页下拉自动同步用量;无缓存时启动自动全量同步;CCGO 详情页支持手动清除并重新同步 |
|
||||
| 🌙 **深色模式** | 全局自适应 — App 内文字/图标/背景自动切换,小组件独立适配暗色布局 |
|
||||
| ➕ **一键添桌面** | APP 内点 + 直接添加小组件,不用去系统列表翻;二次确认 + 权限检测 |
|
||||
| 💡 **使用小技巧** | 首页随机展示一条操作提示(每次启动刷新);设置页可查看全部 13 条隐藏操作技巧 |
|
||||
| ⚡ **Room 数据库** | 用量记录存 Room(indexed on workspaceId+timeCreated),DAO 查询替代全量 JSON 序列化;首次启动自动从旧 DataStore 迁移 |
|
||||
| 🎀 **雨晴粉主题** | Material Design 3 · 草莓粉 #FF85A2 · 樱粉 #FFD1DC |
|
||||
| 🎀 **雨晴粉主题** | 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 名称与小组件跟随语言 |
|
||||
|
||||
---
|
||||
|
||||
@ -48,7 +72,7 @@ Android AI 余额与用量查询 APP —— 统一查看 DeepSeek、OpenCode Go
|
||||
|
||||
前往 [Releases](https://github.com/CATMIAOZHI/Rainytoken/releases) 下载最新 APK。
|
||||
|
||||
> ⚠️ 需要配置 DeepSeek API Key、OpenCode Go 登录凭据、CommandCode Go API Key、Codex auth.json 或 Ollama Pro Cookie 才能拉取数据。
|
||||
> ⚠️ 需要配置 DeepSeek API Key、OpenCode Go 登录凭据、CommandCode API Key、Codex(OAuth 登录或粘贴 auth.json)或 Ollama Cookie 才能拉取数据。
|
||||
|
||||
---
|
||||
|
||||
@ -60,7 +84,8 @@ Android AI 余额与用量查询 APP —— 统一查看 DeepSeek、OpenCode Go
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ Compose UI(3 层页面) │ │
|
||||
│ │ 仪表盘 · 用量图表 · 总统计 · 详细数据 · 设置│ │
|
||||
│ │ 仪表盘 · 用量图表 · 总统计 │ │
|
||||
│ │ 详细数据 · Token活动 · 设置 │ │
|
||||
│ └────────────────────┬────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌────────────────────▼─────────────────────────┐ │
|
||||
@ -108,6 +133,10 @@ Rainytoken/
|
||||
│ │ └── usecase/ # RefreshBalanceUseCase / SyncUsageUseCase / SyncCommandCodeUsageUseCase
|
||||
│ ├── ui/
|
||||
│ │ ├── 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)
|
||||
@ -129,6 +158,9 @@ Rainytoken/
|
||||
|
||||
### 方式二:命令行
|
||||
```bash
|
||||
# 运行单元测试
|
||||
./gradlew testDebugUnitTest
|
||||
|
||||
# 构建 Debug APK
|
||||
./gradlew assembleDebug
|
||||
# APK 输出:app/build/outputs/apk/debug/app-debug.apk
|
||||
@ -146,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>
|
||||
|
||||
@ -173,22 +211,22 @@ chmod +x ./setup_android_env.sh
|
||||
|
||||
- ✅ API Key / Session 凭据存入 **Android Keystore**(AES-256 GCM 加密)
|
||||
- ✅ 网络请求仅向 DeepSeek / OpenCode 官方 API 发出
|
||||
- ✅ API Key / Session 由 Android Keystore 加密;应用数据备份规则由 `data_extraction_rules.xml` / `backup_rules.xml` 控制
|
||||
- ✅ `allowBackup=false`:凭据密文、用量数据均不参与系统备份,避免换机恢复后密文无法解密
|
||||
- ✅ 签名密钥固定,每次 Release 可覆盖安装
|
||||
- ✅ GitHub Secrets 加密存储签名密钥,CI 中解码使用
|
||||
- ✅ GitHub Secrets 加密存储签名密钥及密码,CI 中解码使用
|
||||
|
||||
---
|
||||
|
||||
## 🐱 关于
|
||||
|
||||
RainyToken(雨晴Token)是「雨晴系列」的第 4 个成员,由 [雨晴喵](https://github.com/CATMIAOZHI) 编纂,随 [雨晴系列](https://github.com/CATMIAOZHI?tab=repositories) 发布:
|
||||
RainyToken(雨晴Token)是「雨晴系列」的第 4 个成员,由 [雨晴喵](https://github.com/CATMIAOZHI) 开发维护,随 [雨晴系列](https://github.com/CATMIAOZHI?tab=repositories) 发布:
|
||||
|
||||
- [RainyLLM](https://github.com/CATMIAOZHI/RainyLLM) — 纯离线 Android 本地 LLM 推理服务器(Gemma + OpenAI 兼容 API)
|
||||
- [RainyScanner](https://github.com/CATMIAOZHI/RainyScanner) — 不拦截不跳转的 Android 扫码工具
|
||||
- [Rainy2FA](https://github.com/CATMIAOZHI/Rainy2FA) — 纯本地 · 零联网 · 生物识别保护的 TOTP 验证器
|
||||
- **RainyToken** — AI 余额与用量查询 APP | AI Balance & Usage Quota Query(本项目)
|
||||
|
||||
> 🎀 Made with love by 雨晴喵 — 守护主人的每一分算力预算 💖
|
||||
> 守护每一分 AI 算力预算 💖
|
||||
|
||||
---
|
||||
|
||||
@ -198,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 = 49
|
||||
versionName = "1.7.29"
|
||||
|
||||
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)
|
||||
@ -97,6 +214,12 @@ dependencies {
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.retrofit.kotlinx.serialization.converter)
|
||||
|
||||
// Ktor 本地反代 HTTP 服务
|
||||
implementation(libs.ktor.server.core)
|
||||
implementation(libs.ktor.server.cio)
|
||||
implementation(libs.ktor.server.content.negotiation)
|
||||
implementation(libs.ktor.serialization.kotlinx.json)
|
||||
|
||||
// DataStore
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
|
||||
|
||||
@ -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,14 +3,14 @@
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<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:allowBackup="false"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
@ -35,6 +35,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"
|
||||
@ -68,6 +77,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,7 @@
|
||||
package com.rainy.token.data.local
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import com.rainy.token.R
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
|
||||
@ -25,18 +27,19 @@ data class ModelBucket(
|
||||
val outputTokens: Long
|
||||
)
|
||||
|
||||
/** 时间粒度 */
|
||||
enum class ChartGranularity(val label: String) {
|
||||
LAST_5H_HOURLY("最近5小时"),
|
||||
LAST_12H_10MIN("最近12小时"),
|
||||
LAST_24H_HOURLY("最近24小时"),
|
||||
TODAY_HOURLY("今天"),
|
||||
YESTERDAY_HOURLY("昨天"),
|
||||
LAST_7D_DAILY("最近7天"),
|
||||
THIS_MONTH_DAILY("当月"),
|
||||
CUSTOM_DAY_HOURLY("自定义日"),
|
||||
CUSTOM_MONTH_DAILY("自定义月"),
|
||||
CUSTOM_RANGE_DAILY("自定义日期")
|
||||
/** 时间粒度(label 用字符串资源 ID,UI 层按当前语言解析) */
|
||||
enum class ChartGranularity(@StringRes val labelRes: Int) {
|
||||
LAST_5H_HOURLY(R.string.time_last_5h),
|
||||
LAST_12H_10MIN(R.string.time_last_12h),
|
||||
LAST_24H_HOURLY(R.string.time_last_24h),
|
||||
TODAY_HOURLY(R.string.time_today),
|
||||
YESTERDAY_HOURLY(R.string.time_yesterday),
|
||||
LAST_7D_DAILY(R.string.time_last_7d),
|
||||
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)
|
||||
}
|
||||
|
||||
/** 聚合工具 */
|
||||
|
||||
@ -62,6 +62,12 @@ class UsageCache(
|
||||
return dao.getAllIds().toSet()
|
||||
}
|
||||
|
||||
/** 按 workspaceId 获取已有记录 ID 集合(增量同步用) */
|
||||
suspend fun getIdsByWorkspace(workspaceId: String): Set<String> {
|
||||
ensureMigrated()
|
||||
return dao.getIdsByWorkspace(workspaceId).toSet()
|
||||
}
|
||||
|
||||
/** 按 workspaceId 删除所有记录。用于修复旧数据格式问题后重新全量同步。 */
|
||||
suspend fun deleteByWorkspaceId(workspaceId: String) {
|
||||
ensureMigrated()
|
||||
|
||||
87
app/src/main/java/com/rainy/token/data/proxy/AccountPool.kt
Normal file
87
app/src/main/java/com/rainy/token/data/proxy/AccountPool.kt
Normal file
@ -0,0 +1,87 @@
|
||||
package com.rainy.token.data.proxy
|
||||
|
||||
import com.rainy.token.data.repository.CredentialRepository
|
||||
import com.rainy.token.domain.model.Credential
|
||||
import com.rainy.token.domain.service.ServiceType
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
/**
|
||||
* 账号池:从 CredentialRepository 实时取号,负责轮询与会话粘性。
|
||||
*
|
||||
* - 多账号服务(Trae / WorkBuddy / Sub2API)按 ProviderKind 分组;
|
||||
* - 会话粘性:同一 conversation 头持续命中同一账号,避免上下文错乱;
|
||||
* - 区域匹配:优先选 credential.region 与路由 region 一致的账号,找不到时退回全部账号。
|
||||
*/
|
||||
@Singleton
|
||||
class AccountPool @Inject constructor(
|
||||
private val credentialRepository: CredentialRepository,
|
||||
) {
|
||||
|
||||
data class PooledAccount(
|
||||
val kind: ProviderKind,
|
||||
val region: ProviderRegion? = null,
|
||||
val accountId: String? = null,
|
||||
val label: String? = null,
|
||||
)
|
||||
|
||||
private val mutex = Mutex()
|
||||
private val sessionSticky = mutableMapOf<String, String>()
|
||||
private val cursor = mutableMapOf<ProviderKind, Int>()
|
||||
|
||||
suspend fun next(
|
||||
kind: ProviderKind,
|
||||
region: ProviderRegion? = null,
|
||||
sessionKey: String? = null,
|
||||
): PooledAccount? = mutex.withLock {
|
||||
val service = serviceFor(kind) ?: return null
|
||||
val accounts = credentialRepository.accountsFor(service)
|
||||
val allIds = accounts.map { it.id }
|
||||
val matchedIds = if (region == null) {
|
||||
allIds
|
||||
} else {
|
||||
allIds.filter { accountId -> regionMatches(service, accountId, region) }
|
||||
.ifEmpty { allIds }
|
||||
}
|
||||
if (matchedIds.isEmpty()) return null
|
||||
|
||||
val accountId = if (sessionKey != null) {
|
||||
sessionSticky[sessionKey]
|
||||
?.takeIf { it in matchedIds }
|
||||
?: pickRoundRobin(kind, matchedIds).also { sessionSticky[sessionKey] = it }
|
||||
} else {
|
||||
pickRoundRobin(kind, matchedIds)
|
||||
}
|
||||
|
||||
PooledAccount(
|
||||
kind = kind,
|
||||
region = region,
|
||||
accountId = accountId,
|
||||
label = accounts.firstOrNull { it.id == accountId }?.label,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun regionMatches(service: ServiceType, accountId: String, region: ProviderRegion): Boolean {
|
||||
val credential = credentialRepository.get(service, accountId) ?: return false
|
||||
return when (credential) {
|
||||
is Credential.TraeCredential -> credential.region.equals(region.name, ignoreCase = true)
|
||||
is Credential.WorkBuddyCredential -> credential.region.equals(region.name, ignoreCase = true)
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
|
||||
private fun pickRoundRobin(kind: ProviderKind, ids: List<String>): String {
|
||||
val idx = cursor[kind] ?: 0
|
||||
cursor[kind] = (idx + 1) % ids.size
|
||||
return ids[idx]
|
||||
}
|
||||
|
||||
private fun serviceFor(kind: ProviderKind): ServiceType? = when (kind) {
|
||||
ProviderKind.WORKBUDDY_CN, ProviderKind.WORKBUDDY_INTL -> ServiceType.WORKBUDDY
|
||||
ProviderKind.TRAE_CN, ProviderKind.TRAE_INTL -> ServiceType.TRAE
|
||||
ProviderKind.OPENAI_COMPATIBLE -> ServiceType.SUB2API
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,244 @@
|
||||
package com.rainy.token.data.proxy
|
||||
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import io.ktor.server.application.Application
|
||||
import io.ktor.server.application.ApplicationCall
|
||||
import io.ktor.server.application.install
|
||||
import io.ktor.server.cio.CIO
|
||||
import io.ktor.server.engine.EmbeddedServer
|
||||
import io.ktor.server.engine.embeddedServer
|
||||
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.server.request.receiveText
|
||||
import io.ktor.server.response.respond
|
||||
import io.ktor.server.response.respondBytes
|
||||
import io.ktor.server.response.respondOutputStream
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.routing.post
|
||||
import io.ktor.server.routing.routing
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* 基于 Ktor CIO 的本地 HTTP 反代服务。
|
||||
*
|
||||
* M1b 能力:
|
||||
* - GET /health
|
||||
* - GET /v1/models
|
||||
* - POST /v1/chat/completions
|
||||
* 按模型前缀路由:wbcn- → WorkBuddy 国内版;traecn- → Trae CN;其余 → Sub2API 透传
|
||||
*
|
||||
* 安全:
|
||||
* - 默认只绑定 127.0.0.1;config.allowLan=true 时绑定 0.0.0.0(热点/局域网共享)
|
||||
* - config.apiKey 非空时,所有 v1 业务路由要求 Bearer Key 一致,否则 401
|
||||
*/
|
||||
@Singleton
|
||||
class KtorLocalProxyServer @Inject constructor(
|
||||
private val sub2ApiChatProxy: Sub2ApiChatProxy,
|
||||
private val workBuddyChatProxy: WorkBuddyChatProxy,
|
||||
private val traeChatProxy: TraeChatProxy,
|
||||
private val providerRouter: ProviderRouter,
|
||||
private val accountPool: AccountPool,
|
||||
) : LocalProxyServer {
|
||||
|
||||
private val lock = Any()
|
||||
|
||||
@Volatile
|
||||
private var server: EmbeddedServer<*, *>? = null
|
||||
|
||||
private val _isRunning = MutableStateFlow(false)
|
||||
override val isRunning: StateFlow<Boolean> = _isRunning.asStateFlow()
|
||||
|
||||
override fun start(config: ProxyServerConfig): Result<Unit> {
|
||||
synchronized(lock) {
|
||||
if (_isRunning.value) return Result.success(Unit)
|
||||
return try {
|
||||
val bindHost = if (config.allowLan) "0.0.0.0" else "127.0.0.1"
|
||||
val engine = embeddedServer(CIO, host = bindHost, port = config.port) {
|
||||
proxyModule(config.apiKey, sub2ApiChatProxy, workBuddyChatProxy, traeChatProxy, providerRouter, accountPool)
|
||||
}
|
||||
engine.start(wait = false)
|
||||
server = engine
|
||||
_isRunning.value = true
|
||||
Result.success(Unit)
|
||||
} catch (e: Throwable) {
|
||||
server = null
|
||||
_isRunning.value = false
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
synchronized(lock) {
|
||||
runCatching { server?.stop(gracePeriodMillis = 500, timeoutMillis = 2000) }
|
||||
server = null
|
||||
_isRunning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun Application.proxyModule(
|
||||
apiKey: String?,
|
||||
sub2Api: Sub2ApiChatProxy,
|
||||
workBuddy: WorkBuddyChatProxy,
|
||||
trae: TraeChatProxy,
|
||||
router: ProviderRouter,
|
||||
pool: AccountPool,
|
||||
) {
|
||||
install(ContentNegotiation) {
|
||||
json()
|
||||
}
|
||||
routing {
|
||||
get("/health") {
|
||||
call.respond(mapOf("status" to "ok"))
|
||||
}
|
||||
get("/v1/models") {
|
||||
if (!authorized(call, apiKey)) {
|
||||
call.respond(HttpStatusCode.Unauthorized, errorBody("未授权"))
|
||||
return@get
|
||||
}
|
||||
val result = sub2Api.forwardModels()
|
||||
if (result == null) {
|
||||
call.respond(HttpStatusCode.BadRequest, errorBody("Sub2API 未配置或未登录,请在设置中填写 API Key"))
|
||||
} else {
|
||||
call.respondBytes(result.body, contentTypeOf(result.contentType), HttpStatusCode(result.status, ""))
|
||||
}
|
||||
}
|
||||
post("/v1/chat/completions") {
|
||||
if (!authorized(call, apiKey)) {
|
||||
call.respond(HttpStatusCode.Unauthorized, errorBody("未授权"))
|
||||
return@post
|
||||
}
|
||||
val rawBody = call.receiveText()
|
||||
if (rawBody.length > MAX_REQUEST_BYTES) {
|
||||
call.respond(HttpStatusCode(413, ""), errorBody("请求体过大"))
|
||||
return@post
|
||||
}
|
||||
val conversationId = call.request.headers["X-Conversation-Id"] ?: extractUser(rawBody)
|
||||
try {
|
||||
if (extractStream(rawBody)) {
|
||||
val stream = router.openStreamingChat(rawBody, conversationId)
|
||||
if (stream == null) {
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
errorBody("所有上游均不可用或未配置,请检查设置")
|
||||
)
|
||||
} else {
|
||||
call.respondOutputStream(
|
||||
contentType = contentTypeOf(stream.contentType),
|
||||
status = HttpStatusCode(stream.status, "")
|
||||
) {
|
||||
try {
|
||||
val buffer = ByteArray(8192)
|
||||
val input = stream.input
|
||||
while (true) {
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
write(buffer, 0, read)
|
||||
flush()
|
||||
}
|
||||
} finally {
|
||||
stream.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val result = router.forwardChat(rawBody, conversationId)
|
||||
if (result == null) {
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
errorBody("所有上游均不可用或未配置,请检查设置")
|
||||
)
|
||||
} else {
|
||||
call.respondBytes(result.body, contentTypeOf(result.contentType), HttpStatusCode(result.status, ""))
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
val detail = e.message ?: "未知错误"
|
||||
call.respond(
|
||||
HttpStatusCode.BadGateway,
|
||||
errorBody("上游网络错误:$detail")
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
if (e is kotlinx.coroutines.CancellationException) throw e
|
||||
val detail = e.message ?: "未知错误"
|
||||
call.respond(
|
||||
HttpStatusCode.InternalServerError,
|
||||
errorBody("网关内部错误:$detail")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun authorized(call: ApplicationCall, apiKey: String?): Boolean {
|
||||
if (apiKey.isNullOrBlank()) return true
|
||||
val header = call.request.headers[HttpHeaders.Authorization] ?: return false
|
||||
val expect = "Bearer $apiKey"
|
||||
return header.trim() == expect
|
||||
}
|
||||
|
||||
private fun errorBody(message: String): Map<String, Any> =
|
||||
mapOf("error" to mapOf("message" to message, "type" to "invalid_request_error"))
|
||||
|
||||
private fun contentTypeOf(raw: String): ContentType =
|
||||
runCatching { ContentType.parse(raw) }.getOrDefault(ContentType.Application.Json)
|
||||
|
||||
private fun extractStream(body: String): Boolean = runCatching {
|
||||
val el = kotlinx.serialization.json.Json.parseToJsonElement(body)
|
||||
(el as? kotlinx.serialization.json.JsonObject)
|
||||
?.get("stream")
|
||||
?.let { if (it is kotlinx.serialization.json.JsonPrimitive) it.contentOrNull?.toBooleanStrictOrNull() else null }
|
||||
}.getOrNull() ?: false
|
||||
|
||||
private fun stripModelPrefix(model: String): String {
|
||||
val m = model.trim()
|
||||
val lower = m.lowercase()
|
||||
val prefix = listOf(
|
||||
"wbcn-", "workbuddy-cn", "codebuddy-",
|
||||
"wbintl-", "workbuddy-intl", "workbuddy-",
|
||||
"traeintl-", "trae-intl", "traecn-", "trae-cn", "trae-",
|
||||
"sub2api-", "openai-",
|
||||
).firstOrNull { lower.startsWith(it) }
|
||||
return if (prefix != null) m.substring(prefix.length).ifBlank { m } else m
|
||||
}
|
||||
|
||||
private fun rewriteModelBody(body: String, newModel: String): String = try {
|
||||
val obj = kotlinx.serialization.json.Json.parseToJsonElement(body)
|
||||
as? kotlinx.serialization.json.JsonObject ?: return body
|
||||
kotlinx.serialization.json.buildJsonObject {
|
||||
obj.forEach { (key, value) ->
|
||||
put(key, if (key == "model") kotlinx.serialization.json.JsonPrimitive(newModel) else value)
|
||||
}
|
||||
}.toString()
|
||||
} catch (_: Throwable) {
|
||||
body
|
||||
}
|
||||
|
||||
private fun extractModel(body: String): String = runCatching {
|
||||
val el = kotlinx.serialization.json.Json.parseToJsonElement(body)
|
||||
(el as? kotlinx.serialization.json.JsonObject)
|
||||
?.get("model")
|
||||
?.let { if (it is kotlinx.serialization.json.JsonPrimitive) it.content else null }
|
||||
}.getOrNull() ?: ""
|
||||
|
||||
private fun extractUser(body: String): String? = runCatching {
|
||||
val el = kotlinx.serialization.json.Json.parseToJsonElement(body)
|
||||
(el as? kotlinx.serialization.json.JsonObject)
|
||||
?.get("user")
|
||||
?.let { if (it is kotlinx.serialization.json.JsonPrimitive) it.content else null }
|
||||
}.getOrNull()
|
||||
|
||||
companion object {
|
||||
private const val MAX_REQUEST_BYTES = 10 * 1024 * 1024
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,41 @@
|
||||
package com.rainy.token.data.proxy
|
||||
|
||||
import java.net.Inet4Address
|
||||
import java.net.NetworkInterface
|
||||
|
||||
/**
|
||||
* 取当前手机可用于局域网/热点的 IPv4 地址。
|
||||
*
|
||||
* 热点接口名各机型不同(ap0/rndis0/wlan0/swlan0…),这里优先常见热点接口,
|
||||
* 其次回退到任意非回环 IPv4。热点开启时手机热点 IP 一般稳定(多数为
|
||||
* 192.168.x.1),但跨机型/跨配置可能不同,所以运行时取一次最稳妥。
|
||||
*/
|
||||
fun currentLanIpv4(): String? {
|
||||
return try {
|
||||
val candidates = mutableListOf<String>()
|
||||
val fallback = mutableListOf<String>()
|
||||
NetworkInterface.getNetworkInterfaces()?.let { nis ->
|
||||
for (ni in nis) {
|
||||
if (!ni.isUp || ni.isLoopback) continue
|
||||
val ip = ni.inetAddresses
|
||||
.asSequence()
|
||||
.mapNotNull { it as? Inet4Address }
|
||||
.mapNotNull { it.hostAddress }
|
||||
.firstOrNull { addr ->
|
||||
!addr.startsWith("127.") && !addr.startsWith("169.254.") &&
|
||||
addr.count { it == '.' } == 3
|
||||
} ?: continue
|
||||
val name = ni.name.lowercase()
|
||||
when {
|
||||
name == "ap0" || name == "rndis0" -> candidates.add(0, ip)
|
||||
name == "wlan0" || name.startsWith("wlan") || name.startsWith("swlan") ->
|
||||
candidates.add(ip)
|
||||
else -> fallback.add(ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
candidates.firstOrNull() ?: fallback.firstOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
package com.rainy.token.data.proxy
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* 本地 HTTP 反代服务配置。
|
||||
*/
|
||||
data class ProxyServerConfig(
|
||||
val port: Int = 8787,
|
||||
/** 客户端必须携带的 Bearer Key;null = 关闭鉴权(仅本机建议)。 */
|
||||
val apiKey: String? = null,
|
||||
/** true = 绑定 0.0.0.0,允许热点/同一 WiFi 下其他设备连接(需配合 API Key 使用)。 */
|
||||
val allowLan: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* 本地 HTTP 反代服务。
|
||||
*
|
||||
* 具体实现建议用 Ktor CIO(协程友好,方便 SSE 流式转发);
|
||||
* 若 APK 体积敏感,可换 NanoHTTPD,但流式转发需要额外桥接。
|
||||
*/
|
||||
interface LocalProxyServer {
|
||||
val isRunning: StateFlow<Boolean>
|
||||
|
||||
fun start(config: ProxyServerConfig): Result<Unit>
|
||||
|
||||
fun stop()
|
||||
}
|
||||
@ -0,0 +1,70 @@
|
||||
package com.rainy.token.data.proxy
|
||||
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.server.cio.CIO
|
||||
import io.ktor.server.engine.EmbeddedServer
|
||||
import io.ktor.server.engine.embeddedServer
|
||||
import io.ktor.server.response.respondText
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.routing.routing
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
/**
|
||||
* 本地登录回调服务器:在 App 内起一个 127.0.0.1 端口,
|
||||
* 手机浏览器完成 Trae OAuth 后跳回 `http://127.0.0.1:<port>/authorize?...`,
|
||||
* 这里把整个回调 URL 交给 ViewModel 解析并保存凭据(Sub2API 同款体验)。
|
||||
*
|
||||
* 只绑 loopback;授权完成/超时/退出登录页时调用 [stop] 释放端口。
|
||||
*/
|
||||
object LoginCallbackServer {
|
||||
|
||||
private const val PATH = "/authorize"
|
||||
|
||||
private var server: EmbeddedServer<*, *>? = null
|
||||
private var pending: CompletableDeferred<String>? = null
|
||||
|
||||
/** 启动(已启动则只重置等待任务)。返回是否可用。 */
|
||||
@Synchronized
|
||||
fun start(port: Int): Boolean {
|
||||
if (server != null) {
|
||||
pending = CompletableDeferred()
|
||||
return true
|
||||
}
|
||||
pending = CompletableDeferred()
|
||||
return try {
|
||||
val engine = embeddedServer(CIO, host = "127.0.0.1", port = port) {
|
||||
routing {
|
||||
get(PATH) {
|
||||
val callbackUrl = "http://127.0.0.1:$port" + call.request.local.uri
|
||||
pending?.complete(callbackUrl)
|
||||
call.respondText(
|
||||
"登录成功,可以返回雨晴Token了。",
|
||||
ContentType.Text.Html
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
engine.start(wait = false)
|
||||
server = engine
|
||||
true
|
||||
} catch (t: Throwable) {
|
||||
server = null
|
||||
pending = null
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/** 等待登录回调;[timeoutMs] 内没等到返回 null。 */
|
||||
suspend fun await(timeoutMs: Long): String? {
|
||||
val deferred = pending ?: return null
|
||||
return withTimeoutOrNull(timeoutMs) { deferred.await() }
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun stop() {
|
||||
runCatching { server?.stop(gracePeriodMillis = 200, timeoutMillis = 1000) }
|
||||
server = null
|
||||
pending = null
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
package com.rainy.token.data.proxy
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* 统一的上游 Provider 适配器。
|
||||
*
|
||||
* 实现类负责把 [ProxyChatRequest] 翻译成各家私有协议
|
||||
* (llm_utils_chat / v2/plugin / ACP 等),并把上游响应/SSE 流翻译回 [Flow]。
|
||||
*
|
||||
* 账号池、熔断、会话粘性由网关上层统一处理,不在适配器内重复实现。
|
||||
*/
|
||||
interface ProviderAdapter {
|
||||
val kind: ProviderKind
|
||||
|
||||
/** 该适配器支持的模型列表;空列表表示暂未加载/不支持。 */
|
||||
suspend fun models(): List<ProxyModel>
|
||||
|
||||
/** 发起对话,内部处理流式/非流式。 */
|
||||
fun chat(request: ProxyChatRequest, accountId: String?): Flow<ProxyChatChunk>
|
||||
}
|
||||
33
app/src/main/java/com/rainy/token/data/proxy/ProviderKind.kt
Normal file
33
app/src/main/java/com/rainy/token/data/proxy/ProviderKind.kt
Normal file
@ -0,0 +1,33 @@
|
||||
package com.rainy.token.data.proxy
|
||||
|
||||
/**
|
||||
* 网关支持的 Provider 清单。
|
||||
*
|
||||
* 命名规则:按「产品 + 区域」区分(同一个产品在国内版/国际版使用不同域名与鉴权体系)。
|
||||
* supportsCheckin 表示该平台官方是否有每日签到类活动;是否已实测由各自 Repository 决定。
|
||||
*/
|
||||
enum class ProviderKind(
|
||||
val displayName: String,
|
||||
val regions: Set<ProviderRegion> = ProviderRegion.entries.toSet(),
|
||||
val supportsChat: Boolean = true,
|
||||
val supportsCheckin: Boolean = false,
|
||||
) {
|
||||
WORKBUDDY_CN("WorkBuddy 国内版", setOf(ProviderRegion.CN), supportsCheckin = true),
|
||||
WORKBUDDY_INTL("WorkBuddy 国际版", setOf(ProviderRegion.INTL)),
|
||||
TRAE_CN("Trae CN", setOf(ProviderRegion.CN), supportsCheckin = true),
|
||||
TRAE_INTL("Trae INTL", setOf(ProviderRegion.INTL)),
|
||||
TRAE_WORK("TraeWork 桌面版", setOf(ProviderRegion.CN)),
|
||||
QODER_CN("Qoder CN", setOf(ProviderRegion.CN), supportsCheckin = true),
|
||||
RACCOON_CN("小浣熊(商汤)", setOf(ProviderRegion.CN), supportsCheckin = true),
|
||||
CATPAW_CN("CatPaw(美团)", setOf(ProviderRegion.CN)),
|
||||
AUTOCLAW_CN("AutoClaw 国内版", setOf(ProviderRegion.CN), supportsCheckin = true),
|
||||
AUTOCLAW_INTL("AutoClaw 国际版", setOf(ProviderRegion.INTL)),
|
||||
CLINE("Cline", setOf(ProviderRegion.INTL)),
|
||||
ACCIO_CN("Accio 国内版", setOf(ProviderRegion.CN)),
|
||||
ACCIO_INTL("Accio 国际版", setOf(ProviderRegion.INTL)),
|
||||
ZCODE_CN("ZCode 国内版", setOf(ProviderRegion.CN)),
|
||||
ZCODE_INTL("ZCode 国际版", setOf(ProviderRegion.INTL)),
|
||||
CODEARTS_CN("CodeArts(华为云)", setOf(ProviderRegion.CN)),
|
||||
LOOMY_CN("Loomy(讯飞)", setOf(ProviderRegion.CN), supportsCheckin = true),
|
||||
OPENAI_COMPATIBLE("OpenAI 兼容自定义", ProviderRegion.entries.toSet()),
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
package com.rainy.token.data.proxy
|
||||
|
||||
/**
|
||||
* 区域:CN=国内版(中国大陆域名),INTL=国际版(海外域名)。
|
||||
*/
|
||||
enum class ProviderRegion { CN, INTL }
|
||||
293
app/src/main/java/com/rainy/token/data/proxy/ProviderRouter.kt
Normal file
293
app/src/main/java/com/rainy/token/data/proxy/ProviderRouter.kt
Normal file
@ -0,0 +1,293 @@
|
||||
package com.rainy.token.data.proxy
|
||||
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
/**
|
||||
* 聚合路由:一个本地 API Key 通吃所有供应商。
|
||||
*
|
||||
* 规则:
|
||||
* 1. 模型名带显式前缀(traecn- / wbintl- 等)→ 强制指定供应商;
|
||||
* 2. 否则按【模型名】找所有支持的供应商,按历史速度排序逐个尝试;
|
||||
* 3. 上游不可用/HTTP>=400/抛错 → 自动切换到下一个供应商;
|
||||
* 4. Sub2API 作为通用兜底(配置了账号时)。
|
||||
*/
|
||||
@Singleton
|
||||
class ProviderRouter @Inject constructor(
|
||||
private val traeChatProxy: TraeChatProxy,
|
||||
private val workBuddyChatProxy: WorkBuddyChatProxy,
|
||||
private val sub2ApiChatProxy: Sub2ApiChatProxy,
|
||||
private val traeModelProvider: TraeModelProvider,
|
||||
private val workBuddyModelProvider: WorkBuddyModelProvider,
|
||||
private val accountPool: AccountPool,
|
||||
) {
|
||||
|
||||
data class Route(
|
||||
val kind: ProviderKind,
|
||||
val region: ProviderRegion?,
|
||||
)
|
||||
|
||||
/** 网关内供应商目标。 */
|
||||
enum class ProviderTarget(
|
||||
val kind: ProviderKind,
|
||||
val region: ProviderRegion?,
|
||||
val displayName: String,
|
||||
) {
|
||||
TRAE_CN(ProviderKind.TRAE_CN, ProviderRegion.CN, "Trae CN"),
|
||||
TRAE_INTL(ProviderKind.TRAE_INTL, ProviderRegion.INTL, "Trae INTL"),
|
||||
WORKBUDDY_CN(ProviderKind.WORKBUDDY_CN, ProviderRegion.CN, "WorkBuddy CN"),
|
||||
WORKBUDDY_INTL(ProviderKind.WORKBUDDY_INTL, ProviderRegion.INTL, "WorkBuddy INTL"),
|
||||
SUB2API(ProviderKind.OPENAI_COMPATIBLE, null, "Sub2API"),
|
||||
}
|
||||
|
||||
private data class Candidate(
|
||||
val target: ProviderTarget,
|
||||
val accountId: String?,
|
||||
)
|
||||
|
||||
// ---- 速度 / 健康度统计(内存态) ----
|
||||
|
||||
private val avgLatency = ConcurrentHashMap<ProviderTarget, Long>()
|
||||
private val cooldownUntil = ConcurrentHashMap<ProviderTarget, Long>()
|
||||
private val failCount = ConcurrentHashMap<ProviderTarget, Int>()
|
||||
|
||||
private fun recordSuccess(target: ProviderTarget, startedMs: Long, status: Int) {
|
||||
val ms = (System.currentTimeMillis() - startedMs).coerceAtLeast(1L)
|
||||
val old = avgLatency[target]
|
||||
avgLatency[target] = if (old == null) ms else (old * 3 + ms) / 4
|
||||
cooldownUntil.remove(target)
|
||||
failCount.remove(target)
|
||||
}
|
||||
|
||||
private fun recordFail(target: ProviderTarget, startedMs: Long) {
|
||||
val ms = (System.currentTimeMillis() - startedMs).coerceAtLeast(1L)
|
||||
val old = avgLatency[target]
|
||||
avgLatency[target] = if (old == null) ms + 5000L else old + 5000L
|
||||
val fails = (failCount[target] ?: 0) + 1
|
||||
failCount[target] = fails
|
||||
cooldownUntil[target] = System.currentTimeMillis() + 30_000L * fails
|
||||
}
|
||||
|
||||
private fun cooling(target: ProviderTarget): Boolean {
|
||||
val until = cooldownUntil[target] ?: return false
|
||||
if (System.currentTimeMillis() >= until) {
|
||||
cooldownUntil.remove(target)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun orderBySpeed(input: List<Candidate>): List<Candidate> = input.sortedWith(
|
||||
compareBy<Candidate>(
|
||||
{ cooling(it.target) },
|
||||
{ avgLatency[it.target] ?: Long.MAX_VALUE },
|
||||
{ if (it.target == ProviderTarget.SUB2API) 1 else 0 },
|
||||
)
|
||||
)
|
||||
|
||||
// ---- 流式转发(带失败切换) ----
|
||||
|
||||
suspend fun openStreamingChat(
|
||||
requestBody: String,
|
||||
conversationId: String? = null,
|
||||
): ProxyUpstreamStream? {
|
||||
val model = extractModel(requestBody)
|
||||
val forced = explicitTarget(model)
|
||||
val upstreamBody = if (forced != null) stripModelInBody(requestBody, model) else requestBody
|
||||
val candidates = candidatesFor(model, conversationId, forced)
|
||||
for (candidate in candidates) {
|
||||
val started = System.currentTimeMillis()
|
||||
val stream = try {
|
||||
when (candidate.target.kind) {
|
||||
ProviderKind.WORKBUDDY_CN, ProviderKind.WORKBUDDY_INTL ->
|
||||
workBuddyChatProxy.openStreamingChat(
|
||||
upstreamBody, candidate.accountId, candidate.target.region,
|
||||
)
|
||||
|
||||
ProviderKind.TRAE_CN, ProviderKind.TRAE_INTL ->
|
||||
traeChatProxy.openStreamingChat(
|
||||
upstreamBody, candidate.accountId, candidate.target.region,
|
||||
)
|
||||
|
||||
else ->
|
||||
sub2ApiChatProxy.openStreamingChat(upstreamBody, candidate.accountId)
|
||||
}
|
||||
} catch (e: java.io.IOException) {
|
||||
recordFail(candidate.target, started)
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
if (e is kotlinx.coroutines.CancellationException) throw e
|
||||
recordFail(candidate.target, started)
|
||||
null
|
||||
}
|
||||
|
||||
if (stream == null) {
|
||||
recordFail(candidate.target, started)
|
||||
continue
|
||||
}
|
||||
if (stream.status >= 400) {
|
||||
stream.close()
|
||||
recordFail(candidate.target, started)
|
||||
continue
|
||||
}
|
||||
recordSuccess(candidate.target, started, stream.status)
|
||||
return stream
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ---- 非流式转发(带失败切换) ----
|
||||
|
||||
suspend fun forwardChat(
|
||||
requestBody: String,
|
||||
conversationId: String? = null,
|
||||
): ProxyUpstreamResponse? {
|
||||
val model = extractModel(requestBody)
|
||||
val forced = explicitTarget(model)
|
||||
val upstreamBody = if (forced != null) stripModelInBody(requestBody, model) else requestBody
|
||||
val candidates = candidatesFor(model, conversationId, forced)
|
||||
for (candidate in candidates) {
|
||||
val started = System.currentTimeMillis()
|
||||
val result = try {
|
||||
when (candidate.target.kind) {
|
||||
ProviderKind.WORKBUDDY_CN, ProviderKind.WORKBUDDY_INTL ->
|
||||
workBuddyChatProxy.forwardChat(
|
||||
upstreamBody, candidate.accountId, candidate.target.region,
|
||||
)
|
||||
|
||||
ProviderKind.TRAE_CN, ProviderKind.TRAE_INTL ->
|
||||
traeChatProxy.forwardChat(
|
||||
upstreamBody, candidate.accountId, candidate.target.region,
|
||||
)
|
||||
|
||||
else ->
|
||||
sub2ApiChatProxy.forwardChat(upstreamBody, candidate.accountId)
|
||||
}
|
||||
} catch (e: java.io.IOException) {
|
||||
recordFail(candidate.target, started)
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
if (e is kotlinx.coroutines.CancellationException) throw e
|
||||
recordFail(candidate.target, started)
|
||||
null
|
||||
}
|
||||
|
||||
if (result == null) {
|
||||
recordFail(candidate.target, started)
|
||||
continue
|
||||
}
|
||||
if (result.status >= 400) {
|
||||
recordFail(candidate.target, started)
|
||||
continue
|
||||
}
|
||||
recordSuccess(candidate.target, started, result.status)
|
||||
return result
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ---- 候选构建 ----
|
||||
|
||||
private suspend fun candidatesFor(
|
||||
model: String,
|
||||
conversationId: String?,
|
||||
forced: ProviderTarget?,
|
||||
): List<Candidate> {
|
||||
val targets = if (forced != null) {
|
||||
listOf(forced)
|
||||
} else {
|
||||
capableTargets(model)
|
||||
}
|
||||
val built = targets.mapNotNull { target ->
|
||||
val pooled = accountPool.next(target.kind, target.region, conversationId)
|
||||
pooled?.accountId?.let { Candidate(target, it) }
|
||||
}
|
||||
return orderBySpeed(built)
|
||||
}
|
||||
|
||||
private fun capableTargets(model: String): List<ProviderTarget> {
|
||||
val m = model.trim().lowercase()
|
||||
if (m.isBlank() || m == "auto") {
|
||||
return listOf(
|
||||
ProviderTarget.TRAE_CN,
|
||||
ProviderTarget.WORKBUDDY_CN,
|
||||
ProviderTarget.TRAE_INTL,
|
||||
ProviderTarget.WORKBUDDY_INTL,
|
||||
ProviderTarget.SUB2API,
|
||||
)
|
||||
}
|
||||
val out = mutableListOf<ProviderTarget>()
|
||||
if (traeModelProvider.supports(model, ProviderRegion.CN)) out += ProviderTarget.TRAE_CN
|
||||
if (traeModelProvider.supports(model, ProviderRegion.INTL)) out += ProviderTarget.TRAE_INTL
|
||||
if (workBuddyModelProvider.supports(model, ProviderRegion.CN)) out += ProviderTarget.WORKBUDDY_CN
|
||||
if (workBuddyModelProvider.supports(model, ProviderRegion.INTL)) out += ProviderTarget.WORKBUDDY_INTL
|
||||
// 通用兜底:Sub2API 是 OpenAI 兼容实例,什么模型都可能支持
|
||||
out += ProviderTarget.SUB2API
|
||||
return out.distinct().ifEmpty { listOf(ProviderTarget.TRAE_CN) }
|
||||
}
|
||||
|
||||
private fun explicitTarget(model: String): ProviderTarget? {
|
||||
val m = model.trim().lowercase()
|
||||
return when {
|
||||
m.startsWith("wbcn-") || m.startsWith("workbuddy-cn") || m.startsWith("codebuddy-") ->
|
||||
ProviderTarget.WORKBUDDY_CN
|
||||
|
||||
m.startsWith("wbintl-") || m.startsWith("workbuddy-intl") || m.startsWith("workbuddy-") ->
|
||||
ProviderTarget.WORKBUDDY_INTL
|
||||
|
||||
m.startsWith("traeintl-") || m.startsWith("trae-intl") ->
|
||||
ProviderTarget.TRAE_INTL
|
||||
|
||||
m.startsWith("traecn-") || m.startsWith("trae-cn") || m.startsWith("trae-") ->
|
||||
ProviderTarget.TRAE_CN
|
||||
|
||||
m.startsWith("sub2api-") || m.startsWith("openai-") ->
|
||||
ProviderTarget.SUB2API
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/** 兼容旧调用:只返回模型名前缀对应的路由。 */
|
||||
fun route(model: String): Route {
|
||||
val target = explicitTarget(model)
|
||||
return if (target == null) {
|
||||
Route(ProviderKind.OPENAI_COMPATIBLE, null)
|
||||
} else {
|
||||
Route(target.kind, target.region)
|
||||
}
|
||||
}
|
||||
|
||||
private fun stripModelInBody(body: String, originalModel: String): String {
|
||||
val m = originalModel.trim()
|
||||
val lower = m.lowercase()
|
||||
val prefix = listOf(
|
||||
"wbcn-", "workbuddy-cn", "codebuddy-",
|
||||
"wbintl-", "workbuddy-intl", "workbuddy-",
|
||||
"traeintl-", "trae-intl", "traecn-", "trae-cn", "trae-",
|
||||
"sub2api-", "openai-",
|
||||
).firstOrNull { lower.startsWith(it) } ?: return body
|
||||
val newModel = m.substring(prefix.length).ifBlank { m }
|
||||
if (newModel == m) return body
|
||||
return runCatching {
|
||||
val obj = Json.parseToJsonElement(body) as? JsonObject ?: return@runCatching body
|
||||
JsonObject(
|
||||
obj.entries.associate { (key, value) ->
|
||||
key to (if (key == "model") JsonPrimitive(newModel) else value)
|
||||
}
|
||||
).toString()
|
||||
}.getOrDefault(body)
|
||||
}
|
||||
|
||||
private fun extractModel(body: String): String = runCatching {
|
||||
val el = Json.parseToJsonElement(body)
|
||||
(el as? JsonObject)
|
||||
?.get("model")
|
||||
?.let { if (it is JsonPrimitive) it.content else null }
|
||||
}.getOrNull() ?: ""
|
||||
}
|
||||
56
app/src/main/java/com/rainy/token/data/proxy/ProxyModels.kt
Normal file
56
app/src/main/java/com/rainy/token/data/proxy/ProxyModels.kt
Normal file
@ -0,0 +1,56 @@
|
||||
package com.rainy.token.data.proxy
|
||||
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
|
||||
/**
|
||||
* 网关对外的最小 OpenAI 兼容模型。
|
||||
* 后续按协议(Chat Completions / Responses / Anthropic)扩展。
|
||||
*/
|
||||
data class ProxyChatRequest(
|
||||
val model: String,
|
||||
val messages: List<ProxyChatMessage> = emptyList(),
|
||||
val stream: Boolean = false,
|
||||
val tools: List<JsonElement>? = null,
|
||||
val toolChoice: JsonElement? = null,
|
||||
val reasoningEffort: String? = null,
|
||||
val maxTokens: Int? = null,
|
||||
)
|
||||
|
||||
data class ProxyChatMessage(
|
||||
val role: String,
|
||||
val content: String? = null,
|
||||
val toolCalls: List<JsonElement>? = null,
|
||||
val toolCallId: String? = null,
|
||||
val name: String? = null,
|
||||
)
|
||||
|
||||
data class ProxyModel(
|
||||
val id: String,
|
||||
val provider: ProviderKind,
|
||||
val region: ProviderRegion? = null,
|
||||
)
|
||||
|
||||
/** 流式/非流式统一输出块,由 StreamNormalizer 归一。 */
|
||||
sealed interface ProxyChatChunk {
|
||||
data class Done(
|
||||
val text: String? = null,
|
||||
val usage: ProxyUsage? = null,
|
||||
) : ProxyChatChunk
|
||||
|
||||
data class Delta(
|
||||
val text: String? = null,
|
||||
val reasoning: String? = null,
|
||||
val toolCalls: List<JsonElement>? = null,
|
||||
) : ProxyChatChunk
|
||||
|
||||
data class Error(
|
||||
val message: String,
|
||||
val code: Int? = null,
|
||||
) : ProxyChatChunk
|
||||
}
|
||||
|
||||
data class ProxyUsage(
|
||||
val promptTokens: Long? = null,
|
||||
val completionTokens: Long? = null,
|
||||
val totalTokens: Long? = null,
|
||||
)
|
||||
@ -0,0 +1,15 @@
|
||||
package com.rainy.token.data.proxy
|
||||
|
||||
import java.io.InputStream
|
||||
|
||||
/**
|
||||
* 上游流式响应句柄:由本地网关在收到客户端 stream=true 请求时创建,
|
||||
* 把上游 SSE/原始字节流实时转发给本地客户端。
|
||||
* 使用方必须在 finally 中调用 [close]。
|
||||
*/
|
||||
class ProxyUpstreamStream(
|
||||
val status: Int,
|
||||
val contentType: String,
|
||||
val input: InputStream,
|
||||
val close: () -> Unit,
|
||||
)
|
||||
30
app/src/main/java/com/rainy/token/data/proxy/RegionRouter.kt
Normal file
30
app/src/main/java/com/rainy/token/data/proxy/RegionRouter.kt
Normal file
@ -0,0 +1,30 @@
|
||||
package com.rainy.token.data.proxy
|
||||
|
||||
/**
|
||||
* 模型名 → Provider + Region 路由。
|
||||
*
|
||||
* 骨架策略:按模型家族前缀映射;后续可用 agent2api / proxy-hub 的远端模型目录替换。
|
||||
*/
|
||||
class RegionRouter {
|
||||
|
||||
data class Route(
|
||||
val kind: ProviderKind,
|
||||
val region: ProviderRegion,
|
||||
)
|
||||
|
||||
fun route(model: String): Route? {
|
||||
val normalized = model.lowercase()
|
||||
return when {
|
||||
// 国内/字节/智谱系模型默认 CN
|
||||
CN_MODEL_FAMILIES.any { normalized.contains(it) } -> Route(ProviderKind.OPENAI_COMPATIBLE, ProviderRegion.CN)
|
||||
// 国际模型默认 INTL
|
||||
INTL_MODEL_FAMILIES.any { normalized.contains(it) } -> Route(ProviderKind.OPENAI_COMPATIBLE, ProviderRegion.INTL)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val CN_MODEL_FAMILIES = listOf("glm", "deepseek", "kimi", "qwen", "doubao", "hunyuan", "minimax")
|
||||
private val INTL_MODEL_FAMILIES = listOf("gpt", "o1", "o3", "o4", "claude", "gemini", "grok")
|
||||
}
|
||||
}
|
||||
135
app/src/main/java/com/rainy/token/data/proxy/Sub2ApiChatProxy.kt
Normal file
135
app/src/main/java/com/rainy/token/data/proxy/Sub2ApiChatProxy.kt
Normal file
@ -0,0 +1,135 @@
|
||||
package com.rainy.token.data.proxy
|
||||
|
||||
import com.rainy.token.data.repository.CredentialRepository
|
||||
import com.rainy.token.domain.model.Credential
|
||||
import com.rainy.token.domain.service.ServiceType
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
|
||||
/**
|
||||
* Sub2API Chat 透传代理。
|
||||
*
|
||||
* Sub2API 实例本身暴露 OpenAI 兼容接口,因此这里不做协议转换:
|
||||
* 取出用户保存的 Sub2ApiCredential(优先 sk- API Key,其次面板 authToken),
|
||||
* 把客户端请求原样转发到 {base}/v1/chat/completions,
|
||||
* 并把上游响应(含 SSE 流式内容)原样返回给本地客户端。
|
||||
*
|
||||
* M1a 阶段:先做整包透传(流式也先缓冲),后续由 StreamNormalizer 升级为逐块转发。
|
||||
*/
|
||||
@Singleton
|
||||
class Sub2ApiChatProxy @Inject constructor(
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val credentialRepository: CredentialRepository,
|
||||
) {
|
||||
|
||||
/** 转发 POST /v1/chat/completions。 */
|
||||
suspend fun forwardChat(requestBody: String, accountId: String? = null): ProxyUpstreamResponse? {
|
||||
val base = resolveBase(accountId) ?: return null
|
||||
return forward(base = base, path = "/v1/chat/completions", requestBody = requestBody, accountId = accountId)
|
||||
}
|
||||
|
||||
/** 转发流式 POST /v1/chat/completions(上游 SSE 原样转发)。 */
|
||||
suspend fun openStreamingChat(
|
||||
requestBody: String,
|
||||
accountId: String? = null,
|
||||
): ProxyUpstreamStream? = withContext(Dispatchers.IO) {
|
||||
val credential = credentialRepository.get(ServiceType.SUB2API, accountId)
|
||||
?: return@withContext null
|
||||
if (credential !is Credential.Sub2ApiCredential) return@withContext null
|
||||
val auth = resolveAuth(credential) ?: return@withContext null
|
||||
val base = normalizeBase(credential.baseUrl) ?: return@withContext null
|
||||
|
||||
val builder = Request.Builder()
|
||||
.url(base + "/v1/chat/completions")
|
||||
.addHeader("Authorization", auth)
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.post(requestBody.toRequestBody("application/json".toMediaType()))
|
||||
|
||||
val response = okHttpClient.newCall(builder.build()).execute()
|
||||
val input = response.body?.byteStream() ?: run {
|
||||
response.close()
|
||||
return@withContext null
|
||||
}
|
||||
ProxyUpstreamStream(
|
||||
status = response.code,
|
||||
contentType = response.header("Content-Type") ?: "text/event-stream",
|
||||
input = input,
|
||||
close = { response.close() },
|
||||
)
|
||||
}
|
||||
|
||||
/** 转发 GET /v1/models。 */
|
||||
suspend fun forwardModels(accountId: String? = null): ProxyUpstreamResponse? {
|
||||
val base = resolveBase(accountId) ?: return null
|
||||
return forward(base = base, path = "/v1/models", requestBody = null, accountId = accountId)
|
||||
}
|
||||
|
||||
private suspend fun resolveBase(accountId: String?): String? {
|
||||
val credential = credentialRepository.get(ServiceType.SUB2API, accountId) ?: return null
|
||||
if (credential !is Credential.Sub2ApiCredential) return null
|
||||
if (resolveAuth(credential) == null) return null
|
||||
return normalizeBase(credential.baseUrl)
|
||||
}
|
||||
|
||||
private suspend fun forward(
|
||||
base: String,
|
||||
path: String,
|
||||
requestBody: String?,
|
||||
accountId: String?,
|
||||
): ProxyUpstreamResponse? = withContext(Dispatchers.IO) {
|
||||
val credential = credentialRepository.get(ServiceType.SUB2API, accountId)
|
||||
?: return@withContext null
|
||||
if (credential !is Credential.Sub2ApiCredential) return@withContext null
|
||||
val auth = resolveAuth(credential) ?: return@withContext null
|
||||
|
||||
val builder = Request.Builder()
|
||||
.url(base + path)
|
||||
.addHeader("Authorization", auth)
|
||||
val body = requestBody?.takeIf { it.isNotBlank() }
|
||||
if (body != null) {
|
||||
builder
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.post(body.toRequestBody("application/json".toMediaType()))
|
||||
}
|
||||
|
||||
val response = okHttpClient.newCall(builder.build()).execute()
|
||||
val bytes = response.body?.bytes() ?: ByteArray(0)
|
||||
val contentType = response.header("Content-Type") ?: "application/json"
|
||||
val status = response.code
|
||||
response.close()
|
||||
ProxyUpstreamResponse(status, contentType, bytes)
|
||||
}
|
||||
|
||||
private fun resolveAuth(credential: Credential.Sub2ApiCredential): String? {
|
||||
credential.apiKey?.trim()?.takeIf { it.isNotBlank() }?.let { return "Bearer $it" }
|
||||
credential.authToken?.trim()?.takeIf { it.isNotBlank() }?.let { return "Bearer $it" }
|
||||
return null
|
||||
}
|
||||
|
||||
private fun normalizeBase(raw: String): String? {
|
||||
var s = raw.trim()
|
||||
while (s.endsWith("/")) s = s.dropLast(1)
|
||||
return s.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
/** 是否已配置 Sub2API 凭据(用于聚合网关兜底)。 */
|
||||
suspend fun hasCredential(): Boolean {
|
||||
val credential = credentialRepository.get(ServiceType.SUB2API, null)
|
||||
return credential != null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上游 HTTP 响应(透传用)。
|
||||
*/
|
||||
data class ProxyUpstreamResponse(
|
||||
val status: Int,
|
||||
val contentType: String,
|
||||
val body: ByteArray,
|
||||
)
|
||||
343
app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt
Normal file
343
app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt
Normal file
@ -0,0 +1,343 @@
|
||||
package com.rainy.token.data.proxy
|
||||
|
||||
import com.rainy.token.data.repository.CredentialRepository
|
||||
import com.rainy.token.domain.model.Credential
|
||||
import com.rainy.token.domain.service.ServiceType
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
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.put
|
||||
import okhttp3.Headers.Companion.toHeaders
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
|
||||
/**
|
||||
* Trae CN 上游 Chat 代理。
|
||||
*
|
||||
* 上游为私有协议 POST {base}/api/agent/v3/llm_utils_chat。
|
||||
* CN 模型网关固定走 trae-api-cn.mchost.guru(api.trae.cn 只是账号/OAuth 主机,返回 404)。
|
||||
* 请求头/body 对齐 trae2api-cn 参考实现的 SOLO 协议。
|
||||
*/
|
||||
@Singleton
|
||||
class TraeChatProxy @Inject constructor(
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val credentialRepository: CredentialRepository,
|
||||
private val modelProvider: TraeModelProvider,
|
||||
) {
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
suspend fun forwardChat(
|
||||
requestBody: String,
|
||||
accountId: String? = null,
|
||||
forcedRegion: ProviderRegion? = null,
|
||||
): ProxyUpstreamResponse? =
|
||||
withContext(Dispatchers.IO) {
|
||||
val credential = pickCredential(accountId, forcedRegion)
|
||||
?: return@withContext null
|
||||
if (credential !is Credential.TraeCredential) return@withContext null
|
||||
val jwt = credential.jwt.trim().takeIf { it.isNotBlank() }
|
||||
?: return@withContext null
|
||||
|
||||
val effectiveRegion = forcedRegion ?: runCatching {
|
||||
ProviderRegion.valueOf(credential.region.uppercase())
|
||||
}.getOrNull()
|
||||
val base = chatBase(effectiveRegion)
|
||||
val upstreamBody = buildUpstreamBody(requestBody, credential, effectiveRegion)
|
||||
val requestId = UUID.randomUUID().toString()
|
||||
|
||||
val builder = Request.Builder()
|
||||
.url(base + "/api/agent/v3/llm_utils_chat")
|
||||
.headers(soloHeaders(jwt, credential, requestId).toHeaders())
|
||||
.post(upstreamBody.toRequestBody("application/json".toMediaType()))
|
||||
|
||||
val response = try {
|
||||
okHttpClient.newCall(builder.build()).execute()
|
||||
} catch (e: java.io.IOException) {
|
||||
throw e
|
||||
}
|
||||
val bytes = try { response.body?.bytes() ?: ByteArray(0) } catch (_: Throwable) { ByteArray(0) }
|
||||
val contentType = response.header("Content-Type") ?: "application/json"
|
||||
val status = response.code
|
||||
response.close()
|
||||
ProxyUpstreamResponse(status, contentType, bytes)
|
||||
}
|
||||
|
||||
suspend fun openStreamingChat(
|
||||
requestBody: String,
|
||||
accountId: String? = null,
|
||||
forcedRegion: ProviderRegion? = null,
|
||||
): ProxyUpstreamStream? = withContext(Dispatchers.IO) {
|
||||
val credential = pickCredential(accountId, forcedRegion)
|
||||
?: return@withContext null
|
||||
if (credential !is Credential.TraeCredential) return@withContext null
|
||||
val jwt = credential.jwt.trim().takeIf { it.isNotBlank() }
|
||||
?: return@withContext null
|
||||
|
||||
val effectiveRegion = forcedRegion ?: runCatching {
|
||||
ProviderRegion.valueOf(credential.region.uppercase())
|
||||
}.getOrNull()
|
||||
val base = chatBase(effectiveRegion)
|
||||
val upstreamBody = buildUpstreamBody(requestBody, credential, effectiveRegion)
|
||||
val requestId = UUID.randomUUID().toString()
|
||||
|
||||
val builder = Request.Builder()
|
||||
.url(base + "/api/agent/v3/llm_utils_chat")
|
||||
.headers(soloHeaders(jwt, credential, requestId).toHeaders())
|
||||
.post(upstreamBody.toRequestBody("application/json".toMediaType()))
|
||||
|
||||
val response = try {
|
||||
okHttpClient.newCall(builder.build()).execute()
|
||||
} catch (e: java.io.IOException) {
|
||||
throw e
|
||||
}
|
||||
val input = response.body?.byteStream() ?: run {
|
||||
response.close()
|
||||
return@withContext null
|
||||
}
|
||||
ProxyUpstreamStream(
|
||||
status = response.code,
|
||||
contentType = response.header("Content-Type") ?: "text/event-stream",
|
||||
input = input,
|
||||
close = { response.close() },
|
||||
)
|
||||
}
|
||||
|
||||
private fun soloHeaders(
|
||||
jwt: String,
|
||||
credential: Credential.TraeCredential,
|
||||
requestId: String,
|
||||
): Map<String, String> = linkedMapOf(
|
||||
"Content-Type" to "application/json",
|
||||
"Accept" to "text/event-stream",
|
||||
"Connection" to "keep-alive",
|
||||
"Authorization" to "Cloud-IDE-JWT $jwt",
|
||||
"X-Cloudide-Token" to jwt,
|
||||
"x-ide-token" to jwt,
|
||||
"x-uid" to (credential.userId ?: ""),
|
||||
"x-app-id" to "6eefa01c-1036-4c7e-9ca5-d891f63bfcd8",
|
||||
"x-device-id" to (credential.deviceId ?: credential.checkinDeviceId ?: ""),
|
||||
"x-machine-id" to (credential.deviceId ?: credential.checkinDeviceId ?: ""),
|
||||
"x-request-id" to requestId,
|
||||
"x-ide-version" to "0.1.52",
|
||||
"x-ide-version-code" to "20260811",
|
||||
"x-ide-version-type" to "stable",
|
||||
"x-app-version" to "default",
|
||||
"x-app-version-code" to "20260811",
|
||||
"x-version-code" to "20260811",
|
||||
"x-device-cpu" to "AMD",
|
||||
"x-device-brand" to "83DG",
|
||||
"x-device-type" to "windows",
|
||||
"x-device-platform" to "windows",
|
||||
"x-os-version" to "Windows 11 Pro",
|
||||
"x-system-type" to "Windows",
|
||||
"package-type" to "stable_cn",
|
||||
"x-lscbd-aid" to "787976",
|
||||
"x-lscbd-platform" to "windows",
|
||||
"x-ss-dp" to "787976",
|
||||
"x-plugin-channel" to "icube-ai",
|
||||
"app-version" to "0.1.52",
|
||||
"x-bridge-transport" to "aha",
|
||||
"x-ahanet-timeout" to "86400",
|
||||
"x-lgw-req-sdk-type" to "3",
|
||||
"x-net-sdk-domain-dispatch" to "1",
|
||||
"x-ttnet-bypass-decompression" to "1",
|
||||
"x-ttnet-bypass-cookie" to "0",
|
||||
"request-traffic-type" to "prod",
|
||||
"User-Agent" to "Trae/0.1.52",
|
||||
)
|
||||
|
||||
private fun convertNativeMessages(src: JsonElement?): List<JsonElement> {
|
||||
val arr = src as? JsonArray ?: return emptyList()
|
||||
return arr.mapNotNull { el ->
|
||||
val m = el as? JsonObject ?: return@mapNotNull null
|
||||
val rawRole = (m["role"] as? JsonPrimitive)?.contentOrNull?.lowercase() ?: "user"
|
||||
val role = when (rawRole) {
|
||||
"developer" -> "system"
|
||||
"system", "user", "assistant", "tool", "function" -> rawRole
|
||||
else -> "user"
|
||||
}
|
||||
buildJsonObject {
|
||||
put("role", role)
|
||||
val content = nativeContent(m["content"])
|
||||
if (content != null) put("content", content)
|
||||
m["name"]?.let { put("name", it) }
|
||||
m["tool_call_id"]?.let { put("tool_call_id", it) }
|
||||
m["tool_calls"]?.let { put("tool_calls", it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun nativeContent(content: JsonElement?): JsonElement? {
|
||||
if (content == null || content is JsonNull) return null
|
||||
if (content is JsonPrimitive) {
|
||||
return buildJsonArray {
|
||||
add(buildJsonObject {
|
||||
put("type", "text")
|
||||
put("text", content.content)
|
||||
})
|
||||
}
|
||||
}
|
||||
if (content is JsonArray) {
|
||||
return buildJsonArray {
|
||||
content.forEach { block ->
|
||||
when (block) {
|
||||
is JsonPrimitive -> add(buildJsonObject {
|
||||
put("type", "text")
|
||||
put("text", block.content)
|
||||
})
|
||||
is JsonObject -> {
|
||||
val type = (block["type"] as? JsonPrimitive)?.contentOrNull?.lowercase()
|
||||
if (type == "text" || type == "input_text") {
|
||||
val text = (block["text"] as? JsonPrimitive)?.contentOrNull
|
||||
?: (block["content"] as? JsonPrimitive)?.contentOrNull
|
||||
?: ""
|
||||
add(buildJsonObject {
|
||||
put("type", "text")
|
||||
put("text", text)
|
||||
})
|
||||
} else {
|
||||
add(block)
|
||||
}
|
||||
}
|
||||
else -> add(block)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 外部模型名 -> Trae 内部模型名;空/auto 用设置页选择的默认模型。 */
|
||||
private fun normalizeModel(raw: String?, region: ProviderRegion?): String {
|
||||
val selected = modelProvider.selectedFor(region ?: ProviderRegion.CN)
|
||||
val id = raw?.trim()?.takeIf { it.isNotBlank() } ?: return selected
|
||||
if (id.equals("auto", ignoreCase = true)) return selected
|
||||
return MODEL_ALIASES[id.lowercase()] ?: id
|
||||
}
|
||||
|
||||
/**
|
||||
* INTL 统一走 Web 网关 core-normal.trae.ai(实测 api-us-east/a0ai 均不提供
|
||||
* llm_utils_chat 路由);CN 走 IDE 网关 trae-api-cn.mchost.guru。
|
||||
*/
|
||||
/** 按区域选凭据:显式 accountId 优先;未指定时优先同区域账号,再兼容默认账号。 */
|
||||
private suspend fun pickCredential(
|
||||
accountId: String?,
|
||||
region: ProviderRegion?,
|
||||
): Credential.TraeCredential? {
|
||||
if (!accountId.isNullOrBlank()) {
|
||||
return credentialRepository.get(ServiceType.TRAE, accountId) as? Credential.TraeCredential
|
||||
}
|
||||
val accounts = credentialRepository.accountsFor(ServiceType.TRAE)
|
||||
if (accounts.isEmpty()) {
|
||||
val legacy = credentialRepository.get(ServiceType.TRAE, null) as? Credential.TraeCredential
|
||||
?: return null
|
||||
return if (region == null || legacy.region.equals(region.name, ignoreCase = true)) legacy else null
|
||||
}
|
||||
val all = accounts.mapNotNull { account ->
|
||||
credentialRepository.get(ServiceType.TRAE, account.id) as? Credential.TraeCredential
|
||||
}
|
||||
if (all.isEmpty()) return null
|
||||
if (region != null) {
|
||||
all.firstOrNull { it.region.equals(region.name, ignoreCase = true) }?.let { return it }
|
||||
}
|
||||
return all.first()
|
||||
}
|
||||
|
||||
private fun chatBase(region: ProviderRegion?): String =
|
||||
if (region == ProviderRegion.INTL) {
|
||||
"https://core-normal.trae.ai"
|
||||
} else {
|
||||
"https://trae-api-cn.mchost.guru"
|
||||
}
|
||||
|
||||
private fun buildUpstreamBody(raw: String, credential: Credential.TraeCredential, region: ProviderRegion?): String {
|
||||
val src = try {
|
||||
json.parseToJsonElement(raw) as? JsonObject
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
} ?: return raw
|
||||
|
||||
val rawModel = (src["model"] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotBlank() }
|
||||
val model = normalizeModel(rawModel, region)
|
||||
val messages = convertNativeMessages(src["messages"])
|
||||
val sessionId = UUID.randomUUID().toString().replace("-", "")
|
||||
|
||||
return buildJsonObject {
|
||||
put("messages", JsonArray(messages))
|
||||
put("config_name", model)
|
||||
put("model", model)
|
||||
put("function", "solo_work_lite")
|
||||
put("stream", true)
|
||||
put("request_id", sessionId)
|
||||
put("session_id", sessionId)
|
||||
src["tools"]?.let { put("tools", it) }
|
||||
src["tool_choice"]?.let { put("tool_choice", it) }
|
||||
(src["max_tokens"] as? JsonPrimitive)?.contentOrNull?.toIntOrNull()?.let {
|
||||
put("max_tokens", it)
|
||||
}
|
||||
}.toString()
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** 供路由判断模型是否可能由 Trae 消化。 */
|
||||
internal fun resolveAlias(modelName: String): String? =
|
||||
MODEL_ALIASES[modelName.trim().lowercase()]
|
||||
/** OpenAI/Claude 常用名 -> Trae CN 内部模型名(参考 trae2api-cn)。 */
|
||||
private val MODEL_ALIASES = mapOf(
|
||||
"auto" to "glm-5.2",
|
||||
"gpt-4o" to "DeepSeek-V4-Pro",
|
||||
"gpt-4o-latest" to "DeepSeek-V4-Pro",
|
||||
"gpt-4.1" to "DeepSeek-V4-Pro",
|
||||
"deepseek-v3" to "DeepSeek-V4-Pro",
|
||||
"deepseek-r1" to "DeepSeek-V4-Pro",
|
||||
"gpt-4o-mini" to "DeepSeek-V4-Flash",
|
||||
"claude-opus-4-7" to "glm-5.2",
|
||||
"claude-opus-4-6" to "glm-5.2",
|
||||
"claude-opus-4-5" to "glm-5.2",
|
||||
"claude-sonnet-4-6" to "glm-5.2",
|
||||
"claude-sonnet-4-5" to "glm-5.2",
|
||||
"claude-sonnet-4" to "glm-5.2",
|
||||
"claude-3.7-sonnet" to "glm-5.2",
|
||||
"claude-3-7-sonnet" to "glm-5.2",
|
||||
"claude-3.5-sonnet" to "glm-5.2",
|
||||
"claude-haiku-4-5" to "glm-5.1",
|
||||
"deepseek-v4-flash-official" to "DeepSeek-V4-Flash-Official",
|
||||
"deepseek-v4-pro-official" to "DeepSeek-V4-Pro-Official",
|
||||
"doubao-seed-evolving" to "Doubao-Seed-Evolving",
|
||||
"deepseek-v4-pro" to "DeepSeek-V4-Pro",
|
||||
"deepseek-v4-flash" to "DeepSeek-V4-Flash",
|
||||
"seed-2.1-pro" to "Doubao-Seed-2.1-Pro",
|
||||
"seed-2.1-turbo" to "Doubao-Seed-2.1-Turbo",
|
||||
"seed-code" to "Doubao-Seed-Code",
|
||||
"seed-evolving" to "Doubao-Seed-Evolving",
|
||||
"qwen3.7-plus" to "qwen-3.7-plus",
|
||||
"kimi-k2.6" to "kimi-k2.6",
|
||||
"kimi-k3" to "kimi-k3",
|
||||
"kimi-k2.7-code" to "kimi-k2.7-code",
|
||||
"kimi-k2" to "kimi-k2",
|
||||
"kimi-k2.5" to "kimi-k2.5",
|
||||
"minimax-m3" to "minimax-m3",
|
||||
"minimax-m2.7" to "minimax-m2.7",
|
||||
"minimax-m2.1" to "minimax-m2.1",
|
||||
"mimo-v2.5" to "mimo-v2.5",
|
||||
"mimo-v2.5-pro" to "mimo-v2.5-pro",
|
||||
"minimax-m25" to "minimax-m25",
|
||||
"qwen36-35b" to "qwen36-35b",
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,191 @@
|
||||
package com.rainy.token.data.proxy
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
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.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import okhttp3.Headers.Companion.toHeaders
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
|
||||
/**
|
||||
* Trae 模型列表:内置参考映射 + 在线拉取账号可用模型 + 记住用户选择。
|
||||
* 选择结果同时供设置页测试和 [TraeChatProxy] 作为缺省模型使用。
|
||||
*/
|
||||
@Singleton
|
||||
class TraeModelProvider @Inject constructor(
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val credentialRepository: CredentialRepository,
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
|
||||
data class TraeModel(val id: String, val label: String? = null)
|
||||
|
||||
private val prefs: SharedPreferences =
|
||||
context.getSharedPreferences("rainytoken_proxy", Context.MODE_PRIVATE)
|
||||
|
||||
private val _modelsByRegion = MutableStateFlow(
|
||||
ProviderRegion.entries.associateWith { defaultModels() }
|
||||
)
|
||||
|
||||
/** 获取指定区域模型列表(在线拉取成功后该区域独立更新)。 */
|
||||
fun modelsFor(region: ProviderRegion): List<TraeModel> =
|
||||
_modelsByRegion.value[region] ?: defaultModels()
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
/** 当前区域选择的默认模型(未设置时 CN 用 GLM-5.2)。 */
|
||||
fun selectedFor(region: ProviderRegion): String {
|
||||
if (region == ProviderRegion.INTL) {
|
||||
return prefs.getString(KEY_MODEL_INTL, DEFAULT_MODEL_INTL) ?: DEFAULT_MODEL_INTL
|
||||
}
|
||||
return prefs.getString(KEY_MODEL_CN, "glm-5.2") ?: "glm-5.2"
|
||||
}
|
||||
|
||||
fun setSelectedFor(region: ProviderRegion, id: String) {
|
||||
if (id.isBlank()) return
|
||||
val key = if (region == ProviderRegion.INTL) KEY_MODEL_INTL else KEY_MODEL_CN
|
||||
prefs.edit().putString(key, id).apply()
|
||||
}
|
||||
|
||||
/** 该区域是否可能支持此模型(内置列表/在线列表/别名)。auto 视为支持。 */
|
||||
fun supports(modelId: String, region: ProviderRegion): Boolean {
|
||||
val want = modelId.trim().lowercase()
|
||||
if (want.isBlank() || want == "auto") return true
|
||||
if (modelsFor(region).any { it.id.equals(modelId, ignoreCase = true) }) return true
|
||||
return TraeChatProxy.resolveAlias(modelId) != null
|
||||
}
|
||||
|
||||
/** 拉取指定区域账号的在线模型列表;该区域未配置或失败时保留内置列表并返回 false。 */
|
||||
suspend fun refreshFor(forcedRegion: ProviderRegion?): Boolean = withContext(Dispatchers.IO) {
|
||||
val effectiveRegion = forcedRegion ?: ProviderRegion.CN
|
||||
val credential = pickCredential(effectiveRegion) ?: return@withContext false
|
||||
val jwt = credential.jwt.trim().takeIf { it.isNotBlank() } ?: return@withContext false
|
||||
|
||||
val intl = effectiveRegion == ProviderRegion.INTL
|
||||
val base = if (intl) "https://core-normal.trae.ai" else "https://trae-api-cn.mchost.guru"
|
||||
val origin = if (intl) "https://solo.trae.ai" else "https://solo.trae.cn"
|
||||
val userRegion = if (intl) {
|
||||
if (credential.apiHost?.contains("us", ignoreCase = true) == true) "US" else "SG"
|
||||
} else "CN"
|
||||
val url = base + "/api/remote/v1/models" +
|
||||
"?functions=solo_agent_remote%2Csolo_work_remote%2Csolo_design_remote" +
|
||||
"&show_custom_model=true"
|
||||
|
||||
val headers = linkedMapOf(
|
||||
"Authorization" to "Cloud-IDE-JWT $jwt",
|
||||
"Content-Type" to "application/json",
|
||||
"X-Trae-Client-Type" to "web",
|
||||
"X-Preferenced-Language" to "zh-CN",
|
||||
"x-user-region" to userRegion,
|
||||
"Origin" to origin,
|
||||
"Referer" to origin + "/",
|
||||
"User-Agent" to (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
|
||||
"(KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"
|
||||
),
|
||||
)
|
||||
val request = Request.Builder().url(url).headers(headers.toHeaders()).get().build()
|
||||
val parsed = try {
|
||||
okHttpClient.newCall(request).execute().use { resp ->
|
||||
if (!resp.isSuccessful) return@use null
|
||||
val body = resp.body?.string() ?: return@use null
|
||||
parseModelList(body)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
if (parsed.isNullOrEmpty()) return@withContext false
|
||||
_modelsByRegion.value = _modelsByRegion.value + (effectiveRegion to parsed)
|
||||
true
|
||||
}
|
||||
|
||||
private suspend fun pickCredential(region: ProviderRegion): Credential.TraeCredential? {
|
||||
val accounts = credentialRepository.accountsFor(ServiceType.TRAE)
|
||||
if (accounts.isEmpty()) {
|
||||
val legacy = credentialRepository.get(ServiceType.TRAE, null)
|
||||
return (legacy as? Credential.TraeCredential)
|
||||
?.takeIf { it.region.equals(region.name, ignoreCase = true) }
|
||||
}
|
||||
return accounts.mapNotNull { account ->
|
||||
credentialRepository.get(ServiceType.TRAE, account.id) as? Credential.TraeCredential
|
||||
}.firstOrNull { it.region.equals(region.name, ignoreCase = true) }
|
||||
}
|
||||
|
||||
private fun parseModelList(body: String): List<TraeModel>? {
|
||||
val root = runCatching { json.parseToJsonElement(body) as? JsonObject }.getOrNull()
|
||||
?: return null
|
||||
val list = (root["data"] as? JsonObject)?.get("list") as? JsonArray ?: return null
|
||||
val seen = LinkedHashMap<String, TraeModel>()
|
||||
for (groupEl in list) {
|
||||
val group = groupEl as? JsonObject ?: continue
|
||||
val models = group["models"] as? JsonArray ?: continue
|
||||
for (rawEl in models) {
|
||||
val raw = rawEl as? JsonObject ?: continue
|
||||
val id = (raw["name"] as? JsonPrimitive)?.contentOrNull?.trim()
|
||||
?.takeIf { it.isNotEmpty() } ?: continue
|
||||
val label = (raw["display_name"] as? JsonPrimitive)?.contentOrNull
|
||||
?: (raw["display_model_name"] as? JsonPrimitive)?.contentOrNull
|
||||
?: id
|
||||
seen[id] = TraeModel(id, label)
|
||||
}
|
||||
}
|
||||
return seen.values.toList().ifEmpty { null }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEY_MODEL_CN = "trae_default_model_cn"
|
||||
private const val KEY_MODEL_INTL = "trae_default_model_intl"
|
||||
private const val DEFAULT_MODEL_INTL = "glm-5.2"
|
||||
|
||||
/** 内置兜底模型(参考 trae2api-cn 的模型映射 /v1/models 列表)。 */
|
||||
fun defaultModels(): List<TraeModel> = listOf(
|
||||
TraeModel("glm-5.2", "GLM-5.2"),
|
||||
TraeModel("glm-5.3", "GLM-5.3"),
|
||||
TraeModel("glm-5.1", "GLM-5.1"),
|
||||
TraeModel("glm-5", "GLM-5"),
|
||||
TraeModel("glm-4.7", "GLM-4.7"),
|
||||
TraeModel("glm-4.6", "GLM-4.6"),
|
||||
TraeModel("DeepSeek-V4-Pro", "DeepSeek-V4-Pro"),
|
||||
TraeModel("DeepSeek-V4-Pro-Official", "DeepSeek-V4-Pro 正式版"),
|
||||
TraeModel("DeepSeek-V4-Flash", "DeepSeek-V4-Flash"),
|
||||
TraeModel("DeepSeek-V4-Flash-Official", "DeepSeek-V4-Flash 正式版"),
|
||||
TraeModel("Doubao-Seed-2.1-Pro", "Doubao-Seed-2.1-Pro"),
|
||||
TraeModel("Doubao-Seed-2.1-Turbo", "Doubao-Seed-2.1-Turbo"),
|
||||
TraeModel("Doubao-Seed-Code", "Doubao-Seed-Code"),
|
||||
TraeModel("Doubao-Seed-Evolving", "Doubao-Seed-Evolving"),
|
||||
TraeModel("qwen-3.7-plus", "qwen-3.7-plus"),
|
||||
TraeModel("qwen-3.6-plus", "qwen-3.6-plus"),
|
||||
TraeModel("qwen3.8-max", "qwen3.8-max"),
|
||||
TraeModel("qwen-3.5", "qwen-3.5"),
|
||||
TraeModel("qwen3-coder", "qwen3-coder"),
|
||||
TraeModel("kimi-k2.6", "kimi-k2.6"),
|
||||
TraeModel("kimi-k3", "kimi-k3"),
|
||||
TraeModel("kimi-k2.7-code", "kimi-k2.7-code"),
|
||||
TraeModel("kimi-k2", "kimi-k2"),
|
||||
TraeModel("kimi-k2.5", "kimi-k2.5"),
|
||||
TraeModel("minimax-m3", "minimax-m3"),
|
||||
TraeModel("minimax-m2.7", "minimax-m2.7"),
|
||||
TraeModel("minimax-m2.1", "minimax-m2.1"),
|
||||
TraeModel("mimo-v2.5-pro", "mimo-v2.5-pro"),
|
||||
TraeModel("mimo-v2.5", "mimo-v2.5"),
|
||||
TraeModel("minimax-m25", "minimax-m25"),
|
||||
TraeModel("qwen36-35b", "qwen36-35b"),
|
||||
TraeModel("work", "Work(200K 档)"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,310 @@
|
||||
package com.rainy.token.data.proxy
|
||||
|
||||
import com.rainy.token.data.repository.CredentialRepository
|
||||
import com.rainy.token.data.repository.WorkBuddyRepository
|
||||
import com.rainy.token.domain.model.Credential
|
||||
import com.rainy.token.domain.service.ServiceType
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
|
||||
/**
|
||||
* WorkBuddy / CodeBuddy 上游 Chat 代理(国内版)。
|
||||
*
|
||||
* 上游协议为 OpenAI 兼容的 POST {base}/v2/chat/completions,Bearer accessToken。
|
||||
* 这里补齐桌面端请求头与上游 body 归一化,国际版先走 console 路由再退回 v2。
|
||||
*/
|
||||
@Singleton
|
||||
class WorkBuddyChatProxy @Inject constructor(
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val credentialRepository: CredentialRepository,
|
||||
private val workBuddyRepository: WorkBuddyRepository,
|
||||
) {
|
||||
|
||||
suspend fun forwardChat(
|
||||
requestBody: String,
|
||||
accountId: String? = null,
|
||||
forcedRegion: ProviderRegion? = null,
|
||||
): ProxyUpstreamResponse? =
|
||||
withContext(Dispatchers.IO) {
|
||||
val credential = pickCredential(accountId, forcedRegion)
|
||||
?: return@withContext null
|
||||
if (credential !is Credential.WorkBuddyCredential) return@withContext null
|
||||
val accessToken = credential.accessToken.trim().takeIf { it.isNotBlank() }
|
||||
?: return@withContext null
|
||||
|
||||
val effectiveRegion = forcedRegion ?: runCatching {
|
||||
ProviderRegion.valueOf(credential.region.uppercase())
|
||||
}.getOrNull()
|
||||
val base = if (effectiveRegion == ProviderRegion.INTL) {
|
||||
"https://www.workbuddy.ai"
|
||||
} else {
|
||||
"https://copilot.tencent.com"
|
||||
}
|
||||
val upstreamBody = prepareChatBody(requestBody, effectiveRegion == ProviderRegion.INTL)
|
||||
val intl = effectiveRegion == ProviderRegion.INTL
|
||||
val paths = if (intl) listOf("/console/chat/completions", "/v2/chat/completions") else listOf("/v2/chat/completions")
|
||||
var current = credential
|
||||
var token = accessToken
|
||||
for (path in paths) {
|
||||
var attempt = 0
|
||||
while (true) {
|
||||
val request = buildChatRequest(base, path, upstreamBody, token, intl)
|
||||
val response = try {
|
||||
okHttpClient.newCall(request).execute()
|
||||
} catch (e: java.io.IOException) {
|
||||
throw e
|
||||
}
|
||||
val status = response.code
|
||||
// 国际站 console 路由返回 404/405 时再退回共享 v2 路径
|
||||
if (status == 404 || status == 405) {
|
||||
response.close()
|
||||
break
|
||||
}
|
||||
if ((status == 401 || status == 403) && attempt == 0) {
|
||||
response.close()
|
||||
val refreshed = workBuddyRepository.refreshCredential(current)
|
||||
if (refreshed != null) {
|
||||
credentialRepository.save(refreshed, accountId)
|
||||
current = refreshed
|
||||
token = refreshed.accessToken
|
||||
attempt++
|
||||
continue
|
||||
}
|
||||
}
|
||||
val bytes = try { response.body?.bytes() ?: ByteArray(0) } catch (_: Throwable) { ByteArray(0) }
|
||||
val contentType = response.header("Content-Type") ?: "application/json"
|
||||
response.close()
|
||||
return@withContext ProxyUpstreamResponse(status, contentType, bytes)
|
||||
}
|
||||
}
|
||||
return@withContext null
|
||||
}
|
||||
suspend fun openStreamingChat(
|
||||
requestBody: String,
|
||||
accountId: String? = null,
|
||||
forcedRegion: ProviderRegion? = null,
|
||||
): ProxyUpstreamStream? = withContext(Dispatchers.IO) {
|
||||
val credential = pickCredential(accountId, forcedRegion)
|
||||
?: return@withContext null
|
||||
if (credential !is Credential.WorkBuddyCredential) return@withContext null
|
||||
val accessToken = credential.accessToken.trim().takeIf { it.isNotBlank() }
|
||||
?: return@withContext null
|
||||
|
||||
val effectiveRegion = forcedRegion ?: runCatching {
|
||||
ProviderRegion.valueOf(credential.region.uppercase())
|
||||
}.getOrNull()
|
||||
val base = if (effectiveRegion == ProviderRegion.INTL) {
|
||||
"https://www.workbuddy.ai"
|
||||
} else {
|
||||
"https://copilot.tencent.com"
|
||||
}
|
||||
val upstreamBody = prepareChatBody(requestBody, effectiveRegion == ProviderRegion.INTL)
|
||||
val intl = effectiveRegion == ProviderRegion.INTL
|
||||
val paths = if (intl) listOf("/console/chat/completions", "/v2/chat/completions") else listOf("/v2/chat/completions")
|
||||
var current = credential
|
||||
var token = accessToken
|
||||
for (path in paths) {
|
||||
var attempt = 0
|
||||
while (true) {
|
||||
val request = buildChatRequest(base, path, upstreamBody, token, intl)
|
||||
val response = try {
|
||||
okHttpClient.newCall(request).execute()
|
||||
} catch (e: java.io.IOException) {
|
||||
throw e
|
||||
}
|
||||
if (response.code == 404 || response.code == 405) {
|
||||
response.close()
|
||||
break
|
||||
}
|
||||
if ((response.code == 401 || response.code == 403) && attempt == 0) {
|
||||
response.close()
|
||||
val refreshed = workBuddyRepository.refreshCredential(current)
|
||||
if (refreshed != null) {
|
||||
credentialRepository.save(refreshed, accountId)
|
||||
current = refreshed
|
||||
token = refreshed.accessToken
|
||||
attempt++
|
||||
continue
|
||||
}
|
||||
}
|
||||
val input = response.body?.byteStream() ?: run {
|
||||
response.close()
|
||||
return@withContext null
|
||||
}
|
||||
return@withContext ProxyUpstreamStream(
|
||||
status = response.code,
|
||||
contentType = response.header("Content-Type") ?: "text/event-stream",
|
||||
input = input,
|
||||
close = { response.close() },
|
||||
)
|
||||
}
|
||||
}
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
/** 按区域选凭据:显式 accountId 优先;未指定时优先同区域账号,再兼容单账号旧档。 */
|
||||
private suspend fun pickCredential(
|
||||
accountId: String?,
|
||||
region: ProviderRegion?,
|
||||
): Credential.WorkBuddyCredential? {
|
||||
if (!accountId.isNullOrBlank()) {
|
||||
return credentialRepository.get(ServiceType.WORKBUDDY, accountId) as? Credential.WorkBuddyCredential
|
||||
}
|
||||
val accounts = credentialRepository.accountsFor(ServiceType.WORKBUDDY)
|
||||
if (accounts.isEmpty()) {
|
||||
val legacy = credentialRepository.get(ServiceType.WORKBUDDY, null) as? Credential.WorkBuddyCredential
|
||||
?: return null
|
||||
return if (region == null || legacy.region.equals(region.name, ignoreCase = true)) legacy else null
|
||||
}
|
||||
val all = accounts.mapNotNull { account ->
|
||||
credentialRepository.get(ServiceType.WORKBUDDY, account.id) as? Credential.WorkBuddyCredential
|
||||
}
|
||||
if (all.isEmpty()) return null
|
||||
if (region != null) {
|
||||
all.firstOrNull { it.region.equals(region.name, ignoreCase = true) }?.let { return it }
|
||||
}
|
||||
return all.first()
|
||||
}
|
||||
|
||||
private fun buildChatRequest(
|
||||
base: String,
|
||||
path: String,
|
||||
upstreamBody: String,
|
||||
accessToken: String,
|
||||
intl: Boolean,
|
||||
): Request {
|
||||
val origin = if (intl) "https://www.workbuddy.ai" else "https://www.codebuddy.cn"
|
||||
val requestId = UUID.randomUUID().toString()
|
||||
val builder = Request.Builder()
|
||||
.url(base + path)
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.addHeader("Accept", "application/json, text/event-stream")
|
||||
.addHeader("Authorization", "Bearer $accessToken")
|
||||
.addHeader("User-Agent", "CLI/2.63.2 CodeBuddy/2.63.2")
|
||||
.addHeader("X-Requested-With", "XMLHttpRequest")
|
||||
.addHeader("Origin", origin)
|
||||
.addHeader("Referer", origin + "/")
|
||||
.addHeader("X-CodeBuddy-Request", "1")
|
||||
.addHeader("Accept-Language", if (intl) "en-US" else "zh-CN")
|
||||
.addHeader("X-No-User-Id", "1")
|
||||
.addHeader("X-Agent-Purpose", "conversation")
|
||||
.addHeader("X-IDE-Name", "WorkBuddy")
|
||||
.addHeader("X-IDE-Type", "WorkBuddy")
|
||||
.addHeader("X-IDE-Version", "2.63.2")
|
||||
.addHeader("X-Product", "WorkBuddy")
|
||||
.addHeader("X-Request-ID", requestId)
|
||||
.addHeader("X-Conv-Request-ID", requestId)
|
||||
if (intl) {
|
||||
// 国际站强制声明个人客户端:不携带 enterprise,域固定为 www.workbuddy.ai
|
||||
builder.addHeader("X-No-Enterprise-Id", "1")
|
||||
builder.addHeader("X-Domain", "www.workbuddy.ai")
|
||||
} else {
|
||||
builder.addHeader("X-No-Enterprise-Id", "1")
|
||||
builder.addHeader("X-No-Department-Info", "1")
|
||||
}
|
||||
return builder.post(upstreamBody.toRequestBody("application/json".toMediaType())).build()
|
||||
}
|
||||
|
||||
/**
|
||||
* 对齐 dsh-workbuddy-connect 的上游归一化:
|
||||
* - 上游拒绝非流式,强制 stream=true;
|
||||
* - developer -> system(国内站直接拒绝 developer role);
|
||||
* - 国际站首条必须 system,缺省则前置空 system(避免 11128);
|
||||
* - max_completion_tokens 翻译成 max_tokens;
|
||||
* - tool_choice 只接受字符串,对象形式拍平成 auto/工具名/none。
|
||||
*/
|
||||
private fun prepareChatBody(body: String, intl: Boolean): String {
|
||||
val root = runCatching { Json.parseToJsonElement(body) as? JsonObject }.getOrNull() ?: return body
|
||||
if (root.isEmpty()) return body
|
||||
val out = LinkedHashMap(root)
|
||||
out["stream"] = JsonPrimitive(true)
|
||||
if (!out.containsKey("stream_options")) {
|
||||
out["stream_options"] = JsonObject(
|
||||
mapOf("include_usage" to JsonPrimitive(true))
|
||||
)
|
||||
}
|
||||
val maxCompletion = (root["max_completion_tokens"] as? JsonPrimitive)?.contentOrNull
|
||||
?.toLongOrNull()
|
||||
if (maxCompletion != null && !out.containsKey("max_tokens")) {
|
||||
out["max_tokens"] = JsonPrimitive(maxCompletion)
|
||||
}
|
||||
val messages = root["messages"] as? JsonArray
|
||||
if (messages != null) {
|
||||
var list = messages.map { msg ->
|
||||
val obj = msg as? JsonObject ?: return@map msg
|
||||
if ((obj["role"] as? JsonPrimitive)?.contentOrNull == "developer") {
|
||||
JsonObject(obj.entries.associate { (key, value) ->
|
||||
key to (if (key == "role") JsonPrimitive("system") else value)
|
||||
})
|
||||
} else {
|
||||
obj
|
||||
}
|
||||
}.toMutableList()
|
||||
if (intl) {
|
||||
val firstRole = (list.firstOrNull() as? JsonObject)?.get("role") as? JsonPrimitive
|
||||
if (firstRole?.contentOrNull != "system") {
|
||||
val fallback = JsonObject(
|
||||
mapOf(
|
||||
"role" to JsonPrimitive("system"),
|
||||
"content" to JsonPrimitive("You are a helpful assistant."),
|
||||
)
|
||||
)
|
||||
list.add(0, fallback)
|
||||
}
|
||||
}
|
||||
out["messages"] = JsonArray(list)
|
||||
}
|
||||
normalizeToolChoice(out)
|
||||
return JsonObject(out).toString()
|
||||
}
|
||||
|
||||
private fun normalizeToolChoice(out: MutableMap<String, kotlinx.serialization.json.JsonElement>) {
|
||||
val choice = out["tool_choice"] ?: return
|
||||
val suppress = {
|
||||
out.remove("tools")
|
||||
out.remove("functions")
|
||||
}
|
||||
if (choice is JsonPrimitive) {
|
||||
val s = choice.contentOrNull?.trim()?.lowercase()
|
||||
if (s == "none") {
|
||||
out.remove("tool_choice")
|
||||
suppress()
|
||||
}
|
||||
return
|
||||
}
|
||||
val obj = choice as? JsonObject
|
||||
if (obj != null) {
|
||||
val type = (obj["type"] as? JsonPrimitive)?.contentOrNull?.trim()?.lowercase()
|
||||
when (type) {
|
||||
"none" -> {
|
||||
out.remove("tool_choice")
|
||||
suppress()
|
||||
}
|
||||
"auto", "required" -> out["tool_choice"] = JsonPrimitive(type)
|
||||
"function" -> {
|
||||
val fn = obj["function"] as? JsonObject
|
||||
val name = ((fn?.get("name") as? JsonPrimitive)?.contentOrNull
|
||||
?: (obj["name"] as? JsonPrimitive)?.contentOrNull
|
||||
?: "").trim()
|
||||
out["tool_choice"] = JsonPrimitive(if (name.isNotEmpty()) name else "auto")
|
||||
}
|
||||
else -> out.remove("tool_choice")
|
||||
}
|
||||
return
|
||||
}
|
||||
out.remove("tool_choice")
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,177 @@
|
||||
package com.rainy.token.data.proxy
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
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.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import okhttp3.Headers.Companion.toHeaders
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
|
||||
/**
|
||||
* WorkBuddy / CodeBuddy 模型列表:
|
||||
* 在线接口 GET {base}/v2/enterprises/personal/models + 内置兜底 + 按区域记住选择。
|
||||
*/
|
||||
@Singleton
|
||||
class WorkBuddyModelProvider @Inject constructor(
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val credentialRepository: CredentialRepository,
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
|
||||
private val prefs: SharedPreferences =
|
||||
context.getSharedPreferences("rainytoken_proxy", Context.MODE_PRIVATE)
|
||||
|
||||
private val _modelsByRegion = MutableStateFlow(
|
||||
ProviderRegion.entries.associateWith { defaultModels(it) }
|
||||
)
|
||||
|
||||
/** 获取指定区域模型列表(在线拉取成功后该区域独立更新)。 */
|
||||
fun modelsFor(region: ProviderRegion): List<TraeModelProvider.TraeModel> =
|
||||
_modelsByRegion.value[region] ?: defaultModels(region)
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
fun selectedFor(region: ProviderRegion): String {
|
||||
val key = if (region == ProviderRegion.INTL) KEY_MODEL_INTL else KEY_MODEL_CN
|
||||
return prefs.getString(key, DEFAULT_MODEL) ?: DEFAULT_MODEL
|
||||
}
|
||||
|
||||
fun setSelectedFor(region: ProviderRegion, id: String) {
|
||||
if (id.isBlank()) return
|
||||
val key = if (region == ProviderRegion.INTL) KEY_MODEL_INTL else KEY_MODEL_CN
|
||||
prefs.edit().putString(key, id).apply()
|
||||
}
|
||||
|
||||
/** 该区域是否可能支持此模型(在线/内置列表)。 */
|
||||
fun supports(modelId: String, region: ProviderRegion): Boolean {
|
||||
val want = modelId.trim().lowercase()
|
||||
if (want.isBlank() || want == "auto") return true
|
||||
return modelsFor(region).any { it.id.equals(modelId, ignoreCase = true) }
|
||||
}
|
||||
|
||||
suspend fun refreshFor(region: ProviderRegion): Boolean = withContext(Dispatchers.IO) {
|
||||
val credential = pickCredential(region) ?: return@withContext false
|
||||
val accessToken = credential.accessToken.trim().takeIf { it.isNotBlank() }
|
||||
?: return@withContext false
|
||||
val intl = region == ProviderRegion.INTL
|
||||
val base = if (intl) "https://www.workbuddy.ai" else "https://copilot.tencent.com"
|
||||
// 国内站官方 CLI 吃 /console/enterprises/personal/models;国际站产品目录在 /v3/config,
|
||||
// 且必须用 App 形态 UA,否则网关拒发(实测 code 12403)。
|
||||
val url = base + if (intl) "/v3/config" else "/console/enterprises/personal/models"
|
||||
val origin = if (intl) "https://www.workbuddy.ai" else "https://www.codebuddy.cn"
|
||||
val headers = linkedMapOf(
|
||||
"Authorization" to "Bearer $accessToken",
|
||||
"Content-Type" to "application/json",
|
||||
"Accept" to "application/json",
|
||||
"X-Requested-With" to "XMLHttpRequest",
|
||||
"Origin" to origin,
|
||||
"Referer" to "$origin/",
|
||||
"X-CodeBuddy-Request" to "1",
|
||||
"Accept-Language" to (if (intl) "en-US" else "zh-CN"),
|
||||
"X-Client-ID" to "codebuddy-cli",
|
||||
"X-Client-Version" to "2.63.2",
|
||||
"X-Product" to (if (intl) "SaaS" else "WorkBuddy"),
|
||||
"User-Agent" to (if (intl) "WorkBuddyAI/5.5.2" else "CLI/2.63.2 CodeBuddy/2.63.2"),
|
||||
)
|
||||
val request = Request.Builder().url(url).headers(headers.toHeaders()).get().build()
|
||||
val parsed = try {
|
||||
okHttpClient.newCall(request).execute().use { resp ->
|
||||
if (!resp.isSuccessful) return@use null
|
||||
val body = resp.body?.string() ?: return@use null
|
||||
parseModelList(body)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
if (parsed.isNullOrEmpty()) return@withContext false
|
||||
_modelsByRegion.value = _modelsByRegion.value + (region to parsed)
|
||||
true
|
||||
}
|
||||
|
||||
private suspend fun pickCredential(region: ProviderRegion): Credential.WorkBuddyCredential? {
|
||||
val accounts = credentialRepository.accountsFor(ServiceType.WORKBUDDY)
|
||||
if (accounts.isEmpty()) {
|
||||
val legacy = credentialRepository.get(ServiceType.WORKBUDDY, null)
|
||||
return (legacy as? Credential.WorkBuddyCredential)
|
||||
?.takeIf { it.region.equals(region.name, ignoreCase = true) }
|
||||
}
|
||||
return accounts.mapNotNull { account ->
|
||||
credentialRepository.get(ServiceType.WORKBUDDY, account.id) as? Credential.WorkBuddyCredential
|
||||
}.firstOrNull { it.region.equals(region.name, ignoreCase = true) }
|
||||
}
|
||||
|
||||
private fun parseModelList(body: String): List<TraeModelProvider.TraeModel>? {
|
||||
val root = runCatching { json.parseToJsonElement(body) as? JsonObject }.getOrNull()
|
||||
?: return null
|
||||
// 国内站永远是 {code,msg,data} 包络;国际站 /v3/config 有时裸返回产品文档。
|
||||
val data = (root["data"] as? JsonObject) ?: root
|
||||
val rawModels = data["models"] as? JsonArray ?: return null
|
||||
val agents = data["agents"] as? JsonArray
|
||||
// 只暴露 cli agent 可用的模型(国际站 App 目录里 agents[].name=="cli" 限定模型名单)
|
||||
val cliIds = agents?.mapNotNull { it as? JsonObject }?.firstOrNull { agent ->
|
||||
(agent["name"] as? JsonPrimitive)?.contentOrNull == "cli"
|
||||
}?.get("models") as? JsonArray
|
||||
// 有 agents 文档但找不到 cli 列表时,不猜模型,避免把 App 只读模型塞给 CLI 反代
|
||||
if (agents != null && cliIds == null) return null
|
||||
val allowed = cliIds?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull }?.toSet()
|
||||
val seen = LinkedHashMap<String, TraeModelProvider.TraeModel>()
|
||||
for (rawEl in rawModels) {
|
||||
val raw = rawEl as? JsonObject ?: continue
|
||||
val id = (raw["id"] as? JsonPrimitive)?.contentOrNull?.trim()
|
||||
?.takeIf { it.isNotEmpty() } ?: continue
|
||||
if (allowed != null && id !in allowed) continue
|
||||
if (raw["disabled"] as? JsonPrimitive == JsonPrimitive(true)) continue
|
||||
val label = (raw["name"] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotBlank() } ?: id
|
||||
seen[id] = TraeModelProvider.TraeModel(id, label)
|
||||
}
|
||||
return seen.values.toList().ifEmpty { null }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEY_MODEL_CN = "wb_default_model_cn"
|
||||
private const val KEY_MODEL_INTL = "wb_default_model_intl"
|
||||
private const val DEFAULT_MODEL = "hy4-preview"
|
||||
|
||||
/** 内置兜底(在线列表成功后会被替换)。 */
|
||||
fun defaultModels(region: ProviderRegion): List<TraeModelProvider.TraeModel> =
|
||||
if (region == ProviderRegion.INTL) intlDefaultModels() else cnDefaultModels()
|
||||
|
||||
/** 国内站 CLI 模型兜底(参考 workbuddy-gateway 模型目录)。 */
|
||||
private fun cnDefaultModels(): List<TraeModelProvider.TraeModel> = listOf(
|
||||
TraeModelProvider.TraeModel("hy4-preview", "hy4-preview"),
|
||||
TraeModelProvider.TraeModel("hy3", "hy3"),
|
||||
TraeModelProvider.TraeModel("hy3-preview", "hy3-preview"),
|
||||
TraeModelProvider.TraeModel("deepseek-v4.1-flash", "deepseek-v4.1-flash"),
|
||||
)
|
||||
|
||||
/** 国际站 App 模型兜底(参考 cpa-multi-plugins 实测目录,避免刷新前误用国内模型名)。 */
|
||||
private fun intlDefaultModels(): List<TraeModelProvider.TraeModel> = listOf(
|
||||
TraeModelProvider.TraeModel("default-model", "Auto"),
|
||||
TraeModelProvider.TraeModel("fast-model", "Fast"),
|
||||
TraeModelProvider.TraeModel("balanced-model", "Balanced"),
|
||||
TraeModelProvider.TraeModel("primary-model", "Primary"),
|
||||
TraeModelProvider.TraeModel("deep-model", "Deep"),
|
||||
TraeModelProvider.TraeModel("hy4-preview-f", "Hy4 preview"),
|
||||
TraeModelProvider.TraeModel("hy3", "Hy3"),
|
||||
TraeModelProvider.TraeModel("deepseek-v4.1-flash", "Deepseek-V4.1-Flash"),
|
||||
TraeModelProvider.TraeModel("gpt-6-astra", "GPT-6-Astra"),
|
||||
TraeModelProvider.TraeModel("gpt-5.6-luna", "GPT-5.6-Luna"),
|
||||
TraeModelProvider.TraeModel("gemini-3.5-flash", "Gemini-3.5-Flash"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -3,6 +3,7 @@ package com.rainy.token.data.repository
|
||||
import com.rainy.token.data.cache.BalanceCache
|
||||
import com.rainy.token.domain.model.Credential
|
||||
import com.rainy.token.domain.model.ServiceBalance
|
||||
import com.rainy.token.domain.model.TriggerSummary
|
||||
import com.rainy.token.domain.service.ServiceConfigProvider
|
||||
import com.rainy.token.domain.service.ServiceType
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@ -10,6 +11,8 @@ 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
|
||||
@ -18,6 +21,8 @@ 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
|
||||
|
||||
@ -29,80 +34,374 @@ class CodexRepository(
|
||||
) {
|
||||
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())
|
||||
?: return@withContext Result.failure(RepositoryError.InvalidCredential("未找到 Codex 凭据"))
|
||||
if (credential !is Credential.CodexCredential)
|
||||
return@withContext Result.failure(RepositoryError.InvalidCredential())
|
||||
return@withContext Result.failure(RepositoryError.InvalidCredential("凭据类型不匹配"))
|
||||
|
||||
val effectiveCred = if (tokenNeedsRefresh(credential)) {
|
||||
val refreshed = refreshToken(credential)
|
||||
if (refreshed != null) { credentialRepository.save(refreshed); refreshed } else 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) {
|
||||
val retry = refreshToken(credential)
|
||||
if (retry != null) {
|
||||
credentialRepository.save(retry)
|
||||
try { fetchJson(WHAM_USAGE, retry.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)) }
|
||||
} else return@withContext Result.failure(e)
|
||||
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 = parseUsageWindows(usageResult)
|
||||
if (windows.isEmpty()) return@withContext Result.failure(RepositoryError.ParseError("未找到 Codex 用量窗口数据"))
|
||||
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 = windows.firstOrNull { it.label.contains("h") } ?: windows.first()
|
||||
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)
|
||||
}
|
||||
usageResult["plan_type"]?.jsonPrimitive?.content?.let { put("plan", it) }
|
||||
usageResult["credits"]?.jsonObject?.let { c ->
|
||||
c["balance"]?.jsonPrimitive?.floatOrNull?.let { put("usageCredits", it.toString()) }
|
||||
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, primary.remainingPct.toDouble(), config.displayUnit, true, null, null, primary.resetAt, extras)
|
||||
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 fun refreshToken(cred: Credential.CodexCredential): Credential.CodexCredential? {
|
||||
val bodyStr = json.encodeToString(OAuthRefreshRequest.serializer(),
|
||||
OAuthRefreshRequest("refresh_token", cred.refreshToken, CLIENT_ID, "openid profile email"))
|
||||
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/json")
|
||||
.post(bodyStr.toRequestBody("application/json".toMediaType())).build()
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.post(formBody).build()
|
||||
return try {
|
||||
okHttpClient.newCall(request).execute().use { resp ->
|
||||
if (!resp.isSuccessful) return@use null
|
||||
val tr = json.decodeFromString(OAuthRefreshResponse.serializer(), resp.body?.string() ?: return@use null)
|
||||
cred.copy(accessToken = tr.accessToken, refreshToken = tr.refreshToken,
|
||||
expiresAt = System.currentTimeMillis() + tr.expiresIn * 1000L, lastVerifiedAt = System.currentTimeMillis())
|
||||
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) { null }
|
||||
} 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 {
|
||||
@ -115,33 +414,66 @@ class CodexRepository(
|
||||
.header("Authorization", "Bearer $token").get().build()
|
||||
val resp = okHttpClient.newCall(request).execute()
|
||||
resp.use {
|
||||
if (!it.isSuccessful) throw if (it.code in listOf(401, 403)) RepositoryError.InvalidCredential() else RepositoryError.ServerError(it.code)
|
||||
return json.parseToJsonElement(it.body?.string() ?: throw RepositoryError.ParseError("响应体为空")).jsonObject
|
||||
}
|
||||
}
|
||||
|
||||
private data class UsageWindow(val label: String, val remainingPct: Int, val resetAt: Long?)
|
||||
|
||||
private fun parseUsageWindows(data: JsonObject): List<UsageWindow> {
|
||||
val result = mutableListOf<UsageWindow>()
|
||||
fun addWindows(rl: JsonObject?) {
|
||||
if (rl == null) return
|
||||
for (key in listOf("primary_window", "secondary_window")) {
|
||||
val w = rl[key]?.jsonObject ?: continue
|
||||
val usedPct = w["used_percent"]?.jsonPrimitive?.floatOrNull ?: continue
|
||||
val remaining = (100 - usedPct).toInt().coerceIn(0, 100)
|
||||
result.add(UsageWindow(durationLabel(w["limit_window_seconds"]?.jsonPrimitive?.longOrNull), remaining, w["reset_at"]?.jsonPrimitive?.longOrNull?.times(1000L)))
|
||||
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 对象")
|
||||
}
|
||||
addWindows(data["rate_limit"]?.jsonObject)
|
||||
(data["additional_rate_limits"] as? kotlinx.serialization.json.JsonArray)?.forEach { item ->
|
||||
if (item is JsonObject) addWindows(item["rate_limit"]?.jsonObject)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun durationLabel(seconds: Long?): String = when { seconds == null -> "Usage"; seconds / 60.0 >= 10079 -> "每周"; seconds / 60.0 >= 1439 -> "${(seconds / 86400).toInt()}d"; seconds / 60.0 >= 60 -> "${(seconds / 3600).toInt()}h"; else -> "${maxOf(1, (seconds / 60).toInt())}m" }
|
||||
@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)
|
||||
}
|
||||
|
||||
@Serializable data class OAuthRefreshRequest(@kotlinx.serialization.SerialName("grant_type") val grantType: String, @kotlinx.serialization.SerialName("refresh_token") val refreshToken: String, @kotlinx.serialization.SerialName("client_id") val clientId: String, val scope: String)
|
||||
@Serializable data class OAuthRefreshResponse(@kotlinx.serialization.SerialName("access_token") val accessToken: String, @kotlinx.serialization.SerialName("refresh_token") val refreshToken: String, @kotlinx.serialization.SerialName("expires_in") val expiresIn: Long, @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
|
||||
)
|
||||
}
|
||||
@ -19,7 +19,7 @@ import java.io.IOException
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* CommandCode Go 余额仓库。
|
||||
* CommandCode 余额仓库。
|
||||
*
|
||||
* 调 JSON API 获取月度配额余额 + 用量窗口信息:
|
||||
* GET https://api.commandcode.ai/alpha/billing/credits
|
||||
@ -52,7 +52,7 @@ class CommandCodeGoRepository(
|
||||
return@withContext Result.failure(RepositoryError.InvalidCredential())
|
||||
}
|
||||
|
||||
// 并行拉取 credits + subscriptions
|
||||
// 顺序拉取 credits + subscriptions(credits 是主要数据源,subscription 用于补充计划信息)
|
||||
val creditsResult = runCatching { fetchCredits(apiKey) }
|
||||
val subResult = runCatching { fetchSubscription(apiKey) }
|
||||
|
||||
@ -65,7 +65,7 @@ class CommandCodeGoRepository(
|
||||
|
||||
// 从订阅信息拿计划名称,查 plan catalog 拿总量
|
||||
val monthlyTotal = subResult.getOrNull()?.let { sub ->
|
||||
PLANS[sub.planId.lowercase()]
|
||||
planMonthlyQuota(sub.planId)
|
||||
}
|
||||
val billingPeriodEndMillis = subResult.getOrNull()?.let { parseIsoToEpoch(it.currentPeriodEnd) }
|
||||
|
||||
@ -187,24 +187,35 @@ class CommandCodeGoRepository(
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
private fun planDisplayName(planId: String): String =
|
||||
PLAN_NAMES[planId.lowercase()] ?: planId
|
||||
|
||||
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-pro" to 30.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)
|
||||
|
||||
@ -7,6 +7,7 @@ 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
|
||||
@ -21,7 +22,7 @@ import java.util.TimeZone
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* CommandCode Go 用量记录仓库。
|
||||
* CommandCode 用量记录仓库。
|
||||
*
|
||||
* 调 JSON API 分页抓取 usage 记录:
|
||||
* GET https://api.commandcode.ai/internal/usage?limit=50
|
||||
@ -35,7 +36,6 @@ class CommandCodeUsageRepository(
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val credentialRepository: CredentialRepository
|
||||
) {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val apiBase = "https://api.commandcode.ai"
|
||||
|
||||
companion object {
|
||||
@ -44,6 +44,121 @@ class CommandCodeUsageRepository(
|
||||
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 {
|
||||
@ -99,108 +214,18 @@ class CommandCodeUsageRepository(
|
||||
if (resp.code == 401 || resp.code == 403) {
|
||||
val detail = if (body != null && body.length < 200) ":$body" else ""
|
||||
return@withContext Result.failure(RepositoryError.InvalidCredential(
|
||||
RuntimeException("HTTP ${resp.code}$detail")
|
||||
"HTTP ${resp.code}$detail"
|
||||
))
|
||||
}
|
||||
return@withContext Result.failure(RepositoryError.ServerError(resp.code))
|
||||
}
|
||||
|
||||
if (body == null) return@withContext Result.failure(
|
||||
RepositoryError.ParseError("响应体为空")
|
||||
RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空")
|
||||
)
|
||||
|
||||
val records = parseUsageResponse(body)
|
||||
Result.success(records)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 JSON 响应。
|
||||
*/
|
||||
private 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 ->
|
||||
val obj = elem.jsonObject
|
||||
parseUsageObject(obj)
|
||||
}
|
||||
|
||||
// 从最后一条记录计算下一页 cursor
|
||||
val nextCursor = if (records.size >= PAGE_SIZE) {
|
||||
val last = records.last()
|
||||
encodeCursor(last.id, last.timeCreated)
|
||||
} else null
|
||||
|
||||
return records to nextCursor
|
||||
}
|
||||
|
||||
private 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 tokensTotal = obj["tokensTotal"]?.jsonPrimitive?.content?.toLongOrNull() ?: 0L
|
||||
|
||||
val creditsTotal = obj["creditsTotal"]?.jsonPrimitive?.content?.toDoubleOrNull() ?: 0.0
|
||||
val cost = (creditsTotal * COST_DENOM).toLong()
|
||||
|
||||
val meta = obj["meta"]?.jsonObject
|
||||
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(命中)
|
||||
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 val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'X'", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
|
||||
private val dateFormatNoMillis = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'X'", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
|
||||
private fun parseIsoDate(iso: String): Long? {
|
||||
// 处理末尾 Z 和时区偏移
|
||||
val normalized = iso
|
||||
.replace("Z", "X")
|
||||
.replace(Regex("""[+-]\d{2}:\d{2}$"""), "X")
|
||||
return try {
|
||||
dateFormat.parse(normalized)?.time
|
||||
?: dateFormatNoMillis.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())
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -1,13 +1,21 @@
|
||||
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
|
||||
@ -15,7 +23,7 @@ import java.util.TimeZone
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Ollama Pro Cloud 用量仓库。
|
||||
* Ollama Cloud 用量仓库。
|
||||
*
|
||||
* Ollama 没有公开的 Usage API(见 ollama/ollama#12532),
|
||||
* 本 Repository 通过 OkHttp 搓 Cookie 请求 https://ollama.com/settings,
|
||||
@ -36,8 +44,13 @@ class OllamaRepository(
|
||||
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"
|
||||
|
||||
@ -95,7 +108,7 @@ class OllamaRepository(
|
||||
if (sessionPct == null && weeklyPct == null) return null
|
||||
|
||||
return ParsedUsage(
|
||||
plan = plan ?: "Unknown",
|
||||
plan = plan.orEmpty(), // 空串由 UI 层兜底显示 "—",避免英文 "Unknown" 出现在中文 UI
|
||||
sessionPercent = sessionPct ?: 0f,
|
||||
weeklyPercent = weeklyPct ?: 0f,
|
||||
sessionResetAt = sessionResetAt,
|
||||
@ -165,13 +178,14 @@ class OllamaRepository(
|
||||
}
|
||||
|
||||
val html = resp.body?.string() ?: return@withContext Result.failure(
|
||||
RepositoryError.ParseError("响应体为空")
|
||||
RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空")
|
||||
)
|
||||
|
||||
val parsed = parseUsage(html)
|
||||
if (parsed == null) {
|
||||
return@withContext Result.failure(
|
||||
RepositoryError.ParseError(
|
||||
RepositoryError.ParseErrorReason.NO_WINDOWS,
|
||||
"解析失败:未找到 Ollama Cloud 用量数据。HTML=${html.length} 字节。Cookie 可能已过期。"
|
||||
)
|
||||
)
|
||||
@ -213,4 +227,119 @@ class OllamaRepository(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 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,15 +143,122 @@ 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)
|
||||
// 模型级用量作为增量增强:并行拉取 + 短超时,失败/超时不影响主窗口数据与缓存
|
||||
val modelUsage = fetchModelWindows(workspaceId, authCookie)
|
||||
if (modelUsage.isEmpty()) {
|
||||
return@withContext Result.success(balance)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 models.dev/api.json 获取 OpenCode Go 可用模型列表(provider key = "opencode-go")。
|
||||
* 该 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["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))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 一键激活用量:用 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")
|
||||
|
||||
/**
|
||||
@ -154,10 +288,14 @@ class OpenCodeGoRepository(
|
||||
val braceEnd = findMatchingBrace(html, braceStart) ?: continue
|
||||
val body = html.substring(braceStart, braceEnd + 1)
|
||||
|
||||
val pct = extractNumberAfterKey(body, "usagePercent")?.toIntOrNull()
|
||||
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)
|
||||
result[field] = ScrapedWindow(pct, reset, usage, limit)
|
||||
}
|
||||
}
|
||||
|
||||
@ -185,9 +323,23 @@ class OpenCodeGoRepository(
|
||||
/**
|
||||
* 在 body 字符串中找 "key:" 后面紧跟的数字(含可选小数)。返回数字字符串,未找到返回 null。
|
||||
* 跳过 status 字符串值("ok" 之类)。
|
||||
*
|
||||
* @param exactKey 为 true 时要求 key 后紧跟 ':'(精确字段匹配),
|
||||
* 避免 "usage" 误命中 "usagePercent" 这类前缀字段。
|
||||
*/
|
||||
private fun extractNumberAfterKey(body: String, key: String): String? {
|
||||
val keyIdx = body.indexOf(key)
|
||||
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
|
||||
// 跳过 ":" 后面所有非数字、非负号、非小数点字符
|
||||
@ -210,6 +362,203 @@ class OpenCodeGoRepository(
|
||||
return body.substring(start, i).ifEmpty { null }
|
||||
}
|
||||
|
||||
internal data class ScrapedWindow(val usagePercent: Int, val resetInSec: Long)
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,856 @@
|
||||
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 }
|
||||
|
||||
/** 拉取近 30 天按会话聚合的用量事件(官方 Usage Events 数据)。 */
|
||||
suspend fun fetchUsageEvents(
|
||||
accountId: String? = null,
|
||||
startSec: Long = System.currentTimeMillis() / 1000L - 30 * 86400L,
|
||||
endSec: Long = System.currentTimeMillis() / 1000L,
|
||||
pageNum: Int = 1,
|
||||
pageSize: Int = 20
|
||||
): Result<List<TraeUsageEvent>> = 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 = if (regionKey == "INTL") {
|
||||
val host = credential.apiHost?.takeIf { it.startsWith("http") }?.trimEnd('/')
|
||||
?: "https://grow-normal.trae.ai"
|
||||
host + "/trae/api/v1/pay/query_user_usage_group_by_session"
|
||||
} else {
|
||||
"https://api.trae.cn/trae/api/v1/pay/query_user_usage_group_by_session"
|
||||
}
|
||||
val body =
|
||||
"{\"start_time\":$startSec,\"end_time\":$endSec,\"page_size\":$pageSize,\"page_num\":$pageNum,\"usage_type\":[7]}"
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.addHeader("Authorization", "Cloud-IDE-JWT " + credential.jwt.trim())
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.post(body.toRequestBody("application/json".toMediaType()))
|
||||
.build()
|
||||
val text = try {
|
||||
okHttpClient.newCall(request).execute().use { resp ->
|
||||
val t = resp.body?.string().orEmpty()
|
||||
DebugLog.i(TAG, "fetchUsageEvents: http=${resp.code} len=${t.length}")
|
||||
if (!resp.isSuccessful) {
|
||||
return@withContext Result.failure(
|
||||
if (resp.code == 401 || resp.code == 403) authErrorFor(credential.jwt.trim())
|
||||
else mapHttpError(resp.code)
|
||||
)
|
||||
}
|
||||
t
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
return@withContext Result.failure(RepositoryError.Network(e))
|
||||
} catch (e: Throwable) {
|
||||
DebugLog.e(TAG, "fetchUsageEvents exception: " + e)
|
||||
return@withContext Result.failure(RepositoryError.Network(e))
|
||||
}
|
||||
val events = try {
|
||||
val root = json.parseToJsonElement(text).jsonObject
|
||||
root["user_usage_group_by_sessions"]?.jsonArray?.mapNotNull { el ->
|
||||
val obj = el.jsonObject
|
||||
val extra = obj["extra_info"]?.jsonObject
|
||||
TraeUsageEvent(
|
||||
usageTime = obj.scalarOr("usage_time")?.toLongOrNull() ?: 0L,
|
||||
modelName = obj.scalarOr("model_name").orEmpty(),
|
||||
mode = obj.scalarOr("mode").orEmpty(),
|
||||
userInputPreview = obj.scalarOr("user_input_preview").orEmpty(),
|
||||
creditsFloat = obj.numberOr("credits_float", "amount_float") ?: 0.0,
|
||||
costMoneyFloat = obj.numberOr("cost_money_float") ?: 0.0,
|
||||
inputToken = extra?.numberOr("input_token")?.toLong() ?: 0L,
|
||||
outputToken = extra?.numberOr("output_token")?.toLong() ?: 0L,
|
||||
cacheReadToken = extra?.numberOr("cache_read_token")?.toLong() ?: 0L,
|
||||
cacheWriteToken = extra?.numberOr("cache_write_token")?.toLong() ?: 0L
|
||||
)
|
||||
} ?: emptyList()
|
||||
} catch (e: Throwable) {
|
||||
DebugLog.e(TAG, "fetchUsageEvents parse error: " + e)
|
||||
emptyList()
|
||||
}
|
||||
Result.success(events.sortedByDescending { it.usageTime }.take(50))
|
||||
}
|
||||
|
||||
|
||||
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 = if (regionKey == "INTL") {
|
||||
val host = credential.apiHost?.takeIf { it.startsWith("http") }?.trimEnd('/')
|
||||
?: "https://grow-normal.trae.ai"
|
||||
host + "/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 }
|
||||
val isDollarBilling = root["is_dollar_usage_billing"]?.jsonPrimitive?.contentOrNull?.equals("true", true) == true
|
||||
if (isDollarBilling) extras["billing_mode"] = "dollar"
|
||||
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]"
|
||||
if (isDollarBilling) {
|
||||
packCount++
|
||||
if (packShown == 0) {
|
||||
extras["intl.plan_name"] = name
|
||||
extras["intl.billing_version"] = root["billing_version"]?.jsonPrimitive?.contentOrNull ?: ""
|
||||
packShown++
|
||||
}
|
||||
quota?.let { q ->
|
||||
q.getNumber("premium_model_fast_request_limit")?.let { extras["intl.premium_fast_limit"] = trimNum(it) }
|
||||
q.getNumber("premium_model_slow_request_limit")?.let { extras["intl.premium_slow_limit"] = trimNum(it) }
|
||||
q.getNumber("advanced_model_request_limit")?.let { extras["intl.advanced_limit"] = trimNum(it) }
|
||||
q.getNumber("auto_completion_limit")?.let { extras["intl.auto_completion_limit"] = trimNum(it) }
|
||||
q.getNumber("basic_usage_limit")?.let { extras["intl.basic_usage_limit"] = trimNum(it) }
|
||||
q.getNumber("basic_usage_limit")?.let { extras["intl.dollar_limit"] = it.toString() }
|
||||
q.getNumber("bonus_usage_limit")?.let { extras["intl.bonus_usage_limit"] = trimNum(it) }
|
||||
}
|
||||
pack.getNumber("usage", "basic_usage_amount")?.let { extras["intl.basic_used"] = trimNum(it) }
|
||||
pack.getNumber("usage", "basic_usage_amount")?.let { extras["intl.dollar_used"] = it.toString() }
|
||||
pack.getNumber("usage", "bonus_usage_amount")?.let { extras["intl.bonus_used"] = trimNum(it) }
|
||||
pack.getNumber("usage", "credits_amount")?.let { extras["intl.credits_used"] = trimNum(it) }
|
||||
return@forEachIndexed
|
||||
}
|
||||
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 && !isDollarBilling) {
|
||||
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()
|
||||
// 已签语义优先于「设备」判定:像「当前设备今日已经签到」这种文案虽然含“设备”,
|
||||
// 但语义是今日已签,必须按 ALREADY 落本地记录,避免误判 DEVICE_BLOCKED 反复重试。
|
||||
val alreadyMsg = msg.contains("已签到") || msg.contains("已经签到") || msg.contains("重复签到") ||
|
||||
msg.contains("重复") || msg.contains("不能重复") ||
|
||||
msg.contains("明日再来") || msg.contains("今日已完成") || msg.contains("已领取") ||
|
||||
lowerMsg.contains("already") || lowerMsg.contains("checked") || lowerMsg.contains("claimed") ||
|
||||
lowerMsg.contains("duplicate") || lowerMsg.contains("repeat")
|
||||
val deviceBlock = !alreadyMsg && (
|
||||
msg.contains("设备") || lowerMsg.contains("device") || lowerMsg.contains("machine")
|
||||
)
|
||||
// 权威口径幂等兜底码是 10001(trae-checkin-helper 逆向);历史实现用 1001,
|
||||
// 两者都认,避免把已签误判为 FAILED 硬错误。
|
||||
val already = (code == 1001L || code == 10001L) || alreadyMsg
|
||||
// 服务端临时限流/排队(账号、设备或活动人数限制):提示可稍后重试,不算硬错误。
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
/** 官方 Usage Events 按会话聚合的一条用量记录。 */
|
||||
data class TraeUsageEvent(
|
||||
val usageTime: Long,
|
||||
val modelName: String,
|
||||
val mode: String,
|
||||
val userInputPreview: String,
|
||||
val creditsFloat: Double,
|
||||
val costMoneyFloat: Double,
|
||||
val inputToken: Long,
|
||||
val outputToken: Long,
|
||||
val cacheReadToken: Long,
|
||||
val cacheWriteToken: Long
|
||||
)
|
||||
@ -0,0 +1,119 @@
|
||||
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 到缓存目录;同版本已下载完成时直接复用,不重复下载。 */
|
||||
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")
|
||||
if (file.exists() && file.length() > 0L) {
|
||||
return@withContext Result.success(file)
|
||||
}
|
||||
// 先写 .part,下载完整后再替换正式文件名,避免半截文件被当成完整包
|
||||
val part = File(dir, file.name + ".part")
|
||||
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()
|
||||
part.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (file.exists()) file.delete()
|
||||
part.renameTo(file)
|
||||
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 =
|
||||
"https://gitea.xxcsn.site/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,820 @@
|
||||
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 fun Credential.WorkBuddyCredential.isIntlRegion(): Boolean =
|
||||
region.equals("INTL", ignoreCase = true)
|
||||
|
||||
private fun Credential.WorkBuddyCredential.apiBase(): String =
|
||||
if (isIntlRegion()) "https://www.workbuddy.ai" else "https://copilot.tencent.com"
|
||||
|
||||
private fun Credential.WorkBuddyCredential.originBase(): String =
|
||||
if (isIntlRegion()) "https://www.workbuddy.ai" else "https://www.codebuddy.cn"
|
||||
|
||||
private fun Credential.WorkBuddyCredential.summaryUrl(): String =
|
||||
if (isIntlRegion()) "https://www.workbuddy.ai/billing/meter/get-user-resource-summary"
|
||||
else "https://www.workbuddy.cn/billing/meter/get-user-resource-summary"
|
||||
|
||||
private fun Credential.WorkBuddyCredential.resourceUrl(): String =
|
||||
if (isIntlRegion()) "https://www.workbuddy.ai/billing/meter/get-user-resource"
|
||||
else "https://www.codebuddy.cn/v2/billing/meter/get-user-resource"
|
||||
|
||||
private fun Credential.WorkBuddyCredential.refreshUrl(): String =
|
||||
apiBase() + "/v2/plugin/auth/token/refresh"
|
||||
|
||||
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(credential, access) else codebuddyRequest(credential, 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(credential, 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(credential, 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(credential: Credential.WorkBuddyCredential, accessToken: String): Map<String, String>? {
|
||||
val request = Request.Builder()
|
||||
.url(credential.resourceUrl())
|
||||
.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(credential: Credential.WorkBuddyCredential, accessToken: String): Request =
|
||||
Request.Builder()
|
||||
.url(credential.summaryUrl())
|
||||
.addHeader("Authorization", "Bearer $accessToken")
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.post("{}".toRequestBody(jsonMedia))
|
||||
.build()
|
||||
|
||||
/** codebuddy.cn 官方 CLI 插件模型(桌面/CLI accessToken 兜底)。 */
|
||||
private fun codebuddyRequest(credential: Credential.WorkBuddyCredential, accessToken: String): Request =
|
||||
Request.Builder()
|
||||
.url(credential.resourceUrl())
|
||||
.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(credential: Credential.WorkBuddyCredential, refreshToken: String): RefreshedWorkBuddy? {
|
||||
val request = Request.Builder()
|
||||
.url(credential.refreshUrl())
|
||||
.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
|
||||
}
|
||||
}
|
||||
|
||||
/** 供 Chat 代理等非余额路径使用的 token 刷新入口:成功返回新凭据(由调用方落库)。 */
|
||||
fun refreshCredential(credential: Credential.WorkBuddyCredential): Credential.WorkBuddyCredential? {
|
||||
val refreshed = tryRefesh(credential, credential.refreshToken) ?: return null
|
||||
return credential.copy(
|
||||
accessToken = refreshed.accessToken,
|
||||
refreshToken = refreshed.refreshToken,
|
||||
expiresAt = if (refreshed.expiresIn > 0L) {
|
||||
System.currentTimeMillis() + refreshed.expiresIn * 1000L
|
||||
} else {
|
||||
credential.expiresAt
|
||||
},
|
||||
lastVerifiedAt = System.currentTimeMillis()
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
/** 国际站无签到活动(活动未开启/未上线,非错误) */
|
||||
NO_CAMPAIGN
|
||||
}
|
||||
|
||||
/**
|
||||
* 签到状态摘要(来源: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())
|
||||
}
|
||||
// 国际版没有独立签到档案:积分来自网页/客户端活跃使用,直接返回“无活动”。
|
||||
if (credential.isIntlRegion()) {
|
||||
return@withContext Result.success(
|
||||
CheckinStatus(false, false, 0, 0, 0, "", "", CheckinOutcome.NO_CAMPAIGN)
|
||||
)
|
||||
}
|
||||
// 两条候选路径 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())
|
||||
}
|
||||
// 国际版不调用 daily-checkin(无签到营业),直接按无活动处理,避免每次刷新都报错。
|
||||
if (credential.isIntlRegion()) {
|
||||
return@withContext Result.success(
|
||||
CheckinStatus(false, false, 0, 0, 0, "", "", CheckinOutcome.NO_CAMPAIGN)
|
||||
)
|
||||
}
|
||||
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()
|
||||
val lowerMsg = msg.lowercase()
|
||||
// 国际站无签到活动:接口可能返回 data.active=false 或「活动未开启/已过期」。
|
||||
val inactive = runCatching {
|
||||
val r = json.parseToJsonElement(body).jsonObject
|
||||
((r["data"] as? JsonObject)?.get("active") as? JsonPrimitive)?.contentOrNull?.equals("false", true) == true
|
||||
}.getOrDefault(false)
|
||||
val noCampaign = inactive || msg.contains("活动未开启") || msg.contains("已过期") ||
|
||||
lowerMsg.contains("not active")
|
||||
// 已签到语义与上游 cpa-multi-plugins 对齐:code=10001 或文案含「已签/今日/already」。
|
||||
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
|
||||
)
|
||||
)
|
||||
noCampaign -> Result.success(
|
||||
(status ?: CheckinStatus(false, false, 0, 0, 0, "")).copy(
|
||||
message = msg,
|
||||
outcome = CheckinOutcome.NO_CAMPAIGN
|
||||
)
|
||||
)
|
||||
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(credential.originBase() + path)
|
||||
.addHeader("Authorization", "Bearer " + access)
|
||||
.addHeader("Accept", "application/json, text/plain, */*")
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.addHeader("User-Agent", "CLI/2.63.2 CodeBuddy/2.63.2")
|
||||
.addHeader("Origin", credential.originBase())
|
||||
.addHeader("Referer", credential.originBase() + "/profile/growth-center")
|
||||
.addHeader("X-CodeBuddy-Request", "1")
|
||||
.addHeader("Accept-Language", if (credential.isIntlRegion()) "en-US" else "zh-CN")
|
||||
.apply {
|
||||
if (credential.isIntlRegion()) {
|
||||
addHeader("X-Domain", "www.workbuddy.ai")
|
||||
addHeader("X-No-Enterprise-Id", "1")
|
||||
} else {
|
||||
addHeader("X-No-Enterprise-Id", "1")
|
||||
}
|
||||
}
|
||||
.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(credential, 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -17,6 +17,19 @@ 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.proxy.AccountPool
|
||||
import com.rainy.token.data.proxy.ProviderRouter
|
||||
import com.rainy.token.data.proxy.KtorLocalProxyServer
|
||||
import com.rainy.token.data.proxy.LocalProxyServer
|
||||
import com.rainy.token.data.proxy.Sub2ApiChatProxy
|
||||
import com.rainy.token.data.proxy.TraeChatProxy
|
||||
import com.rainy.token.data.proxy.TraeModelProvider
|
||||
import com.rainy.token.data.proxy.WorkBuddyChatProxy
|
||||
import com.rainy.token.data.proxy.WorkBuddyModelProvider
|
||||
import com.rainy.token.data.repository.WorkBuddyRepository
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
@ -122,7 +135,7 @@ object NetworkModule {
|
||||
): OpenCodeGoRepository = OpenCodeGoRepository(okHttpClient, credentialRepository, balanceCache)
|
||||
|
||||
/**
|
||||
* CommandCode Go 用量仓库。
|
||||
* CommandCode 用量仓库。
|
||||
*/
|
||||
@Provides
|
||||
@Singleton
|
||||
@ -132,7 +145,7 @@ object NetworkModule {
|
||||
): CommandCodeUsageRepository = CommandCodeUsageRepository(okHttpClient, credentialRepository)
|
||||
|
||||
/**
|
||||
* CommandCode Go 仓库:API Key 认证,调 JSON API。
|
||||
* CommandCode 仓库:API Key 认证,调 JSON API。
|
||||
*/
|
||||
@Provides
|
||||
@Singleton
|
||||
@ -155,7 +168,7 @@ object NetworkModule {
|
||||
): DeepSeekRepository = DeepSeekRepository(deepSeekApi, credentialRepository, balanceCache)
|
||||
|
||||
/**
|
||||
* Codex / ChatGPT Plus 额度仓库:用 OkHttp 调 chatgpt.com 后台 API。
|
||||
* Codex / ChatGPT 额度仓库:用 OkHttp 调 chatgpt.com 后台 API。
|
||||
*/
|
||||
@Provides
|
||||
@Singleton
|
||||
@ -166,7 +179,7 @@ object NetworkModule {
|
||||
): CodexRepository = CodexRepository(okHttpClient, credentialRepository, balanceCache)
|
||||
|
||||
/**
|
||||
* Ollama Pro Cloud 用量仓库:Cookie 认证 + OkHttp 抓 settings HTML 解析。
|
||||
* Ollama Cloud 用量仓库:Cookie 认证 + OkHttp 抓 settings HTML 解析。
|
||||
*/
|
||||
@Provides
|
||||
@Singleton
|
||||
@ -176,6 +189,114 @@ object NetworkModule {
|
||||
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)
|
||||
|
||||
// ---- 本地反代网关 ----
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideSub2ApiChatProxy(
|
||||
okHttpClient: OkHttpClient,
|
||||
credentialRepository: CredentialRepository
|
||||
): Sub2ApiChatProxy = Sub2ApiChatProxy(okHttpClient, credentialRepository)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideWorkBuddyChatProxy(
|
||||
okHttpClient: OkHttpClient,
|
||||
credentialRepository: CredentialRepository,
|
||||
workBuddyRepository: WorkBuddyRepository,
|
||||
): WorkBuddyChatProxy = WorkBuddyChatProxy(okHttpClient, credentialRepository, workBuddyRepository)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideTraeChatProxy(
|
||||
okHttpClient: OkHttpClient,
|
||||
credentialRepository: CredentialRepository,
|
||||
traeModelProvider: TraeModelProvider,
|
||||
): TraeChatProxy = TraeChatProxy(okHttpClient, credentialRepository, traeModelProvider)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideProviderRouter(
|
||||
sub2ApiChatProxy: Sub2ApiChatProxy,
|
||||
workBuddyChatProxy: WorkBuddyChatProxy,
|
||||
traeChatProxy: TraeChatProxy,
|
||||
traeModelProvider: TraeModelProvider,
|
||||
workBuddyModelProvider: WorkBuddyModelProvider,
|
||||
accountPool: AccountPool,
|
||||
): ProviderRouter = ProviderRouter(
|
||||
traeChatProxy,
|
||||
workBuddyChatProxy,
|
||||
sub2ApiChatProxy,
|
||||
traeModelProvider,
|
||||
workBuddyModelProvider,
|
||||
accountPool,
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAccountPool(
|
||||
credentialRepository: CredentialRepository
|
||||
): AccountPool = AccountPool(credentialRepository)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideLocalProxyServer(
|
||||
sub2ApiChatProxy: Sub2ApiChatProxy,
|
||||
workBuddyChatProxy: WorkBuddyChatProxy,
|
||||
traeChatProxy: TraeChatProxy,
|
||||
providerRouter: ProviderRouter,
|
||||
accountPool: AccountPool
|
||||
): LocalProxyServer = KtorLocalProxyServer(
|
||||
sub2ApiChatProxy,
|
||||
workBuddyChatProxy,
|
||||
traeChatProxy,
|
||||
providerRouter,
|
||||
accountPool
|
||||
)
|
||||
|
||||
/** 余额缓存 DataStore(计划 7.1) */
|
||||
@Provides
|
||||
@Singleton
|
||||
|
||||
@ -8,7 +8,7 @@ import kotlinx.serialization.Serializable
|
||||
*
|
||||
* - [ApiKeyCredential] 用于 DeepSeek(API Key 形式)
|
||||
* - [SessionCredential] 用于 OpenCode Zen/Go(用户登录 dashboard 后粘贴的值)
|
||||
* - [CodexCredential] 用于 Codex / ChatGPT Plus(完整 OAuth 凭据,含自动刷新)
|
||||
* - [CodexCredential] 用于 Codex / ChatGPT(完整 OAuth 凭据,含自动刷新)
|
||||
*/
|
||||
@Serializable
|
||||
sealed class Credential {
|
||||
@ -41,13 +41,15 @@ sealed class Credential {
|
||||
val authCookie: String? = null,
|
||||
/** OpenCode Go 专用:workspace ID */
|
||||
val workspaceId: String? = null,
|
||||
/** Ollama Pro 专用:完整的 Cookie 字符串(用户从浏览器 DevTools 复制) */
|
||||
/** Ollama 专用:完整的 Cookie 字符串(用户从浏览器 DevTools 复制) */
|
||||
val ollamaCookie: String? = null,
|
||||
/** OpenCode Go / Ollama 专用:API Key,用于一键激活用量 */
|
||||
val apiKey: String? = null,
|
||||
override val lastVerifiedAt: Long = 0L
|
||||
) : Credential()
|
||||
|
||||
/**
|
||||
* 用于 Codex / ChatGPT Plus:完整 OAuth 凭据,支持自动刷新。
|
||||
* 用于 Codex / ChatGPT:完整 OAuth 凭据,支持自动刷新。
|
||||
* 用户从 auth.json 粘贴整个 tokens 对象。
|
||||
*/
|
||||
@Serializable
|
||||
@ -60,6 +62,87 @@ sealed class Credential {
|
||||
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 / api-us-east.trae.ai) */
|
||||
val region: String = "CN",
|
||||
/** 登录回调携带的账号 API 主机(如 https://api-us-east.trae.ai);null=旧凭据用区域默认 */
|
||||
val apiHost: String? = null,
|
||||
/** 登录会话绑定的设备 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,
|
||||
/** 区域:"CN"(copilot.tencent.com / codebuddy.cn)/ "INTL"(workbuddy.ai) */
|
||||
val region: String = "CN",
|
||||
/** 过期时间(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,
|
||||
@ -66,11 +73,49 @@ object ServiceConfigProvider {
|
||||
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 = "$"
|
||||
)
|
||||
)
|
||||
|
||||
fun get(type: ServiceType): ServiceConfig =
|
||||
configs.getValue(type)
|
||||
|
||||
/**
|
||||
* 根据账号区域选择外部操作页:Trae 国际版用 trae.ai,国内版用 trae.cn。
|
||||
* 其余服务仍走静态配置。
|
||||
*/
|
||||
fun externalUrlFor(type: ServiceType, region: String? = null): String? =
|
||||
if (type == ServiceType.TRAE) {
|
||||
if (region.equals("INTL", ignoreCase = true)) {
|
||||
"https://www.trae.ai/pricing"
|
||||
} else {
|
||||
"https://www.trae.cn/pricing"
|
||||
}
|
||||
} else {
|
||||
get(type).externalUrl
|
||||
}
|
||||
|
||||
fun all(): List<ServiceConfig> = ServiceType.entries.map { get(it) }
|
||||
}
|
||||
@ -15,13 +15,27 @@ enum class ServiceType(
|
||||
val storageKey: String
|
||||
) {
|
||||
OPENCODE_GO("OpenCode Go", "opencode_go"),
|
||||
COMMANDCODE_GO("CommandCode Go", "commandcode_go"),
|
||||
COMMANDCODE_GO("CommandCode", "commandcode_go"),
|
||||
DEEPSEEK("DeepSeek", "deepseek"),
|
||||
CODEX("Codex / ChatGPT", "codex"),
|
||||
OLLAMA("Ollama Pro", "ollama");
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -2,39 +2,224 @@ 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)+ CommandCode Go(JSON API)
|
||||
* + Codex / ChatGPT Plus(wham usage API)+ Ollama Pro(Cookie 抓 settings HTML)
|
||||
* - **使用 Provider 注入**——规避 KSP 2.x 在多个 @Inject constructor Repository
|
||||
* 注入同一 UseCase 时的"could not be resolved"误报(KSP 已知 issue)。
|
||||
* Provider 让 Hilt 推迟创建 Repository 实例到第一次 .get() 时,KSP 不需要在
|
||||
* 编译期解析所有构造器签名。
|
||||
* - **重试策略**:对 Network 和 5xx ServerError 自动指数退避重试(最多 2 次),
|
||||
* 401/403/429/ParseError 不重试。
|
||||
* 唯一的余额刷新 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 commandCodeGoRepositoryProvider: Provider<CommandCodeGoRepository>,
|
||||
private val codexRepositoryProvider: Provider<CodexRepository>,
|
||||
private val ollamaRepositoryProvider: Provider<OllamaRepository>
|
||||
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 -> 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() }
|
||||
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 语义;提交失败不会伪装成业务成功。
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,7 +6,7 @@ import javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
|
||||
/**
|
||||
* CommandCode Go 用量同步 UseCase。
|
||||
* CommandCode 用量同步 UseCase。
|
||||
*
|
||||
* 游标协议:每页返回 (记录列表, 下一页游标)。
|
||||
* - cursor=null → 最新页
|
||||
@ -23,12 +23,18 @@ 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)
|
||||
@ -38,6 +44,7 @@ class SyncCommandCodeUsageUseCase @Inject constructor(
|
||||
}
|
||||
val (records, nextCursor) = pageResult.getOrThrow()
|
||||
if (records.isEmpty()) break
|
||||
if (nextCursor == cursor) break // 游标未前进,防死循环
|
||||
|
||||
val before = cache.count()
|
||||
cache.insertAll(records)
|
||||
@ -45,6 +52,7 @@ class SyncCommandCodeUsageUseCase @Inject constructor(
|
||||
|
||||
if (records.size < CommandCodeUsageRepository.PAGE_SIZE) break
|
||||
cursor = nextCursor
|
||||
if (++pages >= MAX_PAGES) break // 防御性上限,正常窗口不会触及
|
||||
}
|
||||
|
||||
return if (errors.isEmpty()) Result.success(SyncResult(inserted = totalInserted))
|
||||
@ -56,6 +64,7 @@ class SyncCommandCodeUsageUseCase @Inject constructor(
|
||||
val cache = cacheProvider.get()
|
||||
var cursor: String? = null
|
||||
var totalInserted = 0
|
||||
var pages = 0
|
||||
|
||||
while (true) {
|
||||
val pageResult = repo.fetchPage(cursor)
|
||||
@ -63,8 +72,11 @@ class SyncCommandCodeUsageUseCase @Inject constructor(
|
||||
|
||||
val (records, nextCursor) = pageResult.getOrThrow()
|
||||
if (records.isEmpty()) break
|
||||
if (nextCursor == cursor) break // 游标未前进,防死循环
|
||||
|
||||
val existingIds = cache.getAllIds()
|
||||
// 按 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
|
||||
@ -74,6 +86,7 @@ class SyncCommandCodeUsageUseCase @Inject constructor(
|
||||
|
||||
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 // 整页都已存在 → 接到旧数据
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
package com.rainy.token.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.animation.AnimatedContentTransitionScope
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
@ -18,37 +21,51 @@ 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
|
||||
|
||||
/**
|
||||
* 应用导航图。
|
||||
@ -62,19 +79,46 @@ import com.rainy.token.ui.webview.WebViewLoginScreen
|
||||
object Routes {
|
||||
const val DASHBOARD = "dashboard"
|
||||
const val SETTINGS = "settings"
|
||||
const val API_MANAGEMENT = "api_management"
|
||||
const val TIPS = "tips"
|
||||
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 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}®ion={region}"
|
||||
fun webviewLogin(type: ServiceType, accountId: String? = null, region: String? = null): String = buildString {
|
||||
append("webview_login/").append(type.name)
|
||||
if (!accountId.isNullOrBlank()) {
|
||||
append("?account=").append(Uri.encode(accountId))
|
||||
}
|
||||
if (!region.isNullOrBlank()) {
|
||||
append(if (accountId.isNullOrBlank()) "?" else "&").append("region=").append(Uri.encode(region))
|
||||
}
|
||||
}
|
||||
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 =
|
||||
@ -82,12 +126,27 @@ 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) : 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()
|
||||
}
|
||||
|
||||
private fun Context.openTraeUrl(url: String) {
|
||||
try {
|
||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@ -179,11 +238,39 @@ private fun CompactNavHost() {
|
||||
}
|
||||
) {
|
||||
composable(Routes.DASHBOARD) {
|
||||
val context = LocalContext.current
|
||||
val rechargeLabel = stringResource(R.string.action_recharge)
|
||||
DashboardScreen(
|
||||
onOpenSettings = { navController.navigate(Routes.SETTINGS) },
|
||||
onOpenService = { type -> navController.navigate(Routes.serviceDetail(type)) },
|
||||
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) }
|
||||
onOpenCcgoUsageDetail = { navController.navigate(Routes.CCGO_USAGE_DETAIL) },
|
||||
onOpenHeatmap = { navController.navigate(Routes.HEATMAP) },
|
||||
onOpenRecharge = { type, region ->
|
||||
val url = ServiceConfigProvider.externalUrlFor(type, region)
|
||||
if (url != null) {
|
||||
val intlTrae = type == ServiceType.TRAE && region.equals("INTL", ignoreCase = true)
|
||||
if (intlTrae) context.openTraeUrl(url) else navController.navigate(Routes.webviewPage(url, rechargeLabel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
composable(Routes.API_MANAGEMENT) {
|
||||
val context = LocalContext.current
|
||||
val rechargeLabel = stringResource(R.string.action_recharge)
|
||||
ApiManagementScreen(
|
||||
onBack = guardedPop,
|
||||
onConfigure = { type -> navController.navigate(credentialEntry(type)) },
|
||||
onRecharge = { type, region ->
|
||||
val url = ServiceConfigProvider.externalUrlFor(type, region)
|
||||
if (url != null) {
|
||||
val intlTrae = type == ServiceType.TRAE && region.equals("INTL", ignoreCase = true)
|
||||
if (intlTrae) context.openTraeUrl(url) else navController.navigate(Routes.webviewPage(url, rechargeLabel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
composable(Routes.USAGE_DETAIL) {
|
||||
@ -242,48 +329,144 @@ private fun CompactNavHost() {
|
||||
composable(Routes.SETTINGS) {
|
||||
SettingsScreen(
|
||||
onBack = guardedPop,
|
||||
onEditCredential = { type -> navController.navigate(Routes.credentialEdit(type)) },
|
||||
onOpenTips = { navController.navigate(Routes.TIPS) }
|
||||
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,
|
||||
accountId = accountId,
|
||||
onBack = guardedPop,
|
||||
onStartWebViewLogin = { service -> navController.navigate(Routes.webviewLogin(service)) },
|
||||
onWebViewLoginSuccess = { }
|
||||
onStartWebViewLogin = { svc, acc, region -> navController.navigate(Routes.webviewLogin(svc, acc, region)) },
|
||||
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
|
||||
},
|
||||
navArgument("region") {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
defaultValue = null
|
||||
}
|
||||
)
|
||||
) { backStackEntry ->
|
||||
val type = parseServiceType(backStackEntry.arguments?.getString("type"))
|
||||
val accountId = backStackEntry.arguments?.getString("account")
|
||||
val region = backStackEntry.arguments?.getString("region")
|
||||
WebViewLoginScreen(
|
||||
service = type,
|
||||
accountId = accountId,
|
||||
region = region,
|
||||
onBack = guardedPop,
|
||||
onLoginSucceeded = { 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 context = LocalContext.current
|
||||
val rechargeLabel = stringResource(R.string.action_recharge)
|
||||
ServiceDetailScreen(
|
||||
service = type,
|
||||
accountId = accountId,
|
||||
onBack = guardedPop,
|
||||
onConfigureCredential = { svc -> navController.navigate(Routes.credentialEdit(svc)) },
|
||||
onStartWebViewLogin = { svc -> navController.navigate(Routes.webviewLogin(svc)) }
|
||||
onSwitchAccount = { svc, acc ->
|
||||
// 切换账号时替换当前详情页(避免同路由栈堆积)
|
||||
navController.navigate(Routes.serviceDetail(svc, acc)) {
|
||||
popUpTo(Routes.SERVICE_DETAIL) { inclusive = true }
|
||||
}
|
||||
},
|
||||
onConfigureCredential = { svc -> navController.navigate(credentialEntry(svc)) },
|
||||
onStartWebViewLogin = { svc, acc, region -> navController.navigate(Routes.webviewLogin(svc, acc, region)) },
|
||||
onOpenRecharge = { svc, region ->
|
||||
val url = ServiceConfigProvider.externalUrlFor(svc, region)
|
||||
if (url != null) {
|
||||
val intlTrae = svc == ServiceType.TRAE && region.equals("INTL", ignoreCase = true)
|
||||
if (intlTrae) context.openTraeUrl(url) else 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -294,6 +477,20 @@ private fun CompactNavHost() {
|
||||
@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 context = LocalContext.current
|
||||
val rechargeLabel = stringResource(R.string.action_recharge)
|
||||
|
||||
Row(modifier = Modifier.fillMaxSize()) {
|
||||
Box(
|
||||
@ -303,9 +500,21 @@ private fun ExpandedLayout() {
|
||||
) {
|
||||
DashboardScreen(
|
||||
onOpenSettings = { detailPane = DetailPane.Settings },
|
||||
onOpenService = { type -> detailPane = DetailPane.ServiceDetail(type) },
|
||||
onOpenApiManagement = { detailPane = DetailPane.ApiManagement },
|
||||
onOpenService = { type, accountId ->
|
||||
detailPane = DetailPane.ServiceDetail(type, accountId)
|
||||
},
|
||||
onOpenUsageDetail = { detailPane = DetailPane.OCGOUsage },
|
||||
onOpenCcgoUsageDetail = { detailPane = DetailPane.CCGOUsage }
|
||||
onOpenCcgoUsageDetail = { detailPane = DetailPane.CCGOUsage },
|
||||
onOpenHeatmap = { detailPane = DetailPane.Heatmap },
|
||||
onOpenRecharge = { type, region ->
|
||||
val url = ServiceConfigProvider.externalUrlFor(type, region)
|
||||
if (url != null) {
|
||||
val intlTrae = type == ServiceType.TRAE && region.equals("INTL", ignoreCase = true)
|
||||
if (intlTrae) context.openTraeUrl(url) else detailPane = DetailPane.WebPage(url = url, title = rechargeLabel)
|
||||
}
|
||||
},
|
||||
viewModel = dashboardVm
|
||||
)
|
||||
}
|
||||
|
||||
@ -322,7 +531,11 @@ private fun ExpandedLayout() {
|
||||
) {
|
||||
ExpandedDetailPane(
|
||||
pane = detailPane,
|
||||
onClose = { detailPane = DetailPane.Empty }
|
||||
onClose = { detailPane = DetailPane.Empty },
|
||||
onSwitchAccount = { svc, acc ->
|
||||
detailPane = DetailPane.ServiceDetail(svc, acc)
|
||||
},
|
||||
onCredentialEditClosed = { dashboardVm.reloadLocalState() }
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -331,24 +544,62 @@ private fun ExpandedLayout() {
|
||||
@Composable
|
||||
private fun ExpandedDetailPane(
|
||||
pane: DetailPane,
|
||||
onClose: () -> Unit
|
||||
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("👈 选择服务查看详情", style = MaterialTheme.typography.titleMedium, color = inkMuted())
|
||||
Text("点击左侧卡片即可", style = MaterialTheme.typography.bodySmall, color = inkMuted())
|
||||
Text(stringResource(R.string.nav_empty_title), style = MaterialTheme.typography.titleMedium, color = inkMuted())
|
||||
Text(stringResource(R.string.nav_empty_subtitle), style = MaterialTheme.typography.bodySmall, color = inkMuted())
|
||||
}
|
||||
}
|
||||
}
|
||||
is DetailPane.ServiceDetail -> {
|
||||
ServiceDetailScreen(
|
||||
service = pane.type,
|
||||
onBack = onClose,
|
||||
onConfigureCredential = { onClose() },
|
||||
onStartWebViewLogin = { }
|
||||
)
|
||||
// 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 context = LocalContext.current
|
||||
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, region ->
|
||||
val url = ServiceConfigProvider.externalUrlFor(svc, region)
|
||||
if (url != null) {
|
||||
val intlTrae = svc == ServiceType.TRAE && region.equals("INTL", ignoreCase = true)
|
||||
if (intlTrae) context.openTraeUrl(url) else 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()
|
||||
@ -409,8 +660,150 @@ private fun ExpandedDetailPane(
|
||||
}
|
||||
}
|
||||
}
|
||||
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 context = LocalContext.current
|
||||
val rechargeLabel = LocalContext.current.getString(R.string.action_recharge)
|
||||
ApiManagementScreen(
|
||||
onBack = onClose,
|
||||
onConfigure = { type -> apiNavController.navigate(credentialEntry(type)) },
|
||||
onRecharge = { type, region ->
|
||||
val url = ServiceConfigProvider.externalUrlFor(type, region)
|
||||
if (url != null) {
|
||||
val intlTrae = type == ServiceType.TRAE && region.equals("INTL", ignoreCase = true)
|
||||
if (intlTrae) context.openTraeUrl(url) else 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, region ->
|
||||
apiNavController.navigate(Routes.webviewLogin(svc, acc, region))
|
||||
},
|
||||
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
|
||||
},
|
||||
navArgument("region") {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
defaultValue = null
|
||||
}
|
||||
)
|
||||
) { entry ->
|
||||
val type = parseServiceType(entry.arguments?.getString("type"))
|
||||
val accountId = entry.arguments?.getString("account")
|
||||
val region = entry.arguments?.getString("region")
|
||||
WebViewLoginScreen(
|
||||
service = type,
|
||||
accountId = accountId,
|
||||
region = region,
|
||||
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",
|
||||
@ -420,37 +813,93 @@ private fun ExpandedDetailPane(
|
||||
SettingsScreen(
|
||||
onBack = onClose,
|
||||
onEditCredential = { type ->
|
||||
settingsNavController.navigate(Routes.credentialEdit(type))
|
||||
settingsNavController.navigate(credentialEntry(type))
|
||||
},
|
||||
onOpenTips = { settingsNavController.navigate("tips") }
|
||||
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 })
|
||||
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 ->
|
||||
settingsNavController.navigate(Routes.webviewLogin(svc))
|
||||
onStartWebViewLogin = { svc, acc, region ->
|
||||
settingsNavController.navigate(Routes.webviewLogin(svc, acc, region))
|
||||
},
|
||||
onWebViewLoginSuccess = { }
|
||||
onWebViewLoginSuccess = { },
|
||||
onStartCodexOAuth = { settingsNavController.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
|
||||
},
|
||||
navArgument("region") {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
defaultValue = null
|
||||
}
|
||||
)
|
||||
) { entry ->
|
||||
val type = parseServiceType(entry.arguments?.getString("type"))
|
||||
val accountId = entry.arguments?.getString("account")
|
||||
val region = entry.arguments?.getString("region")
|
||||
WebViewLoginScreen(
|
||||
service = type,
|
||||
accountId = accountId,
|
||||
region = region,
|
||||
onBack = { settingsNavController.popBackStack() },
|
||||
onLoginSucceeded = { 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, String?) -> 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, row.region) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
) {
|
||||
// 有官方充值/订阅页的服务才展示充值入口(Trae 按账号区域分流)
|
||||
val rechargeUrl = remember(row.service, row.region) {
|
||||
runCatching { ServiceConfigProvider.externalUrlFor(row.service, row.region) }.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,185 @@
|
||||
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
|
||||
},
|
||||
region = when (type) {
|
||||
ServiceType.TRAE -> {
|
||||
(cred as? Credential.TraeCredential)?.region
|
||||
?: credentialRepository.accountsFor(type)
|
||||
.firstOrNull()
|
||||
?.let { (credentialRepository.get(type, it.id) as? Credential.TraeCredential)?.region }
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
)
|
||||
}
|
||||
_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,
|
||||
/** Trae 账号区域(用于选择官方充值/用量页)。 */
|
||||
val region: String? = null
|
||||
) {
|
||||
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()
|
||||
)
|
||||
@ -28,44 +28,50 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.annotation.StringRes
|
||||
import com.rainy.token.R
|
||||
import com.rainy.token.ui.theme.InkMuted
|
||||
import com.rainy.token.ui.theme.StrawberryPink
|
||||
|
||||
/**
|
||||
* APP 使用小技巧列表。
|
||||
* Dashboard 卡片上方随机展示一条,设置页可查看全部。
|
||||
*
|
||||
* 文案全部改为字符串资源 ID(@StringRes),由 UI 层按当前语言环境解析,
|
||||
* 保证英文系统下自动显示英文翻译。
|
||||
*/
|
||||
object AppTips {
|
||||
|
||||
data class Tip(
|
||||
val title: String,
|
||||
val hint: String,
|
||||
val detail: String
|
||||
@StringRes val titleRes: Int,
|
||||
@StringRes val hintRes: Int,
|
||||
@StringRes val detailRes: Int
|
||||
)
|
||||
|
||||
val tips: List<Tip> = listOf(
|
||||
Tip("长按卡片拖拽排序", "长按首页卡片可以拖拽排序", "在首页长按任意服务卡片即可拖拽到想要的位置,排序会自动保存。"),
|
||||
Tip("小组件点左上角进 APP", "小组件左上角标识可打开 APP", "桌面小组件左上角 RainyToken › 标识可以点击打开应用,其它区域点击切换服务。"),
|
||||
Tip("图表点击查看详情", "点击图表柱子可查看时段详情", "在用量图表上点击或滑动手指,可查看对应时段的详细数值。"),
|
||||
Tip("图表标题可点击", "点击图表标题弹出按模型明细", "图表卡片标题(如「消耗金额」)点击后会弹出按模型拆分的明细。"),
|
||||
Tip("切换图表时区", "用量详情页可切换 UTC+0 / UTC+8", "用量详情页顶部可切换 UTC+0 / UTC+8,图表标签会跟随切换。"),
|
||||
Tip("CCGO 清除并重新同步", "CCGO 详情页可清除缓存重新同步", "CCGO 用量详情页右上角「清除」可删除本地缓存并重新拉取全部数据。"),
|
||||
Tip("查看用量详情", "服务卡片底部可进入用量图表", "有凭据的服务卡片底部会出现「查看用量详情」入口,点击进入图表统计。"),
|
||||
Tip("原始数据浏览", "用量详情页右上角可查看原始记录", "用量详情页右上角「详细数据」可查看每条原始调用记录,支持分页跳转。"),
|
||||
Tip("下拉刷新联动用量", "下拉刷新会同步余额和用量", "首页下拉刷新不仅刷新余额,还会自动同步 OCGO / CCGO 用量记录。"),
|
||||
Tip("凭据获取教程", "凭据页底部有获取教程", "凭据编辑页底部的「如何获取」按钮有针对每个服务的详细操作步骤。"),
|
||||
Tip("剪贴板自动导入", "Codex 支持从剪贴板一键导入", "Codex 凭据页支持「从剪贴板自动导入」,粘贴 auth.json 后一键填入。"),
|
||||
Tip("详细数据页码跳转", "原始数据页可输入页码直跳", "原始数据页底部可输入页码直接跳转,数据量大时不必逐页翻。"),
|
||||
Tip("小组件自动刷新", "划到小组件会自动刷新余额", "桌面小组件划到负一屏时会自动刷新余额,也可手动点 ↻ 按钮刷新。"),
|
||||
Tip(R.string.tip_1_title, R.string.tip_1_hint, R.string.tip_1_detail),
|
||||
Tip(R.string.tip_2_title, R.string.tip_2_hint, R.string.tip_2_detail),
|
||||
Tip(R.string.tip_3_title, R.string.tip_3_hint, R.string.tip_3_detail),
|
||||
Tip(R.string.tip_4_title, R.string.tip_4_hint, R.string.tip_4_detail),
|
||||
Tip(R.string.tip_5_title, R.string.tip_5_hint, R.string.tip_5_detail),
|
||||
Tip(R.string.tip_6_title, R.string.tip_6_hint, R.string.tip_6_detail),
|
||||
Tip(R.string.tip_7_title, R.string.tip_7_hint, R.string.tip_7_detail),
|
||||
Tip(R.string.tip_8_title, R.string.tip_8_hint, R.string.tip_8_detail),
|
||||
Tip(R.string.tip_9_title, R.string.tip_9_hint, R.string.tip_9_detail),
|
||||
Tip(R.string.tip_10_title, R.string.tip_10_hint, R.string.tip_10_detail),
|
||||
Tip(R.string.tip_11_title, R.string.tip_11_hint, R.string.tip_11_detail),
|
||||
Tip(R.string.tip_12_title, R.string.tip_12_hint, R.string.tip_12_detail),
|
||||
Tip(R.string.tip_13_title, R.string.tip_13_hint, R.string.tip_13_detail),
|
||||
)
|
||||
|
||||
/** 首页轮换用的一句话文案。 */
|
||||
val shortHints: List<String> = tips.map { it.hint }
|
||||
/** 首页轮换用的一句话文案(资源 ID 形式)。 */
|
||||
val shortHintRes: List<Int> = tips.map { it.hintRes }
|
||||
|
||||
/** 随机取一条短文案。 */
|
||||
fun randomHint(): String = shortHints.random()
|
||||
/** 随机取一条短文案的资源 ID。 */
|
||||
fun randomHintRes(): Int = shortHintRes.random()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@ -77,7 +83,7 @@ fun TipsScreen(onBack: () -> Unit) {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
"使用小技巧",
|
||||
stringResource(R.string.title_tips),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
@ -86,7 +92,7 @@ fun TipsScreen(onBack: () -> Unit) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Icons.Filled.ArrowBack,
|
||||
contentDescription = "返回",
|
||||
contentDescription = stringResource(R.string.action_back),
|
||||
tint = StrawberryPink
|
||||
)
|
||||
}
|
||||
@ -123,14 +129,14 @@ fun TipsScreen(onBack: () -> Unit) {
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = tip.title,
|
||||
text = stringResource(tip.titleRes),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Text(
|
||||
text = tip.detail,
|
||||
text = stringResource(tip.detailRes),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = InkMuted
|
||||
)
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -6,42 +6,79 @@ import java.util.Locale
|
||||
* 共享格式化工具函数。
|
||||
*
|
||||
* 从 DashboardScreen / ServiceDetailScreen / WidgetProvider 提取,消除三处重复。
|
||||
*
|
||||
* 本地化策略:函数保持纯 JVM(不依赖 Context / Resources,可被单元测试直接调用),
|
||||
* 本地化标签通过 [DurationText] / 字符串参数注入。调用方(Compose / Widget)用
|
||||
* `context.getString(...)` 取得当前语言标签后传入;测试继续使用默认中文标签。
|
||||
*/
|
||||
|
||||
/** 格式化金额:整数无小数点,带小数保留 2 位 */
|
||||
/** 时长标签(用于 "X 天 Y 小时" 这类拼接)。 */
|
||||
data class DurationText(
|
||||
val day: String,
|
||||
val hour: String,
|
||||
val minute: String,
|
||||
/** 无有效时长时展示的占位符 */
|
||||
val dash: String = "—"
|
||||
)
|
||||
|
||||
/** 默认中文标签(保持与原实现输出完全一致,供单元测试与兜底使用)。 */
|
||||
val ChineseDurationText = DurationText(day = "天", hour = "小时", minute = "分")
|
||||
|
||||
/** 格式化金额:整数无小数点,带小数保留 2 位(金额数值格式固定 Locale.US,避免小数点歧义) */
|
||||
fun formatAmount(value: Double): String {
|
||||
return if (value % 1.0 == 0.0) value.toInt().toString()
|
||||
else String.format(Locale.US, "%.2f", value)
|
||||
}
|
||||
|
||||
/** 格式化重置倒计时(秒 → "X 天 Y 小时" / "X 小时 Y 分" / "Y 分"),无效返回 "—" */
|
||||
fun formatResetInSec(sec: Long): String {
|
||||
if (sec <= 0) return "—"
|
||||
fun formatResetInSec(sec: Long, text: DurationText = ChineseDurationText): String {
|
||||
if (sec <= 0) return text.dash
|
||||
val days = sec / 86400
|
||||
val hours = (sec % 86400) / 3600
|
||||
val minutes = (sec % 3600) / 60
|
||||
return when {
|
||||
days > 0 -> "$days 天 $hours 小时"
|
||||
hours > 0 -> "$hours 小时 $minutes 分"
|
||||
else -> "$minutes 分"
|
||||
days > 0 -> "$days ${text.day} $hours ${text.hour}"
|
||||
hours > 0 -> "$hours ${text.hour} $minutes ${text.minute}"
|
||||
else -> "$minutes ${text.minute}"
|
||||
}
|
||||
}
|
||||
|
||||
/** Widget 版本:逻辑相同但空值返回空串(非 "—"),适配 RemoteViews 布局 */
|
||||
fun formatResetForWidget(sec: Long): String {
|
||||
fun formatResetForWidget(sec: Long, text: DurationText = ChineseDurationText): String {
|
||||
if (sec <= 0) return ""
|
||||
val days = sec / 86400
|
||||
val hours = (sec % 86400) / 3600
|
||||
val minutes = (sec % 3600) / 60
|
||||
return when {
|
||||
days > 0 -> "$days 天 $hours 小时"
|
||||
hours > 0 -> "$hours 小时 $minutes 分"
|
||||
else -> "$minutes 分"
|
||||
days > 0 -> "$days ${text.day} $hours ${text.hour}"
|
||||
hours > 0 -> "$hours ${text.hour} $minutes ${text.minute}"
|
||||
else -> "$minutes ${text.minute}"
|
||||
}
|
||||
}
|
||||
|
||||
/** 标准化窗口标签:weekly → 每周,其他原样返回 */
|
||||
fun normalizeWindowLabel(label: String): String = when (label.lowercase()) {
|
||||
"weekly" -> "每周"
|
||||
/** 标准化窗口标签:weekly/每周 → 每周标签,monthly/每月 → 每月标签,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 容器层,所有页面共享同一层渐变。
|
||||
*/
|
||||
|
||||
@ -13,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
|
||||
|
||||
@ -42,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(
|
||||
@ -57,12 +59,12 @@ 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(
|
||||
painter = painterResource(id = R.drawable.ic_opencode_go_logo),
|
||||
contentDescription = "CommandCode Go",
|
||||
contentDescription = "CommandCode",
|
||||
modifier = Modifier.size((size * 0.85).dp, ((size * 0.85f * 30f / 54f)).dp)
|
||||
)
|
||||
}
|
||||
@ -112,10 +114,75 @@ fun ServiceIcon(
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.ic_ollama_logo),
|
||||
contentDescription = "Ollama Pro",
|
||||
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
|
||||
}
|
||||
@ -24,17 +24,20 @@ 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 Go 用量统计主卡片 —— 风格与 UsageStatsCard(OCGO)完全一致。
|
||||
* CommandCode 用量统计主卡片 —— 风格与 UsageStatsCard(OCGO)完全一致。
|
||||
* 仅展示核心指标:输入 Token(含 Cache)、输出 Token、总花费,
|
||||
* 外加同步按钮和"查看详情"入口。详细统计在 CCGO 专属详情页。
|
||||
*/
|
||||
@ -68,12 +71,12 @@ fun CommandCodeUsageStatsCard(
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "CommandCode 用量",
|
||||
text = stringResource(R.string.usage_title_commandcode),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Text(
|
||||
text = if (uiState.recordCount > 0) "${uiState.recordCount} 条记录" else "点击同步",
|
||||
text = if (uiState.recordCount > 0) stringResource(R.string.usage_record_count, uiState.recordCount) else stringResource(R.string.usage_click_to_sync),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = InkMuted
|
||||
)
|
||||
@ -88,7 +91,7 @@ fun CommandCodeUsageStatsCard(
|
||||
IconButton(onClick = { viewModel.sync() }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Refresh,
|
||||
contentDescription = "同步用量数据",
|
||||
contentDescription = stringResource(R.string.action_sync_usage),
|
||||
tint = StrawberryPink
|
||||
)
|
||||
}
|
||||
@ -101,13 +104,13 @@ fun CommandCodeUsageStatsCard(
|
||||
|
||||
// 输入 Token = input + cacheRead
|
||||
val inputTotal = overview.inputTokens + overview.cacheReadTokens
|
||||
MetricRow("输入 Token", formatTokenCount(inputTotal))
|
||||
MetricRow(stringResource(R.string.usage_input_tokens), formatTokenCount(inputTotal))
|
||||
|
||||
// 缓存子行
|
||||
if (overview.cacheReadTokens > 0 || overview.cacheWriteTokens > 0) {
|
||||
val parts = mutableListOf<String>()
|
||||
if (overview.cacheReadTokens > 0) parts.add("缓存读取 ${formatTokenCount(overview.cacheReadTokens)}")
|
||||
if (overview.cacheWriteTokens > 0) parts.add("缓存写入 ${formatTokenCount(overview.cacheWriteTokens)}")
|
||||
if (overview.cacheReadTokens > 0) parts.add(stringResource(R.string.usage_cache_read, formatTokenCount(overview.cacheReadTokens)))
|
||||
if (overview.cacheWriteTokens > 0) parts.add(stringResource(R.string.usage_cache_write, formatTokenCount(overview.cacheWriteTokens)))
|
||||
Text(
|
||||
text = parts.joinToString(" · "),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
@ -120,8 +123,8 @@ fun CommandCodeUsageStatsCard(
|
||||
|
||||
// 输出 Token + 总花费 同一排
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
MetricRow("输出 Token", formatTokenCount(overview.outputTokens), Modifier.weight(1f))
|
||||
MetricRow("总花费", "$${String.format(Locale.US, "%.4f", overview.totalCost / 100_000_000.0)}", Modifier.weight(1f))
|
||||
MetricRow(stringResource(R.string.usage_output_tokens), formatTokenCount(overview.outputTokens), Modifier.weight(1f))
|
||||
MetricRow(stringResource(R.string.usage_total_cost), "$${String.format(Locale.US, "%.4f", overview.totalCost / 100_000_000.0)}", Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
|
||||
@ -129,7 +132,7 @@ fun CommandCodeUsageStatsCard(
|
||||
if (uiState.overview == null && !uiState.syncing) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = if (uiState.loading) "加载中…" else "暂无数据,点击 🔄 同步",
|
||||
text = if (uiState.loading) stringResource(R.string.common_loading) else stringResource(R.string.usage_no_data_sync),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = InkMuted
|
||||
)
|
||||
@ -139,7 +142,7 @@ fun CommandCodeUsageStatsCard(
|
||||
if (uiState.overview != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
TextButton(onClick = onOpenDetail) {
|
||||
Text("查看详情", color = StrawberryPink)
|
||||
Text(stringResource(R.string.action_view_detail), color = StrawberryPink)
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Filled.KeyboardArrowRight,
|
||||
@ -154,7 +157,7 @@ fun CommandCodeUsageStatsCard(
|
||||
if (uiState.lastSyncResult > 0) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "✓ 新增 ${uiState.lastSyncResult} 条",
|
||||
text = stringResource(R.string.usage_new_records, uiState.lastSyncResult),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = StrawberryPink
|
||||
)
|
||||
@ -162,12 +165,22 @@ fun CommandCodeUsageStatsCard(
|
||||
uiState.lastSyncError?.let { err ->
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "✗ $err",
|
||||
text = "✗ ${err.asString()}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 刷新时间(与其余服务卡片底部一致) ───
|
||||
if (uiState.lastSyncAt > 0) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = usageUpdatedAtText(uiState.lastSyncAt),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = InkMuted
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,21 +19,27 @@ 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.offset
|
||||
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.verticalScroll
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
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.DateRange
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.SwitchDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
@ -42,6 +48,7 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
@ -67,23 +74,36 @@ import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.layout.positionInWindow
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.delay
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.rainy.token.data.repository.CredentialRepository
|
||||
import com.rainy.token.BuildConfig
|
||||
import com.rainy.token.R
|
||||
import com.rainy.token.domain.model.CredentialStatus
|
||||
import com.rainy.token.domain.model.ServiceBalance
|
||||
import com.rainy.token.domain.service.ServiceConfigProvider
|
||||
import com.rainy.token.domain.service.ServiceType
|
||||
import com.rainy.token.ui.components.ServiceIcon
|
||||
import com.rainy.token.ui.components.SiteIcon
|
||||
import com.rainy.token.ui.components.StatusChip
|
||||
import com.rainy.token.ui.components.StatusLevel
|
||||
import com.rainy.token.ui.components.StatusStyle
|
||||
import com.rainy.token.ui.components.AppTips
|
||||
import com.rainy.token.ui.components.asString
|
||||
import com.rainy.token.ui.dashboard.autocheckin.AutoCheckinManager
|
||||
import com.rainy.token.ui.theme.inkMuted
|
||||
import com.rainy.token.ui.theme.PureWhite
|
||||
import com.rainy.token.ui.theme.inkWarm
|
||||
import com.rainy.token.ui.theme.StrawberryPink
|
||||
import android.app.AppOpsManager
|
||||
import android.appwidget.AppWidgetManager
|
||||
@ -113,14 +133,32 @@ import java.util.Locale
|
||||
@Composable
|
||||
fun DashboardScreen(
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenService: (ServiceType) -> Unit,
|
||||
onOpenApiManagement: () -> Unit,
|
||||
onOpenService: (ServiceType, String?) -> Unit,
|
||||
onOpenUsageDetail: () -> Unit,
|
||||
onOpenCcgoUsageDetail: () -> Unit = {},
|
||||
onOpenHeatmap: () -> Unit = {},
|
||||
onOpenRecharge: (ServiceType, String?) -> Unit = { _, _ -> },
|
||||
viewModel: DashboardViewModel = hiltViewModel()
|
||||
) {
|
||||
// 从设置页返回时重新读取本地凭据状态 + 缓存;若有已配置但无余额缓存的
|
||||
// 新账号(刚登录/导入),ViewModel 会自动拉一次余额让卡片立刻有数据
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
|
||||
viewModel.reloadLocalState()
|
||||
}
|
||||
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
// 自动签到汇报(每日首次打开 App 签到后弹一次)
|
||||
val autoCheckinReport by viewModel.autoCheckinReport.collectAsStateWithLifecycle()
|
||||
// Sub2API 面板管理弹窗数据源
|
||||
val panelKeysState by viewModel.panelKeysState.collectAsStateWithLifecycle()
|
||||
val panelGroups by viewModel.panelGroups.collectAsStateWithLifecycle()
|
||||
val panelActionMessage by viewModel.panelActionMessage.collectAsStateWithLifecycle()
|
||||
val recentlyCreatedKey by viewModel.recentlyCreatedKey.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
var showAddWidgetConfirm by remember { mutableStateOf(false) }
|
||||
// 首页 Sub2API 卡片「面板管理」弹窗开关(内嵌与详情页同一个面板管理组件)
|
||||
var showSub2PanelManagement by remember { mutableStateOf(false) }
|
||||
// 一次性"长按拖拽排序"提示
|
||||
var showDragHint by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) {
|
||||
@ -136,8 +174,14 @@ fun DashboardScreen(
|
||||
?.split(',')
|
||||
?.filter { it.isNotBlank() }
|
||||
.orEmpty()
|
||||
// 兼容历史排序 id:
|
||||
// - 最早的格式 "service:{storageKey}"(单账号服务)→ "{storageKey}:"
|
||||
// - 迁移后旧格式 "{storageKey}:" / "{storageKey}:default" 以及任意 "{storageKey}:{账号id}"
|
||||
// (多账号服务)→ 服务组 id "group:{storageKey}"(叠放卡组)
|
||||
// - 其余(用量卡/热力图卡等)原样保留
|
||||
val migrated = saved.mapNotNull { id -> migrateOrderId(id) }.distinct()
|
||||
cardOrder.clear()
|
||||
cardOrder.addAll(saved)
|
||||
cardOrder.addAll(migrated)
|
||||
}
|
||||
|
||||
// 全局刷新触发器——每次 dashboard 刷新完成后 +1,UsageStatsCard 据此同步用量数据
|
||||
@ -154,8 +198,8 @@ fun DashboardScreen(
|
||||
if (showAddWidgetConfirm) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showAddWidgetConfirm = false },
|
||||
title = { Text("添加桌面小组件?") },
|
||||
text = { Text("确认后会请求桌面启动器添加雨晴Token小组件。部分系统仍会弹出系统确认窗口。") },
|
||||
title = { Text(stringResource(R.string.dialog_add_widget_title)) },
|
||||
text = { Text(stringResource(R.string.dialog_add_widget_body)) },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
@ -163,12 +207,51 @@ fun DashboardScreen(
|
||||
requestRainyTokenWidgetPin(context)
|
||||
}
|
||||
) {
|
||||
Text("确认添加", color = StrawberryPink)
|
||||
Text(stringResource(R.string.action_confirm_add), color = StrawberryPink)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showAddWidgetConfirm = false }) {
|
||||
Text("取消")
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 每日自动签到结果汇报:「xx 已签到,积分增加 x」/ 已签 / 失败原因
|
||||
if (autoCheckinReport != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = viewModel::consumeAutoCheckinReport,
|
||||
title = { Text(stringResource(R.string.auto_checkin_dialog_title)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
autoCheckinReport!!.forEach { r ->
|
||||
val serviceName = r.accountLabel?.let {
|
||||
r.service.displayName + " · " + it
|
||||
} ?: r.service.displayName
|
||||
Text(
|
||||
text = when {
|
||||
r.failed && r.message.isNotBlank() ->
|
||||
stringResource(R.string.auto_checkin_line_failed, serviceName, r.message)
|
||||
r.failed ->
|
||||
stringResource(R.string.auto_checkin_line_failed_generic, serviceName)
|
||||
r.newlyCheckedIn && r.creditsGained > 0 ->
|
||||
stringResource(R.string.auto_checkin_line_gained, serviceName, r.creditsGained)
|
||||
// 今日确实已签(成功或服务端回报已签)→ 「今日已签到」
|
||||
r.checkedInToday ->
|
||||
stringResource(R.string.auto_checkin_line_already, serviceName)
|
||||
// 其余(静默跳过等)不出现在汇报里,兜底走失败提示
|
||||
else ->
|
||||
stringResource(R.string.auto_checkin_line_failed_generic, serviceName)
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = viewModel::consumeAutoCheckinReport) {
|
||||
Text(stringResource(R.string.action_confirm), color = StrawberryPink)
|
||||
}
|
||||
}
|
||||
)
|
||||
@ -186,7 +269,7 @@ fun DashboardScreen(
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(
|
||||
text = "AI 余额一览",
|
||||
text = stringResource(R.string.dashboard_subtitle),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = inkMuted()
|
||||
)
|
||||
@ -196,7 +279,7 @@ fun DashboardScreen(
|
||||
IconButton(onClick = { showAddWidgetConfirm = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Add,
|
||||
contentDescription = "添加小组件到桌面",
|
||||
contentDescription = stringResource(R.string.action_add_widget),
|
||||
tint = StrawberryPink
|
||||
)
|
||||
}
|
||||
@ -206,14 +289,21 @@ fun DashboardScreen(
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Refresh,
|
||||
contentDescription = "刷新",
|
||||
contentDescription = stringResource(R.string.action_refresh),
|
||||
tint = StrawberryPink
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onOpenApiManagement) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Lock,
|
||||
contentDescription = stringResource(R.string.api_mgmt_title),
|
||||
tint = StrawberryPink
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onOpenSettings) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Settings,
|
||||
contentDescription = "设置",
|
||||
contentDescription = stringResource(R.string.action_settings),
|
||||
tint = StrawberryPink
|
||||
)
|
||||
}
|
||||
@ -231,7 +321,8 @@ fun DashboardScreen(
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
) {
|
||||
if (uiState.cards.isEmpty()) {
|
||||
// 仅初始加载时显示转圈;加载完即使没有已配置服务也渲染(展示用量卡片 + 空态引导)
|
||||
if (uiState.loading) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
@ -255,20 +346,62 @@ fun DashboardScreen(
|
||||
.padding(contentPadding),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// 随机小技巧提示(每次启动随机一条)
|
||||
val tipText = remember { AppTips.randomHint() }
|
||||
Text(
|
||||
text = "💡 $tipText",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = inkMuted(),
|
||||
modifier = Modifier.padding(horizontal = 4.dp)
|
||||
)
|
||||
// 随机小技巧提示(每次启动随机一条)+ 最右侧自动签到开关
|
||||
val tipTextRes = remember { AppTips.randomHintRes() }
|
||||
val autoCheckinEnabled by viewModel.autoCheckinEnabled.collectAsStateWithLifecycle()
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "💡 " + stringResource(tipTextRes),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = inkMuted(),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.auto_checkin_switch_label),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = inkWarm(),
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
Switch(
|
||||
checked = autoCheckinEnabled,
|
||||
onCheckedChange = viewModel::setAutoCheckinEnabled,
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = PureWhite,
|
||||
checkedTrackColor = StrawberryPink,
|
||||
checkedBorderColor = StrawberryPink,
|
||||
uncheckedThumbColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
uncheckedTrackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
uncheckedBorderColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
val items = rememberDashboardItems(
|
||||
cards = uiState.cards,
|
||||
order = cardOrder,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenUsageDetail = onOpenUsageDetail,
|
||||
onOpenCcgoUsageDetail = onOpenCcgoUsageDetail,
|
||||
onOpenService = onOpenService,
|
||||
onOpenHeatmap = onOpenHeatmap,
|
||||
onOpenSub2PanelManagement = { accountId ->
|
||||
viewModel.openSub2Panel(accountId)
|
||||
showSub2PanelManagement = true
|
||||
},
|
||||
onOpenRecharge = onOpenRecharge,
|
||||
refreshTrigger = usageSyncTrigger
|
||||
)
|
||||
DraggableDashboardCards(
|
||||
@ -307,13 +440,13 @@ fun DashboardScreen(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "💡 长按卡片可拖拽排序",
|
||||
text = stringResource(R.string.dashboard_drag_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = StrawberryPink,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = "知道了",
|
||||
text = stringResource(R.string.action_got_it),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = StrawberryPink,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
@ -328,6 +461,70 @@ fun DashboardScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sub2API 面板管理弹窗
|
||||
// 滚动说明:AlertDialog 的 text 槽是固定高度且**不自带滚动容器**,内容超过上限会被直接裁掉
|
||||
// (表现为「打开后滚不动、下面的 Key 看不到」)。这里给 text 槽加 verticalScroll + 高度上限,
|
||||
// 让整个面板管理内容(Key 列表/新建/分组/兑换)可以正常上下滚动。
|
||||
if (showSub2PanelManagement) {
|
||||
val scrollState = rememberScrollState()
|
||||
val maxDialogHeight = (LocalConfiguration.current.screenHeightDp * 0.72f).dp
|
||||
// 面板弹窗按当前选中的账号取余额:默认账号 accountId=null,多账号为具体 ID;
|
||||
// 找不到匹配卡(如刚新建账号尚无余额)时回退到第一张 Sub2API 卡,避免显示空。
|
||||
val panelAccountId by viewModel.sub2PanelAccountId.collectAsStateWithLifecycle()
|
||||
val panelBalanceCard = uiState.cards.firstOrNull {
|
||||
it.service == ServiceType.SUB2API && it.accountId == panelAccountId
|
||||
} ?: uiState.cards.firstOrNull { it.service == ServiceType.SUB2API }
|
||||
AlertDialog(
|
||||
onDismissRequest = { showSub2PanelManagement = false },
|
||||
// 标题与刷新按钮同一行:刷新重新拉取 Key 列表与分组
|
||||
title = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = stringResource(R.string.sub2_panel_manage_title),
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
IconButton(onClick = { viewModel.loadPanelKeys(); viewModel.loadPanelGroups() }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Refresh,
|
||||
contentDescription = stringResource(R.string.action_refresh)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
text = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = maxDialogHeight)
|
||||
.verticalScroll(scrollState)
|
||||
) {
|
||||
com.rainy.token.ui.servicedetail.Sub2ApiPanelManagement(
|
||||
balance = panelBalanceCard?.cachedBalance?.balance,
|
||||
keysState = panelKeysState,
|
||||
groups = panelGroups,
|
||||
actionMessage = panelActionMessage,
|
||||
createdKey = recentlyCreatedKey,
|
||||
onLoadKeys = { viewModel.loadPanelKeys() },
|
||||
onLoadGroups = { viewModel.loadPanelGroups() },
|
||||
onCreateKey = viewModel::createPanelKey,
|
||||
onSwitchGroup = viewModel::switchPanelGroup,
|
||||
onDeleteKey = viewModel::deletePanelKey,
|
||||
onRedeem = viewModel::redeemPanelCode,
|
||||
onConsumeMessage = viewModel::consumePanelActionMessage,
|
||||
onConsumeCreatedKey = viewModel::consumeRecentlyCreatedKey,
|
||||
showHeader = false
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { showSub2PanelManagement = false }) {
|
||||
Text(stringResource(R.string.action_close))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private const val DASHBOARD_ORDER_PREFS = "dashboard_card_order"
|
||||
@ -335,6 +532,7 @@ private const val DASHBOARD_ORDER_KEY = "order"
|
||||
private const val USAGE_OCGO_CARD_ID = "usage:opencode_go"
|
||||
private const val USAGE_CCGO_CARD_ID = "usage:commandcode_go"
|
||||
private const val DASHBOARD_CARD_SPACING_DP = 12
|
||||
private const val HEATMAP_CARD_ID = "heatmap"
|
||||
|
||||
private data class DashboardHomeItem(
|
||||
val id: String,
|
||||
@ -345,37 +543,128 @@ private data class DashboardHomeItem(
|
||||
private fun rememberDashboardItems(
|
||||
cards: List<DashboardCardUi>,
|
||||
order: List<String>,
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenUsageDetail: () -> Unit,
|
||||
onOpenCcgoUsageDetail: () -> Unit,
|
||||
onOpenService: (ServiceType) -> Unit,
|
||||
onOpenService: (ServiceType, String?) -> Unit,
|
||||
onOpenHeatmap: () -> Unit,
|
||||
onOpenSub2PanelManagement: (String?) -> Unit,
|
||||
onOpenRecharge: (ServiceType, String?) -> Unit,
|
||||
refreshTrigger: Int
|
||||
): List<DashboardHomeItem> {
|
||||
val defaultItems = buildList {
|
||||
add(DashboardHomeItem(USAGE_OCGO_CARD_ID) {
|
||||
UsageStatsCard(onOpenDetail = onOpenUsageDetail, refreshTrigger = refreshTrigger)
|
||||
})
|
||||
add(DashboardHomeItem(USAGE_CCGO_CARD_ID) {
|
||||
CommandCodeUsageStatsCard(onOpenDetail = onOpenCcgoUsageDetail, refreshTrigger = refreshTrigger)
|
||||
})
|
||||
// 未配置任何服务时的引导卡片(首页仅显示已配置的服务)
|
||||
if (cards.isEmpty()) {
|
||||
add(DashboardHomeItem("empty_hint") {
|
||||
EmptyServicesHintCard(onOpenSettings = onOpenSettings)
|
||||
})
|
||||
}
|
||||
// 用量卡与热力图卡只在对应服务已配置时出现:
|
||||
// 未配置 OCGO/CCGO 时不应展示它们的用量卡(此前无条件添加,导致未配置也占位)。
|
||||
val configuredServices = cards.map { it.service }.toSet()
|
||||
val ocgoConfigured = ServiceType.OPENCODE_GO in configuredServices
|
||||
val ccgoConfigured = ServiceType.COMMANDCODE_GO in configuredServices
|
||||
if (ocgoConfigured) {
|
||||
add(DashboardHomeItem(USAGE_OCGO_CARD_ID) {
|
||||
UsageStatsCard(onOpenDetail = onOpenUsageDetail, onOpenHeatmap = onOpenHeatmap, refreshTrigger = refreshTrigger)
|
||||
})
|
||||
}
|
||||
if (ccgoConfigured) {
|
||||
add(DashboardHomeItem(USAGE_CCGO_CARD_ID) {
|
||||
CommandCodeUsageStatsCard(onOpenDetail = onOpenCcgoUsageDetail, refreshTrigger = refreshTrigger)
|
||||
})
|
||||
}
|
||||
// 热力图统计的是 OCGO 数据,未配置 OCGO 时同样不展示
|
||||
if (ocgoConfigured) {
|
||||
add(DashboardHomeItem(HEATMAP_CARD_ID) {
|
||||
HeatmapEntryCard(onOpenHeatmap = onOpenHeatmap)
|
||||
})
|
||||
}
|
||||
// 服务卡片:每个账号一张普通卡(同服务多账号即多张连续卡片,不叠放)
|
||||
cards.forEach { card ->
|
||||
add(DashboardHomeItem("service:${card.service.storageKey}") {
|
||||
// 多账号卡片用“账号 N”兜底显示名(同服务内默认账号在前,按展示顺序编号)
|
||||
add(DashboardHomeItem(card.key) {
|
||||
DashboardCard(
|
||||
card = card,
|
||||
onClick = { onOpenService(card.service) },
|
||||
accountLabel = if (card.accountId != null) {
|
||||
card.accountLabel?.takeIf { it.isNotBlank() }
|
||||
?: stringResource(R.string.account_fallback_name, accountOrdinal(cards, card))
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = { onOpenService(card.service, card.accountId) },
|
||||
onOpenUsageDetail = when (card.service) {
|
||||
ServiceType.OPENCODE_GO -> onOpenUsageDetail
|
||||
ServiceType.COMMANDCODE_GO -> onOpenCcgoUsageDetail
|
||||
else -> null
|
||||
}
|
||||
},
|
||||
// Token 活动热力图入口仅 OCGO 服务卡片显示(数据只统计 OCGO)
|
||||
onOpenHeatmap = if (card.service == ServiceType.OPENCODE_GO) onOpenHeatmap else null,
|
||||
// Sub2API 面板管理快捷入口(首页弹窗):绑定当前卡片所属账号
|
||||
onOpenPanelManagement = if (card.service == ServiceType.SUB2API) {
|
||||
{ accountId -> onOpenSub2PanelManagement(accountId ?: card.accountId) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
// 充值/订阅入口:有官方 externalUrl 的服务(DeepSeek/Trae/WorkBuddy)在卡片上直开
|
||||
onOpenRecharge = if (ServiceConfigProvider.get(card.service).externalUrl != null || card.service == ServiceType.TRAE) {
|
||||
{ onOpenRecharge(card.service, card.region) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
val itemById = defaultItems.associateBy { it.id }
|
||||
val ordered = order.mapNotNull { itemById[it] }
|
||||
val ordered = order.mapNotNull { id ->
|
||||
// 回退兼容:叠放实验期间保存过 "group:{storageKey}" 排序 id 的用户,
|
||||
// 旧 id 映射为当前该服务第一张账号卡 key,避免升级回退后卡片掉尾
|
||||
if (id.startsWith("group:")) {
|
||||
val serviceKey = id.removePrefix("group:")
|
||||
defaultItems.firstOrNull { it.id.startsWith("$serviceKey:") && it.id != id }?.id ?: id
|
||||
} else {
|
||||
id
|
||||
}
|
||||
}.mapNotNull { itemById[it] }
|
||||
return ordered + defaultItems.filterNot { item -> ordered.any { it.id == item.id } }
|
||||
}
|
||||
|
||||
@Composable
|
||||
/** 同服务内账号序号(1 起,用于“账号 N”兜底显示)。 */
|
||||
private fun accountOrdinal(cards: List<DashboardCardUi>, card: DashboardCardUi): Int {
|
||||
var idx = 1
|
||||
for (c in cards) {
|
||||
if (c.key == card.key) break
|
||||
if (c.service == card.service) idx++
|
||||
}
|
||||
return idx
|
||||
}
|
||||
|
||||
/** 历史排序 id → 当前 id。返回 null 表示该 id 无法对应任何卡片(忽略)。 */
|
||||
private fun migrateOrderId(id: String): String? {
|
||||
// 最早格式:"service:{storageKey}"
|
||||
val servicePrefix = Regex("""^service:([^/:]+)$""").find(id)?.groupValues?.get(1)
|
||||
if (servicePrefix != null) {
|
||||
val svc = ServiceType.entries.firstOrNull { it.storageKey == servicePrefix } ?: return null
|
||||
return if (ServiceType.isMultiAccount(svc)) {
|
||||
"${svc.storageKey}:${CredentialRepository.LEGACY_DEFAULT_ACCOUNT_ID}"
|
||||
} else {
|
||||
"${svc.storageKey}:"
|
||||
}
|
||||
}
|
||||
// 旧默认账号 key:多账号服务 "{key}:" → "{key}:default"(其余账号 key 原样保留)
|
||||
val sep = id.indexOf(':')
|
||||
if (sep > 0) {
|
||||
val key = id.substring(0, sep)
|
||||
val svc = ServiceType.entries.firstOrNull { it.storageKey == key } ?: return id
|
||||
if (ServiceType.isMultiAccount(svc) && id.endsWith(":")) {
|
||||
return "$key:${CredentialRepository.LEGACY_DEFAULT_ACCOUNT_ID}"
|
||||
}
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DraggableDashboardCards(
|
||||
items: List<DashboardHomeItem>,
|
||||
@ -611,7 +900,15 @@ private fun DraggableDashboardCards(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DashboardCard(card: DashboardCardUi, onClick: () -> Unit, onOpenUsageDetail: (() -> Unit)? = null) {
|
||||
private fun DashboardCard(
|
||||
card: DashboardCardUi,
|
||||
accountLabel: String? = null,
|
||||
onClick: () -> Unit,
|
||||
onOpenUsageDetail: (() -> Unit)? = null,
|
||||
onOpenHeatmap: (() -> Unit)? = null,
|
||||
onOpenPanelManagement: ((String?) -> Unit)? = null,
|
||||
onOpenRecharge: (() -> Unit)? = null,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@ -623,20 +920,68 @@ private fun DashboardCard(card: DashboardCardUi, onClick: () -> Unit, onOpenUsag
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
// ─── 顶部:图标 + 名称 + 状态 chip ───
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
ServiceIcon(service = card.service, size = 44)
|
||||
if (card.service == ServiceType.SUB2API && card.siteIcon != null) {
|
||||
// Sub2API 面板登录后用站点真实 Logo 替换内置图标
|
||||
SiteIcon(url = card.siteIcon, size = 44.dp)
|
||||
} else {
|
||||
ServiceIcon(service = card.service, size = 44)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = card.service.displayName,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
// 多账号:默认账号在卡片标题旁标「默认」
|
||||
if (card.accountId != null && card.isDefaultAccount) {
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.account_default_badge),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = StrawberryPink,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(StrawberryPink.copy(alpha = 0.12f))
|
||||
.padding(horizontal = 6.dp, vertical = 1.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = card.service.displayName,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Text(
|
||||
text = secondaryLine(card),
|
||||
// Sub2API 显示登录方式;Trae/WorkBuddy 显示「账号 N · 订阅状态」;
|
||||
// 其余服务用固定描述
|
||||
text = when (card.service) {
|
||||
ServiceType.SUB2API -> sub2AuthModeText(card)
|
||||
ServiceType.TRAE, ServiceType.WORKBUDDY -> {
|
||||
val sub = subscriptionLine(card) ?: stringResource(secondaryLineRes(card))
|
||||
if (card.accountId != null && accountLabel != null) {
|
||||
"$accountLabel · $sub"
|
||||
} else {
|
||||
sub
|
||||
}
|
||||
}
|
||||
else -> stringResource(secondaryLineRes(card))
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = inkMuted()
|
||||
)
|
||||
}
|
||||
// 今日已签到角标(Trae/WorkBuddy):让用户在首页即知签到状态,不用点进详情
|
||||
if (card.checkedInToday) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.checkin_badge_today),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = StrawberryPink,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(StrawberryPink.copy(alpha = 0.12f))
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
StatusChip(style = card.statusBadgeStyle())
|
||||
}
|
||||
|
||||
@ -645,31 +990,204 @@ private fun DashboardCard(card: DashboardCardUi, onClick: () -> Unit, onOpenUsag
|
||||
// ─── 主体:服务特定的主信息 ───
|
||||
BalanceMainArea(card)
|
||||
|
||||
// 用量服务卡片底部:用量详情入口(仅当有凭证时显示)
|
||||
// 用量服务卡片底部:用量详情入口 + Token 活动快捷入口(仅当有凭证时显示)
|
||||
if (onOpenUsageDetail != null && card.credentialState != com.rainy.token.domain.model.CredentialStatus.State.NOT_CONFIGURED) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
TextButton(onClick = onOpenUsageDetail) {
|
||||
Text("查看用量详情", color = StrawberryPink)
|
||||
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 = onOpenUsageDetail) {
|
||||
Text(
|
||||
stringResource(R.string.action_view_usage_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))
|
||||
if (onOpenHeatmap != null) {
|
||||
// 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 底部:更新时间 / 错误信息 ───
|
||||
// ─── 底部:左侧更新时间/错误信息,右侧「面板管理 / 充值订阅」入口(同一行、右对齐)───
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = footerText(card),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (card.lastFetchError != null)
|
||||
MaterialTheme.colorScheme.error
|
||||
else
|
||||
inkMuted()
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = footerText(card).asString(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (card.lastFetchError != null)
|
||||
MaterialTheme.colorScheme.error
|
||||
else
|
||||
inkMuted(),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
// 占据全部剩余宽度,把右侧按钮顶到卡片最右边。
|
||||
// 注意不能再用一个 Spacer(weight) 平分剩余空间——那样按钮只会到中间。
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
if (card.service == ServiceType.SUB2API &&
|
||||
card.credentialState != CredentialStatus.State.NOT_CONFIGURED &&
|
||||
onOpenPanelManagement != null
|
||||
) {
|
||||
// 清零内边距让文字贴到卡片右内缘。
|
||||
// 注意:Material3 的最小尺寸挂在 TextButton 内层节点上,外层 modifier 传
|
||||
// defaultMinSize(0) 压不下去(实测为 no-op),这里不再写无用约束。
|
||||
TextButton(
|
||||
onClick = { onOpenPanelManagement?.invoke(card.accountId) },
|
||||
contentPadding = PaddingValues(0.dp)
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.sub2_panel_manage_title),
|
||||
color = StrawberryPink,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = StrawberryPink,
|
||||
// 图标盒即箭头可视范围;不用 offset 微调(offset 只改放置不改测量,
|
||||
// 会让右移的那几 dp 落在按钮命中区之外造成误触)。
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
// 充值/订阅入口(DeepSeek/Trae/WorkBuddy 等有官方 externalUrl 的服务):
|
||||
// 与「面板管理」同款式同位置,点开 App 内 WebView 到官方充值/订阅页
|
||||
if (onOpenRecharge != null &&
|
||||
card.credentialState != CredentialStatus.State.NOT_CONFIGURED
|
||||
) {
|
||||
TextButton(
|
||||
onClick = onOpenRecharge,
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
modifier = Modifier.padding(start = 8.dp)
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.action_subscribe_recharge),
|
||||
color = StrawberryPink,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = StrawberryPink,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Token 活动热力图入口卡片。
|
||||
* 白底圆角,左侧日历图标,中间标题,右侧箭头,点击跳转热力图页面。
|
||||
*/
|
||||
@Composable
|
||||
private fun HeatmapEntryCard(onOpenHeatmap: () -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onOpenHeatmap() },
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.DateRange,
|
||||
contentDescription = null,
|
||||
tint = StrawberryPink,
|
||||
modifier = Modifier.size(44.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(R.string.heatmap_entry),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.heatmap_entry_subtitle),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = inkMuted()
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = StrawberryPink
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 空态引导卡:首页仅显示已配置的服务,当一个都没配置时给出引导。
|
||||
*/
|
||||
@Composable
|
||||
private fun EmptyServicesHintCard(onOpenSettings: () -> 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(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.dashboard_empty_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.dashboard_empty_subtitle),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = inkMuted()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
OutlinedButton(onClick = onOpenSettings) {
|
||||
Text(stringResource(R.string.dashboard_empty_go_settings))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -687,13 +1205,13 @@ private fun DashboardFooter() {
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = "RainyToken · v1.0",
|
||||
text = stringResource(R.string.dashboard_footer_version, BuildConfig.VERSION_NAME),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = inkMuted()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "本地加密存储,不上传任何数据",
|
||||
text = stringResource(R.string.dashboard_footer_privacy),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = inkMuted()
|
||||
)
|
||||
@ -719,14 +1237,14 @@ private fun requestRainyTokenWidgetPin(context: Context) {
|
||||
val appWidgetManager = AppWidgetManager.getInstance(context)
|
||||
val component = ComponentName(context, OpenCodeGoWidgetProvider::class.java)
|
||||
if (!canInstallLauncherShortcut(context)) {
|
||||
Toast.makeText(context, "桌面快捷方式权限已被拒绝,请在系统设置中允许后重试", Toast.LENGTH_LONG).show()
|
||||
Toast.makeText(context, context.getString(R.string.toast_shortcut_permission_denied), Toast.LENGTH_LONG).show()
|
||||
return
|
||||
}
|
||||
|
||||
if (appWidgetManager.isRequestPinAppWidgetSupported) {
|
||||
val requested = appWidgetManager.requestPinAppWidget(component, null, null)
|
||||
if (!requested) {
|
||||
Toast.makeText(context, "桌面未接受添加请求,请检查启动器权限", Toast.LENGTH_LONG).show()
|
||||
Toast.makeText(context, context.getString(R.string.toast_add_rejected), Toast.LENGTH_LONG).show()
|
||||
}
|
||||
return
|
||||
}
|
||||
@ -738,6 +1256,6 @@ private fun requestRainyTokenWidgetPin(context: Context) {
|
||||
if (canOpenPicker) {
|
||||
context.startActivity(pickerIntent)
|
||||
} else {
|
||||
Toast.makeText(context, "当前桌面不支持应用内添加,请在桌面长按 → 小组件 → 查找雨晴Token", Toast.LENGTH_LONG).show()
|
||||
Toast.makeText(context, context.getString(R.string.toast_picker_unsupported), Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
@ -3,17 +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.domain.model.Credential
|
||||
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
|
||||
@ -22,86 +27,418 @@ 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),
|
||||
region = (local.credential as? Credential.TraeCredential)?.region
|
||||
)
|
||||
|
||||
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(
|
||||
@ -112,18 +449,65 @@ 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,
|
||||
/** 账号区域(Trae CN/INTL 等,用于选择官方充值/用量页)。 */
|
||||
val region: String? = null
|
||||
) {
|
||||
/** 余额展示主数字。优先取缓存,错误时也展示(不隐藏,让用户看到旧值 + 红点提示) */
|
||||
/** 余额展示主数字。优先取缓存,错误时也展示(不隐藏,让用户看到旧值 + 红点提示)。 */
|
||||
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()
|
||||
@ -1,6 +1,7 @@
|
||||
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
|
||||
@ -8,30 +9,43 @@ 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 ──
|
||||
@ -47,7 +61,7 @@ internal fun BalanceMainArea(card: DashboardCardUi) {
|
||||
color = inkMuted()
|
||||
)
|
||||
Text(
|
||||
text = "点击配置凭据",
|
||||
text = stringResource(R.string.service_click_to_configure),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = inkMuted()
|
||||
)
|
||||
@ -59,7 +73,7 @@ internal fun BalanceMainArea(card: DashboardCardUi) {
|
||||
color = inkMuted()
|
||||
)
|
||||
Text(
|
||||
text = "下拉刷新",
|
||||
text = stringResource(R.string.service_pull_to_refresh),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = inkMuted()
|
||||
)
|
||||
@ -84,6 +98,12 @@ internal fun BalanceMainArea(card: DashboardCardUi) {
|
||||
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(
|
||||
@ -102,14 +122,14 @@ internal fun BalanceMainArea(card: DashboardCardUi) {
|
||||
}
|
||||
if (!balance.isAvailable) {
|
||||
Text(
|
||||
text = "服务当前不可用",
|
||||
text = stringResource(R.string.service_unavailable),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
balance.monthlySpent?.let { spent ->
|
||||
Text(
|
||||
text = "本月已用 ${formatAmount(spent)}${balance.unit}",
|
||||
text = stringResource(R.string.service_monthly_used, formatAmount(spent), balance.unit),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = inkMuted()
|
||||
)
|
||||
@ -118,6 +138,113 @@ internal fun BalanceMainArea(card: DashboardCardUi) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trae 首页卡片主区:不显示总积分,分开显示「通用积分」(TraeCode/TraeWork 均可用)
|
||||
* 与「Work 专属积分」(仅 TraeWork),取 extras 中 general_credits/work_credits 小计
|
||||
* (与详情页 TraePacksCard 同口径)。旧缓存缺小计键时回退总积分展示,刷新后恢复。
|
||||
*/
|
||||
@Composable
|
||||
private fun TraeSplitMainBalance(balance: ServiceBalance) {
|
||||
val extras = balance.extras
|
||||
if (extras?.get("billing_mode") == "dollar") {
|
||||
TraeDollarPlanSummary(extras)
|
||||
return
|
||||
}
|
||||
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?, unit: String = "Credits") {
|
||||
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 = unit,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = inkMuted(),
|
||||
modifier = Modifier.padding(bottom = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TraeDollarPlanSummary(extras: Map<String, String>) {
|
||||
val planName = extras["intl.plan_name"] ?: "Free plan"
|
||||
Text(
|
||||
text = planName,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = inkMuted()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
val dollarUsed = extras["intl.dollar_used"]?.toDoubleOrNull()
|
||||
val dollarLimit = extras["intl.dollar_limit"]?.toDoubleOrNull()
|
||||
if (dollarLimit != null) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.trae_intl_dollar_usage_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = inkMuted()
|
||||
)
|
||||
Text(
|
||||
text = String.format(Locale.US, "\$%.2f / \$%.2f", dollarUsed ?: 0.0, dollarLimit),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
TraeSplitLine(stringResource(R.string.trae_intl_premium_fast_title), extras["intl.premium_fast_limit"]?.toDoubleOrNull(), stringResource(R.string.trae_intl_quota_unit))
|
||||
TraeSplitLine(stringResource(R.string.trae_intl_premium_slow_title), extras["intl.premium_slow_limit"]?.toDoubleOrNull(), stringResource(R.string.trae_intl_quota_unit))
|
||||
TraeSplitLine(stringResource(R.string.trae_intl_advanced_title), extras["intl.advanced_limit"]?.toDoubleOrNull(), stringResource(R.string.trae_intl_quota_unit))
|
||||
TraeSplitLine(stringResource(R.string.trae_intl_auto_completion_title), extras["intl.auto_completion_limit"]?.toDoubleOrNull(), stringResource(R.string.trae_intl_quota_unit))
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun OpenCodeGoMainBalance(balance: ServiceBalance) {
|
||||
Row(verticalAlignment = Alignment.Bottom) {
|
||||
@ -136,7 +263,7 @@ internal fun OpenCodeGoMainBalance(balance: ServiceBalance) {
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "5h 用量",
|
||||
text = stringResource(R.string.window_5h_usage),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = inkMuted(),
|
||||
modifier = Modifier.padding(bottom = 6.dp)
|
||||
@ -147,9 +274,9 @@ internal fun OpenCodeGoMainBalance(balance: ServiceBalance) {
|
||||
@Composable
|
||||
internal fun OpenCodeGoUsageWindows(balance: ServiceBalance) {
|
||||
val windows = listOf(
|
||||
Triple("5 小时", balance.extras["rolling.pct"]?.toIntOrNull(), balance.extras["rolling.resetInSec"]?.toLongOrNull()),
|
||||
Triple("本周", balance.extras["weekly.pct"]?.toIntOrNull(), balance.extras["weekly.resetInSec"]?.toLongOrNull()),
|
||||
Triple("本月", balance.extras["monthly.pct"]?.toIntOrNull(), balance.extras["monthly.resetInSec"]?.toLongOrNull())
|
||||
Triple(stringResource(R.string.window_5h), balance.extras["rolling.pct"]?.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) ->
|
||||
@ -181,7 +308,7 @@ internal fun CommandCodeGoMainBalance(balance: ServiceBalance) {
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "剩余",
|
||||
text = stringResource(R.string.service_remaining),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = inkMuted(),
|
||||
modifier = Modifier.padding(bottom = 6.dp)
|
||||
@ -189,7 +316,7 @@ internal fun CommandCodeGoMainBalance(balance: ServiceBalance) {
|
||||
if (total != null && total > 0) {
|
||||
val used = total - balance.amount
|
||||
Text(
|
||||
text = " · 已用 ${formatAmount(used)} / 共 ${formatAmount(total)}",
|
||||
text = stringResource(R.string.service_used_total, formatAmount(used), formatAmount(total)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = inkMuted(),
|
||||
modifier = Modifier.padding(bottom = 6.dp)
|
||||
@ -206,9 +333,9 @@ internal fun CommandCodeGoUsageWindows(balance: ServiceBalance) {
|
||||
return ((used / cap) * 100).toInt().coerceIn(0, 100)
|
||||
}
|
||||
val windows = listOf(
|
||||
Triple("5 小时", calcPct(extras["fiveHour.used"]?.toDoubleOrNull(), extras["fiveHour.cap"]?.toDoubleOrNull()), extras["fiveHour.resetInSec"]?.toLongOrNull()),
|
||||
Triple("本周", calcPct(extras["weekly.used"]?.toDoubleOrNull(), extras["weekly.cap"]?.toDoubleOrNull()), extras["weekly.resetInSec"]?.toLongOrNull()),
|
||||
Triple("本月", calcPct(balance.monthlySpent, balance.totalQuota), extras["monthly.resetInSec"]?.toLongOrNull())
|
||||
Triple(stringResource(R.string.window_5h), calcPct(extras["fiveHour.used"]?.toDoubleOrNull(), extras["fiveHour.cap"]?.toDoubleOrNull()), extras["fiveHour.resetInSec"]?.toLongOrNull()),
|
||||
Triple(stringResource(R.string.window_weekly), calcPct(extras["weekly.used"]?.toDoubleOrNull(), extras["weekly.cap"]?.toDoubleOrNull()), extras["weekly.resetInSec"]?.toLongOrNull()),
|
||||
Triple(stringResource(R.string.window_monthly), calcPct(balance.monthlySpent, balance.totalQuota), extras["monthly.resetInSec"]?.toLongOrNull())
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
windows.forEach { (label, pct, resetSec) ->
|
||||
@ -223,9 +350,15 @@ internal fun CommandCodeGoUsageWindows(balance: ServiceBalance) {
|
||||
|
||||
@Composable
|
||||
internal fun CodexMainBalance(balance: ServiceBalance) {
|
||||
val plan = balance.extras["plan"]?.let {
|
||||
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),
|
||||
@ -242,14 +375,14 @@ internal fun CodexMainBalance(balance: ServiceBalance) {
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "5h 剩余",
|
||||
text = stringResource(R.string.service_used_label, primaryLabel),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = inkMuted(),
|
||||
modifier = Modifier.padding(bottom = 6.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "· $plan",
|
||||
text = stringResource(R.string.service_plan_suffix, plan),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = inkMuted(),
|
||||
modifier = Modifier.padding(bottom = 6.dp)
|
||||
@ -257,6 +390,24 @@ internal fun CodexMainBalance(balance: ServiceBalance) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 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
|
||||
@ -265,32 +416,72 @@ internal fun CodexUsageWindows(balance: ServiceBalance) {
|
||||
.distinct()
|
||||
.maxOrNull()?.plus(1) ?: 0
|
||||
|
||||
val weeklyLabel = stringResource(R.string.window_every_week)
|
||||
val monthlyLabel = stringResource(R.string.window_every_month)
|
||||
val 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 label = normalizeWindowLabel(extras["window_$i.label"] ?: "Usage")
|
||||
val remainingPct = extras["window_$i.remainingPct"]?.toIntOrNull()
|
||||
val resetAt = extras["window_$i.resetAt"]?.toLongOrNull()?.takeIf { it > 0 }
|
||||
Triple(label, remainingPct, resetAt)
|
||||
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 = "Usage", resetInSec = null)
|
||||
CompactUsageRowEmpty(label = stringResource(R.string.usage_window_title_plain), resetInSec = null)
|
||||
} else {
|
||||
windows.forEach { (label, remainingPct, resetAt) ->
|
||||
if (remainingPct != null) {
|
||||
val usedPct = (100 - remainingPct).coerceIn(0, 100)
|
||||
CompactUsageRow(label = label, pct = usedPct, resetInSec = resetAt?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 })
|
||||
// 如果没有 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 = label, resetInSec = resetAt?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 })
|
||||
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"]?.let {
|
||||
val plan = balance.extras["plan"]?.takeIf { it.isNotBlank() }?.let {
|
||||
when (it.lowercase()) { "pro" -> "Pro"; "max" -> "Max"; "free" -> "Free"; else -> it }
|
||||
} ?: "—"
|
||||
Row(verticalAlignment = Alignment.Bottom) {
|
||||
@ -309,14 +500,14 @@ internal fun OllamaMainBalance(balance: ServiceBalance) {
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "5h 已用",
|
||||
text = stringResource(R.string.service_5h_used),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = inkMuted(),
|
||||
modifier = Modifier.padding(bottom = 6.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "· $plan",
|
||||
text = stringResource(R.string.service_plan_suffix, plan),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = inkMuted(),
|
||||
modifier = Modifier.padding(bottom = 6.dp)
|
||||
@ -331,25 +522,26 @@ internal fun OllamaUsageWindows(balance: ServiceBalance) {
|
||||
val weeklyPct = extras["weekly.pct"]?.toFloatOrNull()
|
||||
val sessionResetAt = extras["session.resetAt"]?.toLongOrNull()
|
||||
val weeklyResetAt = extras["weekly.resetAt"]?.toLongOrNull()
|
||||
val weeklyLabel = stringResource(R.string.window_every_week)
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (sessionPct != null) {
|
||||
CompactUsageRow(
|
||||
label = "5h",
|
||||
label = stringResource(R.string.window_5h_short),
|
||||
pct = sessionPct.toInt().coerceIn(0, 100),
|
||||
resetInSec = sessionResetAt?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 }
|
||||
)
|
||||
} else {
|
||||
CompactUsageRowEmpty(label = "5h", resetInSec = null)
|
||||
CompactUsageRowEmpty(label = stringResource(R.string.window_5h_short), resetInSec = null)
|
||||
}
|
||||
if (weeklyPct != null) {
|
||||
CompactUsageRow(
|
||||
label = "每周",
|
||||
label = weeklyLabel,
|
||||
pct = weeklyPct.toInt().coerceIn(0, 100),
|
||||
resetInSec = weeklyResetAt?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 }
|
||||
)
|
||||
} else {
|
||||
CompactUsageRowEmpty(label = "每周", resetInSec = null)
|
||||
CompactUsageRowEmpty(label = weeklyLabel, resetInSec = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -358,6 +550,7 @@ internal fun OllamaUsageWindows(balance: ServiceBalance) {
|
||||
|
||||
@Composable
|
||||
internal fun CompactUsageRowEmpty(label: String, resetInSec: Long?) {
|
||||
val durationText = rememberDurationText()
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@ -389,7 +582,7 @@ internal fun CompactUsageRowEmpty(label: String, resetInSec: Long?) {
|
||||
if (resetInSec != null && resetInSec > 0) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = "${formatResetInSec(resetInSec)}后重置",
|
||||
text = stringResource(R.string.service_reset_in, formatResetInSec(resetInSec, durationText)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = inkMuted()
|
||||
)
|
||||
@ -399,6 +592,7 @@ internal fun CompactUsageRowEmpty(label: String, resetInSec: Long?) {
|
||||
|
||||
@Composable
|
||||
internal fun CompactUsageRow(label: String, pct: Int, resetInSec: Long?) {
|
||||
val durationText = rememberDurationText()
|
||||
val pctValue = pct.coerceIn(0, 100).toFloat()
|
||||
Column {
|
||||
Row(
|
||||
@ -440,7 +634,7 @@ internal fun CompactUsageRow(label: String, pct: Int, resetInSec: Long?) {
|
||||
if (resetInSec != null && resetInSec > 0) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = "${formatResetInSec(resetInSec)}后重置",
|
||||
text = stringResource(R.string.service_reset_in, formatResetInSec(resetInSec, durationText)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = inkMuted()
|
||||
)
|
||||
@ -448,45 +642,293 @@ internal fun CompactUsageRow(label: String, pct: Int, resetInSec: Long?) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 当前语言环境的时长标签("天 / 小时 / 分")。 */
|
||||
@Composable
|
||||
private fun rememberDurationText(): DurationText = DurationText(
|
||||
day = stringResource(R.string.format_day),
|
||||
hour = stringResource(R.string.format_hour),
|
||||
minute = stringResource(R.string.format_minute)
|
||||
)
|
||||
|
||||
// ── Dashboard utility functions ──
|
||||
|
||||
internal fun DashboardCardUi.statusBadgeStyle(): StatusStyle = when {
|
||||
credentialState == CredentialStatus.State.NOT_CONFIGURED ->
|
||||
StatusStyle("未配置", StatusLevel.WARNING)
|
||||
StatusStyle(R.string.status_not_configured, StatusLevel.WARNING)
|
||||
lastFetchError != null ->
|
||||
StatusStyle("刷新失败", StatusLevel.ERROR)
|
||||
StatusStyle(R.string.status_refresh_failed, StatusLevel.ERROR)
|
||||
credentialState == CredentialStatus.State.EXPIRED ->
|
||||
StatusStyle("已过期", StatusLevel.ERROR)
|
||||
StatusStyle(R.string.status_expired, StatusLevel.ERROR)
|
||||
credentialState == CredentialStatus.State.WARNING ->
|
||||
StatusStyle("需重登", StatusLevel.WARNING)
|
||||
StatusStyle(R.string.status_relogin, StatusLevel.WARNING)
|
||||
cachedBalance == null ->
|
||||
StatusStyle("待获取", StatusLevel.INFO)
|
||||
StatusStyle(R.string.status_waiting, StatusLevel.INFO)
|
||||
else ->
|
||||
StatusStyle("正常", StatusLevel.OK)
|
||||
StatusStyle(R.string.status_normal, StatusLevel.OK)
|
||||
}
|
||||
|
||||
internal fun secondaryLine(card: DashboardCardUi): String = when (card.service) {
|
||||
ServiceType.DEEPSEEK -> "REST API · ¥"
|
||||
ServiceType.OPENCODE_GO -> "WebView 抓取 · 5h 配额"
|
||||
ServiceType.COMMANDCODE_GO -> "JSON API · $"
|
||||
ServiceType.CODEX -> "ChatGPT Plus · Codex 额度"
|
||||
ServiceType.OLLAMA -> "Cookie 抓取 · Cloud 配额"
|
||||
}
|
||||
|
||||
internal fun footerText(card: DashboardCardUi): String {
|
||||
if (card.lastFetchError != null) {
|
||||
val msg = card.lastFetchError.take(60)
|
||||
return "⚠ $msg${if (card.lastFetchError.length > 60) "…" else ""}"
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
val fetchedAt = card.cachedBalance?.fetchedAt ?: return "从未获取"
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 -> "刚刚更新"
|
||||
diffMin < 60 -> "$diffMin 分钟前更新"
|
||||
diffMin < 1440 -> "${diffMin / 60} 小时前更新"
|
||||
else -> "$timeStr 更新"
|
||||
diffMin < 1 -> UiText.Resource(R.string.footer_just_updated)
|
||||
diffMin < 60 -> UiText.Resource(R.string.footer_minutes_ago, listOf(diffMin))
|
||||
diffMin < 1440 -> UiText.Resource(R.string.footer_hours_ago, listOf(diffMin / 60))
|
||||
else -> UiText.Resource(R.string.footer_updated_at, listOf(timeStr))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用量卡片(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,12 +53,15 @@ import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.rainy.token.R
|
||||
import com.rainy.token.data.local.ChartBucket
|
||||
import com.rainy.token.data.local.ChartGranularity
|
||||
import com.rainy.token.ui.theme.InkMuted
|
||||
@ -73,7 +76,7 @@ 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
|
||||
@ -86,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)
|
||||
@ -100,6 +103,7 @@ fun UsageChartScreen(
|
||||
viewModel: UsageChartViewModel = hiltViewModel()
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
var granularityExpanded by remember { mutableStateOf(false) }
|
||||
var modelExpanded by remember { mutableStateOf(false) }
|
||||
var showCustomDayPicker by remember { mutableStateOf(false) }
|
||||
@ -115,9 +119,9 @@ fun UsageChartScreen(
|
||||
containerColor = Color.Transparent,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("统计图表") },
|
||||
title = { Text(stringResource(R.string.title_chart)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, "返回") }
|
||||
IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, stringResource(R.string.action_back)) }
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent)
|
||||
)
|
||||
@ -125,7 +129,7 @@ fun UsageChartScreen(
|
||||
) { innerPadding ->
|
||||
if (state.loading) {
|
||||
Box(Modifier.fillMaxSize().padding(innerPadding), contentAlignment = Alignment.Center) {
|
||||
Text("加载中…", color = InkMuted)
|
||||
Text(stringResource(R.string.common_loading), color = InkMuted)
|
||||
}
|
||||
} else {
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize().padding(innerPadding)) {
|
||||
@ -146,13 +150,13 @@ fun UsageChartScreen(
|
||||
// 粒度下拉
|
||||
Box {
|
||||
TextButton(onClick = { granularityExpanded = true }) {
|
||||
Text(state.granularity.label, color = StrawberryPink)
|
||||
Text(stringResource(state.granularity.labelRes), color = StrawberryPink)
|
||||
Icon(Icons.Filled.ArrowDropDown, null, tint = StrawberryPink)
|
||||
}
|
||||
DropdownMenu(granularityExpanded, { granularityExpanded = false }) {
|
||||
ChartGranularity.entries.forEach { g ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(g.label) },
|
||||
text = { Text(stringResource(g.labelRes)) },
|
||||
onClick = {
|
||||
granularityExpanded = false
|
||||
when (g) {
|
||||
@ -169,15 +173,15 @@ fun UsageChartScreen(
|
||||
Spacer(Modifier.weight(1f))
|
||||
// 模型多选下拉
|
||||
Box {
|
||||
val modelLabel = if (state.selectedModels.isEmpty()) "全部模型"
|
||||
else "${state.selectedModels.size} 个模型"
|
||||
val modelLabel = if (state.selectedModels.isEmpty()) stringResource(R.string.common_all_models)
|
||||
else stringResource(R.string.model_count, state.selectedModels.size)
|
||||
TextButton(onClick = { modelExpanded = true }) {
|
||||
Text(modelLabel, color = StrawberryPink, style = MaterialTheme.typography.bodySmall)
|
||||
Icon(Icons.Filled.ArrowDropDown, null, tint = StrawberryPink)
|
||||
}
|
||||
DropdownMenu(modelExpanded, { modelExpanded = false }) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("全部模型", fontWeight = FontWeight.Bold) },
|
||||
text = { Text(stringResource(R.string.common_all_models), fontWeight = FontWeight.Bold) },
|
||||
onClick = { modelExpanded = false; viewModel.selectAllModels() }
|
||||
)
|
||||
state.allModels.forEach { model ->
|
||||
@ -210,7 +214,7 @@ fun UsageChartScreen(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
ChartCard("消耗金额 (USD)", "$${String.format(Locale.US, "%.4f", costTotal)}", { showCostDetail = true }) {
|
||||
ChartCard(stringResource(R.string.chart_cost), "$${String.format(Locale.US, "%.4f", costTotal)}", { showCostDetail = true }) {
|
||||
StackedBarChart(
|
||||
buckets = state.buckets,
|
||||
valueSelector = { it.totalCost.toDouble() / 100_000_000.0 },
|
||||
@ -235,12 +239,12 @@ fun UsageChartScreen(
|
||||
}
|
||||
}
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
ChartCard("API 请求次数", "${reqTotal}次", { showReqDetail = true }) {
|
||||
ChartCard(stringResource(R.string.chart_requests), stringResource(R.string.count_times, reqTotal), { showReqDetail = true }) {
|
||||
LineChart(
|
||||
buckets = state.buckets,
|
||||
valueSelector = { it.totalRequests.toFloat() },
|
||||
lineColor = StrawberryPink,
|
||||
formatValue = { "${it.toInt()}次" },
|
||||
formatValue = { context.getString(R.string.count_times, it.toInt()) },
|
||||
granularity = state.granularity,
|
||||
useUtc8 = state.useUtc8
|
||||
)
|
||||
@ -251,7 +255,7 @@ fun UsageChartScreen(
|
||||
// 第三张图表独立一行
|
||||
item {
|
||||
val tokTotal = state.buckets.sumOf { it.cacheHitTokens + it.inputTokens + it.outputTokens }
|
||||
ChartCard("Token 消耗", formatTokenComma(tokTotal), { showTokenDetail = true }) {
|
||||
ChartCard(stringResource(R.string.chart_tokens), formatTokenComma(tokTotal), { showTokenDetail = true }) {
|
||||
StackedBarChart(
|
||||
buckets = state.buckets,
|
||||
valueSelector = { (it.cacheHitTokens + it.inputTokens + it.outputTokens).toDouble() },
|
||||
@ -262,7 +266,7 @@ fun UsageChartScreen(
|
||||
bucket.cacheHitTokens.toDouble() to tokenColors[0]
|
||||
)
|
||||
},
|
||||
stackLabels = { listOf("输出", "输入(未命中)", "命中缓存") },
|
||||
stackLabels = { listOf(context.getString(R.string.chart_stack_output), context.getString(R.string.chart_stack_input), context.getString(R.string.chart_stack_cache)) },
|
||||
formatValue = { formatTokenComma(it.toLong()) },
|
||||
granularity = state.granularity,
|
||||
tooltipReversed = true,
|
||||
@ -271,9 +275,9 @@ fun UsageChartScreen(
|
||||
}
|
||||
ChartLegend(
|
||||
items = listOf(
|
||||
"输入(未命中)" to tokenColors[1],
|
||||
"命中缓存" to tokenColors[0],
|
||||
"输出" to tokenColors[2]
|
||||
stringResource(R.string.chart_stack_input) to tokenColors[1],
|
||||
stringResource(R.string.chart_stack_cache) to tokenColors[0],
|
||||
stringResource(R.string.chart_stack_output) to tokenColors[2]
|
||||
)
|
||||
)
|
||||
}
|
||||
@ -283,7 +287,7 @@ fun UsageChartScreen(
|
||||
val models = state.selectedModels.ifEmpty { state.allModels.toSet() }
|
||||
val costTotal = state.buckets.sumOf { it.totalCost.toDouble() / 100_000_000.0 }
|
||||
ChartCard(
|
||||
title = "消耗金额 (USD)",
|
||||
title = stringResource(R.string.chart_cost),
|
||||
summary = "$${String.format(Locale.US, "%.4f", costTotal)}",
|
||||
onSummaryClick = { showCostDetail = true }
|
||||
) {
|
||||
@ -314,15 +318,15 @@ fun UsageChartScreen(
|
||||
item {
|
||||
val reqTotal = state.buckets.sumOf { it.totalRequests }
|
||||
ChartCard(
|
||||
title = "API 请求次数",
|
||||
summary = "${reqTotal}次",
|
||||
title = stringResource(R.string.chart_requests),
|
||||
summary = stringResource(R.string.count_times, reqTotal),
|
||||
onSummaryClick = { showReqDetail = true }
|
||||
) {
|
||||
LineChart(
|
||||
buckets = state.buckets,
|
||||
valueSelector = { it.totalRequests.toFloat() },
|
||||
lineColor = StrawberryPink,
|
||||
formatValue = { "${it.toInt()}次" },
|
||||
formatValue = { context.getString(R.string.count_times, it.toInt()) },
|
||||
granularity = state.granularity,
|
||||
useUtc8 = state.useUtc8
|
||||
)
|
||||
@ -332,7 +336,7 @@ fun UsageChartScreen(
|
||||
item {
|
||||
val tokTotal = state.buckets.sumOf { it.cacheHitTokens + it.inputTokens + it.outputTokens }
|
||||
ChartCard(
|
||||
title = "Token 消耗",
|
||||
title = stringResource(R.string.chart_tokens),
|
||||
summary = formatTokenComma(tokTotal),
|
||||
onSummaryClick = { showTokenDetail = true }
|
||||
) {
|
||||
@ -346,7 +350,7 @@ fun UsageChartScreen(
|
||||
bucket.cacheHitTokens.toDouble() to tokenColors[0]
|
||||
)
|
||||
},
|
||||
stackLabels = { listOf("输出", "输入(未命中)", "命中缓存") },
|
||||
stackLabels = { listOf(context.getString(R.string.chart_stack_output), context.getString(R.string.chart_stack_input), context.getString(R.string.chart_stack_cache)) },
|
||||
formatValue = { formatTokenComma(it.toLong()) },
|
||||
granularity = state.granularity,
|
||||
tooltipReversed = true,
|
||||
@ -355,9 +359,9 @@ fun UsageChartScreen(
|
||||
}
|
||||
ChartLegend(
|
||||
items = listOf(
|
||||
"输入(未命中)" to tokenColors[1],
|
||||
"命中缓存" to tokenColors[0],
|
||||
"输出" to tokenColors[2]
|
||||
stringResource(R.string.chart_stack_input) to tokenColors[1],
|
||||
stringResource(R.string.chart_stack_cache) to tokenColors[0],
|
||||
stringResource(R.string.chart_stack_output) to tokenColors[2]
|
||||
)
|
||||
)
|
||||
}
|
||||
@ -371,7 +375,7 @@ fun UsageChartScreen(
|
||||
// ─── 自定义日期选择器 ───
|
||||
if (showCustomDayPicker) {
|
||||
DateTimePickerDialog(
|
||||
title = "选择日期",
|
||||
title = stringResource(R.string.date_select_day),
|
||||
onConfirm = { ms ->
|
||||
viewModel.setCustomDay(ms.toUtcLocalDate())
|
||||
showCustomDayPicker = false
|
||||
@ -381,7 +385,7 @@ fun UsageChartScreen(
|
||||
}
|
||||
if (showCustomMonthPicker) {
|
||||
DateTimePickerDialog(
|
||||
title = "选择月份(任意一天)",
|
||||
title = stringResource(R.string.date_select_month),
|
||||
onConfirm = { ms ->
|
||||
viewModel.setCustomMonth(ms.toUtcLocalDate())
|
||||
showCustomMonthPicker = false
|
||||
@ -391,7 +395,7 @@ fun UsageChartScreen(
|
||||
}
|
||||
if (showCustomRangeStart) {
|
||||
DateTimePickerDialog(
|
||||
title = "开始日期",
|
||||
title = stringResource(R.string.date_pick_start),
|
||||
onConfirm = { ms ->
|
||||
customRangeStartDate = ms.toUtcLocalDate()
|
||||
showCustomRangeStart = false
|
||||
@ -402,7 +406,7 @@ fun UsageChartScreen(
|
||||
}
|
||||
if (showCustomRangeEnd) {
|
||||
DateTimePickerDialog(
|
||||
title = "结束日期",
|
||||
title = stringResource(R.string.date_pick_end),
|
||||
onConfirm = { ms ->
|
||||
val startDate = customRangeStartDate ?: return@DateTimePickerDialog
|
||||
val endDate = ms.toUtcLocalDate()
|
||||
@ -420,7 +424,7 @@ fun UsageChartScreen(
|
||||
if (showCostDetail) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showCostDetail = false },
|
||||
title = { Text("消费明细", fontWeight = FontWeight.Bold) },
|
||||
title = { Text(stringResource(R.string.chart_detail_cost), fontWeight = FontWeight.Bold) },
|
||||
text = {
|
||||
Column {
|
||||
models.forEach { model ->
|
||||
@ -435,13 +439,13 @@ fun UsageChartScreen(
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = { showCostDetail = false }) { Text("关闭") } }
|
||||
confirmButton = { TextButton(onClick = { showCostDetail = false }) { Text(stringResource(R.string.action_close)) } }
|
||||
)
|
||||
}
|
||||
if (showReqDetail) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showReqDetail = false },
|
||||
title = { Text("调用次数明细", fontWeight = FontWeight.Bold) },
|
||||
title = { Text(stringResource(R.string.chart_detail_requests), fontWeight = FontWeight.Bold) },
|
||||
text = {
|
||||
Column {
|
||||
models.forEach { model ->
|
||||
@ -449,13 +453,13 @@ fun UsageChartScreen(
|
||||
if (total > 0) {
|
||||
Row(Modifier.fillMaxWidth().padding(vertical = 2.dp), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(model, style = MaterialTheme.typography.bodySmall)
|
||||
Text("${total}次", color = StrawberryPink, style = MaterialTheme.typography.bodySmall)
|
||||
Text(stringResource(R.string.count_times, total), color = StrawberryPink, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = { showReqDetail = false }) { Text("关闭") } }
|
||||
confirmButton = { TextButton(onClick = { showReqDetail = false }) { Text(stringResource(R.string.action_close)) } }
|
||||
)
|
||||
}
|
||||
if (showTokenDetail) {
|
||||
@ -464,24 +468,24 @@ fun UsageChartScreen(
|
||||
val outTotal = state.buckets.sumOf { it.outputTokens }
|
||||
AlertDialog(
|
||||
onDismissRequest = { showTokenDetail = false },
|
||||
title = { Text("Token 明细", fontWeight = FontWeight.Bold) },
|
||||
title = { Text(stringResource(R.string.chart_detail_tokens), fontWeight = FontWeight.Bold) },
|
||||
text = {
|
||||
Column {
|
||||
Row(Modifier.fillMaxWidth().padding(vertical = 2.dp), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text("命中缓存", style = MaterialTheme.typography.bodySmall)
|
||||
Text(stringResource(R.string.chart_stack_cache), style = MaterialTheme.typography.bodySmall)
|
||||
Text(formatTokenComma(hitTotal), color = StrawberryPink, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
Row(Modifier.fillMaxWidth().padding(vertical = 2.dp), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text("输入(未命中)", style = MaterialTheme.typography.bodySmall)
|
||||
Text(stringResource(R.string.chart_stack_input), style = MaterialTheme.typography.bodySmall)
|
||||
Text(formatTokenComma(missTotal), color = StrawberryPink, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
Row(Modifier.fillMaxWidth().padding(vertical = 2.dp), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text("输出", style = MaterialTheme.typography.bodySmall)
|
||||
Text(stringResource(R.string.chart_stack_output), style = MaterialTheme.typography.bodySmall)
|
||||
Text(formatTokenComma(outTotal), color = StrawberryPink, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = { showTokenDetail = false }) { Text("关闭") } }
|
||||
confirmButton = { TextButton(onClick = { showTokenDetail = false }) { Text(stringResource(R.string.action_close)) } }
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -544,10 +548,11 @@ internal fun StackedBarChart(
|
||||
useUtc8: Boolean = false
|
||||
) {
|
||||
if (buckets.isEmpty()) {
|
||||
Text("暂无数据", color = InkMuted, style = MaterialTheme.typography.bodySmall)
|
||||
Text(stringResource(R.string.common_no_data), color = InkMuted, style = MaterialTheme.typography.bodySmall)
|
||||
return
|
||||
}
|
||||
var tooltipBucket by remember { mutableStateOf(buckets.lastOrNull()) }
|
||||
// key = buckets:切换时间范围/筛选后重置 tooltip,避免残留旧数据
|
||||
var tooltipBucket by remember(buckets) { mutableStateOf(buckets.lastOrNull()) }
|
||||
val density = LocalDensity.current
|
||||
val d = density.density
|
||||
val barCount = buckets.size
|
||||
@ -639,7 +644,7 @@ internal fun StackedBarChart(
|
||||
) {
|
||||
Column(Modifier.padding(8.dp)) {
|
||||
Text(formatChartTime(bucket.ts, granularity, 0, 1, useUtc8).first, style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold)
|
||||
Text("合计: ${formatValue(total)}", style = MaterialTheme.typography.bodySmall, color = StrawberryPink)
|
||||
Text(stringResource(R.string.chart_total_label, formatValue(total)), style = MaterialTheme.typography.bodySmall, color = StrawberryPink)
|
||||
val labels = stackLabels(bucket)
|
||||
val items = stackSelector(bucket).withIndex()
|
||||
val display = if (tooltipReversed) items.toList().reversed() else items.toList()
|
||||
@ -674,10 +679,11 @@ internal fun LineChart(
|
||||
useUtc8: Boolean = false
|
||||
) {
|
||||
if (buckets.isEmpty()) {
|
||||
Text("暂无数据", color = InkMuted, style = MaterialTheme.typography.bodySmall)
|
||||
Text(stringResource(R.string.common_no_data), color = InkMuted, style = MaterialTheme.typography.bodySmall)
|
||||
return
|
||||
}
|
||||
var tooltipBucket by remember { mutableStateOf(buckets.lastOrNull()) }
|
||||
// key = buckets:切换时间范围/筛选后重置 tooltip,避免残留旧数据
|
||||
var tooltipBucket by remember(buckets) { mutableStateOf(buckets.lastOrNull()) }
|
||||
val density = LocalDensity.current
|
||||
val d = density.density
|
||||
val barCount = buckets.size
|
||||
@ -809,6 +815,7 @@ 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 ->
|
||||
|
||||
@ -44,18 +44,7 @@ class UsageChartViewModel @Inject constructor(
|
||||
loadGeneration++
|
||||
allowFallback = true
|
||||
_state.value = ChartUiState(useUtc8 = _state.value.useUtc8)
|
||||
viewModelScope.launch {
|
||||
// 首次加载时同步读取 UTC 偏好,避免用默认值 false 加载后再切换
|
||||
if (!_state.value.useUtc8Initialized) {
|
||||
val saved = chartSettingsStore.useUtc8Flow.first()
|
||||
if (saved != _state.value.useUtc8) {
|
||||
_state.update { it.copy(useUtc8 = saved, useUtc8Initialized = true) }
|
||||
} else {
|
||||
_state.update { it.copy(useUtc8Initialized = true) }
|
||||
}
|
||||
}
|
||||
load()
|
||||
}
|
||||
load()
|
||||
}
|
||||
|
||||
private suspend fun workspaceId(): String? {
|
||||
@ -101,6 +90,11 @@ class UsageChartViewModel @Inject constructor(
|
||||
|
||||
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()
|
||||
@ -126,6 +120,7 @@ class UsageChartViewModel @Inject constructor(
|
||||
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, offsetHours)
|
||||
@ -158,30 +153,35 @@ class UsageChartViewModel @Inject constructor(
|
||||
// 降级后或用户手动选择 → 关闭降级,正常显示
|
||||
allowFallback = false
|
||||
|
||||
// 图例数据源:只保留当前显示范围内实际出现过的模型
|
||||
val rangeModels = result.flatMap { it.byModel.keys }.distinct().sorted()
|
||||
|
||||
_state.update {
|
||||
it.copy(
|
||||
buckets = result,
|
||||
allModels = allModels,
|
||||
rangeModels = rangeModels,
|
||||
loading = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
fun setCustomDay(date: LocalDate) {
|
||||
_state.update { it.copy(granularity = ChartGranularity.CUSTOM_DAY_HOURLY) }
|
||||
_state.update { it.copy(granularity = ChartGranularity.CUSTOM_DAY_HOURLY, customDay = date) }
|
||||
_customDay = date
|
||||
load()
|
||||
}
|
||||
|
||||
fun setCustomMonth(monthDate: LocalDate) {
|
||||
_state.update { it.copy(granularity = ChartGranularity.CUSTOM_MONTH_DAILY) }
|
||||
_customMonth = monthDate.withDayOfMonth(1)
|
||||
val month = monthDate.withDayOfMonth(1)
|
||||
_state.update { it.copy(granularity = ChartGranularity.CUSTOM_MONTH_DAILY, customMonth = month) }
|
||||
_customMonth = month
|
||||
load()
|
||||
}
|
||||
|
||||
fun setCustomRange(fromDate: LocalDate, toDate: LocalDate) {
|
||||
if (toDate.isBefore(fromDate)) return
|
||||
_state.update { it.copy(granularity = ChartGranularity.CUSTOM_RANGE_DAILY) }
|
||||
_state.update { it.copy(granularity = ChartGranularity.CUSTOM_RANGE_DAILY, customRange = fromDate to toDate) }
|
||||
_customRange = fromDate to toDate
|
||||
load()
|
||||
}
|
||||
@ -202,6 +202,7 @@ class UsageChartViewModel @Inject constructor(
|
||||
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.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 -> {
|
||||
@ -266,6 +267,12 @@ 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 useUtc8: Boolean = false,
|
||||
|
||||
@ -46,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
|
||||
@ -80,10 +83,7 @@ fun UsageDataScreen(
|
||||
|
||||
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) }
|
||||
@ -93,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)
|
||||
)
|
||||
}
|
||||
@ -105,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 }) {
|
||||
@ -114,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
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -133,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 = {
|
||||
@ -153,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) {
|
||||
@ -188,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -198,22 +195,22 @@ fun UsageDataScreen(
|
||||
Card(Modifier.fillMaxWidth(), RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp),
|
||||
CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) {
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp)) {
|
||||
HeaderCell("时间", Modifier.weight(1.5f), false)
|
||||
HeaderCell("模型", Modifier.weight(1.8f), false)
|
||||
HeaderCell("输入(缓存)", Modifier.weight(1.5f), false)
|
||||
HeaderCell("输出", Modifier.weight(1.2f), false)
|
||||
HeaderCell("成本", Modifier.weight(1.0f), false)
|
||||
HeaderCell(stringResource(R.string.header_time), Modifier.weight(1.5f), false)
|
||||
HeaderCell(stringResource(R.string.header_model), Modifier.weight(1.8f), false)
|
||||
HeaderCell(stringResource(R.string.header_input_cache), Modifier.weight(1.5f), false)
|
||||
HeaderCell(stringResource(R.string.header_output), Modifier.weight(1.2f), false)
|
||||
HeaderCell(stringResource(R.string.header_cost), Modifier.weight(1.0f), false)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 记录列表 ───
|
||||
if (state.loading) {
|
||||
Box(Modifier.fillMaxWidth().height(200.dp), contentAlignment = Alignment.Center) {
|
||||
Text("加载中…", color = inkMuted())
|
||||
Text(stringResource(R.string.common_loading), color = inkMuted())
|
||||
}
|
||||
} else if (state.records.isEmpty()) {
|
||||
Box(Modifier.fillMaxWidth().height(200.dp), contentAlignment = Alignment.Center) {
|
||||
Text("暂无数据", color = inkMuted())
|
||||
Text(stringResource(R.string.common_no_data), color = inkMuted())
|
||||
}
|
||||
} else {
|
||||
LazyColumn(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(1.dp)) {
|
||||
@ -226,45 +223,68 @@ 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)) } }
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -311,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
|
||||
|
||||
@ -50,11 +50,15 @@ 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
|
||||
@ -80,6 +84,7 @@ fun UsageDetailScreen(
|
||||
clearViewModel: UsageViewModel? = null // non-null = CCGO, 显示清除按钮
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
|
||||
// OCGO 首次加载(CCGO 由 NavHost 的 setWorkspace 触发,不重复 load)
|
||||
LaunchedEffect(Unit) {
|
||||
@ -92,9 +97,7 @@ fun UsageDetailScreen(
|
||||
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 customRangeStartDate by remember { mutableStateOf<LocalDate?>(null) }
|
||||
var showCustomRangePicker by remember { mutableStateOf(false) }
|
||||
var showCostDetail by remember { mutableStateOf(false) }
|
||||
var showReqDetail by remember { mutableStateOf(false) }
|
||||
var showTokenDetail by remember { mutableStateOf(false) }
|
||||
@ -106,16 +109,32 @@ fun UsageDetailScreen(
|
||||
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("清除", color = MaterialTheme.colorScheme.error, fontWeight = FontWeight.SemiBold, fontSize = 13.sp)
|
||||
Text(stringResource(R.string.action_clear), color = MaterialTheme.colorScheme.error, fontWeight = FontWeight.SemiBold, fontSize = 13.sp)
|
||||
}
|
||||
}
|
||||
TextButton(onClick = onOpenData) {
|
||||
Text("详细数据", color = StrawberryPink, fontWeight = FontWeight.SemiBold, fontSize = 13.sp)
|
||||
Text(stringResource(R.string.action_view_raw_data), color = StrawberryPink, fontWeight = FontWeight.SemiBold, fontSize = 13.sp)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent)
|
||||
@ -124,7 +143,7 @@ fun UsageDetailScreen(
|
||||
) { innerPadding ->
|
||||
if (state.loading) {
|
||||
Box(Modifier.fillMaxSize().padding(innerPadding), contentAlignment = Alignment.Center) {
|
||||
Text("加载中…", color = InkMuted)
|
||||
Text(stringResource(R.string.common_loading), color = InkMuted)
|
||||
}
|
||||
} else {
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize().padding(innerPadding)) {
|
||||
@ -139,17 +158,17 @@ fun UsageDetailScreen(
|
||||
Row(Modifier.fillMaxWidth(), Arrangement.spacedBy(8.dp), Alignment.CenterVertically) {
|
||||
Box {
|
||||
TextButton(onClick = { granularityExpanded = true }) {
|
||||
Text(state.granularity.label, color = StrawberryPink)
|
||||
Text(stringResource(state.granularity.labelRes), color = StrawberryPink)
|
||||
Icon(Icons.Filled.ArrowDropDown, null, tint = StrawberryPink)
|
||||
}
|
||||
DropdownMenu(granularityExpanded, { granularityExpanded = false }) {
|
||||
ChartGranularity.entries.forEach { g ->
|
||||
DropdownMenuItem(text = { Text(g.label) }, onClick = {
|
||||
DropdownMenuItem(text = { Text(stringResource(g.labelRes)) }, onClick = {
|
||||
granularityExpanded = false
|
||||
when (g) {
|
||||
ChartGranularity.CUSTOM_DAY_HOURLY -> showCustomDayPicker = true
|
||||
ChartGranularity.CUSTOM_MONTH_DAILY -> showCustomMonthPicker = true
|
||||
ChartGranularity.CUSTOM_RANGE_DAILY -> showCustomRangeStart = true
|
||||
ChartGranularity.CUSTOM_RANGE_DAILY -> showCustomRangePicker = true
|
||||
else -> viewModel.setGranularity(g)
|
||||
}
|
||||
})
|
||||
@ -160,7 +179,7 @@ fun UsageDetailScreen(
|
||||
viewModel.toggleUtc8()
|
||||
scope.launch {
|
||||
snackbarHostState.showSnackbar(
|
||||
if (state.useUtc8) "已切换到 UTC+8,图表按北京时间显示" else "已切换到 UTC+0,图表按世界协调时显示",
|
||||
if (state.useUtc8) context.getString(R.string.msg_utc8_on) else context.getString(R.string.msg_utc0_on),
|
||||
duration = SnackbarDuration.Short
|
||||
)
|
||||
}
|
||||
@ -172,7 +191,7 @@ fun UsageDetailScreen(
|
||||
fontWeight = if (state.useUtc8) FontWeight.SemiBold else FontWeight.Normal
|
||||
)
|
||||
Text(
|
||||
" 点击切换",
|
||||
stringResource(R.string.usage_click_to_switch),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = InkMuted.copy(alpha = 0.5f),
|
||||
fontWeight = FontWeight.Normal
|
||||
@ -180,14 +199,14 @@ fun UsageDetailScreen(
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
Box {
|
||||
val label = if (state.selectedModels.isEmpty()) "全部模型" else "${state.selectedModels.size} 个模型"
|
||||
val label = if (state.selectedModels.isEmpty()) stringResource(R.string.common_all_models) else stringResource(R.string.model_count, state.selectedModels.size)
|
||||
TextButton(onClick = { modelExpanded = true }) {
|
||||
Text(label, color = StrawberryPink, style = MaterialTheme.typography.bodySmall)
|
||||
Icon(Icons.Filled.ArrowDropDown, null, tint = StrawberryPink)
|
||||
}
|
||||
DropdownMenu(modelExpanded, { modelExpanded = false }) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("全部模型", fontWeight = FontWeight.Bold) },
|
||||
text = { Text(stringResource(R.string.common_all_models), fontWeight = FontWeight.Bold) },
|
||||
onClick = { modelExpanded = false; viewModel.selectAllModels() })
|
||||
state.allModels.forEach { model ->
|
||||
DropdownMenuItem(text = {
|
||||
@ -205,7 +224,7 @@ fun UsageDetailScreen(
|
||||
if (wideEnough) {
|
||||
// 面板够宽:前两张图表并排
|
||||
item {
|
||||
val models = state.selectedModels.ifEmpty { state.allModels.toSet() }
|
||||
val models = state.rangeModels
|
||||
val costTotal = state.buckets.sumOf { it.totalCost.toDouble() / 100_000_000.0 }
|
||||
val reqTotal = state.buckets.sumOf { it.totalRequests }
|
||||
Row(
|
||||
@ -213,7 +232,7 @@ fun UsageDetailScreen(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
ChartCard("消耗金额 (USD)", "$${String.format(Locale.US, "%.4f", costTotal)}", { showCostDetail = true }) {
|
||||
ChartCard(stringResource(R.string.chart_cost), "$${String.format(Locale.US, "%.4f", costTotal)}", { showCostDetail = true }) {
|
||||
StackedBarChart(state.buckets,
|
||||
{ it.totalCost.toDouble() / 100_000_000.0 },
|
||||
{ bucket -> models.mapIndexedNotNull { idx, m -> val v = bucket.byModel[m]?.cost ?: return@mapIndexedNotNull null; v.toDouble() / 100_000_000.0 to modelColors[idx % modelColors.size] } },
|
||||
@ -228,33 +247,33 @@ fun UsageDetailScreen(
|
||||
}
|
||||
}
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
ChartCard("API 请求次数", "${reqTotal}次", { showReqDetail = true }) {
|
||||
LineChart(state.buckets, { it.totalRequests.toFloat() }, StrawberryPink, { "${it.toInt()}次" }, state.granularity, useUtc8 = state.useUtc8)
|
||||
ChartCard(stringResource(R.string.chart_requests), stringResource(R.string.count_times, reqTotal), { showReqDetail = true }) {
|
||||
LineChart(state.buckets, { it.totalRequests.toFloat() }, StrawberryPink, { context.getString(R.string.count_times, it.toInt()) }, state.granularity, useUtc8 = state.useUtc8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
val tokTotal = state.buckets.sumOf { it.cacheHitTokens + it.inputTokens + it.outputTokens }
|
||||
ChartCard("Token 消耗", formatTokenComma(tokTotal), { showTokenDetail = true }) {
|
||||
ChartCard(stringResource(R.string.chart_tokens), formatTokenComma(tokTotal), { showTokenDetail = true }) {
|
||||
StackedBarChart(state.buckets,
|
||||
{ (it.cacheHitTokens + it.inputTokens + it.outputTokens).toDouble() },
|
||||
{ bucket -> listOfNotNull(bucket.outputTokens.toDouble() to tokenColors[2], bucket.inputTokens.toDouble() to tokenColors[1], bucket.cacheHitTokens.toDouble() to tokenColors[0]) },
|
||||
{ listOf("输出", "输入(未命中)", "命中缓存") },
|
||||
{ listOf(context.getString(R.string.chart_stack_output), context.getString(R.string.chart_stack_input), context.getString(R.string.chart_stack_cache)) },
|
||||
formatValue = { formatTokenComma(it.toLong()) },
|
||||
granularity = state.granularity,
|
||||
tooltipReversed = true,
|
||||
useUtc8 = state.useUtc8
|
||||
)
|
||||
}
|
||||
ChartLegend(listOf("输入(未命中)" to tokenColors[1], "命中缓存" to tokenColors[0], "输出" to tokenColors[2]))
|
||||
ChartLegend(listOf(stringResource(R.string.chart_stack_input) to tokenColors[1], stringResource(R.string.chart_stack_cache) to tokenColors[0], stringResource(R.string.chart_stack_output) to tokenColors[2]))
|
||||
}
|
||||
} else {
|
||||
// 窄面板:三张图表纵向堆叠
|
||||
item {
|
||||
val models = state.selectedModels.ifEmpty { state.allModels.toSet() }
|
||||
val models = state.rangeModels
|
||||
val costTotal = state.buckets.sumOf { it.totalCost.toDouble() / 100_000_000.0 }
|
||||
ChartCard("消耗金额 (USD)", "$${String.format(Locale.US, "%.4f", costTotal)}", { showCostDetail = true }) {
|
||||
ChartCard(stringResource(R.string.chart_cost), "$${String.format(Locale.US, "%.4f", costTotal)}", { showCostDetail = true }) {
|
||||
StackedBarChart(state.buckets,
|
||||
{ it.totalCost.toDouble() / 100_000_000.0 },
|
||||
{ bucket -> models.mapIndexedNotNull { idx, m -> val v = bucket.byModel[m]?.cost ?: return@mapIndexedNotNull null; v.toDouble() / 100_000_000.0 to modelColors[idx % modelColors.size] } },
|
||||
@ -270,29 +289,29 @@ fun UsageDetailScreen(
|
||||
}
|
||||
item {
|
||||
val reqTotal = state.buckets.sumOf { it.totalRequests }
|
||||
ChartCard("API 请求次数", "${reqTotal}次", { showReqDetail = true }) {
|
||||
LineChart(state.buckets, { it.totalRequests.toFloat() }, StrawberryPink, { "${it.toInt()}次" }, state.granularity, useUtc8 = state.useUtc8)
|
||||
ChartCard(stringResource(R.string.chart_requests), stringResource(R.string.count_times, reqTotal), { showReqDetail = true }) {
|
||||
LineChart(state.buckets, { it.totalRequests.toFloat() }, StrawberryPink, { context.getString(R.string.count_times, it.toInt()) }, state.granularity, useUtc8 = state.useUtc8)
|
||||
}
|
||||
}
|
||||
item {
|
||||
val tokTotal = state.buckets.sumOf { it.cacheHitTokens + it.inputTokens + it.outputTokens }
|
||||
ChartCard("Token 消耗", formatTokenComma(tokTotal), { showTokenDetail = true }) {
|
||||
ChartCard(stringResource(R.string.chart_tokens), formatTokenComma(tokTotal), { showTokenDetail = true }) {
|
||||
StackedBarChart(state.buckets,
|
||||
{ (it.cacheHitTokens + it.inputTokens + it.outputTokens).toDouble() },
|
||||
{ bucket -> listOfNotNull(bucket.outputTokens.toDouble() to tokenColors[2], bucket.inputTokens.toDouble() to tokenColors[1], bucket.cacheHitTokens.toDouble() to tokenColors[0]) },
|
||||
{ listOf("输出", "输入(未命中)", "命中缓存") },
|
||||
{ listOf(context.getString(R.string.chart_stack_output), context.getString(R.string.chart_stack_input), context.getString(R.string.chart_stack_cache)) },
|
||||
formatValue = { formatTokenComma(it.toLong()) },
|
||||
granularity = state.granularity,
|
||||
tooltipReversed = true,
|
||||
useUtc8 = state.useUtc8
|
||||
)
|
||||
}
|
||||
ChartLegend(listOf("输入(未命中)" to tokenColors[1], "命中缓存" to tokenColors[0], "输出" to tokenColors[2]))
|
||||
ChartLegend(listOf(stringResource(R.string.chart_stack_input) to tokenColors[1], stringResource(R.string.chart_stack_cache) to tokenColors[0], stringResource(R.string.chart_stack_output) to tokenColors[2]))
|
||||
}
|
||||
}
|
||||
item {
|
||||
TextButton(onClick = onOpenOverview, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("📋 总统计", color = StrawberryPink, fontWeight = FontWeight.Bold)
|
||||
Text("📋 " + stringResource(R.string.title_overview), color = StrawberryPink, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
item { Spacer(Modifier.height(32.dp)) }
|
||||
@ -300,55 +319,59 @@ fun UsageDetailScreen(
|
||||
}
|
||||
}
|
||||
// 自定义日期选择器
|
||||
if (showCustomDayPicker) DateOnlyPickerDialog("选择日期", { date ->
|
||||
viewModel.setCustomDay(date)
|
||||
showCustomDayPicker = false
|
||||
}, { showCustomDayPicker = false })
|
||||
if (showCustomMonthPicker) DateOnlyPickerDialog("选择月份(任意一天)", { date ->
|
||||
viewModel.setCustomMonth(date)
|
||||
showCustomMonthPicker = false
|
||||
}, { showCustomMonthPicker = false })
|
||||
if (showCustomRangeStart) DateOnlyPickerDialog("开始日期", { date ->
|
||||
customRangeStartDate = date
|
||||
showCustomRangeStart = false
|
||||
showCustomRangeEnd = true
|
||||
}, { showCustomRangeStart = false })
|
||||
if (showCustomRangeEnd) {
|
||||
val startDate = customRangeStartDate
|
||||
DateOnlyPickerDialog(
|
||||
title = "结束日期",
|
||||
onConfirm = { endDate ->
|
||||
if (startDate != null && !endDate.isBefore(startDate)) {
|
||||
viewModel.setCustomRange(startDate, endDate)
|
||||
showCustomRangeEnd = 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 = { showCustomRangeEnd = false },
|
||||
minDate = startDate
|
||||
onDismiss = { showCustomRangePicker = false }
|
||||
)
|
||||
}
|
||||
val models = state.selectedModels.ifEmpty { state.allModels.toSet() }
|
||||
if (showCostDetail) ChartDetailDialog("消费明细", { showCostDetail = 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("⚠️ 清除数据", fontWeight = FontWeight.Bold) },
|
||||
title = { Text("⚠️ " + stringResource(R.string.dialog_clear_title), fontWeight = FontWeight.Bold) },
|
||||
text = {
|
||||
Column {
|
||||
Text("此操作将清除所有 CommandCode 用量本地缓存并重新同步,需要几秒钟完成。")
|
||||
Text(stringResource(R.string.dialog_clear_body))
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text("是否继续?", fontWeight = FontWeight.SemiBold)
|
||||
Text(stringResource(R.string.dialog_clear_confirm_question), fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
@ -362,14 +385,14 @@ fun UsageDetailScreen(
|
||||
enabled = cd == 0
|
||||
) {
|
||||
Text(
|
||||
if (cd > 0) "确认(${cd}s)" else "确认清除",
|
||||
if (cd > 0) stringResource(R.string.action_confirm_countdown, cd) else stringResource(R.string.action_confirm_clear),
|
||||
color = if (cd == 0) MaterialTheme.colorScheme.error else InkMuted
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showClearDialog = false; clearCountdown = 0 }) {
|
||||
Text("取消")
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
@ -391,7 +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) {
|
||||
@ -421,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
|
||||
)
|
||||
@ -436,7 +459,7 @@ internal fun CustomTimeRangeRow(
|
||||
}
|
||||
if (startMs > 0 && endMs > 0) {
|
||||
TextButton(onClick = onApply) {
|
||||
Text("应用自定义范围", color = StrawberryPink, fontWeight = FontWeight.Bold)
|
||||
Text(stringResource(R.string.action_apply_custom_range), color = StrawberryPink, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -444,8 +467,9 @@ internal fun CustomTimeRangeRow(
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun DateOnlyPickerDialog(
|
||||
internal fun DateOnlyPickerDialog(
|
||||
title: String,
|
||||
initialDate: LocalDate? = null,
|
||||
onConfirm: (LocalDate) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
minDate: LocalDate? = null
|
||||
@ -459,7 +483,10 @@ private fun DateOnlyPickerDialog(
|
||||
}
|
||||
}
|
||||
}
|
||||
val dateState = rememberDatePickerState(selectableDates = selectableDates)
|
||||
val dateState = rememberDatePickerState(
|
||||
initialSelectedDateMillis = initialDate?.atStartOfDay(utc)?.toInstant()?.toEpochMilli(),
|
||||
selectableDates = selectableDates
|
||||
)
|
||||
|
||||
DatePickerDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
@ -471,10 +498,10 @@ private fun DateOnlyPickerDialog(
|
||||
},
|
||||
enabled = dateState.selectedDateMillis != null
|
||||
) {
|
||||
Text("确定")
|
||||
Text(stringResource(R.string.action_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } }
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } }
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
@ -485,13 +512,13 @@ private fun DateOnlyPickerDialog(
|
||||
)
|
||||
if (minDate != null) {
|
||||
Text(
|
||||
text = "结束日期不能早于开始日期 ${minDate.formatDateLabel()}",
|
||||
text = stringResource(R.string.date_end_before_start, minDate.formatDateLabel()),
|
||||
modifier = Modifier.padding(start = 24.dp, top = 8.dp, end = 24.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = InkMuted
|
||||
)
|
||||
}
|
||||
DatePicker(state = dateState)
|
||||
DatePicker(state = dateState, title = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -525,20 +552,20 @@ internal fun DateTimePickerDialog(
|
||||
onConfirm(epochMs)
|
||||
}
|
||||
}) {
|
||||
Text("确定")
|
||||
Text(stringResource(R.string.action_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } }
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } }
|
||||
) {
|
||||
Column {
|
||||
DatePicker(state = dateState)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "时间 ${
|
||||
String.format("%02d", timeState.hour)
|
||||
}:${
|
||||
text = stringResource(
|
||||
R.string.time_hm,
|
||||
String.format("%02d", timeState.hour),
|
||||
String.format("%02d", timeState.minute)
|
||||
}",
|
||||
),
|
||||
modifier = Modifier.padding(horizontal = 24.dp),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
|
||||
@ -40,15 +40,21 @@ 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
|
||||
|
||||
@ -71,18 +77,15 @@ fun UsageOverviewScreen(
|
||||
|
||||
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)
|
||||
)
|
||||
@ -96,7 +99,7 @@ fun UsageOverviewScreen(
|
||||
item {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
TextButton(onClick = { menuExpanded = true }) {
|
||||
Text(uiState.timeFilter.label, color = StrawberryPink, fontWeight = FontWeight.SemiBold)
|
||||
Text(stringResource(uiState.timeFilter.labelRes), color = StrawberryPink, fontWeight = FontWeight.SemiBold)
|
||||
Icon(Icons.Filled.ArrowDropDown, null, tint = StrawberryPink)
|
||||
}
|
||||
Text("UTC+0", style = MaterialTheme.typography.bodySmall, color = inkMuted(), modifier = Modifier.padding(start = 4.dp))
|
||||
@ -106,23 +109,20 @@ 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())
|
||||
}
|
||||
}
|
||||
// 总览
|
||||
@ -134,7 +134,7 @@ fun UsageOverviewScreen(
|
||||
}
|
||||
// 按模型
|
||||
if (uiState.modelStats.isNotEmpty()) {
|
||||
item { SectionHeader("按模型统计") }
|
||||
item { SectionHeader(stringResource(R.string.section_by_model)) }
|
||||
items(uiState.modelStats, key = { it.model }) { ModelDetailRow(it) }
|
||||
}
|
||||
// 按天
|
||||
@ -144,14 +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) },
|
||||
@ -166,18 +166,31 @@ 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -193,24 +206,24 @@ fun UsageOverviewScreen(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
StatItem("输入 Token", formatTokenCount(inputTotal))
|
||||
StatItem("输出 Token", formatTokenCount(overview.outputTokens))
|
||||
StatItem("推理 Token", formatTokenCount(overview.reasoningTokens))
|
||||
StatItem("总计", formatTokenCount(overview.totalTokens))
|
||||
StatItem("总花费", "$${String.format(Locale.US, "%.4f", overview.totalCost / 100_000_000.0)}")
|
||||
StatItem("记录数", "${overview.totalCount}")
|
||||
StatItem(stringResource(R.string.usage_input_tokens), formatTokenCount(inputTotal))
|
||||
StatItem(stringResource(R.string.usage_output_tokens), formatTokenCount(overview.outputTokens))
|
||||
StatItem(stringResource(R.string.usage_reasoning_tokens), formatTokenCount(overview.reasoningTokens))
|
||||
StatItem(stringResource(R.string.usage_total), formatTokenCount(overview.totalTokens))
|
||||
StatItem(stringResource(R.string.usage_total_cost), "$${String.format(Locale.US, "%.4f", overview.totalCost / 100_000_000.0)}")
|
||||
StatItem(stringResource(R.string.usage_records), "${overview.totalCount}")
|
||||
}
|
||||
} else {
|
||||
Row(Modifier.fillMaxWidth(), Arrangement.SpaceEvenly) {
|
||||
StatItem("输入 Token", formatTokenCount(inputTotal))
|
||||
StatItem("输出 Token", formatTokenCount(overview.outputTokens))
|
||||
StatItem("推理 Token", formatTokenCount(overview.reasoningTokens))
|
||||
StatItem(stringResource(R.string.usage_input_tokens), formatTokenCount(inputTotal))
|
||||
StatItem(stringResource(R.string.usage_output_tokens), formatTokenCount(overview.outputTokens))
|
||||
StatItem(stringResource(R.string.usage_reasoning_tokens), formatTokenCount(overview.reasoningTokens))
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Row(Modifier.fillMaxWidth(), Arrangement.SpaceEvenly) {
|
||||
StatItem("总计", formatTokenCount(overview.totalTokens))
|
||||
StatItem("总花费", "$${String.format(Locale.US, "%.4f", overview.totalCost / 100_000_000.0)}")
|
||||
StatItem("记录数", "${overview.totalCount}")
|
||||
StatItem(stringResource(R.string.usage_total), formatTokenCount(overview.totalTokens))
|
||||
StatItem(stringResource(R.string.usage_total_cost), "$${String.format(Locale.US, "%.4f", overview.totalCost / 100_000_000.0)}")
|
||||
StatItem(stringResource(R.string.usage_records), "${overview.totalCount}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -220,15 +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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -243,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)
|
||||
@ -254,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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -273,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,6 +45,7 @@ import java.util.Locale
|
||||
@Composable
|
||||
fun UsageStatsCard(
|
||||
onOpenDetail: () -> Unit,
|
||||
onOpenHeatmap: () -> Unit = {},
|
||||
refreshTrigger: Int = 0,
|
||||
viewModel: UsageViewModel = hiltViewModel()
|
||||
) {
|
||||
@ -67,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
|
||||
)
|
||||
@ -87,7 +92,7 @@ fun UsageStatsCard(
|
||||
IconButton(onClick = { viewModel.sync() }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Refresh,
|
||||
contentDescription = "同步用量数据",
|
||||
contentDescription = stringResource(R.string.action_sync_usage),
|
||||
tint = StrawberryPink
|
||||
)
|
||||
}
|
||||
@ -100,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,
|
||||
@ -119,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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -128,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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -153,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
|
||||
)
|
||||
@ -161,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,16 +1,21 @@
|
||||
package com.rainy.token.ui.dashboard
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.rainy.token.R
|
||||
import com.rainy.token.data.local.OverviewStats
|
||||
import com.rainy.token.data.local.ModelStats
|
||||
import com.rainy.token.data.local.DailyStats
|
||||
import com.rainy.token.data.local.UsageCache
|
||||
import com.rainy.token.data.repository.CredentialRepository
|
||||
import com.rainy.token.data.repository.RepositoryError
|
||||
import com.rainy.token.domain.model.Credential
|
||||
import com.rainy.token.domain.service.ServiceType
|
||||
import com.rainy.token.domain.usecase.SyncCommandCodeUsageUseCase
|
||||
import com.rainy.token.domain.usecase.SyncError
|
||||
import com.rainy.token.domain.usecase.SyncUsageUseCase
|
||||
import com.rainy.token.ui.components.UiText
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@ -26,17 +31,17 @@ import javax.inject.Provider
|
||||
|
||||
internal const val DAILY_PAGE_SIZE = 5
|
||||
|
||||
/** 时间筛选条件 */
|
||||
sealed class TimeFilter(val label: String) {
|
||||
data object All : TimeFilter("全部")
|
||||
data object Last5h : TimeFilter("最近5小时")
|
||||
data object Last24h : TimeFilter("最近24小时")
|
||||
data object Today : TimeFilter("今天")
|
||||
data object Yesterday : TimeFilter("昨天")
|
||||
data object Last7Days : TimeFilter("最近7天")
|
||||
data object Last30Days : TimeFilter("最近30天")
|
||||
data object ThisMonth : TimeFilter("当月")
|
||||
data class Custom(val from: Long, val to: Long) : TimeFilter("自定义")
|
||||
/** 时间筛选条件(label 用字符串资源 ID,UI 层按当前语言解析) */
|
||||
sealed class TimeFilter(@StringRes val labelRes: Int) {
|
||||
data object All : TimeFilter(R.string.time_all)
|
||||
data object Last5h : TimeFilter(R.string.time_last_5h)
|
||||
data object Last24h : TimeFilter(R.string.time_last_24h)
|
||||
data object Today : TimeFilter(R.string.time_today)
|
||||
data object Yesterday : TimeFilter(R.string.time_yesterday)
|
||||
data object Last7Days : TimeFilter(R.string.time_last_7d)
|
||||
data object Last30Days : TimeFilter(R.string.time_last_30d)
|
||||
data object ThisMonth : TimeFilter(R.string.time_this_month)
|
||||
data class Custom(val from: Long, val to: Long) : TimeFilter(R.string.time_custom)
|
||||
|
||||
/** 计算筛选的起止 epoch 毫秒(from 含,to 含)。
|
||||
* 返回 Pair(null, null) 表示不限制。 */
|
||||
@ -83,6 +88,22 @@ class UsageViewModel @Inject constructor(
|
||||
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()
|
||||
}
|
||||
|
||||
/** 覆盖 workspaceId,用于 CCGO 等非 OCGO 服务。必须在 loadStats() 前调用。 */
|
||||
fun setWorkspace(wid: String) {
|
||||
workspaceIdOverride = wid
|
||||
@ -153,7 +174,8 @@ class UsageViewModel @Inject constructor(
|
||||
dailyStats = dailyStats,
|
||||
recordCount = totalCount,
|
||||
loading = false,
|
||||
dailyPage = 1
|
||||
dailyPage = 1,
|
||||
lastSyncAt = loadLastSyncAt(wid)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -184,6 +206,7 @@ class UsageViewModel @Inject constructor(
|
||||
fun sync() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(syncing = true) }
|
||||
val wid = workspaceIdOverride ?: workspaceId()
|
||||
val result = withContext(Dispatchers.Default) {
|
||||
val cache = cacheProvider.get()
|
||||
if (workspaceIdOverride == com.rainy.token.data.repository.CommandCodeUsageRepository.CCGO_WORKSPACE_ID) {
|
||||
@ -196,12 +219,15 @@ class UsageViewModel @Inject constructor(
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -217,12 +243,15 @@ class UsageViewModel @Inject constructor(
|
||||
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()?.message
|
||||
lastSyncError = result.exceptionOrNull()?.let { syncErrorToUiText(it) },
|
||||
lastSyncAt = if (result.isSuccess) syncedAt else it.lastSyncAt
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -241,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 = 全部模型
|
||||
@ -253,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,348 @@
|
||||
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 -> {
|
||||
checkinStatusStore.record(
|
||||
ServiceType.TRAE,
|
||||
account.id,
|
||||
CheckinStatusStore.STATUS_SUCCESS,
|
||||
checkinId = java.util.UUID.randomUUID().toString(),
|
||||
credits = r.points
|
||||
)
|
||||
ServiceResult(
|
||||
service = service,
|
||||
accountLabel = label,
|
||||
newlyCheckedIn = true,
|
||||
checkedInToday = true,
|
||||
creditsGained = r.points,
|
||||
message = "",
|
||||
failed = false
|
||||
)
|
||||
}
|
||||
TraeRepository.CheckinOutcome.ALREADY -> {
|
||||
checkinStatusStore.record(
|
||||
ServiceType.TRAE,
|
||||
account.id,
|
||||
CheckinStatusStore.STATUS_ALREADY
|
||||
)
|
||||
ServiceResult(service, label, false, true, 0, "", false)
|
||||
}
|
||||
// INTL 不支持自动签到:当天落一条已签记录防反复重试,静默跳过不进弹窗
|
||||
TraeRepository.CheckinOutcome.INTL_UNSUPPORTED -> {
|
||||
checkinStatusStore.record(
|
||||
ServiceType.TRAE,
|
||||
account.id,
|
||||
CheckinStatusStore.STATUS_ALREADY
|
||||
)
|
||||
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 -> {
|
||||
checkinStatusStore.record(
|
||||
ServiceType.WORKBUDDY,
|
||||
account.id,
|
||||
CheckinStatusStore.STATUS_SUCCESS,
|
||||
checkinId = java.util.UUID.randomUUID().toString(),
|
||||
credits = (s.todayCredit.takeIf { it > 0 } ?: s.dailyCredit)
|
||||
)
|
||||
ServiceResult(
|
||||
service = service,
|
||||
accountLabel = label,
|
||||
newlyCheckedIn = true,
|
||||
checkedInToday = true,
|
||||
creditsGained = (s.todayCredit.takeIf { it > 0 } ?: s.dailyCredit),
|
||||
message = "",
|
||||
failed = false
|
||||
)
|
||||
}
|
||||
WorkBuddyRepository.CheckinOutcome.ALREADY -> {
|
||||
checkinStatusStore.record(
|
||||
ServiceType.WORKBUDDY,
|
||||
account.id,
|
||||
CheckinStatusStore.STATUS_ALREADY
|
||||
)
|
||||
ServiceResult(service, label, false, true, 0, "", false)
|
||||
}
|
||||
WorkBuddyRepository.CheckinOutcome.QUERY -> {
|
||||
checkinStatusStore.record(
|
||||
ServiceType.WORKBUDDY,
|
||||
account.id,
|
||||
CheckinStatusStore.STATUS_ALREADY
|
||||
)
|
||||
ServiceResult(service, label, false, true, 0, "", false)
|
||||
}
|
||||
// 国际站无签到活动不是账号失败:当天落已签记录防反复重试,静默跳过不进弹窗
|
||||
WorkBuddyRepository.CheckinOutcome.NO_CAMPAIGN -> {
|
||||
checkinStatusStore.record(
|
||||
ServiceType.WORKBUDDY,
|
||||
account.id,
|
||||
CheckinStatusStore.STATUS_ALREADY
|
||||
)
|
||||
null
|
||||
}
|
||||
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,365 @@
|
||||
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.account.id }) { item ->
|
||||
val account = item.account
|
||||
val ordinal = accountOrdinal(uiState.accounts.map { it.account }, account)
|
||||
AccountRow(
|
||||
account = account,
|
||||
region = item.region,
|
||||
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,
|
||||
region: String?,
|
||||
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)
|
||||
)
|
||||
)
|
||||
}
|
||||
if (region != null) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = if (region.equals("INTL", ignoreCase = true)) {
|
||||
stringResource(R.string.option_region_intl)
|
||||
} else {
|
||||
stringResource(R.string.option_region_cn)
|
||||
},
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
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,112 @@
|
||||
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.Credential
|
||||
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)
|
||||
val items = accounts.mapNotNull { account ->
|
||||
val region = credentialRepository.get(type, account.id)
|
||||
?.let { c -> when (c) {
|
||||
is Credential.TraeCredential -> c.region.uppercase()
|
||||
is Credential.WorkBuddyCredential -> c.region.uppercase()
|
||||
else -> null
|
||||
} }
|
||||
AccountItem(account = account, region = region)
|
||||
}
|
||||
if (generation != loadGeneration) return@launch
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
loading = false,
|
||||
accounts = items,
|
||||
// 账号列表可能为空(用户刚删光),保持消息残留会误导,这里清掉
|
||||
message = if (items.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 AccountItem(
|
||||
val account: ServiceAccountInfo,
|
||||
val region: String?,
|
||||
)
|
||||
|
||||
data class AccountManagementUiState(
|
||||
val loading: Boolean = false,
|
||||
val accounts: List<AccountItem> = 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,6 +1,18 @@
|
||||
package com.rainy.token.ui.settings
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import android.content.ClipboardManager
|
||||
import android.content.ClipData
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@ -17,35 +29,55 @@ 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
|
||||
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.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
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.data.proxy.TraeModelProvider
|
||||
import com.rainy.token.domain.service.ServiceType
|
||||
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
|
||||
|
||||
/**
|
||||
* 设置页(雨晴风格重做版)。
|
||||
@ -60,10 +92,28 @@ 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()
|
||||
val proxyRunning by viewModel.proxyRunning.collectAsStateWithLifecycle()
|
||||
val proxyAllowLan by viewModel.proxyAllowLan.collectAsStateWithLifecycle()
|
||||
val proxyLanIp by viewModel.proxyLanIp.collectAsStateWithLifecycle()
|
||||
val proxyApiKey by viewModel.proxyApiKey.collectAsStateWithLifecycle()
|
||||
val proxyError by viewModel.proxyError.collectAsStateWithLifecycle()
|
||||
val proxyDebug by viewModel.proxyDebug.collectAsStateWithLifecycle()
|
||||
val proxyDebugRaw by viewModel.proxyDebugRaw.collectAsStateWithLifecycle()
|
||||
val proxyModels by viewModel.proxyModels.collectAsStateWithLifecycle()
|
||||
val selectedProxy by viewModel.selectedProxy.collectAsStateWithLifecycle()
|
||||
val proxyModel by viewModel.proxyModel.collectAsStateWithLifecycle()
|
||||
var showLanguageDialog by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
containerColor = Color.Transparent,
|
||||
@ -71,7 +121,7 @@ fun SettingsScreen(
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
"设置",
|
||||
stringResource(R.string.title_settings),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
@ -80,7 +130,7 @@ fun SettingsScreen(
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Icons.Filled.ArrowBack,
|
||||
contentDescription = "返回",
|
||||
contentDescription = stringResource(R.string.action_back),
|
||||
tint = StrawberryPink
|
||||
)
|
||||
}
|
||||
@ -110,22 +160,60 @@ 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 {
|
||||
ProxyGatewayCard(
|
||||
running = proxyRunning,
|
||||
allowLan = proxyAllowLan,
|
||||
lanIp = proxyLanIp,
|
||||
apiKey = proxyApiKey,
|
||||
error = proxyError,
|
||||
debug = proxyDebug,
|
||||
debugRaw = proxyDebugRaw,
|
||||
proxyTargets = viewModel.proxyTargets,
|
||||
selectedProxy = selectedProxy,
|
||||
onSelectProxy = viewModel::selectProxy,
|
||||
models = proxyModels,
|
||||
selectedModel = proxyModel,
|
||||
onSelectModel = viewModel::selectProxyModel,
|
||||
onRefreshModels = viewModel::refreshProxyModels,
|
||||
onToggle = viewModel::setProxyEnabled,
|
||||
onAllowLanChange = viewModel::setProxyAllowLan,
|
||||
onTestTrae = viewModel::testTraeStream,
|
||||
onTestWorkBuddy = viewModel::testWorkBuddyStream
|
||||
)
|
||||
}
|
||||
item {
|
||||
TipsCard(onClick = { onOpenTips() })
|
||||
}
|
||||
item {
|
||||
DebugLogCard(onClick = { onOpenDebugLog() })
|
||||
}
|
||||
item {
|
||||
UpdateCard()
|
||||
}
|
||||
item {
|
||||
Spacer(modifier = Modifier.padding(top = 8.dp))
|
||||
AboutCard()
|
||||
@ -133,10 +221,323 @@ fun SettingsScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showLanguageDialog) {
|
||||
LanguageDialog(onDismiss = { showLanguageDialog = false })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CredentialStatusCard(status: CredentialStatus, onClick: () -> Unit) {
|
||||
private fun ProxyGatewayCard(
|
||||
running: Boolean,
|
||||
allowLan: Boolean,
|
||||
lanIp: String?,
|
||||
apiKey: String,
|
||||
error: String?,
|
||||
debug: String?,
|
||||
debugRaw: String?,
|
||||
proxyTargets: List<ProxyTarget>,
|
||||
selectedProxy: ProxyTarget,
|
||||
onSelectProxy: (ProxyTarget) -> Unit,
|
||||
models: List<TraeModelProvider.TraeModel>,
|
||||
selectedModel: String,
|
||||
onSelectModel: (String) -> Unit,
|
||||
onRefreshModels: () -> Unit,
|
||||
onToggle: (Boolean) -> Unit,
|
||||
onAllowLanChange: (Boolean) -> Unit,
|
||||
onTestTrae: () -> Unit,
|
||||
onTestWorkBuddy: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var proxyMenuExpanded by remember { mutableStateOf(false) }
|
||||
var modelMenuExpanded by remember { mutableStateOf(false) }
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
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 = "代理网关",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Text(
|
||||
text = when {
|
||||
!running -> "已停止"
|
||||
allowLan && lanIp != null -> "运行中 · http://$lanIp:8787/v1"
|
||||
allowLan -> "运行中 · 局域网模式(未取到 IP,请看手机热点设置)"
|
||||
else -> "运行中 · http://127.0.0.1:8787/v1"
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = InkMuted,
|
||||
modifier = Modifier.padding(top = 2.dp)
|
||||
)
|
||||
Text(
|
||||
text = "API Key:$apiKey",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = InkMuted,
|
||||
modifier = Modifier.padding(top = 2.dp)
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "允许局域网/热点设备连接",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = InkMuted,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Switch(
|
||||
checked = allowLan,
|
||||
onCheckedChange = onAllowLanChange
|
||||
)
|
||||
}
|
||||
if (allowLan && lanIp != null) {
|
||||
val address = "http://$lanIp:8787/v1"
|
||||
TextButton(
|
||||
onClick = {
|
||||
val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
cm.setPrimaryClip(ClipData.newPlainText("proxy", address))
|
||||
Toast.makeText(context, "已复制 $address", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
) {
|
||||
Text("复制地址:$address")
|
||||
}
|
||||
}
|
||||
if (error != null) {
|
||||
Text(
|
||||
text = error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(top = 2.dp)
|
||||
)
|
||||
}
|
||||
Row(modifier = Modifier.padding(top = 4.dp)) {
|
||||
TextButton(onClick = onTestTrae) {
|
||||
Text("测试 Trae 流")
|
||||
}
|
||||
TextButton(onClick = onTestWorkBuddy) {
|
||||
Text("测试 WorkBuddy 流")
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "代理",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = InkMuted
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Box {
|
||||
TextButton(onClick = { proxyMenuExpanded = true }) {
|
||||
Text(selectedProxy.label + " ▾")
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = proxyMenuExpanded,
|
||||
onDismissRequest = { proxyMenuExpanded = false }
|
||||
) {
|
||||
proxyTargets.forEach { t ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(t.label) },
|
||||
onClick = {
|
||||
onSelectProxy(t)
|
||||
proxyMenuExpanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "模型",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = InkMuted
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Box {
|
||||
TextButton(onClick = { modelMenuExpanded = true }) {
|
||||
Text(selectedModel + " ▾")
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = modelMenuExpanded,
|
||||
onDismissRequest = { modelMenuExpanded = false }
|
||||
) {
|
||||
models.forEach { m ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(m.label ?: m.id) },
|
||||
onClick = {
|
||||
onSelectModel(m.id)
|
||||
modelMenuExpanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
TextButton(onClick = onRefreshModels) {
|
||||
Text("刷新")
|
||||
}
|
||||
}
|
||||
if (debug != null) {
|
||||
Spacer(modifier = Modifier.padding(top = 8.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "探针摘要",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = InkMuted,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
TextButton(onClick = {
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("代理网关探针输出", debugRaw ?: debug))
|
||||
Toast.makeText(context, "已复制全部原始输出", Toast.LENGTH_SHORT).show()
|
||||
}) {
|
||||
Text("复制原始")
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 220.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f))
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = debug,
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
color = InkMuted
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Switch(
|
||||
checked = running,
|
||||
onCheckedChange = onToggle
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LanguageCard(onClick: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val current = LocaleManager.getLocaleCode(context)
|
||||
val labelRes = when (current) {
|
||||
"zh" -> R.string.app_locale_zh
|
||||
"zh-Hant" -> R.string.app_locale_zh_hant
|
||||
"en" -> R.string.app_locale_en
|
||||
else -> R.string.app_locale_follow_system
|
||||
}
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onClick() },
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "🌐",
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(R.string.title_language),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Text(
|
||||
text = stringResource(labelRes),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = InkMuted,
|
||||
modifier = Modifier.padding(top = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LanguageDialog(onDismiss: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val current = LocaleManager.getLocaleCode(context)
|
||||
val options: List<Pair<String?, Int>> = listOf(
|
||||
null to R.string.app_locale_follow_system,
|
||||
"zh" to R.string.app_locale_zh,
|
||||
"zh-Hant" to R.string.app_locale_zh_hant,
|
||||
"en" to R.string.app_locale_en
|
||||
)
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.dialog_language_title)) },
|
||||
text = {
|
||||
Column {
|
||||
options.forEach { (code, labelRes) ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
LocaleManager.saveLocale(context, code)
|
||||
onDismiss()
|
||||
// Android 13+:framework 设置语言后系统自动重建 Activity;
|
||||
// 旧系统才需要手动 recreate 让资源按新语言重新解析
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
(context as? Activity)?.recreate()
|
||||
}
|
||||
}
|
||||
.padding(vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(labelRes),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
if (current == code) {
|
||||
Text("✓", color = StrawberryPink, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CredentialStatusCard(
|
||||
status: CredentialStatus,
|
||||
accountCount: Int = 0,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@ -158,7 +559,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)
|
||||
@ -169,6 +574,159 @@ 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
|
||||
)
|
||||
if (phase.error != null) {
|
||||
Spacer(modifier = Modifier.padding(top = 4.dp))
|
||||
Text(
|
||||
text = phase.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.padding(top = 8.dp))
|
||||
Button(onClick = { viewModel.install(phase.file) }) {
|
||||
Text(
|
||||
if (phase.error == null) {
|
||||
stringResource(R.string.update_install)
|
||||
} else {
|
||||
"重试安装"
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
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))
|
||||
}
|
||||
TextButton(onClick = { viewModel.install(phase.file) }) {
|
||||
Text("已开启,继续安装")
|
||||
}
|
||||
}
|
||||
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(
|
||||
@ -179,13 +737,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
|
||||
)
|
||||
@ -193,18 +751,19 @@ private fun AboutCard() {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun stateLabel(state: CredentialStatus.State): String = when (state) {
|
||||
CredentialStatus.State.NOT_CONFIGURED -> "未配置 · 点击添加"
|
||||
CredentialStatus.State.OK -> "已配置 · 凭据有效"
|
||||
CredentialStatus.State.EXPIRED -> "已过期 · 点击重新配置"
|
||||
CredentialStatus.State.WARNING -> "需要重新验证"
|
||||
CredentialStatus.State.NOT_CONFIGURED -> stringResource(R.string.status_not_configured_add)
|
||||
CredentialStatus.State.OK -> stringResource(R.string.status_ok_valid)
|
||||
CredentialStatus.State.EXPIRED -> stringResource(R.string.status_expired_reconfigure)
|
||||
CredentialStatus.State.WARNING -> stringResource(R.string.status_need_reverify)
|
||||
}
|
||||
|
||||
private fun stateToChip(state: CredentialStatus.State): StatusStyle = when (state) {
|
||||
CredentialStatus.State.NOT_CONFIGURED -> StatusStyle("未配置", StatusLevel.WARNING)
|
||||
CredentialStatus.State.OK -> StatusStyle("已配置", StatusLevel.OK)
|
||||
CredentialStatus.State.EXPIRED -> StatusStyle("已过期", StatusLevel.ERROR)
|
||||
CredentialStatus.State.WARNING -> StatusStyle("需重登", StatusLevel.WARNING)
|
||||
CredentialStatus.State.NOT_CONFIGURED -> StatusStyle(R.string.status_not_configured, StatusLevel.WARNING)
|
||||
CredentialStatus.State.OK -> StatusStyle(R.string.status_configured, StatusLevel.OK)
|
||||
CredentialStatus.State.EXPIRED -> StatusStyle(R.string.status_expired, StatusLevel.ERROR)
|
||||
CredentialStatus.State.WARNING -> StatusStyle(R.string.status_relogin, StatusLevel.WARNING)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@ -228,13 +787,50 @@ private fun TipsCard(onClick: () -> Unit) {
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "使用小技巧",
|
||||
text = stringResource(R.string.title_tips),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = StrawberryPink
|
||||
)
|
||||
Text(
|
||||
text = "查看全部 ${AppTips.tips.size} 条隐藏操作",
|
||||
text = stringResource(R.string.tips_view_all, AppTips.tips.size),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = InkMuted,
|
||||
modifier = Modifier.padding(top = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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)
|
||||
|
||||
@ -1,15 +1,38 @@
|
||||
package com.rainy.token.ui.settings
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.rainy.token.data.proxy.LocalProxyServer
|
||||
import com.rainy.token.data.proxy.ProviderRegion
|
||||
import com.rainy.token.data.proxy.ProxyUpstreamStream
|
||||
import com.rainy.token.data.proxy.ProxyServerConfig
|
||||
import com.rainy.token.data.proxy.TraeChatProxy
|
||||
import com.rainy.token.data.proxy.TraeModelProvider
|
||||
import com.rainy.token.data.proxy.WorkBuddyModelProvider
|
||||
import com.rainy.token.data.proxy.WorkBuddyChatProxy
|
||||
import com.rainy.token.data.proxy.currentLanIpv4
|
||||
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 dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.net.NetworkInterface
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.Json
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
@ -18,12 +41,338 @@ import javax.inject.Inject
|
||||
*/
|
||||
@HiltViewModel
|
||||
class SettingsViewModel @Inject constructor(
|
||||
private val credentialRepository: CredentialRepository
|
||||
@ApplicationContext private val context: Context,
|
||||
private val credentialRepository: CredentialRepository,
|
||||
private val localProxyServer: LocalProxyServer,
|
||||
private val traeChatProxy: TraeChatProxy,
|
||||
private val workBuddyChatProxy: WorkBuddyChatProxy,
|
||||
private val traeModelProvider: TraeModelProvider,
|
||||
private val workBuddyModelProvider: WorkBuddyModelProvider,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(SettingsUiState())
|
||||
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
|
||||
|
||||
/** 本地反代服务运行状态。 */
|
||||
val proxyRunning: StateFlow<Boolean> = localProxyServer.isRunning
|
||||
|
||||
/** 是否允许局域网/热点设备连接(绑定 0.0.0.0)。 */
|
||||
private val _proxyAllowLan = MutableStateFlow(
|
||||
context.getSharedPreferences("proxy_settings", Context.MODE_PRIVATE)
|
||||
.getBoolean("allow_lan", false)
|
||||
)
|
||||
val proxyAllowLan: StateFlow<Boolean> = _proxyAllowLan.asStateFlow()
|
||||
|
||||
/** 当前局域网/热点 IPv4(仅供显示,不要依赖固定值)。 */
|
||||
private val _proxyLanIp = MutableStateFlow(currentLanIpv4())
|
||||
val proxyLanIp: StateFlow<String?> = _proxyLanIp.asStateFlow()
|
||||
|
||||
/** 当前本机 API Key(每次进程随机生成,仅提示用)。 */
|
||||
val proxyApiKey: StateFlow<String> = MutableStateFlow(generateProxyKey())
|
||||
|
||||
/** 启动失败时的错误信息。 */
|
||||
private val _proxyError = MutableStateFlow<String?>(null)
|
||||
val proxyError: StateFlow<String?> = _proxyError.asStateFlow()
|
||||
|
||||
/** 启动/停止本地反代服务。 */
|
||||
fun setProxyEnabled(enabled: Boolean) {
|
||||
if (enabled) {
|
||||
_proxyLanIp.value = currentLanIpv4()
|
||||
val config = ProxyServerConfig(
|
||||
apiKey = proxyApiKey.value,
|
||||
allowLan = _proxyAllowLan.value
|
||||
)
|
||||
localProxyServer.start(config)
|
||||
.onSuccess { _proxyError.value = null }
|
||||
.onFailure { e -> _proxyError.value = e.message ?: "代理网关启动失败" }
|
||||
} else {
|
||||
localProxyServer.stop()
|
||||
_proxyError.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换「允许局域网/热点设备连接」;运行中会按新配置重启代理。 */
|
||||
fun setProxyAllowLan(enabled: Boolean) {
|
||||
context.getSharedPreferences("proxy_settings", Context.MODE_PRIVATE)
|
||||
.edit().putBoolean("allow_lan", enabled).apply()
|
||||
_proxyAllowLan.value = enabled
|
||||
_proxyLanIp.value = currentLanIpv4()
|
||||
if (proxyRunning.value) {
|
||||
localProxyServer.stop()
|
||||
setProxyEnabled(true)
|
||||
}
|
||||
}
|
||||
|
||||
/** 代理调试输出(结构化摘要)。 */
|
||||
private val _proxyDebug = MutableStateFlow<String?>(null)
|
||||
val proxyDebug: StateFlow<String?> = _proxyDebug.asStateFlow()
|
||||
|
||||
/** 代理调试完整原始输出(请求 + 响应帧),供一键复制。 */
|
||||
private val _proxyDebugRaw = MutableStateFlow<String?>(null)
|
||||
val proxyDebugRaw: StateFlow<String?> = _proxyDebugRaw.asStateFlow()
|
||||
|
||||
/** 代理目标(用于测试和模型选择;真实转发仍由 ProviderRouter 自动路由)。 */
|
||||
val proxyTargets: List<ProxyTarget> = ProxyTarget.entries
|
||||
|
||||
private val _selectedProxy = MutableStateFlow(ProxyTarget.TRAE_CN)
|
||||
val selectedProxy: StateFlow<ProxyTarget> = _selectedProxy.asStateFlow()
|
||||
|
||||
private val _proxyModels = MutableStateFlow<List<TraeModelProvider.TraeModel>>(traeModelProvider.modelsFor(ProviderRegion.CN))
|
||||
val proxyModels: StateFlow<List<TraeModelProvider.TraeModel>> = _proxyModels.asStateFlow()
|
||||
|
||||
private val _proxyModel = MutableStateFlow(traeModelProvider.selectedFor(ProviderRegion.CN))
|
||||
val proxyModel: StateFlow<String> = _proxyModel.asStateFlow()
|
||||
|
||||
/** 切换当前测试目标代理。 */
|
||||
fun selectProxy(target: ProxyTarget) {
|
||||
_selectedProxy.value = target
|
||||
val region = target.region()
|
||||
_proxyModels.value = when (target) {
|
||||
ProxyTarget.TRAE_CN, ProxyTarget.TRAE_INTL -> traeModelProvider.modelsFor(region)
|
||||
else -> workBuddyModelProvider.modelsFor(region)
|
||||
}
|
||||
_proxyModel.value = when (target) {
|
||||
ProxyTarget.TRAE_CN, ProxyTarget.TRAE_INTL -> traeModelProvider.selectedFor(region)
|
||||
else -> workBuddyModelProvider.selectedFor(region)
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换当前代理的测试模型并持久化。 */
|
||||
fun selectProxyModel(id: String) {
|
||||
val target = _selectedProxy.value
|
||||
val region = target.region()
|
||||
when (target) {
|
||||
ProxyTarget.TRAE_CN, ProxyTarget.TRAE_INTL -> traeModelProvider.setSelectedFor(region, id)
|
||||
else -> workBuddyModelProvider.setSelectedFor(region, id)
|
||||
}
|
||||
_proxyModel.value = id
|
||||
}
|
||||
|
||||
/** 拉取当前代理可用模型;失败保留内置列表。 */
|
||||
fun refreshProxyModels() {
|
||||
val target = _selectedProxy.value
|
||||
val region = target.region()
|
||||
viewModelScope.launch {
|
||||
val ok = when (target) {
|
||||
ProxyTarget.TRAE_CN, ProxyTarget.TRAE_INTL -> traeModelProvider.refreshFor(region)
|
||||
else -> workBuddyModelProvider.refreshFor(region)
|
||||
}
|
||||
_proxyModels.value = when (target) {
|
||||
ProxyTarget.TRAE_CN, ProxyTarget.TRAE_INTL -> traeModelProvider.modelsFor(region)
|
||||
else -> workBuddyModelProvider.modelsFor(region)
|
||||
}
|
||||
if (!ok) _proxyDebug.value = "模型列表更新失败(当前显示内置列表)"
|
||||
}
|
||||
}
|
||||
|
||||
/** 用当前选中的 Trae 目标发一条 stream=true 测试(未选 Trae 时兜底 CN)。 */
|
||||
fun testTraeStream() {
|
||||
val target = _selectedProxy.value
|
||||
val region = when (target) {
|
||||
ProxyTarget.TRAE_CN, ProxyTarget.TRAE_INTL -> target.region()
|
||||
else -> ProviderRegion.CN
|
||||
}
|
||||
val intl = region == ProviderRegion.INTL
|
||||
viewModelScope.launch {
|
||||
_proxyDebug.value = if (intl) "正在请求 Trae INTL 流..." else "正在请求 Trae CN 流..."
|
||||
_proxyDebugRaw.value = null
|
||||
val model = traeModelProvider.selectedFor(region)
|
||||
val body = """{"model":"$model","stream":true,"messages":[{"role":"user","content":"只回复OK两个字"}],"max_tokens":100}"""
|
||||
val requestLine = if (intl) {
|
||||
"POST https://core-normal.trae.ai/api/agent/v3/llm_utils_chat"
|
||||
} else {
|
||||
"POST https://trae-api-cn.mchost.guru/api/agent/v3/llm_utils_chat"
|
||||
}
|
||||
try {
|
||||
val stream = traeChatProxy.openStreamingChat(body, null, region)
|
||||
if (stream == null) {
|
||||
_proxyDebug.value = if (intl) "Trae INTL 未配置或未登录" else "Trae CN 未配置或未登录"
|
||||
return@launch
|
||||
}
|
||||
val label = if (intl) "Trae INTL" else "Trae CN"
|
||||
val result = readStreamSample(stream, label, body, requestLine)
|
||||
_proxyDebug.value = result.summary
|
||||
_proxyDebugRaw.value = result.raw
|
||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
_proxyDebug.value = (if (intl) "Trae INTL" else "Trae CN") + " 异常:" + (e.message ?: "未知错误")
|
||||
_proxyDebugRaw.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 用当前选中的 WorkBuddy 目标发一条 stream=true 测试(未选 WorkBuddy 时兜底 CN)。 */
|
||||
fun testWorkBuddyStream() {
|
||||
val target = _selectedProxy.value
|
||||
val region = when (target) {
|
||||
ProxyTarget.WORKBUDDY_CN, ProxyTarget.WORKBUDDY_INTL -> target.region()
|
||||
else -> ProviderRegion.CN
|
||||
}
|
||||
val intl = region == ProviderRegion.INTL
|
||||
viewModelScope.launch {
|
||||
_proxyDebug.value = if (intl) "正在请求 WorkBuddy INTL 流..." else "正在请求 WorkBuddy CN 流..."
|
||||
_proxyDebugRaw.value = null
|
||||
val model = workBuddyModelProvider.selectedFor(region)
|
||||
val body = """{"model":"$model","stream":true,"messages":[{"role":"user","content":"只回复OK两个字"}],"max_tokens":100}"""
|
||||
val requestLine = if (intl) {
|
||||
"POST https://www.workbuddy.ai/console/chat/completions"
|
||||
} else {
|
||||
"POST https://copilot.tencent.com/v2/chat/completions"
|
||||
}
|
||||
try {
|
||||
val stream = workBuddyChatProxy.openStreamingChat(body, null, region)
|
||||
if (stream == null) {
|
||||
_proxyDebug.value = if (intl) "WorkBuddy INTL 未配置或未登录" else "WorkBuddy CN 未配置或未登录"
|
||||
return@launch
|
||||
}
|
||||
val label = if (intl) "WorkBuddy INTL" else "WorkBuddy CN"
|
||||
val result = readStreamSample(stream, label, body, requestLine)
|
||||
_proxyDebug.value = result.summary
|
||||
_proxyDebugRaw.value = result.raw
|
||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
_proxyDebug.value = (if (intl) "WorkBuddy INTL" else "WorkBuddy CN") + " 异常:" + (e.message ?: "未知错误")
|
||||
_proxyDebugRaw.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun readStreamSample(
|
||||
stream: ProxyUpstreamStream,
|
||||
label: String,
|
||||
requestBody: String,
|
||||
requestLine: String,
|
||||
): StreamProbeResult = withContext(Dispatchers.IO) {
|
||||
val lines = mutableListOf<String>()
|
||||
val raw = StringBuilder()
|
||||
raw.append(label).append(" HTTP ").append(stream.status)
|
||||
raw.append(" Content-Type=").append(stream.contentType).append("\n")
|
||||
raw.append("请求: ").append(requestLine).append("\n")
|
||||
raw.append("请求体: ").append(requestBody).append("\n")
|
||||
raw.append("---- 上游响应原始帧 ----\n")
|
||||
try {
|
||||
val reader = stream.input.bufferedReader()
|
||||
repeat(120) {
|
||||
val line = reader.readLine() ?: return@repeat
|
||||
lines.add(line)
|
||||
raw.append(line).append("\n")
|
||||
if (line.trim() == "data: [DONE]") return@repeat
|
||||
if (raw.length > 12000) return@repeat
|
||||
}
|
||||
} finally {
|
||||
stream.close()
|
||||
}
|
||||
StreamProbeResult(
|
||||
summary = summarizeProbe(label, stream.status, stream.contentType, requestBody, requestLine, lines),
|
||||
raw = raw.toString()
|
||||
)
|
||||
}
|
||||
|
||||
private fun summarizeProbe(
|
||||
label: String,
|
||||
status: Int,
|
||||
contentType: String,
|
||||
requestBody: String,
|
||||
requestLine: String,
|
||||
lines: List<String>,
|
||||
): String {
|
||||
val sb = StringBuilder()
|
||||
sb.append(label).append(" 探针摘要")
|
||||
sb.append("\n请求: ").append(requestLine)
|
||||
runCatching {
|
||||
val body = json.parseToJsonElement(requestBody) as? JsonObject
|
||||
if (body != null) {
|
||||
val model = (body["model"] as? JsonPrimitive)?.contentOrNull ?: "?"
|
||||
val count = (body["messages"] as? JsonArray)?.size ?: 0
|
||||
val stream = (body["stream"] as? JsonPrimitive)?.contentOrNull ?: "?"
|
||||
sb.append("\n 模型=").append(model)
|
||||
sb.append(" · messages=").append(count)
|
||||
sb.append(" · stream=").append(stream)
|
||||
(body["tools"] as? JsonArray)?.let { sb.append(" · tools=").append(it.size) }
|
||||
}
|
||||
}
|
||||
sb.append("\n回复: HTTP ").append(status).append(" · ").append(contentType)
|
||||
var events = 0
|
||||
val texts = mutableListOf<String>()
|
||||
val models = linkedSetOf<String>()
|
||||
val usage = mutableListOf<String>()
|
||||
val errors = mutableListOf<String>()
|
||||
for (line in lines) {
|
||||
val trimmed = line.trim()
|
||||
if (!trimmed.startsWith("data:")) continue
|
||||
val payload = trimmed.removePrefix("data:").trim()
|
||||
if (payload == "[DONE]") continue
|
||||
val el = runCatching { json.parseToJsonElement(payload) as? JsonObject }.getOrNull() ?: continue
|
||||
events++
|
||||
collectProbeText(el, texts)
|
||||
collectProbeModel(el, models)
|
||||
collectProbeUsage(el, usage)
|
||||
(el["error"] as? JsonObject)?.let { e ->
|
||||
val msg = (e["message"] as? JsonPrimitive)?.contentOrNull ?: e.toString()
|
||||
errors.add(msg)
|
||||
}
|
||||
}
|
||||
if (events > 0) sb.append("\n事件帧: ").append(events)
|
||||
if (models.isNotEmpty()) sb.append("\n模型: ").append(models.joinToString(" / "))
|
||||
if (texts.isNotEmpty()) {
|
||||
val joined = texts.joinToString("").trim().replace("\n", " ").take(200)
|
||||
sb.append("\n文本(截断): ").append(if (joined.isEmpty()) "(空)" else joined)
|
||||
}
|
||||
if (usage.isNotEmpty()) sb.append("\n用量: ").append(usage.distinct().joinToString(" · "))
|
||||
if (errors.isNotEmpty()) sb.append("\n错误: ").append(errors.take(2).joinToString(" | "))
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun collectProbeText(el: JsonObject, out: MutableList<String>) {
|
||||
fun add(v: JsonElement?) {
|
||||
when (v) {
|
||||
is JsonPrimitive -> if (v.isString) out.add(v.content)
|
||||
is JsonObject -> {
|
||||
(v["content"] as? JsonPrimitive)?.takeIf { it.isString }?.let { out.add(it.content) }
|
||||
(v["text"] as? JsonPrimitive)?.takeIf { it.isString }?.let { out.add(it.content) }
|
||||
(v["delta"] as? JsonObject)?.let { add(it) }
|
||||
}
|
||||
is JsonArray -> v.forEach { add(it) }
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
(el["data"] as? JsonObject)?.let { add(it) }
|
||||
(el["content"] as? JsonElement)?.let { add(it) }
|
||||
(el["text"] as? JsonElement)?.let { add(it) }
|
||||
(el["message"] as? JsonObject)?.let { add(it) }
|
||||
(el["choices"] as? JsonArray)?.forEach { c ->
|
||||
val co = c as? JsonObject ?: return@forEach
|
||||
(co["delta"] as? JsonObject)?.let { add(it) }
|
||||
(co["message"] as? JsonObject)?.let { add(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun collectProbeModel(el: JsonObject, out: MutableSet<String>) {
|
||||
for (key in listOf("model_name", "modelName", "config_name", "model", "provider_model_name", "modelProviderName")) {
|
||||
(el[key] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotBlank() }?.let { out.add(it) }
|
||||
}
|
||||
(el["data"] as? JsonObject)?.let { collectProbeModel(it, out) }
|
||||
(el["message"] as? JsonObject)?.let { collectProbeModel(it, out) }
|
||||
}
|
||||
|
||||
private fun collectProbeUsage(el: JsonObject, out: MutableList<String>) {
|
||||
val usage = (el["usage"] as? JsonObject) ?: (el["token_usage"] as? JsonObject) ?: return
|
||||
fun num(key: String, label: String) {
|
||||
(usage[key] as? JsonPrimitive)?.let { out.add(label + "=" + it.content) }
|
||||
}
|
||||
num("prompt_tokens", "输入")
|
||||
num("completion_tokens", "输出")
|
||||
num("total_tokens", "合计")
|
||||
}
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
|
||||
private fun generateProxyKey(): String =
|
||||
"rainy-" + UUID.randomUUID().toString().replace("-", "").take(16)
|
||||
|
||||
init {
|
||||
refresh()
|
||||
}
|
||||
@ -32,14 +381,45 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class ProxyTarget(val key: String, val label: String) {
|
||||
TRAE_CN("trae_cn", "Trae CN"),
|
||||
TRAE_INTL("trae_intl", "Trae INTL"),
|
||||
WORKBUDDY_CN("wb_cn", "WorkBuddy CN"),
|
||||
WORKBUDDY_INTL("wb_intl", "WorkBuddy INTL");
|
||||
|
||||
fun region(): ProviderRegion = when (this) {
|
||||
TRAE_CN, WORKBUDDY_CN -> ProviderRegion.CN
|
||||
else -> ProviderRegion.INTL
|
||||
}
|
||||
}
|
||||
|
||||
data class StreamProbeResult(
|
||||
val summary: String,
|
||||
val raw: String,
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user