feat: CCGO 用量功能 + 首页布局修复 + 文档同步

- 新增 CommandCode Go (CCGO) 用量抓取与展示
- CCGO 输入 token 语义修正(inputTokens = tokensIn - cacheRead)
- 清除数据按钮迁移至 CCGO 详情页顶栏(带倒计时二次确认)
- 移除三个 ViewModel 的 init 自动加载,改为 LaunchedEffect 显式触发
- 修复 hiltViewModel key 碰撞导致 CCGO 详情页无限加载
- 首页 LazyColumn 改为 Column + verticalScroll + PullToRefreshBox
- OCGO/CCGO 页面 autoLoad 参数隔离重复 load
- AGENTS.md / README.md 文档同步更新
This commit is contained in:
WaterRain 2026-06-26 20:37:07 +00:00
parent bb51efde09
commit cc7aecc331
No known key found for this signature in database
20 changed files with 714 additions and 81 deletions

1
.gitignore vendored
View File

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

View File

@ -2,9 +2,9 @@
## 项目概述
Android(Jetpack Compose + Kotlin)APP,统一查询 DeepSeek、OpenCode Go 两项服务的余额/配额。
Android(Jetpack Compose + Kotlin)APP,统一查询 DeepSeek、OpenCode Go、CommandCode Go 三项服务的余额/配额。
DeepSeek 走 REST API,OpenCode Go 通过 OkHttp 抓取 dashboard HTML 解析 SSR hydration 数据。
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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,198 @@
package com.rainy.token.ui.dashboard
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.rainy.token.data.local.OverviewStats
import com.rainy.token.ui.theme.InkMuted
import com.rainy.token.ui.theme.StrawberryPink
import java.util.Locale
/**
* CommandCode Go 用量统计主卡片 —— 风格与 UsageStatsCard(OCGO)完全一致。
* 仅展示核心指标:输入 Token(含 Cache)、输出 Token、总花费,
* 外加同步按钮和"查看详情"入口。详细统计在 CCGO 专属详情页。
*/
@Composable
fun CommandCodeUsageStatsCard(
onOpenDetail: () -> Unit,
refreshTrigger: Int = 0
) {
val wid = com.rainy.token.data.repository.CommandCodeUsageRepository.CCGO_WORKSPACE_ID
val key = "ccgo_$wid"
val viewModel: UsageViewModel = hiltViewModel(key = key)
// 初始化 workspace,后续刷新自动走 CCGO 的 sync use case
LaunchedEffect(Unit) {
viewModel.setWorkspace(wid)
}
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// 全局刷新触发用量同步(跳过首次 0)
LaunchedEffect(refreshTrigger) {
if (refreshTrigger > 0) viewModel.sync()
}
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
// ─── 头部:标题 + 同步按钮 ───
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "CommandCode 用量",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Text(
text = if (uiState.recordCount > 0) "${uiState.recordCount} 条记录" else "点击同步",
style = MaterialTheme.typography.bodySmall,
color = InkMuted
)
}
if (uiState.syncing) {
CircularProgressIndicator(
modifier = Modifier.padding(4.dp),
strokeWidth = 2.dp,
color = StrawberryPink
)
} else {
IconButton(onClick = { viewModel.sync() }) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = "同步用量数据",
tint = StrawberryPink
)
}
}
}
// ─── 核心指标 ───
uiState.overview?.let { overview ->
Spacer(modifier = Modifier.height(12.dp))
// 输入 Token = input + cacheRead
val inputTotal = overview.inputTokens + overview.cacheReadTokens
MetricRow("输入 Token", formatTokenCount(inputTotal))
// 缓存子行
if (overview.cacheReadTokens > 0 || overview.cacheWriteTokens > 0) {
val parts = mutableListOf<String>()
if (overview.cacheReadTokens > 0) parts.add("缓存读取 ${formatTokenCount(overview.cacheReadTokens)}")
if (overview.cacheWriteTokens > 0) parts.add("缓存写入 ${formatTokenCount(overview.cacheWriteTokens)}")
Text(
text = parts.joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = InkMuted,
modifier = Modifier.padding(start = 4.dp, top = 2.dp)
)
}
Spacer(modifier = Modifier.height(4.dp))
// 输出 Token + 总花费 同一排
Row(Modifier.fillMaxWidth()) {
MetricRow("输出 Token", formatTokenCount(overview.outputTokens), Modifier.weight(1f))
MetricRow("总花费", "$${String.format(Locale.US, "%.4f", overview.totalCost / 100_000_000.0)}", Modifier.weight(1f))
}
}
// ─── 空状态 ───
if (uiState.overview == null && !uiState.syncing) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = if (uiState.loading) "加载中…" else "暂无数据,点击 🔄 同步",
style = MaterialTheme.typography.bodyMedium,
color = InkMuted
)
}
// ─── 查看详情 ───
if (uiState.overview != null) {
Spacer(modifier = Modifier.height(8.dp))
TextButton(onClick = onOpenDetail) {
Text("查看详情", color = StrawberryPink)
Spacer(modifier = Modifier.width(2.dp))
Icon(
imageVector = Icons.Filled.KeyboardArrowRight,
contentDescription = null,
tint = StrawberryPink,
modifier = Modifier.padding(top = 1.dp)
)
}
}
// ─── 同步结果反馈 ───
if (uiState.lastSyncResult > 0) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "✓ 新增 ${uiState.lastSyncResult} 条",
style = MaterialTheme.typography.bodySmall,
color = StrawberryPink
)
}
uiState.lastSyncError?.let { err ->
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "✗ $err",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
}
}
}
@Composable
private fun MetricRow(label: String, value: String, modifier: Modifier = Modifier) {
Column(modifier = modifier.fillMaxWidth()) {
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = InkMuted
)
Text(
text = value,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = StrawberryPink
)
}
}
private fun formatTokenCount(tokens: Long): String {
return when {
tokens >= 1_000_000 -> "${"%.1f".format(Locale.US, tokens / 1_000_000.0)}M"
tokens >= 1_000 -> "${"%.1f".format(Locale.US, tokens / 1_000.0)}K"
else -> "$tokens"
}
}

View File

@ -17,14 +17,16 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.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(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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