diff --git a/.gitignore b/.gitignore index 9919769..f8cf874 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ release.jks .operit/ .kotlin/ *.jks +tmp_cc/ diff --git a/AGENTS.md b/AGENTS.md index b34d8b5..fa834d1 100644 --- a/AGENTS.md +++ b/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) diff --git a/README.md b/README.md index 44e278e..11d51d9 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![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) -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) diff --git a/app/src/main/java/com/rainy/token/data/local/UsageCache.kt b/app/src/main/java/com/rainy/token/data/local/UsageCache.kt index 4c2789e..566813b 100644 --- a/app/src/main/java/com/rainy/token/data/local/UsageCache.kt +++ b/app/src/main/java/com/rainy/token/data/local/UsageCache.kt @@ -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, diff --git a/app/src/main/java/com/rainy/token/data/repository/CommandCodeGoRepository.kt b/app/src/main/java/com/rainy/token/data/repository/CommandCodeGoRepository.kt index 6612887..3d2b1d3 100644 --- a/app/src/main/java/com/rainy/token/data/repository/CommandCodeGoRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/CommandCodeGoRepository.kt @@ -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()) } diff --git a/app/src/main/java/com/rainy/token/data/repository/CommandCodeUsageRepository.kt b/app/src/main/java/com/rainy/token/data/repository/CommandCodeUsageRepository.kt index a71df37..cfd6f12 100644 --- a/app/src/main/java/com/rainy/token/data/repository/CommandCodeUsageRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/CommandCodeUsageRepository.kt @@ -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, 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, diff --git a/app/src/main/java/com/rainy/token/domain/usecase/SyncUsageUseCase.kt b/app/src/main/java/com/rainy/token/domain/usecase/SyncUsageUseCase.kt index afcc48e..3498db5 100644 --- a/app/src/main/java/com/rainy/token/domain/usecase/SyncUsageUseCase.kt +++ b/app/src/main/java/com/rainy/token/domain/usecase/SyncUsageUseCase.kt @@ -83,7 +83,10 @@ data class SyncResult( ) sealed class SyncError : Exception() { - class PartialSync(val inserted: Int, val errors: List) : SyncError() { - override val message: String = "部分同步完成:插入 $inserted 条,${errors.size} 页失败" - } +class PartialSync(val inserted: Int, val errors: List) : SyncError() { +override val message: String = buildString { +append("部分同步完成:插入 ${inserted}条,${errors.size}页失败") +if (errors.isNotEmpty()) append("。${errors.first().take(200)}") +} +} } \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt b/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt index ccfa1d1..0d13118 100644 --- a/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt +++ b/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt @@ -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() }, diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/CommandCodeUsageStatsCard.kt b/app/src/main/java/com/rainy/token/ui/dashboard/CommandCodeUsageStatsCard.kt new file mode 100644 index 0000000..2fd6a5b --- /dev/null +++ b/app/src/main/java/com/rainy/token/ui/dashboard/CommandCodeUsageStatsCard.kt @@ -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() + 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" + } +} diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt index 95b125f..62c9791 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt @@ -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( diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt index eb5e118..870ec21 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt @@ -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 diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/UsageChartViewModel.kt b/app/src/main/java/com/rainy/token/ui/dashboard/UsageChartViewModel.kt index ddda919..4d03632 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/UsageChartViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/UsageChartViewModel.kt @@ -29,14 +29,25 @@ class UsageChartViewModel @Inject constructor( private val _state = MutableStateFlow(ChartUiState()) val state: StateFlow = _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, diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/UsageDataScreen.kt b/app/src/main/java/com/rainy/token/ui/dashboard/UsageDataScreen.kt index db7f3a2..5e56463 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/UsageDataScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/UsageDataScreen.kt @@ -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) } diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/UsageDataViewModel.kt b/app/src/main/java/com/rainy/token/ui/dashboard/UsageDataViewModel.kt index c0d3c43..d4ea305 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/UsageDataViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/UsageDataViewModel.kt @@ -27,22 +27,36 @@ class UsageDataViewModel @Inject constructor( private val _state = MutableStateFlow(UsageDataState()) val state: StateFlow = _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 } } diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/UsageDetailScreen.kt b/app/src/main/java/com/rainy/token/ui/dashboard/UsageDetailScreen.kt index db1dcf2..97c901a 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/UsageDetailScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/UsageDetailScreen.kt @@ -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 + } + } + } } } diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/UsageOverviewScreen.kt b/app/src/main/java/com/rainy/token/ui/dashboard/UsageOverviewScreen.kt index efb2c93..95279d3 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/UsageOverviewScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/UsageOverviewScreen.kt @@ -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) } diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/UsageStatsCard.kt b/app/src/main/java/com/rainy/token/ui/dashboard/UsageStatsCard.kt index 993a0bc..fb266f8 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/UsageStatsCard.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/UsageStatsCard.kt @@ -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() diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/UsageViewModel.kt b/app/src/main/java/com/rainy/token/ui/dashboard/UsageViewModel.kt index 83da34d..a179941 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/UsageViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/UsageViewModel.kt @@ -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, private val syncUseCaseProvider: Provider, + private val syncCommandCodeUseCaseProvider: Provider, private val credentialRepository: CredentialRepository ) : ViewModel() { private val _uiState = MutableStateFlow(UsageUiState()) val uiState: StateFlow = _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( diff --git a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt index 7fa0243..b0188d9 100644 --- a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt @@ -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, diff --git a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt index c33aeda..95944ac 100644 --- a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt @@ -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()) } }