feat: add CommandCode Go support (JSON API via API Key)
- 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
This commit is contained in:
parent
d37589b89a
commit
0f26269b7e
@ -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 <API Key>
|
||||
*
|
||||
* 调 subscription 端点获取计划信息(用来算已用/总量百分比):
|
||||
* GET https://api.commandcode.ai/alpha/billing/subscriptions
|
||||
* Authorization: Bearer <API Key>
|
||||
*/
|
||||
@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<ServiceBalance> = 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?
|
||||
)
|
||||
}
|
||||
@ -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 杜绝隐患。
|
||||
|
||||
@ -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 = "$"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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<DeepSeekRepository>,
|
||||
private val openCodeGoRepositoryProvider: Provider<OpenCodeGoRepository>
|
||||
private val openCodeGoRepositoryProvider: Provider<OpenCodeGoRepository>,
|
||||
private val commandCodeGoRepositoryProvider: Provider<CommandCodeGoRepository>
|
||||
) {
|
||||
suspend operator fun invoke(service: ServiceType): Result<ServiceBalance> = when (service) {
|
||||
ServiceType.DEEPSEEK -> deepSeekRepositoryProvider.get().fetchBalance()
|
||||
ServiceType.OPENCODE_GO -> openCodeGoRepositoryProvider.get().fetchBalance()
|
||||
ServiceType.COMMANDCODE_GO -> commandCodeGoRepositoryProvider.get().fetchBalance()
|
||||
}
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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<OpenCodeGoRepository>,
|
||||
private val commandCodeGoRepositoryProvider: Provider<CommandCodeGoRepository>,
|
||||
private val refreshBalanceUseCaseProvider: Provider<RefreshBalanceUseCase>
|
||||
) : 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
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user