feat: CCGO 用量功能 + 首页布局修复 + 文档同步
- 新增 CommandCode Go (CCGO) 用量抓取与展示 - CCGO 输入 token 语义修正(inputTokens = tokensIn - cacheRead) - 清除数据按钮迁移至 CCGO 详情页顶栏(带倒计时二次确认) - 移除三个 ViewModel 的 init 自动加载,改为 LaunchedEffect 显式触发 - 修复 hiltViewModel key 碰撞导致 CCGO 详情页无限加载 - 首页 LazyColumn 改为 Column + verticalScroll + PullToRefreshBox - OCGO/CCGO 页面 autoLoad 参数隔离重复 load - AGENTS.md / README.md 文档同步更新
This commit is contained in:
parent
bb51efde09
commit
cc7aecc331
1
.gitignore
vendored
1
.gitignore
vendored
@ -19,3 +19,4 @@ release.jks
|
|||||||
.operit/
|
.operit/
|
||||||
.kotlin/
|
.kotlin/
|
||||||
*.jks
|
*.jks
|
||||||
|
tmp_cc/
|
||||||
|
|||||||
42
AGENTS.md
42
AGENTS.md
@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
## 项目概述
|
## 项目概述
|
||||||
|
|
||||||
Android(Jetpack Compose + Kotlin)APP,统一查询 DeepSeek、OpenCode Go 两项服务的余额/配额。
|
Android(Jetpack Compose + Kotlin)APP,统一查询 DeepSeek、OpenCode Go、CommandCode Go 三项服务的余额/配额。
|
||||||
DeepSeek 走 REST API,OpenCode Go 通过 OkHttp 抓取 dashboard HTML 解析 SSR hydration 数据。
|
DeepSeek 走 REST API,OpenCode Go 通过 OkHttp 抓取 dashboard HTML 解析 SSR hydration 数据。
|
||||||
APP 名为「雨晴Token」(粉色调品牌),配套桌面小组件。
|
CommandCode Go 走 JSON API 抓取用量数据。APP 名为「雨晴Token」(粉色调品牌),配套桌面小组件。
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
@ -21,20 +21,43 @@ APP 名为「雨晴Token」(粉色调品牌),配套桌面小组件。
|
|||||||
**服务**:
|
**服务**:
|
||||||
- ✅ DeepSeek — REST API `GET /user/balance`,API Key 认证
|
- ✅ DeepSeek — REST API `GET /user/balance`,API Key 认证
|
||||||
- ✅ OpenCode Go — OkHttp 抓 dashboard HTML,解析 `rollingUsage`/`weeklyUsage`/`monthlyUsage`
|
- ✅ OpenCode Go — OkHttp 抓 dashboard HTML,解析 `rollingUsage`/`weeklyUsage`/`monthlyUsage`
|
||||||
|
- ✅ CommandCode Go — JSON API 抓取用量数据,`CommandCodeUsageRepository` 解析(workspaceId = `"commandcode"`)
|
||||||
- ❌ OpenCode Zen / 小米 MiMo — 未实现
|
- ❌ OpenCode Zen / 小米 MiMo — 未实现
|
||||||
|
|
||||||
**用量统计系统**:
|
**用量统计系统**:
|
||||||
- ✅ `UsageCache`(DataStore,~3700 条记录)— 全量 JSON 序列化 + 内存缓存(`@Volatile cachedAll`),仅在写入后失效
|
- ✅ `UsageCache`(DataStore,~3700 条记录)— 全量 JSON 序列化 + 内存缓存(`@Volatile cachedAll`),仅在写入后失效
|
||||||
- ✅ `SyncUsageUseCase` — 首次全量同步(cursor 翻页)、增量同步(逐页比对本地 ID 集合)
|
- ✅ `SyncUsageUseCase`(OCGO)/ `SyncCommandCodeUsageUseCase`(CCGO) — 首次全量同步(cursor 翻页)、增量同步(逐页比对本地 ID 集合)
|
||||||
- ✅ `UsageViewModel` — `loadStatsInternal()` 单次 `getRecords()`→ 内存聚合 Overview/ModelStats/DailyStats,所有重操作包在 `withContext(Dispatchers.Default)` 避免主线程卡顿
|
- ✅ `UsageViewModel` — `loadStatsInternal()` 单次 `getRecords()`→ 内存聚合 Overview/ModelStats/DailyStats,所有重操作包在 `withContext(Dispatchers.Default)` 避免主线程卡顿
|
||||||
- ✅ `UsageChartViewModel` — 图表粒度(5h/24h/今天/昨天/7天/当月/自定义日/月/范围),模型多选,3 张 Canvas 图表
|
- ✅ `UsageChartViewModel` — 图表粒度(5h/24h/今天/昨天/7天/当月/自定义日/月/范围),模型多选,3 张 Canvas 图表
|
||||||
- ✅ `UsageDataViewModel` — 原始记录分页浏览(20条/页),支持时间+模型筛选,页码输入跳转
|
- ✅ `UsageDataViewModel` — 原始记录分页浏览(20条/页),支持时间+模型筛选,页码输入跳转
|
||||||
- ✅ 全局刷新绑定 — Dashboard 下拉刷新 → `DashboardViewModel.refresh()` → `UsageViewModel.sync()`(增量)
|
- ✅ 全局刷新绑定 — Dashboard 下拉刷新 → `DashboardViewModel.refresh()` → `UsageViewModel.sync()`(增量)
|
||||||
|
|
||||||
|
**ViewModel 加载机制红线**:
|
||||||
|
|
||||||
|
> ⚠️ 三个 ViewModel 的 `init` 块**已移除**,不再自动加载。数据加载由 Composable 层的 `LaunchedEffect(Unit)` 显式触发:
|
||||||
|
> - OCGO 页面:`LaunchedEffect(Unit) { viewModel.load() / loadStats() / loadData() }`
|
||||||
|
> - CCGO 页面:`LaunchedEffect(Unit) { viewModel.setWorkspace(wid) }`(`setWorkspace` 内部调 `load()`)
|
||||||
|
> - CCGO 页面通过 `autoLoad = false` 参数跳过 Screen 内的 `LaunchedEffect` 重复 load
|
||||||
|
>
|
||||||
|
> 原因:`init` 自动加载时 `workspaceIdOverride` 为 null,协程读到 OCGO 凭据,导致 CCGO 页面闪现 OCGO 数据。
|
||||||
|
|
||||||
|
**hiltViewModel key 红线**:
|
||||||
|
|
||||||
|
> ⚠️ `hiltViewModel(key = key)` 的 key 在 ViewModelStore 内全局唯一、不区分类型。
|
||||||
|
> CCGO 路由中 `UsageChartViewModel` 和 `UsageViewModel` 用相同 key 会导致类型碰撞、加载失败。
|
||||||
|
> 当前方案:`chartVm` 用 `"ccgo_chart_$wid"`,`usageVm` 用 `"ccgo_$wid"`(与 Dashboard 首页 `CommandCodeUsageStatsCard` 共享实例)。
|
||||||
|
|
||||||
|
**首页布局**:
|
||||||
|
|
||||||
|
> Dashboard 使用 `PullToRefreshBox` → `Column` + `verticalScroll`(非 `LazyColumn`)。
|
||||||
|
> 页面仅 7 个 item,`LazyColumn` 的 dispose/recompose 会导致用量卡片的 `LaunchedEffect` 反复触发,产生卡顿。
|
||||||
|
|
||||||
**页面导航**:
|
**页面导航**:
|
||||||
```
|
```
|
||||||
Dashboard → UsageDetail(图表) → UsageOverview(总统计)
|
Dashboard → OCGO: UsageDetail(图表) → UsageOverview(总统计)
|
||||||
↘ UsageData(原始数据)
|
↘ UsageData(原始数据)
|
||||||
|
→ CCGO: CCGO_USAGE_DETAIL(图表) → CCGO_USAGE_OVERVIEW(总统计)
|
||||||
|
↘ CCGO_USAGE_DATA(原始数据)
|
||||||
```
|
```
|
||||||
|
|
||||||
**桌面小组件(Widget)**:
|
**桌面小组件(Widget)**:
|
||||||
@ -77,11 +100,20 @@ DashboardViewModel.refresh()
|
|||||||
→ OpenCodeGoWidgetProvider.notifyDataChanged(context)
|
→ OpenCodeGoWidgetProvider.notifyDataChanged(context)
|
||||||
|
|
||||||
Dashboard 下拉刷新 → usageSyncTrigger++ → UsageViewModel.sync()
|
Dashboard 下拉刷新 → usageSyncTrigger++ → UsageViewModel.sync()
|
||||||
→ SyncUsageUseCase.fullSync() / incrementalSync()
|
→ OCGO: SyncUsageUseCase.fullSync() / incrementalSync()
|
||||||
→ OpenCodeUsageRepository.fetchPage(cursor) 逐页抓取
|
→ OpenCodeUsageRepository.fetchPage(cursor) 逐页抓取
|
||||||
→ UsageCache.insertAll() → persist() → invalidateCache()
|
→ UsageCache.insertAll() → persist() → invalidateCache()
|
||||||
|
→ CCGO: SyncCommandCodeUsageUseCase.fullSync() / incrementalSync()
|
||||||
|
→ CommandCodeUsageRepository.fetchPage(cursor) 逐页抓取
|
||||||
→ UsageViewModel.loadStats() → getRecords() → 内存聚合
|
→ UsageViewModel.loadStats() → getRecords() → 内存聚合
|
||||||
|
|
||||||
|
CCGO 清除按钮(详情页顶栏):
|
||||||
|
点击 → AlertDialog 警告弹窗 → 3s 倒计时确认
|
||||||
|
→ UsageViewModel.clearAndResync()
|
||||||
|
→ UsageCache.deleteByWorkspaceId("commandcode")
|
||||||
|
→ SyncCommandCodeUsageUseCase.fullSync()
|
||||||
|
→ loadStats() → onBack()
|
||||||
|
|
||||||
Widget 刷新按钮:
|
Widget 刷新按钮:
|
||||||
↻ → PendingIntent.getBroadcast() → WidgetRefreshReceiver
|
↻ → PendingIntent.getBroadcast() → WidgetRefreshReceiver
|
||||||
→ EntryPoints → RefreshBalanceUseCase(DEEPSEEK + OPENCODE_GO)
|
→ EntryPoints → RefreshBalanceUseCase(DEEPSEEK + OPENCODE_GO)
|
||||||
|
|||||||
20
README.md
20
README.md
@ -5,7 +5,7 @@
|
|||||||
[](https://github.com/CATMIAOZHI/Rainytoken/actions)
|
[](https://github.com/CATMIAOZHI/Rainytoken/actions)
|
||||||
[](https://github.com/CATMIAOZHI/Rainytoken/releases)
|
[](https://github.com/CATMIAOZHI/Rainytoken/releases)
|
||||||
|
|
||||||
Android AI 余额查询 APP —— 统一查看 DeepSeek 和 OpenCode Go 的余额与用量配额。粉色调品牌 UI,配套桌面小组件。
|
Android AI 余额查询 APP —— 统一查看 DeepSeek、OpenCode Go 和 CommandCode Go 的余额与用量配额。粉色调品牌 UI,配套桌面小组件。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -13,13 +13,13 @@ Android AI 余额查询 APP —— 统一查看 DeepSeek 和 OpenCode Go 的余
|
|||||||
|
|
||||||
| 特性 | 说明 |
|
| 特性 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 📊 **仪表盘** | DeepSeek 余额(¥)+ OpenCode Go 用量(5h/本周/本月)+ 下拉全局刷新 |
|
| 📊 **仪表盘** | DeepSeek 余额(¥)+ OpenCode Go 用量(5h/本周/本月)+ CommandCode Go 余额($)+ 下拉全局刷新 |
|
||||||
| 📈 **用量图表** | 3 张 Canvas 手绘图表 — 消耗金额 / API 请求次数 / Token 消耗 |
|
| 📈 **用量图表** | 3 张 Canvas 手绘图表 — 消耗金额 / API 请求次数 / Token 消耗(OCGO & CCGO 双数据源) |
|
||||||
| 📋 **详细数据** | 原始记录分页浏览,支持时间 + 模型筛选,点击查看完整字段 |
|
| 📋 **详细数据** | 原始记录分页浏览,支持时间 + 模型筛选,点击查看完整字段 |
|
||||||
| 🔍 **多粒度筛选** | 5小时 / 24小时 / 今天 / 昨天 / 最近7天 / 当月 / 自定义日·月·范围 |
|
| 🔍 **多粒度筛选** | 5小时 / 24小时 / 今天 / 昨天 / 最近7天 / 当月 / 自定义日·月·范围 |
|
||||||
| 🏷️ **模型筛选** | 多选 / 单选 / 全选,动态图例自适应换行 |
|
| 🏷️ **模型筛选** | 多选 / 单选 / 全选,动态图例自适应换行 |
|
||||||
| 📱 **桌面小组件** | 不打开 APP 也能看用量;可拖入负一屏;划到即自动刷新(MIUI 曝光刷新) |
|
| 📱 **桌面小组件** | 不打开 APP 也能看用量;可拖入负一屏;划到即自动刷新(MIUI 曝光刷新) |
|
||||||
| 🔄 **自动同步** | 首页下拉自动同步用量;无缓存时启动自动全量同步 |
|
| 🔄 **自动同步** | 首页下拉自动同步用量;无缓存时启动自动全量同步;CCGO 详情页支持手动清除并重新同步 |
|
||||||
| 🌙 **深色模式** | 全局自适应 — App 内文字/图标/背景自动切换,小组件独立适配暗色布局 |
|
| 🌙 **深色模式** | 全局自适应 — App 内文字/图标/背景自动切换,小组件独立适配暗色布局 |
|
||||||
| ➕ **一键添桌面** | APP 内点 + 直接添加小组件,不用去系统列表翻 |
|
| ➕ **一键添桌面** | APP 内点 + 直接添加小组件,不用去系统列表翻 |
|
||||||
| ⚡ **内存缓存** | DataStore 全量 JSON 仅反序列化一次,后续操作零 IO |
|
| ⚡ **内存缓存** | DataStore 全量 JSON 仅反序列化一次,后续操作零 IO |
|
||||||
@ -55,13 +55,13 @@ Android AI 余额查询 APP —— 统一查看 DeepSeek 和 OpenCode Go 的余
|
|||||||
│ ┌────────────────────▼─────────────────────────┐ │
|
│ ┌────────────────────▼─────────────────────────┐ │
|
||||||
│ │ UseCase 层 │ │
|
│ │ UseCase 层 │ │
|
||||||
│ │ RefreshBalanceUseCase(余额) │ │
|
│ │ RefreshBalanceUseCase(余额) │ │
|
||||||
│ │ SyncUsageUseCase(用量全量/增量同步) │ │
|
│ │ SyncUsageUseCase / SyncCommandCodeUsageUseCase│ │
|
||||||
│ └────────────────────┬────────────────────────┘ │
|
│ └────────────────────┬────────────────────────┘ │
|
||||||
│ │ │
|
│ │ │
|
||||||
│ ┌────────────────────▼─────────────────────────┐ │
|
│ ┌────────────────────▼─────────────────────────┐ │
|
||||||
│ │ Repository + Network │ │
|
│ │ Repository + Network │ │
|
||||||
│ │ DeepSeekApi(Retrofit)· OpenCodeGo 网页抓取 │ │
|
│ │ DeepSeekApi(Retrofit)· OpenCodeGo 网页抓取 │ │
|
||||||
│ │ · OpenCodeUsageRepository(分页翻页) │ │
|
│ │ · OpenCodeUsageRepository · CommandCodeUsageRepository │ │
|
||||||
│ └────────────────────┬────────────────────────┘ │
|
│ └────────────────────┬────────────────────────┘ │
|
||||||
│ │ │
|
│ │ │
|
||||||
│ ┌────────────────────▼─────────────────────────┐ │
|
│ ┌────────────────────▼─────────────────────────┐ │
|
||||||
@ -83,14 +83,14 @@ Rainytoken/
|
|||||||
│ ├── data/
|
│ ├── data/
|
||||||
│ │ ├── cache/ # BalanceCache(DataStore)
|
│ │ ├── cache/ # BalanceCache(DataStore)
|
||||||
│ │ ├── local/ # UsageCache(DataStore + 内存缓存)、UsageRecord、ChartBucket
|
│ │ ├── local/ # UsageCache(DataStore + 内存缓存)、UsageRecord、ChartBucket
|
||||||
│ │ ├── remote/ # DeepSeekApi(Retrofit)+ OpenCodeGo 抓取 + OpenCodeUsageRepository
|
│ │ ├── remote/ # DeepSeekApi(Retrofit)+ OpenCodeGo 抓取 + UsageRepository
|
||||||
│ │ └── repository/ # DeepSeekRepository / OpenCodeGoRepository / CredentialRepository
|
│ │ └── repository/ # DeepSeek / OpenCodeGo / CommandCode / Credential Repository
|
||||||
│ ├── domain/
|
│ ├── domain/
|
||||||
│ │ ├── model/ # ServiceBalance、Credential 等
|
│ │ ├── model/ # ServiceBalance、Credential 等
|
||||||
│ │ ├── service/ # ServiceType 枚举
|
│ │ ├── service/ # ServiceType 枚举
|
||||||
│ │ └── usecase/ # RefreshBalanceUseCase / SyncUsageUseCase
|
│ │ └── usecase/ # RefreshBalanceUseCase / SyncUsageUseCase / SyncCommandCodeUsageUseCase
|
||||||
│ ├── ui/
|
│ ├── ui/
|
||||||
│ │ ├── dashboard/ # DashboardScreen / UsageDetailScreen(图表)/ UsageOverviewScreen(总统计)/ UsageDataScreen(原始数据)
|
│ │ ├── dashboard/ # DashboardScreen / UsageDetailScreen / UsageOverviewScreen / UsageDataScreen
|
||||||
│ │ ├── widget/ # 桌面小组件(OpenCodeGoWidgetProvider)
|
│ │ ├── widget/ # 桌面小组件(OpenCodeGoWidgetProvider)
|
||||||
│ │ ├── components/ # ServiceIcon / StatusChip 等
|
│ │ ├── components/ # ServiceIcon / StatusChip 等
|
||||||
│ │ ├── theme/ # 雨晴粉主题(StrawberryPink / InkMuted)
|
│ │ ├── theme/ # 雨晴粉主题(StrawberryPink / InkMuted)
|
||||||
|
|||||||
@ -77,8 +77,18 @@ class UsageCache(
|
|||||||
return loadAll().map { it.id }.toSet()
|
return loadAll().map { it.id }.toSet()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 按 workspaceId 删除所有记录。用于修复旧数据格式问题后重新全量同步。 */
|
||||||
|
suspend fun deleteByWorkspaceId(workspaceId: String) {
|
||||||
|
val current = loadAll().toMutableList()
|
||||||
|
val removed = current.removeAll { it.workspaceId == workspaceId }
|
||||||
|
if (removed) persist(current)
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun count(): Int = loadAll().size
|
suspend fun count(): Int = loadAll().size
|
||||||
|
|
||||||
|
/** 按 workspaceId 统计记录数。 */
|
||||||
|
suspend fun count(workspaceId: String): Int = loadAll().count { it.workspaceId == workspaceId }
|
||||||
|
|
||||||
/** 获取过滤后的原始记录列表(供图表等聚合使用) */
|
/** 获取过滤后的原始记录列表(供图表等聚合使用) */
|
||||||
suspend fun getRecords(
|
suspend fun getRecords(
|
||||||
workspaceId: String,
|
workspaceId: String,
|
||||||
|
|||||||
@ -42,12 +42,13 @@ class CommandCodeGoRepository(
|
|||||||
val credential = credentialRepository.get(ServiceType.COMMANDCODE_GO)
|
val credential = credentialRepository.get(ServiceType.COMMANDCODE_GO)
|
||||||
?: return@withContext Result.failure(RepositoryError.InvalidCredential())
|
?: return@withContext Result.failure(RepositoryError.InvalidCredential())
|
||||||
|
|
||||||
if (credential !is Credential.ApiKeyCredential) {
|
if (credential !is Credential.SessionCredential) {
|
||||||
return@withContext Result.failure(RepositoryError.InvalidCredential())
|
return@withContext Result.failure(RepositoryError.InvalidCredential())
|
||||||
}
|
}
|
||||||
|
|
||||||
val apiKey = credential.key
|
// token 字段存 API Key
|
||||||
if (apiKey.isBlank()) {
|
val apiKey = credential.token
|
||||||
|
if (apiKey.isNullOrBlank()) {
|
||||||
return@withContext Result.failure(RepositoryError.InvalidCredential())
|
return@withContext Result.failure(RepositoryError.InvalidCredential())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package com.rainy.token.data.repository
|
|||||||
|
|
||||||
import com.rainy.token.data.local.UsageRecord
|
import com.rainy.token.data.local.UsageRecord
|
||||||
import com.rainy.token.domain.model.Credential
|
import com.rainy.token.domain.model.Credential
|
||||||
|
import com.rainy.token.domain.model.CookieEntry
|
||||||
import com.rainy.token.domain.service.ServiceType
|
import com.rainy.token.domain.service.ServiceType
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@ -41,16 +42,22 @@ class CommandCodeUsageRepository(
|
|||||||
const val PAGE_SIZE = 100
|
const val PAGE_SIZE = 100
|
||||||
/** cost / DENOM = USD */
|
/** cost / DENOM = USD */
|
||||||
const val COST_DENOM = 100_000_000L
|
const val COST_DENOM = 100_000_000L
|
||||||
private const val CCGO_WORKSPACE_ID = "commandcode"
|
/** CCGO 用量数据在 UsageCache 中的 workspaceId 区分键 */
|
||||||
|
const val CCGO_WORKSPACE_ID = "commandcode"
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getApiKey(): String {
|
private suspend fun getCookieHeader(): String {
|
||||||
val c = credentialRepository.get(ServiceType.COMMANDCODE_GO)
|
val c = credentialRepository.get(ServiceType.COMMANDCODE_GO)
|
||||||
?: throw RepositoryError.InvalidCredential()
|
?: throw RepositoryError.InvalidCredential()
|
||||||
if (c !is Credential.ApiKeyCredential || c.key.isBlank()) {
|
if (c !is Credential.SessionCredential) {
|
||||||
throw RepositoryError.InvalidCredential()
|
throw RepositoryError.InvalidCredential()
|
||||||
}
|
}
|
||||||
return c.key
|
// 优先用 cookies 列表,否则尝试从 authCookie 字段解析
|
||||||
|
if (c.cookies.isNotEmpty()) {
|
||||||
|
return c.cookies.joinToString("; ") { "${it.name}=${it.value}" }
|
||||||
|
}
|
||||||
|
// fallback: 如果用户通过旧版接口存了 authCookie,尝试恢复
|
||||||
|
throw RepositoryError.InvalidCredential()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -60,8 +67,8 @@ class CommandCodeUsageRepository(
|
|||||||
*/
|
*/
|
||||||
suspend fun fetchPage(cursor: String?): Result<Pair<List<UsageRecord>, String?>> =
|
suspend fun fetchPage(cursor: String?): Result<Pair<List<UsageRecord>, String?>> =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val apiKey = try {
|
val cookieHeader = try {
|
||||||
getApiKey()
|
getCookieHeader()
|
||||||
} catch (e: RepositoryError) {
|
} catch (e: RepositoryError) {
|
||||||
return@withContext Result.failure(e)
|
return@withContext Result.failure(e)
|
||||||
}
|
}
|
||||||
@ -73,7 +80,7 @@ class CommandCodeUsageRepository(
|
|||||||
|
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
.url(url)
|
.url(url)
|
||||||
.header("Authorization", "Bearer $apiKey")
|
.header("Cookie", cookieHeader)
|
||||||
.header("Accept", "application/json")
|
.header("Accept", "application/json")
|
||||||
.header("Origin", "https://commandcode.ai")
|
.header("Origin", "https://commandcode.ai")
|
||||||
.header("Referer", "https://commandcode.ai/")
|
.header("Referer", "https://commandcode.ai/")
|
||||||
@ -87,14 +94,18 @@ class CommandCodeUsageRepository(
|
|||||||
}
|
}
|
||||||
|
|
||||||
response.use { resp ->
|
response.use { resp ->
|
||||||
|
val body = resp.body?.string()
|
||||||
if (!resp.isSuccessful) {
|
if (!resp.isSuccessful) {
|
||||||
if (resp.code == 401 || resp.code == 403) {
|
if (resp.code == 401 || resp.code == 403) {
|
||||||
return@withContext Result.failure(RepositoryError.InvalidCredential())
|
val detail = if (body != null && body.length < 200) ":$body" else ""
|
||||||
|
return@withContext Result.failure(RepositoryError.InvalidCredential(
|
||||||
|
RuntimeException("HTTP ${resp.code}$detail")
|
||||||
|
))
|
||||||
}
|
}
|
||||||
return@withContext Result.failure(RepositoryError.ServerError(resp.code))
|
return@withContext Result.failure(RepositoryError.ServerError(resp.code))
|
||||||
}
|
}
|
||||||
|
|
||||||
val body = resp.body?.string() ?: return@withContext Result.failure(
|
if (body == null) return@withContext Result.failure(
|
||||||
RepositoryError.ParseError("响应体为空")
|
RepositoryError.ParseError("响应体为空")
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -141,6 +152,9 @@ class CommandCodeUsageRepository(
|
|||||||
val provider = meta?.get("provider")?.jsonPrimitive?.content ?: ""
|
val provider = meta?.get("provider")?.jsonPrimitive?.content ?: ""
|
||||||
val cacheReadInputTokens = meta?.get("cacheReadInputTokens")?.jsonPrimitive?.content?.toLongOrNull() ?: 0L
|
val cacheReadInputTokens = meta?.get("cacheReadInputTokens")?.jsonPrimitive?.content?.toLongOrNull() ?: 0L
|
||||||
|
|
||||||
|
// CCGO 的 tokensIn 是总输入(缓存命中 + 未命中),按 OCGO 惯例拆分为 inputTokens(未命中)和 cacheReadTokens(命中)
|
||||||
|
val inputMissTokens = (tokensIn - cacheReadInputTokens).coerceAtLeast(0)
|
||||||
|
|
||||||
return UsageRecord(
|
return UsageRecord(
|
||||||
id = id,
|
id = id,
|
||||||
workspaceId = CCGO_WORKSPACE_ID,
|
workspaceId = CCGO_WORKSPACE_ID,
|
||||||
@ -148,7 +162,7 @@ class CommandCodeUsageRepository(
|
|||||||
timeUpdated = timeCreated,
|
timeUpdated = timeCreated,
|
||||||
model = model,
|
model = model,
|
||||||
provider = provider,
|
provider = provider,
|
||||||
inputTokens = tokensIn,
|
inputTokens = inputMissTokens,
|
||||||
outputTokens = tokensOut,
|
outputTokens = tokensOut,
|
||||||
reasoningTokens = 0L,
|
reasoningTokens = 0L,
|
||||||
cacheReadTokens = cacheReadInputTokens,
|
cacheReadTokens = cacheReadInputTokens,
|
||||||
|
|||||||
@ -84,6 +84,9 @@ data class SyncResult(
|
|||||||
|
|
||||||
sealed class SyncError : Exception() {
|
sealed class SyncError : Exception() {
|
||||||
class PartialSync(val inserted: Int, val errors: List<String>) : SyncError() {
|
class PartialSync(val inserted: Int, val errors: List<String>) : SyncError() {
|
||||||
override val message: String = "部分同步完成:插入 $inserted 条,${errors.size} 页失败"
|
override val message: String = buildString {
|
||||||
|
append("部分同步完成:插入 ${inserted}条,${errors.size}页失败")
|
||||||
|
if (errors.isNotEmpty()) append("。${errors.first().take(200)}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,6 +1,8 @@
|
|||||||
package com.rainy.token.ui
|
package com.rainy.token.ui
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.navigation.NavType
|
import androidx.navigation.NavType
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
@ -8,9 +10,12 @@ import androidx.navigation.compose.rememberNavController
|
|||||||
import androidx.navigation.navArgument
|
import androidx.navigation.navArgument
|
||||||
import com.rainy.token.domain.service.ServiceType
|
import com.rainy.token.domain.service.ServiceType
|
||||||
import com.rainy.token.ui.dashboard.DashboardScreen
|
import com.rainy.token.ui.dashboard.DashboardScreen
|
||||||
|
import com.rainy.token.ui.dashboard.UsageChartViewModel
|
||||||
import com.rainy.token.ui.dashboard.UsageDataScreen
|
import com.rainy.token.ui.dashboard.UsageDataScreen
|
||||||
|
import com.rainy.token.ui.dashboard.UsageDataViewModel
|
||||||
import com.rainy.token.ui.dashboard.UsageDetailScreen
|
import com.rainy.token.ui.dashboard.UsageDetailScreen
|
||||||
import com.rainy.token.ui.dashboard.UsageOverviewScreen
|
import com.rainy.token.ui.dashboard.UsageOverviewScreen
|
||||||
|
import com.rainy.token.ui.dashboard.UsageViewModel
|
||||||
import com.rainy.token.ui.servicedetail.ServiceDetailScreen
|
import com.rainy.token.ui.servicedetail.ServiceDetailScreen
|
||||||
import com.rainy.token.ui.settings.CredentialEditScreen
|
import com.rainy.token.ui.settings.CredentialEditScreen
|
||||||
import com.rainy.token.ui.settings.SettingsScreen
|
import com.rainy.token.ui.settings.SettingsScreen
|
||||||
@ -33,6 +38,9 @@ object Routes {
|
|||||||
const val USAGE_DETAIL = "usage_detail"
|
const val USAGE_DETAIL = "usage_detail"
|
||||||
const val USAGE_OVERVIEW = "usage_overview"
|
const val USAGE_OVERVIEW = "usage_overview"
|
||||||
const val USAGE_DATA = "usage_data"
|
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"
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseServiceType(typeName: String?): ServiceType =
|
private fun parseServiceType(typeName: String?): ServiceType =
|
||||||
@ -48,7 +56,8 @@ fun RainyTokenNavHost() {
|
|||||||
DashboardScreen(
|
DashboardScreen(
|
||||||
onOpenSettings = { navController.navigate(Routes.SETTINGS) },
|
onOpenSettings = { navController.navigate(Routes.SETTINGS) },
|
||||||
onOpenService = { type -> navController.navigate(Routes.serviceDetail(type)) },
|
onOpenService = { type -> navController.navigate(Routes.serviceDetail(type)) },
|
||||||
onOpenUsageDetail = { navController.navigate(Routes.USAGE_DETAIL) }
|
onOpenUsageDetail = { navController.navigate(Routes.USAGE_DETAIL) },
|
||||||
|
onOpenCcgoUsageDetail = { navController.navigate(Routes.CCGO_USAGE_DETAIL) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(Routes.USAGE_DETAIL) {
|
composable(Routes.USAGE_DETAIL) {
|
||||||
@ -68,6 +77,45 @@ fun RainyTokenNavHost() {
|
|||||||
onBack = { navController.popBackStack() }
|
onBack = { navController.popBackStack() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// CCGO 用量详情 — 隔离实例通过 key 参数标记 workspaceId
|
||||||
|
composable(Routes.CCGO_USAGE_DETAIL) {
|
||||||
|
val wid = com.rainy.token.data.repository.CommandCodeUsageRepository.CCGO_WORKSPACE_ID
|
||||||
|
val chartVm: UsageChartViewModel = hiltViewModel(key = "ccgo_chart_$wid")
|
||||||
|
val usageVm: UsageViewModel = hiltViewModel(key = "ccgo_$wid")
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
usageVm.setWorkspace(wid)
|
||||||
|
chartVm.setWorkspace(wid)
|
||||||
|
}
|
||||||
|
UsageDetailScreen(
|
||||||
|
onBack = { navController.popBackStack() },
|
||||||
|
onOpenOverview = { navController.navigate(Routes.CCGO_USAGE_OVERVIEW) },
|
||||||
|
onOpenData = { navController.navigate(Routes.CCGO_USAGE_DATA) },
|
||||||
|
viewModel = chartVm,
|
||||||
|
clearViewModel = usageVm
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(Routes.CCGO_USAGE_OVERVIEW) {
|
||||||
|
val wid = com.rainy.token.data.repository.CommandCodeUsageRepository.CCGO_WORKSPACE_ID
|
||||||
|
val key = "ccgo_$wid"
|
||||||
|
val ovVm: UsageViewModel = hiltViewModel(key = key)
|
||||||
|
LaunchedEffect(Unit) { ovVm.setWorkspace(wid) }
|
||||||
|
UsageOverviewScreen(
|
||||||
|
onBack = { navController.popBackStack() },
|
||||||
|
viewModel = ovVm,
|
||||||
|
autoLoad = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(Routes.CCGO_USAGE_DATA) {
|
||||||
|
val wid = com.rainy.token.data.repository.CommandCodeUsageRepository.CCGO_WORKSPACE_ID
|
||||||
|
val key = "ccgo_$wid"
|
||||||
|
val dataVm: UsageDataViewModel = hiltViewModel(key = key)
|
||||||
|
LaunchedEffect(Unit) { dataVm.setWorkspace(wid) }
|
||||||
|
UsageDataScreen(
|
||||||
|
onBack = { navController.popBackStack() },
|
||||||
|
viewModel = dataVm,
|
||||||
|
autoLoad = false
|
||||||
|
)
|
||||||
|
}
|
||||||
composable(Routes.SETTINGS) {
|
composable(Routes.SETTINGS) {
|
||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
onBack = { navController.popBackStack() },
|
onBack = { navController.popBackStack() },
|
||||||
|
|||||||
@ -0,0 +1,198 @@
|
|||||||
|
package com.rainy.token.ui.dashboard
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.KeyboardArrowRight
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.rainy.token.data.local.OverviewStats
|
||||||
|
import com.rainy.token.ui.theme.InkMuted
|
||||||
|
import com.rainy.token.ui.theme.StrawberryPink
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CommandCode Go 用量统计主卡片 —— 风格与 UsageStatsCard(OCGO)完全一致。
|
||||||
|
* 仅展示核心指标:输入 Token(含 Cache)、输出 Token、总花费,
|
||||||
|
* 外加同步按钮和"查看详情"入口。详细统计在 CCGO 专属详情页。
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CommandCodeUsageStatsCard(
|
||||||
|
onOpenDetail: () -> Unit,
|
||||||
|
refreshTrigger: Int = 0
|
||||||
|
) {
|
||||||
|
val wid = com.rainy.token.data.repository.CommandCodeUsageRepository.CCGO_WORKSPACE_ID
|
||||||
|
val key = "ccgo_$wid"
|
||||||
|
val viewModel: UsageViewModel = hiltViewModel(key = key)
|
||||||
|
// 初始化 workspace,后续刷新自动走 CCGO 的 sync use case
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.setWorkspace(wid)
|
||||||
|
}
|
||||||
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
// 全局刷新触发用量同步(跳过首次 0)
|
||||||
|
LaunchedEffect(refreshTrigger) {
|
||||||
|
if (refreshTrigger > 0) viewModel.sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(20.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
// ─── 头部:标题 + 同步按钮 ───
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = "CommandCode 用量",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = if (uiState.recordCount > 0) "${uiState.recordCount} 条记录" else "点击同步",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = InkMuted
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (uiState.syncing) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.padding(4.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = StrawberryPink
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
IconButton(onClick = { viewModel.sync() }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Refresh,
|
||||||
|
contentDescription = "同步用量数据",
|
||||||
|
tint = StrawberryPink
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 核心指标 ───
|
||||||
|
uiState.overview?.let { overview ->
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// 输入 Token = input + cacheRead
|
||||||
|
val inputTotal = overview.inputTokens + overview.cacheReadTokens
|
||||||
|
MetricRow("输入 Token", formatTokenCount(inputTotal))
|
||||||
|
|
||||||
|
// 缓存子行
|
||||||
|
if (overview.cacheReadTokens > 0 || overview.cacheWriteTokens > 0) {
|
||||||
|
val parts = mutableListOf<String>()
|
||||||
|
if (overview.cacheReadTokens > 0) parts.add("缓存读取 ${formatTokenCount(overview.cacheReadTokens)}")
|
||||||
|
if (overview.cacheWriteTokens > 0) parts.add("缓存写入 ${formatTokenCount(overview.cacheWriteTokens)}")
|
||||||
|
Text(
|
||||||
|
text = parts.joinToString(" · "),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = InkMuted,
|
||||||
|
modifier = Modifier.padding(start = 4.dp, top = 2.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
|
// 输出 Token + 总花费 同一排
|
||||||
|
Row(Modifier.fillMaxWidth()) {
|
||||||
|
MetricRow("输出 Token", formatTokenCount(overview.outputTokens), Modifier.weight(1f))
|
||||||
|
MetricRow("总花费", "$${String.format(Locale.US, "%.4f", overview.totalCost / 100_000_000.0)}", Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 空状态 ───
|
||||||
|
if (uiState.overview == null && !uiState.syncing) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = if (uiState.loading) "加载中…" else "暂无数据,点击 🔄 同步",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = InkMuted
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 查看详情 ───
|
||||||
|
if (uiState.overview != null) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
TextButton(onClick = onOpenDetail) {
|
||||||
|
Text("查看详情", color = StrawberryPink)
|
||||||
|
Spacer(modifier = Modifier.width(2.dp))
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.KeyboardArrowRight,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = StrawberryPink,
|
||||||
|
modifier = Modifier.padding(top = 1.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 同步结果反馈 ───
|
||||||
|
if (uiState.lastSyncResult > 0) {
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = "✓ 新增 ${uiState.lastSyncResult} 条",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = StrawberryPink
|
||||||
|
)
|
||||||
|
}
|
||||||
|
uiState.lastSyncError?.let { err ->
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = "✗ $err",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MetricRow(label: String, value: String, modifier: Modifier = Modifier) {
|
||||||
|
Column(modifier = modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = InkMuted
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = StrawberryPink
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatTokenCount(tokens: Long): String {
|
||||||
|
return when {
|
||||||
|
tokens >= 1_000_000 -> "${"%.1f".format(Locale.US, tokens / 1_000_000.0)}M"
|
||||||
|
tokens >= 1_000 -> "${"%.1f".format(Locale.US, tokens / 1_000.0)}K"
|
||||||
|
else -> "$tokens"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -17,14 +17,16 @@ import androidx.compose.foundation.layout.height
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.KeyboardArrowRight
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
@ -78,6 +80,7 @@ import java.util.Locale
|
|||||||
* 视觉:
|
* 视觉:
|
||||||
* - 顶部 TopAppBar 透明 + 渐变背景
|
* - 顶部 TopAppBar 透明 + 渐变背景
|
||||||
* - 下拉刷新(PullToRefresh)触发 DashboardViewModel.refresh()
|
* - 下拉刷新(PullToRefresh)触发 DashboardViewModel.refresh()
|
||||||
|
* - 顶栏刷新按钮亦可触发
|
||||||
* - 卡片:白底圆角 + 左侧服务图标 + 中间余额大数字 + 右侧状态 chip
|
* - 卡片:白底圆角 + 左侧服务图标 + 中间余额大数字 + 右侧状态 chip
|
||||||
* - 卡片底部展示"更新于 X 分钟前"或错误信息
|
* - 卡片底部展示"更新于 X 分钟前"或错误信息
|
||||||
* - 主数字加粗超大,视觉锚点
|
* - 主数字加粗超大,视觉锚点
|
||||||
@ -88,6 +91,7 @@ fun DashboardScreen(
|
|||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
onOpenService: (ServiceType) -> Unit,
|
onOpenService: (ServiceType) -> Unit,
|
||||||
onOpenUsageDetail: () -> Unit,
|
onOpenUsageDetail: () -> Unit,
|
||||||
|
onOpenCcgoUsageDetail: () -> Unit = {},
|
||||||
viewModel: DashboardViewModel = hiltViewModel()
|
viewModel: DashboardViewModel = hiltViewModel()
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
@ -186,26 +190,30 @@ fun DashboardScreen(
|
|||||||
CircularProgressIndicator(color = StrawberryPink)
|
CircularProgressIndicator(color = StrawberryPink)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
LazyColumn(
|
Column(
|
||||||
contentPadding = PaddingValues(16.dp),
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
// 用量统计卡片(主页面,独立 ViewModel)
|
// 用量统计卡片(主页面,独立 ViewModel)
|
||||||
item { UsageStatsCard(onOpenDetail = onOpenUsageDetail, refreshTrigger = usageSyncTrigger) }
|
UsageStatsCard(onOpenDetail = onOpenUsageDetail, refreshTrigger = usageSyncTrigger)
|
||||||
|
// CommandCode Go 用量统计卡片(风格完全一致)
|
||||||
|
CommandCodeUsageStatsCard(onOpenDetail = onOpenCcgoUsageDetail, refreshTrigger = usageSyncTrigger)
|
||||||
// 分隔
|
// 分隔
|
||||||
item {
|
|
||||||
Text(
|
Text(
|
||||||
text = "服务余额",
|
text = "服务余额",
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = inkMuted(),
|
color = inkMuted(),
|
||||||
modifier = Modifier.padding(top = 8.dp)
|
modifier = Modifier.padding(top = 8.dp)
|
||||||
)
|
)
|
||||||
}
|
uiState.cards.forEach { card ->
|
||||||
items(uiState.cards, key = { it.service.name }) { card ->
|
DashboardCard(card = card, onClick = { onOpenService(card.service) },
|
||||||
DashboardCard(card = card, onClick = { onOpenService(card.service) })
|
onOpenCcgoUsageDetail = if (card.service == ServiceType.COMMANDCODE_GO) onOpenCcgoUsageDetail else null)
|
||||||
}
|
}
|
||||||
// 底部 footer:填空白 + 提供版本号
|
// 底部 footer:填空白 + 提供版本号
|
||||||
item { DashboardFooter() }
|
DashboardFooter()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -213,7 +221,7 @@ fun DashboardScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DashboardCard(card: DashboardCardUi, onClick: () -> Unit) {
|
private fun DashboardCard(card: DashboardCardUi, onClick: () -> Unit, onOpenCcgoUsageDetail: (() -> Unit)? = null) {
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@ -247,6 +255,21 @@ private fun DashboardCard(card: DashboardCardUi, onClick: () -> Unit) {
|
|||||||
// ─── 主体:服务特定的主信息 ───
|
// ─── 主体:服务特定的主信息 ───
|
||||||
BalanceMainArea(card)
|
BalanceMainArea(card)
|
||||||
|
|
||||||
|
// CCGO 卡片底部:用量详情入口(仅当有凭证且非 CCGO 未配置状态时显示)
|
||||||
|
if (onOpenCcgoUsageDetail != null && card.credentialState != com.rainy.token.domain.model.CredentialStatus.State.NOT_CONFIGURED) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
TextButton(onClick = onOpenCcgoUsageDetail) {
|
||||||
|
Text("查看用量详情", color = StrawberryPink)
|
||||||
|
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))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import com.rainy.token.data.cache.BalanceCache
|
import com.rainy.token.data.cache.BalanceCache
|
||||||
import com.rainy.token.data.cache.CachedBalance
|
import com.rainy.token.data.cache.CachedBalance
|
||||||
import com.rainy.token.data.repository.CredentialRepository
|
import com.rainy.token.data.repository.CredentialRepository
|
||||||
|
import com.rainy.token.domain.model.Credential
|
||||||
import com.rainy.token.domain.model.CredentialStatus
|
import com.rainy.token.domain.model.CredentialStatus
|
||||||
import com.rainy.token.domain.model.ServiceBalance
|
import com.rainy.token.domain.model.ServiceBalance
|
||||||
import com.rainy.token.domain.service.ServiceType
|
import com.rainy.token.domain.service.ServiceType
|
||||||
|
|||||||
@ -29,14 +29,25 @@ class UsageChartViewModel @Inject constructor(
|
|||||||
private val _state = MutableStateFlow(ChartUiState())
|
private val _state = MutableStateFlow(ChartUiState())
|
||||||
val state: StateFlow<ChartUiState> = _state.asStateFlow()
|
val state: StateFlow<ChartUiState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private var workspaceIdOverride: String? = null
|
||||||
|
private var loadGeneration = 0 // 递增:过时的 load() 结果自动丢弃
|
||||||
|
|
||||||
|
/** 覆盖 workspaceId,用于 CCGO 等非 OCGO 服务。必须在 load() 前调用。 */
|
||||||
|
fun setWorkspace(wid: String) {
|
||||||
|
workspaceIdOverride = wid
|
||||||
|
loadGeneration++
|
||||||
|
// 先清空数据防止 init 自动加载的 OCGO 数据闪一下
|
||||||
|
_state.value = ChartUiState()
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun workspaceId(): String? {
|
private suspend fun workspaceId(): String? {
|
||||||
|
workspaceIdOverride?.let { return it }
|
||||||
val c = credentialRepository.get(ServiceType.OPENCODE_GO)
|
val c = credentialRepository.get(ServiceType.OPENCODE_GO)
|
||||||
return (c as? Credential.SessionCredential)?.workspaceId?.takeIf { it.isNotBlank() }
|
return (c as? Credential.SessionCredential)?.workspaceId?.takeIf { it.isNotBlank() }
|
||||||
}
|
}
|
||||||
|
|
||||||
init {
|
// 不在 init 自动加载,由 Composable 层显式触发 load()(OCGO)或 setWorkspace()(CCGO)
|
||||||
load()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setGranularity(g: ChartGranularity) {
|
fun setGranularity(g: ChartGranularity) {
|
||||||
_state.update { it.copy(granularity = g) }
|
_state.update { it.copy(granularity = g) }
|
||||||
@ -65,6 +76,7 @@ class UsageChartViewModel @Inject constructor(
|
|||||||
fun load() {
|
fun load() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val wid = workspaceId() ?: return@launch
|
val wid = workspaceId() ?: return@launch
|
||||||
|
val genAtStart = loadGeneration
|
||||||
val cache = cacheProvider.get()
|
val cache = cacheProvider.get()
|
||||||
val allModels = cache.getDistinctModels(wid)
|
val allModels = cache.getDistinctModels(wid)
|
||||||
|
|
||||||
@ -96,6 +108,9 @@ class UsageChartViewModel @Inject constructor(
|
|||||||
val result = if (_state.value.granularity == ChartGranularity.LAST_5H_HOURLY)
|
val result = if (_state.value.granularity == ChartGranularity.LAST_5H_HOURLY)
|
||||||
filled.takeLast(5) else filled
|
filled.takeLast(5) else filled
|
||||||
|
|
||||||
|
// 如果 loadGeneration 已经变化(setWorkspace 被调用),丢弃这次结果
|
||||||
|
if (loadGeneration != genAtStart) return@launch
|
||||||
|
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
buckets = result,
|
buckets = result,
|
||||||
|
|||||||
@ -38,6 +38,7 @@ import androidx.compose.material3.TextButton
|
|||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@ -67,9 +68,16 @@ import java.util.Locale
|
|||||||
@Composable
|
@Composable
|
||||||
fun UsageDataScreen(
|
fun UsageDataScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
viewModel: UsageDataViewModel = hiltViewModel()
|
viewModel: UsageDataViewModel = hiltViewModel(),
|
||||||
|
autoLoad: Boolean = true
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
// OCGO 首次加载(CCGO 由 setWorkspace 触发,不重复 load)
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
if (autoLoad) viewModel.loadData()
|
||||||
|
}
|
||||||
|
|
||||||
var timeMenuExpanded by remember { mutableStateOf(false) }
|
var timeMenuExpanded by remember { mutableStateOf(false) }
|
||||||
var modelMenuExpanded by remember { mutableStateOf(false) }
|
var modelMenuExpanded by remember { mutableStateOf(false) }
|
||||||
var showStartPicker by remember { mutableStateOf(false) }
|
var showStartPicker by remember { mutableStateOf(false) }
|
||||||
|
|||||||
@ -27,22 +27,36 @@ class UsageDataViewModel @Inject constructor(
|
|||||||
private val _state = MutableStateFlow(UsageDataState())
|
private val _state = MutableStateFlow(UsageDataState())
|
||||||
val state: StateFlow<UsageDataState> = _state.asStateFlow()
|
val state: StateFlow<UsageDataState> = _state.asStateFlow()
|
||||||
|
|
||||||
init {
|
private var workspaceIdOverride: String? = null
|
||||||
loadModelsAndData()
|
private var loadGeneration = 0
|
||||||
|
|
||||||
|
/** 覆盖 workspaceId,用于 CCGO 等非 OCGO 服务。必须在 loadModelsAndData() 前调用。 */
|
||||||
|
fun setWorkspace(wid: String) {
|
||||||
|
workspaceIdOverride = wid
|
||||||
|
loadGeneration++
|
||||||
|
// 先清空数据防止 init 自动加载的 OCGO 数据闪一下
|
||||||
|
_state.value = UsageDataState()
|
||||||
|
loadData()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 不在 init 自动加载,由 Composable 层显式触发 loadData()(OCGO)或 setWorkspace()(CCGO)
|
||||||
|
|
||||||
private fun loadModelsAndData() {
|
private fun loadModelsAndData() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val wid = workspaceId() ?: return@launch
|
val wid = workspaceId() ?: return@launch
|
||||||
|
val genAtStart = loadGeneration
|
||||||
val cache = cacheProvider.get()
|
val cache = cacheProvider.get()
|
||||||
val models = cache.getDistinctModels(wid)
|
val models = cache.getDistinctModels(wid)
|
||||||
_state.update { it.copy(allModels = models) }
|
_state.update { it.copy(allModels = models) }
|
||||||
|
// 如果 loadGeneration 已经变化,丢弃这次结果
|
||||||
|
if (loadGeneration != genAtStart) return@launch
|
||||||
// 紧接着加载数据
|
// 紧接着加载数据
|
||||||
loadDataInternal(cache, wid)
|
loadDataInternal(cache, wid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun workspaceId(): String? {
|
private suspend fun workspaceId(): String? {
|
||||||
|
workspaceIdOverride?.let { return it }
|
||||||
val c = credentialRepository.get(ServiceType.OPENCODE_GO)
|
val c = credentialRepository.get(ServiceType.OPENCODE_GO)
|
||||||
return (c as? Credential.SessionCredential)?.workspaceId?.takeIf { it.isNotBlank() }
|
return (c as? Credential.SessionCredential)?.workspaceId?.takeIf { it.isNotBlank() }
|
||||||
}
|
}
|
||||||
@ -51,8 +65,11 @@ class UsageDataViewModel @Inject constructor(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_state.update { it.copy(loading = true) }
|
_state.update { it.copy(loading = true) }
|
||||||
val wid = workspaceId() ?: return@launch
|
val wid = workspaceId() ?: return@launch
|
||||||
|
val genAtStart = loadGeneration
|
||||||
val cache = cacheProvider.get()
|
val cache = cacheProvider.get()
|
||||||
loadDataInternal(cache, wid)
|
loadDataInternal(cache, wid)
|
||||||
|
// 如果 loadGeneration 已经变化,丢弃这次结果
|
||||||
|
if (loadGeneration != genAtStart) return@launch
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -33,6 +33,7 @@ import androidx.compose.material3.TopAppBarDefaults
|
|||||||
import androidx.compose.material3.rememberDatePickerState
|
import androidx.compose.material3.rememberDatePickerState
|
||||||
import androidx.compose.material3.rememberTimePickerState
|
import androidx.compose.material3.rememberTimePickerState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@ -65,9 +66,18 @@ fun UsageDetailScreen(
|
|||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onOpenOverview: () -> Unit,
|
onOpenOverview: () -> Unit,
|
||||||
onOpenData: () -> Unit = {},
|
onOpenData: () -> Unit = {},
|
||||||
viewModel: UsageChartViewModel = hiltViewModel()
|
viewModel: UsageChartViewModel = hiltViewModel(),
|
||||||
|
clearViewModel: UsageViewModel? = null // non-null = CCGO, 显示清除按钮
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
// OCGO 首次加载(CCGO 由 NavHost 的 setWorkspace 触发,不重复 load)
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
if (clearViewModel == null) viewModel.load()
|
||||||
|
}
|
||||||
|
|
||||||
|
var showClearDialog by remember { mutableStateOf(false) }
|
||||||
|
var clearCountdown by remember { mutableStateOf(0) }
|
||||||
var granularityExpanded by remember { mutableStateOf(false) }
|
var granularityExpanded by remember { mutableStateOf(false) }
|
||||||
var modelExpanded by remember { mutableStateOf(false) }
|
var modelExpanded by remember { mutableStateOf(false) }
|
||||||
var showCustomDayPicker by remember { mutableStateOf(false) }
|
var showCustomDayPicker by remember { mutableStateOf(false) }
|
||||||
@ -86,6 +96,11 @@ fun UsageDetailScreen(
|
|||||||
title = { Text("用量详情") },
|
title = { Text("用量详情") },
|
||||||
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, "返回") } },
|
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, "返回") } },
|
||||||
actions = {
|
actions = {
|
||||||
|
if (clearViewModel != null) {
|
||||||
|
TextButton(onClick = { showClearDialog = true }) {
|
||||||
|
Text("清除", color = MaterialTheme.colorScheme.error, fontWeight = FontWeight.SemiBold, fontSize = 13.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
TextButton(onClick = onOpenData) {
|
TextButton(onClick = onOpenData) {
|
||||||
Text("详细数据", color = StrawberryPink, fontWeight = FontWeight.SemiBold, fontSize = 13.sp)
|
Text("详细数据", color = StrawberryPink, fontWeight = FontWeight.SemiBold, fontSize = 13.sp)
|
||||||
}
|
}
|
||||||
@ -216,6 +231,51 @@ fun UsageDetailScreen(
|
|||||||
DetailRow("输入(未命中)", formatTokenComma(state.buckets.sumOf { it.inputTokens }))
|
DetailRow("输入(未命中)", formatTokenComma(state.buckets.sumOf { it.inputTokens }))
|
||||||
DetailRow("输出", formatTokenComma(state.buckets.sumOf { it.outputTokens }))
|
DetailRow("输出", formatTokenComma(state.buckets.sumOf { it.outputTokens }))
|
||||||
}
|
}
|
||||||
|
// 清除数据警告弹窗(仅 CCGO)
|
||||||
|
if (showClearDialog && clearViewModel != null) {
|
||||||
|
val cd = clearCountdown
|
||||||
|
androidx.compose.material3.AlertDialog(
|
||||||
|
onDismissRequest = { showClearDialog = false; clearCountdown = 0 },
|
||||||
|
title = { Text("⚠️ 清除数据", fontWeight = FontWeight.Bold) },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text("此操作将清除所有 CommandCode 用量本地缓存并重新同步,需要几秒钟完成。")
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text("是否继续?", fontWeight = FontWeight.SemiBold)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
showClearDialog = false
|
||||||
|
clearCountdown = 0
|
||||||
|
clearViewModel?.clearAndResync()
|
||||||
|
onBack()
|
||||||
|
},
|
||||||
|
enabled = cd == 0
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
if (cd > 0) "确认(${cd}s)" else "确认清除",
|
||||||
|
color = if (cd == 0) MaterialTheme.colorScheme.error else InkMuted
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showClearDialog = false; clearCountdown = 0 }) {
|
||||||
|
Text("取消")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
// 倒计时启动
|
||||||
|
LaunchedEffect(showClearDialog) {
|
||||||
|
if (!showClearDialog) return@LaunchedEffect
|
||||||
|
clearCountdown = 3
|
||||||
|
for (i in 3 downTo 1) {
|
||||||
|
kotlinx.coroutines.delay(1000)
|
||||||
|
clearCountdown = i - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -30,6 +30,7 @@ import androidx.compose.material3.TextButton
|
|||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@ -56,9 +57,16 @@ import java.util.Locale
|
|||||||
@Composable
|
@Composable
|
||||||
fun UsageOverviewScreen(
|
fun UsageOverviewScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
viewModel: UsageViewModel = hiltViewModel()
|
viewModel: UsageViewModel = hiltViewModel(),
|
||||||
|
autoLoad: Boolean = true
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
// OCGO 首次加载(CCGO 由 setWorkspace 触发,不重复 load)
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
if (autoLoad) viewModel.loadStats()
|
||||||
|
}
|
||||||
|
|
||||||
var menuExpanded by remember { mutableStateOf(false) }
|
var menuExpanded by remember { mutableStateOf(false) }
|
||||||
var modelMenuExpanded by remember { mutableStateOf(false) }
|
var modelMenuExpanded by remember { mutableStateOf(false) }
|
||||||
var showStartPicker by remember { mutableStateOf(false) }
|
var showStartPicker by remember { mutableStateOf(false) }
|
||||||
|
|||||||
@ -46,6 +46,11 @@ fun UsageStatsCard(
|
|||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
// OCGO 首次加载(init 不再自动加载)
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.loadStats()
|
||||||
|
}
|
||||||
|
|
||||||
// 全局刷新触发用量同步(跳过首次 0)
|
// 全局刷新触发用量同步(跳过首次 0)
|
||||||
LaunchedEffect(refreshTrigger) {
|
LaunchedEffect(refreshTrigger) {
|
||||||
if (refreshTrigger > 0) viewModel.sync()
|
if (refreshTrigger > 0) viewModel.sync()
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import com.rainy.token.data.local.UsageCache
|
|||||||
import com.rainy.token.data.repository.CredentialRepository
|
import com.rainy.token.data.repository.CredentialRepository
|
||||||
import com.rainy.token.domain.model.Credential
|
import com.rainy.token.domain.model.Credential
|
||||||
import com.rainy.token.domain.service.ServiceType
|
import com.rainy.token.domain.service.ServiceType
|
||||||
|
import com.rainy.token.domain.usecase.SyncCommandCodeUsageUseCase
|
||||||
import com.rainy.token.domain.usecase.SyncUsageUseCase
|
import com.rainy.token.domain.usecase.SyncUsageUseCase
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@ -72,27 +73,29 @@ sealed class TimeFilter(val label: String) {
|
|||||||
class UsageViewModel @Inject constructor(
|
class UsageViewModel @Inject constructor(
|
||||||
private val cacheProvider: Provider<UsageCache>,
|
private val cacheProvider: Provider<UsageCache>,
|
||||||
private val syncUseCaseProvider: Provider<SyncUsageUseCase>,
|
private val syncUseCaseProvider: Provider<SyncUsageUseCase>,
|
||||||
|
private val syncCommandCodeUseCaseProvider: Provider<SyncCommandCodeUsageUseCase>,
|
||||||
private val credentialRepository: CredentialRepository
|
private val credentialRepository: CredentialRepository
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(UsageUiState())
|
private val _uiState = MutableStateFlow(UsageUiState())
|
||||||
val uiState: StateFlow<UsageUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<UsageUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
init {
|
private var workspaceIdOverride: String? = null
|
||||||
loadStatsAndSyncIfNeeded()
|
private var loadGeneration = 0 // 递增:过时的 loadStatsInternal 结果自动丢弃
|
||||||
|
|
||||||
|
/** 覆盖 workspaceId,用于 CCGO 等非 OCGO 服务。必须在 loadStats() 前调用。 */
|
||||||
|
fun setWorkspace(wid: String) {
|
||||||
|
workspaceIdOverride = wid
|
||||||
|
loadGeneration++
|
||||||
|
// 先清空数据防止 init 自动加载的 OCGO 数据闪一下
|
||||||
|
_uiState.value = UsageUiState()
|
||||||
|
loadStats()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadStatsAndSyncIfNeeded() {
|
// 不在 init 自动加载,由 Composable 层显式触发 loadStats()(OCGO)或 setWorkspace()(CCGO)
|
||||||
viewModelScope.launch {
|
|
||||||
loadStatsInternal()
|
|
||||||
// 无缓存数据 → 自动触发同步
|
|
||||||
if (_uiState.value.overview == null && _uiState.value.recordCount == 0) {
|
|
||||||
sync()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun workspaceId(): String? {
|
private suspend fun workspaceId(): String? {
|
||||||
|
workspaceIdOverride?.let { return it }
|
||||||
val c = credentialRepository.get(ServiceType.OPENCODE_GO)
|
val c = credentialRepository.get(ServiceType.OPENCODE_GO)
|
||||||
return (c as? Credential.SessionCredential)?.workspaceId?.takeIf { it.isNotBlank() }
|
return (c as? Credential.SessionCredential)?.workspaceId?.takeIf { it.isNotBlank() }
|
||||||
}
|
}
|
||||||
@ -104,6 +107,7 @@ class UsageViewModel @Inject constructor(
|
|||||||
/** 只调一次 getRecords(),所有统计从同一次结果派生。重 IO/CPU 操作跑在 Default 上。 */
|
/** 只调一次 getRecords(),所有统计从同一次结果派生。重 IO/CPU 操作跑在 Default 上。 */
|
||||||
private suspend fun loadStatsInternal() {
|
private suspend fun loadStatsInternal() {
|
||||||
val wid = workspaceId() ?: return
|
val wid = workspaceId() ?: return
|
||||||
|
val genAtStart = loadGeneration // 记下发起时的世代
|
||||||
val cache = cacheProvider.get()
|
val cache = cacheProvider.get()
|
||||||
val filter = _uiState.value.timeFilter
|
val filter = _uiState.value.timeFilter
|
||||||
val (fromTs, toTs) = filter.toRange()
|
val (fromTs, toTs) = filter.toRange()
|
||||||
@ -139,6 +143,9 @@ class UsageViewModel @Inject constructor(
|
|||||||
LoadResult(ov, ms, ds, records.size)
|
LoadResult(ov, ms, ds, records.size)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果 loadGeneration 已经变化(setWorkspace 被调用),丢弃这次结果
|
||||||
|
if (loadGeneration != genAtStart) return
|
||||||
|
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
overview = overview,
|
overview = overview,
|
||||||
@ -178,11 +185,17 @@ class UsageViewModel @Inject constructor(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.update { it.copy(syncing = true) }
|
_uiState.update { it.copy(syncing = true) }
|
||||||
val result = withContext(Dispatchers.Default) {
|
val result = withContext(Dispatchers.Default) {
|
||||||
val useCase = syncUseCaseProvider.get()
|
|
||||||
val cache = cacheProvider.get()
|
val cache = cacheProvider.get()
|
||||||
|
if (workspaceIdOverride == com.rainy.token.data.repository.CommandCodeUsageRepository.CCGO_WORKSPACE_ID) {
|
||||||
|
val useCase = syncCommandCodeUseCaseProvider.get()
|
||||||
|
val count = cache.count(com.rainy.token.data.repository.CommandCodeUsageRepository.CCGO_WORKSPACE_ID)
|
||||||
|
if (count == 0) useCase.fullSync() else useCase.incrementalSync()
|
||||||
|
} else {
|
||||||
|
val useCase = syncUseCaseProvider.get()
|
||||||
val count = cache.count()
|
val count = cache.count()
|
||||||
if (count == 0) useCase.fullSync() else useCase.incrementalSync()
|
if (count == 0) useCase.fullSync() else useCase.incrementalSync()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
result.onSuccess { loadStats() }
|
result.onSuccess { loadStats() }
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
@ -193,6 +206,31 @@ class UsageViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 清除当前 workspace 的缓存数据并全量重新同步。 */
|
||||||
|
fun clearAndResync() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val wid = workspaceIdOverride ?: return@launch
|
||||||
|
_uiState.update { it.copy(syncing = true) }
|
||||||
|
val cache = cacheProvider.get()
|
||||||
|
cache.deleteByWorkspaceId(wid)
|
||||||
|
invalidateData()
|
||||||
|
val useCase = syncCommandCodeUseCaseProvider.get()
|
||||||
|
val result = useCase.fullSync()
|
||||||
|
result.onSuccess { loadStats() }
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
syncing = false,
|
||||||
|
lastSyncResult = result.getOrNull()?.inserted ?: 0,
|
||||||
|
lastSyncError = result.exceptionOrNull()?.message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun invalidateData() {
|
||||||
|
_uiState.value = UsageUiState()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class UsageUiState(
|
data class UsageUiState(
|
||||||
|
|||||||
@ -109,6 +109,17 @@ fun CredentialEditScreen(
|
|||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
if (uiState.isApiKeyService) {
|
if (uiState.isApiKeyService) {
|
||||||
|
if (service == ServiceType.COMMANDCODE_GO) {
|
||||||
|
CommandCodeGoForm(
|
||||||
|
apiKey = uiState.apiKey,
|
||||||
|
cookieInput = uiState.cookieInput,
|
||||||
|
hasExisting = uiState.hasExisting,
|
||||||
|
onApiKeyChange = viewModel::updateApiKey,
|
||||||
|
onCookieChange = viewModel::updateCookieInput,
|
||||||
|
onSave = viewModel::saveCommandCodeGoCredential,
|
||||||
|
onTestAndSave = viewModel::testAndSaveCommandCodeGo
|
||||||
|
)
|
||||||
|
} else {
|
||||||
ApiKeyForm(
|
ApiKeyForm(
|
||||||
apiKey = uiState.apiKey,
|
apiKey = uiState.apiKey,
|
||||||
hasExisting = uiState.hasExisting,
|
hasExisting = uiState.hasExisting,
|
||||||
@ -116,6 +127,7 @@ fun CredentialEditScreen(
|
|||||||
onSave = viewModel::saveApiKey,
|
onSave = viewModel::saveApiKey,
|
||||||
onTestAndSave = viewModel::testAndSaveApiKey
|
onTestAndSave = viewModel::testAndSaveApiKey
|
||||||
)
|
)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// OpenCode Go:用户粘贴 auth cookie + workspaceId(自动抓取)
|
// OpenCode Go:用户粘贴 auth cookie + workspaceId(自动抓取)
|
||||||
if (service == ServiceType.OPENCODE_GO) {
|
if (service == ServiceType.OPENCODE_GO) {
|
||||||
@ -283,6 +295,52 @@ private fun OpenCodeGoForm(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CommandCodeGoForm(
|
||||||
|
apiKey: String,
|
||||||
|
cookieInput: String,
|
||||||
|
hasExisting: Boolean,
|
||||||
|
onApiKeyChange: (String) -> Unit,
|
||||||
|
onCookieChange: (String) -> Unit,
|
||||||
|
onSave: () -> Unit,
|
||||||
|
onTestAndSave: () -> Unit
|
||||||
|
) {
|
||||||
|
Text(text = "CommandCode Go 凭据", style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
text = "需要 API Key(拉余额)。Session Cookie(可选)在浏览器 DevTools 复制,用于拉用量记录。不填也可正常查看余额。",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.outline
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = apiKey,
|
||||||
|
onValueChange = onApiKeyChange,
|
||||||
|
label = { Text("API Key") },
|
||||||
|
placeholder = { Text("从 Studio → API Keys 生成") },
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = cookieInput,
|
||||||
|
onValueChange = onCookieChange,
|
||||||
|
label = { Text("Session Cookie") },
|
||||||
|
placeholder = { Text("Secure-commandcode_prod_.session_token=xxx; Secure-commandcode_prod_.session_data=xxx") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
minLines = 2
|
||||||
|
)
|
||||||
|
Button(onClick = onSave, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(if (hasExisting) "更新凭据" else "保存凭据")
|
||||||
|
}
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onTestAndSave,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = apiKey.isNotBlank()
|
||||||
|
) {
|
||||||
|
Text(text = "测试并保存")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ApiKeyForm(
|
private fun ApiKeyForm(
|
||||||
apiKey: String,
|
apiKey: String,
|
||||||
|
|||||||
@ -65,7 +65,11 @@ class CredentialEditViewModel @Inject constructor(
|
|||||||
val existing = credentialRepository.get(type)
|
val existing = credentialRepository.get(type)
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
apiKey = if (existing is Credential.ApiKeyCredential) existing.key else "",
|
apiKey = when (existing) {
|
||||||
|
is Credential.ApiKeyCredential -> existing.key
|
||||||
|
is Credential.SessionCredential -> existing.token.orEmpty()
|
||||||
|
else -> ""
|
||||||
|
},
|
||||||
cookieInput = if (existing is Credential.SessionCredential) {
|
cookieInput = if (existing is Credential.SessionCredential) {
|
||||||
existing.cookies.joinToString("; ") { c -> "${c.name}=${c.value}" }
|
existing.cookies.joinToString("; ") { c -> "${c.name}=${c.value}" }
|
||||||
} else "",
|
} else "",
|
||||||
@ -268,6 +272,94 @@ class CredentialEditViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存 CommandCode Go 的 API Key + session cookie。
|
||||||
|
* API Key 存 token 字段,cookie 字符串解析后存 cookies 列表。
|
||||||
|
*/
|
||||||
|
fun saveCommandCodeGoCredential() {
|
||||||
|
val type = serviceType ?: return
|
||||||
|
val current = _uiState.value
|
||||||
|
val trimmedKey = current.apiKey.trim()
|
||||||
|
if (trimmedKey.isBlank()) {
|
||||||
|
_uiState.update { it.copy(message = "API Key 不能为空") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val cookies = if (current.cookieInput.isNotBlank()) {
|
||||||
|
parseCookieString(current.cookieInput)
|
||||||
|
} else emptyList()
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
val existing = credentialRepository.get(type) as? Credential.SessionCredential
|
||||||
|
val updated = (existing ?: Credential.SessionCredential(
|
||||||
|
service = type,
|
||||||
|
cookies = cookies,
|
||||||
|
token = trimmedKey
|
||||||
|
)).copy(
|
||||||
|
cookies = cookies,
|
||||||
|
token = trimmedKey,
|
||||||
|
lastVerifiedAt = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
credentialRepository.save(updated)
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
apiKey = trimmedKey,
|
||||||
|
message = "已保存凭据",
|
||||||
|
hasExisting = true,
|
||||||
|
cookieCount = cookies.size
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存并测试 CommandCode Go 连通性。
|
||||||
|
*/
|
||||||
|
fun testAndSaveCommandCodeGo() {
|
||||||
|
val type = serviceType ?: return
|
||||||
|
val current = _uiState.value
|
||||||
|
val trimmedKey = current.apiKey.trim()
|
||||||
|
if (trimmedKey.isBlank()) {
|
||||||
|
_uiState.update { it.copy(message = "API Key 不能为空") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
// 先落盘
|
||||||
|
val cookies = if (current.cookieInput.isNotBlank()) {
|
||||||
|
parseCookieString(current.cookieInput)
|
||||||
|
} else emptyList()
|
||||||
|
val existing = credentialRepository.get(type) as? Credential.SessionCredential
|
||||||
|
val updated = (existing ?: Credential.SessionCredential(
|
||||||
|
service = type, cookies = cookies, token = trimmedKey
|
||||||
|
)).copy(cookies = cookies, token = trimmedKey, lastVerifiedAt = System.currentTimeMillis())
|
||||||
|
credentialRepository.save(updated)
|
||||||
|
_uiState.update { it.copy(apiKey = trimmedKey) }
|
||||||
|
|
||||||
|
val result = refreshBalanceUseCaseProvider.get().invoke(type)
|
||||||
|
result.fold(
|
||||||
|
onSuccess = { bal ->
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
message = "连接成功!余额: \$${String.format(java.util.Locale.US, "%.2f", bal.amount)}",
|
||||||
|
hasExisting = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFailure = { err ->
|
||||||
|
val reason = when (err) {
|
||||||
|
is RepositoryError.InvalidCredential -> "API Key 无效 (401/403)"
|
||||||
|
is RepositoryError.RateLimited -> "请求过于频繁 (429)"
|
||||||
|
is RepositoryError.ServerError -> "服务端错误 (${err.code})"
|
||||||
|
is RepositoryError.Network -> "网络错误:${err.cause?.message ?: "未知"}"
|
||||||
|
else -> err.message ?: err::class.simpleName ?: "未知错误"
|
||||||
|
}
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(message = "连接失败。$reason", hasExisting = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun deleteCredential() {
|
fun deleteCredential() {
|
||||||
val type = serviceType ?: return
|
val type = serviceType ?: return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@ -333,6 +425,7 @@ class CredentialEditViewModel @Inject constructor(
|
|||||||
.mapNotNull { entry ->
|
.mapNotNull { entry ->
|
||||||
val parts = entry.trim().split("=", limit = 2)
|
val parts = entry.trim().split("=", limit = 2)
|
||||||
if (parts.size != 2 || parts[0].isBlank() || parts[1].isBlank()) return@mapNotNull null
|
if (parts.size != 2 || parts[0].isBlank() || parts[1].isBlank()) return@mapNotNull null
|
||||||
|
// cookie 值原样存储,不做任何解码(better-auth 签名包含了原始字符)
|
||||||
CookieEntry(name = parts[0].trim(), value = parts[1].trim())
|
CookieEntry(name = parts[0].trim(), value = parts[1].trim())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user