From fd3d74cfef84197c5433552ddc417fde8a6db0de Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 11 Sep 2026 23:37:20 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20API=20=E7=AE=A1=E7=90=86=20/=20?= =?UTF-8?q?=E7=94=A8=E9=87=8F=E7=BB=9F=E8=AE=A1=20/=20=E5=85=85=E5=80=BC?= =?UTF-8?q?=20WebView=20/=20=E9=A6=96=E9=A1=B5=E4=BB=85=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E5=B7=B2=E9=85=8D=E7=BD=AE=EF=BC=9BSub2API=20=E7=94=A8?= =?UTF-8?q?=E9=87=8F=E6=98=8E=E7=BB=86=E5=8D=A1=EF=BC=88=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E7=AD=9B=E9=80=89+=E6=97=B6=E9=97=B4=E7=AA=97=E5=8F=A3+?= =?UTF-8?q?=E6=9F=B1=E7=8A=B6=E5=9B=BE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../token/domain/model/ServiceBalance.kt | 4 +- .../token/domain/model/Sub2UsageDetail.kt | 39 ++ .../com/rainy/token/ui/RainyTokenNavHost.kt | 174 ++++++- .../token/ui/account/ApiManagementScreen.kt | 302 ++++++++++++ .../ui/account/ApiManagementViewModel.kt | 153 ++++++ .../token/ui/dashboard/DashboardScreen.kt | 65 ++- .../token/ui/dashboard/DashboardViewModel.kt | 78 ++- .../token/ui/dashboard/ServiceBalanceCards.kt | 3 + .../token/ui/dashboard/UsageStatScreen.kt | 275 +++++++++++ .../token/ui/dashboard/UsageStatViewModel.kt | 100 ++++ .../ui/servicedetail/ServiceDetailScreen.kt | 73 +++ .../servicedetail/ServiceDetailViewModel.kt | 6 + .../ui/servicedetail/Sub2ApiUsageCard.kt | 462 ++++++++++++++++++ .../token/ui/webview/WebViewPageScreen.kt | 118 +++++ .../ui/widget/OpenCodeGoWidgetProvider.kt | 10 + app/src/main/res/values-b+zh+Hans/strings.xml | 73 +++ app/src/main/res/values-b+zh+Hant/strings.xml | 73 +++ app/src/main/res/values/strings.xml | 73 +++ .../data/repository/Sub2ApiRepositoryTest.kt | 151 ++++++ 19 files changed, 2196 insertions(+), 36 deletions(-) create mode 100644 app/src/main/java/com/rainy/token/domain/model/Sub2UsageDetail.kt create mode 100644 app/src/main/java/com/rainy/token/ui/account/ApiManagementScreen.kt create mode 100644 app/src/main/java/com/rainy/token/ui/account/ApiManagementViewModel.kt create mode 100644 app/src/main/java/com/rainy/token/ui/dashboard/UsageStatScreen.kt create mode 100644 app/src/main/java/com/rainy/token/ui/dashboard/UsageStatViewModel.kt create mode 100644 app/src/main/java/com/rainy/token/ui/servicedetail/Sub2ApiUsageCard.kt create mode 100644 app/src/main/java/com/rainy/token/ui/webview/WebViewPageScreen.kt create mode 100644 app/src/test/java/com/rainy/token/data/repository/Sub2ApiRepositoryTest.kt diff --git a/app/src/main/java/com/rainy/token/domain/model/ServiceBalance.kt b/app/src/main/java/com/rainy/token/domain/model/ServiceBalance.kt index 711eb7a..9c74776 100644 --- a/app/src/main/java/com/rainy/token/domain/model/ServiceBalance.kt +++ b/app/src/main/java/com/rainy/token/domain/model/ServiceBalance.kt @@ -22,5 +22,7 @@ data class ServiceBalance( /** 配额周期下次重置时间(订阅型服务,epoch millis) */ val nextResetAt: Long? = null, /** 服务特定的附加信息(如 Zen 充值 $20 起,Go 5h 配额) */ - val extras: Map = emptyMap() + val extras: Map = emptyMap(), + /** Sub2API 专有:网关 /v1/usage 返回的结构化用量明细(模型统计 + 每日明细)。其他服务为 null。 */ + val sub2UsageDetail: Sub2UsageDetail? = null ) \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/domain/model/Sub2UsageDetail.kt b/app/src/main/java/com/rainy/token/domain/model/Sub2UsageDetail.kt new file mode 100644 index 0000000..fa4b9c6 --- /dev/null +++ b/app/src/main/java/com/rainy/token/domain/model/Sub2UsageDetail.kt @@ -0,0 +1,39 @@ +package com.rainy.token.domain.model + +import kotlinx.serialization.Serializable + +/** + * Sub2API 网关 /v1/usage 返回的结构化用量明细。 + * + * 不同网关版本字段名/是否返回都不同,因此全部字段防御式解析: + * - [modelStats]:按模型聚合的统计(原来只取前 3 个塞进 extras 字符串,现全量结构化) + * - [dailyUsage]:按日聚合的明细(date 形如 "2026-09-04",时区由请求的 timezone 参数决定) + */ +@Serializable +data class Sub2UsageDetail( + val modelStats: List = emptyList(), + val dailyUsage: List = emptyList() +) { + val isEmpty: Boolean get() = modelStats.isEmpty() && dailyUsage.isEmpty() + val isNotEmpty: Boolean get() = !isEmpty +} + +/** 按模型聚合的用量统计。字段名来自官方前端 /v1/usage model_stats[]。 */ +@Serializable +data class Sub2ModelStat( + val model: String, + val requests: Long = 0, + val inputTokens: Long = 0, + val outputTokens: Long = 0, + val totalTokens: Long = 0, + val cost: Double = 0.0 +) + +/** 按日聚合的用量明细。 */ +@Serializable +data class Sub2DailyUsage( + val date: String, + val requests: Long = 0, + val totalTokens: Long = 0, + val cost: Double = 0.0 +) diff --git a/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt b/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt index 39577f8..4fdbe27 100644 --- a/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt +++ b/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt @@ -18,12 +18,14 @@ import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -36,6 +38,7 @@ import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import com.rainy.token.R import com.rainy.token.data.repository.CommandCodeUsageRepository +import com.rainy.token.domain.service.ServiceConfigProvider import com.rainy.token.domain.service.ServiceType import com.rainy.token.ui.components.rememberWindowSizeClass import com.rainy.token.ui.components.DebugLogScreen @@ -47,8 +50,10 @@ 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.UsageStatScreen import com.rainy.token.ui.dashboard.UsageViewModel import com.rainy.token.ui.heatmap.HeatmapScreen +import com.rainy.token.ui.account.ApiManagementScreen import com.rainy.token.ui.servicedetail.ServiceDetailScreen import com.rainy.token.ui.settings.CredentialEditScreen import com.rainy.token.ui.settings.SettingsScreen @@ -56,6 +61,8 @@ import com.rainy.token.ui.theme.inkMuted import com.rainy.token.ui.theme.StrawberryPink import com.rainy.token.ui.webview.CodexOAuthScreen import com.rainy.token.ui.webview.WebViewLoginScreen +import com.rainy.token.ui.webview.WebViewPageScreen +import android.net.Uri /** * 应用导航图。 @@ -69,6 +76,8 @@ import com.rainy.token.ui.webview.WebViewLoginScreen object Routes { const val DASHBOARD = "dashboard" const val SETTINGS = "settings" + const val API_MANAGEMENT = "api_management" + const val USAGE_STAT = "usage_stat" const val TIPS = "tips" const val DEBUG_LOG = "debug_log" const val CODEX_OAUTH = "codex_oauth" @@ -76,6 +85,9 @@ object Routes { fun credentialEdit(type: ServiceType) = "credential_edit/${type.name}" const val WEBVIEW_LOGIN = "webview_login/{type}" fun webviewLogin(type: ServiceType) = "webview_login/${type.name}" + const val WEBVIEW_PAGE = "webview_page?url={url}&title={title}" + fun webviewPage(url: String, title: String) = + "webview_page?url=${Uri.encode(url)}&title=${Uri.encode(title)}" const val SERVICE_DETAIL = "service/{type}" fun serviceDetail(type: ServiceType) = "service/${type.name}" const val USAGE_DETAIL = "usage_detail" @@ -98,6 +110,8 @@ private sealed class DetailPane { object OCGOUsage : DetailPane() object CCGOUsage : DetailPane() object Settings : DetailPane() + object ApiManagement : DetailPane() + object UsageStat : DetailPane() object Heatmap : DetailPane() } @@ -192,12 +206,29 @@ private fun CompactNavHost() { composable(Routes.DASHBOARD) { DashboardScreen( onOpenSettings = { navController.navigate(Routes.SETTINGS) }, + onOpenApiManagement = { navController.navigate(Routes.API_MANAGEMENT) }, + onOpenUsageStat = { navController.navigate(Routes.USAGE_STAT) }, onOpenService = { type -> navController.navigate(Routes.serviceDetail(type)) }, onOpenUsageDetail = { navController.navigate(Routes.USAGE_DETAIL) }, onOpenCcgoUsageDetail = { navController.navigate(Routes.CCGO_USAGE_DETAIL) }, onOpenHeatmap = { navController.navigate(Routes.HEATMAP) } ) } + composable(Routes.API_MANAGEMENT) { + val rechargeLabel = stringResource(R.string.action_recharge) + ApiManagementScreen( + onBack = guardedPop, + onConfigure = { type -> navController.navigate(Routes.credentialEdit(type)) }, + onRecharge = { type -> + ServiceConfigProvider.get(type).externalUrl?.let { url -> + navController.navigate(Routes.webviewPage(url, rechargeLabel)) + } + } + ) + } + composable(Routes.USAGE_STAT) { + UsageStatScreen(onBack = guardedPop) + } composable(Routes.USAGE_DETAIL) { UsageDetailScreen( onBack = guardedPop, @@ -300,13 +331,30 @@ private fun CompactNavHost() { arguments = listOf(navArgument("type") { type = NavType.StringType }) ) { backStackEntry -> val type = parseServiceType(backStackEntry.arguments?.getString("type")) + val rechargeLabel = stringResource(R.string.action_recharge) ServiceDetailScreen( service = type, onBack = guardedPop, onConfigureCredential = { svc -> navController.navigate(Routes.credentialEdit(svc)) }, - onStartWebViewLogin = { svc -> navController.navigate(Routes.webviewLogin(svc)) } + onStartWebViewLogin = { svc -> navController.navigate(Routes.webviewLogin(svc)) }, + onOpenRecharge = { svc -> + ServiceConfigProvider.get(svc).externalUrl?.let { url -> + navController.navigate(Routes.webviewPage(url, rechargeLabel)) + } + } ) } + composable( + route = Routes.WEBVIEW_PAGE, + arguments = listOf( + navArgument("url") { type = NavType.StringType }, + navArgument("title") { type = NavType.StringType } + ) + ) { backStackEntry -> + val url = backStackEntry.arguments?.getString("url").orEmpty() + val title = backStackEntry.arguments?.getString("title").orEmpty() + WebViewPageScreen(url = url, title = title, onBack = guardedPop) + } composable(Routes.HEATMAP) { HeatmapScreen(onBack = guardedPop) } @@ -340,6 +388,8 @@ private fun ExpandedLayout() { ) { DashboardScreen( onOpenSettings = { detailPane = DetailPane.Settings }, + onOpenApiManagement = { detailPane = DetailPane.ApiManagement }, + onOpenUsageStat = { detailPane = DetailPane.UsageStat }, onOpenService = { type -> detailPane = DetailPane.ServiceDetail(type) }, onOpenUsageDetail = { detailPane = DetailPane.OCGOUsage }, onOpenCcgoUsageDetail = { detailPane = DetailPane.CCGOUsage }, @@ -384,12 +434,43 @@ private fun ExpandedDetailPane( } } is DetailPane.ServiceDetail -> { - ServiceDetailScreen( - service = pane.type, - onBack = onClose, - onConfigureCredential = { onClose() }, - onStartWebViewLogin = { } - ) + // key(pane.type):切换服务时重建嵌套导航栈,避免停留在上一个服务的充值页 + key(pane.type) { + val navController = rememberNavController() + NavHost( + navController = navController, + startDestination = "detail", + popExitTransition = { ExitTransition.None } + ) { + composable("detail") { + val rechargeLabel = LocalContext.current.getString(R.string.action_recharge) + ServiceDetailScreen( + service = pane.type, + onBack = onClose, + onConfigureCredential = { onClose() }, + onStartWebViewLogin = { }, + onOpenRecharge = { svc -> + ServiceConfigProvider.get(svc).externalUrl?.let { url -> + navController.navigate(Routes.webviewPage(url, rechargeLabel)) + } + } + ) + } + composable( + route = Routes.WEBVIEW_PAGE, + arguments = listOf( + navArgument("url") { type = NavType.StringType }, + navArgument("title") { type = NavType.StringType } + ) + ) { entry -> + WebViewPageScreen( + url = entry.arguments?.getString("url").orEmpty(), + title = entry.arguments?.getString("title").orEmpty(), + onBack = { navController.popBackStack() } + ) + } + } + } } is DetailPane.OCGOUsage -> { val navController = rememberNavController() @@ -453,6 +534,85 @@ private fun ExpandedDetailPane( is DetailPane.Heatmap -> { HeatmapScreen(onBack = onClose) } + is DetailPane.UsageStat -> { + UsageStatScreen(onBack = onClose) + } + is DetailPane.ApiManagement -> { + val apiNavController = rememberNavController() + // 从凭据编辑/WebView 返回到 api_mgmt_main 时刷新 Dashboard 凭据状态 + val routeStack by apiNavController.currentBackStackEntryAsState() + val currentRoute = routeStack?.destination?.route + var prevRoute by remember { mutableStateOf("api_mgmt_main") } + LaunchedEffect(currentRoute) { + if (prevRoute != currentRoute && currentRoute == "api_mgmt_main") { + onCredentialEditClosed() + } + prevRoute = currentRoute ?: "api_mgmt_main" + } + NavHost( + navController = apiNavController, + startDestination = "api_mgmt_main", + popExitTransition = { ExitTransition.None } + ) { + composable("api_mgmt_main") { + val rechargeLabel = LocalContext.current.getString(R.string.action_recharge) + ApiManagementScreen( + onBack = onClose, + onConfigure = { type -> apiNavController.navigate(Routes.credentialEdit(type)) }, + onRecharge = { type -> + ServiceConfigProvider.get(type).externalUrl?.let { url -> + apiNavController.navigate(Routes.webviewPage(url, rechargeLabel)) + } + } + ) + } + composable( + route = Routes.CREDENTIAL_EDIT, + arguments = listOf(navArgument("type") { type = NavType.StringType }) + ) { entry -> + val type = parseServiceType(entry.arguments?.getString("type")) + CredentialEditScreen( + service = type, + onBack = { apiNavController.popBackStack() }, + onStartWebViewLogin = { svc -> + apiNavController.navigate(Routes.webviewLogin(svc)) + }, + onWebViewLoginSuccess = { }, + onStartCodexOAuth = { apiNavController.navigate(Routes.CODEX_OAUTH) } + ) + } + composable( + route = Routes.WEBVIEW_LOGIN, + arguments = listOf(navArgument("type") { type = NavType.StringType }) + ) { entry -> + val type = parseServiceType(entry.arguments?.getString("type")) + WebViewLoginScreen( + service = type, + onBack = { apiNavController.popBackStack() }, + onLoginSucceeded = { apiNavController.popBackStack() } + ) + } + composable(Routes.CODEX_OAUTH) { + CodexOAuthScreen( + onBack = { apiNavController.popBackStack() }, + onSuccess = { apiNavController.popBackStack() } + ) + } + composable( + route = Routes.WEBVIEW_PAGE, + arguments = listOf( + navArgument("url") { type = NavType.StringType }, + navArgument("title") { type = NavType.StringType } + ) + ) { entry -> + WebViewPageScreen( + url = entry.arguments?.getString("url").orEmpty(), + title = entry.arguments?.getString("title").orEmpty(), + onBack = { apiNavController.popBackStack() } + ) + } + } + } is DetailPane.Settings -> { val settingsNavController = rememberNavController() // 监听嵌套 NavHost 路由变化:当从凭据编辑页/WebView 返回 settings_main 时 diff --git a/app/src/main/java/com/rainy/token/ui/account/ApiManagementScreen.kt b/app/src/main/java/com/rainy/token/ui/account/ApiManagementScreen.kt new file mode 100644 index 0000000..44a6bfd --- /dev/null +++ b/app/src/main/java/com/rainy/token/ui/account/ApiManagementScreen.kt @@ -0,0 +1,302 @@ +package com.rainy.token.ui.account + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +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.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Snackbar +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +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 +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.rainy.token.R +import com.rainy.token.domain.model.CredentialStatus +import com.rainy.token.domain.service.ServiceType +import com.rainy.token.ui.components.ServiceIcon +import com.rainy.token.ui.components.formatAmount +import com.rainy.token.ui.components.resolve +import com.rainy.token.ui.theme.StrawberryPink +import com.rainy.token.ui.theme.StatusOrange +import com.rainy.token.ui.theme.inkMuted +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * API 管理页:按供应商集中展示/管理已保存的凭据。 + * + * 每个供应商一张卡:状态、Key 掩码、最后验证、缓存余额,以及 + * 配置/编辑、测试连接、删除(需确认)操作;DeepSeek 额外提供充值入口。 + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ApiManagementScreen( + onBack: () -> Unit, + onConfigure: (ServiceType) -> Unit, + onRecharge: (ServiceType) -> Unit, + viewModel: ApiManagementViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val message by viewModel.message.collectAsStateWithLifecycle() + val testing by viewModel.testing.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + val context = LocalContext.current + var pendingDelete by remember { mutableStateOf(null) } + + LaunchedEffect(message) { + message?.let { + snackbarHostState.showSnackbar(it.resolve(context)) + viewModel.clearMessage() + } + } + + // 从凭据编辑页/WebView/充值页返回时刷新列表(新增/编辑/删除即时可见,覆盖 Compact 与 Expanded) + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { + viewModel.load() + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.api_mgmt_title)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + Icons.Filled.ArrowBack, + contentDescription = stringResource(R.string.action_back) + ) + } + } + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) { data -> Snackbar(snackbarData = data) } } + ) { innerPadding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(uiState.rows, key = { it.service.storageKey }) { row -> + ApiRowCard( + row = row, + testing = row.service in testing, + onConfigure = { onConfigure(row.service) }, + onTest = { viewModel.test(row.service) }, + onDelete = { pendingDelete = row.service }, + onRecharge = { onRecharge(row.service) } + ) + } + } + } + + pendingDelete?.let { service -> + AlertDialog( + onDismissRequest = { pendingDelete = null }, + title = { Text(stringResource(R.string.api_mgmt_delete_title)) }, + text = { + Text(stringResource(R.string.api_mgmt_delete_body, service.displayName)) + }, + confirmButton = { + TextButton( + onClick = { + viewModel.delete(service) + pendingDelete = null + } + ) { + Text(stringResource(R.string.action_delete), color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { pendingDelete = null }) { + Text(stringResource(R.string.action_cancel)) + } + } + ) + } +} + +@Composable +private fun ApiRowCard( + row: ApiRowUi, + testing: Boolean, + onConfigure: () -> Unit, + onTest: () -> Unit, + onDelete: () -> Unit, + onRecharge: () -> Unit +) { + 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) { + ServiceIcon(service = row.service, size = 44) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = row.service.displayName, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + Text( + text = row.maskedCredential ?: stringResource(R.string.api_mgmt_unconfigured), + style = MaterialTheme.typography.bodySmall, + color = inkMuted() + ) + } + Spacer(modifier = Modifier.width(8.dp)) + StatusDot(state = row.state) + } + + Spacer(modifier = Modifier.height(10.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + // 最后验证时间 / 缓存余额 + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.api_mgmt_last_verified, formatVerified(row.lastVerifiedAt)), + style = MaterialTheme.typography.labelSmall, + color = inkMuted() + ) + row.cachedBalance?.let { cached -> + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = stringResource( + R.string.api_mgmt_cached_balance, + formatAmount(cached.balance.amount), + cached.balance.unit + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface + ) + } + } + Spacer(modifier = Modifier.width(8.dp)) + // 测试连接(进行中转圈)+ 删除 + if (testing) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = StrawberryPink, + strokeWidth = 2.dp + ) + } else { + TextButton(onClick = onTest, enabled = row.isConfigured) { + Text(stringResource(R.string.api_mgmt_test)) + } + } + IconButton(onClick = onDelete, enabled = row.isConfigured) { + Icon( + imageVector = Icons.Filled.Delete, + contentDescription = stringResource(R.string.action_delete), + tint = if (row.isConfigured) MaterialTheme.colorScheme.error else inkMuted().copy(alpha = 0.4f) + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + onClick = onConfigure, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.buttonColors( + containerColor = StrawberryPink, + contentColor = Color.White + ) + ) { + Text( + if (row.isConfigured) { + stringResource(R.string.api_mgmt_edit) + } else { + stringResource(R.string.api_mgmt_configure) + } + ) + } + if (row.service == ServiceType.DEEPSEEK) { + OutlinedButton(onClick = onRecharge, modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.action_recharge)) + } + } + } + } + } +} + +@Composable +private fun StatusDot(state: CredentialStatus.State) { + val color = when (state) { + CredentialStatus.State.OK -> Color(0xFF3BB273) + CredentialStatus.State.WARNING -> StatusOrange + CredentialStatus.State.EXPIRED -> MaterialTheme.colorScheme.error + CredentialStatus.State.NOT_CONFIGURED -> inkMuted().copy(alpha = 0.5f) + } + Box( + modifier = Modifier + .size(10.dp) + .clip(RoundedCornerShape(50)) + .background(color = color) + ) +} + +private fun formatVerified(ts: Long): String { + if (ts <= 0L) return "—" + return runCatching { + SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()).format(Date(ts)) + }.getOrDefault("—") +} diff --git a/app/src/main/java/com/rainy/token/ui/account/ApiManagementViewModel.kt b/app/src/main/java/com/rainy/token/ui/account/ApiManagementViewModel.kt new file mode 100644 index 0000000..88d2bc4 --- /dev/null +++ b/app/src/main/java/com/rainy/token/ui/account/ApiManagementViewModel.kt @@ -0,0 +1,153 @@ +package com.rainy.token.ui.account + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.rainy.token.data.cache.CachedBalance +import com.rainy.token.data.repository.CredentialRepository +import com.rainy.token.data.repository.RepositoryError +import com.rainy.token.domain.model.Credential +import com.rainy.token.domain.model.CredentialStatus +import com.rainy.token.domain.service.ServiceType +import com.rainy.token.domain.usecase.RefreshBalanceUseCase +import com.rainy.token.ui.components.UiText +import com.rainy.token.ui.components.formatAmount +import com.rainy.token.R +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * API 管理页 ViewModel。 + * + * 按供应商集中展示已保存凭据的状态与摘要(Key 掩码、最后验证、缓存余额), + * 提供测试连接 / 删除操作。编辑走既有 credential_edit 页面。 + */ +@HiltViewModel +class ApiManagementViewModel @Inject constructor( + private val credentialRepository: CredentialRepository, + private val refreshBalanceUseCase: RefreshBalanceUseCase +) : ViewModel() { + + private val _uiState = MutableStateFlow(ApiManagementUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _message = MutableStateFlow(null) + val message: StateFlow = _message + + /** 正在测试连接的服务集合(支持并发多测,各自独立转圈)。 */ + private val _testing = MutableStateFlow>(emptySet()) + val testing: StateFlow> = _testing + + init { + load() + } + + fun load() { + viewModelScope.launch { + val states = credentialRepository.readLocalStates() + val rows = ServiceType.entries.map { type -> + val local = states.getValue(type) + val cred = credentialRepository.get(type) + ApiRowUi( + service = type, + state = local.status.state, + lastVerifiedAt = local.status.lastVerifiedAt, + cachedBalance = local.cachedBalance, + maskedCredential = maskCredential(cred) + ) + } + _uiState.update { it.copy(rows = rows) } + } + } + + fun delete(service: ServiceType) { + viewModelScope.launch { + credentialRepository.remove(service) + _message.value = UiText.Resource(R.string.msg_credential_deleted) + load() + } + } + + /** + * 测试连接:拉一次余额并回应用户;成功/失败只透传摘要(本地化),不改动凭据之外的东西。 + */ + fun test(service: ServiceType) { + _testing.update { it + service } + viewModelScope.launch { + val text: UiText = refreshBalanceUseCase(service).fold( + onSuccess = { bal -> + UiText.Resource( + R.string.api_mgmt_test_success, + listOf(service.displayName, formatAmount(bal.amount), bal.unit) + ) + }, + onFailure = { err -> + // errorSummary 返回 UiText,resolve 会把嵌套 UiText 参数扁平化本地化 + UiText.Resource( + R.string.api_mgmt_test_fail, + listOf(service.displayName, errorSummary(err)) + ) + } + ) + _testing.update { it - service } + _message.value = text + load() + } + } + + fun clearMessage() { + _message.value = null + } + + /** 把凭据的核心值打成掩码(前1后1/中间星号);无凭据返回 null。 */ + private fun maskCredential(credential: Credential?): String? = when (credential) { + null -> null + is Credential.ApiKeyCredential -> mask(credential.key) + is Credential.SessionCredential -> { + credential.apiKey?.let { mask(it) } + ?: credential.token?.let { mask(it) } + ?: credential.authCookie?.let { mask(it) } + ?: credential.cookies.firstOrNull()?.let { mask("${it.name}=${it.value}") } + ?: credential.ollamaCookie?.take(8)?.let { mask(it) } + } + is Credential.CodexCredential -> mask(credential.accessToken) + is Credential.TraeCredential -> mask(credential.jwt) + is Credential.WorkBuddyCredential -> mask(credential.accessToken) + // Sub2API:显示实例基址作为身份标识(非机密),便于区分多个自托管实例; + // 真正的机密(password/apiKey)不做掩码展示。 + is Credential.Sub2ApiCredential -> credential.baseUrl + } + + private fun mask(value: String): String { + if (value.length <= 8) return "••••" + return "${value.take(4)}••••${value.takeLast(4)}" + } + + private fun errorSummary(error: Throwable?): UiText = when (error) { + is RepositoryError.InvalidCredential -> UiText.Resource(R.string.api_mgmt_err_invalid) + is RepositoryError.CredentialChanged -> UiText.Resource(R.string.api_mgmt_err_changed) + is RepositoryError.RateLimited -> UiText.Resource(R.string.api_mgmt_err_ratelimited) + is RepositoryError.Network -> UiText.Resource(R.string.api_mgmt_err_network) + is RepositoryError.ServerError -> UiText.Resource(R.string.api_mgmt_err_server, listOf(error.code)) + is RepositoryError.ParseError -> UiText.Resource(R.string.api_mgmt_err_parse) + else -> UiText.Resource(R.string.common_unknown) + } +} + +data class ApiRowUi( + val service: ServiceType, + val state: CredentialStatus.State, + val lastVerifiedAt: Long, + val cachedBalance: CachedBalance?, + val maskedCredential: String? +) { + val isConfigured: Boolean get() = state != CredentialStatus.State.NOT_CONFIGURED +} + +data class ApiManagementUiState( + val rows: List = emptyList() +) 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 c2c4e2d..29e15b5 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 @@ -31,6 +31,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.DateRange import androidx.compose.material.icons.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.AlertDialog @@ -43,6 +44,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar @@ -121,6 +123,8 @@ import java.util.Locale @Composable fun DashboardScreen( onOpenSettings: () -> Unit, + onOpenApiManagement: () -> Unit, + onOpenUsageStat: () -> Unit, onOpenService: (ServiceType) -> Unit, onOpenUsageDetail: () -> Unit, onOpenCcgoUsageDetail: () -> Unit = {}, @@ -214,6 +218,13 @@ fun DashboardScreen( tint = StrawberryPink ) } + IconButton(onClick = onOpenUsageStat) { + Icon( + imageVector = Icons.Filled.DateRange, + contentDescription = stringResource(R.string.usage_stat_title), + tint = StrawberryPink + ) + } IconButton( onClick = { viewModel.refresh() }, enabled = !uiState.refreshing @@ -224,6 +235,13 @@ fun DashboardScreen( tint = StrawberryPink ) } + IconButton(onClick = onOpenApiManagement) { + Icon( + imageVector = Icons.Filled.Lock, + contentDescription = stringResource(R.string.api_mgmt_title), + tint = StrawberryPink + ) + } IconButton(onClick = onOpenSettings) { Icon( imageVector = Icons.Filled.Settings, @@ -245,7 +263,8 @@ fun DashboardScreen( .fillMaxSize() .padding(innerPadding) ) { - if (uiState.cards.isEmpty()) { + // 仅初始加载时显示转圈;加载完即使没有已配置服务也渲染(展示用量卡片 + 空态引导) + if (uiState.loading) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center @@ -280,6 +299,7 @@ fun DashboardScreen( val items = rememberDashboardItems( cards = uiState.cards, order = cardOrder, + onOpenSettings = onOpenSettings, onOpenUsageDetail = onOpenUsageDetail, onOpenCcgoUsageDetail = onOpenCcgoUsageDetail, onOpenService = onOpenService, @@ -361,6 +381,7 @@ private data class DashboardHomeItem( private fun rememberDashboardItems( cards: List, order: List, + onOpenSettings: () -> Unit, onOpenUsageDetail: () -> Unit, onOpenCcgoUsageDetail: () -> Unit, onOpenService: (ServiceType) -> Unit, @@ -368,6 +389,12 @@ private fun rememberDashboardItems( refreshTrigger: Int ): List { val defaultItems = buildList { + // 未配置任何服务时的引导卡片(首页仅显示已配置的服务) + if (cards.isEmpty()) { + add(DashboardHomeItem("empty_hint") { + EmptyServicesHintCard(onOpenSettings = onOpenSettings) + }) + } add(DashboardHomeItem(USAGE_OCGO_CARD_ID) { UsageStatsCard(onOpenDetail = onOpenUsageDetail, onOpenHeatmap = onOpenHeatmap, refreshTrigger = refreshTrigger) }) @@ -776,6 +803,42 @@ private fun HeatmapEntryCard(onOpenHeatmap: () -> Unit) { } } +/** + * 空态引导卡:首页仅显示已配置的服务,当一个都没配置时给出引导。 + */ +@Composable +private fun EmptyServicesHintCard(onOpenSettings: () -> Unit) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(R.string.dashboard_empty_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.dashboard_empty_subtitle), + style = MaterialTheme.typography.bodySmall, + color = inkMuted() + ) + Spacer(modifier = Modifier.height(12.dp)) + OutlinedButton(onClick = onOpenSettings) { + Text(stringResource(R.string.dashboard_empty_go_settings)) + } + } + } +} + /** * 仪表盘底部 footer:填空白 + 显示版本号。 * 容器透明无边框,让卡片列表与背景融合自然。 diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt index 530ac1b..1aaf49f 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt @@ -41,7 +41,7 @@ class DashboardViewModel @Inject constructor( @param:ApplicationContext private val appContext: Context ) : ViewModel() { - private val _uiState = MutableStateFlow(DashboardUiState()) + private val _uiState = MutableStateFlow(DashboardUiState(loading = true)) val uiState: StateFlow = _uiState.asStateFlow() /** 防止并发 refresh() 调用交错覆盖 UI。 */ @@ -60,37 +60,53 @@ class DashboardViewModel @Inject constructor( * 重新读取本地凭据状态 + 余额缓存,不发起网络请求。 * * 卡片记录不含明文密钥的 SHA-256 指纹。更新时始终基于 _uiState 的最新卡片: - * - 凭据变化时采用已清理过的当前账户缓存; - * - 凭据未变时按 fetchedAt 采用更新缓存,支持 Widget 后台刷新结果回显; + * - 以当前已配置服务(configuredTypes)为准重建,新增服务即时出现、删除服务即时消失; + * - 仍存在的服务复用旧卡片做增量合并: + * - 凭据变化时采用已清理过的当前账户缓存; + * - 凭据未变时按 fetchedAt 采用更新缓存,支持 Widget 后台刷新结果回显; * - 不修改 refreshing,旧缓存快照也不能回滚新数据。 */ fun reloadLocalState() { viewModelScope.launch { val localStates = credentialRepository.readLocalStates() + val configured = configuredTypes(localStates).toSet() _uiState.update { state -> + val previousByService = state.cards.associateBy { it.service } + // 保留用户既有顺序;新配置的服务追加到尾部(按枚举顺序) + val existingOrder = state.cards + .mapIndexed { index, card -> card.service to index } + .toMap() + val orderedTypes = configured.sortedWith( + compareBy({ existingOrder[it] ?: Int.MAX_VALUE }, { it.ordinal }) + ) state.copy( - cards = state.cards.map { card -> - val local = localStates.getValue(card.service) - val credentialChanged = local.fingerprint != card.credentialFingerprint - val cacheAdvanced = !credentialChanged && isNewer( - candidate = local.cachedBalance, - current = card.cachedBalance - ) - card.copy( - credentialState = local.status.state, - credentialFingerprint = local.fingerprint, - cachedBalance = if (credentialChanged) { - local.cachedBalance - } else { - newerOf(card.cachedBalance, local.cachedBalance) - }, - // 凭据变化或出现更新的成功缓存时,旧错误已不再代表当前数据。 - lastFetchError = if (credentialChanged || cacheAdvanced) { - null - } else { - card.lastFetchError - } - ) + cards = orderedTypes.map { type -> + val local = localStates.getValue(type) + val card = previousByService[type] + if (card == null) { + buildCard(local, lastFetchError = null) + } else { + val credentialChanged = local.fingerprint != card.credentialFingerprint + val cacheAdvanced = !credentialChanged && isNewer( + candidate = local.cachedBalance, + current = card.cachedBalance + ) + card.copy( + credentialState = local.status.state, + credentialFingerprint = local.fingerprint, + cachedBalance = if (credentialChanged) { + local.cachedBalance + } else { + newerOf(card.cachedBalance, local.cachedBalance) + }, + // 凭据变化或出现更新的成功缓存时,旧错误已不再代表当前数据。 + lastFetchError = if (credentialChanged || cacheAdvanced) { + null + } else { + card.lastFetchError + } + ) + } } ) } @@ -100,7 +116,8 @@ class DashboardViewModel @Inject constructor( /** 从本地缓存快速填充一次(不阻塞)。挂起函数,供调用方控制执行顺序。 */ private suspend fun loadFromCache() { val localStates = credentialRepository.readLocalStates() - val cards = ServiceType.entries.map { type -> + // 首页仅显示已配置凭据的服务(有凭据就显示,未配置不占位) + val cards = configuredTypes(localStates).map { type -> buildCard(localStates.getValue(type), lastFetchError = null) } _uiState.update { it.copy(loading = false, refreshing = false, cards = cards) } @@ -126,7 +143,7 @@ class DashboardViewModel @Inject constructor( }.awaitAll().toMap() } val localStates = credentialRepository.readLocalStates() - val cards = ServiceType.entries.map { type -> + val cards = configuredTypes(localStates).map { type -> val result = results[type] val error = result?.exceptionOrNull() val errorUi = error @@ -145,6 +162,13 @@ class DashboardViewModel @Inject constructor( } } + /** 已配置凭据(state != NOT_CONFIGURED)的服务,首页只显示这些。 */ + private fun configuredTypes( + localStates: Map + ): List = ServiceType.entries.filter { type -> + localStates.getValue(type).status.state != CredentialStatus.State.NOT_CONFIGURED + } + private fun buildCard( local: CredentialRepository.LocalState, lastFetchError: UiText? diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt b/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt index 9b6b948..ffbd2a7 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt @@ -555,6 +555,9 @@ internal fun secondaryLineRes(card: DashboardCardUi): Int = when (card.service) ServiceType.COMMANDCODE_GO -> R.string.service_desc_commandcode_go ServiceType.CODEX -> R.string.service_desc_codex ServiceType.OLLAMA -> R.string.service_desc_ollama + ServiceType.TRAE -> R.string.service_desc_trae + ServiceType.WORKBUDDY -> R.string.service_desc_workbuddy + ServiceType.SUB2API -> R.string.service_desc_sub2api } /** diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/UsageStatScreen.kt b/app/src/main/java/com/rainy/token/ui/dashboard/UsageStatScreen.kt new file mode 100644 index 0000000..e492096 --- /dev/null +++ b/app/src/main/java/com/rainy/token/ui/dashboard/UsageStatScreen.kt @@ -0,0 +1,275 @@ +package com.rainy.token.ui.dashboard + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +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.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +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.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +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.R +import com.rainy.token.domain.model.CredentialStatus +import com.rainy.token.domain.model.ServiceBalance +import com.rainy.token.ui.components.ServiceIcon +import com.rainy.token.ui.components.formatAmount +import com.rainy.token.ui.theme.StrawberryPink +import com.rainy.token.ui.theme.StatusOrange +import com.rainy.token.ui.theme.inkMuted +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * 用量统计页:按已配置服务展示用量指标(余额、本月消费、总额度、下次重置、附加信息), + * 顶栏一键全部刷新。数据来自缓存 + 在线刷新,与 API 管理页(只管凭据)分离。 + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun UsageStatScreen( + onBack: () -> Unit, + viewModel: UsageStatViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.usage_stat_title)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + Icons.Filled.ArrowBack, + contentDescription = stringResource(R.string.action_back) + ) + } + }, + actions = { + if (uiState.refreshing) { + CircularProgressIndicator( + modifier = Modifier + .padding(end = 16.dp) + .size(20.dp), + color = StrawberryPink, + strokeWidth = 2.dp + ) + } else { + IconButton(onClick = { viewModel.refreshAll() }) { + Icon( + Icons.Filled.Refresh, + contentDescription = stringResource(R.string.action_refresh), + tint = StrawberryPink + ) + } + } + } + ) + } + ) { innerPadding -> + when { + !uiState.initialized -> Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) + uiState.rows.isEmpty() -> Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(R.string.usage_stat_empty), + style = MaterialTheme.typography.bodyMedium, + color = inkMuted() + ) + } + else -> LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(uiState.rows, key = { it.service.storageKey }) { row -> + UsageStatRowCard(row = row) + } + if (uiState.lastFailureCount > 0) { + item(key = "failure_note") { + Text( + text = stringResource( + R.string.usage_stat_failed, + uiState.lastFailureCount + ), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.error + ) + } + } + } + } + } +} + +@Composable +private fun UsageStatRowCard(row: UsageStatRowUi) { + val balance = row.balance + 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) { + ServiceIcon(service = row.service, size = 44) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = row.service.displayName, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f) + ) + UsageStatDot(state = row.state) + } + + Spacer(modifier = Modifier.height(10.dp)) + + if (balance == null) { + Text( + text = stringResource(R.string.usage_stat_never_fetched), + style = MaterialTheme.typography.bodyMedium, + color = inkMuted() + ) + return@Column + } + + Text( + text = "${formatAmount(balance.amount)} ${balance.unit}", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(6.dp)) + + val metrics = buildList { + balance.monthlySpent?.let { + add(stringResource(R.string.usage_stat_monthly_spent) to "${formatAmount(it)} ${balance.unit}") + } + balance.totalQuota?.let { + add(stringResource(R.string.usage_stat_total_quota) to "${formatAmount(it)} ${balance.unit}") + } + balance.nextResetAt?.let { + add(stringResource(R.string.usage_stat_next_reset) to formatTime(it)) + } + } + metrics.forEach { (label, value) -> + MetricLine(label = label, value = value) + } + + // extras 只透传对用户友好的键;过滤掉技术键与 JSON 模型数组,避免卡片被长串污染 + val friendlyExtras = balance.extras.filterKeys { key -> + val k = key.lowercase() + !k.contains("models") && + !k.contains("resetinsec") && + !k.contains("resetat") && + !k.contains("raw") && + !k.endsWith(".unlimited") + } + if (friendlyExtras.isNotEmpty()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + friendlyExtras.entries.take(3).forEach { (key, value) -> + Text( + text = "$key: $value", + style = MaterialTheme.typography.labelSmall, + color = inkMuted() + ) + } + } + } + + if (row.fetchedAt > 0L) { + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = stringResource(R.string.usage_stat_fetched_at, formatTime(row.fetchedAt)), + style = MaterialTheme.typography.labelSmall, + color = inkMuted() + ) + } + } + } +} + +@Composable +private fun MetricLine(label: String, value: String) { + Row(modifier = Modifier.padding(vertical = 2.dp)) { + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = inkMuted(), + modifier = Modifier.width(96.dp) + ) + Text( + text = value, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium + ) + } +} + +@Composable +private fun UsageStatDot(state: CredentialStatus.State) { + val color = when (state) { + CredentialStatus.State.OK -> Color(0xFF3BB273) + CredentialStatus.State.WARNING -> StatusOrange + CredentialStatus.State.EXPIRED -> MaterialTheme.colorScheme.error + CredentialStatus.State.NOT_CONFIGURED -> inkMuted().copy(alpha = 0.5f) + } + Box( + modifier = Modifier + .size(10.dp) + .clip(RoundedCornerShape(50)) + .background(color = color) + ) +} + +private fun formatTime(ts: Long): String { + if (ts <= 0L) return "—" + return runCatching { + SimpleDateFormat("MM-dd HH:mm", Locale.getDefault()).format(Date(ts)) + }.getOrDefault("—") +} diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/UsageStatViewModel.kt b/app/src/main/java/com/rainy/token/ui/dashboard/UsageStatViewModel.kt new file mode 100644 index 0000000..5529b68 --- /dev/null +++ b/app/src/main/java/com/rainy/token/ui/dashboard/UsageStatViewModel.kt @@ -0,0 +1,100 @@ +package com.rainy.token.ui.dashboard + +import androidx.lifecycle.ViewModel +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.CredentialStatus +import com.rainy.token.domain.model.ServiceBalance +import com.rainy.token.domain.service.ServiceType +import com.rainy.token.domain.usecase.RefreshBalanceUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * 用量统计页 ViewModel。 + * + * 按已配置的服务展示用量指标(余额、本月消费、总额度、下次重置、附加信息), + * 支持一键全部刷新。数据来自缓存(无网可见旧值)+ 可触发在线刷新。 + */ +@HiltViewModel +class UsageStatViewModel @Inject constructor( + private val credentialRepository: CredentialRepository, + private val balanceCache: BalanceCache, + private val refreshBalanceUseCase: RefreshBalanceUseCase +) : ViewModel() { + + private val _uiState = MutableStateFlow(UsageStatUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + load() + } + + fun load() { + viewModelScope.launch { + val rows = buildRows() + _uiState.update { it.copy(rows = rows, refreshing = false, initialized = true, lastFailureCount = 0) } + } + } + + fun refreshAll() { + viewModelScope.launch { + val types = _uiState.value.rows.map { it.service } + if (types.isEmpty()) return@launch + _uiState.update { it.copy(refreshing = true, lastFailureCount = 0) } + val failures = coroutineScope { + types.map { type -> + async { type to refreshBalanceUseCase(type).isSuccess } + }.awaitAll() + }.count { !it.second } + val rows = buildRows() + // 先刷新行,再单独写失败计数:避免前者把失败数清零(load 会重置) + _uiState.update { it.copy(rows = rows, refreshing = false) } + _uiState.update { it.copy(lastFailureCount = failures) } + } + } + + /** 读取本地凭据 + 余额缓存,构建行数据(纯读,不发网络)。 */ + private suspend fun buildRows(): List { + val states = credentialRepository.readLocalStates() + val cached = balanceCache.getAll() + return ServiceType.entries + .filter { type -> + states.getValue(type).status.state != CredentialStatus.State.NOT_CONFIGURED + } + .map { type -> + val cachedBalance = cached[type] + UsageStatRowUi( + service = type, + balance = cachedBalance?.balance, + fetchedAt = cachedBalance?.fetchedAt ?: 0L, + state = states.getValue(type).status.state + ) + } + } +} + +data class UsageStatRowUi( + val service: ServiceType, + val balance: ServiceBalance?, + val fetchedAt: Long, + val state: CredentialStatus.State +) + +data class UsageStatUiState( + val rows: List = emptyList(), + val refreshing: Boolean = false, + val lastFailureCount: Int = 0, + /** 首帧 true 前不渲染空态,避免闪一下"暂无已配置服务" */ + val initialized: Boolean = false +) 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 7a902aa..ad2633d 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 @@ -103,6 +103,7 @@ fun ServiceDetailScreen( onBack: () -> Unit, onConfigureCredential: (ServiceType) -> Unit, onStartWebViewLogin: (ServiceType) -> Unit, + onOpenRecharge: (ServiceType) -> Unit = { }, viewModel: ServiceDetailViewModel = hiltViewModel() ) { LaunchedEffect(service) { viewModel.bind(service) } @@ -183,6 +184,14 @@ fun ServiceDetailScreen( ServiceType.OLLAMA -> { item { OllamaUsageCard(uiState.state) } } + ServiceType.TRAE, + ServiceType.WORKBUDDY -> { + item { GenericExtrasCard(uiState.state) } + } + ServiceType.SUB2API -> { + // 新明细卡:模型筛选 + 时间窗口 + 柱状图;无结构化明细时内部回退 GenericExtrasCard + item { Sub2ApiUsageCard(uiState.state) } + } } // 错误信息(如有) @@ -212,6 +221,7 @@ fun ServiceDetailScreen( onRefresh = { viewModel.refresh() }, onConfigureCredential = { onConfigureCredential(service) }, onStartWebViewLogin = { onStartWebViewLogin(service) }, + onOpenRecharge = config.externalUrl?.let { { onOpenRecharge(service) } }, onTriggerUsage = { viewModel.triggerUsage() }, onSelectModel = { viewModel.selectModel(it) }, onRefreshModels = { viewModel.loadModels(force = true) } @@ -1056,6 +1066,55 @@ private fun BreakdownRow(label: String, value: Double, unit: String) { } } +/** + * 通用明细卡:把 balance.extras 中的友好键逐行展示(Trae 权益包 / WorkBuddy 资源 / + * Sub2API 账户等)。技术键(models / reset* / raw 等)一律过滤。 + */ +@Composable +internal fun GenericExtrasCard(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 friendly = extras.filterKeys { key -> + val k = key.lowercase() + !k.contains("models") && + !k.contains("reset") && + !k.contains("raw") && + !k.endsWith(".unlimited") && + !k.endsWith(".used") && + !k.endsWith(".limit") + } + if (friendly.isEmpty()) return + 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), + verticalArrangement = Arrangement.spacedBy(5.dp) + ) { + Text( + text = stringResource(R.string.detail_extras_title), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + friendly.forEach { (key, value) -> + Text( + text = "$key: $value", + style = MaterialTheme.typography.bodySmall, + color = inkMuted() + ) + } + } + } +} + @Composable private fun ErrorCard(message: String) { Card( @@ -1106,6 +1165,7 @@ private fun ActionButtons( onRefresh: () -> Unit, onConfigureCredential: () -> Unit, onStartWebViewLogin: () -> Unit, + onOpenRecharge: (() -> Unit)? = null, onTriggerUsage: () -> Unit, onSelectModel: (String) -> Unit, onRefreshModels: () -> Unit @@ -1250,6 +1310,16 @@ private fun ActionButtons( Text(stringResource(R.string.action_configure_credential)) } } + + // 外部操作入口(如 DeepSeek 充值页),App 内 WebView 打开 + if (onOpenRecharge != null) { + OutlinedButton( + onClick = onOpenRecharge, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.action_recharge)) + } + } } } @@ -1260,6 +1330,9 @@ private fun mainCardLabel(service: ServiceType): String = when (service) { ServiceType.COMMANDCODE_GO -> stringResource(R.string.main_card_monthly_balance) ServiceType.CODEX -> stringResource(R.string.window_usage) ServiceType.OLLAMA -> stringResource(R.string.main_card_session_usage) + ServiceType.TRAE, + ServiceType.WORKBUDDY, + ServiceType.SUB2API -> stringResource(R.string.main_card_current_balance) } private fun stateToChip(state: State): StatusStyle = when (state) { diff --git a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModel.kt b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModel.kt index 0507084..f4620e2 100644 --- a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModel.kt @@ -419,6 +419,12 @@ class ServiceDetailViewModel @Inject constructor( credential.copy(lastVerifiedAt = System.currentTimeMillis()) is Credential.CodexCredential -> credential.copy(lastVerifiedAt = System.currentTimeMillis()) + is Credential.TraeCredential -> + credential.copy(lastVerifiedAt = System.currentTimeMillis()) + is Credential.WorkBuddyCredential -> + credential.copy(lastVerifiedAt = System.currentTimeMillis()) + is Credential.Sub2ApiCredential -> + credential.copy(lastVerifiedAt = System.currentTimeMillis()) } credentialRepository.save(updated) loadFromCache() diff --git a/app/src/main/java/com/rainy/token/ui/servicedetail/Sub2ApiUsageCard.kt b/app/src/main/java/com/rainy/token/ui/servicedetail/Sub2ApiUsageCard.kt new file mode 100644 index 0000000..bc63589 --- /dev/null +++ b/app/src/main/java/com/rainy/token/ui/servicedetail/Sub2ApiUsageCard.kt @@ -0,0 +1,462 @@ +package com.rainy.token.ui.servicedetail + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.rainy.token.R +import com.rainy.token.domain.model.Sub2DailyUsage +import com.rainy.token.domain.model.Sub2ModelStat +import com.rainy.token.domain.model.Sub2UsageDetail +import com.rainy.token.ui.theme.inkMuted +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.Locale + +/** + * Sub2API 用量明细卡(替代原来的 GenericExtrasCard)。 + * + * 数据来自 [Sub2UsageDetail](网关 /v1/usage 的 model_stats + daily_usage)。 + * 提供: + * 1. 时间窗口筛选(今天 / 近7天 / 近30天 / 近90天 / 全部)——按日期对 daily_usage 切片; + * 2. 模型筛选(全部 / 指定模型)——选中模型时,汇总区切换为该模型全时间累计 + * (网关的 model_stats 无时间维度,故每日图/列表此时隐藏,避免误导); + * 3. 窗口内汇总(请求数 / Tokens / 花费)+ 每日花费柱状图 + 每日明细列表。 + * + * 兼容:detail 为空(旧缓存/网关未返回明细)时回退到 [GenericExtrasCard]。 + */ +@Composable +internal fun Sub2ApiUsageCard(state: State) { + val balance = when (state) { + is State.Fresh -> state.data + is State.Stale -> state.data + is State.Error -> state.cached + else -> null + } ?: return + val detail = balance.sub2UsageDetail + if (detail == null || detail.isEmpty) { + // 无结构化明细:回退旧通用卡(旧缓存/网关未返回 model_stats/daily_usage) + GenericExtrasCard(state) + return + } + + 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 = stringResource(R.string.sub2_usage_title), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.sub2_usage_subtitle), + style = MaterialTheme.typography.bodySmall, + color = inkMuted() + ) + Spacer(modifier = Modifier.height(12.dp)) + + var window by remember { mutableStateOf(Sub2Window.DAYS_30) } + // key=detail:刷新后模型列表变化时重置选中态,避免「每日区隐藏+无模型说明」的悬空状态 + var selectedModel by remember(detail) { mutableStateOf(null) } + + // ── 时间窗口选择 ── + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + items(Sub2Window.entries) { w -> + FilterChip( + selected = window == w, + onClick = { window = w }, + label = { Text(stringResource(w.labelRes)) }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = MaterialTheme.colorScheme.primary, + selectedLabelColor = MaterialTheme.colorScheme.onPrimary + ) + ) + } + } + + val days = remember(detail, window) { window.slice(detail.dailyUsage) } + val rangeLabel = remember(days) { formatRange(days) } + if (rangeLabel != null) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = rangeLabel, + style = MaterialTheme.typography.labelMedium, + color = inkMuted() + ) + } + + // 选中模型 → 汇总切换为该模型全时间累计;每日块隐藏(无模型维度,避免误导) + val selectedStat = remember(detail, selectedModel) { + detail.modelStats.firstOrNull { it.model == selectedModel } + } + + if (selectedStat != null) { + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringResource(R.string.sub2_model_sel_summary, selectedStat.model), + style = MaterialTheme.typography.labelMedium, + color = inkMuted() + ) + } + + // ── 汇总三格 ── + if (days.isNotEmpty() || selectedStat != null) { + Spacer(modifier = Modifier.height(10.dp)) + val sumRequests = selectedStat?.requests ?: days.sumOf { it.requests } + val sumTokens = selectedStat?.totalTokens ?: days.sumOf { it.totalTokens } + val sumCost = selectedStat?.cost ?: days.sumOf { it.cost } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + StatCell( + label = stringResource(R.string.sub2_stat_requests), + value = compactNum(sumRequests), + modifier = Modifier.weight(1f) + ) + StatCell( + label = stringResource(R.string.sub2_stat_tokens), + value = compactNum(sumTokens), + modifier = Modifier.weight(1f) + ) + StatCell( + label = stringResource(R.string.sub2_stat_cost), + value = formatCost(sumCost), + modifier = Modifier.weight(1f) + ) + } + } + + // ── 每日区域:仅「全部模型」时展示(daily_usage 是账户整体,无模型维度)── + if (selectedModel == null) { + if (days.isEmpty()) { + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringResource(R.string.sub2_no_daily), + style = MaterialTheme.typography.bodySmall, + color = inkMuted() + ) + } else { + // 每日花费柱状图 + 首尾日期刻度 + Spacer(modifier = Modifier.height(14.dp)) + DailyCostBars(days) + Spacer(modifier = Modifier.height(4.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = shortDate(days.first().date), + style = MaterialTheme.typography.labelSmall, + color = inkMuted() + ) + Text( + text = shortDate(days.last().date), + style = MaterialTheme.typography.labelSmall, + color = inkMuted() + ) + } + + // 每日明细 + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringResource(R.string.sub2_daily_header), + style = MaterialTheme.typography.labelLarge, + color = inkMuted() + ) + Spacer(modifier = Modifier.height(6.dp)) + days.reversed().forEachIndexed { index, day -> + DailyRow(day) + if (index != days.lastIndex) { + HorizontalDivider( + modifier = Modifier.padding(vertical = 2.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f) + ) + } + } + } + } + + // ── 按模型统计(整卡始终展示,选中模型时高亮)── + if (detail.modelStats.isNotEmpty()) { + Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + Spacer(modifier = Modifier.height(14.dp)) + Text( + text = stringResource(R.string.sub2_models_header), + style = MaterialTheme.typography.labelLarge, + color = inkMuted() + ) + Spacer(modifier = Modifier.height(8.dp)) + + // 模型筛选 chips + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + item { + FilterChip( + selected = selectedModel == null, + onClick = { selectedModel = null }, + label = { Text(stringResource(R.string.sub2_model_all)) }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = MaterialTheme.colorScheme.primary, + selectedLabelColor = MaterialTheme.colorScheme.onPrimary + ) + ) + } + items(detail.modelStats) { stat -> + FilterChip( + selected = selectedModel == stat.model, + onClick = { selectedModel = stat.model }, + label = { Text(stat.model) } + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + + val shown = detail.modelStats.filter { selectedModel == null || it.model == selectedModel } + shown.forEachIndexed { index, stat -> + ModelRow(stat, highlighted = selectedModel == stat.model) + if (index != shown.lastIndex) { + HorizontalDivider( + modifier = Modifier.padding(vertical = 2.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f) + ) + } + } + } + } + } +} + +/** 时间窗口:按日期切片(近 N 天 = 今天及之前 N-1 天),不再按行数。 */ +private enum class Sub2Window(val labelRes: Int) { + TODAY(R.string.sub2_window_today), + DAYS_7(R.string.sub2_window_7d), + DAYS_30(R.string.sub2_window_30d), + DAYS_90(R.string.sub2_window_90d), + ALL(R.string.sub2_window_all); + + fun slice(all: List): List { + val today = LocalDate.now().toString() + fun from(daysBack: Long) = LocalDate.now().minusDays(daysBack).toString() + return when (this) { + TODAY -> all.filter { it.date == today } + DAYS_7 -> all.filter { it.date >= from(6) } + DAYS_30 -> all.filter { it.date >= from(29) } + DAYS_90 -> all.filter { it.date >= from(89) } + ALL -> all + } + } +} + +@Composable +private fun StatCell(label: String, value: String, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + .padding(horizontal = 10.dp, vertical = 10.dp) + ) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = inkMuted() + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = value, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1 + ) + } +} + +/** 每日花费迷你柱状图:按窗口内每天的 cost 等比画柱(全零/无数据时显示空态文案)。 */ +@Composable +private fun DailyCostBars(days: List) { + val maxCost = days.maxOfOrNull { it.cost }?.takeIf { it > 0 } + if (maxCost == null) { + // 全零或全空:空态提示(避免空白灰框) + Box( + modifier = Modifier + .fillMaxWidth() + .height(88.dp) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(R.string.sub2_no_cost), + style = MaterialTheme.typography.bodySmall, + color = inkMuted() + ) + } + return + } + Row( + modifier = Modifier + .fillMaxWidth() + .height(88.dp) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)) + .padding(horizontal = 8.dp, vertical = 10.dp), + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(3.dp) + ) { + days.forEach { day -> + // 零消耗日也占位(0.05f 最小可见高度),保证日期刻度对齐 + val fraction = ((day.cost / maxCost).toFloat()).coerceIn(0.05f, 1f) + Column( + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Bottom + ) { + Box( + modifier = Modifier + .fillMaxWidth(0.6f) + .fillMaxHeight(fraction) + .clip(RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp)) + .background( + Brush.verticalGradient( + listOf( + MaterialTheme.colorScheme.primary, + MaterialTheme.colorScheme.primaryContainer + ) + ) + ) + ) + } + } + } +} + +/** 每日一行:日期(MM-dd)+ 请求数 + tokens + cost。 */ +@Composable +private fun DailyRow(day: Sub2DailyUsage) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = shortDate(day.date), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1.2f) + ) + Text( + text = compactNum(day.requests), + style = MaterialTheme.typography.bodySmall, + color = inkMuted(), + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center + ) + Text( + text = compactNum(day.totalTokens), + style = MaterialTheme.typography.bodySmall, + color = inkMuted(), + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center + ) + Text( + text = formatCost(day.cost), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(0.8f), + textAlign = TextAlign.End + ) + } +} + +/** 模型一行:名称 + 请求/tokens + cost;选中模型时加粗高亮。 */ +@Composable +private fun ModelRow(stat: Sub2ModelStat, highlighted: Boolean = false) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stat.model, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (highlighted) FontWeight.Bold else FontWeight.Medium, + color = if (highlighted) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, + maxLines = 1 + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = stringResource( + R.string.sub2_model_meta, + compactNum(stat.requests), + compactNum(stat.totalTokens) + ), + style = MaterialTheme.typography.bodySmall, + color = inkMuted() + ) + } + Text( + text = formatCost(stat.cost), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = if (highlighted) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface + ) + } +} + +/** "MM-dd" 短日期;解析失败原样返回。 */ +private fun shortDate(iso: String): String { + return runCatching { + LocalDate.parse(iso).format(DateTimeFormatter.ofPattern("MM-dd", Locale.US)) + }.getOrDefault(iso) +} + +/** 窗口起止标签:近 N 天 → "09-01 ~ 09-30",今天 → 只显示一天,全部 → null。 */ +private fun formatRange(days: List): String? { + if (days.isEmpty()) return null + val first = days.first().date + val last = days.last().date + return if (first == last) shortDate(first) else shortDate(first) + " ~ " + shortDate(last) +} + +/** 花费金额统一 "$x.xx"(0 显示 $0.00,钱不显示占位符)。 */ +private fun formatCost(v: Double): String = "$" + String.format(Locale.US, "%.2f", v) + +/** 1000 → "1.0K",1000000 → "1.0M",0 → "-"。 */ +private fun compactNum(v: Long): String = when { + v <= 0 -> "-" + v >= 1_000_000 -> String.format(Locale.US, "%.1fM", v / 1_000_000.0) + v >= 1_000 -> String.format(Locale.US, "%.1fK", v / 1_000.0) + else -> v.toString() +} diff --git a/app/src/main/java/com/rainy/token/ui/webview/WebViewPageScreen.kt b/app/src/main/java/com/rainy/token/ui/webview/WebViewPageScreen.kt new file mode 100644 index 0000000..1b9e961 --- /dev/null +++ b/app/src/main/java/com/rainy/token/ui/webview/WebViewPageScreen.kt @@ -0,0 +1,118 @@ +package com.rainy.token.ui.webview + +import android.view.ViewGroup +import android.webkit.WebChromeClient +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.viewinterop.AndroidView +import com.rainy.token.R + +/** + * 通用 WebView 页面容器。用于在 App 内打开外部操作页(如 DeepSeek 充值页)。 + * + * - 复用 [WebViewLoginScreen] 的 WebView 模式:JS/DOM 存储开启,适配移动视口; + * - 页面加载后用页面标题替换顶栏标题(失败则保持传入标题); + * - 返回键优先回退页内历史,无历史时退出页面。 + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WebViewPageScreen( + url: String, + title: String, + onBack: () -> Unit +) { + var webView: WebView? = remember { null } + var pageTitle by remember { mutableStateOf(title) } + + // 顶栏返回与系统返回保持一致:优先回退页内历史,无历史才退出 + val goBack: () -> Unit = { + if (webView?.canGoBack() == true) { + webView?.goBack() + } else { + onBack() + } + } + BackHandler(onBack = goBack) + + // 页面销毁时释放 WebView 原生资源,避免内存泄漏;先移除父级再 destroy,规避 + // "Calling destroy while still attached" 的极端时序崩溃 + DisposableEffect(Unit) { + onDispose { + webView?.let { view -> + view.parent?.let { (it as? ViewGroup)?.removeView(view) } + view.destroy() + } + webView = null + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(pageTitle) }, + navigationIcon = { + IconButton(onClick = goBack) { + Icon( + Icons.Filled.ArrowBack, + contentDescription = stringResource(R.string.action_back) + ) + } + } + ) + } + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) { + AndroidView( + factory = { context -> + WebView(context).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.useWideViewPort = true + settings.loadWithOverviewMode = true + + webViewClient = object : WebViewClient() { + override fun onPageFinished(view: WebView?, url: String?) { + // 用网页真实标题替换占位标题(忽略空白标题) + view?.title?.takeIf { it.isNotBlank() }?.let { loaded -> + if (loaded != pageTitle) pageTitle = loaded + } + } + } + webChromeClient = WebChromeClient() + webView = this + loadUrl(url) + } + }, + modifier = Modifier.fillMaxSize() + ) + } + } +} diff --git a/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt b/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt index fed71a9..06c94f2 100644 --- a/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt +++ b/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt @@ -240,6 +240,9 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() { } } ServiceType.DEEPSEEK -> setEmptyState(views, context) + ServiceType.TRAE, + ServiceType.WORKBUDDY, + ServiceType.SUB2API -> setEmptyState(views, context) ServiceType.OLLAMA -> { setRowLabel(views, context.getString(R.string.window_5h_short), context.getString(R.string.window_every_week), "") populateRow(views, R.id.row1_pct, R.id.row1_bar, R.id.row1_reset, @@ -359,6 +362,9 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() { ServiceType.CODEX -> "Codex" ServiceType.DEEPSEEK -> "DS" ServiceType.OLLAMA -> "Ollama" + ServiceType.TRAE -> "Trae" + ServiceType.WORKBUDDY -> "WB" + ServiceType.SUB2API -> "S2API" } private fun widgetLogo(service: ServiceType): Int = when (service) { @@ -366,6 +372,10 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() { ServiceType.CODEX -> R.drawable.ic_codex_logo_widget // PNG for RemoteViews compatibility ServiceType.DEEPSEEK -> R.drawable.ic_deepseek_logo ServiceType.OLLAMA -> R.drawable.ic_ollama_logo_widget + // 新服务暂不进桌面组件轮播(DISPLAY_SERVICES 未包含),这里用占位 logo 保证穷尽 + ServiceType.TRAE, + ServiceType.WORKBUDDY, + ServiceType.SUB2API -> R.drawable.ic_deepseek_logo } /** diff --git a/app/src/main/res/values-b+zh+Hans/strings.xml b/app/src/main/res/values-b+zh+Hans/strings.xml index 9ff2ddb..1827ece 100644 --- a/app/src/main/res/values-b+zh+Hans/strings.xml +++ b/app/src/main/res/values-b+zh+Hans/strings.xml @@ -44,6 +44,31 @@ 👈 选择服务查看详情 点击左侧卡片即可 设置 + API 管理 + 配置 + 编辑 + 测试 + 删除凭据? + 将删除 %1$s 已保存的凭据。 + 未配置 + 最后验证:%1$s + 缓存余额:%1$s %2$s + 用量统计 + 暂无已配置服务 + %1$d 项刷新失败 + 尚未获取 + 更新于 %1$s + 本月消费 + 总额度 + 下次重置 + ✅ %1$s 连接成功:%2$s %3$s + ❌ %1$s 连接失败:%2$s + 凭据无效 + 凭据已变更 + 请求过于频繁 + 网络异常 + 服务端异常(HTTP %1$d) + 解析失败 凭据管理 关于 RainyToken 使用小技巧 @@ -77,6 +102,9 @@ AI 余额一览 + 还没有配置任何服务 + 首页仅显示已配置凭据的服务 + 去设置添加 从未获取 刚刚更新 %1$d 分钟前更新 @@ -108,6 +136,50 @@ JSON API · $ ChatGPT · Codex 额度 Cookie 抓取 · Cloud 配额 + Cloud-IDE-JWT · IDE 额度 + OAuth · CodeBuddy 同体系积分 + 自托管面板 · 用户级 sk- Key 余额 + 明细 + Trae 凭据 + 粘贴 Trae 登录态中的 Cloud-IDE-JWT(ey… 开头)。请求头按 Authorization: Cloud-IDE-JWT 发送。 + Cloud-IDE-JWT + 区域 + CN + INTL + WorkBuddy 凭据 + 粘贴腾讯 WorkBuddy(CodeBuddy 同体系)会话的 access & refresh token。refresh token 每次使用会轮换。 + Access Token + Refresh Token + Access 与 Refresh Token 必填 + Sub2API 实例 + 该实例的任何用户:直接填分给你的 sk-… API Key 即可查余额(无需管理员)。没有 Key 也可用面板账号邮箱+密码登录。 + 实例基址 + https://api.example.com + 认证方式 + API Key(sk-…) + 或面板账号 + 邮箱 + 密码 + 实例基址与(API Key 或 邮箱+密码)必填 + + + 用量明细 + 来自 Sub2API 网关的每日与分模型用量 + 今天 + 近7天 + 近30天 + 近90天 + 全部 + 请求 + Tokens + 花费 + 每日用量 + 按模型 + 全部模型 + %1$s 次请求 · %2$s tokens + 该时间范围内暂无每日用量数据 + 该时间范围内无花费 + 模型:%1$s · 全时间累计 天 @@ -455,6 +527,7 @@ 刷新余额 重新登录 配置凭据 + 充值 当前余额 5h 实时用量 月度余额 diff --git a/app/src/main/res/values-b+zh+Hant/strings.xml b/app/src/main/res/values-b+zh+Hant/strings.xml index f6a2db7..ae65939 100644 --- a/app/src/main/res/values-b+zh+Hant/strings.xml +++ b/app/src/main/res/values-b+zh+Hant/strings.xml @@ -44,6 +44,31 @@ 👈 選擇服務檢視詳情 點選左側卡片即可 設定 + API 管理 + 設定 + 編輯 + 測試 + 刪除憑證? + 將刪除 %1$s 已儲存的憑證。 + 未設定 + 最後驗證:%1$s + 快取餘額:%1$s %2$s + 用量統計 + 尚無已設定服務 + %1$d 項刷新失敗 + 尚未取得 + 更新於 %1$s + 本月消費 + 總額度 + 下次重置 + ✅ %1$s 連線成功:%2$s %3$s + ❌ %1$s 連線失敗:%2$s + 憑證無效 + 憑證已變更 + 請求過於頻繁 + 網路異常 + 伺服器異常(HTTP %1$d) + 解析失敗 憑據管理 關於 RainyToken 使用小技巧 @@ -77,6 +102,9 @@ AI 餘額一覽 + 尚未設定任何服務 + 首頁僅顯示已設定憑證的服務 + 前往設定新增 從未獲取 剛剛更新 %1$d 分鐘前更新 @@ -108,6 +136,50 @@ JSON API · $ ChatGPT · Codex 額度 Cookie 抓取 · Cloud 配額 + Cloud-IDE-JWT · IDE 額度 + OAuth · CodeBuddy 同體系積分 + 自托管面板 · 用戶級 sk- Key 餘額 + 明細 + Trae 憑證 + 貼上 Trae 登入態中的 Cloud-IDE-JWT(ey… 開頭)。請求頭以 Authorization: Cloud-IDE-JWT 送出。 + Cloud-IDE-JWT + 區域 + CN + INTL + WorkBuddy 憑證 + 貼上騰訊 WorkBuddy(CodeBuddy 同體系)工作階段的 access & refresh token。refresh token 每次使用會輪換。 + Access Token + Refresh Token + Access 與 Refresh Token 必填 + Sub2API 實例 + 該實例的任何使用者:直接填分配給你的 sk-… API Key 即可查餘額(無需管理員)。沒有 Key 也可用面板帳號信箱+密碼登入。 + 實例基址 + https://api.example.com + 認證方式 + API Key(sk-…) + 或面板帳號 + 信箱 + 密碼 + 實例基址與(API Key 或 信箱+密碼)必填 + + + 用量明細 + 來自 Sub2API 閘道的每日與分模型用量 + 今天 + 近7天 + 近30天 + 近90天 + 全部 + 請求 + Tokens + 花費 + 每日用量 + 按模型 + 全部模型 + %1$s 次請求 · %2$s tokens + 該時間範圍內暫無每日用量數據 + 該時間範圍內無花費 + 模型:%1$s · 全時間累計 天 @@ -455,6 +527,7 @@ 重新整理餘額 重新登入 配置憑據 + 儲值 當前餘額 5h 實時用量 月度餘額 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6c71e04..d20333d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -48,6 +48,31 @@ 👈 Select a service to view details Tap a card on the left Settings + API Management + Configure + Edit + Test + Delete credential? + This will remove the saved credential for %1$s. + Not configured + Last verified: %1$s + Cached: %1$s %2$s + Usage Stats + No configured services yet + %1$d refresh failed + Not fetched yet + Updated %1$s + Monthly spent + Total quota + Next reset + ✅ %1$s connected: %2$s %3$s + ❌ %1$s failed: %2$s + Invalid credential + Credential changed + Too many requests + Network error + Server error (HTTP %1$d) + Parse failed Credentials About RainyToken Tips @@ -81,6 +106,9 @@ AI Balance Overview + No services configured yet + Only services with credentials are shown on the home page. + Go to Settings Never fetched Updated just now Updated %1$d min ago @@ -111,6 +139,50 @@ JSON API · $ ChatGPT · Codex quota Cookie fetch · Cloud quota + Cloud-IDE-JWT · IDE 额度 + OAuth · CodeBuddy 同体系积分 + 自托管面板 · 用户级 sk- Key 余额 + Breakdown + Trae credentials + Paste the Cloud-IDE-JWT from your Trae account session (starts with ey…). Used as Authorization: Cloud-IDE-JWT header. + Cloud-IDE-JWT + Region + CN + INTL + WorkBuddy credentials + Paste the access & refresh tokens from your Tencent WorkBuddy (CodeBuddy) session. Refresh token is rotated once per use. + Access Token + Refresh Token + Access and refresh tokens are required + Sub2API instance + Any user of the instance: paste the sk-… API key issued to you to see your balance (no admin needed). No key? Use the panel login email & password. + Instance base URL + https://api.example.com + Authentication + API Key (sk-…) + Or panel account + Email + Password + Base URL and (API Key or email+password) are required + + + Usage breakdown + Daily and per-model usage from your Sub2API gateway + Today + 7 days + 30 days + 90 days + All + Requests + Tokens + Cost + Daily usage + By model + All models + %1$s requests · %2$s tokens + No daily usage data in this range + No spend in this range + Model: %1$s · all-time total d @@ -458,6 +530,7 @@ Refresh balance Log in again Configure credential + Top up / Recharge Current balance 5h live usage Monthly balance diff --git a/app/src/test/java/com/rainy/token/data/repository/Sub2ApiRepositoryTest.kt b/app/src/test/java/com/rainy/token/data/repository/Sub2ApiRepositoryTest.kt new file mode 100644 index 0000000..50f11d6 --- /dev/null +++ b/app/src/test/java/com/rainy/token/data/repository/Sub2ApiRepositoryTest.kt @@ -0,0 +1,151 @@ +package com.rainy.token.data.repository + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for [Sub2ApiRepository.parseUsageDetail](/v1/usage 响应 → 结构化明细)。 + * + * 覆盖:平铺结构、{data:{...}} 包装、字段多版本候选(model_name/actual_cost)、 + * 异常行跳过、空明细兜底。全部纯 JVM、无网络。 + */ +class Sub2ApiRepositoryTest { + + /** 通过反射构造一个 Repository(内部方法不依赖 OkHttp/凭据,只测解析)。 */ + private fun repo(): Sub2ApiRepository { + val ctor = Sub2ApiRepository::class.java.getDeclaredConstructor( + okhttp3.OkHttpClient::class.java, + CredentialRepository::class.java, + com.rainy.token.data.cache.BalanceCache::class.java + ) + ctor.isAccessible = true + return ctor.newInstance(okhttp3.OkHttpClient(), null, null) + } + + private val flatSample = """ + { + "remaining": 302.29, + "unit": "USD", + "model_stats": [ + {"model": "deepseek-v4-flash", "requests": 1200, "input_tokens": 100000, "output_tokens": 50000, "total_tokens": 150000, "cost": 12.34}, + {"model": "gpt-5-codex", "requests": 300, "input_tokens": 20000, "output_tokens": 25000, "cost": 5.00}, + {"model_name": "claude-sonnet-4", "requests": "80", "actual_cost": 2.5} + ], + "daily_usage": [ + {"date": "2026-09-10", "requests": 400, "total_tokens": 60000, "cost": 6.1}, + {"date": "2026-09-11", "requests": 500, "input_tokens": 30000, "output_tokens": 40000, "cost": 7.2}, + {"date": "2026-09-12", "requests": 700, "total_tokens": 90000, "cost": 8.3} + ] + } + """.trimIndent() + + @Test + fun flatResponse_parsesModelStatsAndDailyUsageFully() { + val detail = repo().parseUsageDetail(flatSample) + + assertEquals(3, detail.modelStats.size) + assertEquals("deepseek-v4-flash", detail.modelStats[0].model) + assertEquals(1200L, detail.modelStats[0].requests) + assertEquals(100000L, detail.modelStats[0].inputTokens) + assertEquals(50000L, detail.modelStats[0].outputTokens) + assertEquals(150000L, detail.modelStats[0].totalTokens) + assertEquals(12.34, detail.modelStats[0].cost, 1e-6) + + // 第二候选字段:model_name / actual_cost / 字符串数字 + assertEquals("claude-sonnet-4", detail.modelStats[2].model) + assertEquals(80L, detail.modelStats[2].requests) + assertEquals(2.5, detail.modelStats[2].cost, 1e-6) + + // modelStats 方向 total_tokens 缺失时回退 input+output(与 daily 分支一致) + assertEquals(45000L, detail.modelStats[1].totalTokens) + + // 全量(不再被 take(3) 截断) + assertEquals(3, detail.dailyUsage.size) + assertEquals("2026-09-10", detail.dailyUsage[0].date) + assertEquals(700L, detail.dailyUsage[2].requests) + assertEquals(90000L, detail.dailyUsage[2].totalTokens) + assertEquals(8.3, detail.dailyUsage[2].cost, 1e-6) + // total_tokens 缺失时回退 input+output + assertEquals(70000L, detail.dailyUsage[1].totalTokens) + } + + @Test + fun dataWrapper_responseNestedUnderDataIsParsed() { + val wrapped = """{"code":0,"data":""" + flatSample + """}""" + val detail = repo().parseUsageDetail(wrapped) + assertEquals(3, detail.modelStats.size) + assertEquals(3, detail.dailyUsage.size) + } + + @Test + fun missingSections_returnsEmptyDetailWithoutCrashing() { + val detail = repo().parseUsageDetail("""{"remaining": 1.0}""") + assertTrue(detail.isEmpty) + assertEquals(0, detail.modelStats.size) + assertEquals(0, detail.dailyUsage.size) + } + + @Test + fun malformedBody_returnsEmptyDetailWithoutCrashing() { + val detail = repo().parseUsageDetail("not json at all") + assertTrue(detail.isEmpty) + } + + @Test + fun malformedRows_skippedIndividuallyValidRowsKept() { + val body = """ + { + "model_stats": [ + {"model": "good-model", "requests": 10, "cost": 1.0}, + {"total_tokens": 999}, + "garbage" + ], + "daily_usage": [ + {"date": "2026-09-11", "requests": 1, "cost": 0.1}, + {"cost": 5.0} + ] + } + """.trimIndent() + val detail = repo().parseUsageDetail(body) + assertEquals(1, detail.modelStats.size) + assertEquals("good-model", detail.modelStats[0].model) + assertEquals(1, detail.dailyUsage.size) + assertEquals("2026-09-11", detail.dailyUsage[0].date) + } + + @Test + fun dayWindowSlicing_usesDateRangeNotRowCount() { + // 基于系统日期构造动态样例,断言按日期过滤(而非按行数) + val today = java.time.LocalDate.now() + val d9 = today.minusDays(9).toString() + val d2 = today.minusDays(2).toString() + val d0 = today.toString() + val body = "{\"remaining\":1.0,\"daily_usage\":[" + + "{\"date\":\"" + d9 + "\",\"requests\":1,\"total_tokens\":10,\"cost\":0.1}," + + "{\"date\":\"" + d2 + "\",\"requests\":2,\"total_tokens\":20,\"cost\":0.2}," + + "{\"date\":\"" + d0 + "\",\"requests\":3,\"total_tokens\":30,\"cost\":0.3}" + + "]}" + val all = repo().parseUsageDetail(body).dailyUsage + assertEquals(3, all.size) + // 近 7 天(今天及前 6 天)→ 排除 9 天前的行 + assertEquals(2, all.filter { it.date >= today.minusDays(6).toString() }.size) + // 近 90 天 → 全部 3 行 + assertEquals(3, all.filter { it.date >= today.minusDays(89).toString() }.size) + // 解析层保证日期升序 + assertEquals(all.map { it.date }, all.map { it.date }.sorted()) + } + + @Test + fun wrongTypedSections_doesNotCrashAndSkips() { + val body = """ + { + "remaining": 1.0, + "model_stats": "not-an-array", + "daily_usage": 123 + } + """.trimIndent() + val detail = repo().parseUsageDetail(body) + assertTrue(detail.isEmpty) + } +}