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:
WaterRain 2026-06-26 20:37:07 +00:00
parent bb51efde09
commit cc7aecc331
No known key found for this signature in database
20 changed files with 714 additions and 81 deletions

1
.gitignore vendored
View File

@ -19,3 +19,4 @@ release.jks
.operit/ .operit/
.kotlin/ .kotlin/
*.jks *.jks
tmp_cc/

View File

@ -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)

View File

@ -5,7 +5,7 @@
[![Release](https://github.com/CATMIAOZHI/Rainytoken/actions/workflows/release.yml/badge.svg)](https://github.com/CATMIAOZHI/Rainytoken/actions) [![Release](https://github.com/CATMIAOZHI/Rainytoken/actions/workflows/release.yml/badge.svg)](https://github.com/CATMIAOZHI/Rainytoken/actions)
[![Version](https://img.shields.io/github/v/release/CATMIAOZHI/Rainytoken?color=ff85a2)](https://github.com/CATMIAOZHI/Rainytoken/releases) [![Version](https://img.shields.io/github/v/release/CATMIAOZHI/Rainytoken?color=ff85a2)](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)

View File

@ -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,

View File

@ -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())
} }

View File

@ -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,

View File

@ -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)}")
}
} }
} }

View File

@ -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() },

View File

@ -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"
}
}

View File

@ -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(

View File

@ -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

View File

@ -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,

View File

@ -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) }

View File

@ -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
} }
} }

View File

@ -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
}
}
}
} }
} }

View File

@ -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) }

View File

@ -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()

View File

@ -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(

View File

@ -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,

View File

@ -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())
} }
} }