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/
|
||||
.kotlin/
|
||||
*.jks
|
||||
tmp_cc/
|
||||
|
||||
44
AGENTS.md
44
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 数据。
|
||||
APP 名为「雨晴Token」(粉色调品牌),配套桌面小组件。
|
||||
CommandCode Go 走 JSON API 抓取用量数据。APP 名为「雨晴Token」(粉色调品牌),配套桌面小组件。
|
||||
|
||||
## 技术栈
|
||||
|
||||
@ -21,20 +21,43 @@ APP 名为「雨晴Token」(粉色调品牌),配套桌面小组件。
|
||||
**服务**:
|
||||
- ✅ DeepSeek — REST API `GET /user/balance`,API Key 认证
|
||||
- ✅ OpenCode Go — OkHttp 抓 dashboard HTML,解析 `rollingUsage`/`weeklyUsage`/`monthlyUsage`
|
||||
- ✅ CommandCode Go — JSON API 抓取用量数据,`CommandCodeUsageRepository` 解析(workspaceId = `"commandcode"`)
|
||||
- ❌ OpenCode Zen / 小米 MiMo — 未实现
|
||||
|
||||
**用量统计系统**:
|
||||
- ✅ `UsageCache`(DataStore,~3700 条记录)— 全量 JSON 序列化 + 内存缓存(`@Volatile cachedAll`),仅在写入后失效
|
||||
- ✅ `SyncUsageUseCase` — 首次全量同步(cursor 翻页)、增量同步(逐页比对本地 ID 集合)
|
||||
- ✅ `SyncUsageUseCase`(OCGO)/ `SyncCommandCodeUsageUseCase`(CCGO) — 首次全量同步(cursor 翻页)、增量同步(逐页比对本地 ID 集合)
|
||||
- ✅ `UsageViewModel` — `loadStatsInternal()` 单次 `getRecords()`→ 内存聚合 Overview/ModelStats/DailyStats,所有重操作包在 `withContext(Dispatchers.Default)` 避免主线程卡顿
|
||||
- ✅ `UsageChartViewModel` — 图表粒度(5h/24h/今天/昨天/7天/当月/自定义日/月/范围),模型多选,3 张 Canvas 图表
|
||||
- ✅ `UsageDataViewModel` — 原始记录分页浏览(20条/页),支持时间+模型筛选,页码输入跳转
|
||||
- ✅ 全局刷新绑定 — 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(总统计)
|
||||
↘ UsageData(原始数据)
|
||||
Dashboard → OCGO: UsageDetail(图表) → UsageOverview(总统计)
|
||||
↘ UsageData(原始数据)
|
||||
→ CCGO: CCGO_USAGE_DETAIL(图表) → CCGO_USAGE_OVERVIEW(总统计)
|
||||
↘ CCGO_USAGE_DATA(原始数据)
|
||||
```
|
||||
|
||||
**桌面小组件(Widget)**:
|
||||
@ -77,11 +100,20 @@ DashboardViewModel.refresh()
|
||||
→ OpenCodeGoWidgetProvider.notifyDataChanged(context)
|
||||
|
||||
Dashboard 下拉刷新 → usageSyncTrigger++ → UsageViewModel.sync()
|
||||
→ SyncUsageUseCase.fullSync() / incrementalSync()
|
||||
→ OCGO: SyncUsageUseCase.fullSync() / incrementalSync()
|
||||
→ OpenCodeUsageRepository.fetchPage(cursor) 逐页抓取
|
||||
→ UsageCache.insertAll() → persist() → invalidateCache()
|
||||
→ CCGO: SyncCommandCodeUsageUseCase.fullSync() / incrementalSync()
|
||||
→ CommandCodeUsageRepository.fetchPage(cursor) 逐页抓取
|
||||
→ UsageViewModel.loadStats() → getRecords() → 内存聚合
|
||||
|
||||
CCGO 清除按钮(详情页顶栏):
|
||||
点击 → AlertDialog 警告弹窗 → 3s 倒计时确认
|
||||
→ UsageViewModel.clearAndResync()
|
||||
→ UsageCache.deleteByWorkspaceId("commandcode")
|
||||
→ SyncCommandCodeUsageUseCase.fullSync()
|
||||
→ loadStats() → onBack()
|
||||
|
||||
Widget 刷新按钮:
|
||||
↻ → PendingIntent.getBroadcast() → WidgetRefreshReceiver
|
||||
→ EntryPoints → RefreshBalanceUseCase(DEEPSEEK + OPENCODE_GO)
|
||||
|
||||
20
README.md
20
README.md
@ -5,7 +5,7 @@
|
||||
[](https://github.com/CATMIAOZHI/Rainytoken/actions)
|
||||
[](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/本周/本月)+ 下拉全局刷新 |
|
||||
| 📈 **用量图表** | 3 张 Canvas 手绘图表 — 消耗金额 / API 请求次数 / Token 消耗 |
|
||||
| 📊 **仪表盘** | DeepSeek 余额(¥)+ OpenCode Go 用量(5h/本周/本月)+ CommandCode Go 余额($)+ 下拉全局刷新 |
|
||||
| 📈 **用量图表** | 3 张 Canvas 手绘图表 — 消耗金额 / API 请求次数 / Token 消耗(OCGO & CCGO 双数据源) |
|
||||
| 📋 **详细数据** | 原始记录分页浏览,支持时间 + 模型筛选,点击查看完整字段 |
|
||||
| 🔍 **多粒度筛选** | 5小时 / 24小时 / 今天 / 昨天 / 最近7天 / 当月 / 自定义日·月·范围 |
|
||||
| 🏷️ **模型筛选** | 多选 / 单选 / 全选,动态图例自适应换行 |
|
||||
| 📱 **桌面小组件** | 不打开 APP 也能看用量;可拖入负一屏;划到即自动刷新(MIUI 曝光刷新) |
|
||||
| 🔄 **自动同步** | 首页下拉自动同步用量;无缓存时启动自动全量同步 |
|
||||
| 🔄 **自动同步** | 首页下拉自动同步用量;无缓存时启动自动全量同步;CCGO 详情页支持手动清除并重新同步 |
|
||||
| 🌙 **深色模式** | 全局自适应 — App 内文字/图标/背景自动切换,小组件独立适配暗色布局 |
|
||||
| ➕ **一键添桌面** | APP 内点 + 直接添加小组件,不用去系统列表翻 |
|
||||
| ⚡ **内存缓存** | DataStore 全量 JSON 仅反序列化一次,后续操作零 IO |
|
||||
@ -55,13 +55,13 @@ Android AI 余额查询 APP —— 统一查看 DeepSeek 和 OpenCode Go 的余
|
||||
│ ┌────────────────────▼─────────────────────────┐ │
|
||||
│ │ UseCase 层 │ │
|
||||
│ │ RefreshBalanceUseCase(余额) │ │
|
||||
│ │ SyncUsageUseCase(用量全量/增量同步) │ │
|
||||
│ │ SyncUsageUseCase / SyncCommandCodeUsageUseCase│ │
|
||||
│ └────────────────────┬────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌────────────────────▼─────────────────────────┐ │
|
||||
│ │ Repository + Network │ │
|
||||
│ │ DeepSeekApi(Retrofit)· OpenCodeGo 网页抓取 │ │
|
||||
│ │ · OpenCodeUsageRepository(分页翻页) │ │
|
||||
│ │ · OpenCodeUsageRepository · CommandCodeUsageRepository │ │
|
||||
│ └────────────────────┬────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌────────────────────▼─────────────────────────┐ │
|
||||
@ -83,14 +83,14 @@ Rainytoken/
|
||||
│ ├── data/
|
||||
│ │ ├── cache/ # BalanceCache(DataStore)
|
||||
│ │ ├── local/ # UsageCache(DataStore + 内存缓存)、UsageRecord、ChartBucket
|
||||
│ │ ├── remote/ # DeepSeekApi(Retrofit)+ OpenCodeGo 抓取 + OpenCodeUsageRepository
|
||||
│ │ └── repository/ # DeepSeekRepository / OpenCodeGoRepository / CredentialRepository
|
||||
│ │ ├── remote/ # DeepSeekApi(Retrofit)+ OpenCodeGo 抓取 + UsageRepository
|
||||
│ │ └── repository/ # DeepSeek / OpenCodeGo / CommandCode / Credential Repository
|
||||
│ ├── domain/
|
||||
│ │ ├── model/ # ServiceBalance、Credential 等
|
||||
│ │ ├── service/ # ServiceType 枚举
|
||||
│ │ └── usecase/ # RefreshBalanceUseCase / SyncUsageUseCase
|
||||
│ │ └── usecase/ # RefreshBalanceUseCase / SyncUsageUseCase / SyncCommandCodeUsageUseCase
|
||||
│ ├── ui/
|
||||
│ │ ├── dashboard/ # DashboardScreen / UsageDetailScreen(图表)/ UsageOverviewScreen(总统计)/ UsageDataScreen(原始数据)
|
||||
│ │ ├── dashboard/ # DashboardScreen / UsageDetailScreen / UsageOverviewScreen / UsageDataScreen
|
||||
│ │ ├── widget/ # 桌面小组件(OpenCodeGoWidgetProvider)
|
||||
│ │ ├── components/ # ServiceIcon / StatusChip 等
|
||||
│ │ ├── theme/ # 雨晴粉主题(StrawberryPink / InkMuted)
|
||||
|
||||
@ -77,8 +77,18 @@ class UsageCache(
|
||||
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
|
||||
|
||||
/** 按 workspaceId 统计记录数。 */
|
||||
suspend fun count(workspaceId: String): Int = loadAll().count { it.workspaceId == workspaceId }
|
||||
|
||||
/** 获取过滤后的原始记录列表(供图表等聚合使用) */
|
||||
suspend fun getRecords(
|
||||
workspaceId: String,
|
||||
|
||||
@ -42,12 +42,13 @@ class CommandCodeGoRepository(
|
||||
val credential = credentialRepository.get(ServiceType.COMMANDCODE_GO)
|
||||
?: return@withContext Result.failure(RepositoryError.InvalidCredential())
|
||||
|
||||
if (credential !is Credential.ApiKeyCredential) {
|
||||
if (credential !is Credential.SessionCredential) {
|
||||
return@withContext Result.failure(RepositoryError.InvalidCredential())
|
||||
}
|
||||
|
||||
val apiKey = credential.key
|
||||
if (apiKey.isBlank()) {
|
||||
// token 字段存 API Key
|
||||
val apiKey = credential.token
|
||||
if (apiKey.isNullOrBlank()) {
|
||||
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.domain.model.Credential
|
||||
import com.rainy.token.domain.model.CookieEntry
|
||||
import com.rainy.token.domain.service.ServiceType
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
@ -41,16 +42,22 @@ class CommandCodeUsageRepository(
|
||||
const val PAGE_SIZE = 100
|
||||
/** cost / DENOM = USD */
|
||||
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)
|
||||
?: throw RepositoryError.InvalidCredential()
|
||||
if (c !is Credential.ApiKeyCredential || c.key.isBlank()) {
|
||||
if (c !is Credential.SessionCredential) {
|
||||
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?>> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val apiKey = try {
|
||||
getApiKey()
|
||||
val cookieHeader = try {
|
||||
getCookieHeader()
|
||||
} catch (e: RepositoryError) {
|
||||
return@withContext Result.failure(e)
|
||||
}
|
||||
@ -73,7 +80,7 @@ class CommandCodeUsageRepository(
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.header("Authorization", "Bearer $apiKey")
|
||||
.header("Cookie", cookieHeader)
|
||||
.header("Accept", "application/json")
|
||||
.header("Origin", "https://commandcode.ai")
|
||||
.header("Referer", "https://commandcode.ai/")
|
||||
@ -87,14 +94,18 @@ class CommandCodeUsageRepository(
|
||||
}
|
||||
|
||||
response.use { resp ->
|
||||
val body = resp.body?.string()
|
||||
if (!resp.isSuccessful) {
|
||||
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))
|
||||
}
|
||||
|
||||
val body = resp.body?.string() ?: return@withContext Result.failure(
|
||||
if (body == null) return@withContext Result.failure(
|
||||
RepositoryError.ParseError("响应体为空")
|
||||
)
|
||||
|
||||
@ -141,6 +152,9 @@ class CommandCodeUsageRepository(
|
||||
val provider = meta?.get("provider")?.jsonPrimitive?.content ?: ""
|
||||
val cacheReadInputTokens = meta?.get("cacheReadInputTokens")?.jsonPrimitive?.content?.toLongOrNull() ?: 0L
|
||||
|
||||
// CCGO 的 tokensIn 是总输入(缓存命中 + 未命中),按 OCGO 惯例拆分为 inputTokens(未命中)和 cacheReadTokens(命中)
|
||||
val inputMissTokens = (tokensIn - cacheReadInputTokens).coerceAtLeast(0)
|
||||
|
||||
return UsageRecord(
|
||||
id = id,
|
||||
workspaceId = CCGO_WORKSPACE_ID,
|
||||
@ -148,7 +162,7 @@ class CommandCodeUsageRepository(
|
||||
timeUpdated = timeCreated,
|
||||
model = model,
|
||||
provider = provider,
|
||||
inputTokens = tokensIn,
|
||||
inputTokens = inputMissTokens,
|
||||
outputTokens = tokensOut,
|
||||
reasoningTokens = 0L,
|
||||
cacheReadTokens = cacheReadInputTokens,
|
||||
|
||||
@ -83,7 +83,10 @@ data class SyncResult(
|
||||
)
|
||||
|
||||
sealed class SyncError : Exception() {
|
||||
class PartialSync(val inserted: Int, val errors: List<String>) : SyncError() {
|
||||
override val message: String = "部分同步完成:插入 $inserted 条,${errors.size} 页失败"
|
||||
}
|
||||
class PartialSync(val inserted: Int, val errors: List<String>) : SyncError() {
|
||||
override val message: String = buildString {
|
||||
append("部分同步完成:插入 ${inserted}条,${errors.size}页失败")
|
||||
if (errors.isNotEmpty()) append("。${errors.first().take(200)}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,8 @@
|
||||
package com.rainy.token.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
@ -8,9 +10,12 @@ import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.rainy.token.domain.service.ServiceType
|
||||
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.UsageDataViewModel
|
||||
import com.rainy.token.ui.dashboard.UsageDetailScreen
|
||||
import com.rainy.token.ui.dashboard.UsageOverviewScreen
|
||||
import com.rainy.token.ui.dashboard.UsageViewModel
|
||||
import com.rainy.token.ui.servicedetail.ServiceDetailScreen
|
||||
import com.rainy.token.ui.settings.CredentialEditScreen
|
||||
import com.rainy.token.ui.settings.SettingsScreen
|
||||
@ -33,6 +38,9 @@ object Routes {
|
||||
const val USAGE_DETAIL = "usage_detail"
|
||||
const val USAGE_OVERVIEW = "usage_overview"
|
||||
const val USAGE_DATA = "usage_data"
|
||||
const val CCGO_USAGE_DETAIL = "ccgo_usage_detail"
|
||||
const val CCGO_USAGE_OVERVIEW = "ccgo_usage_overview"
|
||||
const val CCGO_USAGE_DATA = "ccgo_usage_data"
|
||||
}
|
||||
|
||||
private fun parseServiceType(typeName: String?): ServiceType =
|
||||
@ -40,7 +48,7 @@ private fun parseServiceType(typeName: String?): ServiceType =
|
||||
ServiceType.fromStorageKey(it) ?: runCatching { ServiceType.valueOf(it) }.getOrNull()
|
||||
} ?: ServiceType.DEEPSEEK
|
||||
|
||||
@Composable
|
||||
@Composable
|
||||
fun RainyTokenNavHost() {
|
||||
val navController = rememberNavController()
|
||||
NavHost(navController = navController, startDestination = Routes.DASHBOARD) {
|
||||
@ -48,7 +56,8 @@ fun RainyTokenNavHost() {
|
||||
DashboardScreen(
|
||||
onOpenSettings = { navController.navigate(Routes.SETTINGS) },
|
||||
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) {
|
||||
@ -68,6 +77,45 @@ fun RainyTokenNavHost() {
|
||||
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) {
|
||||
SettingsScreen(
|
||||
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.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
@ -78,6 +80,7 @@ import java.util.Locale
|
||||
* 视觉:
|
||||
* - 顶部 TopAppBar 透明 + 渐变背景
|
||||
* - 下拉刷新(PullToRefresh)触发 DashboardViewModel.refresh()
|
||||
* - 顶栏刷新按钮亦可触发
|
||||
* - 卡片:白底圆角 + 左侧服务图标 + 中间余额大数字 + 右侧状态 chip
|
||||
* - 卡片底部展示"更新于 X 分钟前"或错误信息
|
||||
* - 主数字加粗超大,视觉锚点
|
||||
@ -88,6 +91,7 @@ fun DashboardScreen(
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenService: (ServiceType) -> Unit,
|
||||
onOpenUsageDetail: () -> Unit,
|
||||
onOpenCcgoUsageDetail: () -> Unit = {},
|
||||
viewModel: DashboardViewModel = hiltViewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
@ -186,26 +190,30 @@ fun DashboardScreen(
|
||||
CircularProgressIndicator(color = StrawberryPink)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// 用量统计卡片(主页面,独立 ViewModel)
|
||||
item { UsageStatsCard(onOpenDetail = onOpenUsageDetail, refreshTrigger = usageSyncTrigger) }
|
||||
UsageStatsCard(onOpenDetail = onOpenUsageDetail, refreshTrigger = usageSyncTrigger)
|
||||
// CommandCode Go 用量统计卡片(风格完全一致)
|
||||
CommandCodeUsageStatsCard(onOpenDetail = onOpenCcgoUsageDetail, refreshTrigger = usageSyncTrigger)
|
||||
// 分隔
|
||||
item {
|
||||
Text(
|
||||
text = "服务余额",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = inkMuted(),
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
}
|
||||
items(uiState.cards, key = { it.service.name }) { card ->
|
||||
DashboardCard(card = card, onClick = { onOpenService(card.service) })
|
||||
Text(
|
||||
text = "服务余额",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = inkMuted(),
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
uiState.cards.forEach { card ->
|
||||
DashboardCard(card = card, onClick = { onOpenService(card.service) },
|
||||
onOpenCcgoUsageDetail = if (card.service == ServiceType.COMMANDCODE_GO) onOpenCcgoUsageDetail else null)
|
||||
}
|
||||
// 底部 footer:填空白 + 提供版本号
|
||||
item { DashboardFooter() }
|
||||
DashboardFooter()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -213,7 +221,7 @@ fun DashboardScreen(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DashboardCard(card: DashboardCardUi, onClick: () -> Unit) {
|
||||
private fun DashboardCard(card: DashboardCardUi, onClick: () -> Unit, onOpenCcgoUsageDetail: (() -> Unit)? = null) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@ -247,6 +255,21 @@ private fun DashboardCard(card: DashboardCardUi, onClick: () -> Unit) {
|
||||
// ─── 主体:服务特定的主信息 ───
|
||||
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))
|
||||
Text(
|
||||
|
||||
@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.rainy.token.data.cache.BalanceCache
|
||||
import com.rainy.token.data.cache.CachedBalance
|
||||
import com.rainy.token.data.repository.CredentialRepository
|
||||
import com.rainy.token.domain.model.Credential
|
||||
import com.rainy.token.domain.model.CredentialStatus
|
||||
import com.rainy.token.domain.model.ServiceBalance
|
||||
import com.rainy.token.domain.service.ServiceType
|
||||
|
||||
@ -29,14 +29,25 @@ class UsageChartViewModel @Inject constructor(
|
||||
private val _state = MutableStateFlow(ChartUiState())
|
||||
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? {
|
||||
workspaceIdOverride?.let { return it }
|
||||
val c = credentialRepository.get(ServiceType.OPENCODE_GO)
|
||||
return (c as? Credential.SessionCredential)?.workspaceId?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
// 不在 init 自动加载,由 Composable 层显式触发 load()(OCGO)或 setWorkspace()(CCGO)
|
||||
|
||||
fun setGranularity(g: ChartGranularity) {
|
||||
_state.update { it.copy(granularity = g) }
|
||||
@ -65,6 +76,7 @@ class UsageChartViewModel @Inject constructor(
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
val wid = workspaceId() ?: return@launch
|
||||
val genAtStart = loadGeneration
|
||||
val cache = cacheProvider.get()
|
||||
val allModels = cache.getDistinctModels(wid)
|
||||
|
||||
@ -96,6 +108,9 @@ class UsageChartViewModel @Inject constructor(
|
||||
val result = if (_state.value.granularity == ChartGranularity.LAST_5H_HOURLY)
|
||||
filled.takeLast(5) else filled
|
||||
|
||||
// 如果 loadGeneration 已经变化(setWorkspace 被调用),丢弃这次结果
|
||||
if (loadGeneration != genAtStart) return@launch
|
||||
|
||||
_state.update {
|
||||
it.copy(
|
||||
buckets = result,
|
||||
|
||||
@ -38,6 +38,7 @@ import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@ -67,9 +68,16 @@ import java.util.Locale
|
||||
@Composable
|
||||
fun UsageDataScreen(
|
||||
onBack: () -> Unit,
|
||||
viewModel: UsageDataViewModel = hiltViewModel()
|
||||
viewModel: UsageDataViewModel = hiltViewModel(),
|
||||
autoLoad: Boolean = true
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
// OCGO 首次加载(CCGO 由 setWorkspace 触发,不重复 load)
|
||||
LaunchedEffect(Unit) {
|
||||
if (autoLoad) viewModel.loadData()
|
||||
}
|
||||
|
||||
var timeMenuExpanded by remember { mutableStateOf(false) }
|
||||
var modelMenuExpanded by remember { mutableStateOf(false) }
|
||||
var showStartPicker by remember { mutableStateOf(false) }
|
||||
|
||||
@ -27,22 +27,36 @@ class UsageDataViewModel @Inject constructor(
|
||||
private val _state = MutableStateFlow(UsageDataState())
|
||||
val state: StateFlow<UsageDataState> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
loadModelsAndData()
|
||||
private var workspaceIdOverride: String? = null
|
||||
private var loadGeneration = 0
|
||||
|
||||
/** 覆盖 workspaceId,用于 CCGO 等非 OCGO 服务。必须在 loadModelsAndData() 前调用。 */
|
||||
fun setWorkspace(wid: String) {
|
||||
workspaceIdOverride = wid
|
||||
loadGeneration++
|
||||
// 先清空数据防止 init 自动加载的 OCGO 数据闪一下
|
||||
_state.value = UsageDataState()
|
||||
loadData()
|
||||
}
|
||||
|
||||
// 不在 init 自动加载,由 Composable 层显式触发 loadData()(OCGO)或 setWorkspace()(CCGO)
|
||||
|
||||
private fun loadModelsAndData() {
|
||||
viewModelScope.launch {
|
||||
val wid = workspaceId() ?: return@launch
|
||||
val genAtStart = loadGeneration
|
||||
val cache = cacheProvider.get()
|
||||
val models = cache.getDistinctModels(wid)
|
||||
_state.update { it.copy(allModels = models) }
|
||||
// 如果 loadGeneration 已经变化,丢弃这次结果
|
||||
if (loadGeneration != genAtStart) return@launch
|
||||
// 紧接着加载数据
|
||||
loadDataInternal(cache, wid)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun workspaceId(): String? {
|
||||
workspaceIdOverride?.let { return it }
|
||||
val c = credentialRepository.get(ServiceType.OPENCODE_GO)
|
||||
return (c as? Credential.SessionCredential)?.workspaceId?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
@ -51,8 +65,11 @@ class UsageDataViewModel @Inject constructor(
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(loading = true) }
|
||||
val wid = workspaceId() ?: return@launch
|
||||
val genAtStart = loadGeneration
|
||||
val cache = cacheProvider.get()
|
||||
loadDataInternal(cache, wid)
|
||||
// 如果 loadGeneration 已经变化,丢弃这次结果
|
||||
if (loadGeneration != genAtStart) return@launch
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -33,6 +33,7 @@ import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberDatePickerState
|
||||
import androidx.compose.material3.rememberTimePickerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@ -65,9 +66,18 @@ fun UsageDetailScreen(
|
||||
onBack: () -> Unit,
|
||||
onOpenOverview: () -> Unit,
|
||||
onOpenData: () -> Unit = {},
|
||||
viewModel: UsageChartViewModel = hiltViewModel()
|
||||
viewModel: UsageChartViewModel = hiltViewModel(),
|
||||
clearViewModel: UsageViewModel? = null // non-null = CCGO, 显示清除按钮
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
// OCGO 首次加载(CCGO 由 NavHost 的 setWorkspace 触发,不重复 load)
|
||||
LaunchedEffect(Unit) {
|
||||
if (clearViewModel == null) viewModel.load()
|
||||
}
|
||||
|
||||
var showClearDialog by remember { mutableStateOf(false) }
|
||||
var clearCountdown by remember { mutableStateOf(0) }
|
||||
var granularityExpanded by remember { mutableStateOf(false) }
|
||||
var modelExpanded by remember { mutableStateOf(false) }
|
||||
var showCustomDayPicker by remember { mutableStateOf(false) }
|
||||
@ -86,6 +96,11 @@ fun UsageDetailScreen(
|
||||
title = { Text("用量详情") },
|
||||
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, "返回") } },
|
||||
actions = {
|
||||
if (clearViewModel != null) {
|
||||
TextButton(onClick = { showClearDialog = true }) {
|
||||
Text("清除", color = MaterialTheme.colorScheme.error, fontWeight = FontWeight.SemiBold, fontSize = 13.sp)
|
||||
}
|
||||
}
|
||||
TextButton(onClick = onOpenData) {
|
||||
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.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.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@ -56,9 +57,16 @@ import java.util.Locale
|
||||
@Composable
|
||||
fun UsageOverviewScreen(
|
||||
onBack: () -> Unit,
|
||||
viewModel: UsageViewModel = hiltViewModel()
|
||||
viewModel: UsageViewModel = hiltViewModel(),
|
||||
autoLoad: Boolean = true
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
// OCGO 首次加载(CCGO 由 setWorkspace 触发,不重复 load)
|
||||
LaunchedEffect(Unit) {
|
||||
if (autoLoad) viewModel.loadStats()
|
||||
}
|
||||
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
var modelMenuExpanded by remember { mutableStateOf(false) }
|
||||
var showStartPicker by remember { mutableStateOf(false) }
|
||||
|
||||
@ -46,6 +46,11 @@ fun UsageStatsCard(
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
// OCGO 首次加载(init 不再自动加载)
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loadStats()
|
||||
}
|
||||
|
||||
// 全局刷新触发用量同步(跳过首次 0)
|
||||
LaunchedEffect(refreshTrigger) {
|
||||
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.domain.model.Credential
|
||||
import com.rainy.token.domain.service.ServiceType
|
||||
import com.rainy.token.domain.usecase.SyncCommandCodeUsageUseCase
|
||||
import com.rainy.token.domain.usecase.SyncUsageUseCase
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@ -72,27 +73,29 @@ sealed class TimeFilter(val label: String) {
|
||||
class UsageViewModel @Inject constructor(
|
||||
private val cacheProvider: Provider<UsageCache>,
|
||||
private val syncUseCaseProvider: Provider<SyncUsageUseCase>,
|
||||
private val syncCommandCodeUseCaseProvider: Provider<SyncCommandCodeUsageUseCase>,
|
||||
private val credentialRepository: CredentialRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(UsageUiState())
|
||||
val uiState: StateFlow<UsageUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
loadStatsAndSyncIfNeeded()
|
||||
private var workspaceIdOverride: String? = null
|
||||
private var loadGeneration = 0 // 递增:过时的 loadStatsInternal 结果自动丢弃
|
||||
|
||||
/** 覆盖 workspaceId,用于 CCGO 等非 OCGO 服务。必须在 loadStats() 前调用。 */
|
||||
fun setWorkspace(wid: String) {
|
||||
workspaceIdOverride = wid
|
||||
loadGeneration++
|
||||
// 先清空数据防止 init 自动加载的 OCGO 数据闪一下
|
||||
_uiState.value = UsageUiState()
|
||||
loadStats()
|
||||
}
|
||||
|
||||
private fun loadStatsAndSyncIfNeeded() {
|
||||
viewModelScope.launch {
|
||||
loadStatsInternal()
|
||||
// 无缓存数据 → 自动触发同步
|
||||
if (_uiState.value.overview == null && _uiState.value.recordCount == 0) {
|
||||
sync()
|
||||
}
|
||||
}
|
||||
}
|
||||
// 不在 init 自动加载,由 Composable 层显式触发 loadStats()(OCGO)或 setWorkspace()(CCGO)
|
||||
|
||||
private suspend fun workspaceId(): String? {
|
||||
workspaceIdOverride?.let { return it }
|
||||
val c = credentialRepository.get(ServiceType.OPENCODE_GO)
|
||||
return (c as? Credential.SessionCredential)?.workspaceId?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
@ -104,6 +107,7 @@ class UsageViewModel @Inject constructor(
|
||||
/** 只调一次 getRecords(),所有统计从同一次结果派生。重 IO/CPU 操作跑在 Default 上。 */
|
||||
private suspend fun loadStatsInternal() {
|
||||
val wid = workspaceId() ?: return
|
||||
val genAtStart = loadGeneration // 记下发起时的世代
|
||||
val cache = cacheProvider.get()
|
||||
val filter = _uiState.value.timeFilter
|
||||
val (fromTs, toTs) = filter.toRange()
|
||||
@ -139,6 +143,9 @@ class UsageViewModel @Inject constructor(
|
||||
LoadResult(ov, ms, ds, records.size)
|
||||
}
|
||||
|
||||
// 如果 loadGeneration 已经变化(setWorkspace 被调用),丢弃这次结果
|
||||
if (loadGeneration != genAtStart) return
|
||||
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
overview = overview,
|
||||
@ -178,10 +185,16 @@ class UsageViewModel @Inject constructor(
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(syncing = true) }
|
||||
val result = withContext(Dispatchers.Default) {
|
||||
val useCase = syncUseCaseProvider.get()
|
||||
val cache = cacheProvider.get()
|
||||
val count = cache.count()
|
||||
if (count == 0) useCase.fullSync() else useCase.incrementalSync()
|
||||
if (workspaceIdOverride == com.rainy.token.data.repository.CommandCodeUsageRepository.CCGO_WORKSPACE_ID) {
|
||||
val useCase = syncCommandCodeUseCaseProvider.get()
|
||||
val count = cache.count(com.rainy.token.data.repository.CommandCodeUsageRepository.CCGO_WORKSPACE_ID)
|
||||
if (count == 0) useCase.fullSync() else useCase.incrementalSync()
|
||||
} else {
|
||||
val useCase = syncUseCaseProvider.get()
|
||||
val count = cache.count()
|
||||
if (count == 0) useCase.fullSync() else useCase.incrementalSync()
|
||||
}
|
||||
}
|
||||
result.onSuccess { loadStats() }
|
||||
_uiState.update {
|
||||
@ -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(
|
||||
|
||||
@ -109,13 +109,25 @@ fun CredentialEditScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
if (uiState.isApiKeyService) {
|
||||
ApiKeyForm(
|
||||
apiKey = uiState.apiKey,
|
||||
hasExisting = uiState.hasExisting,
|
||||
onApiKeyChange = viewModel::updateApiKey,
|
||||
onSave = viewModel::saveApiKey,
|
||||
onTestAndSave = viewModel::testAndSaveApiKey
|
||||
)
|
||||
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(
|
||||
apiKey = uiState.apiKey,
|
||||
hasExisting = uiState.hasExisting,
|
||||
onApiKeyChange = viewModel::updateApiKey,
|
||||
onSave = viewModel::saveApiKey,
|
||||
onTestAndSave = viewModel::testAndSaveApiKey
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// OpenCode Go:用户粘贴 auth cookie + workspaceId(自动抓取)
|
||||
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
|
||||
private fun ApiKeyForm(
|
||||
apiKey: String,
|
||||
|
||||
@ -65,7 +65,11 @@ class CredentialEditViewModel @Inject constructor(
|
||||
val existing = credentialRepository.get(type)
|
||||
_uiState.update {
|
||||
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) {
|
||||
existing.cookies.joinToString("; ") { c -> "${c.name}=${c.value}" }
|
||||
} 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() {
|
||||
val type = serviceType ?: return
|
||||
viewModelScope.launch {
|
||||
@ -333,6 +425,7 @@ class CredentialEditViewModel @Inject constructor(
|
||||
.mapNotNull { entry ->
|
||||
val parts = entry.trim().split("=", limit = 2)
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user