Compare commits
No commits in common. "dev" and "v1.5.3" have entirely different histories.
29
.github/workflows/ci.yml
vendored
29
.github/workflows/ci.yml
vendored
@ -5,20 +5,17 @@ on:
|
||||
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
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
- uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
|
||||
- uses: android-actions/setup-android@v3
|
||||
with:
|
||||
packages: 'platforms;android-35 build-tools;35.0.0'
|
||||
|
||||
@ -30,7 +27,7 @@ jobs:
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: unit-test-results
|
||||
path: app/build/test-results/testDebugUnitTest/*.xml
|
||||
@ -41,7 +38,7 @@ jobs:
|
||||
|
||||
- name: Upload lint results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: lint-results
|
||||
path: app/build/reports/lint-results-debug.*
|
||||
@ -52,12 +49,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
- uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
- uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
|
||||
- uses: android-actions/setup-android@v3
|
||||
with:
|
||||
packages: 'platforms;android-35 build-tools;35.0.0'
|
||||
|
||||
@ -68,7 +65,7 @@ jobs:
|
||||
run: ./gradlew assembleDebug
|
||||
|
||||
- name: Upload Debug APK
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: debug-apk
|
||||
path: app/build/outputs/apk/debug/app-debug.apk
|
||||
@ -79,12 +76,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
- uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
- uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
|
||||
- uses: android-actions/setup-android@v3
|
||||
with:
|
||||
packages: 'platforms;android-35 build-tools;35.0.0'
|
||||
|
||||
@ -122,7 +119,7 @@ jobs:
|
||||
echo "✅ APK integrity verified: manifest + arsc + res/ all present"
|
||||
|
||||
- name: Upload Release APK
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-apk
|
||||
path: app/build/outputs/apk/release/app-release.apk
|
||||
|
||||
11
.github/workflows/release.yml
vendored
11
.github/workflows/release.yml
vendored
@ -10,12 +10,12 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
- uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
- uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
|
||||
- uses: android-actions/setup-android@v3
|
||||
with:
|
||||
packages: 'platforms;android-35 build-tools;35.0.0'
|
||||
- name: Decode Keystore
|
||||
@ -28,13 +28,10 @@ jobs:
|
||||
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@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
|
||||
- uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
name: "🎉 RainyToken ${{ github.ref_name }}"
|
||||
files: RainyToken-${{ github.ref_name }}.apk
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@ -7,7 +7,6 @@
|
||||
/.idea/workspace.xml
|
||||
/.idea/navEditor.xml
|
||||
/.idea/assetWizardSettings.xml
|
||||
/.idea/
|
||||
.DS_Store
|
||||
/build
|
||||
app/build/
|
||||
@ -22,8 +21,3 @@ release.jks
|
||||
*.jks
|
||||
tmp_cc/
|
||||
goal.md
|
||||
/reference/
|
||||
|
||||
# 调试临时文件(可能包含真实接口响应/账号数据,禁止提交)
|
||||
_tmp_*
|
||||
build_compile.log
|
||||
|
||||
285
AGENTS.md
285
AGENTS.md
@ -1,5 +1,284 @@
|
||||
任务开始前必须先读取 taste.md(协作偏好),任务全程遵守其中约定,包括:修改后派发独立 subagent 审计(无阻断问题 + 无影响用户体验问题),修复后用同一个 subagent 复审。
|
||||
# 雨晴Token — AI 余额查询 APP
|
||||
|
||||
未经用户允许禁止提交推送
|
||||
## ⚠️ 提交/推送许可红线(最高优先级)
|
||||
|
||||
派发子代理任务(独立审计/探索/通用 subagent)时,prompt 中涉及工作区文件的所有路径必须使用完整绝对路径(以工作区根目录为前缀)。子代理没有工作区附着上下文,相对路径会导致其找不到文件或无法执行。
|
||||
> 本仓库的所有 Git 写操作(`git commit`、`git push`、创建/合并 PR、推送 tag 等)**必须事先获得水晴喵的明确同意**,未经允许一律禁止执行。
|
||||
>
|
||||
> - 任何代码修改完成后,只做本地编辑,**不得擅自 commit / push**。需要提交时,先向水晴喵说明改动内容与提交意图,等她说「可以提交」「push 吧」之类的明确许可后再执行。
|
||||
> - 不得以「PR 审查」「CI 验证」等理由绕过许可——创建 PR、推送分支也属于写操作,同样需要先征求同意。
|
||||
> - 水晴喵说「不用了」「先别提交」「再看看」时,保持当前状态,不 push 任何东西。
|
||||
> - 这条规则优先级高于文件中其它任何流程(包括「重大修改 PR 审计红线」),其它规则只决定**怎么提交**,这条决定**能不能提交**。
|
||||
|
||||
## 项目概述
|
||||
|
||||
Android(Jetpack Compose + Kotlin)APP,统一查询 DeepSeek、OpenCode Go、CommandCode Go、Codex / ChatGPT Plus、Ollama Pro 的余额/配额。
|
||||
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`。**一键激活用量**:OCGO 详情页可向 `opencode.ai/zen/go/v1/chat/completions` 发送简短请求(`content:hello`)触发用量统计,API Key 从设置页手动填写(存 `SessionCredential.apiKey`),模型列表从 `models.dev/api.json`(provider=`opencode-go`)动态获取,用户选择持久化到 `SharedPreferences("ocgo_trigger_prefs")`,响应经 `parseChatResponse()` 提取回复文本+用量统计后弹窗展示。
|
||||
- ✅ CommandCode Go — JSON API 抓取用量数据,`CommandCodeUsageRepository` 解析(workspaceId = `"commandcode"`)
|
||||
- ✅ Codex / ChatGPT Plus — 支持 **OAuth PKCE 登录(无头模式)** 或粘贴完整 auth.json(含 refresh_token),调 `chatgpt.com/backend-api/wham/usage`;token 过期前 60 分钟自动刷新。OpenAI 采用 refresh_token 单次轮换机制,被外部工具使用后旧 token 立即失效,需重新 OAuth 登录或导入新 auth.json。**5h 窗口可能被 OpenAI 临时关闭**(`primary_window` 返回 `null`),UI 保留空 5h 槽位 + 主标签动态化(`extras["primary.label"]`),恢复后自动填回。**一键激活用量**:Codex 详情页可向 `chatgpt.com/backend-api/codex/responses` 发送简短请求(`input:hello`)触发用量统计,模型列表从 `models.dev/api.json` 动态获取(无需认证),用户选择持久化到 `SharedPreferences("codex_trigger_prefs")`,请求需 `stream:true`+`store:false`+`ChatGPT-Account-Id` 头(从 access_token JWT 解析),SSE 响应经 `parseSseResponse()` 提取回复文本+用量统计后弹窗展示。
|
||||
- ✅ Ollama Pro — Cookie 认证,OkHttp 抓 `ollama.com/settings` HTML,正则解析 plan/session(5h)/weekly 百分比 + `data-time` 重置时间 + `data-model` 模型级请求次数;无官方 API(ollama/ollama#12532)。**一键激活用量**:Ollama 详情页可向 `ollama.com/v1/chat/completions` 发送简短请求(`content:hello`)触发用量统计,API Key 从设置页手动填写(存 `SessionCredential.apiKey`),模型列表从 `models.dev/api.json`(provider=`ollama-cloud`)动态获取,用户选择持久化到 `SharedPreferences("ollama_trigger_prefs")`,响应经 `parseOllamaChatResponse()` 提取回复文本+用量统计后弹窗展示。
|
||||
- ✅ 文案统一:所有服务标签均使用中文("每周"统一代替 "weekly"/"Weekly"/"weekly")
|
||||
- ❌ OpenCode Zen / 小米 MiMo — 未实现
|
||||
|
||||
**用量统计系统**:
|
||||
- ✅ `UsageCache`(Room,indexed on workspaceId+timeCreated)— DAO 查询替代全量 JSON 序列化;首次启动自动从旧 DataStore JSON 迁移
|
||||
- ✅ `SyncUsageUseCase`(OCGO)/ `SyncCommandCodeUsageUseCase`(CCGO) — 首次全量同步(cursor 翻页)、增量同步(逐页比对本地 ID 集合,按 `getIdsByWorkspace(wid)` 过滤避免跨 workspace 碰撞)
|
||||
- ✅ `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()`(增量)
|
||||
|
||||
**调试与错误诊断**:
|
||||
- ✅ `DebugLog` — 内存 ring buffer(200 条,线程安全),所有 Repository 关键路径(网络请求、Token 刷新、解析错误)均写入日志;设置页提供「调试日志」入口,无需连电脑即可在 APP 内查看 ERROR/WARN/INFO 三级日志。
|
||||
- ✅ `RepositoryError.InvalidCredential` 支持自定义 `detail`,Codex `RefreshResult` 密封类明确区分刷新成功/失败原因,错误信息可透传到 Dashboard 卡片与调试日志。
|
||||
|
||||
**JsonNull 安全红线**:
|
||||
|
||||
> ⚠️ kotlinx.serialization 中 `?.jsonObject` / `?.jsonPrimitive` 对 JSON 显式 `null` 值无效——返回的是 `JsonNull` 对象(非 Kotlin null),扩展函数内部类型检查会抛 `IllegalArgumentException` 导致闪退。
|
||||
> **必须用 `as? JsonObject` / `as? JsonPrimitive` 替代**,安全转换能正确处理 `JsonNull`(返回 null)。
|
||||
> `parseUsageWindows()` 等解析方法外部必须包 `try/catch` → `RepositoryError.ParseError`,防止 API 响应结构变化导致异常逃逸。
|
||||
|
||||
**凭据回显红线**:
|
||||
|
||||
> ⚠️ `CredentialEditViewModel.load()` 首次加载已有凭据时,需针对每种 `Credential` 子类显式编写回显分支。
|
||||
> 当前覆盖:`ApiKeyCredential`(API Key 输入框)、`SessionCredential`(Cookie 输入框 / `ollamaCookie` 字段 / `apiKey` 字段 → `triggerApiKey`)、`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 数据。
|
||||
|
||||
**DashboardViewModel 刷新竞态红线**:
|
||||
|
||||
> ⚠️ `DashboardViewModel.init` 必须在**同一协程内串行**调用 `loadFromCache()` → `refresh()`,不能拆成两个独立协程。
|
||||
> 原因:并行时 `loadFromCache()` 读到的旧缓存快照可能在 `refresh()` 写入新数据后才 `_uiState.update`,导致用量数据闪回旧值(如 Codex 5h 61%→2%)。
|
||||
> `refresh()` 内部用 `Mutex.tryLock()` 防并发——下拉刷新与 init 的 refresh 并发时后者直接跳过,避免交错覆盖。
|
||||
|
||||
**hiltViewModel key 红线**:
|
||||
|
||||
> ⚠️ `hiltViewModel(key = key)` 的 key 在 ViewModelStore 内全局唯一、不区分类型。
|
||||
> CCGO 路由中 `UsageChartViewModel` 和 `UsageViewModel` 用相同 key 会导致类型碰撞、加载失败。
|
||||
> 当前方案:`chartVm` 用 `"ccgo_chart_$wid"`,`usageVm` 用 `"ccgo_$wid"`(与 Dashboard 首页 `CommandCodeUsageStatsCard` 共享实例)。
|
||||
|
||||
**ARM64 Proot Release 构建红线**:
|
||||
|
||||
> ⚠️ AGP 9.0 的 `optimizeReleaseResources` 在 ARM64 Proot 下传入 `--resource-path-shortening-map=<path>`(等号形式),ARM64 AAPT2 不接受此语法(只接受空格分隔),exit code=1 但 AGP `invokeAapt()` 只调 `rethrowFailure()` 不调 `assertNormalExitValue()`,失败被静默吞掉。导致 optimized `.ap_` 缺失,`packageRelease` 生成无 `AndroidManifest.xml` / `resources.arsc` / `res/` 的残缺 APK(BUILD SUCCESSFUL 但 APK 不可用)。
|
||||
>
|
||||
> 修复链(不可删除任一环节):
|
||||
> 1. `~/.gradle/gradle.properties`(全局,不提交项目仓库)→ `android.aapt2FromMavenOverride` 指向 `~/.local/lib/android-aapt2-wrapper/aapt2` wrapper 脚本,将 `--resource-path-shortening-map=<path>` 拆分为 `--resource-path-shortening-map` `<path>` 两个独立 argv
|
||||
> 2. `app/build.gradle.kts` 中 `guardReleaseResources` 任务:`optimizeReleaseResources` 之后验证 optimized `.ap_` 完整性(manifest + arsc + res/),缺失则复制 linked `.ap_` 作为 fallback;`packageRelease` 依赖此任务
|
||||
> 3. `app/build.gradle.kts` 中 `resolutionStrategy` 强制 `aapt2:linux-aarch64` classifier
|
||||
>
|
||||
> CI(GitHub Actions x86_64)不受影响——全局 `~/.gradle/gradle.properties` 不在项目仓库中。Debug 不经过 `optimizeReleaseResources`,无此问题。
|
||||
|
||||
**首页布局**:
|
||||
|
||||
> 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 的视觉提示(ASCII `>` 替代 Unicode `›`,确保全 ROM 兼容)。
|
||||
|
||||
**使用小技巧系统**:
|
||||
|
||||
> - `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()`,进入图表页即可看到最新时段数值详情,暗示图表可交互。
|
||||
|
||||
**测试体系**:
|
||||
|
||||
- 纯 JVM 单元测试(`testDebugUnitTest`),无 Android 框架依赖,无网络
|
||||
- 5 个测试文件,78 个用例:
|
||||
- `OllamaRepositoryTest`(16)— HTML 解析:plan/百分比/时间戳/模型次数/空输入
|
||||
- `OpenCodeGoRepositoryTest`(10)— SSR hydration 解析:三窗口/嵌套/缺失字段
|
||||
- `FormatUtilsTest`(18)— `formatAmount`/`formatResetInSec`/`formatResetForWidget`/`normalizeWindowLabel`
|
||||
- `FormatCodexPrimaryLabelTest`(9)— `formatCodexPrimaryLabel`:null fallback/大小写/未知值
|
||||
- `CodexRepositoryTest`(25)— `durationLabel` 阈值 + `parseUsageWindows` JsonNull 安全 + `parseSseResponse` SSE 解析
|
||||
- CodexRepository 中 `parseUsageWindows`/`durationLabel`/`UsageWindow` 为 `internal`(companion object),`parseSseResponse` 为 `internal` 顶层函数——不使用实例状态,便于测试直接调用
|
||||
|
||||
**CI workflow(`.github/workflows/ci.yml`)**:
|
||||
|
||||
- 触发:push main / 所有 PR
|
||||
- 3 个 job 串行(test 通过后才构建):
|
||||
1. `test`:`testDebugUnitTest` + `lintDebug`,上传 XML 测试报告 + lint 报告 artifact
|
||||
2. `build-debug`:`assembleDebug`,上传 Debug APK artifact
|
||||
3. `build-release`:`assembleRelease` + APK 完整性验证(`AndroidManifest.xml` + `resources.arsc` + `res/`),上传 Release APK artifact
|
||||
- Release 签名 fallback:CI 无 `release.jks`,`build.gradle.kts` 仅在 `CI=true` 环境变量下 fallback 到 `~/.android/debug.keystore`;本地构建缺 `release.jks` 时直接 `GradleException` 报错,防止静默生成 debug 签名的 Release APK
|
||||
- artifact 保留 14 天
|
||||
|
||||
**重大修改 PR 审计红线**:
|
||||
|
||||
> ⚠️ 重大修改(构建配置、签名、CI、架构变更、新服务接入)必须通过 PR 提交,不能直接 push main。
|
||||
> PR 触发 CI 三 job 全部通过后才可合并:单元测试 + lint + Debug/Release APK 构建验证。
|
||||
> Release APK 完整性验证(manifest + arsc + res/)是防止 ARM64 Proot 构建问题再次发生的最后防线。
|
||||
|
||||
## RemoteViews 兼容性红线
|
||||
|
||||
以下元素在 Widget 布局中**不可用**,会导致「载入出现问题」:
|
||||
|
||||
| ❌ 不可用 | ✅ 替代方案 |
|
||||
|-----------|------------|
|
||||
| `<Space>` | 透明 ProgressBar(`0dp + weight=1`) |
|
||||
| `<View>` | ProgressBar 或 TextView |
|
||||
| `<ImageView>` + 矢量 drawable | PNG(`drawable-nodpi`)。Widget logo 必须用 `_widget` 后缀 PNG(`ic_opencode_go_logo_widget.png`、`ic_codex_logo_widget.png`);DeepSeek / Ollama 已有 PNG(`ic_deepseek_logo.png`、`ic_ollama_logo.png`)。Compose 层(`ServiceIcon.kt`)继续用 VectorDrawable XML。 |
|
||||
| `<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 testDebugUnitTest
|
||||
# 78 个单元测试,纯 JVM,无设备依赖
|
||||
|
||||
./gradlew assembleDebug
|
||||
# Debug APK: app/build/outputs/apk/debug/app-debug.apk
|
||||
|
||||
./gradlew assembleRelease
|
||||
# Release APK: app/build/outputs/apk/release/app-release.apk
|
||||
# ARM64 Proot 需 ~/.gradle/gradle.properties 配置 aapt2FromMavenOverride(见红线)
|
||||
# CI x86_64 无需额外配置
|
||||
```
|
||||
|
||||
## 数据流
|
||||
|
||||
```
|
||||
DashboardViewModel.refresh()
|
||||
→ RefreshBalanceUseCase(service) (retryOnTransientError: Network/5xx 指数退避重试 2 次)
|
||||
→ DeepSeekRepository.fetchBalance() / OpenCodeGoRepository.fetchBalance()
|
||||
→ CommandCodeGoRepository.fetchBalance() / CodexRepository.fetchBalance()
|
||||
→ OllamaRepository.fetchBalance()
|
||||
→ BalanceCache.put(service, result) — put() 在 dataStore.edit {} 互斥锁内做 read-modify-write,避免并发覆盖
|
||||
→ 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/` 适配。
|
||||
32
README.en.md
32
README.en.md
@ -5,7 +5,7 @@
|
||||
[](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.
|
||||
Android app for checking AI balance & usage — unified view of DeepSeek, OpenCode Go, CommandCode Go, Codex / ChatGPT Plus, and Ollama Pro balances and usage quotas. Pink-toned brand UI, with companion home screen widgets.
|
||||
RainyToken (雨晴Token) — AI Balance & Usage Quota Query · the Rainy Family tools.
|
||||
|
||||
---
|
||||
@ -16,28 +16,20 @@ RainyToken (雨晴Token) — AI Balance & Usage Quota Query · the Rainy Family
|
||||
<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" />
|
||||
<img src="docs/screenshots/ollama-detail.jpg" width="200" alt="Ollama Pro model call counts" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<em>Dashboard (light) · Dashboard (dark) · Usage charts · Ollama model call counts</em>
|
||||
<em>Dashboard (light) · Dashboard (dark) · Usage charts · Ollama Pro model call counts</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/widget.jpg" width="300" alt="Home screen widget" />
|
||||
<img src="docs/screenshots/ollama-card.jpg" width="200" alt="Ollama card" />
|
||||
<img src="docs/screenshots/ollama-card.jpg" width="200" alt="Ollama Pro 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>
|
||||
<em>Home screen widget · Ollama Pro home card</em>
|
||||
</p>
|
||||
|
||||
---
|
||||
@ -48,10 +40,9 @@ RainyToken (雨晴Token) — AI Balance & Usage Quota Query · the Rainy Family
|
||||
|------|------|
|
||||
| 📊 **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 |
|
||||
| 🔍 **Multi-granularity filters** | 5 hours / 12 hours (10-minute buckets) / 24 hours / today / yesterday / last 7 days / current month / custom day · month · range |
|
||||
| 🏷️ **Model filtering** | Multi-select / single-select / select all, with dynamic legends that wrap automatically |
|
||||
| 📱 **Home screen widget** | Check usage without opening the app; switch between four services (OCGO/CCGO/Codex/Ollama) + DeepSeek balance; top-left corner → open app, tap elsewhere to switch, ↻ to refresh; can be added to the At a Glance screen; auto-refreshes when scrolled into view (MIUI exposure refresh) |
|
||||
| 🔄 **Auto sync** | Pull-to-refresh on home auto-syncs usage; auto full sync on startup when no cache; CCGO detail page supports manual clear & re-sync |
|
||||
@ -59,7 +50,7 @@ RainyToken (雨晴Token) — AI Balance & Usage Quota Query · the Rainy Family
|
||||
| ➕ **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 |
|
||||
| 🎀 **Rainy pink theme** | Material Design 3 · Strawberry Pink #FF85A2 · Sakura Pink #FFD1DC |
|
||||
| 🔐 **Codex OAuth sign-in** | Headless OAuth PKCE: the app generates an authorization link → sign in in an external browser → paste the callback URL to complete authorization; no manual auth.json export needed |
|
||||
| ⚡ **Codex one-tap usage activation** | Codex detail page can send a short request to the ChatGPT API to trigger usage tracking; model list fetched dynamically from models.dev and persisted, with manual refresh; response dialog is copyable |
|
||||
| ⚡ **OCGO / Ollama one-tap usage activation** | OCGO detail page can send a request to `opencode.ai/zen/v1`, Ollama detail page to `ollama.com/v1` to trigger usage tracking; API keys are entered manually in settings; model lists fetched dynamically from models.dev |
|
||||
@ -72,7 +63,7 @@ RainyToken (雨晴Token) — AI Balance & Usage Quota Query · the Rainy Family
|
||||
|
||||
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.
|
||||
> ⚠️ You need a DeepSeek API Key, OpenCode Go login credentials, CommandCode Go API Key, Codex (OAuth sign-in or pasted auth.json), or an Ollama Pro Cookie to fetch data.
|
||||
|
||||
---
|
||||
|
||||
@ -84,8 +75,7 @@ Download the latest APK from [Releases](https://github.com/CATMIAOZHI/Rainytoken
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Compose UI (3-layer screens) │ │
|
||||
│ │ Dashboard · Usage Charts · Totals │ │
|
||||
│ │ Detail · Token Activity · Settings │ │
|
||||
│ │ Dashboard · Usage Charts · Totals · Detail · Settings │ │
|
||||
│ └──────────────────────────────┬─────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────────────────▼─────────────────────────────┐ │
|
||||
@ -134,10 +124,6 @@ Rainytoken/
|
||||
│ │ └── 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)
|
||||
|
||||
32
README.md
32
README.md
@ -5,7 +5,7 @@
|
||||
[](https://github.com/CATMIAOZHI/Rainytoken/actions/workflows/ci.yml)
|
||||
[](https://github.com/CATMIAOZHI/Rainytoken/actions)
|
||||
[](https://github.com/CATMIAOZHI/Rainytoken/releases)
|
||||
Android AI 余额与用量查询 APP —— 统一查看 DeepSeek、OpenCode Go、CommandCode、Codex / ChatGPT、Ollama 的余额与用量配额。粉色调品牌 UI,配套桌面小组件。
|
||||
Android AI 余额与用量查询 APP —— 统一查看 DeepSeek、OpenCode Go、CommandCode Go、Codex / ChatGPT Plus、Ollama Pro 的余额与用量配额。粉色调品牌 UI,配套桌面小组件。
|
||||
RainyToken(雨晴Token)— AI Balance & Usage Quota Query · the Rainy Family tools.
|
||||
|
||||
---
|
||||
@ -16,28 +16,20 @@ RainyToken(雨晴Token)— AI Balance & Usage Quota Query · the Rainy Famil
|
||||
<img src="docs/screenshots/dashboard-light.jpg" width="200" alt="仪表盘(亮色)" />
|
||||
<img src="docs/screenshots/dashboard-dark.jpg" width="200" alt="仪表盘(深色)" />
|
||||
<img src="docs/screenshots/detail.jpg" width="200" alt="用量图表" />
|
||||
<img src="docs/screenshots/ollama-detail.jpg" width="200" alt="Ollama 模型调用次数" />
|
||||
<img src="docs/screenshots/ollama-detail.jpg" width="200" alt="Ollama Pro 模型调用次数" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<em>仪表盘(亮色) · 仪表盘(深色) · 用量图表 · Ollama 模型调用次数</em>
|
||||
<em>仪表盘(亮色) · 仪表盘(深色) · 用量图表 · Ollama Pro 模型调用次数</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 卡片" />
|
||||
<img src="docs/screenshots/ollama-card.jpg" width="200" alt="Ollama Pro 卡片" />
|
||||
</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>
|
||||
<em>桌面小组件 · Ollama Pro 首页卡片</em>
|
||||
</p>
|
||||
|
||||
---
|
||||
@ -48,10 +40,9 @@ RainyToken(雨晴Token)— AI Balance & Usage Quota Query · the Rainy Famil
|
||||
|------|------|
|
||||
| 📊 **仪表盘** | 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天 / 最近30天 / 当月 / 自定义日·月·范围 |
|
||||
| 🔍 **多粒度筛选** | 5小时 / 12小时(10分钟桶) / 24小时 / 今天 / 昨天 / 最近7天 / 当月 / 自定义日·月·范围 |
|
||||
| 🏷️ **模型筛选** | 多选 / 单选 / 全选,动态图例自适应换行 |
|
||||
| 📱 **桌面小组件** | 不打开 APP 也能看用量;支持四服务切换(OCGO/CCGO/Codex/Ollama)+ DeepSeek 余额;左上角 > 进 APP、其他区域点切换、↻ 刷新;可拖入负一屏;划到即自动刷新(MIUI 曝光刷新) |
|
||||
| 🔄 **自动同步** | 首页下拉自动同步用量;无缓存时启动自动全量同步;CCGO 详情页支持手动清除并重新同步 |
|
||||
@ -59,7 +50,7 @@ RainyToken(雨晴Token)— AI Balance & Usage Quota Query · the Rainy Famil
|
||||
| ➕ **一键添桌面** | APP 内点 + 直接添加小组件,不用去系统列表翻;二次确认 + 权限检测 |
|
||||
| 💡 **使用小技巧** | 首页随机展示一条操作提示(每次启动刷新);设置页可查看全部 13 条隐藏操作技巧 |
|
||||
| ⚡ **Room 数据库** | 用量记录存 Room(indexed on workspaceId+timeCreated),DAO 查询替代全量 JSON 序列化;首次启动自动从旧 DataStore 迁移 |
|
||||
| 🎀 **雨晴粉主题** | Material Design 3 · 精致玫瑰粉 主色 #C9436C · 粉白背景 #FDEFF4 |
|
||||
| 🎀 **雨晴粉主题** | Material Design 3 · 草莓粉 #FF85A2 · 樱粉 #FFD1DC |
|
||||
| 🔐 **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 动态获取 |
|
||||
@ -72,7 +63,7 @@ RainyToken(雨晴Token)— AI Balance & Usage Quota Query · the Rainy Famil
|
||||
|
||||
前往 [Releases](https://github.com/CATMIAOZHI/Rainytoken/releases) 下载最新 APK。
|
||||
|
||||
> ⚠️ 需要配置 DeepSeek API Key、OpenCode Go 登录凭据、CommandCode API Key、Codex(OAuth 登录或粘贴 auth.json)或 Ollama Cookie 才能拉取数据。
|
||||
> ⚠️ 需要配置 DeepSeek API Key、OpenCode Go 登录凭据、CommandCode Go API Key、Codex(OAuth 登录或粘贴 auth.json)或 Ollama Pro Cookie 才能拉取数据。
|
||||
|
||||
---
|
||||
|
||||
@ -84,8 +75,7 @@ RainyToken(雨晴Token)— AI Balance & Usage Quota Query · the Rainy Famil
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ Compose UI(3 层页面) │ │
|
||||
│ │ 仪表盘 · 用量图表 · 总统计 │ │
|
||||
│ │ 详细数据 · Token活动 · 设置 │ │
|
||||
│ │ 仪表盘 · 用量图表 · 总统计 · 详细数据 · 设置│ │
|
||||
│ └────────────────────┬────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌────────────────────▼─────────────────────────┐ │
|
||||
@ -133,10 +123,6 @@ 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)
|
||||
|
||||
@ -16,8 +16,8 @@ android {
|
||||
applicationId = "com.rainy.token"
|
||||
minSdk = 31
|
||||
targetSdk = 35
|
||||
versionCode = 48
|
||||
versionName = "1.7.28"
|
||||
versionCode = 8
|
||||
versionName = "1.5.3"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
@ -32,18 +32,9 @@ android {
|
||||
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")
|
||||
storePassword = System.getenv("KEYSTORE_PASSWORD") ?: "RainyToken2026!"
|
||||
keyAlias = System.getenv("KEYSTORE_ALIAS") ?: "rainy"
|
||||
keyPassword = System.getenv("KEY_PASSWORD") ?: "RainyToken2026!"
|
||||
} else {
|
||||
// 仅在 CI 环境中 fallback 到 debug keystore(用于编译/资源完整性验证)
|
||||
// 本地构建缺少 release.jks 时直接报错,避免静默生成 debug 签名的 Release APK
|
||||
@ -84,7 +75,6 @@ android {
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
androidResources {
|
||||
// Android 13+ 应用级语言设置:根据 values-* 目录自动生成 locales_config
|
||||
@ -190,7 +180,6 @@ 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)
|
||||
@ -214,12 +203,6 @@ 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)
|
||||
|
||||
|
||||
@ -3,9 +3,7 @@
|
||||
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"
|
||||
@ -35,15 +33,6 @@
|
||||
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"
|
||||
@ -77,16 +66,6 @@
|
||||
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>
|
||||
@ -15,7 +15,7 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||
*
|
||||
* 布局策略:
|
||||
* - 外层只套 RainyTokenTheme(统一品牌色 + 字体)
|
||||
* - 再套 RainyBackground(精致粉调渐变背景,全局共享)
|
||||
* - 再套 RainyBackground(樱粉渐变背景,全局共享)
|
||||
* - NavHost 在背景之上,每个页面自己用 Scaffold 处理 TopAppBar 和 padding
|
||||
* - **不**在外层再套 Scaffold —— 避免双 Scaffold 嵌套的 padding 计算混乱
|
||||
*/
|
||||
|
||||
@ -21,11 +21,7 @@ import kotlinx.serialization.json.Json
|
||||
*
|
||||
* 计划 7.1:断网时 UI 仍可展示 stale 数据。DataStore 文件名 `balance_cache`。
|
||||
*
|
||||
* 存储结构:
|
||||
* - `balance_cache_v3`:JSON 序列化的 `Map<ServiceType, CachedBalance>`,供单账号服务
|
||||
* (以及多账号服务的默认账号镜像,兼容旧读取路径)。
|
||||
* - `balance_cache_accounts_v1`:JSON 序列化的 `Map<String, CachedBalance>`,复合键
|
||||
* `"{service.storageKey}:{accountId}"`,存放多账号服务每个账号的独立缓存。
|
||||
* 存储结构:JSON 序列化的 `Map<ServiceType, CachedBalance>`。
|
||||
*/
|
||||
class BalanceCache(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
@ -35,9 +31,6 @@ class BalanceCache(
|
||||
// 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 decode(raw)
|
||||
@ -45,46 +38,18 @@ class BalanceCache(
|
||||
|
||||
suspend fun get(service: ServiceType): CachedBalance? = getAll()[service]
|
||||
|
||||
/** 读取缓存:无 [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
|
||||
) {
|
||||
suspend fun put(service: ServiceType, balance: ServiceBalance) {
|
||||
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)
|
||||
}
|
||||
putCached(
|
||||
service = service,
|
||||
cachedBalance = CachedBalance(
|
||||
balance = balance,
|
||||
fetchedAt = System.currentTimeMillis()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/** 恢复一份已有缓存并保留原 fetchedAt;仅供凭据测试安全回滚使用。 */
|
||||
@ -96,58 +61,8 @@ class BalanceCache(
|
||||
}
|
||||
}
|
||||
|
||||
/** 以账号复合键写入缓存(保留原 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 调用。 */
|
||||
/** 删除单个服务缓存。凭据新增、替换或删除时由 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
|
||||
@ -161,31 +76,9 @@ class BalanceCache(
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除单个账号的缓存(默认账号的旧结构镜像由调用方另行清理)。 */
|
||||
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)
|
||||
it.remove(accountsCacheKey)
|
||||
dataStore.edit { it.remove(cacheKey) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun accountKey(service: ServiceType, accountId: String) =
|
||||
"${service.storageKey}:$accountId"
|
||||
|
||||
private fun decode(raw: String?): Map<ServiceType, CachedBalance> {
|
||||
if (raw == null) return emptyMap()
|
||||
@ -203,22 +96,6 @@ class BalanceCache(
|
||||
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
|
||||
|
||||
@ -35,7 +35,6 @@ enum class ChartGranularity(@StringRes val labelRes: Int) {
|
||||
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),
|
||||
|
||||
@ -1,87 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -1,244 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -1,29 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@ -1,70 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -1,21 +0,0 @@
|
||||
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>
|
||||
}
|
||||
@ -1,33 +0,0 @@
|
||||
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()),
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
package com.rainy.token.data.proxy
|
||||
|
||||
/**
|
||||
* 区域:CN=国内版(中国大陆域名),INTL=国际版(海外域名)。
|
||||
*/
|
||||
enum class ProviderRegion { CN, INTL }
|
||||
@ -1,293 +0,0 @@
|
||||
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() ?: ""
|
||||
}
|
||||
@ -1,56 +0,0 @@
|
||||
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,
|
||||
)
|
||||
@ -1,15 +0,0 @@
|
||||
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,
|
||||
)
|
||||
@ -1,30 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@ -1,135 +0,0 @@
|
||||
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,
|
||||
)
|
||||
@ -1,343 +0,0 @@
|
||||
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",
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,191 +0,0 @@
|
||||
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 档)"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -1,310 +0,0 @@
|
||||
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")
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,177 +0,0 @@
|
||||
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"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -1,141 +0,0 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
@ -15,7 +15,7 @@ import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
|
||||
/**
|
||||
* Codex / ChatGPT OAuth PKCE 辅助工具。
|
||||
* Codex / ChatGPT Plus OAuth PKCE 辅助工具。
|
||||
*
|
||||
* 参考:7shi/codex-oauth 的 Python 实现,移植到 Kotlin/Android。
|
||||
* 流程:
|
||||
|
||||
@ -34,16 +34,6 @@ 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"
|
||||
@ -53,49 +43,27 @@ class CodexRepository(
|
||||
private const val REFRESH_BUFFER_MS = 60L * 60 * 1000
|
||||
private const val TAG = "Codex"
|
||||
|
||||
internal data class UsageWindow(val label: String, val remainingPct: Int, val resetAt: Long?)
|
||||
|
||||
internal fun parseUsageWindows(data: JsonObject): List<UsageWindow> {
|
||||
val result = mutableListOf<UsageWindow>()
|
||||
fun addWindows(rl: JsonObject?, group: WindowGroup) {
|
||||
fun addWindows(rl: JsonObject?) {
|
||||
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))
|
||||
result.add(UsageWindow(durationLabel((w["limit_window_seconds"] as? JsonPrimitive)?.longOrNull), remaining, (w["reset_at"] as? JsonPrimitive)?.longOrNull?.times(1000L)))
|
||||
}
|
||||
}
|
||||
// 顶层 rate_limit 始终属于普通 Codex 限额
|
||||
addWindows(data["rate_limit"] as? JsonObject, WindowGroup.CODEX)
|
||||
// additional_rate_limits 每项可携带 limit_name,含 "spark" 的归入 Spark 独立限额组
|
||||
addWindows(data["rate_limit"] as? JsonObject)
|
||||
(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)
|
||||
}
|
||||
if (item is JsonObject) addWindows(item["rate_limit"] as? JsonObject)
|
||||
}
|
||||
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"
|
||||
}
|
||||
internal 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" }
|
||||
}
|
||||
|
||||
suspend fun fetchBalance(): Result<ServiceBalance> = withContext(Dispatchers.IO) {
|
||||
@ -152,13 +120,12 @@ class CodexRepository(
|
||||
if (windows.isEmpty()) return@withContext Result.failure(RepositoryError.ParseError(RepositoryError.ParseErrorReason.NO_WINDOWS, "未找到 Codex 用量窗口数据"))
|
||||
|
||||
val config = ServiceConfigProvider.get(ServiceType.CODEX)
|
||||
val primary = selectPrimaryWindow(windows)
|
||||
val primary = windows.firstOrNull { it.label.contains("h") } ?: windows.first()
|
||||
val extras = buildMap {
|
||||
windows.forEachIndexed { i, w ->
|
||||
put("window_$i.label", w.label)
|
||||
put("window_$i.remainingPct", w.remainingPct.toString())
|
||||
put("window_$i.resetAt", w.resetAt?.toString() ?: "")
|
||||
put("window_$i.group", w.group.name)
|
||||
}
|
||||
put("primary.label", primary.label)
|
||||
(usageResult["plan_type"] as? JsonPrimitive)?.contentOrNull?.let { put("plan", it) }
|
||||
|
||||
@ -19,7 +19,7 @@ import java.io.IOException
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* CommandCode 余额仓库。
|
||||
* CommandCode Go 余额仓库。
|
||||
*
|
||||
* 调 JSON API 获取月度配额余额 + 用量窗口信息:
|
||||
* GET https://api.commandcode.ai/alpha/billing/credits
|
||||
@ -65,7 +65,7 @@ class CommandCodeGoRepository(
|
||||
|
||||
// 从订阅信息拿计划名称,查 plan catalog 拿总量
|
||||
val monthlyTotal = subResult.getOrNull()?.let { sub ->
|
||||
planMonthlyQuota(sub.planId)
|
||||
PLANS[sub.planId.lowercase()]
|
||||
}
|
||||
val billingPeriodEndMillis = subResult.getOrNull()?.let { parseIsoToEpoch(it.currentPeriodEnd) }
|
||||
|
||||
@ -187,35 +187,24 @@ 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-goat" to 70.0,
|
||||
"individual-pro" to 80.0,
|
||||
"individual-pro" to 30.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,7 +7,6 @@ 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
|
||||
@ -22,7 +21,7 @@ import java.util.TimeZone
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* CommandCode 用量记录仓库。
|
||||
* CommandCode Go 用量记录仓库。
|
||||
*
|
||||
* 调 JSON API 分页抓取 usage 记录:
|
||||
* GET https://api.commandcode.ai/internal/usage?limit=50
|
||||
@ -36,6 +35,7 @@ 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,121 +44,6 @@ 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 {
|
||||
@ -228,4 +113,95 @@ class CommandCodeUsageRepository(
|
||||
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 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())
|
||||
}
|
||||
}
|
||||
@ -3,14 +3,11 @@ 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 java.security.MessageDigest
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
@ -22,12 +19,6 @@ import kotlinx.coroutines.sync.withLock
|
||||
*
|
||||
* 所有凭据变更都经过 [mutationMutex] 串行化,并维护进程内 revision。网络刷新先取得
|
||||
* [CredentialSnapshot];请求结束后只有快照仍为当前版本时,暂存的凭据与余额才会提交。
|
||||
*
|
||||
* 多账号服务(Trae / WorkBuddy):
|
||||
* - 账号元数据([ServiceAccountInfo])存 `accounts_{storageKey}`(加密 DataStore);
|
||||
* - 每个账号的凭据独立存 `credential_{storageKey}_{accountId}`;
|
||||
* - 旧版本单凭据存 `credential_{storageKey}`,首次读取时自动迁移为默认账号
|
||||
* (固定 id = [LEGACY_DEFAULT_ACCOUNT_ID]),旧键保留作为兼容回退。
|
||||
*/
|
||||
@Singleton
|
||||
class CredentialRepository @Inject constructor(
|
||||
@ -38,11 +29,7 @@ class CredentialRepository @Inject constructor(
|
||||
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 fingerprint: String
|
||||
) {
|
||||
val service: ServiceType get() = credential.service
|
||||
}
|
||||
@ -50,19 +37,7 @@ class CredentialRepository @Inject constructor(
|
||||
internal data class LocalState(
|
||||
val status: CredentialStatus,
|
||||
val fingerprint: String?,
|
||||
val cachedBalance: CachedBalance?,
|
||||
/** 当前凭据对象(首页卡片可能需要读取如 Sub2API 站点 Logo 等附加字段)。 */
|
||||
val credential: Credential? = null
|
||||
)
|
||||
|
||||
/** 按账号展开的本地状态(多账号服务一个账号一条;单账号服务一条 [account]=null)。 */
|
||||
data class AccountLocalState(
|
||||
val service: ServiceType,
|
||||
val account: ServiceAccountInfo?,
|
||||
val status: CredentialStatus,
|
||||
val fingerprint: String?,
|
||||
val cachedBalance: CachedBalance?,
|
||||
val credential: Credential?
|
||||
val cachedBalance: CachedBalance?
|
||||
)
|
||||
|
||||
private data class CacheRollbackEntry(
|
||||
@ -72,232 +47,69 @@ class CredentialRepository @Inject constructor(
|
||||
)
|
||||
|
||||
private val mutationMutex = Mutex()
|
||||
private val revisions = mutableMapOf<String, Long>()
|
||||
private val cacheRollbackEntries = mutableMapOf<String, CacheRollbackEntry>()
|
||||
private val revisions = mutableMapOf<ServiceType, Long>()
|
||||
private val cacheRollbackEntries = mutableMapOf<ServiceType, CacheRollbackEntry>()
|
||||
|
||||
private fun accountDocKeyFor(service: ServiceType): String = "accounts_${service.storageKey}"
|
||||
private fun keyFor(service: ServiceType): String = "credential_${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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除单个账号:删除该账号凭据 + 独立缓存;若删除的是默认账号则提升第一个
|
||||
* 剩余账号为默认;最后一个账号删除后整个服务回到未配置状态。
|
||||
*/
|
||||
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 {
|
||||
suspend fun save(credential: Credential) {
|
||||
currentCoroutineContext()[RefreshWriteSession]?.let { session ->
|
||||
session.stageCredential(credential)
|
||||
return session.snapshot.accountId ?: ""
|
||||
return
|
||||
}
|
||||
|
||||
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 current = getUnlocked(service)
|
||||
val currentIdentity = cacheIdentityFingerprint(current)
|
||||
val newIdentity = cacheIdentityFingerprint(credential)
|
||||
val cacheIdentityChanged = currentIdentity != newIdentity
|
||||
|
||||
bumpRevision(service, effectiveId)
|
||||
bumpRevision(service)
|
||||
if (cacheIdentityChanged) {
|
||||
cacheRollbackEntries[key] = CacheRollbackEntry(
|
||||
cacheRollbackEntries[service] = CacheRollbackEntry(
|
||||
testedFingerprint = credentialFingerprint(credential)!!,
|
||||
previousIdentity = currentIdentity,
|
||||
cachedBalance = if (current != null) {
|
||||
accountCacheFor(service, effectiveId)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
cachedBalance = if (current != null) balanceCache.get(service) else null
|
||||
)
|
||||
removeCacheFor(service, effectiveId)
|
||||
balanceCache.remove(service)
|
||||
} else {
|
||||
cacheRollbackEntries.remove(key)
|
||||
cacheRollbackEntries.remove(service)
|
||||
}
|
||||
putUnlocked(credential, effectiveId)
|
||||
return effectiveId ?: ""
|
||||
putUnlocked(credential)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun get(service: ServiceType, accountId: String? = null): Credential? {
|
||||
suspend fun get(service: ServiceType): 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 session.credentialForRead()
|
||||
}
|
||||
}
|
||||
return mutationMutex.withLock { getUnlocked(service, accountId) }
|
||||
return mutationMutex.withLock { getUnlocked(service) }
|
||||
}
|
||||
|
||||
internal suspend fun readLocalState(
|
||||
service: ServiceType,
|
||||
accountId: String? = null
|
||||
): LocalState =
|
||||
internal suspend fun readLocalState(service: ServiceType): LocalState =
|
||||
mutationMutex.withLock {
|
||||
val credential = getUnlocked(service, accountId)
|
||||
localStateOf(service, credential, accountCacheFor(service, accountId))
|
||||
val credential = getUnlocked(service)
|
||||
localStateOf(service, credential, balanceCache.get(service))
|
||||
}
|
||||
|
||||
/** 全部服务的按账号本地状态(保留单账号服务旧语义,多账号展开一条/账号)。 */
|
||||
suspend fun readAccountStates(): List<AccountLocalState> =
|
||||
mutationMutex.withLock { readAccountStatesUnlocked() }
|
||||
|
||||
internal suspend fun readLocalStates(): Map<ServiceType, LocalState> =
|
||||
mutationMutex.withLock {
|
||||
val accountStates = readAccountStatesUnlocked()
|
||||
val legacyCached = balanceCache.getAll()
|
||||
val cached = 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])
|
||||
}
|
||||
val credential = getUnlocked(service)
|
||||
localStateOf(service, credential, cached[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))
|
||||
bumpRevision(service)
|
||||
cacheRollbackEntries.remove(service)
|
||||
balanceCache.remove(service)
|
||||
secureStorage.remove(keyFor(service))
|
||||
}
|
||||
}
|
||||
|
||||
@ -310,13 +122,12 @@ class CredentialRepository @Inject constructor(
|
||||
previous: Credential?
|
||||
): Boolean = mutationMutex.withLock {
|
||||
val service = testedSnapshot.service
|
||||
val accountId = testedSnapshot.accountId
|
||||
val current = getUnlocked(service, accountId) ?: return@withLock false
|
||||
val current = getUnlocked(service) ?: return@withLock false
|
||||
if (
|
||||
!snapshotMatches(
|
||||
snapshotRevision = testedSnapshot.revision,
|
||||
snapshotFingerprint = testedSnapshot.fingerprint,
|
||||
currentRevision = revisionFor(service, accountId),
|
||||
currentRevision = revisionFor(service),
|
||||
currentFingerprint = credentialFingerprint(current)
|
||||
)
|
||||
) return@withLock false
|
||||
@ -324,54 +135,35 @@ class CredentialRepository @Inject constructor(
|
||||
val currentIdentity = cacheIdentityFingerprint(current)
|
||||
val previousIdentity = cacheIdentityFingerprint(previous)
|
||||
val identityChanged = currentIdentity != previousIdentity
|
||||
val rollbackEntry = cacheRollbackEntries[revisionKey(service, accountId)]?.takeIf {
|
||||
val rollbackEntry = cacheRollbackEntries[service]?.takeIf {
|
||||
it.testedFingerprint == testedSnapshot.fingerprint &&
|
||||
it.previousIdentity == previousIdentity
|
||||
}
|
||||
|
||||
bumpRevision(service, accountId)
|
||||
bumpRevision(service)
|
||||
if (identityChanged) {
|
||||
removeCacheFor(service, accountId)
|
||||
balanceCache.remove(service)
|
||||
}
|
||||
if (previous == null) {
|
||||
secureStorage.remove(keyFor(service, accountId))
|
||||
secureStorage.remove(keyFor(service))
|
||||
} else {
|
||||
require(previous.service == service) { "回滚凭据服务不匹配" }
|
||||
putUnlocked(previous, accountId)
|
||||
putUnlocked(previous)
|
||||
}
|
||||
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)
|
||||
rollbackEntry?.cachedBalance?.let { balanceCache.putCached(service, it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cacheRollbackEntries.remove(revisionKey(service, accountId))
|
||||
cacheRollbackEntries.remove(service)
|
||||
true
|
||||
}
|
||||
|
||||
internal suspend fun snapshot(
|
||||
service: ServiceType,
|
||||
accountId: String? = null
|
||||
): CredentialSnapshot? =
|
||||
internal suspend fun snapshot(service: ServiceType): CredentialSnapshot? =
|
||||
mutationMutex.withLock {
|
||||
val resolvedId = if (ServiceType.isMultiAccount(service)) {
|
||||
resolveAccountUnlocked(service, accountId)?.id
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val credential = getUnlocked(service, resolvedId) ?: return@withLock null
|
||||
val credential = getUnlocked(service) ?: return@withLock null
|
||||
CredentialSnapshot(
|
||||
credential = credential,
|
||||
revision = revisionFor(service, resolvedId),
|
||||
fingerprint = credentialFingerprint(credential)!!,
|
||||
accountId = resolvedId,
|
||||
requestedAccountId = accountId?.takeIf { it.isNotBlank() }
|
||||
revision = revisionFor(service),
|
||||
fingerprint = credentialFingerprint(credential)!!
|
||||
)
|
||||
}
|
||||
|
||||
@ -391,11 +183,11 @@ class CredentialRepository @Inject constructor(
|
||||
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 current = getUnlocked(snapshot.service) ?: return@withLock false
|
||||
val currentFingerprint = credentialFingerprint(current)
|
||||
|
||||
// 一键激活等只读会话不应被同凭据刷新造成的 revision 变化误伤;但凭据真正
|
||||
// 替换/删除后,旧账户的成功响应和失败信息都必须丢弃。
|
||||
if (!hasPendingWrites) {
|
||||
return@withLock currentFingerprint == snapshot.fingerprint
|
||||
}
|
||||
@ -403,7 +195,7 @@ class CredentialRepository @Inject constructor(
|
||||
val snapshotStillCurrent = snapshotMatches(
|
||||
snapshotRevision = snapshot.revision,
|
||||
snapshotFingerprint = snapshot.fingerprint,
|
||||
currentRevision = revisionFor(service, accountId),
|
||||
currentRevision = revisionFor(snapshot.service),
|
||||
currentFingerprint = currentFingerprint
|
||||
)
|
||||
val canMergeRotatedCredential = pendingCredential != null &&
|
||||
@ -419,33 +211,22 @@ class CredentialRepository @Inject constructor(
|
||||
cacheIdentityFingerprint(current) != cacheIdentityFingerprint(finalCredential) &&
|
||||
!sameLineage
|
||||
|
||||
bumpRevision(service, accountId)
|
||||
bumpRevision(snapshot.service)
|
||||
if (cacheIdentityChanged) {
|
||||
removeCacheFor(service, accountId)
|
||||
balanceCache.remove(snapshot.service)
|
||||
}
|
||||
if (pendingCredential != null) {
|
||||
putUnlocked(pendingCredential, accountId)
|
||||
putUnlocked(pendingCredential)
|
||||
}
|
||||
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))
|
||||
balanceCache.put(snapshot.service, pendingBalance)
|
||||
cacheRollbackEntries.remove(snapshot.service)
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
suspend fun statusFor(
|
||||
service: ServiceType,
|
||||
accountId: String? = null
|
||||
): CredentialStatus {
|
||||
val credential = get(service, accountId) ?: return CredentialStatus(
|
||||
suspend fun statusFor(service: ServiceType): CredentialStatus {
|
||||
val credential = get(service) ?: return CredentialStatus(
|
||||
service = service,
|
||||
state = CredentialStatus.State.NOT_CONFIGURED,
|
||||
lastVerifiedAt = 0L
|
||||
@ -463,74 +244,6 @@ class CredentialRepository @Inject constructor(
|
||||
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?,
|
||||
@ -555,167 +268,31 @@ class CredentialRepository @Inject constructor(
|
||||
return LocalState(
|
||||
status = status,
|
||||
fingerprint = credentialFingerprint(credential),
|
||||
cachedBalance = cached,
|
||||
credential = credential
|
||||
cachedBalance = cached
|
||||
)
|
||||
}
|
||||
|
||||
/** 读取账号元数据;多账号服务且文档不存在时尝试从旧单凭据迁移。 */
|
||||
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>
|
||||
) {
|
||||
private suspend fun putUnlocked(credential: Credential) {
|
||||
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),
|
||||
key = keyFor(credential.service),
|
||||
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 getUnlocked(service: ServiceType): Credential? =
|
||||
secureStorage.get(
|
||||
key = keyFor(service),
|
||||
serializer = Credential.serializer()
|
||||
)
|
||||
|
||||
/** 删除某账号(或单账号服务)关联的余额缓存;默认账号会一并清理旧结构镜像。 */
|
||||
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 revisionFor(service: ServiceType): Long = revisions[service] ?: 0L
|
||||
|
||||
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
|
||||
private fun bumpRevision(service: ServiceType) {
|
||||
revisions[service] = revisionFor(service) + 1L
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** 旧单凭据迁移为默认账号时使用的固定账号 ID。 */
|
||||
const val LEGACY_DEFAULT_ACCOUNT_ID = "default"
|
||||
|
||||
internal fun determineCredentialState(
|
||||
lastVerifiedAt: Long,
|
||||
now: Long
|
||||
@ -773,23 +350,6 @@ class CredentialRepository @Inject constructor(
|
||||
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)
|
||||
@ -836,33 +396,11 @@ class CredentialRepository @Inject constructor(
|
||||
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(
|
||||
|
||||
@ -23,7 +23,7 @@ import java.util.TimeZone
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Ollama Cloud 用量仓库。
|
||||
* Ollama Pro Cloud 用量仓库。
|
||||
*
|
||||
* Ollama 没有公开的 Usage API(见 ollama/ollama#12532),
|
||||
* 本 Repository 通过 OkHttp 搓 Cookie 请求 https://ollama.com/settings,
|
||||
@ -108,7 +108,7 @@ class OllamaRepository(
|
||||
if (sessionPct == null && weeklyPct == null) return null
|
||||
|
||||
return ParsedUsage(
|
||||
plan = plan.orEmpty(), // 空串由 UI 层兜底显示 "—",避免英文 "Unknown" 出现在中文 UI
|
||||
plan = plan ?: "Unknown",
|
||||
sessionPercent = sessionPct ?: 0f,
|
||||
weeklyPercent = weeklyPct ?: 0f,
|
||||
sessionResetAt = sessionResetAt,
|
||||
|
||||
@ -8,12 +8,7 @@ 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
|
||||
@ -26,7 +21,6 @@ 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
|
||||
|
||||
/**
|
||||
@ -110,29 +104,21 @@ 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(),
|
||||
@ -143,22 +129,11 @@ class OpenCodeGoRepository(
|
||||
nextResetAt = System.currentTimeMillis() + primary.resetInSec * 1000L,
|
||||
extras = extras
|
||||
)
|
||||
|
||||
balanceCache.put(ServiceType.OPENCODE_GO, balance)
|
||||
credentialRepository.save(credential.copy(lastVerifiedAt = System.currentTimeMillis()))
|
||||
|
||||
// 模型级用量作为增量增强:并行拉取 + 短超时,失败/超时不影响主窗口数据与缓存
|
||||
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)
|
||||
Result.success(balance)
|
||||
}
|
||||
}
|
||||
|
||||
@ -253,12 +228,6 @@ class OpenCodeGoRepository(
|
||||
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")
|
||||
|
||||
/**
|
||||
@ -288,14 +257,10 @@ class OpenCodeGoRepository(
|
||||
val braceEnd = findMatchingBrace(html, braceStart) ?: continue
|
||||
val body = html.substring(braceStart, braceEnd + 1)
|
||||
|
||||
val pct = extractNumberAfterKey(body, "usagePercent")?.toFloatOrNull()
|
||||
val pct = extractNumberAfterKey(body, "usagePercent")?.toIntOrNull()
|
||||
val reset = extractNumberAfterKey(body, "resetInSec")?.toLongOrNull()
|
||||
// usage/limit 是页面新增的用量与限额字段(单位以服务端定义为准,缺失不影响窗口识别)
|
||||
// exactKey=true:避免 "usage" 误命中 "usagePercent" 前缀
|
||||
val usage = extractNumberAfterKey(body, "usage", exactKey = true)?.toLongOrNull()
|
||||
val limit = extractNumberAfterKey(body, "limit", exactKey = true)?.toLongOrNull()
|
||||
if (pct != null && reset != null) {
|
||||
result[field] = ScrapedWindow(pct, reset, usage, limit)
|
||||
result[field] = ScrapedWindow(pct, reset)
|
||||
}
|
||||
}
|
||||
|
||||
@ -323,23 +288,9 @@ class OpenCodeGoRepository(
|
||||
/**
|
||||
* 在 body 字符串中找 "key:" 后面紧跟的数字(含可选小数)。返回数字字符串,未找到返回 null。
|
||||
* 跳过 status 字符串值("ok" 之类)。
|
||||
*
|
||||
* @param exactKey 为 true 时要求 key 后紧跟 ':'(精确字段匹配),
|
||||
* 避免 "usage" 误命中 "usagePercent" 这类前缀字段。
|
||||
*/
|
||||
private fun extractNumberAfterKey(body: String, key: String, exactKey: Boolean = false): String? {
|
||||
val keyIdx = if (exactKey) {
|
||||
// 精确匹配:key 后必须紧跟 ':'(如 "usage:"),不能是 "usagePercent"
|
||||
var idx = body.indexOf(key)
|
||||
while (idx >= 0) {
|
||||
val after = idx + key.length
|
||||
if (after < body.length && body[after] == ':') break
|
||||
idx = body.indexOf(key, idx + 1)
|
||||
}
|
||||
idx
|
||||
} else {
|
||||
body.indexOf(key)
|
||||
}
|
||||
private fun extractNumberAfterKey(body: String, key: String): String? {
|
||||
val keyIdx = body.indexOf(key)
|
||||
if (keyIdx < 0) return null
|
||||
var i = keyIdx + key.length
|
||||
// 跳过 ":" 后面所有非数字、非负号、非小数点字符
|
||||
@ -362,179 +313,10 @@ class OpenCodeGoRepository(
|
||||
return body.substring(start, i).ifEmpty { null }
|
||||
}
|
||||
|
||||
internal data class ScrapedWindow(
|
||||
val usagePercent: Float,
|
||||
val resetInSec: Long,
|
||||
val usage: Long? = null,
|
||||
val limit: Long? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 解析 _server 返回的模型级用量 flight 响应。
|
||||
*
|
||||
* 实际响应形如:
|
||||
* ```
|
||||
* ;0x000003bc;((self.$R=self.$R||{})["server-fn:2"]=[],($R=>$R[0]={
|
||||
* usage:1889462439,limit:6000000000,usagePercent:31.5,
|
||||
* rows:$R[1]=[$R[2]={model:"deepseek-v4-flash",name:"DeepSeek V4 Flash",
|
||||
* cost:1002607535,quotaCost:2005215070,multiplier:2,
|
||||
* estimated:!0,contributionPercent:30.5},...]
|
||||
* })($R["server-fn:2"]))
|
||||
* ```
|
||||
*
|
||||
* 返回窗口模型用量(usage/limit/usagePercent + 每模型行),解析失败返回 null。
|
||||
* cost/quotaCost 单位为 1e-8 美元(cost × 10⁻⁸ = 美元)。
|
||||
*/
|
||||
internal fun parseModelRows(flight: String): WindowModelUsage? {
|
||||
// 定位 "$R[0]={" 数据块(窗口模型用量的固定 hydration 格式)
|
||||
val rootIdx = flight.indexOf("\$R[0]={")
|
||||
if (rootIdx < 0) return null
|
||||
val braceStart = rootIdx + "\$R[0]=".length
|
||||
val braceEnd = findMatchingBrace(flight, braceStart) ?: return null
|
||||
val body = flight.substring(braceStart, braceEnd + 1)
|
||||
|
||||
val usage = extractNumberAfterKey(body, "usage", exactKey = true)?.toLongOrNull()
|
||||
val limit = extractNumberAfterKey(body, "limit", exactKey = true)?.toLongOrNull()
|
||||
val usagePercent = extractNumberAfterKey(body, "usagePercent")?.toFloatOrNull()
|
||||
|
||||
return WindowModelUsage(
|
||||
usage = usage,
|
||||
limit = limit,
|
||||
usagePercent = usagePercent,
|
||||
rows = extractModelRows(body)
|
||||
)
|
||||
}
|
||||
|
||||
/** 从外层对象 body 中提取所有 {model:"..."} 模型行对象。 */
|
||||
private fun extractModelRows(body: String): List<ModelUsageRow> {
|
||||
val rows = mutableListOf<ModelUsageRow>()
|
||||
var idx = body.indexOf("{model:")
|
||||
while (idx >= 0) {
|
||||
val braceEnd = findMatchingBrace(body, idx) ?: break
|
||||
val obj = body.substring(idx, braceEnd + 1)
|
||||
val model = extractQuoted(obj, "model") ?: ""
|
||||
val name = extractQuoted(obj, "name") ?: ""
|
||||
val cost = extractNumberAfterKey(obj, "cost")?.toLongOrNull() ?: 0L
|
||||
val quotaCost = extractNumberAfterKey(obj, "quotaCost")?.toLongOrNull() ?: 0L
|
||||
val multiplier = extractNumberAfterKey(obj, "multiplier")?.toDoubleOrNull() ?: 1.0
|
||||
val estimated = obj.contains("estimated:!0")
|
||||
val contributionPercent = extractNumberAfterKey(obj, "contributionPercent")?.toDoubleOrNull() ?: 0.0
|
||||
rows += ModelUsageRow(
|
||||
model = model,
|
||||
name = name,
|
||||
cost = cost,
|
||||
quotaCost = quotaCost,
|
||||
multiplier = multiplier,
|
||||
estimated = estimated,
|
||||
contributionPercent = contributionPercent
|
||||
)
|
||||
idx = body.indexOf("{model:", braceEnd + 1)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
/** 提取 "key:" 后面紧跟的 "..." 引号字符串值。 */
|
||||
private fun extractQuoted(body: String, key: String): String? {
|
||||
val keyIdx = body.indexOf("$key:\"")
|
||||
if (keyIdx < 0) return null
|
||||
val start = keyIdx + key.length + 2
|
||||
val end = body.indexOf('"', start)
|
||||
if (end < 0) return null
|
||||
return body.substring(start, end)
|
||||
}
|
||||
|
||||
/**
|
||||
* 窗口配额(1e-8 美元整数):窗口 limit(token 数)÷ multiplier。
|
||||
* 与网页端各窗口"配额"列口径一致(实测:5h=12 亿÷2=$6、周=30 亿÷2=$15、月=60 亿÷2=$30;hy3 月=60 亿÷0.125=$480)。
|
||||
* multiplier 缺失或非正时安全降级为月度基础 $60(与网页默认一致)。
|
||||
*/
|
||||
internal fun windowQuotaRaw(windowLimit: Long, multiplier: Double): Long {
|
||||
if (multiplier <= 0.0) return 60L * 100_000_000L
|
||||
return (windowLimit.toDouble() / multiplier).toLong()
|
||||
internal data class ScrapedWindow(val usagePercent: Int, val resetInSec: Long)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取三个窗口(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 响应,提取回复文本和用量统计。
|
||||
*/
|
||||
|
||||
@ -19,9 +19,6 @@ internal class RefreshWriteSession(
|
||||
|
||||
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
|
||||
@ -75,7 +72,4 @@ 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)
|
||||
}
|
||||
|
||||
@ -9,9 +9,6 @@ sealed class RepositoryError(message: String, cause: Throwable? = null) : Except
|
||||
class InvalidCredential(detail: String? = null, cause: Throwable? = null) :
|
||||
RepositoryError("凭据无效" + (detail?.let { ": $it" } ?: ""), cause)
|
||||
|
||||
/** 服务端 CheckLogin 确认登录态已失效(未登录或 JWT 过期),需重新登录 */
|
||||
class SessionExpired : RepositoryError("登录已过期,请重新登录")
|
||||
|
||||
/** 请求期间凭据被保存、替换或删除;旧结果已安全丢弃。 */
|
||||
class CredentialChanged : RepositoryError("凭据已变更,本次请求结果已丢弃")
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,856 +0,0 @@
|
||||
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
|
||||
)
|
||||
@ -1,119 +0,0 @@
|
||||
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,7 +1,6 @@
|
||||
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
|
||||
@ -22,16 +21,10 @@ class WebViewSessionSaver @Inject constructor(
|
||||
private val credentialRepository: CredentialRepository
|
||||
) {
|
||||
|
||||
/** Sub2API 面板登录保存结果:凭据 + 实际写入的账号 ID(新建账号时返回新 ID)。 */
|
||||
data class SavedSub2PanelSession(
|
||||
val credential: Credential.Sub2ApiCredential,
|
||||
val accountId: String
|
||||
)
|
||||
|
||||
/**
|
||||
* 提取并保存 Cookie 列表。返回保存后的凭据。
|
||||
*/
|
||||
suspend fun saveFromCookieManager(
|
||||
fun saveFromCookieManager(
|
||||
service: ServiceType,
|
||||
url: String,
|
||||
token: String? = null,
|
||||
@ -48,97 +41,13 @@ class WebViewSessionSaver @Inject constructor(
|
||||
expiresAt = expiresAt,
|
||||
lastVerifiedAt = System.currentTimeMillis()
|
||||
)
|
||||
// 挂起写凭据:调用方在 viewModelScope 中,避免在主线程做 Keystore 加解密 + DataStore 写盘
|
||||
// 用 runBlocking 写凭据 —— 调用方在 Composable 中,可以接受
|
||||
kotlinx.coroutines.runBlocking {
|
||||
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
|
||||
|
||||
@ -1,820 +0,0 @@
|
||||
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,19 +17,6 @@ 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
|
||||
@ -135,7 +122,7 @@ object NetworkModule {
|
||||
): OpenCodeGoRepository = OpenCodeGoRepository(okHttpClient, credentialRepository, balanceCache)
|
||||
|
||||
/**
|
||||
* CommandCode 用量仓库。
|
||||
* CommandCode Go 用量仓库。
|
||||
*/
|
||||
@Provides
|
||||
@Singleton
|
||||
@ -145,7 +132,7 @@ object NetworkModule {
|
||||
): CommandCodeUsageRepository = CommandCodeUsageRepository(okHttpClient, credentialRepository)
|
||||
|
||||
/**
|
||||
* CommandCode 仓库:API Key 认证,调 JSON API。
|
||||
* CommandCode Go 仓库:API Key 认证,调 JSON API。
|
||||
*/
|
||||
@Provides
|
||||
@Singleton
|
||||
@ -168,7 +155,7 @@ object NetworkModule {
|
||||
): DeepSeekRepository = DeepSeekRepository(deepSeekApi, credentialRepository, balanceCache)
|
||||
|
||||
/**
|
||||
* Codex / ChatGPT 额度仓库:用 OkHttp 调 chatgpt.com 后台 API。
|
||||
* Codex / ChatGPT Plus 额度仓库:用 OkHttp 调 chatgpt.com 后台 API。
|
||||
*/
|
||||
@Provides
|
||||
@Singleton
|
||||
@ -179,7 +166,7 @@ object NetworkModule {
|
||||
): CodexRepository = CodexRepository(okHttpClient, credentialRepository, balanceCache)
|
||||
|
||||
/**
|
||||
* Ollama Cloud 用量仓库:Cookie 认证 + OkHttp 抓 settings HTML 解析。
|
||||
* Ollama Pro Cloud 用量仓库:Cookie 认证 + OkHttp 抓 settings HTML 解析。
|
||||
*/
|
||||
@Provides
|
||||
@Singleton
|
||||
@ -189,114 +176,6 @@ 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(完整 OAuth 凭据,含自动刷新)
|
||||
* - [CodexCredential] 用于 Codex / ChatGPT Plus(完整 OAuth 凭据,含自动刷新)
|
||||
*/
|
||||
@Serializable
|
||||
sealed class Credential {
|
||||
@ -41,15 +41,15 @@ sealed class Credential {
|
||||
val authCookie: String? = null,
|
||||
/** OpenCode Go 专用:workspace ID */
|
||||
val workspaceId: String? = null,
|
||||
/** Ollama 专用:完整的 Cookie 字符串(用户从浏览器 DevTools 复制) */
|
||||
/** Ollama Pro 专用:完整的 Cookie 字符串(用户从浏览器 DevTools 复制) */
|
||||
val ollamaCookie: String? = null,
|
||||
/** OpenCode Go / Ollama 专用:API Key,用于一键激活用量 */
|
||||
/** OpenCode Go / Ollama Pro 专用:API Key,用于一键激活用量 */
|
||||
val apiKey: String? = null,
|
||||
override val lastVerifiedAt: Long = 0L
|
||||
) : Credential()
|
||||
|
||||
/**
|
||||
* 用于 Codex / ChatGPT:完整 OAuth 凭据,支持自动刷新。
|
||||
* 用于 Codex / ChatGPT Plus:完整 OAuth 凭据,支持自动刷新。
|
||||
* 用户从 auth.json 粘贴整个 tokens 对象。
|
||||
*/
|
||||
@Serializable
|
||||
@ -62,87 +62,6 @@ 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()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -1,32 +0,0 @@
|
||||
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,7 +22,5 @@ data class ServiceBalance(
|
||||
/** 配额周期下次重置时间(订阅型服务,epoch millis) */
|
||||
val nextResetAt: Long? = null,
|
||||
/** 服务特定的附加信息(如 Zen 充值 $20 起,Go 5h 配额) */
|
||||
val extras: Map<String, String> = emptyMap(),
|
||||
/** Sub2API 专有:网关 /v1/usage 返回的结构化用量明细(模型统计 + 每日明细)。其他服务为 null。 */
|
||||
val sub2UsageDetail: Sub2UsageDetail? = null
|
||||
val extras: Map<String, String> = emptyMap()
|
||||
)
|
||||
@ -1,39 +0,0 @@
|
||||
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
|
||||
)
|
||||
@ -10,9 +10,6 @@ enum class FetchMethod {
|
||||
/** WebView 登录后抓取 HTML/内部 JSON API */
|
||||
WEBVIEW_SCRAPER,
|
||||
|
||||
/** 用户自托管实例的 REST API(地址在凭据里,如 Sub2API) */
|
||||
SELF_HOSTED_REST,
|
||||
|
||||
/** 完全手动输入(已降级) */
|
||||
MANUAL
|
||||
}
|
||||
@ -27,8 +24,6 @@ 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 有效期 < 该值则触发降级(小时) */
|
||||
@ -45,9 +40,7 @@ object ServiceConfigProvider {
|
||||
type = ServiceType.DEEPSEEK,
|
||||
method = FetchMethod.REST_API,
|
||||
loginUrl = "", // REST 模式不需要登录 URL
|
||||
displayUnit = "¥",
|
||||
// App 内 WebView 打开的官方充值页
|
||||
externalUrl = "https://platform.deepseek.com/top_up"
|
||||
displayUnit = "¥"
|
||||
),
|
||||
ServiceType.OPENCODE_GO to ServiceConfig(
|
||||
type = ServiceType.OPENCODE_GO,
|
||||
@ -73,49 +66,11 @@ 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,27 +15,13 @@ enum class ServiceType(
|
||||
val storageKey: String
|
||||
) {
|
||||
OPENCODE_GO("OpenCode Go", "opencode_go"),
|
||||
COMMANDCODE_GO("CommandCode", "commandcode_go"),
|
||||
COMMANDCODE_GO("CommandCode Go", "commandcode_go"),
|
||||
DEEPSEEK("DeepSeek", "deepseek"),
|
||||
CODEX("Codex / ChatGPT", "codex"),
|
||||
OLLAMA("Ollama", "ollama"),
|
||||
TRAE("Trae", "trae"),
|
||||
WORKBUDDY("WorkBuddy", "workbuddy"),
|
||||
SUB2API("Sub2API", "sub2api");
|
||||
OLLAMA("Ollama Pro", "ollama");
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -3,15 +3,11 @@ 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
|
||||
@ -30,21 +26,14 @@ import kotlinx.coroutines.withContext
|
||||
*/
|
||||
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 traeRepositoryProvider: Provider<TraeRepository>,
|
||||
private val workBuddyRepositoryProvider: Provider<WorkBuddyRepository>,
|
||||
private val sub2ApiRepositoryProvider: Provider<Sub2ApiRepository>
|
||||
private val ollamaRepositoryProvider: Provider<OllamaRepository>
|
||||
) {
|
||||
suspend operator fun invoke(
|
||||
service: ServiceType,
|
||||
accountId: String? = null
|
||||
): Result<ServiceBalance> =
|
||||
withCredentialSession(service, accountId) {
|
||||
suspend operator fun invoke(service: ServiceType): Result<ServiceBalance> =
|
||||
withCredentialSession(service) {
|
||||
when (service) {
|
||||
ServiceType.DEEPSEEK -> retryOnTransientError {
|
||||
deepSeekRepositoryProvider.get().fetchBalance()
|
||||
@ -61,15 +50,6 @@ class RefreshBalanceUseCase @Inject constructor(
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -97,86 +77,11 @@ class RefreshBalanceUseCase @Inject constructor(
|
||||
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)
|
||||
val snapshot = credentialRepository.snapshot(service)
|
||||
?: return Result.failure(RepositoryError.InvalidCredential())
|
||||
val session = RefreshWriteSession(snapshot)
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@ import javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
|
||||
/**
|
||||
* CommandCode 用量同步 UseCase。
|
||||
* CommandCode Go 用量同步 UseCase。
|
||||
*
|
||||
* 游标协议:每页返回 (记录列表, 下一页游标)。
|
||||
* - cursor=null → 最新页
|
||||
@ -23,18 +23,12 @@ 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)
|
||||
@ -44,7 +38,6 @@ class SyncCommandCodeUsageUseCase @Inject constructor(
|
||||
}
|
||||
val (records, nextCursor) = pageResult.getOrThrow()
|
||||
if (records.isEmpty()) break
|
||||
if (nextCursor == cursor) break // 游标未前进,防死循环
|
||||
|
||||
val before = cache.count()
|
||||
cache.insertAll(records)
|
||||
@ -52,7 +45,6 @@ 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))
|
||||
@ -64,7 +56,6 @@ 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)
|
||||
@ -72,7 +63,6 @@ class SyncCommandCodeUsageUseCase @Inject constructor(
|
||||
|
||||
val (records, nextCursor) = pageResult.getOrThrow()
|
||||
if (records.isEmpty()) break
|
||||
if (nextCursor == cursor) break // 游标未前进,防死循环
|
||||
|
||||
// 按 workspace 过滤本地已有 ID,避免跨 workspace 碰撞
|
||||
val workspaceId = records.firstOrNull()?.workspaceId ?: CommandCodeUsageRepository.CCGO_WORKSPACE_ID
|
||||
@ -86,7 +76,6 @@ class SyncCommandCodeUsageUseCase @Inject constructor(
|
||||
|
||||
if (records.size < CommandCodeUsageRepository.PAGE_SIZE) break
|
||||
cursor = nextCursor
|
||||
if (++pages >= MAX_PAGES) break // 防御性上限
|
||||
}
|
||||
|
||||
return Result.success(SyncResult(inserted = totalInserted))
|
||||
|
||||
@ -1,8 +1,5 @@
|
||||
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
|
||||
@ -21,14 +18,12 @@ 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
|
||||
@ -41,7 +36,6 @@ 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
|
||||
@ -54,18 +48,13 @@ 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
|
||||
|
||||
/**
|
||||
* 应用导航图。
|
||||
@ -79,46 +68,21 @@ import com.rainy.token.ui.webview.WebViewPageScreen
|
||||
object Routes {
|
||||
const val DASHBOARD = "dashboard"
|
||||
const val SETTINGS = "settings"
|
||||
const val API_MANAGEMENT = "api_management"
|
||||
const val TIPS = "tips"
|
||||
const val DEBUG_LOG = "debug_log"
|
||||
const val CODEX_OAUTH = "codex_oauth"
|
||||
const val CREDENTIAL_EDIT = "credential_edit/{type}?account={account}"
|
||||
fun credentialEdit(type: ServiceType, accountId: String? = null): String =
|
||||
if (accountId.isNullOrBlank()) {
|
||||
"credential_edit/${type.name}"
|
||||
} else {
|
||||
"credential_edit/${type.name}?account=${Uri.encode(accountId)}"
|
||||
}
|
||||
const val ACCOUNTS = "accounts/{type}"
|
||||
fun accounts(type: ServiceType) = "accounts/${type.name}"
|
||||
const val WEBVIEW_LOGIN = "webview_login/{type}?account={account}®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 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 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 =
|
||||
@ -126,27 +90,12 @@ private fun parseServiceType(typeName: String?): ServiceType =
|
||||
ServiceType.fromStorageKey(it) ?: runCatching { ServiceType.valueOf(it) }.getOrNull()
|
||||
} ?: ServiceType.DEEPSEEK
|
||||
|
||||
/** 凭据入口:多账号服务先进账号管理页,其余直达凭据编辑页。 */
|
||||
private fun credentialEntry(type: ServiceType): String =
|
||||
if (ServiceType.isMultiAccount(type)) Routes.accounts(type) else Routes.credentialEdit(type)
|
||||
|
||||
private sealed class DetailPane {
|
||||
object Empty : DetailPane()
|
||||
data class ServiceDetail(val type: ServiceType, val accountId: String? = null) : DetailPane()
|
||||
data class ServiceDetail(val type: ServiceType) : 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
|
||||
@ -238,39 +187,11 @@ private fun CompactNavHost() {
|
||||
}
|
||||
) {
|
||||
composable(Routes.DASHBOARD) {
|
||||
val context = LocalContext.current
|
||||
val rechargeLabel = stringResource(R.string.action_recharge)
|
||||
DashboardScreen(
|
||||
onOpenSettings = { navController.navigate(Routes.SETTINGS) },
|
||||
onOpenApiManagement = { navController.navigate(Routes.API_MANAGEMENT) },
|
||||
onOpenService = { type, accountId ->
|
||||
navController.navigate(Routes.serviceDetail(type, accountId))
|
||||
},
|
||||
onOpenService = { type -> navController.navigate(Routes.serviceDetail(type)) },
|
||||
onOpenUsageDetail = { navController.navigate(Routes.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))
|
||||
}
|
||||
}
|
||||
onOpenCcgoUsageDetail = { navController.navigate(Routes.CCGO_USAGE_DETAIL) }
|
||||
)
|
||||
}
|
||||
composable(Routes.USAGE_DETAIL) {
|
||||
@ -329,22 +250,11 @@ private fun CompactNavHost() {
|
||||
composable(Routes.SETTINGS) {
|
||||
SettingsScreen(
|
||||
onBack = guardedPop,
|
||||
onEditCredential = { type -> navController.navigate(credentialEntry(type)) },
|
||||
onManageAccounts = { type -> navController.navigate(Routes.accounts(type)) },
|
||||
onEditCredential = { type -> navController.navigate(Routes.credentialEdit(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)
|
||||
}
|
||||
@ -359,114 +269,40 @@ private fun CompactNavHost() {
|
||||
}
|
||||
composable(
|
||||
route = Routes.CREDENTIAL_EDIT,
|
||||
arguments = listOf(
|
||||
navArgument("type") { type = NavType.StringType },
|
||||
navArgument("account") {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
defaultValue = null
|
||||
}
|
||||
)
|
||||
arguments = listOf(navArgument("type") { type = NavType.StringType })
|
||||
) { backStackEntry ->
|
||||
val type = parseServiceType(backStackEntry.arguments?.getString("type"))
|
||||
val accountId = backStackEntry.arguments?.getString("account")
|
||||
CredentialEditScreen(
|
||||
service = type,
|
||||
accountId = accountId,
|
||||
onBack = guardedPop,
|
||||
onStartWebViewLogin = { svc, acc, region -> navController.navigate(Routes.webviewLogin(svc, acc, region)) },
|
||||
onStartWebViewLogin = { service -> navController.navigate(Routes.webviewLogin(service)) },
|
||||
onWebViewLoginSuccess = { },
|
||||
onStartCodexOAuth = { navController.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
|
||||
}
|
||||
)
|
||||
arguments = listOf(navArgument("type") { type = NavType.StringType })
|
||||
) { 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 = { _, 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()
|
||||
}
|
||||
}
|
||||
onLoginSucceeded = { guardedPop() }
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Routes.SERVICE_DETAIL,
|
||||
arguments = listOf(
|
||||
navArgument("type") { type = NavType.StringType },
|
||||
navArgument("account") {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
defaultValue = null
|
||||
}
|
||||
)
|
||||
arguments = listOf(navArgument("type") { type = NavType.StringType })
|
||||
) { 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,
|
||||
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))
|
||||
}
|
||||
}
|
||||
onConfigureCredential = { svc -> navController.navigate(Routes.credentialEdit(svc)) },
|
||||
onStartWebViewLogin = { svc -> navController.navigate(Routes.webviewLogin(svc)) }
|
||||
)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -489,9 +325,6 @@ private fun ExpandedLayout() {
|
||||
prevPane = detailPane
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
val rechargeLabel = stringResource(R.string.action_recharge)
|
||||
|
||||
Row(modifier = Modifier.fillMaxSize()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@ -500,20 +333,9 @@ private fun ExpandedLayout() {
|
||||
) {
|
||||
DashboardScreen(
|
||||
onOpenSettings = { detailPane = DetailPane.Settings },
|
||||
onOpenApiManagement = { detailPane = DetailPane.ApiManagement },
|
||||
onOpenService = { type, accountId ->
|
||||
detailPane = DetailPane.ServiceDetail(type, accountId)
|
||||
},
|
||||
onOpenService = { type -> detailPane = DetailPane.ServiceDetail(type) },
|
||||
onOpenUsageDetail = { detailPane = DetailPane.OCGOUsage },
|
||||
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
|
||||
)
|
||||
}
|
||||
@ -532,9 +354,6 @@ private fun ExpandedLayout() {
|
||||
ExpandedDetailPane(
|
||||
pane = detailPane,
|
||||
onClose = { detailPane = DetailPane.Empty },
|
||||
onSwitchAccount = { svc, acc ->
|
||||
detailPane = DetailPane.ServiceDetail(svc, acc)
|
||||
},
|
||||
onCredentialEditClosed = { dashboardVm.reloadLocalState() }
|
||||
)
|
||||
}
|
||||
@ -545,7 +364,6 @@ private fun ExpandedLayout() {
|
||||
private fun ExpandedDetailPane(
|
||||
pane: DetailPane,
|
||||
onClose: () -> Unit,
|
||||
onSwitchAccount: (ServiceType, String?) -> Unit = { _, _ -> },
|
||||
onCredentialEditClosed: () -> Unit = {}
|
||||
) {
|
||||
when (pane) {
|
||||
@ -558,49 +376,13 @@ private fun ExpandedDetailPane(
|
||||
}
|
||||
}
|
||||
is DetailPane.ServiceDetail -> {
|
||||
// key(pane.type + accountId):切换服务/账号时重建嵌套导航栈,避免停留在上一个页面
|
||||
key("${pane.type.name}:${pane.accountId.orEmpty()}") {
|
||||
val navController = rememberNavController()
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = "detail",
|
||||
popExitTransition = { ExitTransition.None }
|
||||
) {
|
||||
composable("detail") {
|
||||
val 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))
|
||||
}
|
||||
}
|
||||
onStartWebViewLogin = { }
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Routes.WEBVIEW_PAGE,
|
||||
arguments = listOf(
|
||||
navArgument("url") { type = NavType.StringType },
|
||||
navArgument("title") { type = NavType.StringType }
|
||||
)
|
||||
) { entry ->
|
||||
WebViewPageScreen(
|
||||
url = entry.arguments?.getString("url").orEmpty(),
|
||||
title = entry.arguments?.getString("title").orEmpty(),
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
is DetailPane.OCGOUsage -> {
|
||||
val navController = rememberNavController()
|
||||
NavHost(
|
||||
@ -660,137 +442,6 @@ 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 时
|
||||
@ -813,25 +464,12 @@ private fun ExpandedDetailPane(
|
||||
SettingsScreen(
|
||||
onBack = onClose,
|
||||
onEditCredential = { type ->
|
||||
settingsNavController.navigate(credentialEntry(type))
|
||||
},
|
||||
onManageAccounts = { type ->
|
||||
settingsNavController.navigate(Routes.accounts(type))
|
||||
settingsNavController.navigate(Routes.credentialEdit(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() })
|
||||
}
|
||||
@ -846,23 +484,14 @@ private fun ExpandedDetailPane(
|
||||
}
|
||||
composable(
|
||||
route = Routes.CREDENTIAL_EDIT,
|
||||
arguments = listOf(
|
||||
navArgument("type") { type = NavType.StringType },
|
||||
navArgument("account") {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
defaultValue = null
|
||||
}
|
||||
)
|
||||
arguments = listOf(navArgument("type") { type = NavType.StringType })
|
||||
) { entry ->
|
||||
val type = parseServiceType(entry.arguments?.getString("type"))
|
||||
val accountId = entry.arguments?.getString("account")
|
||||
CredentialEditScreen(
|
||||
service = type,
|
||||
accountId = accountId,
|
||||
onBack = { settingsNavController.popBackStack() },
|
||||
onStartWebViewLogin = { svc, acc, region ->
|
||||
settingsNavController.navigate(Routes.webviewLogin(svc, acc, region))
|
||||
onStartWebViewLogin = { svc ->
|
||||
settingsNavController.navigate(Routes.webviewLogin(svc))
|
||||
},
|
||||
onWebViewLoginSuccess = { },
|
||||
onStartCodexOAuth = { settingsNavController.navigate(Routes.CODEX_OAUTH) }
|
||||
@ -870,36 +499,13 @@ private fun ExpandedDetailPane(
|
||||
}
|
||||
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
|
||||
}
|
||||
)
|
||||
arguments = listOf(navArgument("type") { type = NavType.StringType })
|
||||
) { 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 = { _, effectiveId ->
|
||||
if (!effectiveId.isNullOrBlank()) {
|
||||
settingsNavController.previousBackStackEntry?.savedStateHandle?.set(
|
||||
CredentialEditViewModel.KEY_LOGIN_ACCOUNT_ID, effectiveId
|
||||
)
|
||||
}
|
||||
settingsNavController.popBackStack()
|
||||
}
|
||||
onLoginSucceeded = { settingsNavController.popBackStack() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,442 +0,0 @@
|
||||
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("—")
|
||||
}
|
||||
@ -1,185 +0,0 @@
|
||||
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()
|
||||
)
|
||||
@ -39,8 +39,6 @@ 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)
|
||||
@ -105,8 +103,8 @@ fun DebugLogScreen(onBack: () -> Unit) {
|
||||
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
|
||||
DebugLog.Level.WARN -> Color(0xFFFFA726)
|
||||
DebugLog.Level.ERROR -> Color(0xFFE91E63)
|
||||
}
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
|
||||
@ -56,29 +56,13 @@ fun formatResetForWidget(sec: Long, text: DurationText = ChineseDurationText): S
|
||||
}
|
||||
}
|
||||
|
||||
/** 标准化窗口标签:weekly/每周 → 每周标签,monthly/每月 → 每月标签,usage/用量 → 用量标签,其他原样返回 */
|
||||
/** 标准化窗口标签:weekly/每周 → 每周标签,monthly/每月 → 每月标签,其他原样返回 */
|
||||
fun normalizeWindowLabel(
|
||||
label: String,
|
||||
weeklyLabel: String = "Weekly",
|
||||
monthlyLabel: String = "Monthly",
|
||||
usageLabel: String = "Usage"
|
||||
weeklyLabel: String = "每周",
|
||||
monthlyLabel: String = "每月"
|
||||
): 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 模式:低饱和粉白渐变(#FFF9FB → #FDEFF4,从左上到右下)
|
||||
* Dark 模式:暖深渐变(#1A1417 → #251C21)
|
||||
* Light 模式:樱粉渐变(#FFF0F5 → #FFD1DC,从左上到右下)
|
||||
* Dark 模式:暖深渐变(#1F1419 → #2A1F25)
|
||||
*
|
||||
* 放在 Scaffold 容器层,所有页面共享同一层渐变。
|
||||
*/
|
||||
|
||||
@ -13,10 +13,8 @@ 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
|
||||
|
||||
@ -44,7 +42,7 @@ fun ServiceIcon(
|
||||
modifier = modifier
|
||||
.size(size.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(if (isSystemInDarkTheme()) Color(0xFF32262C) else Color.White),
|
||||
.background(if (isSystemInDarkTheme()) Color(0xFF352329) else Color.White),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Image(
|
||||
@ -59,12 +57,12 @@ fun ServiceIcon(
|
||||
modifier = modifier
|
||||
.size(size.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(if (isSystemInDarkTheme()) Color(0xFF32262C) else Color.White),
|
||||
.background(if (isSystemInDarkTheme()) Color(0xFF352329) else Color.White),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.ic_opencode_go_logo),
|
||||
contentDescription = "CommandCode",
|
||||
contentDescription = "CommandCode Go",
|
||||
modifier = Modifier.size((size * 0.85).dp, ((size * 0.85f * 30f / 54f)).dp)
|
||||
)
|
||||
}
|
||||
@ -114,75 +112,10 @@ fun ServiceIcon(
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.ic_ollama_logo),
|
||||
contentDescription = "Ollama",
|
||||
contentDescription = "Ollama Pro",
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -1,65 +0,0 @@
|
||||
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,7 +1,6 @@
|
||||
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
|
||||
@ -45,14 +44,11 @@ fun StatusChip(
|
||||
style: StatusStyle,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
// 暗色模式下胶囊底色是状态色 12% alpha 叠加暗色卡片,
|
||||
// 深色文字几乎不可读,故暗色使用同色系亮色文字。
|
||||
val dark = isSystemInDarkTheme()
|
||||
val (dotColor, textColor) = when (style.level) {
|
||||
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)
|
||||
StatusLevel.OK -> StatusGreen to Color(0xFF1B5E20)
|
||||
StatusLevel.WARNING -> StatusOrange to Color(0xFF8A4A00)
|
||||
StatusLevel.ERROR -> StatusRed to Color(0xFF8B0033)
|
||||
StatusLevel.INFO -> StatusBlue to Color(0xFF0D47A1)
|
||||
}
|
||||
val bgColor = dotColor.copy(alpha = 0.12f)
|
||||
Row(
|
||||
|
||||
@ -37,7 +37,7 @@ import com.rainy.token.ui.theme.StrawberryPink
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* CommandCode 用量统计主卡片 —— 风格与 UsageStatsCard(OCGO)完全一致。
|
||||
* CommandCode Go 用量统计主卡片 —— 风格与 UsageStatsCard(OCGO)完全一致。
|
||||
* 仅展示核心指标:输入 Token(含 Cache)、输出 Token、总花费,
|
||||
* 外加同步按钮和"查看详情"入口。详细统计在 CCGO 专属详情页。
|
||||
*/
|
||||
@ -171,16 +171,6 @@ fun CommandCodeUsageStatsCard(
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 刷新时间(与其余服务卡片底部一致) ───
|
||||
if (uiState.lastSyncAt > 0) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = usageUpdatedAtText(uiState.lastSyncAt),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = InkMuted
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,27 +19,21 @@ 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
|
||||
@ -48,7 +42,6 @@ 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
|
||||
@ -76,7 +69,6 @@ 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
|
||||
@ -86,24 +78,17 @@ 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
|
||||
@ -133,32 +118,19 @@ import java.util.Locale
|
||||
@Composable
|
||||
fun DashboardScreen(
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenApiManagement: () -> Unit,
|
||||
onOpenService: (ServiceType, String?) -> Unit,
|
||||
onOpenService: (ServiceType) -> 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) {
|
||||
@ -174,14 +146,8 @@ 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(migrated)
|
||||
cardOrder.addAll(saved)
|
||||
}
|
||||
|
||||
// 全局刷新触发器——每次 dashboard 刷新完成后 +1,UsageStatsCard 据此同步用量数据
|
||||
@ -218,45 +184,6 @@ fun DashboardScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// 每日自动签到结果汇报:「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)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
containerColor = Color.Transparent,
|
||||
topBar = {
|
||||
@ -293,13 +220,6 @@ fun DashboardScreen(
|
||||
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,
|
||||
@ -321,8 +241,7 @@ fun DashboardScreen(
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
) {
|
||||
// 仅初始加载时显示转圈;加载完即使没有已配置服务也渲染(展示用量卡片 + 空态引导)
|
||||
if (uiState.loading) {
|
||||
if (uiState.cards.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
@ -346,62 +265,20 @@ fun DashboardScreen(
|
||||
.padding(contentPadding),
|
||||
verticalArrangement = Arrangement.spacedBy(12.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)
|
||||
modifier = Modifier.padding(horizontal = 4.dp)
|
||||
)
|
||||
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(
|
||||
@ -461,70 +338,6 @@ 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"
|
||||
@ -532,7 +345,6 @@ 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,
|
||||
@ -543,128 +355,37 @@ private data class DashboardHomeItem(
|
||||
private fun rememberDashboardItems(
|
||||
cards: List<DashboardCardUi>,
|
||||
order: List<String>,
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenUsageDetail: () -> Unit,
|
||||
onOpenCcgoUsageDetail: () -> Unit,
|
||||
onOpenService: (ServiceType, String?) -> Unit,
|
||||
onOpenHeatmap: () -> Unit,
|
||||
onOpenSub2PanelManagement: (String?) -> Unit,
|
||||
onOpenRecharge: (ServiceType, String?) -> Unit,
|
||||
onOpenService: (ServiceType) -> Unit,
|
||||
refreshTrigger: Int
|
||||
): List<DashboardHomeItem> {
|
||||
val defaultItems = buildList {
|
||||
// 未配置任何服务时的引导卡片(首页仅显示已配置的服务)
|
||||
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)
|
||||
UsageStatsCard(onOpenDetail = onOpenUsageDetail, 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 ->
|
||||
// 多账号卡片用“账号 N”兜底显示名(同服务内默认账号在前,按展示顺序编号)
|
||||
add(DashboardHomeItem(card.key) {
|
||||
add(DashboardHomeItem("service:${card.service.storageKey}") {
|
||||
DashboardCard(
|
||||
card = card,
|
||||
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) },
|
||||
onClick = { onOpenService(card.service) },
|
||||
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 { 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] }
|
||||
val ordered = order.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>,
|
||||
@ -900,15 +621,7 @@ private fun DraggableDashboardCards(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DashboardCard(
|
||||
card: DashboardCardUi,
|
||||
accountLabel: String? = null,
|
||||
onClick: () -> Unit,
|
||||
onOpenUsageDetail: (() -> Unit)? = null,
|
||||
onOpenHeatmap: (() -> Unit)? = null,
|
||||
onOpenPanelManagement: ((String?) -> Unit)? = null,
|
||||
onOpenRecharge: (() -> Unit)? = null,
|
||||
) {
|
||||
private fun DashboardCard(card: DashboardCardUi, onClick: () -> Unit, onOpenUsageDetail: (() -> Unit)? = null) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@ -920,68 +633,20 @@ private fun DashboardCard(
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
// ─── 顶部:图标 + 名称 + 状态 chip ───
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
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(
|
||||
// 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))
|
||||
},
|
||||
text = 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())
|
||||
}
|
||||
|
||||
@ -990,17 +655,11 @@ private fun DashboardCard(
|
||||
// ─── 主体:服务特定的主信息 ───
|
||||
BalanceMainArea(card)
|
||||
|
||||
// 用量服务卡片底部:用量详情入口 + Token 活动快捷入口(仅当有凭证时显示)
|
||||
// 用量服务卡片底部:用量详情入口(仅当有凭证时显示)
|
||||
if (onOpenUsageDetail != null && card.credentialState != com.rainy.token.domain.model.CredentialStatus.State.NOT_CONFIGURED) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
TextButton(onClick = onOpenUsageDetail) {
|
||||
Text(
|
||||
stringResource(R.string.action_view_usage_detail),
|
||||
color = StrawberryPink,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(stringResource(R.string.action_view_usage_detail), color = StrawberryPink)
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Filled.KeyboardArrowRight,
|
||||
@ -1009,185 +668,18 @@ private fun DashboardCard(
|
||||
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))
|
||||
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)
|
||||
inkMuted()
|
||||
)
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1205,7 +697,7 @@ private fun DashboardFooter() {
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.dashboard_footer_version, BuildConfig.VERSION_NAME),
|
||||
text = stringResource(R.string.dashboard_footer_version),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = inkMuted()
|
||||
)
|
||||
|
||||
@ -7,18 +7,15 @@ import com.rainy.token.data.cache.CachedBalance
|
||||
import com.rainy.token.data.repository.CredentialRepository
|
||||
import com.rainy.token.data.repository.RepositoryError
|
||||
import com.rainy.token.domain.model.CredentialStatus
|
||||
import com.rainy.token.domain.model.Credential
|
||||
import com.rainy.token.domain.model.ServiceBalance
|
||||
import com.rainy.token.domain.service.ServiceType
|
||||
import com.rainy.token.domain.usecase.RefreshBalanceUseCase
|
||||
import com.rainy.token.R
|
||||
import com.rainy.token.ui.components.UiText
|
||||
import com.rainy.token.ui.dashboard.autocheckin.CHECKIN_SERVICES
|
||||
import com.rainy.token.ui.widget.OpenCodeGoWidgetProvider
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
@ -41,13 +38,10 @@ import kotlinx.coroutines.sync.Mutex
|
||||
class DashboardViewModel @Inject constructor(
|
||||
private val credentialRepository: CredentialRepository,
|
||||
private val refreshBalanceUseCase: RefreshBalanceUseCase,
|
||||
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
|
||||
@param:ApplicationContext private val appContext: Context
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(DashboardUiState(loading = true))
|
||||
private val _uiState = MutableStateFlow(DashboardUiState())
|
||||
val uiState: StateFlow<DashboardUiState> = _uiState.asStateFlow()
|
||||
|
||||
/** 防止并发 refresh() 调用交错覆盖 UI。 */
|
||||
@ -55,152 +49,94 @@ class DashboardViewModel @Inject constructor(
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
// 1) 先从缓存快速填充 UI(不阻塞首帧,断网时也能看旧数据)
|
||||
// 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) {
|
||||
// 签到失败不阻塞余额刷新
|
||||
}
|
||||
// 3) 发起网络刷新——此时 UI 已有缓存兜底,不会闪回旧值
|
||||
// 2) 再发起网络刷新——此时 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()
|
||||
val localStates = credentialRepository.readLocalStates()
|
||||
_uiState.update { state ->
|
||||
state.copy(cards = rebuildCardsPreservingOrder(state, accountStates))
|
||||
state.copy(
|
||||
cards = state.cards.map { card ->
|
||||
val local = localStates.getValue(card.service)
|
||||
val credentialChanged = local.fingerprint != card.credentialFingerprint
|
||||
val cacheAdvanced = !credentialChanged && isNewer(
|
||||
candidate = local.cachedBalance,
|
||||
current = card.cachedBalance
|
||||
)
|
||||
card.copy(
|
||||
credentialState = local.status.state,
|
||||
credentialFingerprint = local.fingerprint,
|
||||
cachedBalance = if (credentialChanged) {
|
||||
local.cachedBalance
|
||||
} else {
|
||||
newerOf(card.cachedBalance, local.cachedBalance)
|
||||
},
|
||||
// 凭据变化或出现更新的成功缓存时,旧错误已不再代表当前数据。
|
||||
lastFetchError = if (credentialChanged || cacheAdvanced) {
|
||||
null
|
||||
} else {
|
||||
card.lastFetchError
|
||||
}
|
||||
// 登录/导入后刚出现的账号还没有余额缓存:立即拉一次,首页即刻看到
|
||||
// 余额与角标,不用等用户点进详情页才触发刷新(刷新内部有 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)
|
||||
val localStates = credentialRepository.readLocalStates()
|
||||
val cards = ServiceType.entries.map { type ->
|
||||
buildCard(localStates.getValue(type), lastFetchError = null)
|
||||
}
|
||||
_uiState.update { it.copy(loading = false, refreshing = false, cards = cards) }
|
||||
}
|
||||
|
||||
/** 拉取所有已配置账号的最新余额,更新缓存。失败保留旧数据并把错误信息带上。 */
|
||||
/** 拉取所有服务最新余额,更新缓存。失败的服务保留旧数据并把错误信息带上。 */
|
||||
fun refresh() {
|
||||
viewModelScope.launch {
|
||||
// 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 ->
|
||||
val results: Map<ServiceType, Result<ServiceBalance>?> = coroutineScope {
|
||||
ServiceType.entries.map { type ->
|
||||
async {
|
||||
val key = AccountKey(entry.service, entry.account?.id)
|
||||
val result: Result<ServiceBalance>? =
|
||||
if (entry.status.state == CredentialStatus.State.NOT_CONFIGURED) {
|
||||
null // 未配置的账号不拉
|
||||
val status = credentialRepository.statusFor(type)
|
||||
if (status.state == CredentialStatus.State.NOT_CONFIGURED) {
|
||||
type to null // 未配置的服务不拉
|
||||
} else {
|
||||
refreshBalanceUseCase(entry.service, entry.account?.id)
|
||||
type to refreshBalanceUseCase(type)
|
||||
}
|
||||
key to result
|
||||
}
|
||||
}.awaitAll().associateBy({ it.first }, { it.second })
|
||||
}.awaitAll().toMap()
|
||||
}
|
||||
val accountStates = credentialRepository.readAccountStates()
|
||||
val cards = orderAccountEntries(accountStates).map { entry ->
|
||||
val key = AccountKey(entry.service, entry.account?.id)
|
||||
val error = results[key]?.exceptionOrNull()
|
||||
val localStates = credentialRepository.readLocalStates()
|
||||
val cards = ServiceType.entries.map { type ->
|
||||
val result = results[type]
|
||||
val error = result?.exceptionOrNull()
|
||||
val errorUi = error
|
||||
?.takeUnless { it is RepositoryError.CredentialChanged }
|
||||
?.let { errorToUiText(it) }
|
||||
buildCard(entry, lastFetchError = errorUi)
|
||||
buildCard(localStates.getValue(type), lastFetchError = errorUi)
|
||||
}
|
||||
_uiState.update { it.copy(refreshing = false, cards = cards) }
|
||||
// 刷新成功后更新桌面小组件
|
||||
if (results[AccountKey(ServiceType.OPENCODE_GO, null)]?.isSuccess == true) {
|
||||
if (results[ServiceType.OPENCODE_GO]?.isSuccess == true) {
|
||||
OpenCodeGoWidgetProvider.notifyDataChanged(appContext)
|
||||
}
|
||||
} finally {
|
||||
@ -209,86 +145,15 @@ class DashboardViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于最新账号状态重建卡片列表,尽量保留旧顺序:
|
||||
* - 同一服务内默认账号在前,其余按账号创建顺序;
|
||||
* - 新增账号追加到同服务现有卡片之后;服务整体顺序沿用旧顺序,新服务按枚举追加。
|
||||
*/
|
||||
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,
|
||||
local: CredentialRepository.LocalState,
|
||||
lastFetchError: UiText?
|
||||
): DashboardCardUi = DashboardCardUi(
|
||||
service = local.service,
|
||||
accountId = local.account?.id,
|
||||
accountLabel = local.account?.label,
|
||||
accountIndex = 0, // 展示兜底名称时由 UI 根据同服务卡片顺序计算
|
||||
isDefaultAccount = local.account?.isDefault == true,
|
||||
service = local.status.service,
|
||||
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
|
||||
lastFetchError = lastFetchError
|
||||
)
|
||||
|
||||
private fun newerOf(
|
||||
@ -305,140 +170,6 @@ class DashboardViewModel @Inject constructor(
|
||||
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(
|
||||
@ -449,42 +180,17 @@ 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: UiText?,
|
||||
/** 站点 Logo URL(Sub2API 面板登录后抓取;非空时主页卡片用它替换内置图标)。 */
|
||||
val siteIcon: String? = null,
|
||||
/** 今日已签到角标(仅可签到服务有值,见 [CHECKIN_SERVICES])。 */
|
||||
val checkedInToday: Boolean = false,
|
||||
/** 账号区域(Trae CN/INTL 等,用于选择官方充值/用量页)。 */
|
||||
val region: String? = null
|
||||
val lastFetchError: UiText?
|
||||
) {
|
||||
/** 余额展示主数字。优先取缓存,错误时也展示(不隐藏,让用户看到旧值 + 红点提示)。 */
|
||||
val displayBalance: ServiceBalance? get() = cachedBalance?.balance
|
||||
|
||||
/** 卡片唯一键:`{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(
|
||||
|
||||
@ -1,79 +0,0 @@
|
||||
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,7 +1,6 @@
|
||||
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
|
||||
@ -9,28 +8,22 @@ 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
|
||||
@ -38,14 +31,12 @@ 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 ──
|
||||
@ -98,12 +89,6 @@ 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(
|
||||
@ -138,113 +123,6 @@ 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) {
|
||||
@ -274,9 +152,9 @@ internal fun OpenCodeGoMainBalance(balance: ServiceBalance) {
|
||||
@Composable
|
||||
internal fun OpenCodeGoUsageWindows(balance: ServiceBalance) {
|
||||
val windows = listOf(
|
||||
Triple(stringResource(R.string.window_5h), balance.extras["rolling.pct"]?.toFloatOrNull()?.roundToInt(), balance.extras["rolling.resetInSec"]?.toLongOrNull()),
|
||||
Triple(stringResource(R.string.window_weekly), balance.extras["weekly.pct"]?.toFloatOrNull()?.roundToInt(), balance.extras["weekly.resetInSec"]?.toLongOrNull()),
|
||||
Triple(stringResource(R.string.window_monthly), balance.extras["monthly.pct"]?.toFloatOrNull()?.roundToInt(), balance.extras["monthly.resetInSec"]?.toLongOrNull())
|
||||
Triple(stringResource(R.string.window_5h), balance.extras["rolling.pct"]?.toIntOrNull(), balance.extras["rolling.resetInSec"]?.toLongOrNull()),
|
||||
Triple(stringResource(R.string.window_weekly), balance.extras["weekly.pct"]?.toIntOrNull(), balance.extras["weekly.resetInSec"]?.toLongOrNull()),
|
||||
Triple(stringResource(R.string.window_monthly), balance.extras["monthly.pct"]?.toIntOrNull(), balance.extras["monthly.resetInSec"]?.toLongOrNull())
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
windows.forEach { (label, pct, resetSec) ->
|
||||
@ -350,7 +228,7 @@ internal fun CommandCodeGoUsageWindows(balance: ServiceBalance) {
|
||||
|
||||
@Composable
|
||||
internal fun CodexMainBalance(balance: ServiceBalance) {
|
||||
val plan = balance.extras["plan"]?.takeIf { it.isNotBlank() }?.let {
|
||||
val plan = balance.extras["plan"]?.let {
|
||||
when (it) { "plus" -> "Plus"; "pro" -> "Pro"; "free" -> "Free"; else -> it.replaceFirstChar { c -> c.uppercaseChar() } }
|
||||
} ?: "—"
|
||||
val primaryLabel = formatCodexPrimaryLabel(
|
||||
@ -396,13 +274,13 @@ internal fun CodexMainBalance(balance: ServiceBalance) {
|
||||
*/
|
||||
internal fun formatCodexPrimaryLabel(
|
||||
raw: String?,
|
||||
weeklyLabel: String = "Weekly",
|
||||
monthlyLabel: String = "Monthly",
|
||||
usageLabel: String = "Usage"
|
||||
weeklyLabel: String = "每周",
|
||||
monthlyLabel: String = "每月",
|
||||
usageLabel: String = "用量"
|
||||
): String = when (raw?.lowercase()) {
|
||||
"5h" -> "5h"
|
||||
"7d", "weekly", "每周", weeklyLabel -> weeklyLabel
|
||||
"30d", "monthly", "每月", monthlyLabel -> monthlyLabel
|
||||
"7d", "每周", weeklyLabel -> weeklyLabel
|
||||
"30d", "每月", monthlyLabel -> monthlyLabel
|
||||
"usage" -> usageLabel
|
||||
null -> "5h"
|
||||
else -> raw ?: "5h"
|
||||
@ -418,70 +296,44 @@ internal fun CodexUsageWindows(balance: ServiceBalance) {
|
||||
|
||||
val weeklyLabel = stringResource(R.string.window_every_week)
|
||||
val monthlyLabel = stringResource(R.string.window_every_month)
|
||||
val usageLabel = stringResource(R.string.window_usage)
|
||||
val sparkTitle = stringResource(R.string.window_spark)
|
||||
|
||||
data class UiWindow(val label: String, val isSpark: Boolean, val remainingPct: Int?, val resetAt: Long?)
|
||||
|
||||
val windows = (0 until windowCount).map { i ->
|
||||
val rawLabel = extras["window_$i.label"] ?: "usage"
|
||||
val label = normalizeWindowLabel(
|
||||
rawLabel,
|
||||
extras["window_$i.label"] ?: "Usage",
|
||||
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 }
|
||||
monthlyLabel = monthlyLabel
|
||||
)
|
||||
val remainingPct = extras["window_$i.remainingPct"]?.toIntOrNull()
|
||||
val resetAt = extras["window_$i.resetAt"]?.toLongOrNull()?.takeIf { it > 0 }
|
||||
Triple(label, remainingPct, resetAt)
|
||||
}
|
||||
|
||||
// 主模型(普通 Codex)窗口独占全部进度行;Spark 独立限额是次要通道,只留一行小字摘要
|
||||
val mainWindows = windows.filter { !it.isSpark }
|
||||
val sparkWindows = windows.filter { it.isSpark }
|
||||
|
||||
// 判断是否有 5h 窗口(与语言无关:匹配 "5h"/"5H" 或本地化标签,仅看主模型窗口)
|
||||
// 判断是否有 5h 窗口(用于保留空槽位)
|
||||
val fiveHourLabel = stringResource(R.string.window_5h)
|
||||
val has5h = mainWindows.any { isFiveHourLabel(it.label, fiveHourLabel, stringResource(R.string.window_5h_short)) }
|
||||
val has5h = windows.any { it.first.contains("5") && it.first.contains("小时") }
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (windows.isEmpty()) {
|
||||
CompactUsageRowEmpty(label = stringResource(R.string.usage_window_title_plain), resetInSec = null)
|
||||
CompactUsageRowEmpty(label = "Usage", resetInSec = null)
|
||||
} else {
|
||||
// 如果没有 5h 窗口,在顶部插入一个空的 5h 槽位(保留位置,等恢复后自动填充)
|
||||
if (!has5h) {
|
||||
CompactUsageRowEmpty(label = fiveHourLabel, resetInSec = null)
|
||||
}
|
||||
mainWindows.forEach { w ->
|
||||
val resetSec = w.resetAt?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 }
|
||||
if (w.remainingPct != null) {
|
||||
CompactUsageRow(label = w.label, pct = (100 - w.remainingPct).coerceIn(0, 100), resetInSec = resetSec)
|
||||
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 })
|
||||
} else {
|
||||
CompactUsageRowEmpty(label = w.label, resetInSec = resetSec)
|
||||
CompactUsageRowEmpty(label = label, resetInSec = resetAt?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 })
|
||||
}
|
||||
}
|
||||
// Spark 次要限额:单行摘要,不与主模型争夺视觉焦点
|
||||
if (sparkWindows.isNotEmpty()) {
|
||||
val summary = sparkWindows.joinToString(" · ") { w ->
|
||||
val used = w.remainingPct?.let { (100 - it).coerceIn(0, 100) }
|
||||
if (used != null) "${w.label} $used%" else "${w.label} —"
|
||||
}
|
||||
Text(
|
||||
text = "$sparkTitle · $summary",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = inkMuted()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun OllamaMainBalance(balance: ServiceBalance) {
|
||||
val plan = balance.extras["plan"]?.takeIf { it.isNotBlank() }?.let {
|
||||
val plan = balance.extras["plan"]?.let {
|
||||
when (it.lowercase()) { "pro" -> "Pro"; "max" -> "Max"; "free" -> "Free"; else -> it }
|
||||
} ?: "—"
|
||||
Row(verticalAlignment = Alignment.Bottom) {
|
||||
@ -667,61 +519,6 @@ internal fun DashboardCardUi.statusBadgeStyle(): StatusStyle = when {
|
||||
StatusStyle(R.string.status_normal, StatusLevel.OK)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sub2API 副标题:显示登录方式(邮箱登录 / API 登录),而不是笼统的「自托管面板 · 用户级 sk- Key 余额」。
|
||||
* 依据 UsageDetail/余额 extras 里的 auth_mode(由 Repository 写入):
|
||||
* panel = 面板 Token(邮箱/账号登录面板)
|
||||
* apikey = 仅配置了 sk- API Key
|
||||
* 无数据时回退到通用描述。
|
||||
*/
|
||||
@Composable
|
||||
internal fun sub2AuthModeText(card: DashboardCardUi): String {
|
||||
val balance = card.displayBalance
|
||||
val mode = balance?.extras?.get("auth_mode")
|
||||
return when (mode) {
|
||||
"panel" -> stringResource(R.string.sub2_auth_panel)
|
||||
"apikey" -> stringResource(R.string.sub2_auth_apikey)
|
||||
else -> stringResource(R.string.service_desc_sub2api)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trae / WorkBuddy 卡片副标题:显示订阅状态(不再展示登录方式)。
|
||||
*
|
||||
* 取自 extras 的订阅字段(付费用户 / 订阅中),与详情页「账户信息」区同一数据源。
|
||||
* 无缓存余额或字段缺失时返回 null,调用方回落到 [secondaryLineRes] 的通用描述。
|
||||
*/
|
||||
@Composable
|
||||
internal fun subscriptionLine(card: DashboardCardUi): String? {
|
||||
// 仅 Trae / WorkBuddy 使用本函数;其余服务回落通用描述
|
||||
if (card.service != ServiceType.TRAE && card.service != ServiceType.WORKBUDDY) return null
|
||||
val extras = card.displayBalance?.extras ?: return null
|
||||
|
||||
// WorkBuddy:订阅 / 付费状态
|
||||
if ("is_paid_user" in extras || "subscription" in extras) {
|
||||
val isPaid = extras["is_paid_user"] == "true"
|
||||
val isSubscribed = extras["subscription"] == "true"
|
||||
return when {
|
||||
isSubscribed -> stringResource(R.string.extra_value_subscription_active)
|
||||
isPaid -> stringResource(R.string.extra_value_paid_user)
|
||||
else -> stringResource(R.string.extra_value_free_user)
|
||||
}
|
||||
}
|
||||
|
||||
// Trae:计费模式(积分计费 / 美元计费),无则不显示
|
||||
val billing = when {
|
||||
extras["is_credits_billing"] == "true" -> stringResource(R.string.extra_value_billing_credits)
|
||||
extras["is_dollar_usage_billing"] == "true" -> stringResource(R.string.extra_value_billing_dollar)
|
||||
else -> null
|
||||
}
|
||||
if (billing != null) {
|
||||
return stringResource(R.string.extra_label_billing_mode_desc, billing)
|
||||
}
|
||||
|
||||
// 字段完全缺失时返回 null,回落通用描述
|
||||
return null
|
||||
}
|
||||
|
||||
/** 服务副标题(资源 ID,由 UI 层解析)。 */
|
||||
@StringRes
|
||||
internal fun secondaryLineRes(card: DashboardCardUi): Int = when (card.service) {
|
||||
@ -730,9 +527,6 @@ internal fun secondaryLineRes(card: DashboardCardUi): Int = when (card.service)
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
@ -765,170 +559,3 @@ internal fun footerText(card: DashboardCardUi): UiText {
|
||||
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)
|
||||
|
||||
@ -76,7 +76,7 @@ import java.util.Locale
|
||||
// 图表用色板(每个模型一个颜色,12 色 Material Design 500 色阶,足够区分 12 个模型)
|
||||
internal val modelColors = listOf(
|
||||
Color(0xFFF44336), // Red
|
||||
Color(0xFFC9436C), // Pink(玫瑰主色)
|
||||
Color(0xFFE91E63), // Pink
|
||||
Color(0xFF9C27B0), // Purple
|
||||
Color(0xFF673AB7), // Deep Purple
|
||||
Color(0xFF3F51B5), // Indigo
|
||||
@ -89,11 +89,11 @@ internal val modelColors = listOf(
|
||||
Color(0xFF607D8B) // Blue Grey
|
||||
)
|
||||
|
||||
// Token 堆叠色板:命中缓存(浅樱粉·顶)、未命中输入(玫瑰粉·中)、输出(深玫瑰·底)
|
||||
// Token 堆叠色板:命中缓存(浅粉·顶)、未命中输入(粉·中)、输出(深粉·底)
|
||||
internal val tokenColors = listOf(
|
||||
Color(0xFFFFE3EC), // cache hit — 浅樱粉(上面)
|
||||
Color(0xFFFFA3BC), // cache miss input — 玫瑰粉(中间)
|
||||
Color(0xFFC9436C) // output — 深玫瑰(主色,下面)
|
||||
Color(0xFFFFD1DC), // cache hit — 浅粉(上面)
|
||||
Color(0xFFFF85A2), // cache miss input — 粉色(中间)
|
||||
Color(0xFFE91E63) // output — 深粉(下面)
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@ -551,8 +551,7 @@ internal fun StackedBarChart(
|
||||
Text(stringResource(R.string.common_no_data), color = InkMuted, style = MaterialTheme.typography.bodySmall)
|
||||
return
|
||||
}
|
||||
// key = buckets:切换时间范围/筛选后重置 tooltip,避免残留旧数据
|
||||
var tooltipBucket by remember(buckets) { mutableStateOf(buckets.lastOrNull()) }
|
||||
var tooltipBucket by remember { mutableStateOf(buckets.lastOrNull()) }
|
||||
val density = LocalDensity.current
|
||||
val d = density.density
|
||||
val barCount = buckets.size
|
||||
@ -682,8 +681,7 @@ internal fun LineChart(
|
||||
Text(stringResource(R.string.common_no_data), color = InkMuted, style = MaterialTheme.typography.bodySmall)
|
||||
return
|
||||
}
|
||||
// key = buckets:切换时间范围/筛选后重置 tooltip,避免残留旧数据
|
||||
var tooltipBucket by remember(buckets) { mutableStateOf(buckets.lastOrNull()) }
|
||||
var tooltipBucket by remember { mutableStateOf(buckets.lastOrNull()) }
|
||||
val density = LocalDensity.current
|
||||
val d = density.density
|
||||
val barCount = buckets.size
|
||||
@ -815,7 +813,6 @@ 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 ->
|
||||
|
||||
@ -120,7 +120,6 @@ 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)
|
||||
@ -153,35 +152,30 @@ 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, customDay = date) }
|
||||
_state.update { it.copy(granularity = ChartGranularity.CUSTOM_DAY_HOURLY) }
|
||||
_customDay = date
|
||||
load()
|
||||
}
|
||||
|
||||
fun setCustomMonth(monthDate: LocalDate) {
|
||||
val month = monthDate.withDayOfMonth(1)
|
||||
_state.update { it.copy(granularity = ChartGranularity.CUSTOM_MONTH_DAILY, customMonth = month) }
|
||||
_customMonth = month
|
||||
_state.update { it.copy(granularity = ChartGranularity.CUSTOM_MONTH_DAILY) }
|
||||
_customMonth = monthDate.withDayOfMonth(1)
|
||||
load()
|
||||
}
|
||||
|
||||
fun setCustomRange(fromDate: LocalDate, toDate: LocalDate) {
|
||||
if (toDate.isBefore(fromDate)) return
|
||||
_state.update { it.copy(granularity = ChartGranularity.CUSTOM_RANGE_DAILY, customRange = fromDate to toDate) }
|
||||
_state.update { it.copy(granularity = ChartGranularity.CUSTOM_RANGE_DAILY) }
|
||||
_customRange = fromDate to toDate
|
||||
load()
|
||||
}
|
||||
@ -202,7 +196,6 @@ 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 -> {
|
||||
@ -267,12 +260,6 @@ 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,
|
||||
|
||||
@ -59,7 +59,6 @@ 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
|
||||
@ -83,7 +82,10 @@ fun UsageDataScreen(
|
||||
|
||||
var timeMenuExpanded by remember { mutableStateOf(false) }
|
||||
var modelMenuExpanded by remember { mutableStateOf(false) }
|
||||
var showRangePicker 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 showCustomDayPicker by remember { mutableStateOf(false) }
|
||||
var showCustomMonthPicker by remember { mutableStateOf(false) }
|
||||
var rawRecord by remember { mutableStateOf<UsageRecord?>(null) }
|
||||
@ -125,7 +127,7 @@ fun UsageDataScreen(
|
||||
timeMenuExpanded = false; showCustomMonthPicker = true
|
||||
})
|
||||
DropdownMenuItem(text = { Text(stringResource(R.string.time_custom_range)) }, onClick = {
|
||||
timeMenuExpanded = false; showRangePicker = true
|
||||
timeMenuExpanded = false; viewModel.setTimeFilter(TimeFilter.Custom(0L, 0L))
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -153,8 +155,11 @@ fun UsageDataScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义时间范围:确认选择后显示 UTC 说明
|
||||
if (state.timeFilter is TimeFilter.Custom) {
|
||||
// 自定义时间范围
|
||||
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(stringResource(R.string.usage_utc0_note), style = MaterialTheme.typography.bodySmall, color = inkMuted())
|
||||
}
|
||||
|
||||
@ -223,43 +228,20 @@ fun UsageDataScreen(
|
||||
}
|
||||
|
||||
// 日期选择器
|
||||
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 }
|
||||
)
|
||||
}
|
||||
if (showCustomDayPicker) DateTimePickerDialog(stringResource(R.string.date_select_day), { ms ->
|
||||
val utc = ZoneOffset.UTC; val ds = Instant.ofEpochMilli(ms).atOffset(utc).toLocalDate().atStartOfDay(utc).toInstant().toEpochMilli()
|
||||
viewModel.setTimeFilter(TimeFilter.Custom(ds, ds + 86400_000L - 1)); showCustomDayPicker = false
|
||||
}, { showCustomDayPicker = false })
|
||||
if (showCustomMonthPicker) DateTimePickerDialog(stringResource(R.string.date_select_month), { ms ->
|
||||
val utc = ZoneOffset.UTC; val ld = Instant.ofEpochMilli(ms).atOffset(utc).toLocalDate()
|
||||
val msStart = ld.withDayOfMonth(1).atStartOfDay(utc).toInstant().toEpochMilli()
|
||||
val msEnd = ld.withDayOfMonth(ld.lengthOfMonth()).plusDays(1).atStartOfDay(utc).toInstant().toEpochMilli() - 1
|
||||
viewModel.setTimeFilter(TimeFilter.Custom(msStart, msEnd)); showCustomMonthPicker = false
|
||||
}, { showCustomMonthPicker = false })
|
||||
if (showStartPicker) DateTimePickerDialog(stringResource(R.string.date_start), { customStartMs = it; showStartPicker = false }, { showStartPicker = false })
|
||||
if (showEndPicker) DateTimePickerDialog(stringResource(R.string.date_end), { ms ->
|
||||
viewModel.setTimeFilter(TimeFilter.Custom(customStartMs, ms)); showEndPicker = false
|
||||
}, { showEndPicker = false })
|
||||
|
||||
// 原始数据弹窗
|
||||
rawRecord?.let { record ->
|
||||
@ -331,12 +313,6 @@ 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
|
||||
|
||||
@ -53,7 +53,6 @@ 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
|
||||
@ -97,7 +96,9 @@ fun UsageDetailScreen(
|
||||
var modelExpanded by remember { mutableStateOf(false) }
|
||||
var showCustomDayPicker by remember { mutableStateOf(false) }
|
||||
var showCustomMonthPicker by remember { mutableStateOf(false) }
|
||||
var showCustomRangePicker by remember { mutableStateOf(false) }
|
||||
var showCustomRangeStart by remember { mutableStateOf(false) }
|
||||
var showCustomRangeEnd by remember { mutableStateOf(false) }
|
||||
var customRangeStartDate by remember { mutableStateOf<LocalDate?>(null) }
|
||||
var showCostDetail by remember { mutableStateOf(false) }
|
||||
var showReqDetail by remember { mutableStateOf(false) }
|
||||
var showTokenDetail by remember { mutableStateOf(false) }
|
||||
@ -109,23 +110,7 @@ fun UsageDetailScreen(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
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
|
||||
)
|
||||
}
|
||||
},
|
||||
title = { Text(stringResource(R.string.title_usage_detail)) },
|
||||
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, stringResource(R.string.action_back)) } },
|
||||
actions = {
|
||||
if (clearViewModel != null) {
|
||||
@ -168,7 +153,7 @@ fun UsageDetailScreen(
|
||||
when (g) {
|
||||
ChartGranularity.CUSTOM_DAY_HOURLY -> showCustomDayPicker = true
|
||||
ChartGranularity.CUSTOM_MONTH_DAILY -> showCustomMonthPicker = true
|
||||
ChartGranularity.CUSTOM_RANGE_DAILY -> showCustomRangePicker = true
|
||||
ChartGranularity.CUSTOM_RANGE_DAILY -> showCustomRangeStart = true
|
||||
else -> viewModel.setGranularity(g)
|
||||
}
|
||||
})
|
||||
@ -224,7 +209,7 @@ fun UsageDetailScreen(
|
||||
if (wideEnough) {
|
||||
// 面板够宽:前两张图表并排
|
||||
item {
|
||||
val models = state.rangeModels
|
||||
val models = state.selectedModels.ifEmpty { state.allModels.toSet() }
|
||||
val costTotal = state.buckets.sumOf { it.totalCost.toDouble() / 100_000_000.0 }
|
||||
val reqTotal = state.buckets.sumOf { it.totalRequests }
|
||||
Row(
|
||||
@ -271,7 +256,7 @@ fun UsageDetailScreen(
|
||||
} else {
|
||||
// 窄面板:三张图表纵向堆叠
|
||||
item {
|
||||
val models = state.rangeModels
|
||||
val models = state.selectedModels.ifEmpty { state.allModels.toSet() }
|
||||
val costTotal = state.buckets.sumOf { it.totalCost.toDouble() / 100_000_000.0 }
|
||||
ChartCard(stringResource(R.string.chart_cost), "$${String.format(Locale.US, "%.4f", costTotal)}", { showCostDetail = true }) {
|
||||
StackedBarChart(state.buckets,
|
||||
@ -319,38 +304,34 @@ fun UsageDetailScreen(
|
||||
}
|
||||
}
|
||||
// 自定义日期选择器
|
||||
if (showCustomDayPicker) DateOnlyPickerDialog(
|
||||
title = stringResource(R.string.date_select_day),
|
||||
initialDate = state.customDay,
|
||||
onConfirm = { date ->
|
||||
if (showCustomDayPicker) DateOnlyPickerDialog(stringResource(R.string.date_select_day), { date ->
|
||||
viewModel.setCustomDay(date)
|
||||
showCustomDayPicker = false
|
||||
},
|
||||
onDismiss = { showCustomDayPicker = false }
|
||||
)
|
||||
if (showCustomMonthPicker) DateOnlyPickerDialog(
|
||||
title = stringResource(R.string.date_select_month),
|
||||
initialDate = state.customMonth,
|
||||
onConfirm = { date ->
|
||||
}, { showCustomDayPicker = false })
|
||||
if (showCustomMonthPicker) DateOnlyPickerDialog(stringResource(R.string.date_select_month), { date ->
|
||||
viewModel.setCustomMonth(date)
|
||||
showCustomMonthPicker = false
|
||||
}, { showCustomMonthPicker = false })
|
||||
if (showCustomRangeStart) DateOnlyPickerDialog(stringResource(R.string.date_pick_start), { date ->
|
||||
customRangeStartDate = date
|
||||
showCustomRangeStart = false
|
||||
showCustomRangeEnd = true
|
||||
}, { showCustomRangeStart = false })
|
||||
if (showCustomRangeEnd) {
|
||||
val startDate = customRangeStartDate
|
||||
DateOnlyPickerDialog(
|
||||
title = stringResource(R.string.date_pick_end),
|
||||
onConfirm = { endDate ->
|
||||
if (startDate != null && !endDate.isBefore(startDate)) {
|
||||
viewModel.setCustomRange(startDate, endDate)
|
||||
showCustomRangeEnd = false
|
||||
}
|
||||
},
|
||||
onDismiss = { showCustomMonthPicker = false }
|
||||
)
|
||||
if (showCustomRangePicker) {
|
||||
val currentRange = state.customRange
|
||||
DateRangePickerDialog(
|
||||
title = stringResource(R.string.date_pick_range),
|
||||
initialStart = currentRange?.first,
|
||||
initialEnd = currentRange?.second,
|
||||
onConfirm = { from, to ->
|
||||
viewModel.setCustomRange(from, to)
|
||||
showCustomRangePicker = false
|
||||
},
|
||||
onDismiss = { showCustomRangePicker = false }
|
||||
onDismiss = { showCustomRangeEnd = false },
|
||||
minDate = startDate
|
||||
)
|
||||
}
|
||||
val models = state.rangeModels
|
||||
val models = state.selectedModels.ifEmpty { state.allModels.toSet() }
|
||||
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)}") }
|
||||
}
|
||||
@ -467,9 +448,8 @@ internal fun CustomTimeRangeRow(
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun DateOnlyPickerDialog(
|
||||
private fun DateOnlyPickerDialog(
|
||||
title: String,
|
||||
initialDate: LocalDate? = null,
|
||||
onConfirm: (LocalDate) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
minDate: LocalDate? = null
|
||||
@ -483,10 +463,7 @@ internal fun DateOnlyPickerDialog(
|
||||
}
|
||||
}
|
||||
}
|
||||
val dateState = rememberDatePickerState(
|
||||
initialSelectedDateMillis = initialDate?.atStartOfDay(utc)?.toInstant()?.toEpochMilli(),
|
||||
selectableDates = selectableDates
|
||||
)
|
||||
val dateState = rememberDatePickerState(selectableDates = selectableDates)
|
||||
|
||||
DatePickerDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
@ -518,7 +495,7 @@ internal fun DateOnlyPickerDialog(
|
||||
color = InkMuted
|
||||
)
|
||||
}
|
||||
DatePicker(state = dateState, title = {})
|
||||
DatePicker(state = dateState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -52,9 +52,6 @@ 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
|
||||
|
||||
@ -77,7 +74,10 @@ fun UsageOverviewScreen(
|
||||
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
var modelMenuExpanded by remember { mutableStateOf(false) }
|
||||
var showRangePicker 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) }
|
||||
|
||||
Scaffold(
|
||||
containerColor = Color.Transparent,
|
||||
@ -115,13 +115,16 @@ fun UsageOverviewScreen(
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.time_custom)) },
|
||||
onClick = { menuExpanded = false; showRangePicker = true }
|
||||
onClick = { menuExpanded = false; viewModel.setTimeFilter(TimeFilter.Custom(0L, 0L)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (uiState.timeFilter is TimeFilter.Custom) {
|
||||
item {
|
||||
CustomTimeRangeRow(customStartMs, customEndMs,
|
||||
{ showStartPicker = true }, { showEndPicker = true },
|
||||
{ viewModel.setTimeFilter(TimeFilter.Custom(customStartMs, customEndMs)) })
|
||||
Text(stringResource(R.string.usage_utc0_note), style = MaterialTheme.typography.bodySmall, color = inkMuted())
|
||||
}
|
||||
}
|
||||
@ -176,21 +179,8 @@ fun UsageOverviewScreen(
|
||||
}
|
||||
item { Spacer(Modifier.height(24.dp)) }
|
||||
}
|
||||
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 }
|
||||
)
|
||||
}
|
||||
if (showStartPicker) DateTimePickerDialog(stringResource(R.string.date_start), { customStartMs = it; showStartPicker = false }, { showStartPicker = false })
|
||||
if (showEndPicker) DateTimePickerDialog(stringResource(R.string.date_end), { customEndMs = it; showEndPicker = false }, { showEndPicker = false })
|
||||
}
|
||||
}
|
||||
|
||||
@ -291,12 +281,6 @@ 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"
|
||||
|
||||
@ -26,7 +26,6 @@ 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
|
||||
@ -45,7 +44,6 @@ import java.util.Locale
|
||||
@Composable
|
||||
fun UsageStatsCard(
|
||||
onOpenDetail: () -> Unit,
|
||||
onOpenHeatmap: () -> Unit = {},
|
||||
refreshTrigger: Int = 0,
|
||||
viewModel: UsageViewModel = hiltViewModel()
|
||||
) {
|
||||
@ -139,17 +137,11 @@ fun UsageStatsCard(
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 查看详情 + Token 活动快捷入口 ───
|
||||
// ─── 查看详情 ───
|
||||
if (uiState.overview != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
TextButton(onClick = onOpenDetail) {
|
||||
Text(
|
||||
stringResource(R.string.action_view_detail),
|
||||
color = StrawberryPink,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(stringResource(R.string.action_view_detail), color = StrawberryPink)
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Filled.KeyboardArrowRight,
|
||||
@ -158,25 +150,6 @@ fun UsageStatsCard(
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 同步结果反馈 ───
|
||||
@ -196,16 +169,6 @@ fun UsageStatsCard(
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -88,22 +88,6 @@ 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
|
||||
@ -174,8 +158,7 @@ class UsageViewModel @Inject constructor(
|
||||
dailyStats = dailyStats,
|
||||
recordCount = totalCount,
|
||||
loading = false,
|
||||
dailyPage = 1,
|
||||
lastSyncAt = loadLastSyncAt(wid)
|
||||
dailyPage = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -206,7 +189,6 @@ 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) {
|
||||
@ -219,15 +201,12 @@ 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()?.let { syncErrorToUiText(it) },
|
||||
lastSyncAt = if (result.isSuccess && wid != null) syncedAt else it.lastSyncAt
|
||||
lastSyncError = result.exceptionOrNull()?.let { syncErrorToUiText(it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -243,15 +222,12 @@ 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()?.let { syncErrorToUiText(it) },
|
||||
lastSyncAt = if (result.isSuccess) syncedAt else it.lastSyncAt
|
||||
lastSyncError = result.exceptionOrNull()?.let { syncErrorToUiText(it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -271,7 +247,6 @@ data class UsageUiState(
|
||||
val recordCount: Int = 0,
|
||||
val lastSyncResult: Int = 0,
|
||||
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 = 全部模型
|
||||
@ -289,8 +264,6 @@ private data class LoadResult(
|
||||
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 ->
|
||||
|
||||
@ -1,16 +0,0 @@
|
||||
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
|
||||
}
|
||||
@ -1,348 +0,0 @@
|
||||
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)
|
||||
@ -1,60 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,748 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,808 +0,0 @@
|
||||
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"
|
||||
@ -1,593 +0,0 @@
|
||||
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
@ -7,22 +7,17 @@ import com.rainy.token.data.cache.CachedBalance
|
||||
import com.rainy.token.data.debug.DebugLog
|
||||
import com.rainy.token.data.repository.CredentialRepository
|
||||
import com.rainy.token.data.repository.RepositoryError
|
||||
import com.rainy.token.data.repository.TraeRepository
|
||||
import com.rainy.token.data.repository.TraeUsageEvent
|
||||
import com.rainy.token.data.repository.WorkBuddyRepository
|
||||
import com.rainy.token.data.repository.TriggerError
|
||||
import com.rainy.token.data.repository.TriggerErrorReason
|
||||
import com.rainy.token.domain.model.Credential
|
||||
import com.rainy.token.domain.model.CredentialStatus
|
||||
import com.rainy.token.domain.model.ServiceBalance
|
||||
import com.rainy.token.domain.model.Sub2DailyUsage
|
||||
import com.rainy.token.domain.model.TriggerSummary
|
||||
import com.rainy.token.R
|
||||
import com.rainy.token.domain.service.ServiceType
|
||||
import com.rainy.token.domain.usecase.RefreshBalanceUseCase
|
||||
import com.rainy.token.ui.components.UiText
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import java.time.LocalDate
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@ -35,22 +30,10 @@ import kotlinx.coroutines.launch
|
||||
class ServiceDetailViewModel @Inject constructor(
|
||||
private val credentialRepository: CredentialRepository,
|
||||
private val balanceCache: BalanceCache,
|
||||
private val refreshBalanceUseCase: RefreshBalanceUseCase,
|
||||
private val checkinStatusStore: com.rainy.token.data.repository.CheckinStatusStore,
|
||||
private val sub2ApiRepository: com.rainy.token.data.repository.Sub2ApiRepository,
|
||||
private val traeRepository: TraeRepository
|
||||
private val refreshBalanceUseCase: RefreshBalanceUseCase
|
||||
) : ViewModel() {
|
||||
|
||||
private val _serviceType = MutableStateFlow<ServiceType?>(null)
|
||||
val serviceType: StateFlow<ServiceType?> = _serviceType.asStateFlow()
|
||||
|
||||
/** 当前选中的账号 ID(多账号服务;null=默认账号,单账号服务恒为 null)。 */
|
||||
private val _accountId = MutableStateFlow<String?>(null)
|
||||
|
||||
/** 多账号服务的账号列表(用于详情页账号切换)。 */
|
||||
private val _accounts = MutableStateFlow<List<com.rainy.token.domain.model.ServiceAccountInfo>>(emptyList())
|
||||
val accounts: StateFlow<List<com.rainy.token.domain.model.ServiceAccountInfo>> = _accounts.asStateFlow()
|
||||
|
||||
private val _uiState = MutableStateFlow(ServiceDetailUiState())
|
||||
val uiState: StateFlow<ServiceDetailUiState> = _uiState.asStateFlow()
|
||||
|
||||
@ -66,332 +49,6 @@ class ServiceDetailViewModel @Inject constructor(
|
||||
private val _modelsLoading = MutableStateFlow(false)
|
||||
val modelsLoading: StateFlow<Boolean> = _modelsLoading.asStateFlow()
|
||||
|
||||
/** 每日签到状态(WorkBuddy / Trae 详情页用)。 */
|
||||
private val _checkinState = MutableStateFlow<CheckinState>(CheckinState.Idle)
|
||||
val checkinState: StateFlow<CheckinState> = _checkinState.asStateFlow()
|
||||
|
||||
/**
|
||||
* 进入详情页时预取的「今日签到信息」(WorkBuddy)。
|
||||
* null=未知/未加载;用于展示「今日已签到(连签 N 天)」并禁用按钮,避免重复点击。
|
||||
*/
|
||||
private val _checkinInfo = MutableStateFlow<CheckinInfo?>(null)
|
||||
val checkinInfo: StateFlow<CheckinInfo?> = _checkinInfo.asStateFlow()
|
||||
|
||||
/** Trae 国际版 Usage Events 列表(详情页直接展示,不跳网页)。 */
|
||||
private val _traeUsageEvents = MutableStateFlow<TraeUsageEventsState>(TraeUsageEventsState.Idle)
|
||||
val traeUsageEvents: StateFlow<TraeUsageEventsState> = _traeUsageEvents.asStateFlow()
|
||||
|
||||
/** 今日签到信息摘要(仅字段已确认的才填)。 */
|
||||
data class CheckinInfo(val todayCheckedIn: Boolean, val streakDays: Long)
|
||||
|
||||
/** 进入详情页时预取「今日签到信息」:WORKBUDDY 走状态接口;TRAE 本地记录 + status 接口。 */
|
||||
private fun preloadCheckinInfo(service: ServiceType, accountId: String?) {
|
||||
_checkinInfo.value = null
|
||||
val generation = serviceGeneration
|
||||
when (service) {
|
||||
ServiceType.WORKBUDDY -> viewModelScope.launch {
|
||||
// 走 RefreshBalanceUseCase 会话:状态查询也会触发 OAuth 轮换,需按快照校验提交
|
||||
val status = refreshBalanceUseCase.fetchCheckinStatus(accountId).getOrNull() ?: return@launch
|
||||
if (generation != serviceGeneration || _serviceType.value != service) return@launch
|
||||
_checkinInfo.value = CheckinInfo(
|
||||
todayCheckedIn = status.todayCheckedIn,
|
||||
streakDays = status.streakDays
|
||||
)
|
||||
}
|
||||
ServiceType.TRAE -> {
|
||||
// 先看统一签到记录(零网络秒回),未命中再查服务端 status(跨设备已签也能识别)
|
||||
if (checkinStatusStore.isCheckedToday(service, accountId)) {
|
||||
_checkinInfo.value = CheckinInfo(todayCheckedIn = true, streakDays = 0)
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
val checked = refreshBalanceUseCase.fetchTraeCheckinStatus(accountId).getOrNull()
|
||||
?: return@launch
|
||||
if (generation != serviceGeneration || _serviceType.value != service) return@launch
|
||||
if (checked) {
|
||||
checkinStatusStore.markChecked(service, accountId)
|
||||
_checkinInfo.value = CheckinInfo(todayCheckedIn = true, streakDays = 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
/** 预取 Trae 国际版用量事件(仅 INTL 有 Usage Events;CN 仍走积分体系)。 */
|
||||
private fun preloadUsageEvents(service: ServiceType, accountId: String?) {
|
||||
if (service != ServiceType.TRAE) return
|
||||
if (_traeUsageEvents.value is TraeUsageEventsState.Loading) return
|
||||
val currentRegion = _uiState.value.region
|
||||
if (!currentRegion.equals("INTL", ignoreCase = true)) return
|
||||
_traeUsageEvents.value = TraeUsageEventsState.Loading
|
||||
val generation = serviceGeneration
|
||||
viewModelScope.launch {
|
||||
val result = traeRepository.fetchUsageEvents(accountId)
|
||||
if (generation != serviceGeneration || _serviceType.value != service) return@launch
|
||||
_traeUsageEvents.value = result.fold(
|
||||
onSuccess = { events ->
|
||||
if (events.isEmpty()) TraeUsageEventsState.Success(emptyList())
|
||||
else TraeUsageEventsState.Success(events)
|
||||
},
|
||||
onFailure = { e ->
|
||||
TraeUsageEventsState.Error(
|
||||
UiText.Resource(
|
||||
R.string.trae_usage_events_load_failed,
|
||||
listOf(e.message ?: "")
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 触发每日签到:仅 WORKBUDDY / TRAE 有效;结果按语义本地化(不直接显示 Repository 中文串)。 */
|
||||
fun checkin() {
|
||||
val service = _serviceType.value ?: return
|
||||
val accountId = _accountId.value
|
||||
if (service != ServiceType.WORKBUDDY && service != ServiceType.TRAE) return
|
||||
if (_checkinState.value is CheckinState.Loading) return
|
||||
_checkinState.value = CheckinState.Loading
|
||||
val generation = serviceGeneration
|
||||
viewModelScope.launch {
|
||||
val result = when (service) {
|
||||
ServiceType.WORKBUDDY -> refreshBalanceUseCase.checkInWorkBuddy(accountId).fold(
|
||||
onSuccess = { s ->
|
||||
when (s.outcome) {
|
||||
WorkBuddyRepository.CheckinOutcome.ALREADY ->
|
||||
// 已签时带上连签天数(接口已回传,别浪费)
|
||||
CheckinState.Done(
|
||||
if (s.streakDays > 0) {
|
||||
UiText.Resource(R.string.checkin_already_streak, listOf(s.streakDays))
|
||||
} else {
|
||||
UiText.Resource(R.string.checkin_already)
|
||||
}
|
||||
)
|
||||
WorkBuddyRepository.CheckinOutcome.SUCCESS -> {
|
||||
val credits = s.todayCredit.takeIf { it > 0 } ?: s.dailyCredit
|
||||
CheckinState.Done(
|
||||
if (credits > 0) {
|
||||
UiText.Resource(R.string.checkin_wb_success_credits, listOf(credits))
|
||||
} else {
|
||||
UiText.Resource(R.string.checkin_success)
|
||||
}
|
||||
)
|
||||
}
|
||||
WorkBuddyRepository.CheckinOutcome.FAILED ->
|
||||
// 失败原因优先透传服务端原文(多为官方中文提示),缺省用通用失败文案
|
||||
CheckinState.Error(
|
||||
if (s.message.isNotBlank()) UiText.Dynamic(s.message)
|
||||
else UiText.Resource(R.string.checkin_failed)
|
||||
)
|
||||
WorkBuddyRepository.CheckinOutcome.NO_CAMPAIGN ->
|
||||
CheckinState.Notice(UiText.Resource(R.string.checkin_wb_no_campaign))
|
||||
// QUERY 不该出现在签到动作结果里,兜底为通用成功
|
||||
WorkBuddyRepository.CheckinOutcome.QUERY ->
|
||||
CheckinState.Done(UiText.Resource(R.string.checkin_success))
|
||||
}
|
||||
},
|
||||
// RepositoryError.message 是硬编码中文,必须走 errorMessage() 本地化映射
|
||||
onFailure = { e -> CheckinState.Error(errorMessage(e)) }
|
||||
)
|
||||
ServiceType.TRAE -> refreshBalanceUseCase.checkInTrae(accountId).fold(
|
||||
onSuccess = { r ->
|
||||
when (r.outcome) {
|
||||
TraeRepository.CheckinOutcome.SUCCESS ->
|
||||
CheckinState.Done(UiText.Resource(R.string.checkin_success))
|
||||
TraeRepository.CheckinOutcome.ALREADY ->
|
||||
CheckinState.Done(UiText.Resource(R.string.checkin_already))
|
||||
// 设备级拦截不是「账号已签」:用 Notice 展示,按钮保持可点(用户可换网/稍后重试)
|
||||
TraeRepository.CheckinOutcome.DEVICE_BLOCKED ->
|
||||
CheckinState.Notice(UiText.Resource(R.string.checkin_trae_device_blocked))
|
||||
// 服务端临时限流(参与用户太多等):提示而非错误,稍后可重试
|
||||
TraeRepository.CheckinOutcome.RETRYABLE ->
|
||||
CheckinState.Notice(
|
||||
if (r.message.isNotBlank()) UiText.Dynamic(r.message)
|
||||
else UiText.Resource(R.string.checkin_trae_retry_later)
|
||||
)
|
||||
TraeRepository.CheckinOutcome.INTL_UNSUPPORTED ->
|
||||
CheckinState.Error(
|
||||
UiText.Resource(R.string.checkin_trae_intl),
|
||||
showDeviceHint = false
|
||||
)
|
||||
TraeRepository.CheckinOutcome.FAILED -> CheckinState.Error(
|
||||
// 服务端原始原因优先;缺省用带 code 的失败文案
|
||||
if (r.message.isNotBlank()) {
|
||||
UiText.Dynamic(r.message)
|
||||
} else if (r.code >= 0) {
|
||||
UiText.Resource(R.string.checkin_trae_failed_code, listOf(r.code))
|
||||
} else {
|
||||
UiText.Resource(R.string.checkin_failed)
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
onFailure = { e -> CheckinState.Error(errorMessage(e)) }
|
||||
)
|
||||
else -> CheckinState.Error(UiText.Resource(R.string.checkin_unsupported))
|
||||
}
|
||||
if (generation == serviceGeneration) {
|
||||
_checkinState.value = result
|
||||
// 签到成功后刷新「今日已签到/连签」摘要,让按钮态即时更新
|
||||
if (result is CheckinState.Done) preloadCheckinInfo(service, accountId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Sub2API 面板管理(Key 列表/新建/分组/兑换/低余额) ----------
|
||||
|
||||
/** Key 列表状态。 */
|
||||
private val _panelKeysState = MutableStateFlow<PanelKeysState>(PanelKeysState.Idle)
|
||||
val panelKeysState: StateFlow<PanelKeysState> = _panelKeysState.asStateFlow()
|
||||
|
||||
/** 管理操作的一次性结果消息(新建/切组/兑换/复制)。 */
|
||||
private val _panelActionMessage = MutableStateFlow<String?>(null)
|
||||
val panelActionMessage: StateFlow<String?> = _panelActionMessage.asStateFlow()
|
||||
|
||||
fun consumePanelActionMessage() { _panelActionMessage.value = null }
|
||||
|
||||
/** 可用分组(新建 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()
|
||||
|
||||
/** 按所选时间窗口拉取的分模型统计(「全部」窗口直接用明细里的全量 model_stats)。 */
|
||||
private val _panelWindowStats = MutableStateFlow<PanelWindowStatsState>(PanelWindowStatsState.Idle)
|
||||
val panelWindowStats: StateFlow<PanelWindowStatsState> = _panelWindowStats.asStateFlow()
|
||||
|
||||
/** 窗口统计请求代次:快速切换窗口时只接受最后一次请求的结果,避免旧窗口数据覆盖新窗口。 */
|
||||
private var panelWindowStatsGeneration = 0
|
||||
|
||||
fun loadPanelWindowModelStats(from: LocalDate, to: LocalDate) {
|
||||
val generation = ++panelWindowStatsGeneration
|
||||
_panelWindowStats.value = PanelWindowStatsState.Loading
|
||||
viewModelScope.launch {
|
||||
val result = sub2ApiRepository.fetchWindowModelStats(from, to, _accountId.value)
|
||||
// 过时结果(用户已切换到别的窗口)直接丢弃,不写状态
|
||||
if (generation != panelWindowStatsGeneration) return@launch
|
||||
result.fold(
|
||||
onSuccess = { r -> _panelWindowStats.value = PanelWindowStatsState.Success(r.stats, r.allTime) },
|
||||
onFailure = { e ->
|
||||
_panelWindowStats.value = PanelWindowStatsState.Error(e.message ?: "加载模型统计失败")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadPanelGroups() {
|
||||
viewModelScope.launch {
|
||||
if (_panelGroups.value.isNotEmpty()) return@launch
|
||||
sub2ApiRepository.fetchPanelGroups(_accountId.value).onSuccess { _panelGroups.value = it }
|
||||
}
|
||||
}
|
||||
|
||||
/** 「今天」按小时趋势(面板 snapshot-v2?granularity=hour)。 */
|
||||
private val _hourlyTrend = MutableStateFlow<List<Sub2DailyUsage>>(emptyList())
|
||||
val hourlyTrend: StateFlow<List<Sub2DailyUsage>> = _hourlyTrend.asStateFlow()
|
||||
|
||||
private var hourlyTrendGeneration = 0
|
||||
|
||||
fun loadHourlyTrend(date: LocalDate) {
|
||||
val generation = ++hourlyTrendGeneration
|
||||
viewModelScope.launch {
|
||||
val result = sub2ApiRepository.fetchHourlyTrend(date, _accountId.value)
|
||||
if (generation != hourlyTrendGeneration) return@launch
|
||||
_hourlyTrend.value = result.getOrDefault(emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
/** 「按 Key」用量状态(面板使用记录口径,按当前时间周期)。 */
|
||||
private val _panelKeyStats = MutableStateFlow<PanelKeyStatsState>(PanelKeyStatsState.Idle)
|
||||
val panelKeyStats: StateFlow<PanelKeyStatsState> = _panelKeyStats.asStateFlow()
|
||||
|
||||
private var panelKeyStatsGeneration = 0
|
||||
|
||||
/** 按所选周期拉取「按 Key」用量。 */
|
||||
fun loadPanelKeyStats(from: LocalDate, to: LocalDate) {
|
||||
val generation = ++panelKeyStatsGeneration
|
||||
_panelKeyStats.value = PanelKeyStatsState.Loading
|
||||
viewModelScope.launch {
|
||||
val result = sub2ApiRepository.fetchKeyUsageStats(from, to, _accountId.value)
|
||||
if (generation != panelKeyStatsGeneration) return@launch
|
||||
result.fold(
|
||||
onSuccess = { list ->
|
||||
_panelKeyStats.value = PanelKeyStatsState.Success(
|
||||
list.map {
|
||||
PanelKeyStatsState.KeyStat(
|
||||
keyId = it.keyId, name = it.name, groupName = it.groupName,
|
||||
requests = it.requests, totalTokens = it.totalTokens,
|
||||
cost = it.cost, unused = it.unused
|
||||
)
|
||||
}
|
||||
)
|
||||
},
|
||||
onFailure = { e -> _panelKeyStats.value = PanelKeyStatsState.Error(e.message ?: "加载按 Key 用量失败") }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 刷新面板 Key 列表(仅 SUB2API 且有面板 Token)。 */
|
||||
fun loadPanelKeys() {
|
||||
viewModelScope.launch {
|
||||
if (_panelKeysState.value is PanelKeysState.Loading) return@launch
|
||||
_panelKeysState.value = PanelKeysState.Loading
|
||||
sub2ApiRepository.fetchPanelKeys(_accountId.value).fold(
|
||||
onSuccess = { list -> _panelKeysState.value = PanelKeysState.Success(list) },
|
||||
onFailure = { e -> _panelKeysState.value = PanelKeysState.Error(e.message ?: "加载 Key 列表失败") }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 最近一次新建成功的明文 Key(弹窗立即复制用;查看后清除)。 */
|
||||
private val _recentlyCreatedKey = MutableStateFlow<String?>(null)
|
||||
val recentlyCreatedKey: StateFlow<String?> = _recentlyCreatedKey.asStateFlow()
|
||||
|
||||
fun consumeRecentlyCreatedKey() { _recentlyCreatedKey.value = null }
|
||||
|
||||
fun createPanelKey(name: String, groupId: Long?) {
|
||||
viewModelScope.launch {
|
||||
sub2ApiRepository.createPanelKey(name, groupId, _accountId.value).fold(
|
||||
onSuccess = { key ->
|
||||
_recentlyCreatedKey.value = key
|
||||
_panelActionMessage.value = "已创建 Key:" + maskPanelKey(key) + "(请在弹出的窗口复制完整 Key)"
|
||||
loadPanelKeys()
|
||||
},
|
||||
onFailure = { e -> _panelActionMessage.value = "创建失败:" + (e.message ?: "") }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun switchPanelGroup(keyId: Long, groupId: Long) {
|
||||
viewModelScope.launch {
|
||||
sub2ApiRepository.updatePanelKey(keyId, groupId, accountId = _accountId.value).fold(
|
||||
onSuccess = {
|
||||
_panelActionMessage.value = "分组已切换"
|
||||
loadPanelKeys()
|
||||
},
|
||||
onFailure = { e -> _panelActionMessage.value = "切换失败:" + (e.message ?: "") }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun redeemPanelCode(code: String) {
|
||||
viewModelScope.launch {
|
||||
sub2ApiRepository.redeemPanelCode(code, _accountId.value).fold(
|
||||
onSuccess = { msg ->
|
||||
_panelActionMessage.value = msg
|
||||
refreshBalanceUseCase(ServiceType.SUB2API, _accountId.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)
|
||||
}
|
||||
|
||||
/** 当前凭据的不可逆 SHA-256 指纹。 */
|
||||
private var currentCredentialFingerprint: String? = null
|
||||
|
||||
@ -408,9 +65,6 @@ class ServiceDetailViewModel @Inject constructor(
|
||||
private const val OCGO_PREFS = "ocgo_trigger_prefs"
|
||||
private const val OLLAMA_PREFS = "ollama_trigger_prefs"
|
||||
|
||||
/** 缓存新鲜度阈值:fetchedAt 距今 ≤5 分钟视为最新数据,不再标记为"缓存"。 */
|
||||
private const val CACHE_FRESH_THRESHOLD_MS = 5 * 60_000L
|
||||
|
||||
internal fun credentialFingerprint(credential: Credential?): String? =
|
||||
CredentialRepository.credentialFingerprint(credential)
|
||||
|
||||
@ -435,72 +89,40 @@ class ServiceDetailViewModel @Inject constructor(
|
||||
else -> CODEX_PREFS
|
||||
}
|
||||
|
||||
fun bind(service: ServiceType, accountId: String? = null) {
|
||||
val effectiveAccountId = accountId?.takeIf { it.isNotBlank() }
|
||||
if (_serviceType.value == service && _accountId.value == effectiveAccountId) return
|
||||
fun bind(service: ServiceType) {
|
||||
if (_serviceType.value == service) return
|
||||
|
||||
serviceGeneration++
|
||||
refreshGeneration++
|
||||
_serviceType.value = service
|
||||
_accountId.value = effectiveAccountId
|
||||
_accounts.value = emptyList()
|
||||
currentCredentialFingerprint = null
|
||||
_models.value = emptyList()
|
||||
_selectedModel.value = null
|
||||
_modelsLoading.value = false
|
||||
_triggerState.value = TriggerState.Idle
|
||||
// 切换服务/账号时重置签到状态,避免上一服务/账号的 Done/Error 残留(按钮态/文案串台)
|
||||
_checkinState.value = CheckinState.Idle
|
||||
_traeUsageEvents.value = TraeUsageEventsState.Idle
|
||||
|
||||
if (service == ServiceType.CODEX || service == ServiceType.OPENCODE_GO || service == ServiceType.OLLAMA) {
|
||||
loadSelectedModel(service)?.let { _selectedModel.value = it }
|
||||
loadModelsCache(service).takeIf { it.isNotEmpty() }?.let { _models.value = it }
|
||||
}
|
||||
loadAccounts(service)
|
||||
preloadCheckinInfo(service, effectiveAccountId)
|
||||
loadFromCache()
|
||||
}
|
||||
|
||||
/** 加载多账号服务的账号列表(账号管理页返回后可再次调用刷新)。 */
|
||||
fun loadAccounts(service: ServiceType) {
|
||||
val serviceGen = serviceGeneration
|
||||
viewModelScope.launch {
|
||||
val accounts = credentialRepository.accountsFor(service)
|
||||
if (serviceGen == serviceGeneration && _serviceType.value == service) {
|
||||
_accounts.value = accounts
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回页面时同步凭据和缓存。凭据未变时保留错误/Loading,但按 fetchedAt 采用
|
||||
* Widget 或其他入口写入的更新缓存;凭据新增或替换时废弃旧状态并重新刷新。
|
||||
*/
|
||||
fun reloadCredentialState() {
|
||||
val type = _serviceType.value ?: return
|
||||
val accountId = _accountId.value
|
||||
val serviceGen = serviceGeneration
|
||||
// 账号列表可能因账号管理页新增/删除/设默认而变化,一并刷新
|
||||
loadAccounts(type)
|
||||
viewModelScope.launch {
|
||||
val local = credentialRepository.readLocalState(type, accountId)
|
||||
val local = credentialRepository.readLocalState(type)
|
||||
val newFingerprint = local.fingerprint
|
||||
val change = classifyCredentialChange(currentCredentialFingerprint, newFingerprint)
|
||||
val newHasCredential = local.status.state != CredentialStatus.State.NOT_CONFIGURED
|
||||
|
||||
if (serviceGen != serviceGeneration || _serviceType.value != type) return@launch
|
||||
currentCredentialFingerprint = newFingerprint
|
||||
val currentCredential = credentialRepository.get(type, accountId)
|
||||
val currentRegion = when (currentCredential) {
|
||||
is Credential.TraeCredential -> currentCredential.region.uppercase()
|
||||
is Credential.WorkBuddyCredential -> currentCredential.region.uppercase()
|
||||
else -> null
|
||||
}
|
||||
_uiState.update { it.copy(region = currentRegion) }
|
||||
if (type == ServiceType.TRAE && newHasCredential) {
|
||||
preloadUsageEvents(type, accountId)
|
||||
}
|
||||
|
||||
when (change) {
|
||||
CredentialChange.NONE_TO_NONE, CredentialChange.UNCHANGED -> {
|
||||
@ -544,11 +166,10 @@ class ServiceDetailViewModel @Inject constructor(
|
||||
|
||||
fun refresh() {
|
||||
val type = _serviceType.value ?: return
|
||||
val accountId = _accountId.value
|
||||
val serviceGen = serviceGeneration
|
||||
val gen = ++refreshGeneration
|
||||
viewModelScope.launch {
|
||||
val requestFingerprint = credentialRepository.readLocalState(type, accountId).fingerprint
|
||||
val requestFingerprint = credentialRepository.readLocalState(type).fingerprint
|
||||
if (isStaleRequest(type, serviceGen, gen)) return@launch
|
||||
_uiState.update { it.copy(state = State.Loading) }
|
||||
|
||||
@ -569,10 +190,10 @@ class ServiceDetailViewModel @Inject constructor(
|
||||
return@launch
|
||||
}
|
||||
|
||||
val result = refreshBalanceUseCase(type, accountId)
|
||||
val result = refreshBalanceUseCase(type)
|
||||
if (isStaleRequest(type, serviceGen, gen)) return@launch
|
||||
|
||||
val local = credentialRepository.readLocalState(type, accountId)
|
||||
val local = credentialRepository.readLocalState(type)
|
||||
if (isStaleRequest(type, serviceGen, gen)) return@launch
|
||||
val fingerprintChanged = local.fingerprint != requestFingerprint
|
||||
|
||||
@ -630,9 +251,6 @@ class ServiceDetailViewModel @Inject constructor(
|
||||
)
|
||||
}
|
||||
}
|
||||
if (type == ServiceType.TRAE && _uiState.value.region.equals("INTL", ignoreCase = true)) {
|
||||
preloadUsageEvents(type, accountId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -765,7 +383,6 @@ class ServiceDetailViewModel @Inject constructor(
|
||||
|
||||
fun saveManualBalance(amount: Double) {
|
||||
val type = _serviceType.value ?: return
|
||||
val accountId = _accountId.value
|
||||
val serviceGen = serviceGeneration
|
||||
viewModelScope.launch {
|
||||
val config = com.rainy.token.domain.service.ServiceConfigProvider.get(type)
|
||||
@ -775,8 +392,8 @@ class ServiceDetailViewModel @Inject constructor(
|
||||
unit = config.displayUnit,
|
||||
isAvailable = true
|
||||
)
|
||||
balanceCache.put(type, balance, accountId)
|
||||
val cached = balanceCache.get(type, accountId)
|
||||
balanceCache.put(type, balance)
|
||||
val cached = balanceCache.get(type)
|
||||
if (serviceGen == serviceGeneration && _serviceType.value == type) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
@ -790,9 +407,8 @@ class ServiceDetailViewModel @Inject constructor(
|
||||
|
||||
fun markVerified() {
|
||||
val type = _serviceType.value ?: return
|
||||
val accountId = _accountId.value
|
||||
viewModelScope.launch {
|
||||
val credential = credentialRepository.get(type, accountId) ?: return@launch
|
||||
val credential = credentialRepository.get(type) ?: return@launch
|
||||
val updated = when (credential) {
|
||||
is Credential.ApiKeyCredential ->
|
||||
credential.copy(lastVerifiedAt = System.currentTimeMillis())
|
||||
@ -800,24 +416,17 @@ class ServiceDetailViewModel @Inject constructor(
|
||||
credential.copy(lastVerifiedAt = System.currentTimeMillis())
|
||||
is Credential.CodexCredential ->
|
||||
credential.copy(lastVerifiedAt = System.currentTimeMillis())
|
||||
is Credential.TraeCredential ->
|
||||
credential.copy(lastVerifiedAt = System.currentTimeMillis())
|
||||
is Credential.WorkBuddyCredential ->
|
||||
credential.copy(lastVerifiedAt = System.currentTimeMillis())
|
||||
is Credential.Sub2ApiCredential ->
|
||||
credential.copy(lastVerifiedAt = System.currentTimeMillis())
|
||||
}
|
||||
credentialRepository.save(updated, accountId)
|
||||
credentialRepository.save(updated)
|
||||
loadFromCache()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadFromCache() {
|
||||
val type = _serviceType.value ?: return
|
||||
val accountId = _accountId.value
|
||||
val serviceGen = serviceGeneration
|
||||
viewModelScope.launch {
|
||||
val local = credentialRepository.readLocalState(type, accountId)
|
||||
val local = credentialRepository.readLocalState(type)
|
||||
val status = local.status
|
||||
val cached = local.cachedBalance
|
||||
val config = com.rainy.token.domain.service.ServiceConfigProvider.get(type)
|
||||
@ -831,16 +440,8 @@ class ServiceDetailViewModel @Inject constructor(
|
||||
UiText.Resource(R.string.error_credential_not_configured),
|
||||
RepositoryError.InvalidCredential()
|
||||
)
|
||||
// OK 与 WARNING 都视为「凭据可用」:WARNING 仅是提示建议重新验证(如面板登录较久),
|
||||
// 凭据仍有效,不应误报为「未配置或已过期」——否则能正常刷新余额却显示错误。
|
||||
cached != null && (status.state == CredentialStatus.State.OK ||
|
||||
status.state == CredentialStatus.State.WARNING) ->
|
||||
// 缓存新鲜(≤5 分钟)时视为最新数据直接展示,避免"刚刷新完进详情却显示缓存"的困惑
|
||||
if (isCacheFresh(cached.fetchedAt)) {
|
||||
State.Fresh(cached.balance)
|
||||
} else {
|
||||
cached != null && status.state == CredentialStatus.State.OK ->
|
||||
State.Stale(cached.balance, cached.fetchedAt)
|
||||
}
|
||||
cached != null ->
|
||||
State.Error(
|
||||
cached.balance,
|
||||
@ -859,11 +460,7 @@ class ServiceDetailViewModel @Inject constructor(
|
||||
state = newState
|
||||
)
|
||||
}
|
||||
// 无缓存或缓存已过期(>5 分钟)时自动刷新一次;
|
||||
// 缓存新鲜时直接展示,避免每次进详情页都发网络请求
|
||||
if (!isManual && status.state != CredentialStatus.State.NOT_CONFIGURED &&
|
||||
(cached == null || !isCacheFresh(cached.fetchedAt))
|
||||
) {
|
||||
if (!isManual && status.state != CredentialStatus.State.NOT_CONFIGURED && cached == null) {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
@ -889,19 +486,10 @@ class ServiceDetailViewModel @Inject constructor(
|
||||
cacheAdvanced && current.state is State.Fresh ->
|
||||
State.Fresh(mergedCache!!.balance)
|
||||
cacheAdvanced && current.state is State.Stale ->
|
||||
if (isCacheFresh(mergedCache!!.fetchedAt)) {
|
||||
State.Fresh(mergedCache.balance)
|
||||
} else {
|
||||
State.Stale(mergedCache.balance, mergedCache.fetchedAt)
|
||||
}
|
||||
State.Stale(mergedCache!!.balance, mergedCache.fetchedAt)
|
||||
current.state is State.Fresh -> current.state
|
||||
current.state is State.Stale -> current.state
|
||||
mergedCache != null ->
|
||||
if (isCacheFresh(mergedCache.fetchedAt)) {
|
||||
State.Fresh(mergedCache.balance)
|
||||
} else {
|
||||
State.Stale(mergedCache.balance, mergedCache.fetchedAt)
|
||||
}
|
||||
mergedCache != null -> State.Stale(mergedCache.balance, mergedCache.fetchedAt)
|
||||
else -> current.state
|
||||
}
|
||||
return current.copy(
|
||||
@ -926,18 +514,12 @@ class ServiceDetailViewModel @Inject constructor(
|
||||
current: CachedBalance?
|
||||
): Boolean = candidate != null && (current == null || candidate.fetchedAt > current.fetchedAt)
|
||||
|
||||
/** 缓存是否仍算"最新":fetchedAt 距今 ≤5 分钟(与 Dashboard 底部"刚刚更新"感知一致)。 */
|
||||
private fun isCacheFresh(fetchedAt: Long): Boolean =
|
||||
System.currentTimeMillis() - fetchedAt <= CACHE_FRESH_THRESHOLD_MS
|
||||
|
||||
private fun isStaleRequest(type: ServiceType, serviceGen: Int, refreshGen: Int): Boolean =
|
||||
refreshGen != refreshGeneration ||
|
||||
serviceGen != serviceGeneration ||
|
||||
_serviceType.value != type
|
||||
|
||||
private fun errorMessage(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.CredentialChanged ->
|
||||
@ -991,58 +573,6 @@ class ServiceDetailViewModel @Inject constructor(
|
||||
?: emptyList()
|
||||
}
|
||||
|
||||
/** 每日签到 UI 状态。 */
|
||||
/** 每日签到 UI 状态。消息用 [UiText] 承载,由 UI 层按当前 Locale 解析(支持多语言)。 */
|
||||
sealed class CheckinState {
|
||||
data object Idle : CheckinState()
|
||||
data object Loading : CheckinState()
|
||||
data class Done(val message: UiText) : CheckinState()
|
||||
|
||||
/**
|
||||
* @param showDeviceHint INTL 区接口不支持与设备无关,false 时不追加「设备 ID 获取路径」提示。
|
||||
*/
|
||||
data class Error(val message: UiText, val showDeviceHint: Boolean = true) : CheckinState()
|
||||
|
||||
/**
|
||||
* 提示态(非错误、也非完成):例如 Trae 设备级拦截「该设备今日签到次数已达上限」。
|
||||
* 与 [Done] 的区别是**不置灰按钮**——账号本身尚未签到,换网络/稍后可重试。
|
||||
*/
|
||||
data class Notice(val message: UiText) : CheckinState()
|
||||
}
|
||||
|
||||
/** Sub2API 面板 Key 列表状态。 */
|
||||
sealed class PanelKeysState {
|
||||
data object Idle : PanelKeysState()
|
||||
data object Loading : PanelKeysState()
|
||||
data class Success(val keys: List<com.rainy.token.data.repository.Sub2ApiRepository.PanelApiKey>) : PanelKeysState()
|
||||
data class Error(val message: String) : PanelKeysState()
|
||||
}
|
||||
|
||||
/** Sub2API 按所选时间窗口拉取的分模型统计状态。 */
|
||||
sealed class PanelWindowStatsState {
|
||||
data object Idle : PanelWindowStatsState()
|
||||
data object Loading : PanelWindowStatsState()
|
||||
/** @param allTime 数据为历史累计口径(下游接口无法按窗口过滤),UI 需显式标注 */
|
||||
data class Success(
|
||||
val stats: List<com.rainy.token.domain.model.Sub2ModelStat>,
|
||||
val allTime: Boolean = false
|
||||
) : PanelWindowStatsState()
|
||||
data class Error(val message: String) : PanelWindowStatsState()
|
||||
}
|
||||
|
||||
/** Sub2API「按 Key」用量状态(按当前时间周期查询面板使用记录)。 */
|
||||
sealed class PanelKeyStatsState {
|
||||
data object Idle : PanelKeyStatsState()
|
||||
data object Loading : PanelKeyStatsState()
|
||||
data class Success(val stats: List<KeyStat>) : PanelKeyStatsState()
|
||||
data class Error(val message: String) : PanelKeyStatsState()
|
||||
|
||||
data class KeyStat(
|
||||
val keyId: Long, val name: String, val groupName: String?,
|
||||
val requests: Long, val totalTokens: Long, val cost: Double, val unused: Boolean
|
||||
)
|
||||
}
|
||||
|
||||
sealed class State {
|
||||
data object Loading : State()
|
||||
data class Fresh(val data: ServiceBalance) : State()
|
||||
@ -1055,20 +585,10 @@ sealed class State {
|
||||
data object ManualModeHint : State()
|
||||
}
|
||||
|
||||
/** Trae 国际版 Usage Events 加载状态。 */
|
||||
sealed class TraeUsageEventsState {
|
||||
data object Idle : TraeUsageEventsState()
|
||||
data object Loading : TraeUsageEventsState()
|
||||
data class Success(val events: List<TraeUsageEvent>) : TraeUsageEventsState()
|
||||
data class Error(val message: UiText) : TraeUsageEventsState()
|
||||
}
|
||||
|
||||
data class ServiceDetailUiState(
|
||||
val state: State = State.Loading,
|
||||
val hasCredential: Boolean = false,
|
||||
val cached: CachedBalance? = null,
|
||||
/** 当前账号区域(Trae/WorkBuddy):CN / INTL;其他服务为 null。 */
|
||||
val region: String? = null
|
||||
val cached: CachedBalance? = null
|
||||
)
|
||||
|
||||
sealed class TriggerState {
|
||||
|
||||
@ -1,503 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@ -1,713 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@ -1,365 +0,0 @@
|
||||
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
|
||||
@ -1,112 +0,0 @@
|
||||
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
|
||||
)
|
||||
@ -5,22 +5,18 @@ import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
@ -31,9 +27,6 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.Snackbar
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
@ -44,18 +37,8 @@ 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.setValue
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import com.rainy.token.ui.components.SiteIcon
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||
import kotlinx.coroutines.delay
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@ -80,18 +63,13 @@ import com.rainy.token.domain.service.ServiceType
|
||||
@Composable
|
||||
fun CredentialEditScreen(
|
||||
service: ServiceType,
|
||||
accountId: String? = null,
|
||||
onBack: () -> Unit,
|
||||
onStartWebViewLogin: (ServiceType, String?, String?) -> Unit,
|
||||
onStartWebViewLogin: (ServiceType) -> Unit,
|
||||
onWebViewLoginSuccess: (ServiceType) -> Unit,
|
||||
onStartCodexOAuth: () -> Unit = {},
|
||||
viewModel: CredentialEditViewModel = hiltViewModel()
|
||||
) {
|
||||
LaunchedEffect(service, accountId) { viewModel.bind(service, accountId) }
|
||||
// 从 WebView 登录页返回时,登录流程可能已改写当前账号凭据,重载表单
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
|
||||
viewModel.reload()
|
||||
}
|
||||
LaunchedEffect(service) { viewModel.bind(service) }
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val context = LocalContext.current
|
||||
@ -107,23 +85,10 @@ fun CredentialEditScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// 多账号服务:标题带账号名(新建账号尚无 ID 时也提示是「新账号」)
|
||||
val titleText = when {
|
||||
ServiceType.isMultiAccount(service) && uiState.accountId != null ->
|
||||
stringResource(
|
||||
R.string.service_detail_title_with_account,
|
||||
service.displayName,
|
||||
uiState.accountLabel ?: stringResource(R.string.account_fallback_name, uiState.accountIndex)
|
||||
)
|
||||
ServiceType.isMultiAccount(service) && !uiState.hasExisting ->
|
||||
stringResource(R.string.account_new_credential_title, service.displayName)
|
||||
else -> service.displayName
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(titleText) },
|
||||
title = { Text(service.displayName) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
|
||||
@ -150,84 +115,7 @@ fun CredentialEditScreen(
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
if (service == ServiceType.TRAE) {
|
||||
TraeCredentialForm(
|
||||
jwt = uiState.traeJwt,
|
||||
callbackUrl = uiState.traeCallbackUrl,
|
||||
region = uiState.traeRegion,
|
||||
checkinDeviceId = uiState.traeCheckinDeviceId,
|
||||
hasExisting = uiState.hasExisting,
|
||||
onJwtChange = viewModel::updateTraeJwt,
|
||||
onCallbackUrlChange = viewModel::updateTraeCallbackUrl,
|
||||
onImportCallback = { viewModel.importTraeCallbackFromUrl(it) },
|
||||
onRegionChange = viewModel::updateTraeRegion,
|
||||
onCheckinDeviceIdChange = viewModel::updateTraeCheckinDeviceId,
|
||||
onStartInAppLogin = { onStartWebViewLogin(ServiceType.TRAE, accountId, uiState.traeRegion) },
|
||||
onRecharge = {
|
||||
val url = if (uiState.traeRegion.uppercase() == "INTL") {
|
||||
"https://www.trae.ai/pricing"
|
||||
} else {
|
||||
"https://www.trae.cn/subscription"
|
||||
}
|
||||
runCatching {
|
||||
context.startActivity(
|
||||
android.content.Intent(
|
||||
android.content.Intent.ACTION_VIEW,
|
||||
android.net.Uri.parse(url)
|
||||
).addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
)
|
||||
}
|
||||
},
|
||||
onSave = viewModel::saveTraeCredential
|
||||
)
|
||||
} else if (service == ServiceType.WORKBUDDY) {
|
||||
WorkBuddyCredentialForm(
|
||||
access = uiState.workBuddyAccess,
|
||||
refresh = uiState.workBuddyRefresh,
|
||||
hasExisting = uiState.hasExisting,
|
||||
onAccessChange = viewModel::updateWorkBuddyAccess,
|
||||
onRefreshChange = viewModel::updateWorkBuddyRefresh,
|
||||
region = uiState.workBuddyRegion,
|
||||
onRegionChange = viewModel::updateWorkBuddyRegion,
|
||||
onStartInAppLogin = { onStartWebViewLogin(ServiceType.WORKBUDDY, accountId, uiState.workBuddyRegion) },
|
||||
onSave = viewModel::saveWorkBuddyCredential
|
||||
)
|
||||
} else if (service == ServiceType.SUB2API) {
|
||||
var showPanelLogin by remember { mutableStateOf(false) }
|
||||
Sub2ApiCredentialForm(
|
||||
baseUrl = uiState.sub2BaseUrl,
|
||||
email = uiState.sub2Email,
|
||||
password = uiState.sub2Password,
|
||||
apiKey = uiState.sub2ApiKey,
|
||||
hasExisting = uiState.hasExisting,
|
||||
hasPanelToken = uiState.sub2PanelToken != null,
|
||||
siteName = uiState.sub2SiteName,
|
||||
siteIcon = uiState.sub2SiteIcon,
|
||||
siteLoading = uiState.sub2SiteLoading,
|
||||
loggingIn = uiState.sub2LoggingIn,
|
||||
loginMode = uiState.sub2LoginMode,
|
||||
totp = uiState.sub2Totp,
|
||||
onBaseUrlChange = viewModel::updateSub2BaseUrl,
|
||||
onEmailChange = viewModel::updateSub2Email,
|
||||
onPasswordChange = viewModel::updateSub2Password,
|
||||
onApiKeyChange = viewModel::updateSub2ApiKey,
|
||||
onLoginModeChange = viewModel::updateSub2LoginMode,
|
||||
onTotpChange = viewModel::updateSub2Totp,
|
||||
onPanelLogin = { showPanelLogin = true },
|
||||
onLoginPanel = viewModel::loginPanelOneClick,
|
||||
onSave = viewModel::saveSub2ApiCredential
|
||||
)
|
||||
if (showPanelLogin && uiState.sub2BaseUrl.isNotBlank()) {
|
||||
PanelLoginDialog(
|
||||
baseUrl = uiState.sub2BaseUrl.trim(),
|
||||
onToken = { token ->
|
||||
viewModel.onPanelTokenAcquired(token)
|
||||
showPanelLogin = false
|
||||
},
|
||||
onDismiss = { showPanelLogin = false }
|
||||
)
|
||||
}
|
||||
} else if (uiState.isApiKeyService) {
|
||||
if (uiState.isApiKeyService) {
|
||||
if (service == ServiceType.COMMANDCODE_GO) {
|
||||
CommandCodeGoForm(
|
||||
apiKey = uiState.apiKey,
|
||||
@ -258,7 +146,7 @@ fun CredentialEditScreen(
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Ollama:用户粘贴完整 Cookie 字符串
|
||||
// Ollama Pro:用户粘贴完整 Cookie 字符串
|
||||
if (service == ServiceType.OLLAMA) {
|
||||
OllamaCookieForm(
|
||||
cookie = uiState.ollamaCookie,
|
||||
@ -287,7 +175,6 @@ fun CredentialEditScreen(
|
||||
onImportFromClipboard = { viewModel.importFromClipboard(context) },
|
||||
onCopyLoginUrl = { copyToClipboard(context, uiState.loginUrl) },
|
||||
onOpenLoginUrl = { openInBrowser(context, uiState.loginUrl) },
|
||||
onStartInAppLogin = { onStartWebViewLogin(ServiceType.OPENCODE_GO, accountId, null) },
|
||||
onShowHelp = { showGoHelp = true },
|
||||
onApiKeyChange = viewModel::updateTriggerApiKey,
|
||||
onSaveApiKey = { viewModel.saveTriggerApiKey() }
|
||||
@ -451,7 +338,7 @@ private fun OllamaCookieForm(
|
||||
OutlinedTextField(
|
||||
value = cookie,
|
||||
onValueChange = onCookieChange,
|
||||
label = { Text(stringResource(R.string.field_cookie_string)) },
|
||||
label = { Text("Cookie") },
|
||||
placeholder = { Text("aid=xxx; __Secure-session=yyy") },
|
||||
singleLine = false,
|
||||
minLines = 2,
|
||||
@ -501,7 +388,7 @@ private fun OllamaCookieForm(
|
||||
OutlinedTextField(
|
||||
value = triggerApiKey,
|
||||
onValueChange = onApiKeyChange,
|
||||
label = { Text(stringResource(R.string.field_api_key)) },
|
||||
label = { Text("API Key") },
|
||||
placeholder = { Text(stringResource(R.string.placeholder_ollama_api_key)) },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
@ -531,7 +418,6 @@ private fun OpenCodeGoForm(
|
||||
onImportFromClipboard: () -> Unit,
|
||||
onCopyLoginUrl: () -> Unit,
|
||||
onOpenLoginUrl: () -> Unit,
|
||||
onStartInAppLogin: () -> Unit,
|
||||
onShowHelp: () -> Unit,
|
||||
onApiKeyChange: (String) -> Unit,
|
||||
onSaveApiKey: () -> Unit
|
||||
@ -542,17 +428,13 @@ private fun OpenCodeGoForm(
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
// 主推入口:应用内登录自动抓 auth cookie + workspaceId,免手动开 DevTools 手抄
|
||||
OutlinedButton(onClick = onStartInAppLogin, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(text = stringResource(R.string.action_login_in_app))
|
||||
}
|
||||
OutlinedButton(onClick = onImportFromClipboard, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(text = stringResource(R.string.action_import_from_clipboard))
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = workspaceId,
|
||||
onValueChange = onWorkspaceIdChange,
|
||||
label = { Text(stringResource(R.string.field_workspace_id)) },
|
||||
label = { Text("Workspace ID") },
|
||||
placeholder = { Text(stringResource(R.string.placeholder_workspace_id)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
@ -608,7 +490,7 @@ private fun OpenCodeGoForm(
|
||||
OutlinedTextField(
|
||||
value = triggerApiKey,
|
||||
onValueChange = onApiKeyChange,
|
||||
label = { Text(stringResource(R.string.field_api_key)) },
|
||||
label = { Text("API Key") },
|
||||
placeholder = { Text("opencode-xxx") },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
@ -644,7 +526,7 @@ private fun CommandCodeGoForm(
|
||||
OutlinedTextField(
|
||||
value = apiKey,
|
||||
onValueChange = onApiKeyChange,
|
||||
label = { Text(stringResource(R.string.field_api_key)) },
|
||||
label = { Text("API Key") },
|
||||
placeholder = { Text(stringResource(R.string.placeholder_api_key_from_settings)) },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
@ -691,7 +573,7 @@ private fun ApiKeyForm(
|
||||
OutlinedTextField(
|
||||
value = apiKey,
|
||||
onValueChange = onApiKeyChange,
|
||||
label = { Text(stringResource(R.string.field_api_key)) },
|
||||
label = { Text("API Key") },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
|
||||
@ -794,476 +676,6 @@ private fun ManualCookieForm(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TraeCredentialForm(
|
||||
jwt: String,
|
||||
callbackUrl: String,
|
||||
region: String,
|
||||
checkinDeviceId: String,
|
||||
hasExisting: Boolean,
|
||||
onJwtChange: (String) -> Unit,
|
||||
onCallbackUrlChange: (String) -> Unit,
|
||||
onImportCallback: (String) -> Unit,
|
||||
onRegionChange: (String) -> Unit,
|
||||
onCheckinDeviceIdChange: (String) -> Unit,
|
||||
onStartInAppLogin: () -> Unit,
|
||||
onRecharge: () -> Unit,
|
||||
onSave: () -> Unit
|
||||
) {
|
||||
Text(text = stringResource(R.string.credential_title_trae), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
text = stringResource(R.string.credential_hint_trae),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
if (region.uppercase() != "INTL") {
|
||||
OutlinedButton(
|
||||
onClick = onStartInAppLogin,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(stringResource(R.string.action_login_in_app))
|
||||
}
|
||||
}
|
||||
if (region.uppercase() == "INTL") {
|
||||
OutlinedButton(
|
||||
onClick = onStartInAppLogin,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(stringResource(R.string.action_mobile_browser_login))
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = callbackUrl,
|
||||
onValueChange = onCallbackUrlChange,
|
||||
label = { Text(stringResource(R.string.field_trae_callback_url)) },
|
||||
supportingText = { Text(stringResource(R.string.field_trae_callback_hint)) },
|
||||
minLines = 1,
|
||||
maxLines = 3,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
OutlinedButton(
|
||||
onClick = { onImportCallback(callbackUrl) },
|
||||
enabled = callbackUrl.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(stringResource(R.string.action_parse_callback))
|
||||
}
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = jwt,
|
||||
onValueChange = onJwtChange,
|
||||
label = { Text(stringResource(R.string.field_trae_jwt)) },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.field_trae_region),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
RegionButton(
|
||||
label = stringResource(R.string.option_region_cn),
|
||||
selected = region.uppercase() != "INTL",
|
||||
onSelect = { onRegionChange("CN") }
|
||||
)
|
||||
RegionButton(
|
||||
label = stringResource(R.string.option_region_intl),
|
||||
selected = region.uppercase() == "INTL",
|
||||
onSelect = { onRegionChange("INTL") }
|
||||
)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = onRecharge,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(stringResource(R.string.action_trae_recharge))
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = checkinDeviceId,
|
||||
onValueChange = onCheckinDeviceIdChange,
|
||||
label = { Text(stringResource(R.string.field_trae_checkin_device_id)) },
|
||||
supportingText = { Text(stringResource(R.string.field_trae_checkin_device_hint)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
var showDeviceIdGuide by remember { mutableStateOf(false) }
|
||||
TextButton(onClick = { showDeviceIdGuide = true }) {
|
||||
Text(stringResource(R.string.field_trae_device_id_howto))
|
||||
}
|
||||
if (showDeviceIdGuide) {
|
||||
TraeDeviceIdGuideDialog(onDismiss = { showDeviceIdGuide = false })
|
||||
}
|
||||
Button(onClick = onSave, enabled = jwt.isNotBlank(), modifier = Modifier.fillMaxWidth()) {
|
||||
Text(if (hasExisting) stringResource(R.string.action_update) else stringResource(R.string.action_save))
|
||||
}
|
||||
}
|
||||
|
||||
/** 设备 ID 获取分步指引弹窗:面向不熟悉电脑操作的用户,含一键复制日志目录路径。 */
|
||||
@Composable
|
||||
private fun TraeDeviceIdGuideDialog(onDismiss: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
// 环境变量形式:Win+R 与资源管理器地址栏都能展开 %APPDATA%,相对路径在两处都打不开
|
||||
val logPath = "%APPDATA%\\Trae CN\\logs"
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.field_trae_device_id_howto_title)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(
|
||||
stringResource(R.string.field_trae_device_id_howto_step1),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
// 路径 + 一键复制(小白直接粘到资源管理器地址栏)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
logPath,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.weight(1f, fill = true)
|
||||
)
|
||||
TextButton(onClick = {
|
||||
val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
cm.setPrimaryClip(ClipData.newPlainText("trae_log_path", logPath))
|
||||
// 复制反馈:小白需要确认复制成功(R.string 引用须在 composable 内解析)
|
||||
android.widget.Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.msg_path_copied),
|
||||
android.widget.Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}) {
|
||||
Text(stringResource(R.string.action_copy))
|
||||
}
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.field_trae_device_id_howto_step2),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.field_trae_device_id_howto_step3),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.field_trae_device_id_howto_step4),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_close)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RegionButton(label: String, selected: Boolean, onSelect: () -> Unit) {
|
||||
OutlinedButton(onClick = onSelect) {
|
||||
Text(
|
||||
text = label,
|
||||
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WorkBuddyCredentialForm(
|
||||
access: String,
|
||||
refresh: String,
|
||||
region: String,
|
||||
hasExisting: Boolean,
|
||||
onAccessChange: (String) -> Unit,
|
||||
onRefreshChange: (String) -> Unit,
|
||||
onRegionChange: (String) -> Unit,
|
||||
onStartInAppLogin: () -> Unit,
|
||||
onSave: () -> Unit
|
||||
) {
|
||||
Text(text = stringResource(R.string.credential_title_workbuddy), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
text = stringResource(R.string.credential_hint_workbuddy),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
if (region.uppercase() == "INTL") {
|
||||
OutlinedButton(
|
||||
onClick = onStartInAppLogin,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(stringResource(R.string.action_mobile_browser_login))
|
||||
}
|
||||
} else {
|
||||
OutlinedButton(
|
||||
onClick = onStartInAppLogin,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(stringResource(R.string.action_login_in_app))
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.field_trae_region),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
RegionButton(
|
||||
label = stringResource(R.string.option_region_cn),
|
||||
selected = region.uppercase() != "INTL",
|
||||
onSelect = { onRegionChange("CN") }
|
||||
)
|
||||
RegionButton(
|
||||
label = stringResource(R.string.option_region_intl),
|
||||
selected = region.uppercase() == "INTL",
|
||||
onSelect = { onRegionChange("INTL") }
|
||||
)
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = access,
|
||||
onValueChange = onAccessChange,
|
||||
label = { Text(stringResource(R.string.field_workbuddy_access)) },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = refresh,
|
||||
onValueChange = onRefreshChange,
|
||||
label = { Text(stringResource(R.string.field_workbuddy_refresh)) },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Button(onClick = onSave, enabled = access.isNotBlank() && refresh.isNotBlank(), modifier = Modifier.fillMaxWidth()) {
|
||||
Text(if (hasExisting) stringResource(R.string.action_update) else stringResource(R.string.action_save))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Sub2ApiCredentialForm(
|
||||
baseUrl: String,
|
||||
email: String,
|
||||
password: String,
|
||||
apiKey: String,
|
||||
hasExisting: Boolean,
|
||||
hasPanelToken: Boolean = false,
|
||||
siteName: String? = null,
|
||||
siteIcon: String? = null,
|
||||
siteLoading: Boolean = false,
|
||||
loggingIn: Boolean = false,
|
||||
loginMode: String = "account",
|
||||
totp: String = "",
|
||||
onBaseUrlChange: (String) -> Unit,
|
||||
onEmailChange: (String) -> Unit,
|
||||
onPasswordChange: (String) -> Unit,
|
||||
onApiKeyChange: (String) -> Unit,
|
||||
onLoginModeChange: (String) -> Unit = {},
|
||||
onTotpChange: (String) -> Unit = {},
|
||||
onPanelLogin: () -> Unit = {},
|
||||
onLoginPanel: () -> Unit = {},
|
||||
onSave: () -> Unit
|
||||
) {
|
||||
Text(text = stringResource(R.string.credential_title_sub2api), style = MaterialTheme.typography.titleMedium)
|
||||
OutlinedTextField(
|
||||
value = baseUrl,
|
||||
onValueChange = onBaseUrlChange,
|
||||
label = { Text(stringResource(R.string.field_sub2_base_url)) },
|
||||
placeholder = { Text(stringResource(R.string.placeholder_sub2_base_url)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
// 站点识别预览:Logo + 名称(确认地址填对;探测中/失败静默隐藏)
|
||||
if (siteLoading || siteName != null) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 4.dp)
|
||||
) {
|
||||
if (siteIcon != null) {
|
||||
SiteIcon(url = siteIcon, size = 16.dp)
|
||||
Spacer(modifier = Modifier.size(6.dp))
|
||||
}
|
||||
Text(
|
||||
text = siteName ?: stringResource(R.string.sub2_site_probing),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
}
|
||||
// 登录方式:账号密码(优先)| API Key
|
||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) {
|
||||
SegmentedButton(
|
||||
selected = loginMode == "account",
|
||||
onClick = { onLoginModeChange("account") },
|
||||
shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2)
|
||||
) {
|
||||
Text(stringResource(R.string.sub2_mode_account))
|
||||
}
|
||||
SegmentedButton(
|
||||
selected = loginMode == "apikey",
|
||||
onClick = { onLoginModeChange("apikey") },
|
||||
shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2)
|
||||
) {
|
||||
Text(stringResource(R.string.sub2_mode_apikey))
|
||||
}
|
||||
}
|
||||
if (loginMode == "account") {
|
||||
OutlinedTextField(
|
||||
value = email,
|
||||
onValueChange = onEmailChange,
|
||||
label = { Text(stringResource(R.string.field_sub2_email)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = onPasswordChange,
|
||||
label = { Text(stringResource(R.string.field_sub2_password)) },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = totp,
|
||||
onValueChange = onTotpChange,
|
||||
label = { Text(stringResource(R.string.sub2_field_totp)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
OutlinedButton(
|
||||
onClick = onLoginPanel,
|
||||
enabled = baseUrl.isNotBlank() && email.isNotBlank() && password.isNotBlank() && !loggingIn,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = if (loggingIn) stringResource(R.string.sub2_panel_logining)
|
||||
else if (hasPanelToken) stringResource(R.string.sub2_panel_login_done_short)
|
||||
else stringResource(R.string.sub2_panel_oneclick)
|
||||
)
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
TextButton(onClick = onPanelLogin, enabled = baseUrl.isNotBlank()) {
|
||||
Text(stringResource(R.string.sub2_panel_login))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
OutlinedTextField(
|
||||
value = apiKey,
|
||||
onValueChange = onApiKeyChange,
|
||||
label = { Text(stringResource(R.string.field_sub2_api_key)) },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.sub2_mode_api_key_desc),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = onSave,
|
||||
enabled = baseUrl.isNotBlank() &&
|
||||
(apiKey.isNotBlank() || (email.isNotBlank() && password.isNotBlank()) || hasPanelToken),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(if (hasExisting) stringResource(R.string.action_update) else stringResource(R.string.action_save))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sub2 面板 WebView 登录:打开实例登录页,用户正常登录后自动读取 localStorage.auth_token
|
||||
* (evaluateJavascript 轮询),回填凭据。Token 全程不出设备;也可不登录直接关闭继续手动填写。
|
||||
*/
|
||||
@Composable
|
||||
private fun PanelLoginDialog(
|
||||
baseUrl: String,
|
||||
onToken: (String) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
var webViewRef by remember { mutableStateOf<android.webkit.WebView?>(null) }
|
||||
var detected by remember { mutableStateOf(false) }
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false)
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
android.webkit.WebView(ctx).apply {
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
webViewClient = android.webkit.WebViewClient()
|
||||
webViewRef = this
|
||||
loadUrl(if (baseUrl.contains("://")) baseUrl else "https://" + baseUrl)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.statusBarsPadding()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.sub2_panel_login),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(Icons.Default.Close, contentDescription = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 只接受来自面板本域名的 auth_token:
|
||||
// 登录容器会跳转到第三方页面(OAuth 回调 / CDN),任何被加载的页面都能改写自己的
|
||||
// localStorage,不做来源校验就等于允许它们伪造 token 覆盖用户凭据。
|
||||
val expectedHost = remember(baseUrl) {
|
||||
val withScheme = if (baseUrl.contains("://")) baseUrl else "https://" + baseUrl
|
||||
okhttp3.HttpUrl.Companion.run { withScheme.toHttpUrlOrNull()?.host }
|
||||
}
|
||||
|
||||
// 轮询 localStorage.auth_token(登录成功后自动捕获并回调关闭)
|
||||
LaunchedEffect(detected, expectedHost) {
|
||||
while (!detected) {
|
||||
kotlinx.coroutines.delay(1500)
|
||||
val web = webViewRef ?: continue
|
||||
// 回传 "hosttoken",避免 JSON 在 Kotlin 字符串里的多层转义
|
||||
web.evaluateJavascript(
|
||||
"(function(){try{" +
|
||||
"var t=window.localStorage.getItem('auth_token')||'';" +
|
||||
"var h=window.location.hostname||'';" +
|
||||
"return h+'\u0001'+t;" +
|
||||
"}catch(e){return ''}})()"
|
||||
) { value ->
|
||||
if (detected) return@evaluateJavascript
|
||||
// evaluateJavascript 回传的是 JS 字符串字面量,带引号与转义
|
||||
val unquoted = value?.let { v ->
|
||||
runCatching { org.json.JSONTokener(v).nextValue() as? String }.getOrNull()
|
||||
} ?: return@evaluateJavascript
|
||||
val sep = unquoted.indexOf('')
|
||||
if (sep < 0) return@evaluateJavascript
|
||||
val host = unquoted.substring(0, sep)
|
||||
val token = unquoted.substring(sep + 1)
|
||||
if (token.length > 20 && expectedHost != null &&
|
||||
host.equals(expectedHost, ignoreCase = true)
|
||||
) {
|
||||
detected = true
|
||||
onToken(token)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyToClipboard(context: Context, text: String) {
|
||||
val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
cm.setPrimaryClip(ClipData.newPlainText("login_url", text))
|
||||
|
||||
@ -7,7 +7,6 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.rainy.token.R
|
||||
import com.rainy.token.data.repository.CredentialRepository
|
||||
import com.rainy.token.data.repository.RepositoryError
|
||||
import com.rainy.token.data.repository.Sub2ApiRepository
|
||||
import com.rainy.token.domain.model.CookieEntry
|
||||
import com.rainy.token.domain.model.Credential
|
||||
import com.rainy.token.domain.service.FetchMethod
|
||||
@ -21,12 +20,9 @@ import javax.inject.Provider
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
@ -40,59 +36,33 @@ import kotlinx.serialization.json.jsonPrimitive
|
||||
@HiltViewModel
|
||||
class CredentialEditViewModel @Inject constructor(
|
||||
private val credentialRepository: CredentialRepository,
|
||||
private val refreshBalanceUseCaseProvider: Provider<RefreshBalanceUseCase>,
|
||||
private val sub2ApiRepository: Sub2ApiRepository,
|
||||
private val savedStateHandle: androidx.lifecycle.SavedStateHandle
|
||||
private val refreshBalanceUseCaseProvider: Provider<RefreshBalanceUseCase>
|
||||
) : ViewModel() {
|
||||
|
||||
/** Sub2 站点信息探测(baseUrl 输入 debounce 后触发)。 */
|
||||
private var sub2SiteJob: Job? = null
|
||||
private var sub2SiteGeneration: Int = 0
|
||||
|
||||
private val _uiState = MutableStateFlow(CredentialEditUiState())
|
||||
val uiState: StateFlow<CredentialEditUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var serviceType: ServiceType? = null
|
||||
|
||||
/** 当前编辑的账号 ID(多账号服务);null=新建账号/单账号服务。 */
|
||||
private var accountId: String? = null
|
||||
|
||||
fun bind(service: ServiceType, accountId: String? = null) {
|
||||
val effectiveAccountId = accountId?.takeIf { it.isNotBlank() }
|
||||
if (serviceType == service && this.accountId == effectiveAccountId) return
|
||||
fun bind(service: ServiceType) {
|
||||
if (serviceType == service) return
|
||||
serviceType = service
|
||||
this.accountId = effectiveAccountId
|
||||
val config = ServiceConfigProvider.get(service)
|
||||
val isApiKey = config.method == FetchMethod.REST_API
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
service = service,
|
||||
isApiKeyService = isApiKey,
|
||||
loginUrl = config.loginUrl,
|
||||
accountId = effectiveAccountId,
|
||||
accountLabel = null,
|
||||
accountIndex = 0
|
||||
loginUrl = config.loginUrl
|
||||
)
|
||||
}
|
||||
load()
|
||||
refreshAccountInfo()
|
||||
}
|
||||
|
||||
private fun load() {
|
||||
val type = serviceType ?: return
|
||||
// WebView 登录成功回调会把实际写入的账号 ID 通过 savedStateHandle 回传:
|
||||
// 新建账号模式(accountId=null)登录后要切换到“编辑该新账号”模式,避免再次保存时误建第二个账号
|
||||
savedStateHandle.get<String>(KEY_LOGIN_ACCOUNT_ID)?.let { pendingId ->
|
||||
savedStateHandle[KEY_LOGIN_ACCOUNT_ID] = null
|
||||
if (accountId.isNullOrBlank() && !pendingId.isBlank()) {
|
||||
accountId = pendingId
|
||||
_uiState.update { it.copy(accountId = pendingId) }
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
// 多账号服务 + null accountId = “新建账号”模式:表单必须留空(不能预填默认账号凭据)
|
||||
val isNewAccount = ServiceType.isMultiAccount(type) && accountId.isNullOrBlank()
|
||||
val existing = if (isNewAccount) null else credentialRepository.get(type, accountId)
|
||||
val existing = credentialRepository.get(type)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
apiKey = when (existing) {
|
||||
@ -122,32 +92,9 @@ class CredentialEditViewModel @Inject constructor(
|
||||
} else "",
|
||||
ollamaCookie = (existing as? Credential.SessionCredential)?.ollamaCookie.orEmpty(),
|
||||
triggerApiKey = (existing as? Credential.SessionCredential)?.apiKey.orEmpty(),
|
||||
traeJwt = (existing as? Credential.TraeCredential)?.jwt.orEmpty(),
|
||||
traeRegion = (existing as? Credential.TraeCredential)?.region ?: "CN",
|
||||
traeCheckinDeviceId = (existing as? Credential.TraeCredential)?.checkinDeviceId.orEmpty(),
|
||||
workBuddyAccess = (existing as? Credential.WorkBuddyCredential)?.accessToken.orEmpty(),
|
||||
workBuddyRefresh = (existing as? Credential.WorkBuddyCredential)?.refreshToken.orEmpty(),
|
||||
workBuddyRegion = (existing as? Credential.WorkBuddyCredential)?.region ?: "CN",
|
||||
sub2BaseUrl = (existing as? Credential.Sub2ApiCredential)?.baseUrl.orEmpty(),
|
||||
sub2SiteIcon = (existing as? Credential.Sub2ApiCredential)?.siteIcon,
|
||||
sub2Email = (existing as? Credential.Sub2ApiCredential)?.email.orEmpty(),
|
||||
sub2Password = (existing as? Credential.Sub2ApiCredential)?.password.orEmpty(),
|
||||
sub2ApiKey = (existing as? Credential.Sub2ApiCredential)?.apiKey.orEmpty(),
|
||||
sub2LoginMode = when {
|
||||
// 有面板 Token:账号密码模式优先(面板登录是主力)
|
||||
!((existing as? Credential.Sub2ApiCredential)?.authToken).isNullOrBlank() -> "account"
|
||||
// 仅存过 API Key:保持 API Key 模式
|
||||
!((existing as? Credential.Sub2ApiCredential)?.apiKey).isNullOrBlank() -> "apikey"
|
||||
else -> "account"
|
||||
},
|
||||
hasExisting = existing != null
|
||||
)
|
||||
}
|
||||
// 已有 baseUrl:补跑一次站点识别(load 不经过输入框,探测不会自动触发)
|
||||
val base = (existing as? Credential.Sub2ApiCredential)?.baseUrl
|
||||
if (!base.isNullOrBlank()) {
|
||||
updateSub2BaseUrl(base)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -214,8 +161,6 @@ class CredentialEditViewModel @Inject constructor(
|
||||
}
|
||||
val err = result.exceptionOrNull()
|
||||
val reason: UiText = when (err) {
|
||||
is RepositoryError.SessionExpired ->
|
||||
UiText.Resource(R.string.error_credential_session_expired)
|
||||
is RepositoryError.InvalidCredential ->
|
||||
UiText.Resource(R.string.error_credential_rejected)
|
||||
is RepositoryError.CredentialChanged ->
|
||||
@ -295,304 +240,6 @@ class CredentialEditViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Trae ----------
|
||||
|
||||
fun updateTraeJwt(value: String) {
|
||||
_uiState.update { it.copy(traeJwt = value) }
|
||||
}
|
||||
fun updateTraeCallbackUrl(value: String) {
|
||||
_uiState.update { it.copy(traeCallbackUrl = value) }
|
||||
}
|
||||
|
||||
/** 解析浏览器登录回调链接,填入 JWT;解析失败给提示。 */
|
||||
fun importTraeCallbackFromUrl(raw: String) {
|
||||
val input = raw.trim()
|
||||
if (input.isEmpty()) {
|
||||
_uiState.update { it.copy(message = UiText.Resource(R.string.error_api_key_empty)) }
|
||||
return
|
||||
}
|
||||
val parsed = parseTraeCallback(input)
|
||||
if (parsed == null) {
|
||||
_uiState.update { it.copy(message = UiText.Resource(R.string.error_trae_callback_invalid)) }
|
||||
return
|
||||
}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
traeJwt = parsed.jwt,
|
||||
traeRegion = parsed.region,
|
||||
traeApiHost = parsed.apiHost,
|
||||
traeCallbackUrl = input
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private data class TraeParsedCallback(
|
||||
val jwt: String,
|
||||
val region: String,
|
||||
val apiHost: String
|
||||
)
|
||||
|
||||
private fun parseTraeCallback(input: String): TraeParsedCallback? {
|
||||
val raw = input.trim()
|
||||
val uri = runCatching { android.net.Uri.parse(raw) }.getOrNull() ?: return null
|
||||
fun query(name: String): String? {
|
||||
uri.getQueryParameter(name)?.let { return it }
|
||||
val frag = uri.fragment
|
||||
if (!frag.isNullOrBlank()) {
|
||||
frag.split("&").forEach { pair ->
|
||||
val kv = pair.split("=", limit = 2)
|
||||
if (kv.size == 2 && kv[0] == name) return android.net.Uri.decode(kv[1])
|
||||
if (kv.size == 1 && kv[0] == name) return ""
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
val userJwtRaw = query("userJwt") ?: query("user_jwt") ?: ""
|
||||
val userJwt = runCatching {
|
||||
kotlinx.serialization.json.Json.parseToJsonElement(userJwtRaw).jsonObject
|
||||
}.getOrNull() ?: kotlinx.serialization.json.JsonObject(emptyMap())
|
||||
val token = userJwt["Token"]?.jsonPrimitive?.contentOrNull
|
||||
?: userJwt["token"]?.jsonPrimitive?.contentOrNull
|
||||
?: userJwt["AccessToken"]?.jsonPrimitive?.contentOrNull
|
||||
?: userJwt["accessToken"]?.jsonPrimitive?.contentOrNull
|
||||
?: if (raw.startsWith("{")) userJwt["jwt"]?.jsonPrimitive?.contentOrNull else null
|
||||
if (token.isNullOrBlank()) return null
|
||||
// 区域推断:回调显式 userRegion/us/INTL,或 userInfo.Region 非 CN 域
|
||||
val userRegion = query("userRegion")?.lowercase()
|
||||
val userInfoRegion = query("userInfo")?.let { infoRaw ->
|
||||
runCatching {
|
||||
val info = kotlinx.serialization.json.Json.parseToJsonElement(infoRaw).jsonObject
|
||||
info["Region"]?.jsonPrimitive?.contentOrNull
|
||||
?: info["region"]?.jsonPrimitive?.contentOrNull
|
||||
}.getOrNull()
|
||||
}
|
||||
val intl = userRegion?.let { it != "cn" } == true ||
|
||||
userInfoRegion?.let { !it.equals("cn", true) && !it.equals("china", true) } == true ||
|
||||
query("userRegion")?.equals("us", true) == true
|
||||
val apiHost = query("host")?.takeIf { it.startsWith("http") }?.trimEnd('/') ?: ""
|
||||
return TraeParsedCallback(
|
||||
jwt = token,
|
||||
region = if (intl) "INTL" else "CN",
|
||||
apiHost = apiHost
|
||||
)
|
||||
}
|
||||
|
||||
fun updateTraeRegion(value: String) {
|
||||
_uiState.update { it.copy(traeRegion = value) }
|
||||
}
|
||||
|
||||
fun updateTraeCheckinDeviceId(value: String) {
|
||||
_uiState.update { it.copy(traeCheckinDeviceId = value) }
|
||||
}
|
||||
|
||||
fun saveTraeCredential() {
|
||||
val type = serviceType ?: return
|
||||
val current = _uiState.value
|
||||
var jwt = current.traeJwt.trim()
|
||||
val parsedCallback = if (jwt.isBlank() && current.traeCallbackUrl.isNotBlank()) {
|
||||
parseTraeCallback(current.traeCallbackUrl)
|
||||
} else null
|
||||
if (parsedCallback != null) jwt = parsedCallback.jwt
|
||||
if (jwt.isBlank()) {
|
||||
_uiState.update { it.copy(message = UiText.Resource(R.string.error_api_key_empty)) }
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
// 新建账号模式(多账号服务 + null accountId)不继承默认账号的元数据(lastVerifiedAt 等)
|
||||
val isNewAccount = ServiceType.isMultiAccount(type) && accountId.isNullOrBlank()
|
||||
val existing = if (isNewAccount) {
|
||||
null
|
||||
} else {
|
||||
credentialRepository.get(type, accountId) as? Credential.TraeCredential
|
||||
}
|
||||
val effectiveId = credentialRepository.save(
|
||||
(existing ?: Credential.TraeCredential(service = type, jwt = jwt, region = "CN")).copy(
|
||||
jwt = jwt,
|
||||
apiHost = current.traeApiHost.takeIf { it.isNotBlank() }?.trimEnd('/')
|
||||
?: parsedCallback?.apiHost?.takeIf { it.isNotBlank() }?.trimEnd('/')
|
||||
?: existing?.apiHost,
|
||||
region = if (current.traeRegion.uppercase() == "INTL") "INTL" else "CN",
|
||||
// 换账号(JWT 变化)时清空本地签到短路标记,避免新账号被误判「今日已签到」
|
||||
lastCheckinDate = if (existing != null && existing.jwt == jwt) existing.lastCheckinDate else null,
|
||||
// 签到设备 ID:留空 = 用内置默认;填写 = 优先(换设备后自查 did 更新)
|
||||
checkinDeviceId = current.traeCheckinDeviceId.trim().ifBlank { null },
|
||||
lastVerifiedAt = existing?.lastVerifiedAt ?: 0L
|
||||
),
|
||||
accountId
|
||||
)
|
||||
accountId = effectiveId.ifBlank { accountId }
|
||||
_uiState.update {
|
||||
it.copy(hasExisting = true, message = UiText.Resource(R.string.msg_credentials_saved))
|
||||
}
|
||||
refreshAccountInfo()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- WorkBuddy ----------
|
||||
|
||||
fun updateWorkBuddyAccess(value: String) {
|
||||
_uiState.update { it.copy(workBuddyAccess = value) }
|
||||
}
|
||||
|
||||
fun updateWorkBuddyRefresh(value: String) {
|
||||
_uiState.update { it.copy(workBuddyRefresh = value) }
|
||||
}
|
||||
|
||||
fun updateWorkBuddyRegion(value: String) {
|
||||
_uiState.update { it.copy(workBuddyRegion = value) }
|
||||
}
|
||||
|
||||
fun saveWorkBuddyCredential() {
|
||||
val type = serviceType ?: return
|
||||
val current = _uiState.value
|
||||
val access = current.workBuddyAccess.trim()
|
||||
val refresh = current.workBuddyRefresh.trim()
|
||||
if (access.isBlank() || refresh.isBlank()) {
|
||||
_uiState.update { it.copy(message = UiText.Resource(R.string.error_workbuddy_tokens_required)) }
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
// 新建账号模式(多账号服务 + null accountId)不继承默认账号的元数据
|
||||
val isNewAccount = ServiceType.isMultiAccount(type) && accountId.isNullOrBlank()
|
||||
val existing = if (isNewAccount) {
|
||||
null
|
||||
} else {
|
||||
credentialRepository.get(type, accountId) as? Credential.WorkBuddyCredential
|
||||
}
|
||||
val effectiveId = credentialRepository.save(
|
||||
(existing ?: Credential.WorkBuddyCredential(service = type, accessToken = access, refreshToken = refresh, region = "CN"))
|
||||
.copy(
|
||||
accessToken = access,
|
||||
refreshToken = refresh,
|
||||
region = if (current.workBuddyRegion.uppercase() == "INTL") "INTL" else "CN",
|
||||
expiresAt = existing?.expiresAt ?: 0L,
|
||||
lastVerifiedAt = existing?.lastVerifiedAt ?: 0L
|
||||
),
|
||||
accountId
|
||||
)
|
||||
accountId = effectiveId.ifBlank { accountId }
|
||||
_uiState.update {
|
||||
it.copy(hasExisting = true, message = UiText.Resource(R.string.msg_credentials_saved))
|
||||
}
|
||||
refreshAccountInfo()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Sub2API ----------
|
||||
|
||||
fun updateSub2BaseUrl(value: String) {
|
||||
_uiState.update { it.copy(sub2BaseUrl = value, sub2SiteName = null, sub2SiteIcon = null) }
|
||||
sub2SiteJob?.cancel()
|
||||
val generation = ++sub2SiteGeneration
|
||||
sub2SiteJob = viewModelScope.launch {
|
||||
delay(600)
|
||||
val base = value.trim()
|
||||
if (base.isBlank() || generation != sub2SiteGeneration) return@launch
|
||||
_uiState.update { it.copy(sub2SiteLoading = true) }
|
||||
val info = sub2ApiRepository.fetchSiteInfo(base)
|
||||
if (generation != sub2SiteGeneration) return@launch
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
sub2SiteLoading = false,
|
||||
sub2SiteName = info?.name?.takeIf { n -> n.isNotBlank() },
|
||||
sub2SiteIcon = info?.iconUrl
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 面板账号一键登录:邮箱+密码 → POST /api/v1/auth/login → 面板 Token(存为 authToken 走面板模式)。 */
|
||||
fun loginPanelOneClick() {
|
||||
val current = _uiState.value
|
||||
val base = current.sub2BaseUrl.trim()
|
||||
val email = current.sub2Email.trim()
|
||||
val password = current.sub2Password.trim()
|
||||
if (base.isBlank() || email.isBlank() || password.isBlank()) {
|
||||
_uiState.update { it.copy(message = UiText.Resource(R.string.error_sub2_required)) }
|
||||
return
|
||||
}
|
||||
if (current.sub2LoggingIn) return
|
||||
_uiState.update { it.copy(sub2LoggingIn = true) }
|
||||
viewModelScope.launch {
|
||||
sub2ApiRepository.loginPanel(base, email, password, current.sub2Totp.trim()).fold(
|
||||
onSuccess = { token -> onPanelTokenAcquired(token) },
|
||||
onFailure = { e ->
|
||||
_uiState.update {
|
||||
it.copy(message = UiText.Dynamic(e.message ?: "登录失败,请检查邮箱/密码"))
|
||||
}
|
||||
}
|
||||
)
|
||||
_uiState.update { it.copy(sub2LoggingIn = false) }
|
||||
}
|
||||
}
|
||||
|
||||
fun updateSub2Email(value: String) {
|
||||
_uiState.update { it.copy(sub2Email = value) }
|
||||
}
|
||||
|
||||
fun updateSub2Password(value: String) {
|
||||
_uiState.update { it.copy(sub2Password = value) }
|
||||
}
|
||||
|
||||
fun updateSub2ApiKey(value: String) {
|
||||
_uiState.update { it.copy(sub2ApiKey = value) }
|
||||
}
|
||||
|
||||
fun updateSub2LoginMode(mode: String) {
|
||||
_uiState.update { it.copy(sub2LoginMode = mode) }
|
||||
}
|
||||
|
||||
fun updateSub2Totp(value: String) {
|
||||
_uiState.update { it.copy(sub2Totp = value) }
|
||||
}
|
||||
|
||||
/** WebView 网页登录成功:写入本次待保存的面板 Token(保存时并入 authToken)。 */
|
||||
fun onPanelTokenAcquired(token: String) {
|
||||
_uiState.update { it.copy(sub2PanelToken = token, message = UiText.Resource(R.string.sub2_panel_login_done)) }
|
||||
}
|
||||
|
||||
fun saveSub2ApiCredential() {
|
||||
val type = serviceType ?: return
|
||||
val current = _uiState.value
|
||||
val base = current.sub2BaseUrl.trim()
|
||||
val apiKey = current.sub2ApiKey.trim()
|
||||
val email = current.sub2Email.trim()
|
||||
val password = current.sub2Password.trim()
|
||||
viewModelScope.launch {
|
||||
// 新建账号模式(多账号服务 + null accountId)不继承默认账号的元数据
|
||||
val isNewAccount = ServiceType.isMultiAccount(type) && accountId.isNullOrBlank()
|
||||
val existing = if (isNewAccount) {
|
||||
null
|
||||
} else {
|
||||
credentialRepository.get(type, accountId) as? Credential.Sub2ApiCredential
|
||||
}
|
||||
// 面板 Token(本次 WebView 获取或既有)也算有效认证
|
||||
val panelToken = current.sub2PanelToken ?: existing?.authToken
|
||||
// 实例地址必填;认证需 API Key /(邮箱+密码)/ 面板 Token 之一
|
||||
if (base.isBlank() || (apiKey.isBlank() && (email.isBlank() || password.isBlank()) && panelToken == null)) {
|
||||
_uiState.update { it.copy(message = UiText.Resource(R.string.error_sub2_required)) }
|
||||
return@launch
|
||||
}
|
||||
val effectiveId = credentialRepository.save(
|
||||
(existing ?: Credential.Sub2ApiCredential(service = type, baseUrl = base, email = email, password = password))
|
||||
.copy(
|
||||
baseUrl = base,
|
||||
email = email,
|
||||
password = password,
|
||||
apiKey = apiKey,
|
||||
authToken = panelToken,
|
||||
siteIcon = current.sub2SiteIcon ?: existing?.siteIcon,
|
||||
lastVerifiedAt = existing?.lastVerifiedAt ?: 0L
|
||||
),
|
||||
accountId
|
||||
)
|
||||
accountId = effectiveId.ifBlank { accountId }
|
||||
_uiState.update {
|
||||
it.copy(hasExisting = true, sub2PanelToken = null, message = UiText.Resource(R.string.msg_credentials_saved))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 把 API Key 缩成 'sk-a***xyz' 这种形式,前 4 后 4,中间用 *** 代替。 */
|
||||
private fun maskedKeyPreview(key: String): UiText {
|
||||
if (key.length <= 8) {
|
||||
@ -891,7 +538,7 @@ class CredentialEditViewModel @Inject constructor(
|
||||
formatSuccess = { bal ->
|
||||
UiText.Resource(
|
||||
R.string.msg_connect_success_session,
|
||||
listOf(bal.amount.toString(), bal.extras["plan"]?.takeIf { it.isNotBlank() } ?: "—")
|
||||
listOf(bal.amount.toString(), bal.extras["plan"] ?: "—")
|
||||
)
|
||||
},
|
||||
rollbackOnFailure = true
|
||||
@ -914,19 +561,10 @@ class CredentialEditViewModel @Inject constructor(
|
||||
|
||||
fun deleteCredential() {
|
||||
val type = serviceType ?: return
|
||||
val targetAccountId = accountId
|
||||
viewModelScope.launch {
|
||||
if (ServiceType.isMultiAccount(type) && !targetAccountId.isNullOrBlank()) {
|
||||
credentialRepository.removeAccount(type, targetAccountId)
|
||||
} else {
|
||||
credentialRepository.remove(type)
|
||||
}
|
||||
accountId = null
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
accountId = null,
|
||||
accountLabel = null,
|
||||
accountIndex = 0,
|
||||
message = UiText.Resource(R.string.msg_credential_deleted),
|
||||
hasExisting = false,
|
||||
apiKey = "",
|
||||
@ -934,38 +572,8 @@ class CredentialEditViewModel @Inject constructor(
|
||||
authCookie = "",
|
||||
workspaceId = "",
|
||||
cookieCount = 0,
|
||||
codexAuthJson = "",
|
||||
ollamaCookie = "",
|
||||
triggerApiKey = "",
|
||||
traeJwt = "",
|
||||
traeRegion = "CN",
|
||||
traeCheckinDeviceId = "",
|
||||
workBuddyAccess = "",
|
||||
workBuddyRefresh = "",
|
||||
workBuddyRegion = "CN",
|
||||
sub2BaseUrl = "",
|
||||
sub2Email = "",
|
||||
sub2Password = "",
|
||||
sub2ApiKey = ""
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 多账号服务:按当前账号 ID 刷新展示用的账号名与序号。 */
|
||||
private fun refreshAccountInfo() {
|
||||
val type = serviceType ?: return
|
||||
val aid = accountId ?: return
|
||||
viewModelScope.launch {
|
||||
val accounts = credentialRepository.accountsFor(type)
|
||||
val index = accounts.indexOfFirst { it.id == aid }
|
||||
if (index < 0) return@launch
|
||||
val account = accounts[index]
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
accountId = aid,
|
||||
accountLabel = account.label?.takeIf { l -> l.isNotBlank() },
|
||||
accountIndex = index + 1
|
||||
triggerApiKey = ""
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -975,17 +583,6 @@ class CredentialEditViewModel @Inject constructor(
|
||||
_uiState.update { it.copy(message = null) }
|
||||
}
|
||||
|
||||
/** 从 WebView 登录页返回后重载表单与账号信息(当前凭据可能已被登录流程改写)。 */
|
||||
fun reload() {
|
||||
load()
|
||||
refreshAccountInfo()
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** WebView 登录页通过 previousBackStackEntry.savedStateHandle 回传实际写入的账号 ID。 */
|
||||
const val KEY_LOGIN_ACCOUNT_ID = "login_account_id"
|
||||
}
|
||||
|
||||
fun importFromClipboard(context: Context) {
|
||||
val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
|
||||
val text = cm.primaryClip?.getItemAt(0)?.text?.toString()
|
||||
@ -1042,12 +639,6 @@ class CredentialEditViewModel @Inject constructor(
|
||||
|
||||
data class CredentialEditUiState(
|
||||
val service: ServiceType? = null,
|
||||
/** 当前编辑的账号 ID(多账号服务);null=新建账号/单账号服务。 */
|
||||
val accountId: String? = null,
|
||||
/** 账号自定义名称(可能为 null,UI 按“账号 N”兜底)。 */
|
||||
val accountLabel: String? = null,
|
||||
/** 同服务内账号序号(1 起,兜底名称用)。 */
|
||||
val accountIndex: Int = 0,
|
||||
val isApiKeyService: Boolean = false,
|
||||
val loginUrl: String = "",
|
||||
val apiKey: String = "",
|
||||
@ -1060,35 +651,5 @@ data class CredentialEditUiState(
|
||||
val ollamaCookie: String = "",
|
||||
/** OCGO / Ollama 的一键激活用量 API Key */
|
||||
val triggerApiKey: String = "",
|
||||
/** Trae:Cloud-IDE-JWT + 区域 */
|
||||
val traeJwt: String = "",
|
||||
/** Trae:浏览器回调地址粘贴导入(http://127.0.0.1:18080/authorize?...) */
|
||||
val traeCallbackUrl: String = "",
|
||||
val traeRegion: String = "CN",
|
||||
/** Trae 签到设备 ID(官方客户端绑定 did;留空用内置默认) */
|
||||
val traeCheckinDeviceId: String = "",
|
||||
/** Trae 登录回调携带的账号 API 主机(备用;从回调链接自动解析) */
|
||||
val traeApiHost: String = "",
|
||||
/** WorkBuddy:OAuth tokens + 区域 */
|
||||
val workBuddyAccess: String = "",
|
||||
val workBuddyRefresh: String = "",
|
||||
val workBuddyRegion: String = "CN",
|
||||
/** Sub2API:自托管实例连接 */
|
||||
val sub2BaseUrl: String = "",
|
||||
val sub2Email: String = "",
|
||||
val sub2Password: String = "",
|
||||
val sub2ApiKey: String = "",
|
||||
/** Sub2API:WebView/一键登录新获取的面板 Token(保存时并入 authToken) */
|
||||
val sub2PanelToken: String? = null,
|
||||
/** Sub2API:baseUrl 输入后自动探测的站点名称/图标(确认地址填对) */
|
||||
val sub2SiteName: String? = null,
|
||||
val sub2SiteIcon: String? = null,
|
||||
val sub2SiteLoading: Boolean = false,
|
||||
/** Sub2API:一键登录进行中 */
|
||||
val sub2LoggingIn: Boolean = false,
|
||||
/** Sub2API:登录方式(默认账号密码优先;仅存 apiKey 时切 API Key) */
|
||||
val sub2LoginMode: String = "account",
|
||||
/** Sub2API:二次验证码(选填) */
|
||||
val sub2Totp: String = "",
|
||||
val message: UiText? = null
|
||||
)
|
||||
|
||||
@ -2,17 +2,7 @@ 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
|
||||
@ -33,14 +23,11 @@ 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
|
||||
@ -62,17 +49,11 @@ 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
|
||||
@ -92,7 +73,6 @@ import com.rainy.token.util.LocaleManager
|
||||
fun SettingsScreen(
|
||||
onBack: () -> Unit,
|
||||
onEditCredential: (ServiceType) -> Unit,
|
||||
onManageAccounts: (ServiceType) -> Unit = {},
|
||||
onOpenTips: () -> Unit = {},
|
||||
onOpenDebugLog: () -> Unit = {},
|
||||
viewModel: SettingsViewModel = hiltViewModel()
|
||||
@ -103,15 +83,6 @@ fun SettingsScreen(
|
||||
}
|
||||
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val proxyRunning by viewModel.proxyRunning.collectAsStateWithLifecycle()
|
||||
val proxyAllowLan by viewModel.proxyAllowLan.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(
|
||||
@ -166,52 +137,21 @@ fun SettingsScreen(
|
||||
modifier = Modifier.padding(start = 4.dp, bottom = 4.dp)
|
||||
)
|
||||
}
|
||||
items(uiState.credentialStatuses, key = { it.status.service.name }) { line ->
|
||||
items(uiState.credentialStatuses, key = { it.service.name }) { status ->
|
||||
CredentialStatusCard(
|
||||
status = line.status,
|
||||
accountCount = line.accountCount,
|
||||
onClick = {
|
||||
if (ServiceType.isMultiAccount(line.status.service)) {
|
||||
onManageAccounts(line.status.service)
|
||||
} else {
|
||||
onEditCredential(line.status.service)
|
||||
}
|
||||
}
|
||||
status = status,
|
||||
onClick = { onEditCredential(status.service) }
|
||||
)
|
||||
}
|
||||
item {
|
||||
LanguageCard(onClick = { showLanguageDialog = true })
|
||||
}
|
||||
item {
|
||||
ProxyGatewayCard(
|
||||
running = proxyRunning,
|
||||
allowLan = proxyAllowLan,
|
||||
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()
|
||||
@ -225,202 +165,6 @@ fun SettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProxyGatewayCard(
|
||||
running: Boolean,
|
||||
allowLan: Boolean,
|
||||
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 -> "运行中 · 局域网模式(其他设备填 http://手机热点IP:8787/v1)"
|
||||
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 (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
|
||||
@ -517,11 +261,7 @@ private fun LanguageDialog(onDismiss: () -> Unit) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CredentialStatusCard(
|
||||
status: CredentialStatus,
|
||||
accountCount: Int = 0,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
private fun CredentialStatusCard(status: CredentialStatus, onClick: () -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@ -543,11 +283,7 @@ private fun CredentialStatusCard(
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Text(
|
||||
text = if (accountCount > 0) {
|
||||
stringResource(R.string.account_count_line, stateLabel(status.state), accountCount)
|
||||
} else {
|
||||
stateLabel(status.state)
|
||||
},
|
||||
text = stateLabel(status.state),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = InkMuted,
|
||||
modifier = Modifier.padding(top = 2.dp)
|
||||
@ -558,159 +294,6 @@ private fun CredentialStatusCard(
|
||||
}
|
||||
}
|
||||
|
||||
@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(
|
||||
|
||||
@ -1,36 +1,15 @@
|
||||
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.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.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
|
||||
|
||||
/**
|
||||
@ -39,332 +18,12 @@ import javax.inject.Inject
|
||||
*/
|
||||
@HiltViewModel
|
||||
class SettingsViewModel @Inject constructor(
|
||||
@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,
|
||||
private val credentialRepository: CredentialRepository
|
||||
) : 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()
|
||||
|
||||
/** 当前本机 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) {
|
||||
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
|
||||
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()
|
||||
}
|
||||
@ -373,45 +32,14 @@ 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 = lines)
|
||||
it.copy(loading = false, credentialStatuses = statuses)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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<CredentialStatusLine> = emptyList()
|
||||
)
|
||||
|
||||
/** 凭据状态行:单账号服务 [accountCount]=0;多账号服务为账号数(0 表示尚未配置)。 */
|
||||
data class CredentialStatusLine(
|
||||
val status: CredentialStatus,
|
||||
val accountCount: Int = 0
|
||||
val credentialStatuses: List<CredentialStatus> = emptyList()
|
||||
)
|
||||
@ -1,112 +0,0 @@
|
||||
package com.rainy.token.ui.settings
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.rainy.token.BuildConfig
|
||||
import com.rainy.token.data.repository.UpdateRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** 设置页更新模块:检查更新 → 下载 APK → 拉起系统安装器。 */
|
||||
@HiltViewModel
|
||||
class UpdateViewModel @Inject constructor(
|
||||
private val updateRepository: UpdateRepository,
|
||||
@ApplicationContext private val context: Context
|
||||
) : ViewModel() {
|
||||
|
||||
sealed interface Phase {
|
||||
object Idle : Phase
|
||||
object Checking : Phase
|
||||
object Latest : Phase
|
||||
data class Available(val info: UpdateRepository.UpdateInfo) : Phase
|
||||
data class Downloading(val progress: Float) : Phase
|
||||
data class Ready(val file: File, val error: String? = null) : Phase
|
||||
data class NeedInstallPermission(val file: File) : Phase
|
||||
data class Error(val message: String) : Phase
|
||||
}
|
||||
|
||||
data class UiState(
|
||||
val phase: Phase = Phase.Idle,
|
||||
val currentVersionName: String = BuildConfig.VERSION_NAME,
|
||||
val currentVersionCode: Int = BuildConfig.VERSION_CODE
|
||||
)
|
||||
|
||||
private val _uiState = MutableStateFlow(UiState())
|
||||
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
checkForUpdate()
|
||||
}
|
||||
|
||||
fun checkForUpdate() {
|
||||
_uiState.update { it.copy(phase = Phase.Checking) }
|
||||
viewModelScope.launch {
|
||||
updateRepository.checkForUpdate().fold(
|
||||
onSuccess = { info ->
|
||||
_uiState.update {
|
||||
it.copy(phase = if (info == null) Phase.Latest else Phase.Available(info))
|
||||
}
|
||||
},
|
||||
onFailure = { e ->
|
||||
_uiState.update { it.copy(phase = Phase.Error(e.message ?: "unknown")) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun downloadAndInstall(info: UpdateRepository.UpdateInfo) {
|
||||
_uiState.update { it.copy(phase = Phase.Downloading(0f)) }
|
||||
viewModelScope.launch {
|
||||
updateRepository.downloadApk(info) { downloaded, total ->
|
||||
val progress = if (total > 0) downloaded.toFloat() / total else 0f
|
||||
_uiState.update { it.copy(phase = Phase.Downloading(progress)) }
|
||||
}.fold(
|
||||
onSuccess = { file -> install(file) },
|
||||
onFailure = { e ->
|
||||
_uiState.update { it.copy(phase = Phase.Error(e.message ?: "unknown")) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun install(file: File) {
|
||||
val canInstall = runCatching { context.packageManager.canRequestPackageInstalls() }
|
||||
.getOrDefault(false)
|
||||
if (!canInstall) {
|
||||
_uiState.update { it.copy(phase = Phase.NeedInstallPermission(file)) }
|
||||
return
|
||||
}
|
||||
_uiState.update { it.copy(phase = Phase.Ready(file)) }
|
||||
val authority = context.packageName + ".fileprovider"
|
||||
val uri = try {
|
||||
FileProvider.getUriForFile(context, authority, file)
|
||||
} catch (e: Exception) {
|
||||
_uiState.update { it.copy(phase = Phase.Ready(file, "安装文件无效:" + (e.message ?: "未知错误"))) }
|
||||
return
|
||||
}
|
||||
val actionView = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
val started = runCatching { context.startActivity(actionView) }
|
||||
if (started.isFailure) {
|
||||
val actionInstall = Intent(Intent.ACTION_INSTALL_PACKAGE).apply {
|
||||
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
runCatching { context.startActivity(actionInstall) }.onFailure { e ->
|
||||
_uiState.update { it.copy(phase = Phase.Ready(file, "无法启动安装器:" + (e.message ?: "未知错误"))) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -3,41 +3,40 @@ package com.rainy.token.ui.theme
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
/**
|
||||
* 雨晴风格配色(RainyStyle Palette)— 精致粉调。
|
||||
* 雨晴风格配色(RainyStyle Palette)。
|
||||
*
|
||||
* 视觉基调:低饱和樱粉白背景 + 纯白卡片 + 深玫瑰主色点缀,
|
||||
* 通过克制用色与留白呈现精致感,保留"雨晴粉"品牌识别度。
|
||||
* 视觉基调:元气樱粉 / 草莓粉主调,搭配纯白卡片与暖灰文本。
|
||||
* 状态色:草绿(正常)、暖橙(注意)、玫红(异常)。
|
||||
*
|
||||
* 背景使用柔和粉白渐变(#FFF9FB → #FDEFF4),主元素采用深玫瑰
|
||||
* (#C9436C / #B3305B),与白色形成优雅层次。
|
||||
* 背景使用樱粉渐变(#FFF0F5 → #FFD1DC),主元素采用草莓粉
|
||||
* (#FF85A2 / #FF6B8E),与白色形成层次。
|
||||
*/
|
||||
|
||||
// ─── 品牌主色 ───
|
||||
val CherryPinkLight = Color(0xFFFFF9FB) // 粉白(背景起始)
|
||||
val CherryPinkDeep = Color(0xFFFDEFF4) // 浅樱粉(背景结束)
|
||||
val StrawberryPink = Color(0xFFC9436C) // 深玫瑰(主色 / 强调,白底对比度 ≥ 4.5:1)
|
||||
val StrawberryPinkDark = Color(0xFFB3305B)// 深玫瑰按下态(主按钮)
|
||||
val StrawberryPinkSoft = Color(0xFFFFC9D8)// 浅玫瑰柔(容器 / 选中背景)
|
||||
val CherryPinkLight = Color(0xFFFFF0F5) // 樱粉浅(背景起始)
|
||||
val CherryPinkDeep = Color(0xFFFFD1DC) // 樱粉深(背景结束)
|
||||
val StrawberryPink = Color(0xFFFF85A2) // 草莓粉(主色 / 强调)
|
||||
val StrawberryPinkDark = Color(0xFFFF6B8E)// 草莓粉深(按下态 / 主按钮)
|
||||
val StrawberryPinkSoft = Color(0xFFFFB3C6)// 草莓粉柔(hover / 副按钮)
|
||||
|
||||
// ─── 中性色 ───
|
||||
val PureWhite = Color(0xFFFFFFFF)
|
||||
val SnowWhite = Color(0xFFFAF5F7) // 卡片次级背景
|
||||
val InkWarm = Color(0xFF3A2B31) // 暖黑(主文本)
|
||||
val InkMuted = Color(0xFF7A6870) // 暖灰(次要文本,白底 ≥ 4.5:1)
|
||||
val InkOutline = Color(0xFFE8DCE1) // 暖灰(分隔线 / 描边)
|
||||
val SnowWhite = Color(0xFFFAF6F8) // 卡片次级背景
|
||||
val InkWarm = Color(0xFF3D2C35) // 暖黑(主文本)
|
||||
val InkMuted = Color(0xFF7A6B72) // 暖灰(次要文本)
|
||||
val InkOutline = Color(0xFFD9CFD3) // 暖灰(分隔线 / 描边)
|
||||
|
||||
// ─── 状态色 ───
|
||||
val StatusGreen = Color(0xFF4CAF50) // 正常
|
||||
val StatusGreen = Color(0xFF66BB6A) // 正常
|
||||
val StatusOrange = Color(0xFFFFA726) // 注意 / 需重登
|
||||
val StatusRed = Color(0xFFD64A6E) // 错误 / 异常(玫红,与粉调统一)
|
||||
val StatusRed = Color(0xFFE91E63) // 错误 / 异常
|
||||
val StatusBlue = Color(0xFF64B5F6) // 信息(Stale 缓存)
|
||||
|
||||
// ─── 暗色模式 ───
|
||||
val DarkBackground = Color(0xFF1A1417) // 暖深背景
|
||||
val DarkSurface = Color(0xFF251C21) // 卡片
|
||||
val DarkOnSurface = Color(0xFFF2E6EB) // 卡片文本
|
||||
val DarkPrimary = Color(0xFFFFA9BF) // 暗色下主色更亮
|
||||
val DarkInkWarm = Color(0xFFF2E6EB) // 暗色下暖黑→浅粉白
|
||||
val DarkInkMuted = Color(0xFFCBB8C0) // 暗色下暖灰→浅灰粉
|
||||
val DarkInkOutline = Color(0xFF4C3B44) // 暗色下描边
|
||||
val DarkBackground = Color(0xFF1F1419) // 暖深背景
|
||||
val DarkSurface = Color(0xFF2A1F25) // 卡片
|
||||
val DarkOnSurface = Color(0xFFEFE0E5) // 卡片文本
|
||||
val DarkPrimary = Color(0xFFFFB3C6) // 暗色下主色更亮
|
||||
val DarkInkWarm = Color(0xFFEFE0E5) // 暗色下暖黑→浅粉白
|
||||
val DarkInkMuted = Color(0xFFC9B8BE) // 暗色下暖灰→浅灰粉
|
||||
val DarkInkOutline = Color(0xFF4D3A42) // 暗色下描边
|
||||
|
||||
@ -13,22 +13,21 @@ import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
|
||||
/**
|
||||
* 雨晴风格主题(精致粉调)。
|
||||
* 雨晴风格主题。
|
||||
*
|
||||
* - **不使用** dynamicColor(系统主题色),固定使用雨晴玫瑰粉品牌色。
|
||||
* - **不使用** dynamicColor(系统主题色),固定使用雨晴樱粉品牌色。
|
||||
* 理由:dynamicColor 会让 APP 视觉随系统变化,破坏品牌一致性;
|
||||
* 玩家一眼看到 APP icon + 粉色调就知道是 RainyToken 系列。
|
||||
* - 主色:深玫瑰 #C9436C;容器色:浅玫瑰粉;背景:低饱和粉白渐变。
|
||||
* - 暗色模式:暖深背景 + 亮玫瑰粉强调,跟 Light 模式同色系不同明度。
|
||||
* - 暗色模式:暖深背景 + 亮草莓粉强调,跟 Light 模式同色系不同明度。
|
||||
*/
|
||||
private val LightColors = lightColorScheme(
|
||||
primary = StrawberryPink,
|
||||
onPrimary = PureWhite,
|
||||
primaryContainer = StrawberryPinkSoft,
|
||||
onPrimaryContainer = Color(0xFF5C112C),
|
||||
secondary = Color(0xFFB7698B),
|
||||
onPrimaryContainer = InkWarm,
|
||||
secondary = StrawberryPinkDark,
|
||||
onSecondary = PureWhite,
|
||||
secondaryContainer = Color(0xFFFFE7EE),
|
||||
secondaryContainer = Color(0xFFFFE4EC),
|
||||
onSecondaryContainer = InkWarm,
|
||||
tertiary = StatusGreen,
|
||||
onTertiary = PureWhite,
|
||||
@ -39,32 +38,32 @@ private val LightColors = lightColorScheme(
|
||||
surfaceVariant = SnowWhite,
|
||||
onSurfaceVariant = InkMuted,
|
||||
outline = InkOutline,
|
||||
outlineVariant = Color(0xFFF2E7EB),
|
||||
outlineVariant = Color(0xFFEFE0E5),
|
||||
error = StatusRed,
|
||||
onError = PureWhite
|
||||
)
|
||||
|
||||
private val DarkColors = darkColorScheme(
|
||||
primary = DarkPrimary,
|
||||
onPrimary = Color(0xFF4A1025),
|
||||
primaryContainer = Color(0xFF6B2C42),
|
||||
onPrimaryContainer = DarkOnSurface,
|
||||
onPrimary = DarkBackground,
|
||||
primaryContainer = StrawberryPinkDark,
|
||||
onPrimaryContainer = PureWhite,
|
||||
secondary = StrawberryPink,
|
||||
onSecondary = DarkBackground,
|
||||
secondaryContainer = Color(0xFF4A2E3A),
|
||||
onSecondaryContainer = DarkOnSurface,
|
||||
tertiary = Color(0xFF7BCF9E),
|
||||
onTertiary = Color(0xFF0D2B1C),
|
||||
tertiary = StatusGreen,
|
||||
onTertiary = DarkBackground,
|
||||
background = DarkBackground,
|
||||
onBackground = DarkOnSurface,
|
||||
surface = DarkSurface,
|
||||
onSurface = DarkOnSurface,
|
||||
surfaceVariant = Color(0xFF32262C),
|
||||
onSurfaceVariant = Color(0xFFCBB8C0),
|
||||
outline = Color(0xFF4C3B44),
|
||||
outlineVariant = Color(0xFF3B2C33),
|
||||
error = Color(0xFFFF8194),
|
||||
onError = Color(0xFF4A1025)
|
||||
surfaceVariant = Color(0xFF352329),
|
||||
onSurfaceVariant = Color(0xFFC9B8BE),
|
||||
outline = Color(0xFF4D3A42),
|
||||
outlineVariant = Color(0xFF3A2A30),
|
||||
error = Color(0xFFFF6B8E),
|
||||
onError = PureWhite
|
||||
)
|
||||
|
||||
@Composable
|
||||
|
||||
@ -122,7 +122,7 @@ fun CodexOAuthScreen(
|
||||
Text(
|
||||
uiState.error!!.asString(),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
color = androidx.compose.ui.graphics.Color(0xFFE91E63)
|
||||
)
|
||||
TextButton(onClick = { viewModel.start(CodexOAuthMode.WEBVIEW) }) {
|
||||
Text(stringResource(R.string.action_retry), color = StrawberryPink)
|
||||
@ -286,7 +286,7 @@ private fun HeadlessOAuthContent(
|
||||
Text(
|
||||
text = error.asString(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
color = androidx.compose.ui.graphics.Color(0xFFE91E63),
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
TextButton(onClick = onRetry) {
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
package com.rainy.token.ui.webview
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.WebChromeClient
|
||||
@ -12,20 +10,14 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
@ -34,12 +26,6 @@ import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
@ -47,7 +33,6 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.rainy.token.R
|
||||
import com.rainy.token.data.debug.DebugLog
|
||||
import com.rainy.token.domain.service.ServiceType
|
||||
import com.rainy.token.ui.components.asString
|
||||
|
||||
@ -62,39 +47,17 @@ import com.rainy.token.ui.components.asString
|
||||
@Composable
|
||||
fun WebViewLoginScreen(
|
||||
service: ServiceType,
|
||||
accountId: String? = null,
|
||||
region: String? = null,
|
||||
onBack: () -> Unit,
|
||||
onLoginSucceeded: (ServiceType, String?) -> Unit,
|
||||
/** 用户已保存的面板基址(Sub2API 的 loginUrl 为空,靠它提供登录入口与可信 host)。 */
|
||||
configuredBaseUrl: String? = null,
|
||||
onLoginSucceeded: (ServiceType) -> Unit,
|
||||
viewModel: WebViewLoginViewModel = hiltViewModel()
|
||||
) {
|
||||
// configuredBaseUrl 可选:不传时 ViewModel 自行从凭据仓库读取(Sub2API 自托管实例)
|
||||
LaunchedEffect(service, configuredBaseUrl, accountId) {
|
||||
viewModel.bind(service, configuredBaseUrl, accountId, region)
|
||||
}
|
||||
LaunchedEffect(service) { viewModel.bind(service) }
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val context = LocalContext.current
|
||||
var webViewGeneration by remember { mutableStateOf(0) }
|
||||
var pageLoading by remember { mutableStateOf(true) }
|
||||
var pageError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// 登录成功自动回调(携带实际写入的账号 ID,供新建账号模式回填)
|
||||
// 登录成功自动回调
|
||||
LaunchedEffect(uiState.loginSucceeded) {
|
||||
if (uiState.loginSucceeded) {
|
||||
onLoginSucceeded(service, uiState.loginAccountId)
|
||||
}
|
||||
}
|
||||
|
||||
// 自动识别服务(WorkBuddy 轮询超时、bindWorkBuddy 网络失败等)没有手动确认弹窗兜底,
|
||||
// 错误通过 Snackbar 呈现,避免用户静默卡在登录页。
|
||||
// key 不含 pendingManualConfirm:错误文本不变时不重复提示(弹窗关闭不回放),且保持互斥。
|
||||
val errorText = uiState.error?.asString()
|
||||
LaunchedEffect(errorText, uiState.loginSucceeded) {
|
||||
if (errorText != null && !uiState.pendingManualConfirm && !uiState.loginSucceeded) {
|
||||
snackbarHostState.showSnackbar(errorText)
|
||||
onLoginSucceeded(service)
|
||||
}
|
||||
}
|
||||
|
||||
@ -103,7 +66,6 @@ fun WebViewLoginScreen(
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.title_webview_login, service.displayName)) },
|
||||
@ -111,17 +73,6 @@ fun WebViewLoginScreen(
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
TextButton(onClick = { viewModel.openExternalBrowser() }) {
|
||||
Text(stringResource(R.string.action_open_login_entry))
|
||||
}
|
||||
TextButton(onClick = {
|
||||
viewModel.clearWebViewSession()
|
||||
webViewGeneration++
|
||||
}) {
|
||||
Text(stringResource(R.string.webview_clear_login))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@ -130,22 +81,6 @@ fun WebViewLoginScreen(
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)) {
|
||||
if (uiState.loginUrl.isNotEmpty()) {
|
||||
if (pageLoading) {
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
if (uiState.externalBrowserWaiting) {
|
||||
androidx.compose.material3.Surface(
|
||||
color = androidx.compose.material3.MaterialTheme.colorScheme.secondaryContainer,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.webview_browser_waiting),
|
||||
style = androidx.compose.material3.MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
key(webViewGeneration) {
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
WebView(context).apply {
|
||||
@ -165,143 +100,25 @@ fun WebViewLoginScreen(
|
||||
// 且 TLS fingerprinting 也会识别 WebView。此入口主要作为 fallback,
|
||||
// 主流程走"手动粘贴 Cookie"路线。
|
||||
|
||||
// 注入 JS 桥:面板登录后把 localStorage 里的登录态回传原生层,
|
||||
// 实现「网页登录一次即自动配置凭据」,无需手工粘贴 token
|
||||
addJavascriptInterface(
|
||||
LoginProbeBridge { json ->
|
||||
viewModel.onLocalStorageProbed(parseProbeJson(json))
|
||||
},
|
||||
"AndroidBridge"
|
||||
)
|
||||
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(
|
||||
view: WebView?,
|
||||
request: android.webkit.WebResourceRequest?
|
||||
): Boolean {
|
||||
val url = request?.url?.toString() ?: return false
|
||||
return handleOverrideUrl(url)
|
||||
}
|
||||
|
||||
@Deprecated("deprecated in API 24, kept for older devices")
|
||||
override fun shouldOverrideUrlLoading(
|
||||
view: WebView?,
|
||||
url: String?
|
||||
): Boolean {
|
||||
url ?: return false
|
||||
return handleOverrideUrl(url)
|
||||
}
|
||||
|
||||
fun handleOverrideUrl(url: String): Boolean {
|
||||
// Trae OAuth 回调:拦截 127.0.0.1/localhost:18080/authorize,提取 userJwt
|
||||
// (部分流程会从 https 跳到 http,或把参数放 fragment)
|
||||
if (isTraeLoginCallback(url)) {
|
||||
DebugLog.i("TraeLogin", "回调命中: ${url.toSafeLogUrl()}")
|
||||
viewModel.onTraeCallback(url)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun isTraeLoginCallback(url: String): Boolean {
|
||||
val u = runCatching { android.net.Uri.parse(url) }.getOrNull()
|
||||
return u?.host in setOf("127.0.0.1", "localhost") &&
|
||||
u?.port == 18080 &&
|
||||
u?.path == "/authorize"
|
||||
}
|
||||
|
||||
override fun onPageStarted(view: WebView?, url: String?, favicon: android.graphics.Bitmap?) {
|
||||
pageLoading = true
|
||||
pageError = null
|
||||
DebugLog.i("TraeLogin", "pageStarted: ${url?.toSafeLogUrl()}")
|
||||
// 保险:某些重定向不一定走 shouldOverrideUrlLoading,
|
||||
// 在 pageStarted 也拦一次回调地址,避免页面停在 Authenticating。
|
||||
if (url != null && isTraeLoginCallback(url)) {
|
||||
viewModel.onTraeCallback(url)
|
||||
view?.stopLoading()
|
||||
return
|
||||
}
|
||||
super.onPageStarted(view, url, favicon)
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
url ?: return
|
||||
// 登录成功后面板会写入 localStorage.auth_token,抓到即自动保存
|
||||
view?.evaluateJavascript(viewModel.probeScript, null)
|
||||
viewModel.onPageFinished(url)
|
||||
DebugLog.i("TraeLogin", "pageFinished: ${url.toSafeLogUrl()}")
|
||||
}
|
||||
|
||||
override fun onReceivedError(
|
||||
view: WebView?,
|
||||
request: android.webkit.WebResourceRequest?,
|
||||
error: android.webkit.WebResourceError?
|
||||
) {
|
||||
// 只把“主框架”加载失败当成整页失败;图片/脚本/统计等子资源
|
||||
// ERR_CONNECTION_CLOSED 很常见,页面仍在加载,不能误报。
|
||||
if (request?.isForMainFrame != true) {
|
||||
DebugLog.i("TraeLogin", "subresourceError(ignored): ${request?.url?.toString()?.toSafeLogUrl()} ${error?.description}")
|
||||
super.onReceivedError(view, request, error)
|
||||
return
|
||||
}
|
||||
pageLoading = false
|
||||
pageError = error?.description?.toString() ?: request?.url?.toString().orEmpty()
|
||||
DebugLog.w("TraeLogin", "pageError(main): ${request?.url?.toString()?.toSafeLogUrl()} ${error?.description}")
|
||||
super.onReceivedError(view, request, error)
|
||||
}
|
||||
}
|
||||
webChromeClient = object : WebChromeClient() {
|
||||
override fun onProgressChanged(view: WebView?, newProgress: Int) {
|
||||
pageLoading = newProgress < 100
|
||||
// 页面仍在加载(子资源逐渐完成)时,自动清掉之前的瞬时网络错误提示
|
||||
if (newProgress > 0 && pageError != null && newProgress < 100) {
|
||||
pageError = null
|
||||
}
|
||||
super.onProgressChanged(view, newProgress)
|
||||
url?.let { viewModel.onPageFinished(it) }
|
||||
}
|
||||
}
|
||||
webChromeClient = WebChromeClient()
|
||||
loadUrl(uiState.loginUrl)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
pageError?.let { err ->
|
||||
Card(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(
|
||||
text = stringResource(R.string.error_network) + ": " + err,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Row(horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp)) {
|
||||
TextButton(onClick = {
|
||||
pageError = null
|
||||
webViewGeneration++
|
||||
}) {
|
||||
Text(stringResource(R.string.action_retry))
|
||||
}
|
||||
TextButton(onClick = {
|
||||
val url = uiState.loginUrl
|
||||
runCatching {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
)
|
||||
}
|
||||
}) {
|
||||
Text(stringResource(R.string.action_open_login_entry))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// loginUrl 为空 = ViewModel 正在生成授权地址(Trae/WorkBuddy 需要片刻)
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
Column(modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)) {
|
||||
Text(
|
||||
text = stringResource(R.string.error_login_url_missing),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -330,42 +147,3 @@ fun WebViewLoginScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
/**
|
||||
* JS → 原生桥:页面脚本通过 window.AndroidBridge.onProbe(json) 回传 localStorage 登录态。
|
||||
*
|
||||
* 用 @JavascriptInterface 注解的方法只允许传基本类型,这里统一走 JSON 字符串,
|
||||
* 解析失败按空处理(页面脚本也可能被站点 CSP 拦掉)。
|
||||
*/
|
||||
private class LoginProbeBridge(private val onProbe: (String) -> Unit) {
|
||||
@android.webkit.JavascriptInterface
|
||||
fun onProbe(json: String) {
|
||||
runCatching { onProbe(json) }
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析探测脚本回传的 JSON(扁平 string→string 结构)。 */
|
||||
private fun parseProbeJson(json: String): Map<String, String> = runCatching {
|
||||
val obj = org.json.JSONObject(json)
|
||||
buildMap {
|
||||
obj.keys().forEach { k ->
|
||||
val v = obj.optString(k, "")
|
||||
if (v.isNotEmpty()) put(k, v)
|
||||
}
|
||||
}
|
||||
}.getOrDefault(emptyMap())
|
||||
|
||||
/**
|
||||
* 脱敏日志 URL:只保留 scheme://host/path,去掉 query/fragment(OAuth 回调的
|
||||
* userJwt/userInfo 等敏感参数不落调试日志,与 ViewModel 侧“只记参数名”口径一致)。
|
||||
* 解析失败时回退为截断原文。
|
||||
*/
|
||||
private fun String.toSafeLogUrl(maxLen: Int = 200): String {
|
||||
val parsed = runCatching { android.net.Uri.parse(this) }.getOrNull()
|
||||
val safe = parsed?.let { u ->
|
||||
val host = u.host ?: ""
|
||||
buildString {
|
||||
append(u.scheme ?: "").append("://").append(host).append(u.path.orEmpty())
|
||||
}
|
||||
}
|
||||
return if (safe.isNullOrBlank()) take(maxLen) else safe
|
||||
}
|
||||
@ -1,29 +1,8 @@
|
||||
package com.rainy.token.ui.webview
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
import com.rainy.token.R
|
||||
import com.rainy.token.data.debug.DebugLog
|
||||
import com.rainy.token.data.proxy.LoginCallbackServer
|
||||
import com.rainy.token.data.repository.WebViewSessionSaver
|
||||
import com.rainy.token.data.repository.CredentialRepository
|
||||
import com.rainy.token.domain.model.Credential
|
||||
import com.rainy.token.domain.service.ServiceConfigProvider
|
||||
import com.rainy.token.domain.service.ServiceType
|
||||
@ -36,593 +15,38 @@ import kotlinx.coroutines.flow.update
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* WebView 登录容器 ViewModel。
|
||||
*
|
||||
* 支持三种服务的软件内登录:
|
||||
* - **Trae**:打开 trae.cn 授权页,用户登录后拦截 `http://127.0.0.1:18080/authorize`
|
||||
* 回调,解析 `userJwt` 中的 JWT 并保存为 [Credential.TraeCredential]。
|
||||
* - **WorkBuddy**:调用 copilot.tencent.com 设备授权 API 获取 QR 码 URL,
|
||||
* 在 WebView 中展示二维码,后台轮询直到用户扫码授权,保存为
|
||||
* [Credential.WorkBuddyCredential]。
|
||||
* - **Sub2API**:打开自托管面板登录页,注入 JS 读取 localStorage.auth_token。
|
||||
* WebView 登录容器 ViewModel。计划 4.1:
|
||||
* - 持有目标 URL(来自 ServiceConfig)
|
||||
* - 提供 [onLoginSuccess] 回调:抓 Cookie → 加密存 SecureStorage
|
||||
* - 跟踪"用户手动确认登录"状态(N 秒超时未识别成功时显示按钮)
|
||||
*/
|
||||
@HiltViewModel
|
||||
class WebViewLoginViewModel @Inject constructor(
|
||||
private val sessionSaver: WebViewSessionSaver,
|
||||
private val credentialRepository: CredentialRepository,
|
||||
private val okHttpClient: OkHttpClient
|
||||
private val sessionSaver: WebViewSessionSaver
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(WebViewLoginUiState())
|
||||
val uiState: StateFlow<WebViewLoginUiState> = _uiState.asStateFlow()
|
||||
|
||||
/** WorkBuddy 轮询协程,bind 新服务时取消。 */
|
||||
private var workBuddyPollingJob: Job? = null
|
||||
/** WorkBuddy 当前基址(决定 auth state/token 用 CN 还是 INTL)。 */
|
||||
private var workBuddyBase = "https://copilot.tencent.com"
|
||||
/** WorkBuddy 伪装头:Origin/Referer/Platform/UA 必须与官方客户端一致。 */
|
||||
private var workBuddyOrigin = "https://www.codebuddy.cn"
|
||||
private var workBuddyPlatform = "VSCode"
|
||||
private var workBuddyUserAgent = "CLI/2.143.1 CodeBuddy/2.143.1"
|
||||
/** WorkBuddy 授权等待超时:国内 5 分钟(扫码),INTL 15 分钟(邮箱/验证码/SSO)。 */
|
||||
private var workBuddyPollTimeoutMs = 300_000L
|
||||
|
||||
/** 目标账号(多账号服务);null = 新建账号。 */
|
||||
private var accountId: String? = null
|
||||
/** 当前登录目标区域(Trae/WorkBuddy);null = 其他服务。 */
|
||||
private var region: String? = null
|
||||
|
||||
fun bind(service: ServiceType, configuredBaseUrl: String? = null, accountId: String? = null, region: String? = null) {
|
||||
workBuddyPollingJob?.cancel()
|
||||
workBuddyPollingJob = null
|
||||
this.accountId = accountId?.takeIf { it.isNotBlank() }
|
||||
this.region = region?.takeIf { it.isNotBlank() }?.uppercase()
|
||||
|
||||
fun bind(service: ServiceType) {
|
||||
val config = ServiceConfigProvider.get(service)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
service = service,
|
||||
loginUrl = "",
|
||||
loginSucceeded = false,
|
||||
error = null,
|
||||
pendingManualConfirm = false
|
||||
loginUrl = config.loginUrl
|
||||
)
|
||||
}
|
||||
|
||||
// 每次进入登录页强制清掉 WebView 全局登录态,避免沿用上一个账号的 Cookie
|
||||
if (service == ServiceType.TRAE || service == ServiceType.WORKBUDDY) {
|
||||
clearWebViewSession()
|
||||
}
|
||||
|
||||
when (service) {
|
||||
ServiceType.TRAE -> bindTrae()
|
||||
ServiceType.WORKBUDDY -> bindWorkBuddy()
|
||||
ServiceType.OPENCODE_GO -> bindOpenCodeGo()
|
||||
else -> bindSub2Api(service, config, configuredBaseUrl)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── OpenCode Go ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* OpenCode Go 应用内登录:WebView 打开 opencode.ai/auth(GitHub / Google OAuth)。
|
||||
* 登录后用户会落到 dashboard(`https://opencode.ai/workspace/{workspaceId}/go`),
|
||||
* 此时从 URL 提取 workspaceId、从 CookieManager 提取 `auth` cookie,
|
||||
* 两者即 [OpenCodeGoRepository.fetchBalance] 所需的全部凭据,自动落库免手抄。
|
||||
*/
|
||||
private fun bindOpenCodeGo() {
|
||||
_uiState.update {
|
||||
it.copy(loginUrl = "https://opencode.ai/auth", trustedHosts = setOf("opencode.ai"))
|
||||
}
|
||||
}
|
||||
|
||||
/** dashboard URL → 提取 workspaceId 并连同 auth cookie 保存;成功返回 true。 */
|
||||
private fun trySaveOpenCodeSession(url: String): Boolean {
|
||||
val workspaceId = OPENCODE_WORKSPACE_REGEX.find(url)?.groupValues?.get(1) ?: return false
|
||||
val cookieString = android.webkit.CookieManager.getInstance().getCookie(url) ?: return false
|
||||
val authCookie = cookieString.split(";")
|
||||
.mapNotNull { entry ->
|
||||
val parts = entry.trim().split("=", limit = 2)
|
||||
if (parts.size != 2 || parts[0] != "auth") null else parts[1].trim()
|
||||
}
|
||||
.firstOrNull { it.isNotBlank() } ?: return false
|
||||
viewModelScope.launch {
|
||||
val saved = sessionSaver.saveOpenCodeGoSession(
|
||||
authCookie = authCookie,
|
||||
workspaceId = workspaceId
|
||||
)
|
||||
if (saved != null) {
|
||||
_uiState.update {
|
||||
it.copy(loginSucceeded = true, savedSession = saved, pendingManualConfirm = false, error = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ─── Trae ──────────────────────────────────────────────────────────────
|
||||
|
||||
private fun bindTrae() {
|
||||
val traceId = randomHex(16)
|
||||
val machineId = randomHex(32)
|
||||
// 设备 ID:claim 签到接口按 x-device-id 做设备级每日去重/风控,要求与 OAuth
|
||||
// 会话绑定的设备一致。这里持久化生成每机唯一 ID(首次生成后跨登录复用),
|
||||
// 登录 URL 与签到 claim 用同一个值。不能每次随机(会话绑定不一致会被风控拒),
|
||||
// 也不可用 userJwt.ClientID(固定客户端标识,所有用户相同,必被按设备限流)。
|
||||
val deviceId = traeDeviceId()
|
||||
val callbackUrl = buildTraeAuthUrl(LOGIN_WEBVIEW_CALLBACK_PORT)
|
||||
_uiState.update { it.copy(loginUrl = callbackUrl) }
|
||||
if (region == "INTL") {
|
||||
// 国际版登录页在系统浏览器完成,回调由本地服务器自动接收并回填
|
||||
openExternalBrowser()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
/** 构建 Trae 授权 URL;WebView 走 18080,外部浏览器走 18081(本地回调服务器)。 */
|
||||
private fun buildTraeAuthUrl(callbackPort: Int): String {
|
||||
val traceId = randomHex(16)
|
||||
val machineId = randomHex(32)
|
||||
val deviceId = traeDeviceId()
|
||||
val callbackUrl = "http://127.0.0.1:$callbackPort/authorize"
|
||||
val authRoot = if (region == "INTL") "https://www.trae.ai/authorization" else "https://www.trae.cn/authorization"
|
||||
val authUrl = buildString {
|
||||
append(authRoot).append("?")
|
||||
append("login_version=1&auth_from=solo&login_channel=native_ide&")
|
||||
append("auth_type=local&client_id=en1oxy7wnw8j9n&redirect=0&")
|
||||
append("login_trace_id=$traceId&")
|
||||
append("auth_callback_url=").append(java.net.URLEncoder.encode(callbackUrl, "UTF-8")).append("&")
|
||||
append("machine_id=$machineId&")
|
||||
append("device_id=$deviceId&")
|
||||
append("x_device_id=$deviceId&")
|
||||
append("x_machine_id=$machineId&")
|
||||
append("x_device_brand=PC&x_device_type=PC&")
|
||||
append("x_os_version=1.0&x_app_version=0.1.43&")
|
||||
append("x_app_type=stable&plugin_version=2.3.62834")
|
||||
}
|
||||
DebugLog.i("TraeLogin", "buildTraeAuthUrl: port=$callbackPort len=${authUrl.length} deviceId持久化=${deviceId.take(6)}…")
|
||||
return authUrl
|
||||
}
|
||||
|
||||
/** Trae 签到用的持久化设备 ID:首次生成 19 位数字并保存,此后跨登录复用。 */
|
||||
private fun traeDeviceId(): String {
|
||||
val prefs = com.rainy.token.RainyTokenApplication.appContext
|
||||
.getSharedPreferences("trae_login", android.content.Context.MODE_PRIVATE)
|
||||
prefs.getString("device_id", null)?.takeIf { it.isNotBlank() }?.let { return it }
|
||||
val id = randomDigits(19)
|
||||
prefs.edit().putString("device_id", id).apply()
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* Trae 授权回调拦截。URL 形如:
|
||||
* `http://127.0.0.1:18080/authorize?userJwt=<JSON>&userInfo=<JSON>&...`
|
||||
* 成功 URL 模式匹配(计划 4.1:精确匹配 + fallback)。
|
||||
*
|
||||
* `userJwt` 是 JSON 字符串,含 `{ Token, RefreshToken, ClientID, TokenExpireAt, ... }`。
|
||||
* 提取 `Token`(即 Cloud-IDE-JWT)保存为 [Credential.TraeCredential]。
|
||||
* 默认启发:URL 跳转到非登录域 + 路径不包含 /auth/login/signin 等关键词,
|
||||
* 即认为登录完成。具体规则各服务可在 [ServiceConfigProvider] 扩展。
|
||||
*/
|
||||
fun onTraeCallback(url: String) {
|
||||
val service = _uiState.value.service ?: return
|
||||
if (service != ServiceType.TRAE) return
|
||||
if (_uiState.value.loginSucceeded) return
|
||||
|
||||
val callbackUri = android.net.Uri.parse(url)
|
||||
// 参数同时支持 query 和 fragment(部分 OAuth 回调用 # 而非 ?)
|
||||
fun param(name: String): String? {
|
||||
callbackUri.getQueryParameter(name)?.let { return it }
|
||||
val frag = callbackUri.fragment
|
||||
if (!frag.isNullOrBlank()) {
|
||||
frag.split("&").forEach { pair ->
|
||||
val kv = pair.split("=", limit = 2)
|
||||
if (kv.size == 2 && kv[0] == name) return android.net.Uri.decode(kv[1])
|
||||
if (kv.size == 1 && kv[0] == name) return ""
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
val parsed = url.toHttpUrlOrNull() ?: run {
|
||||
DebugLog.w("TraeLogin", "onTraeCallback: URL 解析失败")
|
||||
return
|
||||
}
|
||||
// 只记录参数名(不含 token 值,避免把 token 明文落调试日志);query+both
|
||||
DebugLog.i("TraeLogin", "onTraeCallback: 收到回调,query=${callbackUri.query?.split("&")?.map { it.substringBefore("=") } ?: emptyList()} fragmentKeys=${callbackUri.fragment?.split("&")?.map { it.substringBefore("=") } ?: emptyList()}")
|
||||
// Trae 回调可能走两套:
|
||||
// 1) userJwt/user_jwt 直接带 Token+RefreshToken(正常流程)
|
||||
// 2) 只带 refreshToken(老流程),需要调用 ExchangeToken 兑换 Cloud-IDE-JWT
|
||||
val userJwtRaw = param("userJwt")
|
||||
?: param("user_jwt")
|
||||
?: ""
|
||||
viewModelScope.launch {
|
||||
val userJwt = runCatching {
|
||||
Json.parseToJsonElement(userJwtRaw).jsonObject
|
||||
}.getOrNull() ?: JsonObject(emptyMap())
|
||||
|
||||
var token = userJwt["Token"]?.jsonPrimitive?.contentOrNull
|
||||
?: userJwt["token"]?.jsonPrimitive?.contentOrNull
|
||||
?: userJwt["AccessToken"]?.jsonPrimitive?.contentOrNull
|
||||
?: userJwt["accessToken"]?.jsonPrimitive?.contentOrNull
|
||||
var refresh = userJwt["RefreshToken"]?.jsonPrimitive?.contentOrNull
|
||||
?: userJwt["refreshToken"]?.jsonPrimitive?.contentOrNull
|
||||
?: param("refreshToken")
|
||||
?: param("refresh_token")
|
||||
?: param("data")
|
||||
val clientId = userJwt["ClientID"]?.jsonPrimitive?.contentOrNull
|
||||
?: userJwt["clientId"]?.jsonPrimitive?.contentOrNull
|
||||
?: param("clientId")
|
||||
?: param("client_id")
|
||||
?: "en1oxy7wnw8j9n"
|
||||
|
||||
// 老流程:回调只有 refreshToken,先兑换 Cloud-IDE-JWT
|
||||
if (token.isNullOrBlank() && !refresh.isNullOrBlank()) {
|
||||
val exchangeHost = param("host")?.takeIf { it.startsWith("http") }
|
||||
?: if (region == "INTL") "https://a0ai-api-sg.byteintlapi.com" else "https://api.trae.cn"
|
||||
val exchanged = exchangeTraeRefreshToken(refresh, clientId, exchangeHost)
|
||||
if (exchanged != null) {
|
||||
token = exchanged.token
|
||||
refresh = exchanged.refreshToken
|
||||
DebugLog.i("TraeLogin", "onTraeCallback: refreshToken 兑换成功(host=${exchangeHost})")
|
||||
} else {
|
||||
DebugLog.w("TraeLogin", "onTraeCallback: refreshToken 兑换失败(host=${exchangeHost})")
|
||||
}
|
||||
}
|
||||
|
||||
if (token.isNullOrBlank()) {
|
||||
DebugLog.w("TraeLogin", "onTraeCallback: 收到回调但无 Token(userJwtKeys=${userJwt.keys})")
|
||||
_uiState.update {
|
||||
it.copy(error = UiText.Resource(R.string.error_login_token_missing))
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
// 设备绑定 ID:登录会话绑定的 App 持久化设备 ID(与授权 URL device_id 一致)。
|
||||
val deviceId = traeDeviceId()
|
||||
// 用户 ID:从回调 userInfo 参数提取,签到按 trae-check 口径拼 ${deviceId}-${userId}。
|
||||
val parsedUserId = param("userInfo")?.let { raw ->
|
||||
runCatching {
|
||||
val u = Json.parseToJsonElement(raw).jsonObject
|
||||
u["userId"]?.jsonPrimitive?.contentOrNull
|
||||
?: u["user_id"]?.jsonPrimitive?.contentOrNull
|
||||
?: u["id"]?.jsonPrimitive?.contentOrNull
|
||||
?: u["accountId"]?.jsonPrimitive?.contentOrNull
|
||||
}.getOrNull()
|
||||
}
|
||||
DebugLog.i("TraeLogin", "onTraeCallback: userId=${if (parsedUserId.isNullOrBlank()) "null" else "ok"}")
|
||||
|
||||
val credential = Credential.TraeCredential(
|
||||
service = ServiceType.TRAE,
|
||||
jwt = token,
|
||||
region = if (region == "INTL") "INTL" else "CN",
|
||||
apiHost = param("host")?.takeIf { it.startsWith("http") }?.trimEnd('/'),
|
||||
deviceId = deviceId,
|
||||
userId = parsedUserId?.takeIf { it.isNotBlank() },
|
||||
lastVerifiedAt = System.currentTimeMillis()
|
||||
)
|
||||
val effectiveId = credentialRepository.save(credential, accountId)
|
||||
_uiState.update {
|
||||
it.copy(loginSucceeded = true, pendingManualConfirm = false, error = null, loginAccountId = effectiveId.ifBlank { null })
|
||||
}
|
||||
DebugLog.i("TraeLogin", "onTraeCallback: 凭据已保存,登录完成")
|
||||
}
|
||||
}
|
||||
|
||||
/** 使用 refreshToken 向 Trae OAuth 兑换 Cloud-IDE-JWT(老流程回调没有 userJwt)。 */
|
||||
private data class TraeExchangeResult(val token: String, val refreshToken: String)
|
||||
|
||||
private suspend fun exchangeTraeRefreshToken(
|
||||
refreshToken: String,
|
||||
clientId: String,
|
||||
host: String
|
||||
): TraeExchangeResult? = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val payload = buildString {
|
||||
append("{\"ClientID\":\"").append(clientId)
|
||||
.append("\",\"RefreshToken\":\"").append(refreshToken)
|
||||
.append("\",\"ClientSecret\":\"-\",\"UserID\":\"\"}")
|
||||
}
|
||||
val request = Request.Builder()
|
||||
.url(host.trimEnd('/') + "/cloudide/api/v3/trae/oauth/ExchangeToken")
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.post(payload.toRequestBody("application/json".toMediaTypeOrNull()))
|
||||
.build()
|
||||
okHttpClient.newCall(request).execute().use { resp ->
|
||||
val body = resp.body?.string().orEmpty()
|
||||
DebugLog.i("TraeLogin", "ExchangeToken: http=${resp.code} bodyLen=${body.length}")
|
||||
val root = runCatching { Json.parseToJsonElement(body).jsonObject }.getOrNull() ?: return@use null
|
||||
val data = root["Result"]?.jsonObject ?: root["result"]?.jsonObject ?: root
|
||||
val token = data["Token"]?.jsonPrimitive?.contentOrNull
|
||||
?: data["token"]?.jsonPrimitive?.contentOrNull
|
||||
?: data["AccessToken"]?.jsonPrimitive?.contentOrNull
|
||||
?: data["accessToken"]?.jsonPrimitive?.contentOrNull
|
||||
if (token.isNullOrBlank()) {
|
||||
DebugLog.w("TraeLogin", "ExchangeToken: 返回无 Token(keys=${data.keys})")
|
||||
return@use null
|
||||
}
|
||||
val newRefresh = data["RefreshToken"]?.jsonPrimitive?.contentOrNull
|
||||
?: data["refreshToken"]?.jsonPrimitive?.contentOrNull
|
||||
?: refreshToken
|
||||
TraeExchangeResult(token, newRefresh)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
DebugLog.w("TraeLogin", "ExchangeToken: 异常=" + (e.message ?: "unknown"))
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// ─── WorkBuddy ─────────────────────────────────────────────────────────
|
||||
|
||||
private fun bindWorkBuddy() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
if (region == "INTL") {
|
||||
workBuddyBase = "https://www.workbuddy.ai"
|
||||
workBuddyOrigin = "https://www.workbuddy.ai"
|
||||
workBuddyPlatform = "workbuddy-ai"
|
||||
workBuddyPollTimeoutMs = 900_000L
|
||||
} else {
|
||||
workBuddyBase = "https://copilot.tencent.com"
|
||||
workBuddyOrigin = "https://www.codebuddy.cn"
|
||||
workBuddyPlatform = "VSCode"
|
||||
workBuddyPollTimeoutMs = 300_000L
|
||||
}
|
||||
workBuddyUserAgent = "CLI/2.143.1 CodeBuddy/2.143.1"
|
||||
val request = Request.Builder()
|
||||
.url(workBuddyBase + "/v2/plugin/auth/state?platform=" + workBuddyPlatform)
|
||||
.addHeader("User-Agent", workBuddyUserAgent)
|
||||
.addHeader("Origin", workBuddyOrigin)
|
||||
.addHeader("Referer", workBuddyOrigin + "/")
|
||||
.addHeader("X-Client-ID", "codebuddy-cli")
|
||||
.addHeader("X-Client-Version", "2.143.1")
|
||||
.addHeader("X-Product", "SaaS")
|
||||
.addHeader("Accept", "application/json, text/plain, */*")
|
||||
.addHeader("X-Requested-With", "XMLHttpRequest")
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.post("{}".toRequestBody("application/json".toMediaTypeOrNull()))
|
||||
.build()
|
||||
val (body, ok) = withContext(Dispatchers.IO) {
|
||||
val resp = okHttpClient.newCall(request).execute()
|
||||
val s = resp.body?.string().orEmpty()
|
||||
s to resp.isSuccessful
|
||||
}
|
||||
if (!ok || body.isBlank()) {
|
||||
_uiState.update {
|
||||
it.copy(error = UiText.Resource(R.string.error_network_generic))
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
val json = Json.parseToJsonElement(body).jsonObject
|
||||
val data = json["data"]?.jsonObject
|
||||
val state = data?.get("state")?.jsonPrimitive?.contentOrNull
|
||||
val authURL = data?.get("authUrl")?.jsonPrimitive?.contentOrNull
|
||||
?: data?.get("authURL")?.jsonPrimitive?.contentOrNull
|
||||
DebugLog.i("WorkBuddyLogin", "bind: host=${workBuddyBase} bodyKeys=${json.keys} dataKeys=${data?.keys ?: emptySet()}")
|
||||
if (state.isNullOrBlank() || authURL.isNullOrBlank()) {
|
||||
_uiState.update {
|
||||
it.copy(error = UiText.Resource(R.string.error_network_generic))
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
_uiState.update { it.copy(loginUrl = authURL) }
|
||||
workBuddyPollingJob = viewModelScope.launch { pollWorkBuddyToken(state) }
|
||||
if (region == "INTL") {
|
||||
// 国际版登录页在系统浏览器完成,凭据由轮询自动回填
|
||||
openExternalBrowser()
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
_uiState.update {
|
||||
it.copy(error = UiText.Resource(R.string.error_network_generic))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 每 2 秒轮询一次 token 接口,超时按站点 Profile(CN 5 分钟 / INTL 15 分钟)。 */
|
||||
private suspend fun pollWorkBuddyToken(state: String) {
|
||||
val timeoutMs = workBuddyPollTimeoutMs
|
||||
val startTime = System.currentTimeMillis()
|
||||
while (System.currentTimeMillis() - startTime < timeoutMs) {
|
||||
if (_uiState.value.loginSucceeded) return
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.url(workBuddyBase + "/v2/plugin/auth/token?state=$state")
|
||||
.addHeader("User-Agent", workBuddyUserAgent)
|
||||
.addHeader("Origin", workBuddyOrigin)
|
||||
.addHeader("Referer", workBuddyOrigin + "/")
|
||||
.addHeader("X-Client-ID", "codebuddy-cli")
|
||||
.addHeader("X-Client-Version", "2.143.1")
|
||||
.addHeader("X-Product", "SaaS")
|
||||
.addHeader("X-No-Authorization", "1")
|
||||
.addHeader("Accept", "application/json, text/plain, */*")
|
||||
.addHeader("X-Requested-With", "XMLHttpRequest")
|
||||
.get()
|
||||
.build()
|
||||
val body = withContext(Dispatchers.IO) {
|
||||
val resp = okHttpClient.newCall(request).execute()
|
||||
val s = resp.body?.string().orEmpty()
|
||||
resp.close()
|
||||
s
|
||||
}
|
||||
DebugLog.i("WorkBuddyLogin", "poll: state=${state.take(6)}… bodyLen=${body.length}")
|
||||
val json = Json.parseToJsonElement(body).jsonObject
|
||||
val codeText = json["code"]?.jsonPrimitive?.contentOrNull
|
||||
val code = codeText?.toIntOrNull() ?: json["code"]?.jsonPrimitive?.intOrNull ?: -1
|
||||
val rawData = json["data"]?.jsonObject ?: json.jsonObject
|
||||
DebugLog.i(
|
||||
"WorkBuddyLogin",
|
||||
"poll: code=$codeText bodyKeys=${json.keys} dataKeys=${rawData.keys}"
|
||||
)
|
||||
if (code == 0 || codeText == "0") {
|
||||
// 兼容多种字段:accessToken/access_token/token/authToken,refreshToken/refresh_token,expiresIn/expires_at
|
||||
val accessToken = rawData.let { obj ->
|
||||
obj["accessToken"]?.jsonPrimitive?.contentOrNull
|
||||
?: obj["access_token"]?.jsonPrimitive?.contentOrNull
|
||||
?: obj["token"]?.jsonPrimitive?.contentOrNull
|
||||
?: obj["authToken"]?.jsonPrimitive?.contentOrNull
|
||||
}
|
||||
val refreshToken = rawData.let { obj ->
|
||||
obj["refreshToken"]?.jsonPrimitive?.contentOrNull
|
||||
?: obj["refresh_token"]?.jsonPrimitive?.contentOrNull
|
||||
?: obj["refresh"]?.jsonPrimitive?.contentOrNull
|
||||
}
|
||||
val expiresIn = rawData.let { obj ->
|
||||
obj["expiresIn"]?.jsonPrimitive?.longOrNull
|
||||
?: obj["expires_in"]?.jsonPrimitive?.longOrNull
|
||||
?: obj["expiresAt"]?.jsonPrimitive?.longOrNull
|
||||
?: 7200L
|
||||
}
|
||||
if (!accessToken.isNullOrBlank()) {
|
||||
val expiresAt = System.currentTimeMillis() + expiresIn * 1000
|
||||
// 刚成功登录的凭据视为已验证(见 Trae 同款注释)
|
||||
val credential = Credential.WorkBuddyCredential(
|
||||
service = ServiceType.WORKBUDDY,
|
||||
accessToken = accessToken,
|
||||
refreshToken = refreshToken ?: "",
|
||||
region = if (region == "INTL") "INTL" else "CN",
|
||||
expiresAt = expiresAt,
|
||||
lastVerifiedAt = System.currentTimeMillis()
|
||||
)
|
||||
val effectiveId = credentialRepository.save(credential, accountId)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
loginSucceeded = true,
|
||||
pendingManualConfirm = false,
|
||||
error = null,
|
||||
loginAccountId = effectiveId.ifBlank { null }
|
||||
)
|
||||
}
|
||||
DebugLog.i("WorkBuddyLogin", "poll: 凭证已保存,登录完成")
|
||||
return
|
||||
}
|
||||
DebugLog.w("WorkBuddyLogin", "poll: code=0 但 data 无 accessToken(dataKeys=${rawData.keys})")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
DebugLog.w("WorkBuddyLogin", "poll: 异常=${e.message ?: "unknown"}")
|
||||
}
|
||||
delay(2000)
|
||||
}
|
||||
_uiState.update { it.copy(error = UiText.Resource(R.string.error_login_timeout)) }
|
||||
}
|
||||
|
||||
// ─── Sub2API (existing) ────────────────────────────────────────────────
|
||||
|
||||
private fun bindSub2Api(
|
||||
service: ServiceType,
|
||||
config: com.rainy.token.domain.service.ServiceConfig,
|
||||
configuredBaseUrl: String?
|
||||
) {
|
||||
_uiState.update {
|
||||
it.copy(service = service, loginUrl = config.loginUrl, trustedHosts = emptySet())
|
||||
}
|
||||
viewModelScope.launch {
|
||||
val base = configuredBaseUrl
|
||||
?: (credentialRepository.get(service) as? Credential.Sub2ApiCredential)?.baseUrl
|
||||
val hosts = buildSet {
|
||||
base?.toHttpUrlOrNull()?.host?.let { add(it) }
|
||||
config.loginUrl.toHttpUrlOrNull()?.host?.let { add(it) }
|
||||
}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
loginUrl = config.loginUrl.ifBlank { base.orEmpty() },
|
||||
trustedHosts = hosts
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 「在浏览器中打开登录入口」:Trae 起本地回调服务器,登录完自动回传凭证;
|
||||
* WorkBuddy 直接打开浏览器并继续原有轮询(官方登录本身就在浏览器完成)。
|
||||
*/
|
||||
fun openExternalBrowser() {
|
||||
val service = _uiState.value.service ?: return
|
||||
val currentUrl = _uiState.value.loginUrl
|
||||
when (service) {
|
||||
ServiceType.TRAE -> {
|
||||
if (!LoginCallbackServer.start(LOGIN_EXTERNAL_CALLBACK_PORT)) {
|
||||
_uiState.update {
|
||||
it.copy(error = UiText.Resource(R.string.error_login_callback_server))
|
||||
}
|
||||
return
|
||||
}
|
||||
val authUrl = buildTraeAuthUrl(LOGIN_EXTERNAL_CALLBACK_PORT)
|
||||
_uiState.update {
|
||||
it.copy(loginUrl = authUrl, externalBrowserWaiting = true, error = null)
|
||||
}
|
||||
launchExternalBrowser(authUrl)
|
||||
viewModelScope.launch {
|
||||
val callback = LoginCallbackServer.await(LOGIN_EXTERNAL_TIMEOUT_MS)
|
||||
if (callback != null) {
|
||||
onTraeCallback(callback)
|
||||
} else if (!_uiState.value.loginSucceeded) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
error = UiText.Resource(R.string.error_login_timeout),
|
||||
externalBrowserWaiting = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ServiceType.WORKBUDDY -> {
|
||||
if (currentUrl.isNotBlank()) {
|
||||
_uiState.update {
|
||||
it.copy(loginUrl = currentUrl, externalBrowserWaiting = true, error = null)
|
||||
}
|
||||
launchExternalBrowser(currentUrl)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
if (currentUrl.isNotBlank()) {
|
||||
launchExternalBrowser(currentUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun launchExternalBrowser(url: String) {
|
||||
runCatching {
|
||||
val ctx = com.rainy.token.RainyTokenApplication.appContext
|
||||
val intent = android.content.Intent(
|
||||
android.content.Intent.ACTION_VIEW,
|
||||
android.net.Uri.parse(url)
|
||||
).addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
ctx.startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
/** 清除 WebView 登录缓存(Cookie/Storage),用于更换账号登录。 */
|
||||
fun clearWebViewSession() {
|
||||
runCatching {
|
||||
val cm = android.webkit.CookieManager.getInstance()
|
||||
cm.removeAllCookies(null)
|
||||
cm.removeSessionCookies(null)
|
||||
android.webkit.WebStorage.getInstance().deleteAllData()
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Page lifecycle ────────────────────────────────────────────────────
|
||||
|
||||
fun onPageFinished(url: String) {
|
||||
val current = _uiState.value
|
||||
if (current.loginSucceeded) return
|
||||
// OpenCode:URL 到 dashboard(/workspace/{id}/go)即自动提取凭据,无 dashboard
|
||||
// (如停在 /auth 登录页)则静默等待,不打扰用户操作。
|
||||
if (_uiState.value.service == ServiceType.OPENCODE_GO) {
|
||||
if (!trySaveOpenCodeSession(url) && !looksLikeLoggedInPage(url)) {
|
||||
_uiState.update { it.copy(pendingManualConfirm = false) }
|
||||
}
|
||||
return
|
||||
}
|
||||
// Trae 的登录完成由 OAuth 回调(127.0.0.1:18080/authorize?userJwt=...,onTraeCallback)
|
||||
// 自动判定,onPageFinished 的 URL 关键词启发式对 Trae 不适用:授权页 URL 含 /auth,
|
||||
// 会在用户尚在页面操作时就误弹「登录完成?」提示;且 saveSession 的 cookie 抓取
|
||||
// 对 JWT 凭据无效,反而可能覆盖已保存的凭据。Trae 流程静默等待回调即可,
|
||||
// 想放弃登录由 BackHandler 返回。
|
||||
if (_uiState.value.service == ServiceType.TRAE) return
|
||||
// WorkBuddy 的登录完成由 token 轮询(pollWorkBuddyToken)自动判定:扫码页面 URL
|
||||
// 含 /authorize,关键词启发式会误弹「登录完成?」;且确认后走 cookie 抓取会把
|
||||
// 错误的 SessionCredential 保存到 WORKBUDDY 服务。静默等待轮询结果即可,
|
||||
// 轮询成功自动落库并回调返回,超时由 pollWorkBuddyToken 置 error。
|
||||
if (_uiState.value.service == ServiceType.WORKBUDDY) return
|
||||
if (looksLikeLoggedInPage(url)) {
|
||||
saveSession(url)
|
||||
} else {
|
||||
@ -631,17 +55,6 @@ class WebViewLoginViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
fun confirmLoginManually() {
|
||||
val service = _uiState.value.service ?: return
|
||||
// 防御性守卫:Trae/WorkBuddy/OpenCode 均通过各自的自动识别(回调/轮询/URL 正则)
|
||||
// 完成登录,不应用 cookie 抓取兜底覆盖凭据;未来若有新入口误将 pendingManualConfirm
|
||||
// 置位,这里直接短路,避免把错误的 SessionCredential 写入这些服务。
|
||||
if (service == ServiceType.TRAE ||
|
||||
service == ServiceType.WORKBUDDY ||
|
||||
service == ServiceType.OPENCODE_GO
|
||||
) {
|
||||
dismissManualPrompt()
|
||||
return
|
||||
}
|
||||
val url = _uiState.value.loginUrl
|
||||
saveSession(url)
|
||||
}
|
||||
@ -650,53 +63,8 @@ class WebViewLoginViewModel @Inject constructor(
|
||||
_uiState.update { it.copy(pendingManualConfirm = false) }
|
||||
}
|
||||
|
||||
fun onLocalStorageProbed(values: Map<String, String>) {
|
||||
val service = _uiState.value.service ?: return
|
||||
if (_uiState.value.loginSucceeded) return
|
||||
if (!isValidProbe(values[SUB2_NONCE_KEY])) return
|
||||
if (service != ServiceType.SUB2API) return
|
||||
val token = values[SUB2_AUTH_TOKEN_KEY].orEmpty()
|
||||
if (token.isBlank()) return
|
||||
val origin = values[SUB2_ORIGIN_KEY].orEmpty()
|
||||
if (!isTrustedOrigin(origin)) return
|
||||
viewModelScope.launch {
|
||||
val saved = sessionSaver.saveSub2ApiPanelSession(
|
||||
baseUrl = origin.ifBlank { _uiState.value.loginUrl },
|
||||
authToken = token,
|
||||
siteIcon = values[SUB2_ICON_KEY],
|
||||
email = values[SUB2_EMAIL_KEY],
|
||||
accountId = this@WebViewLoginViewModel.accountId
|
||||
)
|
||||
if (saved != null) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
loginSucceeded = true,
|
||||
pendingManualConfirm = false,
|
||||
error = null,
|
||||
// 回填实际写入的账号 ID:新建账号流程返回编辑页时保持编辑该账号,
|
||||
// 避免用户再次点保存产生重复账号
|
||||
loginAccountId = saved.accountId.ifBlank { null }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isTrustedOrigin(origin: String): Boolean {
|
||||
val probeHost = origin.toHttpUrlOrNull()?.host ?: return false
|
||||
val allowed = _uiState.value.trustedHosts
|
||||
if (allowed.isEmpty()) return false
|
||||
return allowed.any { it.equals(probeHost, ignoreCase = true) }
|
||||
}
|
||||
|
||||
private val probeNonce: String = java.util.UUID.randomUUID().toString().replace("-", "")
|
||||
val probeScript: String get() = buildProbeScript(probeNonce)
|
||||
fun isValidProbe(nonce: String?): Boolean =
|
||||
nonce != null && nonce.isNotEmpty() && nonce == probeNonce
|
||||
|
||||
private fun saveSession(url: String) {
|
||||
val service = _uiState.value.service ?: return
|
||||
viewModelScope.launch {
|
||||
val saved = sessionSaver.saveFromCookieManager(service = service, url = url)
|
||||
if (saved != null) {
|
||||
_uiState.update {
|
||||
@ -715,79 +83,20 @@ class WebViewLoginViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun looksLikeLoggedInPage(url: String): Boolean {
|
||||
val u = url.lowercase()
|
||||
// 简单启发:URL 不再包含登录路径关键字
|
||||
val loginKeywords = listOf("/auth", "/login", "/signin", "/oauth")
|
||||
return loginKeywords.none { u.contains(it) }
|
||||
}
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
private fun randomHex(length: Int): String {
|
||||
val bytes = ByteArray(length / 2)
|
||||
java.security.SecureRandom().nextBytes(bytes)
|
||||
return bytes.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
private fun randomDigits(length: Int): String {
|
||||
val sb = StringBuilder(length)
|
||||
val rnd = java.security.SecureRandom()
|
||||
repeat(length) { sb.append(rnd.nextInt(10)) }
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
workBuddyPollingJob?.cancel()
|
||||
super.onCleared()
|
||||
}
|
||||
}
|
||||
|
||||
private const val SUB2_AUTH_TOKEN_KEY = "auth_token"
|
||||
private const val SUB2_ORIGIN_KEY = "origin"
|
||||
private const val SUB2_ICON_KEY = "site_icon"
|
||||
private const val SUB2_EMAIL_KEY = "email"
|
||||
private const val SUB2_NONCE_KEY = "nonce"
|
||||
|
||||
/** OpenCode dashboard URL 中的 workspaceId:`/workspace/{id}/go`。 */
|
||||
private val OPENCODE_WORKSPACE_REGEX = Regex("""/workspace/([^/?#]+)/go""")
|
||||
|
||||
private const val LOGIN_WEBVIEW_CALLBACK_PORT = 18080
|
||||
private const val LOGIN_EXTERNAL_CALLBACK_PORT = 18081
|
||||
private const val LOGIN_EXTERNAL_TIMEOUT_MS = 20 * 60 * 1000L
|
||||
|
||||
internal fun buildProbeScript(nonce: String): String = """
|
||||
(function () {
|
||||
try {
|
||||
var out = {};
|
||||
var t = window.localStorage.getItem('auth_token');
|
||||
if (t) out.auth_token = t;
|
||||
var u = window.localStorage.getItem('auth_user');
|
||||
if (u) {
|
||||
try { out.email = (JSON.parse(u) || {}).email || ''; } catch (e) {}
|
||||
}
|
||||
var icon = document.querySelector('link[rel~="icon"]');
|
||||
if (icon && icon.href) out.site_icon = icon.href;
|
||||
out.origin = window.location.origin;
|
||||
out.nonce = '$nonce';
|
||||
if (window.AndroidBridge && window.AndroidBridge.onProbe) {
|
||||
window.AndroidBridge.onProbe(JSON.stringify(out));
|
||||
}
|
||||
} catch (e) {}
|
||||
})();
|
||||
"""
|
||||
|
||||
data class WebViewLoginUiState(
|
||||
val service: ServiceType? = null,
|
||||
val loginUrl: String = "",
|
||||
val trustedHosts: Set<String> = emptySet(),
|
||||
val loginSucceeded: Boolean = false,
|
||||
val savedSession: Credential.SessionCredential? = null,
|
||||
val pendingManualConfirm: Boolean = false,
|
||||
val error: UiText? = null,
|
||||
/** Trae/WorkBuddy 登录实际写入的账号 ID(新建账号模式时用于回填信用编辑页)。 */
|
||||
val loginAccountId: String? = null,
|
||||
/** 已在手机浏览器打开登录页,等待回调/轮询自动带回凭证。 */
|
||||
val externalBrowserWaiting: Boolean = false
|
||||
val error: UiText? = null
|
||||
)
|
||||
@ -1,118 +0,0 @@
|
||||
package com.rainy.token.ui.webview
|
||||
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import com.rainy.token.R
|
||||
|
||||
/**
|
||||
* 通用 WebView 页面容器。用于在 App 内打开外部操作页(如 DeepSeek 充值页)。
|
||||
*
|
||||
* - 复用 [WebViewLoginScreen] 的 WebView 模式:JS/DOM 存储开启,适配移动视口;
|
||||
* - 页面加载后用页面标题替换顶栏标题(失败则保持传入标题);
|
||||
* - 返回键优先回退页内历史,无历史时退出页面。
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun WebViewPageScreen(
|
||||
url: String,
|
||||
title: String,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
var webView: WebView? = remember { null }
|
||||
var pageTitle by remember { mutableStateOf(title) }
|
||||
|
||||
// 顶栏返回与系统返回保持一致:优先回退页内历史,无历史才退出
|
||||
val goBack: () -> Unit = {
|
||||
if (webView?.canGoBack() == true) {
|
||||
webView?.goBack()
|
||||
} else {
|
||||
onBack()
|
||||
}
|
||||
}
|
||||
BackHandler(onBack = goBack)
|
||||
|
||||
// 页面销毁时释放 WebView 原生资源,避免内存泄漏;先移除父级再 destroy,规避
|
||||
// "Calling destroy while still attached" 的极端时序崩溃
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
webView?.let { view ->
|
||||
view.parent?.let { (it as? ViewGroup)?.removeView(view) }
|
||||
view.destroy()
|
||||
}
|
||||
webView = null
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(pageTitle) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = goBack) {
|
||||
Icon(
|
||||
Icons.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.action_back)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
) {
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
WebView(context).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
settings.useWideViewPort = true
|
||||
settings.loadWithOverviewMode = true
|
||||
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
// 用网页真实标题替换占位标题(忽略空白标题)
|
||||
view?.title?.takeIf { it.isNotBlank() }?.let { loaded ->
|
||||
if (loaded != pageTitle) pageTitle = loaded
|
||||
}
|
||||
}
|
||||
}
|
||||
webChromeClient = WebChromeClient()
|
||||
webView = this
|
||||
loadUrl(url)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -8,15 +8,11 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.res.ColorStateList
|
||||
import android.util.TypedValue
|
||||
import android.view.View
|
||||
import android.widget.RemoteViews
|
||||
import com.rainy.token.MainActivity
|
||||
import com.rainy.token.R
|
||||
import com.rainy.token.data.cache.BalanceCache
|
||||
import com.rainy.token.data.cache.balanceCacheDataStore
|
||||
import com.rainy.token.data.local.SecureStorage
|
||||
import com.rainy.token.data.local.secureStorageDataStore
|
||||
import com.rainy.token.data.repository.CredentialRepository
|
||||
import com.rainy.token.domain.service.ServiceType
|
||||
import com.rainy.token.ui.components.normalizeWindowLabel
|
||||
import com.rainy.token.util.LocaleManager
|
||||
@ -24,7 +20,6 @@ import kotlinx.coroutines.runBlocking
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* OpenCode Go 桌面小组件(MIUI Widget)。
|
||||
@ -61,15 +56,12 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
|
||||
appWidgetManager: AppWidgetManager,
|
||||
appWidgetIds: IntArray
|
||||
) {
|
||||
var selectedHasCachedData = false
|
||||
|
||||
// 每次渲染都用最新语言偏好重新包装 context:Application 层的 wrap 在进程启动时固定,
|
||||
// 应用内切换语言后不会自动更新,必须在这里按当前偏好重建,否则 widget 文本停留在旧语言
|
||||
val localized = LocaleManager.wrapContext(context)
|
||||
|
||||
// 已配置服务只读一次(Keystore 解密有成本);未配置的服务不进轮换、DS 行整体隐藏
|
||||
val configured = configuredServices(context)
|
||||
val dsConfigured = isCredentialPresent(context, ServiceType.DEEPSEEK)
|
||||
val selectedService = currentDisplayService(context, configured)
|
||||
|
||||
for (widgetId in appWidgetIds) {
|
||||
val views = RemoteViews(context.packageName, R.layout.widget_opencode_go)
|
||||
|
||||
@ -111,34 +103,7 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
|
||||
try {
|
||||
val dataStore = context.applicationContext.balanceCacheDataStore
|
||||
val cache = BalanceCache(dataStore)
|
||||
views.setViewVisibility(
|
||||
R.id.widget_ds_row,
|
||||
if (dsConfigured) View.VISIBLE else View.GONE
|
||||
)
|
||||
if (selectedService == null || configured.isEmpty()) {
|
||||
// 轮换区没有任何已配置服务:
|
||||
// - DeepSeek 已配置 → 标题回退「DeepSeek 余额」,只展示 DS 余额行
|
||||
// - 全都没配置 → 展示引导空态,不展示任何服务数据
|
||||
views.setTextViewText(R.id.widget_switch, "")
|
||||
val emptyLogo = if (dsConfigured) ServiceType.DEEPSEEK else ServiceType.OPENCODE_GO
|
||||
views.setImageViewResource(R.id.widget_logo, widgetLogo(emptyLogo))
|
||||
// DeepSeek logo 是方形,XML 默认 22x12 会压扁,用正方形盒
|
||||
if (emptyLogo == ServiceType.DEEPSEEK) {
|
||||
views.setViewLayoutWidth(R.id.widget_logo, 14f, TypedValue.COMPLEX_UNIT_DIP)
|
||||
views.setViewLayoutHeight(R.id.widget_logo, 14f, TypedValue.COMPLEX_UNIT_DIP)
|
||||
} else {
|
||||
views.setViewLayoutWidth(R.id.widget_logo, 22f, TypedValue.COMPLEX_UNIT_DIP)
|
||||
views.setViewLayoutHeight(R.id.widget_logo, 12f, TypedValue.COMPLEX_UNIT_DIP)
|
||||
}
|
||||
views.setTextViewText(
|
||||
R.id.widget_service_title,
|
||||
localized.getString(
|
||||
if (dsConfigured) R.string.widget_deepseek_balance
|
||||
else R.string.widget_not_configured
|
||||
)
|
||||
)
|
||||
setEmptyState(views, localized)
|
||||
} else {
|
||||
val selectedService = currentDisplayService(context)
|
||||
views.setTextViewText(R.id.widget_switch, shortName(selectedService))
|
||||
views.setTextViewText(
|
||||
R.id.widget_service_title,
|
||||
@ -155,6 +120,7 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
|
||||
}
|
||||
val cached = cache.get(selectedService)
|
||||
if (cached != null) {
|
||||
selectedHasCachedData = true
|
||||
populateServiceRows(views, localized, selectedService, cached.balance)
|
||||
|
||||
val sdf = SimpleDateFormat("MM-dd HH:mm", Locale.getDefault())
|
||||
@ -171,10 +137,8 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
|
||||
localized.getString(R.string.widget_service_quota, selectedService.displayName)
|
||||
)
|
||||
}
|
||||
}
|
||||
// DeepSeek 余额独立于轮换区填充,且必须放在所有 setEmptyState 之后:
|
||||
// setEmptyState 会把 widget_ds_amount 写成 "—",后填的真值才能覆盖它
|
||||
if (dsConfigured) {
|
||||
|
||||
// DeepSeek 余额
|
||||
val dsCached = cache.get(ServiceType.DEEPSEEK)
|
||||
if (dsCached != null && dsCached.balance.amount > 0) {
|
||||
val dsBal = dsCached.balance
|
||||
@ -183,7 +147,6 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
|
||||
} else {
|
||||
views.setTextViewText(R.id.widget_ds_amount, "—")
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
setEmptyState(views, localized)
|
||||
}
|
||||
@ -192,11 +155,8 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
|
||||
appWidgetManager.updateAppWidget(widgetId, views)
|
||||
}
|
||||
|
||||
// 自动刷新:有已配置服务且超过冷却时间 → 触发后台刷新。
|
||||
// 缓存命中与否不参与决策——receiver 现在无条件重渲染,若以「缓存为空」为由
|
||||
// 绕过冷却,刷新失败时会形成 onUpdate → 广播 → 失败 → 重渲染 → 再广播的
|
||||
// 无冷却重试环;冷却约束是唯一的节流阀。
|
||||
if ((configured.isNotEmpty() || dsConfigured) && shouldAutoRefresh(context)) {
|
||||
// 自动刷新:缓存为空 或 超过冷却时间 → 触发后台刷新
|
||||
if (!selectedHasCachedData || shouldAutoRefresh(context)) {
|
||||
markAutoRefreshTime(context)
|
||||
context.sendBroadcast(WidgetRefreshReceiver.createIntent(context))
|
||||
}
|
||||
@ -213,13 +173,13 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
|
||||
ServiceType.OPENCODE_GO -> {
|
||||
setRowLabel(views, context.getString(R.string.window_5h_short), context.getString(R.string.window_weekly), context.getString(R.string.window_monthly))
|
||||
populateRow(views, R.id.row1_pct, R.id.row1_bar, R.id.row1_reset,
|
||||
pct = extras["rolling.pct"]?.toFloatOrNull()?.roundToInt(),
|
||||
pct = extras["rolling.pct"]?.toIntOrNull(),
|
||||
resetSec = extras["rolling.resetInSec"]?.toLongOrNull())
|
||||
populateRow(views, R.id.row2_pct, R.id.row2_bar, R.id.row2_reset,
|
||||
pct = extras["weekly.pct"]?.toFloatOrNull()?.roundToInt(),
|
||||
pct = extras["weekly.pct"]?.toIntOrNull(),
|
||||
resetSec = extras["weekly.resetInSec"]?.toLongOrNull())
|
||||
populateRow(views, R.id.row3_pct, R.id.row3_bar, R.id.row3_reset,
|
||||
pct = extras["monthly.pct"]?.toFloatOrNull()?.roundToInt(),
|
||||
pct = extras["monthly.pct"]?.toIntOrNull(),
|
||||
resetSec = extras["monthly.resetInSec"]?.toLongOrNull())
|
||||
}
|
||||
ServiceType.COMMANDCODE_GO -> {
|
||||
@ -244,28 +204,25 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
|
||||
.distinct().maxOrNull()?.plus(1) ?: 0
|
||||
val weeklyLabel = context.getString(R.string.window_every_week)
|
||||
val monthlyLabel = context.getString(R.string.window_every_month)
|
||||
val usageLabel = context.getString(R.string.window_usage)
|
||||
// 仅主模型窗口进入桌面组件;Spark 独立限额为次要通道,不占用组件行位
|
||||
val normal = mutableListOf<Triple<String, Int?, Long?>>()
|
||||
for (index in 0 until windowCount) {
|
||||
if (extras["window_$index.group"] == "SPARK") continue
|
||||
val rawLabel = extras["window_$index.label"] ?: "usage"
|
||||
val windows = (0 until windowCount).map { index ->
|
||||
val label = normalizeWindowLabel(
|
||||
rawLabel,
|
||||
extras["window_$index.label"] ?: "Usage",
|
||||
weeklyLabel = weeklyLabel,
|
||||
monthlyLabel = monthlyLabel,
|
||||
usageLabel = usageLabel
|
||||
monthlyLabel = monthlyLabel
|
||||
)
|
||||
val remaining = extras["window_$index.remainingPct"]?.toIntOrNull()
|
||||
val resetAt = extras["window_$index.resetAt"]?.toLongOrNull()?.takeIf { it > 0 }
|
||||
normal.add(Triple(label, remaining?.let { (100 - it).coerceIn(0, 100) }, resetAt?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 }))
|
||||
Triple(label, remaining?.let { (100 - it).coerceIn(0, 100) }, resetAt?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 })
|
||||
}
|
||||
// 判断是否有 5h 窗口
|
||||
val has5h = normal.any { it.first.contains("5") }
|
||||
// 始终保留 5h 槽位在第一行(按索引取行,避免内容相同的窗口被误过滤)
|
||||
val row1Idx = if (has5h) (normal.indexOfFirst { it.first.contains("5") }.takeIf { it >= 0 } ?: 0) else -1
|
||||
val row1 = if (row1Idx >= 0) normal[row1Idx] else Triple("5h", null, null)
|
||||
val otherWindows = normal.filterIndexed { idx, _ -> idx != row1Idx }
|
||||
val has5h = windows.any { it.first.contains("5") }
|
||||
// 始终保留 5h 槽位在第一行
|
||||
val row1 = if (!has5h) {
|
||||
Triple("5h", null, null)
|
||||
} else {
|
||||
windows.firstOrNull { it.first.contains("5") } ?: windows.getOrNull(0) ?: Triple("5h", null, null)
|
||||
}
|
||||
val otherWindows = windows.filter { it != row1 }
|
||||
val row2 = otherWindows.getOrNull(0) ?: Triple(weeklyLabel, null, null)
|
||||
val row3 = otherWindows.getOrNull(1) ?: Triple(monthlyLabel, null, null)
|
||||
setRowLabel(views, row1.first, row2.first, row3.first)
|
||||
@ -279,9 +236,6 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
|
||||
}
|
||||
}
|
||||
ServiceType.DEEPSEEK -> setEmptyState(views, context)
|
||||
ServiceType.TRAE,
|
||||
ServiceType.WORKBUDDY,
|
||||
ServiceType.SUB2API -> setEmptyState(views, context)
|
||||
ServiceType.OLLAMA -> {
|
||||
setRowLabel(views, context.getString(R.string.window_5h_short), context.getString(R.string.window_every_week), "")
|
||||
populateRow(views, R.id.row1_pct, R.id.row1_bar, R.id.row1_reset,
|
||||
@ -339,9 +293,9 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
|
||||
/** 根据用量百分比动态改进度条颜色 */
|
||||
private fun setProgressColor(views: RemoteViews, barViewId: Int, pct: Int) {
|
||||
val color = when {
|
||||
pct >= 80 -> 0xFFD64A6E.toInt() // 玫红
|
||||
pct >= 80 -> 0xFFE91E63.toInt() // 玫红
|
||||
pct >= 50 -> 0xFFFFA726.toInt() // 暖橙
|
||||
else -> 0xFFC9436C.toInt() // 深玫瑰(主色)
|
||||
else -> 0xFFFF85A2.toInt() // 草莓粉
|
||||
}
|
||||
views.setColorStateList(barViewId, "setProgressTintList", ColorStateList.valueOf(color))
|
||||
}
|
||||
@ -384,57 +338,14 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
|
||||
.apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* 已配置凭据的服务(本地存在 credential 即视为已配置)。
|
||||
* 小组件只在这些服务间轮换展示;未配置的不占位。
|
||||
* 注:只覆盖 [DISPLAY_SERVICES](轮换区),DeepSeek 单独用 [isCredentialPresent] 判断。
|
||||
*/
|
||||
fun configuredServices(context: Context): List<ServiceType> {
|
||||
val repository = widgetCredentialRepository(context)
|
||||
return runCatching {
|
||||
runBlocking {
|
||||
DISPLAY_SERVICES.filter { repository.get(it) != null }
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
fun currentDisplayService(context: Context): ServiceType {
|
||||
val key = autoRefreshPrefs(context).getString(KEY_DISPLAY_SERVICE, ServiceType.OPENCODE_GO.storageKey)
|
||||
return DISPLAY_SERVICES.firstOrNull { it.storageKey == key } ?: ServiceType.OPENCODE_GO
|
||||
}
|
||||
|
||||
/** 指定服务本地是否已保存凭据(DeepSeek 余额行显隐用)。 */
|
||||
fun isCredentialPresent(context: Context, service: ServiceType): Boolean {
|
||||
val repository = widgetCredentialRepository(context)
|
||||
return runCatching {
|
||||
runBlocking { repository.get(service) != null }
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
private fun widgetCredentialRepository(context: Context): CredentialRepository {
|
||||
val appContext = context.applicationContext
|
||||
return CredentialRepository(
|
||||
secureStorage = SecureStorage(appContext.secureStorageDataStore),
|
||||
balanceCache = BalanceCache(appContext.balanceCacheDataStore)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前展示的服务:仅在已配置集合内取用户选择;选择失效(服务被删除)
|
||||
* 时回退第一个已配置服务;一个都没配置返回 null(Widget 展示引导空态)。
|
||||
*/
|
||||
fun currentDisplayService(context: Context): ServiceType? =
|
||||
currentDisplayService(context, configuredServices(context))
|
||||
|
||||
/** 同上,但复用已读好的 [configured],避免一次更新里多次 Keystore 解密。 */
|
||||
fun currentDisplayService(context: Context, configured: List<ServiceType>): ServiceType? {
|
||||
if (configured.isEmpty()) return null
|
||||
val key = autoRefreshPrefs(context).getString(KEY_DISPLAY_SERVICE, null)
|
||||
return configured.firstOrNull { it.storageKey == key } ?: configured.first()
|
||||
}
|
||||
|
||||
/** 只在已配置服务间轮换。 */
|
||||
private fun switchDisplayService(context: Context) {
|
||||
val configured = configuredServices(context)
|
||||
if (configured.size <= 1) return
|
||||
val current = currentDisplayService(context, configured)
|
||||
val currentIndex = current?.let { configured.indexOf(it) } ?: -1
|
||||
val next = configured[(currentIndex.coerceAtLeast(0) + 1) % configured.size]
|
||||
val current = currentDisplayService(context)
|
||||
val next = DISPLAY_SERVICES[(DISPLAY_SERVICES.indexOf(current).coerceAtLeast(0) + 1) % DISPLAY_SERVICES.size]
|
||||
autoRefreshPrefs(context).edit().putString(KEY_DISPLAY_SERVICE, next.storageKey).apply()
|
||||
}
|
||||
|
||||
@ -444,9 +355,6 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
|
||||
ServiceType.CODEX -> "Codex"
|
||||
ServiceType.DEEPSEEK -> "DS"
|
||||
ServiceType.OLLAMA -> "Ollama"
|
||||
ServiceType.TRAE -> "Trae"
|
||||
ServiceType.WORKBUDDY -> "WB"
|
||||
ServiceType.SUB2API -> "S2API"
|
||||
}
|
||||
|
||||
private fun widgetLogo(service: ServiceType): Int = when (service) {
|
||||
@ -454,10 +362,6 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
|
||||
ServiceType.CODEX -> R.drawable.ic_codex_logo_widget // PNG for RemoteViews compatibility
|
||||
ServiceType.DEEPSEEK -> R.drawable.ic_deepseek_logo
|
||||
ServiceType.OLLAMA -> R.drawable.ic_ollama_logo_widget
|
||||
// 新服务暂不进桌面组件轮播(DISPLAY_SERVICES 未包含),这里用占位 logo 保证穷尽
|
||||
ServiceType.TRAE,
|
||||
ServiceType.WORKBUDDY,
|
||||
ServiceType.SUB2API -> R.drawable.ic_deepseek_logo
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -23,30 +23,26 @@ class WidgetRefreshReceiver : BroadcastReceiver() {
|
||||
if (isRefreshing) return
|
||||
isRefreshing = true
|
||||
val pendingResult = goAsync()
|
||||
OpenCodeGoWidgetProvider.showRefreshing(appContext)
|
||||
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
// EntryPoint 获取也放进 try:任何异常都必须落到 finally,
|
||||
// 否则 isRefreshing 卡死 true、pendingResult 泄漏
|
||||
val entryPoint = EntryPointAccessors.fromApplication(
|
||||
appContext, WidgetRefreshEntryPoint::class.java
|
||||
)
|
||||
val useCase = entryPoint.refreshBalanceUseCase()
|
||||
OpenCodeGoWidgetProvider.showRefreshing(appContext)
|
||||
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
val selectedService = OpenCodeGoWidgetProvider.currentDisplayService(appContext)
|
||||
val dsConfigured = OpenCodeGoWidgetProvider.isCredentialPresent(appContext, ServiceType.DEEPSEEK)
|
||||
if (selectedService != null || dsConfigured) {
|
||||
withTimeoutOrNull(25_000L) {
|
||||
// 只刷新「当前展示的服务 + 已配置的 DeepSeek」;未配置的不发请求
|
||||
selectedService?.let { useCase(it) }
|
||||
if (dsConfigured) useCase(ServiceType.DEEPSEEK)
|
||||
}
|
||||
val refreshed = withTimeoutOrNull(25_000L) {
|
||||
val selectedResult = useCase(selectedService)
|
||||
val dsResult = useCase(ServiceType.DEEPSEEK)
|
||||
selectedResult.isSuccess || dsResult.isSuccess
|
||||
} == true
|
||||
if (refreshed) {
|
||||
OpenCodeGoWidgetProvider.notifyDataChanged(appContext)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// 静默,Widget 保留旧数据
|
||||
} finally {
|
||||
// 无论成败都重渲染:清掉「刷新中」占位,零配置时也能立即恢复空态文案
|
||||
OpenCodeGoWidgetProvider.notifyDataChanged(appContext)
|
||||
isRefreshing = false
|
||||
pendingResult.finish()
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 17 KiB |
@ -3,8 +3,8 @@
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<corners android:radius="16dp" />
|
||||
<solid android:color="#1A1417" />
|
||||
<solid android:color="#2D1825" />
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="#4C3B44" />
|
||||
android:color="#4D2D3D" />
|
||||
</shape>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 488 B |
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user