From 0f26269b7ed1f83a3053765d7fa89005ea477fd4 Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Fri, 26 Jun 2026 08:04:16 +0000 Subject: [PATCH] feat: add CommandCode Go support (JSON API via API Key) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New CommandCodeGoRepository: calls api.commandcode.ai/alpha/billing/credits + /alpha/billing/subscriptions - ServiceType: COMMANDCODE_GO added (reordered: OCGO → CCGO → DEEPSEEK) - ServiceConfigProvider: REST_API config with $ unit - NetworkModule: DI for CommandCodeGoRepository - RefreshBalanceUseCase: COMMANDCODE_GO branch - CredentialEditViewModel: inject CommandCodeGoRepository, allow testAndSave - ServiceIcon: ⚡ emoji for CommandCode Go - DashboardScreen: CCGO card with 3 windows (5h/weekly/monthly) like OCGO - ServiceDetailScreen: CommandCodeGoUsageCard with 5h→weekly→monthly order - Fixed resetAt: epoch millis → remaining seconds conversion --- .../repository/CommandCodeGoRepository.kt | 237 ++++++++++++++++++ .../java/com/rainy/token/di/NetworkModule.kt | 12 + .../domain/service/ServiceConfigProvider.kt | 6 + .../rainy/token/domain/service/ServiceType.kt | 1 + .../domain/usecase/RefreshBalanceUseCase.kt | 7 +- .../rainy/token/ui/components/ServiceIcon.kt | 15 ++ .../token/ui/dashboard/DashboardScreen.kt | 68 ++++- .../ui/servicedetail/ServiceDetailScreen.kt | 115 +++++++++ .../ui/settings/CredentialEditViewModel.kt | 4 +- 9 files changed, 460 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/rainy/token/data/repository/CommandCodeGoRepository.kt 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 new file mode 100644 index 0000000..6612887 --- /dev/null +++ b/app/src/main/java/com/rainy/token/data/repository/CommandCodeGoRepository.kt @@ -0,0 +1,237 @@ +package com.rainy.token.data.repository + +import com.rainy.token.data.cache.BalanceCache +import com.rainy.token.domain.model.Credential +import com.rainy.token.domain.model.ServiceBalance +import com.rainy.token.domain.service.ServiceConfigProvider +import com.rainy.token.domain.service.ServiceType +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long +import kotlinx.serialization.json.double +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.IOException +import javax.inject.Singleton + +/** + * CommandCode Go 余额仓库。 + * + * 调 JSON API 获取月度配额余额 + 用量窗口信息: + * GET https://api.commandcode.ai/alpha/billing/credits + * Authorization: Bearer + * + * 调 subscription 端点获取计划信息(用来算已用/总量百分比): + * GET https://api.commandcode.ai/alpha/billing/subscriptions + * Authorization: Bearer + */ +@Singleton +class CommandCodeGoRepository( + private val okHttpClient: OkHttpClient, + private val credentialRepository: CredentialRepository, + private val balanceCache: BalanceCache +) { + private val json = Json { ignoreUnknownKeys = true } + private val apiBase = "https://api.commandcode.ai" + + suspend fun fetchBalance(): Result = withContext(Dispatchers.IO) { + val credential = credentialRepository.get(ServiceType.COMMANDCODE_GO) + ?: return@withContext Result.failure(RepositoryError.InvalidCredential()) + + if (credential !is Credential.ApiKeyCredential) { + return@withContext Result.failure(RepositoryError.InvalidCredential()) + } + + val apiKey = credential.key + if (apiKey.isBlank()) { + return@withContext Result.failure(RepositoryError.InvalidCredential()) + } + + // 并行拉取 credits + subscriptions + val creditsResult = runCatching { fetchCredits(apiKey) } + val subResult = runCatching { fetchSubscription(apiKey) } + + val creditsPayload = creditsResult.getOrElse { e -> + return@withContext Result.failure( + if (e is IOException) RepositoryError.Network(e) + else RepositoryError.Unknown(e) + ) + } + + // 从订阅信息拿计划名称,查 plan catalog 拿总量 + val monthlyTotal = subResult.getOrNull()?.let { sub -> + PLANS[sub.planId.lowercase()] + } + val billingPeriodEndMillis = subResult.getOrNull()?.let { parseIsoToEpoch(it.currentPeriodEnd) } + + val config = ServiceConfigProvider.get(ServiceType.COMMANDCODE_GO) + + val used = monthlyTotal?.let { total -> + maxOf(0.0, total - creditsPayload.monthlyCredits) + } + + val extras = buildMap { + put("monthlyRemaining", creditsPayload.monthlyCredits.toString()) + put("purchasedCredits", creditsPayload.purchasedCredits.toString()) + put("freeCredits", creditsPayload.freeCredits.toString()) + monthlyTotal?.let { put("monthlyTotal", it.toString()) } + used?.let { put("monthlyUsed", it.toString()) } + creditsPayload.fiveHourUsed?.let { put("fiveHour.used", it.toString()) } + creditsPayload.fiveHourCap?.let { put("fiveHour.cap", it.toString()) } + creditsPayload.fiveHourResetAt?.let { put("fiveHour.resetInSec", epochToRemainingSec(it).toString()) } + creditsPayload.weeklyUsed?.let { put("weekly.used", it.toString()) } + creditsPayload.weeklyCap?.let { put("weekly.cap", it.toString()) } + creditsPayload.weeklyResetAt?.let { put("weekly.resetInSec", epochToRemainingSec(it).toString()) } + billingPeriodEndMillis?.let { + put("billingPeriodEnd", it.toString()) + put("monthly.resetInSec", epochToRemainingSec(it).toString()) + } + subResult.getOrNull()?.planId?.let { put("planId", it) } + subResult.getOrNull()?.planId?.let { put("planName", planDisplayName(it)) } + } + + val balance = ServiceBalance( + service = ServiceType.COMMANDCODE_GO, + amount = creditsPayload.monthlyCredits, + unit = config.displayUnit, + isAvailable = true, + monthlySpent = used, + totalQuota = monthlyTotal, + nextResetAt = billingPeriodEndMillis, + extras = extras + ) + + balanceCache.put(ServiceType.COMMANDCODE_GO, balance) + credentialRepository.save(credential.copy(lastVerifiedAt = System.currentTimeMillis())) + + Result.success(balance) + } + + private fun fetchCredits(apiKey: String): CreditsPayload { + val request = Request.Builder() + .url("$apiBase/alpha/billing/credits") + .header("Authorization", "Bearer $apiKey") + .header("Accept", "application/json") + .get() + .build() + + val response = okHttpClient.newCall(request).execute() + if (!response.isSuccessful) { + throw IOException("HTTP ${response.code}: ${response.body?.string().orEmpty()}") + } + + val body = response.body?.string() ?: throw IOException("空响应体") + val root = json.parseToJsonElement(body).jsonObject + val credits = root["credits"]?.jsonObject ?: throw IOException("缺少 credits 字段") + + fun getDouble(obj: JsonObject, key: String): Double = + obj[key]?.jsonPrimitive?.double ?: 0.0 + + fun getLong(obj: JsonObject, key: String): Long? = + obj[key]?.jsonPrimitive?.long + + val fiveHour = root["windowLimits"]?.jsonObject?.let { limits -> + if (limits["limited"]?.jsonPrimitive?.content == "true") { + limits["fiveHour"]?.jsonObject + } else null + } + val weekly = root["windowLimits"]?.jsonObject?.let { limits -> + if (limits["limited"]?.jsonPrimitive?.content == "true") { + limits["weekly"]?.jsonObject + } else null + } + + return CreditsPayload( + monthlyCredits = getDouble(credits, "monthlyCredits"), + purchasedCredits = getDouble(credits, "purchasedCredits"), + freeCredits = getDouble(credits, "freeCredits"), + fiveHourUsed = fiveHour?.let { getDouble(it, "used") }, + fiveHourCap = fiveHour?.let { getDouble(it, "cap") }, + fiveHourResetAt = fiveHour?.let { getLong(it, "resetAt") }, + weeklyUsed = weekly?.let { getDouble(it, "used") }, + weeklyCap = weekly?.let { getDouble(it, "cap") }, + weeklyResetAt = weekly?.let { getLong(it, "resetAt") } + ) + } + + private fun fetchSubscription(apiKey: String): SubscriptionPayload? { + val request = Request.Builder() + .url("$apiBase/alpha/billing/subscriptions") + .header("Authorization", "Bearer $apiKey") + .header("Accept", "application/json") + .get() + .build() + + val response = try { + okHttpClient.newCall(request).execute() + } catch (_: Exception) { + return null + } + + if (!response.isSuccessful) return null + val body = response.body?.string() ?: return null + + return try { + val root = json.parseToJsonElement(body).jsonObject + if (root["success"]?.jsonPrimitive?.content != "true") return null + val data = root["data"]?.jsonObject ?: return null + SubscriptionPayload( + planId = data["planId"]?.jsonPrimitive?.content.orEmpty(), + currentPeriodEnd = data["currentPeriodEnd"]?.jsonPrimitive?.content + ) + } catch (_: Exception) { null } + } + + private fun planDisplayName(planId: String): String = + PLAN_NAMES[planId.lowercase()] ?: planId + + companion object { + private val PLANS = mapOf( + "individual-go" to 10.0, + "individual-pro" to 30.0, + "individual-max" to 150.0, + "individual-ultra" to 300.0 + ) + + private val PLAN_NAMES = mapOf( + "individual-go" to "Go", + "individual-pro" to "Pro", + "individual-max" to "Max", + "individual-ultra" to "Ultra" + ) + + /** API 返回的是 epoch millis,转为距现在的剩余秒数 */ + private fun epochToRemainingSec(epochMillis: Long): Long = + maxOf(0L, (epochMillis - System.currentTimeMillis()) / 1000) + + private fun parseIsoToEpoch(isoStr: String?): Long? { + if (isoStr == null) return null + return try { + val sdf = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", java.util.Locale.US) + sdf.timeZone = java.util.TimeZone.getTimeZone("UTC") + sdf.parse(isoStr.take(19))?.time + } catch (_: Exception) { null } + } + } + + private data class CreditsPayload( + val monthlyCredits: Double, + val purchasedCredits: Double, + val freeCredits: Double, + val fiveHourUsed: Double?, + val fiveHourCap: Double?, + val fiveHourResetAt: Long?, + val weeklyUsed: Double?, + val weeklyCap: Double?, + val weeklyResetAt: Long? + ) + + private data class SubscriptionPayload( + val planId: String, + val currentPeriodEnd: String? + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/di/NetworkModule.kt b/app/src/main/java/com/rainy/token/di/NetworkModule.kt index 3428e8e..66669b5 100644 --- a/app/src/main/java/com/rainy/token/di/NetworkModule.kt +++ b/app/src/main/java/com/rainy/token/di/NetworkModule.kt @@ -9,6 +9,7 @@ import com.rainy.token.data.local.UsageCache import com.rainy.token.data.local.usageCacheDataStore import com.rainy.token.data.repository.CredentialRepository import com.rainy.token.data.repository.DeepSeekRepository +import com.rainy.token.data.repository.CommandCodeGoRepository import com.rainy.token.data.repository.OpenCodeGoRepository import com.rainy.token.data.repository.OpenCodeUsageRepository import dagger.Module @@ -101,6 +102,17 @@ object NetworkModule { balanceCache: BalanceCache ): OpenCodeGoRepository = OpenCodeGoRepository(okHttpClient, credentialRepository, balanceCache) + /** + * CommandCode Go 仓库:API Key 认证,调 JSON API。 + */ + @Provides + @Singleton + fun provideCommandCodeGoRepository( + okHttpClient: OkHttpClient, + credentialRepository: CredentialRepository, + balanceCache: BalanceCache + ): CommandCodeGoRepository = CommandCodeGoRepository(okHttpClient, credentialRepository, balanceCache) + /** * DeepSeek 仓库:同上。注意即使 [DeepSeekRepository] 自己有 @Inject constructor, * 在 3+ Repository 同模式时 KSP 2.x 仍会误报,这里统一改 @Provides 杜绝隐患。 diff --git a/app/src/main/java/com/rainy/token/domain/service/ServiceConfigProvider.kt b/app/src/main/java/com/rainy/token/domain/service/ServiceConfigProvider.kt index 64d53ce..684f1e2 100644 --- a/app/src/main/java/com/rainy/token/domain/service/ServiceConfigProvider.kt +++ b/app/src/main/java/com/rainy/token/domain/service/ServiceConfigProvider.kt @@ -48,6 +48,12 @@ object ServiceConfigProvider { method = FetchMethod.WEBVIEW_SCRAPER, loginUrl = "https://opencode.ai/auth", displayUnit = "%" + ), + ServiceType.COMMANDCODE_GO to ServiceConfig( + type = ServiceType.COMMANDCODE_GO, + method = FetchMethod.REST_API, + loginUrl = "", // API Key 模式不需要登录 URL + displayUnit = "$" ) ) diff --git a/app/src/main/java/com/rainy/token/domain/service/ServiceType.kt b/app/src/main/java/com/rainy/token/domain/service/ServiceType.kt index b9391ac..72b8981 100644 --- a/app/src/main/java/com/rainy/token/domain/service/ServiceType.kt +++ b/app/src/main/java/com/rainy/token/domain/service/ServiceType.kt @@ -15,6 +15,7 @@ enum class ServiceType( val storageKey: String ) { OPENCODE_GO("OpenCode Go", "opencode_go"), + COMMANDCODE_GO("CommandCode Go", "commandcode_go"), DEEPSEEK("DeepSeek", "deepseek"); companion object { diff --git a/app/src/main/java/com/rainy/token/domain/usecase/RefreshBalanceUseCase.kt b/app/src/main/java/com/rainy/token/domain/usecase/RefreshBalanceUseCase.kt index 3e69098..fb2a8da 100644 --- a/app/src/main/java/com/rainy/token/domain/usecase/RefreshBalanceUseCase.kt +++ b/app/src/main/java/com/rainy/token/domain/usecase/RefreshBalanceUseCase.kt @@ -1,5 +1,6 @@ package com.rainy.token.domain.usecase +import com.rainy.token.data.repository.CommandCodeGoRepository import com.rainy.token.data.repository.DeepSeekRepository import com.rainy.token.data.repository.OpenCodeGoRepository import com.rainy.token.domain.model.ServiceBalance @@ -10,7 +11,7 @@ import javax.inject.Provider /** * 唯一的 UseCase。计划架构补充说明: * - 内部按 ServiceType 分发到对应 Repository - * - 现阶段实现:DeepSeek(REST API) + OpenCode Go(OkHttp 抓 dashboard) + * - 现阶段实现:DeepSeek(REST API) + OpenCode Go(OkHttp 抓 dashboard)+ CommandCode Go(JSON API) * - **使用 Provider 注入**——规避 KSP 2.x 在多个 @Inject constructor Repository * 注入同一 UseCase 时的"could not be resolved"误报(KSP 已知 issue)。 * Provider 让 Hilt 推迟创建 Repository 实例到第一次 .get() 时,KSP 不需要在 @@ -18,10 +19,12 @@ import javax.inject.Provider */ class RefreshBalanceUseCase @Inject constructor( private val deepSeekRepositoryProvider: Provider, - private val openCodeGoRepositoryProvider: Provider + private val openCodeGoRepositoryProvider: Provider, + private val commandCodeGoRepositoryProvider: Provider ) { suspend operator fun invoke(service: ServiceType): Result = when (service) { ServiceType.DEEPSEEK -> deepSeekRepositoryProvider.get().fetchBalance() ServiceType.OPENCODE_GO -> openCodeGoRepositoryProvider.get().fetchBalance() + ServiceType.COMMANDCODE_GO -> commandCodeGoRepositoryProvider.get().fetchBalance() } } \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/ui/components/ServiceIcon.kt b/app/src/main/java/com/rainy/token/ui/components/ServiceIcon.kt index 1ac174f..b6e0d5e 100644 --- a/app/src/main/java/com/rainy/token/ui/components/ServiceIcon.kt +++ b/app/src/main/java/com/rainy/token/ui/components/ServiceIcon.kt @@ -51,6 +51,21 @@ fun ServiceIcon( ) } } + ServiceType.COMMANDCODE_GO -> { + // CommandCode Go:暂无官方 logo,用 ⚡ emoji 占位 + Box( + modifier = modifier + .size(size.dp) + .clip(RoundedCornerShape(12.dp)) + .background(Color(0xFF2D3748)), + contentAlignment = Alignment.Center + ) { + androidx.compose.material3.Text( + text = "⚡", + fontSize = (size * 0.5).sp + ) + } + } ServiceType.DEEPSEEK -> { // DeepSeek 官方无开放 logo 资源 —— 用蓝鲸 emoji 占位 Box( 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 5c7d997..95b125f 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 @@ -297,6 +297,12 @@ private fun BalanceMainArea(card: DashboardCardUi) { // 3 个窗口进度条 OpenCodeGoUsageWindows(balance) } + card.service == ServiceType.COMMANDCODE_GO -> { + // CommandCode Go 卡片:跟 OCGO 一样平铺三档窗口 + CommandCodeGoMainBalance(balance) + Spacer(modifier = Modifier.height(12.dp)) + CommandCodeGoUsageWindows(balance) + } else -> { // DeepSeek 等:主数字 + 单位 Row(verticalAlignment = Alignment.Bottom) { @@ -358,7 +364,6 @@ private fun OpenCodeGoMainBalance(balance: com.rainy.token.domain.model.ServiceB ) } } - @Composable private fun OpenCodeGoUsageWindows(balance: com.rainy.token.domain.model.ServiceBalance) { val windows = listOf( @@ -371,7 +376,65 @@ private fun OpenCodeGoUsageWindows(balance: com.rainy.token.domain.model.Service if (pct != null) { CompactUsageRow(label = label, pct = pct, resetInSec = resetSec) } else { - // 数据未拿到 —— 不隐藏整行,显示"—"占位 + CompactUsageRowEmpty(label = label, resetInSec = resetSec) + } + } + } +} + +@Composable +private fun CommandCodeGoMainBalance(balance: com.rainy.token.domain.model.ServiceBalance) { + val total = balance.totalQuota + Row(verticalAlignment = Alignment.Bottom) { + Text( + text = formatAmount(balance.amount), + style = MaterialTheme.typography.displayMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "$", + style = MaterialTheme.typography.titleLarge, + color = inkMuted(), + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(bottom = 6.dp, start = 2.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "剩余", + style = MaterialTheme.typography.titleMedium, + color = inkMuted(), + modifier = Modifier.padding(bottom = 6.dp) + ) + if (total != null && total > 0) { + val used = total - balance.amount + Text( + text = " · 已用 ${formatAmount(used)} / 共 ${formatAmount(total)}", + style = MaterialTheme.typography.bodySmall, + color = inkMuted(), + modifier = Modifier.padding(bottom = 6.dp) + ) + } + } +} + +@Composable +private fun CommandCodeGoUsageWindows(balance: com.rainy.token.domain.model.ServiceBalance) { + val extras = balance.extras + fun calcPct(used: Double?, cap: Double?): Int? { + if (used == null || cap == null || cap <= 0) return null + return ((used / cap) * 100).toInt().coerceIn(0, 100) + } + val windows = listOf( + Triple("5 小时", calcPct(extras["fiveHour.used"]?.toDoubleOrNull(), extras["fiveHour.cap"]?.toDoubleOrNull()), extras["fiveHour.resetInSec"]?.toLongOrNull()), + Triple("本周", calcPct(extras["weekly.used"]?.toDoubleOrNull(), extras["weekly.cap"]?.toDoubleOrNull()), extras["weekly.resetInSec"]?.toLongOrNull()), + Triple("本月", calcPct(balance.monthlySpent, balance.totalQuota), extras["monthly.resetInSec"]?.toLongOrNull()) + ) + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + windows.forEach { (label, pct, resetSec) -> + if (pct != null) { + CompactUsageRow(label = label, pct = pct, resetInSec = resetSec) + } else { CompactUsageRowEmpty(label = label, resetInSec = resetSec) } } @@ -501,6 +564,7 @@ private fun DashboardCardUi.statusBadgeStyle(): StatusStyle = when { private fun secondaryLine(card: DashboardCardUi): String = when (card.service) { ServiceType.DEEPSEEK -> "REST API · ¥" ServiceType.OPENCODE_GO -> "WebView 抓取 · 5h 配额" + ServiceType.COMMANDCODE_GO -> "JSON API · \$" } private fun footerText(card: DashboardCardUi): String { diff --git a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt index a36b713..fa23457 100644 --- a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt @@ -131,6 +131,9 @@ fun ServiceDetailScreen( ServiceType.OPENCODE_GO -> { item { OpenCodeGoWindowsCard(uiState.state) } } + ServiceType.COMMANDCODE_GO -> { + item { CommandCodeGoUsageCard(uiState.state) } + } } // 错误信息(如有) @@ -161,6 +164,117 @@ fun ServiceDetailScreen( } } +/** + * CommandCode Go 专属:月度用量卡 + 窗口进度条。 + * + * 数据从 balance.extras 中的 monthlyRemaining / monthlyTotal / fiveHour / weekly 取, + * 展示月度已用/总量 + 5h + 每周窗口进度条。 + * 窗口顺序:5小时 → 本周 → 本月(最底部为月度总览)。 + */ +@Composable +private fun CommandCodeGoUsageCard(state: State) { + val balance = when (state) { + is State.Fresh -> state.data + is State.Stale -> state.data + is State.Error -> state.cached + else -> null + } + val extras = balance?.extras ?: return + val monthlyRemaining = extras["monthlyRemaining"]?.toDoubleOrNull() ?: return + val monthlyTotal = extras["monthlyTotal"]?.toDoubleOrNull() + val purchased = extras["purchasedCredits"]?.toDoubleOrNull() ?: 0.0 + val planName = extras["planName"].orEmpty() + + 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(20.dp)) { + Text( + text = "用量窗口${if (planName.isNotBlank()) " · $planName" else ""}", + style = MaterialTheme.typography.labelLarge, + color = inkMuted() + ) + Spacer(modifier = Modifier.height(12.dp)) + + // 1. 5h 窗口(最上面) + val fiveHourUsed = extras["fiveHour.used"]?.toDoubleOrNull() + val fiveHourCap = extras["fiveHour.cap"]?.toDoubleOrNull() + if (fiveHourUsed != null && fiveHourCap != null && fiveHourCap > 0) { + val pct = ((fiveHourUsed / fiveHourCap) * 100).toInt().coerceIn(0, 100) + UsageWindowRow( + label = "5 小时滚动", + pct = pct, + resetInSec = extras["fiveHour.resetInSec"]?.toLongOrNull() + ) + Spacer(modifier = Modifier.height(14.dp)) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + Spacer(modifier = Modifier.height(14.dp)) + } + + // 2. 每周窗口 + val weeklyUsed = extras["weekly.used"]?.toDoubleOrNull() + val weeklyCap = extras["weekly.cap"]?.toDoubleOrNull() + if (weeklyUsed != null && weeklyCap != null && weeklyCap > 0) { + val pct = ((weeklyUsed / weeklyCap) * 100).toInt().coerceIn(0, 100) + UsageWindowRow( + label = "本周", + pct = pct, + resetInSec = extras["weekly.resetInSec"]?.toLongOrNull() + ) + Spacer(modifier = Modifier.height(14.dp)) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + Spacer(modifier = Modifier.height(14.dp)) + } + + // 3. 本月(最下面) + if (monthlyTotal != null && monthlyTotal > 0) { + val used = monthlyTotal - monthlyRemaining + val pct = ((used / monthlyTotal) * 100).toInt().coerceIn(0, 100) + UsageWindowRow( + label = "本月", + pct = pct, + resetInSec = extras["billingPeriodEnd"]?.let { parseIsoDuration(it) } + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "已用 \$${formatAmount(used)} / 共 \$${formatAmount(monthlyTotal)}", + style = MaterialTheme.typography.bodySmall, + color = inkMuted() + ) + } else { + Text( + text = "剩余 \$${formatAmount(monthlyRemaining)}", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + } + + // 额外充值 + if (purchased > 0) { + Spacer(modifier = Modifier.height(8.dp)) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + Spacer(modifier = Modifier.height(8.dp)) + BreakdownRow("额外充值", purchased, "$") + } + } + } +} + +/** + * 尝试从 ISO8601 时间戳计算剩余秒数。 + */ +private fun parseIsoDuration(isoStr: String): Long? { + return try { + val sdf = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", java.util.Locale.US) + sdf.timeZone = java.util.TimeZone.getTimeZone("UTC") + val end = sdf.parse(isoStr.take(19))?.time ?: return null + maxOf(0L, (end - System.currentTimeMillis()) / 1000) + } catch (_: Exception) { null } +} + /** * OpenCode Go 专属:3 个窗口(rolling 5h / weekly / monthly)独立用量卡。 * @@ -532,6 +646,7 @@ private fun ActionButtons( private fun mainCardLabel(service: ServiceType): String = when (service) { ServiceType.DEEPSEEK -> "当前余额" ServiceType.OPENCODE_GO -> "5h 实时用量" + ServiceType.COMMANDCODE_GO -> "月度余额" } private fun stateToChip(state: State): StatusStyle = when (state) { 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 3aa9ecc..c33aeda 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 @@ -5,6 +5,7 @@ import android.content.ClipboardManager import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.rainy.token.data.repository.CommandCodeGoRepository import com.rainy.token.data.repository.CredentialRepository import com.rainy.token.data.repository.OpenCodeGoRepository import com.rainy.token.data.repository.RepositoryError @@ -34,6 +35,7 @@ import javax.inject.Provider class CredentialEditViewModel @Inject constructor( private val credentialRepository: CredentialRepository, private val openCodeGoRepositoryProvider: Provider, + private val commandCodeGoRepositoryProvider: Provider, private val refreshBalanceUseCaseProvider: Provider ) : ViewModel() { @@ -125,7 +127,7 @@ class CredentialEditViewModel @Inject constructor( */ fun testAndSaveApiKey() { val type = serviceType ?: return - if (type != ServiceType.DEEPSEEK) { + if (type != ServiceType.DEEPSEEK && type != ServiceType.COMMANDCODE_GO) { _uiState.update { it.copy(message = "暂不支持测试此服务") } return }