feat: API 管理 / 用量统计 / 充值 WebView / 首页仅显示已配置;Sub2API 用量明细卡(模型筛选+时间窗口+柱状图)

This commit is contained in:
Liuxinyu176 2026-09-11 23:37:20 +08:00
parent e4cd9a36f6
commit fd3d74cfef
19 changed files with 2196 additions and 36 deletions

View File

@ -22,5 +22,7 @@ data class ServiceBalance(
/** 配额周期下次重置时间(订阅型服务,epoch millis) */ /** 配额周期下次重置时间(订阅型服务,epoch millis) */
val nextResetAt: Long? = null, val nextResetAt: Long? = null,
/** 服务特定的附加信息(如 Zen 充值 $20 起,Go 5h 配额) */ /** 服务特定的附加信息(如 Zen 充值 $20 起,Go 5h 配额) */
val extras: Map<String, String> = emptyMap() val extras: Map<String, String> = emptyMap(),
/** Sub2API 专有:网关 /v1/usage 返回的结构化用量明细(模型统计 + 每日明细)。其他服务为 null。 */
val sub2UsageDetail: Sub2UsageDetail? = null
) )

View File

@ -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<Sub2ModelStat> = emptyList(),
val dailyUsage: List<Sub2DailyUsage> = 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
)

View File

@ -18,12 +18,14 @@ import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -36,6 +38,7 @@ import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument import androidx.navigation.navArgument
import com.rainy.token.R import com.rainy.token.R
import com.rainy.token.data.repository.CommandCodeUsageRepository 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.domain.service.ServiceType
import com.rainy.token.ui.components.rememberWindowSizeClass import com.rainy.token.ui.components.rememberWindowSizeClass
import com.rainy.token.ui.components.DebugLogScreen 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.UsageDataViewModel
import com.rainy.token.ui.dashboard.UsageDetailScreen import com.rainy.token.ui.dashboard.UsageDetailScreen
import com.rainy.token.ui.dashboard.UsageOverviewScreen 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.dashboard.UsageViewModel
import com.rainy.token.ui.heatmap.HeatmapScreen 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.servicedetail.ServiceDetailScreen
import com.rainy.token.ui.settings.CredentialEditScreen import com.rainy.token.ui.settings.CredentialEditScreen
import com.rainy.token.ui.settings.SettingsScreen 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.theme.StrawberryPink
import com.rainy.token.ui.webview.CodexOAuthScreen import com.rainy.token.ui.webview.CodexOAuthScreen
import com.rainy.token.ui.webview.WebViewLoginScreen 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 { object Routes {
const val DASHBOARD = "dashboard" const val DASHBOARD = "dashboard"
const val SETTINGS = "settings" const val SETTINGS = "settings"
const val API_MANAGEMENT = "api_management"
const val USAGE_STAT = "usage_stat"
const val TIPS = "tips" const val TIPS = "tips"
const val DEBUG_LOG = "debug_log" const val DEBUG_LOG = "debug_log"
const val CODEX_OAUTH = "codex_oauth" const val CODEX_OAUTH = "codex_oauth"
@ -76,6 +85,9 @@ object Routes {
fun credentialEdit(type: ServiceType) = "credential_edit/${type.name}" fun credentialEdit(type: ServiceType) = "credential_edit/${type.name}"
const val WEBVIEW_LOGIN = "webview_login/{type}" const val WEBVIEW_LOGIN = "webview_login/{type}"
fun webviewLogin(type: ServiceType) = "webview_login/${type.name}" 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}" const val SERVICE_DETAIL = "service/{type}"
fun serviceDetail(type: ServiceType) = "service/${type.name}" fun serviceDetail(type: ServiceType) = "service/${type.name}"
const val USAGE_DETAIL = "usage_detail" const val USAGE_DETAIL = "usage_detail"
@ -98,6 +110,8 @@ private sealed class DetailPane {
object OCGOUsage : DetailPane() object OCGOUsage : DetailPane()
object CCGOUsage : DetailPane() object CCGOUsage : DetailPane()
object Settings : DetailPane() object Settings : DetailPane()
object ApiManagement : DetailPane()
object UsageStat : DetailPane()
object Heatmap : DetailPane() object Heatmap : DetailPane()
} }
@ -192,12 +206,29 @@ private fun CompactNavHost() {
composable(Routes.DASHBOARD) { composable(Routes.DASHBOARD) {
DashboardScreen( DashboardScreen(
onOpenSettings = { navController.navigate(Routes.SETTINGS) }, onOpenSettings = { navController.navigate(Routes.SETTINGS) },
onOpenApiManagement = { navController.navigate(Routes.API_MANAGEMENT) },
onOpenUsageStat = { navController.navigate(Routes.USAGE_STAT) },
onOpenService = { type -> navController.navigate(Routes.serviceDetail(type)) }, onOpenService = { type -> navController.navigate(Routes.serviceDetail(type)) },
onOpenUsageDetail = { navController.navigate(Routes.USAGE_DETAIL) }, onOpenUsageDetail = { navController.navigate(Routes.USAGE_DETAIL) },
onOpenCcgoUsageDetail = { navController.navigate(Routes.CCGO_USAGE_DETAIL) }, onOpenCcgoUsageDetail = { navController.navigate(Routes.CCGO_USAGE_DETAIL) },
onOpenHeatmap = { navController.navigate(Routes.HEATMAP) } 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) { composable(Routes.USAGE_DETAIL) {
UsageDetailScreen( UsageDetailScreen(
onBack = guardedPop, onBack = guardedPop,
@ -300,13 +331,30 @@ private fun CompactNavHost() {
arguments = listOf(navArgument("type") { type = NavType.StringType }) arguments = listOf(navArgument("type") { type = NavType.StringType })
) { backStackEntry -> ) { backStackEntry ->
val type = parseServiceType(backStackEntry.arguments?.getString("type")) val type = parseServiceType(backStackEntry.arguments?.getString("type"))
val rechargeLabel = stringResource(R.string.action_recharge)
ServiceDetailScreen( ServiceDetailScreen(
service = type, service = type,
onBack = guardedPop, onBack = guardedPop,
onConfigureCredential = { svc -> navController.navigate(Routes.credentialEdit(svc)) }, 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) { composable(Routes.HEATMAP) {
HeatmapScreen(onBack = guardedPop) HeatmapScreen(onBack = guardedPop)
} }
@ -340,6 +388,8 @@ private fun ExpandedLayout() {
) { ) {
DashboardScreen( DashboardScreen(
onOpenSettings = { detailPane = DetailPane.Settings }, onOpenSettings = { detailPane = DetailPane.Settings },
onOpenApiManagement = { detailPane = DetailPane.ApiManagement },
onOpenUsageStat = { detailPane = DetailPane.UsageStat },
onOpenService = { type -> detailPane = DetailPane.ServiceDetail(type) }, onOpenService = { type -> detailPane = DetailPane.ServiceDetail(type) },
onOpenUsageDetail = { detailPane = DetailPane.OCGOUsage }, onOpenUsageDetail = { detailPane = DetailPane.OCGOUsage },
onOpenCcgoUsageDetail = { detailPane = DetailPane.CCGOUsage }, onOpenCcgoUsageDetail = { detailPane = DetailPane.CCGOUsage },
@ -384,12 +434,43 @@ private fun ExpandedDetailPane(
} }
} }
is DetailPane.ServiceDetail -> { is DetailPane.ServiceDetail -> {
ServiceDetailScreen( // key(pane.type):切换服务时重建嵌套导航栈,避免停留在上一个服务的充值页
service = pane.type, key(pane.type) {
onBack = onClose, val navController = rememberNavController()
onConfigureCredential = { onClose() }, NavHost(
onStartWebViewLogin = { } 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 -> { is DetailPane.OCGOUsage -> {
val navController = rememberNavController() val navController = rememberNavController()
@ -453,6 +534,85 @@ private fun ExpandedDetailPane(
is DetailPane.Heatmap -> { is DetailPane.Heatmap -> {
HeatmapScreen(onBack = onClose) 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 -> { is DetailPane.Settings -> {
val settingsNavController = rememberNavController() val settingsNavController = rememberNavController()
// 监听嵌套 NavHost 路由变化:当从凭据编辑页/WebView 返回 settings_main 时 // 监听嵌套 NavHost 路由变化:当从凭据编辑页/WebView 返回 settings_main 时

View File

@ -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<ServiceType?>(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("—")
}

View File

@ -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<ApiManagementUiState> = _uiState.asStateFlow()
private val _message = MutableStateFlow<UiText?>(null)
val message: StateFlow<UiText?> = _message
/** 正在测试连接的服务集合(支持并发多测,各自独立转圈)。 */
private val _testing = MutableStateFlow<Set<ServiceType>>(emptySet())
val testing: StateFlow<Set<ServiceType>> = _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<ApiRowUi> = emptyList()
)

View File

@ -31,6 +31,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.DateRange import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material.icons.filled.KeyboardArrowRight 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.Refresh
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
@ -43,6 +44,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
@ -121,6 +123,8 @@ import java.util.Locale
@Composable @Composable
fun DashboardScreen( fun DashboardScreen(
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onOpenApiManagement: () -> Unit,
onOpenUsageStat: () -> Unit,
onOpenService: (ServiceType) -> Unit, onOpenService: (ServiceType) -> Unit,
onOpenUsageDetail: () -> Unit, onOpenUsageDetail: () -> Unit,
onOpenCcgoUsageDetail: () -> Unit = {}, onOpenCcgoUsageDetail: () -> Unit = {},
@ -214,6 +218,13 @@ fun DashboardScreen(
tint = StrawberryPink tint = StrawberryPink
) )
} }
IconButton(onClick = onOpenUsageStat) {
Icon(
imageVector = Icons.Filled.DateRange,
contentDescription = stringResource(R.string.usage_stat_title),
tint = StrawberryPink
)
}
IconButton( IconButton(
onClick = { viewModel.refresh() }, onClick = { viewModel.refresh() },
enabled = !uiState.refreshing enabled = !uiState.refreshing
@ -224,6 +235,13 @@ fun DashboardScreen(
tint = StrawberryPink tint = StrawberryPink
) )
} }
IconButton(onClick = onOpenApiManagement) {
Icon(
imageVector = Icons.Filled.Lock,
contentDescription = stringResource(R.string.api_mgmt_title),
tint = StrawberryPink
)
}
IconButton(onClick = onOpenSettings) { IconButton(onClick = onOpenSettings) {
Icon( Icon(
imageVector = Icons.Filled.Settings, imageVector = Icons.Filled.Settings,
@ -245,7 +263,8 @@ fun DashboardScreen(
.fillMaxSize() .fillMaxSize()
.padding(innerPadding) .padding(innerPadding)
) { ) {
if (uiState.cards.isEmpty()) { // 仅初始加载时显示转圈;加载完即使没有已配置服务也渲染(展示用量卡片 + 空态引导)
if (uiState.loading) {
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
@ -280,6 +299,7 @@ fun DashboardScreen(
val items = rememberDashboardItems( val items = rememberDashboardItems(
cards = uiState.cards, cards = uiState.cards,
order = cardOrder, order = cardOrder,
onOpenSettings = onOpenSettings,
onOpenUsageDetail = onOpenUsageDetail, onOpenUsageDetail = onOpenUsageDetail,
onOpenCcgoUsageDetail = onOpenCcgoUsageDetail, onOpenCcgoUsageDetail = onOpenCcgoUsageDetail,
onOpenService = onOpenService, onOpenService = onOpenService,
@ -361,6 +381,7 @@ private data class DashboardHomeItem(
private fun rememberDashboardItems( private fun rememberDashboardItems(
cards: List<DashboardCardUi>, cards: List<DashboardCardUi>,
order: List<String>, order: List<String>,
onOpenSettings: () -> Unit,
onOpenUsageDetail: () -> Unit, onOpenUsageDetail: () -> Unit,
onOpenCcgoUsageDetail: () -> Unit, onOpenCcgoUsageDetail: () -> Unit,
onOpenService: (ServiceType) -> Unit, onOpenService: (ServiceType) -> Unit,
@ -368,6 +389,12 @@ private fun rememberDashboardItems(
refreshTrigger: Int refreshTrigger: Int
): List<DashboardHomeItem> { ): List<DashboardHomeItem> {
val defaultItems = buildList { val defaultItems = buildList {
// 未配置任何服务时的引导卡片(首页仅显示已配置的服务)
if (cards.isEmpty()) {
add(DashboardHomeItem("empty_hint") {
EmptyServicesHintCard(onOpenSettings = onOpenSettings)
})
}
add(DashboardHomeItem(USAGE_OCGO_CARD_ID) { add(DashboardHomeItem(USAGE_OCGO_CARD_ID) {
UsageStatsCard(onOpenDetail = onOpenUsageDetail, onOpenHeatmap = onOpenHeatmap, refreshTrigger = refreshTrigger) 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:填空白 + 显示版本号。 * 仪表盘底部 footer:填空白 + 显示版本号。
* 容器透明无边框,让卡片列表与背景融合自然。 * 容器透明无边框,让卡片列表与背景融合自然。

View File

@ -41,7 +41,7 @@ class DashboardViewModel @Inject constructor(
@param:ApplicationContext private val appContext: Context @param:ApplicationContext private val appContext: Context
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(DashboardUiState()) private val _uiState = MutableStateFlow(DashboardUiState(loading = true))
val uiState: StateFlow<DashboardUiState> = _uiState.asStateFlow() val uiState: StateFlow<DashboardUiState> = _uiState.asStateFlow()
/** 防止并发 refresh() 调用交错覆盖 UI。 */ /** 防止并发 refresh() 调用交错覆盖 UI。 */
@ -60,37 +60,53 @@ class DashboardViewModel @Inject constructor(
* 重新读取本地凭据状态 + 余额缓存,不发起网络请求。 * 重新读取本地凭据状态 + 余额缓存,不发起网络请求。
* *
* 卡片记录不含明文密钥的 SHA-256 指纹。更新时始终基于 _uiState 的最新卡片: * 卡片记录不含明文密钥的 SHA-256 指纹。更新时始终基于 _uiState 的最新卡片:
* - 凭据变化时采用已清理过的当前账户缓存; * - 以当前已配置服务(configuredTypes)为准重建,新增服务即时出现、删除服务即时消失;
* - 凭据未变时按 fetchedAt 采用更新缓存,支持 Widget 后台刷新结果回显; * - 仍存在的服务复用旧卡片做增量合并:
* - 凭据变化时采用已清理过的当前账户缓存;
* - 凭据未变时按 fetchedAt 采用更新缓存,支持 Widget 后台刷新结果回显;
* - 不修改 refreshing,旧缓存快照也不能回滚新数据。 * - 不修改 refreshing,旧缓存快照也不能回滚新数据。
*/ */
fun reloadLocalState() { fun reloadLocalState() {
viewModelScope.launch { viewModelScope.launch {
val localStates = credentialRepository.readLocalStates() val localStates = credentialRepository.readLocalStates()
val configured = configuredTypes(localStates).toSet()
_uiState.update { state -> _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( state.copy(
cards = state.cards.map { card -> cards = orderedTypes.map { type ->
val local = localStates.getValue(card.service) val local = localStates.getValue(type)
val credentialChanged = local.fingerprint != card.credentialFingerprint val card = previousByService[type]
val cacheAdvanced = !credentialChanged && isNewer( if (card == null) {
candidate = local.cachedBalance, buildCard(local, lastFetchError = null)
current = card.cachedBalance } else {
) val credentialChanged = local.fingerprint != card.credentialFingerprint
card.copy( val cacheAdvanced = !credentialChanged && isNewer(
credentialState = local.status.state, candidate = local.cachedBalance,
credentialFingerprint = local.fingerprint, current = card.cachedBalance
cachedBalance = if (credentialChanged) { )
local.cachedBalance card.copy(
} else { credentialState = local.status.state,
newerOf(card.cachedBalance, local.cachedBalance) credentialFingerprint = local.fingerprint,
}, cachedBalance = if (credentialChanged) {
// 凭据变化或出现更新的成功缓存时,旧错误已不再代表当前数据。 local.cachedBalance
lastFetchError = if (credentialChanged || cacheAdvanced) { } else {
null newerOf(card.cachedBalance, local.cachedBalance)
} else { },
card.lastFetchError // 凭据变化或出现更新的成功缓存时,旧错误已不再代表当前数据。
} lastFetchError = if (credentialChanged || cacheAdvanced) {
) null
} else {
card.lastFetchError
}
)
}
} }
) )
} }
@ -100,7 +116,8 @@ class DashboardViewModel @Inject constructor(
/** 从本地缓存快速填充一次(不阻塞)。挂起函数,供调用方控制执行顺序。 */ /** 从本地缓存快速填充一次(不阻塞)。挂起函数,供调用方控制执行顺序。 */
private suspend fun loadFromCache() { private suspend fun loadFromCache() {
val localStates = credentialRepository.readLocalStates() val localStates = credentialRepository.readLocalStates()
val cards = ServiceType.entries.map { type -> // 首页仅显示已配置凭据的服务(有凭据就显示,未配置不占位)
val cards = configuredTypes(localStates).map { type ->
buildCard(localStates.getValue(type), lastFetchError = null) buildCard(localStates.getValue(type), lastFetchError = null)
} }
_uiState.update { it.copy(loading = false, refreshing = false, cards = cards) } _uiState.update { it.copy(loading = false, refreshing = false, cards = cards) }
@ -126,7 +143,7 @@ class DashboardViewModel @Inject constructor(
}.awaitAll().toMap() }.awaitAll().toMap()
} }
val localStates = credentialRepository.readLocalStates() val localStates = credentialRepository.readLocalStates()
val cards = ServiceType.entries.map { type -> val cards = configuredTypes(localStates).map { type ->
val result = results[type] val result = results[type]
val error = result?.exceptionOrNull() val error = result?.exceptionOrNull()
val errorUi = error val errorUi = error
@ -145,6 +162,13 @@ class DashboardViewModel @Inject constructor(
} }
} }
/** 已配置凭据(state != NOT_CONFIGURED)的服务,首页只显示这些。 */
private fun configuredTypes(
localStates: Map<ServiceType, CredentialRepository.LocalState>
): List<ServiceType> = ServiceType.entries.filter { type ->
localStates.getValue(type).status.state != CredentialStatus.State.NOT_CONFIGURED
}
private fun buildCard( private fun buildCard(
local: CredentialRepository.LocalState, local: CredentialRepository.LocalState,
lastFetchError: UiText? lastFetchError: UiText?

View File

@ -555,6 +555,9 @@ internal fun secondaryLineRes(card: DashboardCardUi): Int = when (card.service)
ServiceType.COMMANDCODE_GO -> R.string.service_desc_commandcode_go ServiceType.COMMANDCODE_GO -> R.string.service_desc_commandcode_go
ServiceType.CODEX -> R.string.service_desc_codex ServiceType.CODEX -> R.string.service_desc_codex
ServiceType.OLLAMA -> R.string.service_desc_ollama 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
} }
/** /**

View File

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

View File

@ -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<UsageStatUiState> = _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<UsageStatRowUi> {
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<UsageStatRowUi> = emptyList(),
val refreshing: Boolean = false,
val lastFailureCount: Int = 0,
/** 首帧 true 前不渲染空态,避免闪一下"暂无已配置服务" */
val initialized: Boolean = false
)

View File

@ -103,6 +103,7 @@ fun ServiceDetailScreen(
onBack: () -> Unit, onBack: () -> Unit,
onConfigureCredential: (ServiceType) -> Unit, onConfigureCredential: (ServiceType) -> Unit,
onStartWebViewLogin: (ServiceType) -> Unit, onStartWebViewLogin: (ServiceType) -> Unit,
onOpenRecharge: (ServiceType) -> Unit = { },
viewModel: ServiceDetailViewModel = hiltViewModel() viewModel: ServiceDetailViewModel = hiltViewModel()
) { ) {
LaunchedEffect(service) { viewModel.bind(service) } LaunchedEffect(service) { viewModel.bind(service) }
@ -183,6 +184,14 @@ fun ServiceDetailScreen(
ServiceType.OLLAMA -> { ServiceType.OLLAMA -> {
item { OllamaUsageCard(uiState.state) } 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() }, onRefresh = { viewModel.refresh() },
onConfigureCredential = { onConfigureCredential(service) }, onConfigureCredential = { onConfigureCredential(service) },
onStartWebViewLogin = { onStartWebViewLogin(service) }, onStartWebViewLogin = { onStartWebViewLogin(service) },
onOpenRecharge = config.externalUrl?.let { { onOpenRecharge(service) } },
onTriggerUsage = { viewModel.triggerUsage() }, onTriggerUsage = { viewModel.triggerUsage() },
onSelectModel = { viewModel.selectModel(it) }, onSelectModel = { viewModel.selectModel(it) },
onRefreshModels = { viewModel.loadModels(force = true) } 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 @Composable
private fun ErrorCard(message: String) { private fun ErrorCard(message: String) {
Card( Card(
@ -1106,6 +1165,7 @@ private fun ActionButtons(
onRefresh: () -> Unit, onRefresh: () -> Unit,
onConfigureCredential: () -> Unit, onConfigureCredential: () -> Unit,
onStartWebViewLogin: () -> Unit, onStartWebViewLogin: () -> Unit,
onOpenRecharge: (() -> Unit)? = null,
onTriggerUsage: () -> Unit, onTriggerUsage: () -> Unit,
onSelectModel: (String) -> Unit, onSelectModel: (String) -> Unit,
onRefreshModels: () -> Unit onRefreshModels: () -> Unit
@ -1250,6 +1310,16 @@ private fun ActionButtons(
Text(stringResource(R.string.action_configure_credential)) 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.COMMANDCODE_GO -> stringResource(R.string.main_card_monthly_balance)
ServiceType.CODEX -> stringResource(R.string.window_usage) ServiceType.CODEX -> stringResource(R.string.window_usage)
ServiceType.OLLAMA -> stringResource(R.string.main_card_session_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) { private fun stateToChip(state: State): StatusStyle = when (state) {

View File

@ -419,6 +419,12 @@ class ServiceDetailViewModel @Inject constructor(
credential.copy(lastVerifiedAt = System.currentTimeMillis()) credential.copy(lastVerifiedAt = System.currentTimeMillis())
is Credential.CodexCredential -> is Credential.CodexCredential ->
credential.copy(lastVerifiedAt = System.currentTimeMillis()) 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) credentialRepository.save(updated)
loadFromCache() loadFromCache()

View File

@ -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<String?>(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<Sub2DailyUsage>): List<Sub2DailyUsage> {
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<Sub2DailyUsage>) {
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<Sub2DailyUsage>): 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()
}

View File

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

View File

@ -240,6 +240,9 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
} }
} }
ServiceType.DEEPSEEK -> setEmptyState(views, context) ServiceType.DEEPSEEK -> setEmptyState(views, context)
ServiceType.TRAE,
ServiceType.WORKBUDDY,
ServiceType.SUB2API -> setEmptyState(views, context)
ServiceType.OLLAMA -> { ServiceType.OLLAMA -> {
setRowLabel(views, context.getString(R.string.window_5h_short), context.getString(R.string.window_every_week), "") 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, 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.CODEX -> "Codex"
ServiceType.DEEPSEEK -> "DS" ServiceType.DEEPSEEK -> "DS"
ServiceType.OLLAMA -> "Ollama" ServiceType.OLLAMA -> "Ollama"
ServiceType.TRAE -> "Trae"
ServiceType.WORKBUDDY -> "WB"
ServiceType.SUB2API -> "S2API"
} }
private fun widgetLogo(service: ServiceType): Int = when (service) { 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.CODEX -> R.drawable.ic_codex_logo_widget // PNG for RemoteViews compatibility
ServiceType.DEEPSEEK -> R.drawable.ic_deepseek_logo ServiceType.DEEPSEEK -> R.drawable.ic_deepseek_logo
ServiceType.OLLAMA -> R.drawable.ic_ollama_logo_widget ServiceType.OLLAMA -> R.drawable.ic_ollama_logo_widget
// 新服务暂不进桌面组件轮播(DISPLAY_SERVICES 未包含),这里用占位 logo 保证穷尽
ServiceType.TRAE,
ServiceType.WORKBUDDY,
ServiceType.SUB2API -> R.drawable.ic_deepseek_logo
} }
/** /**

View File

@ -44,6 +44,31 @@
<string name="nav_empty_title">👈 选择服务查看详情</string> <string name="nav_empty_title">👈 选择服务查看详情</string>
<string name="nav_empty_subtitle">点击左侧卡片即可</string> <string name="nav_empty_subtitle">点击左侧卡片即可</string>
<string name="title_settings">设置</string> <string name="title_settings">设置</string>
<string name="api_mgmt_title">API 管理</string>
<string name="api_mgmt_configure">配置</string>
<string name="api_mgmt_edit">编辑</string>
<string name="api_mgmt_test">测试</string>
<string name="api_mgmt_delete_title">删除凭据?</string>
<string name="api_mgmt_delete_body">将删除 %1$s 已保存的凭据。</string>
<string name="api_mgmt_unconfigured">未配置</string>
<string name="api_mgmt_last_verified">最后验证:%1$s</string>
<string name="api_mgmt_cached_balance">缓存余额:%1$s %2$s</string>
<string name="usage_stat_title">用量统计</string>
<string name="usage_stat_empty">暂无已配置服务</string>
<string name="usage_stat_failed">%1$d 项刷新失败</string>
<string name="usage_stat_never_fetched">尚未获取</string>
<string name="usage_stat_fetched_at">更新于 %1$s</string>
<string name="usage_stat_monthly_spent">本月消费</string>
<string name="usage_stat_total_quota">总额度</string>
<string name="usage_stat_next_reset">下次重置</string>
<string name="api_mgmt_test_success">✅ %1$s 连接成功:%2$s %3$s</string>
<string name="api_mgmt_test_fail">❌ %1$s 连接失败:%2$s</string>
<string name="api_mgmt_err_invalid">凭据无效</string>
<string name="api_mgmt_err_changed">凭据已变更</string>
<string name="api_mgmt_err_ratelimited">请求过于频繁</string>
<string name="api_mgmt_err_network">网络异常</string>
<string name="api_mgmt_err_server">服务端异常(HTTP %1$d)</string>
<string name="api_mgmt_err_parse">解析失败</string>
<string name="title_credentials">凭据管理</string> <string name="title_credentials">凭据管理</string>
<string name="title_about">关于 RainyToken</string> <string name="title_about">关于 RainyToken</string>
<string name="title_tips">使用小技巧</string> <string name="title_tips">使用小技巧</string>
@ -77,6 +102,9 @@
<!-- ══════════ 仪表盘 ══════════ --> <!-- ══════════ 仪表盘 ══════════ -->
<string name="dashboard_subtitle">AI 余额一览</string> <string name="dashboard_subtitle">AI 余额一览</string>
<string name="dashboard_empty_title">还没有配置任何服务</string>
<string name="dashboard_empty_subtitle">首页仅显示已配置凭据的服务</string>
<string name="dashboard_empty_go_settings">去设置添加</string>
<string name="footer_never_fetched">从未获取</string> <string name="footer_never_fetched">从未获取</string>
<string name="footer_just_updated">刚刚更新</string> <string name="footer_just_updated">刚刚更新</string>
<string name="footer_minutes_ago">%1$d 分钟前更新</string> <string name="footer_minutes_ago">%1$d 分钟前更新</string>
@ -108,6 +136,50 @@
<string name="service_desc_commandcode_go">JSON API · $</string> <string name="service_desc_commandcode_go">JSON API · $</string>
<string name="service_desc_codex">ChatGPT · Codex 额度</string> <string name="service_desc_codex">ChatGPT · Codex 额度</string>
<string name="service_desc_ollama">Cookie 抓取 · Cloud 配额</string> <string name="service_desc_ollama">Cookie 抓取 · Cloud 配额</string>
<string name="service_desc_trae">Cloud-IDE-JWT · IDE 额度</string>
<string name="service_desc_workbuddy">OAuth · CodeBuddy 同体系积分</string>
<string name="service_desc_sub2api">自托管面板 · 用户级 sk- Key 余额</string>
<string name="detail_extras_title">明细</string>
<string name="credential_title_trae">Trae 凭据</string>
<string name="credential_hint_trae">粘贴 Trae 登录态中的 Cloud-IDE-JWT(ey… 开头)。请求头按 Authorization: Cloud-IDE-JWT 发送。</string>
<string name="field_trae_jwt">Cloud-IDE-JWT</string>
<string name="field_trae_region">区域</string>
<string name="option_region_cn">CN</string>
<string name="option_region_intl">INTL</string>
<string name="credential_title_workbuddy">WorkBuddy 凭据</string>
<string name="credential_hint_workbuddy">粘贴腾讯 WorkBuddy(CodeBuddy 同体系)会话的 access &amp; refresh token。refresh token 每次使用会轮换。</string>
<string name="field_workbuddy_access">Access Token</string>
<string name="field_workbuddy_refresh">Refresh Token</string>
<string name="error_workbuddy_tokens_required">Access 与 Refresh Token 必填</string>
<string name="credential_title_sub2api">Sub2API 实例</string>
<string name="credential_hint_sub2api">该实例的任何用户:直接填分给你的 sk-… API Key 即可查余额(无需管理员)。没有 Key 也可用面板账号邮箱+密码登录。</string>
<string name="field_sub2_base_url">实例基址</string>
<string name="placeholder_sub2_base_url">https://api.example.com</string>
<string name="sub2_auth_header">认证方式</string>
<string name="field_sub2_api_key">API Key(sk-…)</string>
<string name="sub2_or_account">或面板账号</string>
<string name="field_sub2_email">邮箱</string>
<string name="field_sub2_password">密码</string>
<string name="error_sub2_required">实例基址与(API Key 或 邮箱+密码)必填</string>
<!-- ══════════ Sub2API 用量明细 ══════════ -->
<string name="sub2_usage_title">用量明细</string>
<string name="sub2_usage_subtitle">来自 Sub2API 网关的每日与分模型用量</string>
<string name="sub2_window_today">今天</string>
<string name="sub2_window_7d">近7天</string>
<string name="sub2_window_30d">近30天</string>
<string name="sub2_window_90d">近90天</string>
<string name="sub2_window_all">全部</string>
<string name="sub2_stat_requests">请求</string>
<string name="sub2_stat_tokens">Tokens</string>
<string name="sub2_stat_cost">花费</string>
<string name="sub2_daily_header">每日用量</string>
<string name="sub2_models_header">按模型</string>
<string name="sub2_model_all">全部模型</string>
<string name="sub2_model_meta">%1$s 次请求 · %2$s tokens</string>
<string name="sub2_no_daily">该时间范围内暂无每日用量数据</string>
<string name="sub2_no_cost">该时间范围内无花费</string>
<string name="sub2_model_sel_summary">模型:%1$s · 全时间累计</string>
<!-- ══════════ 时长标签(DurationText 注入用) ══════════ --> <!-- ══════════ 时长标签(DurationText 注入用) ══════════ -->
<string name="format_day">天</string> <string name="format_day">天</string>
@ -455,6 +527,7 @@
<string name="action_refresh_balance">刷新余额</string> <string name="action_refresh_balance">刷新余额</string>
<string name="action_relogin">重新登录</string> <string name="action_relogin">重新登录</string>
<string name="action_configure_credential">配置凭据</string> <string name="action_configure_credential">配置凭据</string>
<string name="action_recharge">充值</string>
<string name="main_card_current_balance">当前余额</string> <string name="main_card_current_balance">当前余额</string>
<string name="main_card_5h_usage">5h 实时用量</string> <string name="main_card_5h_usage">5h 实时用量</string>
<string name="main_card_monthly_balance">月度余额</string> <string name="main_card_monthly_balance">月度余额</string>

View File

@ -44,6 +44,31 @@
<string name="nav_empty_title">👈 選擇服務檢視詳情</string> <string name="nav_empty_title">👈 選擇服務檢視詳情</string>
<string name="nav_empty_subtitle">點選左側卡片即可</string> <string name="nav_empty_subtitle">點選左側卡片即可</string>
<string name="title_settings">設定</string> <string name="title_settings">設定</string>
<string name="api_mgmt_title">API 管理</string>
<string name="api_mgmt_configure">設定</string>
<string name="api_mgmt_edit">編輯</string>
<string name="api_mgmt_test">測試</string>
<string name="api_mgmt_delete_title">刪除憑證?</string>
<string name="api_mgmt_delete_body">將刪除 %1$s 已儲存的憑證。</string>
<string name="api_mgmt_unconfigured">未設定</string>
<string name="api_mgmt_last_verified">最後驗證:%1$s</string>
<string name="api_mgmt_cached_balance">快取餘額:%1$s %2$s</string>
<string name="usage_stat_title">用量統計</string>
<string name="usage_stat_empty">尚無已設定服務</string>
<string name="usage_stat_failed">%1$d 項刷新失敗</string>
<string name="usage_stat_never_fetched">尚未取得</string>
<string name="usage_stat_fetched_at">更新於 %1$s</string>
<string name="usage_stat_monthly_spent">本月消費</string>
<string name="usage_stat_total_quota">總額度</string>
<string name="usage_stat_next_reset">下次重置</string>
<string name="api_mgmt_test_success">✅ %1$s 連線成功:%2$s %3$s</string>
<string name="api_mgmt_test_fail">❌ %1$s 連線失敗:%2$s</string>
<string name="api_mgmt_err_invalid">憑證無效</string>
<string name="api_mgmt_err_changed">憑證已變更</string>
<string name="api_mgmt_err_ratelimited">請求過於頻繁</string>
<string name="api_mgmt_err_network">網路異常</string>
<string name="api_mgmt_err_server">伺服器異常(HTTP %1$d)</string>
<string name="api_mgmt_err_parse">解析失敗</string>
<string name="title_credentials">憑據管理</string> <string name="title_credentials">憑據管理</string>
<string name="title_about">關於 RainyToken</string> <string name="title_about">關於 RainyToken</string>
<string name="title_tips">使用小技巧</string> <string name="title_tips">使用小技巧</string>
@ -77,6 +102,9 @@
<!-- ══════════ 仪表盘 ══════════ --> <!-- ══════════ 仪表盘 ══════════ -->
<string name="dashboard_subtitle">AI 餘額一覽</string> <string name="dashboard_subtitle">AI 餘額一覽</string>
<string name="dashboard_empty_title">尚未設定任何服務</string>
<string name="dashboard_empty_subtitle">首頁僅顯示已設定憑證的服務</string>
<string name="dashboard_empty_go_settings">前往設定新增</string>
<string name="footer_never_fetched">從未獲取</string> <string name="footer_never_fetched">從未獲取</string>
<string name="footer_just_updated">剛剛更新</string> <string name="footer_just_updated">剛剛更新</string>
<string name="footer_minutes_ago">%1$d 分鐘前更新</string> <string name="footer_minutes_ago">%1$d 分鐘前更新</string>
@ -108,6 +136,50 @@
<string name="service_desc_commandcode_go">JSON API · $</string> <string name="service_desc_commandcode_go">JSON API · $</string>
<string name="service_desc_codex">ChatGPT · Codex 額度</string> <string name="service_desc_codex">ChatGPT · Codex 額度</string>
<string name="service_desc_ollama">Cookie 抓取 · Cloud 配額</string> <string name="service_desc_ollama">Cookie 抓取 · Cloud 配額</string>
<string name="service_desc_trae">Cloud-IDE-JWT · IDE 額度</string>
<string name="service_desc_workbuddy">OAuth · CodeBuddy 同體系積分</string>
<string name="service_desc_sub2api">自托管面板 · 用戶級 sk- Key 餘額</string>
<string name="detail_extras_title">明細</string>
<string name="credential_title_trae">Trae 憑證</string>
<string name="credential_hint_trae">貼上 Trae 登入態中的 Cloud-IDE-JWT(ey… 開頭)。請求頭以 Authorization: Cloud-IDE-JWT 送出。</string>
<string name="field_trae_jwt">Cloud-IDE-JWT</string>
<string name="field_trae_region">區域</string>
<string name="option_region_cn">CN</string>
<string name="option_region_intl">INTL</string>
<string name="credential_title_workbuddy">WorkBuddy 憑證</string>
<string name="credential_hint_workbuddy">貼上騰訊 WorkBuddy(CodeBuddy 同體系)工作階段的 access &amp; refresh token。refresh token 每次使用會輪換。</string>
<string name="field_workbuddy_access">Access Token</string>
<string name="field_workbuddy_refresh">Refresh Token</string>
<string name="error_workbuddy_tokens_required">Access 與 Refresh Token 必填</string>
<string name="credential_title_sub2api">Sub2API 實例</string>
<string name="credential_hint_sub2api">該實例的任何使用者:直接填分配給你的 sk-… API Key 即可查餘額(無需管理員)。沒有 Key 也可用面板帳號信箱+密碼登入。</string>
<string name="field_sub2_base_url">實例基址</string>
<string name="placeholder_sub2_base_url">https://api.example.com</string>
<string name="sub2_auth_header">認證方式</string>
<string name="field_sub2_api_key">API Key(sk-…)</string>
<string name="sub2_or_account">或面板帳號</string>
<string name="field_sub2_email">信箱</string>
<string name="field_sub2_password">密碼</string>
<string name="error_sub2_required">實例基址與(API Key 或 信箱+密碼)必填</string>
<!-- ══════════ Sub2API 用量明細 ══════════ -->
<string name="sub2_usage_title">用量明細</string>
<string name="sub2_usage_subtitle">來自 Sub2API 閘道的每日與分模型用量</string>
<string name="sub2_window_today">今天</string>
<string name="sub2_window_7d">近7天</string>
<string name="sub2_window_30d">近30天</string>
<string name="sub2_window_90d">近90天</string>
<string name="sub2_window_all">全部</string>
<string name="sub2_stat_requests">請求</string>
<string name="sub2_stat_tokens">Tokens</string>
<string name="sub2_stat_cost">花費</string>
<string name="sub2_daily_header">每日用量</string>
<string name="sub2_models_header">按模型</string>
<string name="sub2_model_all">全部模型</string>
<string name="sub2_model_meta">%1$s 次請求 · %2$s tokens</string>
<string name="sub2_no_daily">該時間範圍內暫無每日用量數據</string>
<string name="sub2_no_cost">該時間範圍內無花費</string>
<string name="sub2_model_sel_summary">模型:%1$s · 全時間累計</string>
<!-- ══════════ 时长标签(DurationText 注入用) ══════════ --> <!-- ══════════ 时长标签(DurationText 注入用) ══════════ -->
<string name="format_day">天</string> <string name="format_day">天</string>
@ -455,6 +527,7 @@
<string name="action_refresh_balance">重新整理餘額</string> <string name="action_refresh_balance">重新整理餘額</string>
<string name="action_relogin">重新登入</string> <string name="action_relogin">重新登入</string>
<string name="action_configure_credential">配置憑據</string> <string name="action_configure_credential">配置憑據</string>
<string name="action_recharge">儲值</string>
<string name="main_card_current_balance">當前餘額</string> <string name="main_card_current_balance">當前餘額</string>
<string name="main_card_5h_usage">5h 實時用量</string> <string name="main_card_5h_usage">5h 實時用量</string>
<string name="main_card_monthly_balance">月度餘額</string> <string name="main_card_monthly_balance">月度餘額</string>

View File

@ -48,6 +48,31 @@
<string name="nav_empty_title">👈 Select a service to view details</string> <string name="nav_empty_title">👈 Select a service to view details</string>
<string name="nav_empty_subtitle">Tap a card on the left</string> <string name="nav_empty_subtitle">Tap a card on the left</string>
<string name="title_settings">Settings</string> <string name="title_settings">Settings</string>
<string name="api_mgmt_title">API Management</string>
<string name="api_mgmt_configure">Configure</string>
<string name="api_mgmt_edit">Edit</string>
<string name="api_mgmt_test">Test</string>
<string name="api_mgmt_delete_title">Delete credential?</string>
<string name="api_mgmt_delete_body">This will remove the saved credential for %1$s.</string>
<string name="api_mgmt_unconfigured">Not configured</string>
<string name="api_mgmt_last_verified">Last verified: %1$s</string>
<string name="api_mgmt_cached_balance">Cached: %1$s %2$s</string>
<string name="usage_stat_title">Usage Stats</string>
<string name="usage_stat_empty">No configured services yet</string>
<string name="usage_stat_failed">%1$d refresh failed</string>
<string name="usage_stat_never_fetched">Not fetched yet</string>
<string name="usage_stat_fetched_at">Updated %1$s</string>
<string name="usage_stat_monthly_spent">Monthly spent</string>
<string name="usage_stat_total_quota">Total quota</string>
<string name="usage_stat_next_reset">Next reset</string>
<string name="api_mgmt_test_success">✅ %1$s connected: %2$s %3$s</string>
<string name="api_mgmt_test_fail">❌ %1$s failed: %2$s</string>
<string name="api_mgmt_err_invalid">Invalid credential</string>
<string name="api_mgmt_err_changed">Credential changed</string>
<string name="api_mgmt_err_ratelimited">Too many requests</string>
<string name="api_mgmt_err_network">Network error</string>
<string name="api_mgmt_err_server">Server error (HTTP %1$d)</string>
<string name="api_mgmt_err_parse">Parse failed</string>
<string name="title_credentials">Credentials</string> <string name="title_credentials">Credentials</string>
<string name="title_about">About RainyToken</string> <string name="title_about">About RainyToken</string>
<string name="title_tips">Tips</string> <string name="title_tips">Tips</string>
@ -81,6 +106,9 @@
<!-- ══════════ 仪表盘 ══════════ --> <!-- ══════════ 仪表盘 ══════════ -->
<string name="dashboard_subtitle">AI Balance Overview</string> <string name="dashboard_subtitle">AI Balance Overview</string>
<string name="dashboard_empty_title">No services configured yet</string>
<string name="dashboard_empty_subtitle">Only services with credentials are shown on the home page.</string>
<string name="dashboard_empty_go_settings">Go to Settings</string>
<string name="footer_never_fetched">Never fetched</string> <string name="footer_never_fetched">Never fetched</string>
<string name="footer_just_updated">Updated just now</string> <string name="footer_just_updated">Updated just now</string>
<string name="footer_minutes_ago">Updated %1$d min ago</string> <string name="footer_minutes_ago">Updated %1$d min ago</string>
@ -111,6 +139,50 @@
<string name="service_desc_commandcode_go">JSON API · $</string> <string name="service_desc_commandcode_go">JSON API · $</string>
<string name="service_desc_codex">ChatGPT · Codex quota</string> <string name="service_desc_codex">ChatGPT · Codex quota</string>
<string name="service_desc_ollama">Cookie fetch · Cloud quota</string> <string name="service_desc_ollama">Cookie fetch · Cloud quota</string>
<string name="service_desc_trae">Cloud-IDE-JWT · IDE 额度</string>
<string name="service_desc_workbuddy">OAuth · CodeBuddy 同体系积分</string>
<string name="service_desc_sub2api">自托管面板 · 用户级 sk- Key 余额</string>
<string name="detail_extras_title">Breakdown</string>
<string name="credential_title_trae">Trae credentials</string>
<string name="credential_hint_trae">Paste the Cloud-IDE-JWT from your Trae account session (starts with ey…). Used as Authorization: Cloud-IDE-JWT header.</string>
<string name="field_trae_jwt">Cloud-IDE-JWT</string>
<string name="field_trae_region">Region</string>
<string name="option_region_cn">CN</string>
<string name="option_region_intl">INTL</string>
<string name="credential_title_workbuddy">WorkBuddy credentials</string>
<string name="credential_hint_workbuddy">Paste the access &amp; refresh tokens from your Tencent WorkBuddy (CodeBuddy) session. Refresh token is rotated once per use.</string>
<string name="field_workbuddy_access">Access Token</string>
<string name="field_workbuddy_refresh">Refresh Token</string>
<string name="error_workbuddy_tokens_required">Access and refresh tokens are required</string>
<string name="credential_title_sub2api">Sub2API instance</string>
<string name="credential_hint_sub2api">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 &amp; password.</string>
<string name="field_sub2_base_url">Instance base URL</string>
<string name="placeholder_sub2_base_url">https://api.example.com</string>
<string name="sub2_auth_header">Authentication</string>
<string name="field_sub2_api_key">API Key (sk-…)</string>
<string name="sub2_or_account">Or panel account</string>
<string name="field_sub2_email">Email</string>
<string name="field_sub2_password">Password</string>
<string name="error_sub2_required">Base URL and (API Key or email+password) are required</string>
<!-- ══════════ Sub2API 用量明细 ══════════ -->
<string name="sub2_usage_title">Usage breakdown</string>
<string name="sub2_usage_subtitle">Daily and per-model usage from your Sub2API gateway</string>
<string name="sub2_window_today">Today</string>
<string name="sub2_window_7d">7 days</string>
<string name="sub2_window_30d">30 days</string>
<string name="sub2_window_90d">90 days</string>
<string name="sub2_window_all">All</string>
<string name="sub2_stat_requests">Requests</string>
<string name="sub2_stat_tokens">Tokens</string>
<string name="sub2_stat_cost">Cost</string>
<string name="sub2_daily_header">Daily usage</string>
<string name="sub2_models_header">By model</string>
<string name="sub2_model_all">All models</string>
<string name="sub2_model_meta">%1$s requests · %2$s tokens</string>
<string name="sub2_no_daily">No daily usage data in this range</string>
<string name="sub2_no_cost">No spend in this range</string>
<string name="sub2_model_sel_summary">Model: %1$s · all-time total</string>
<!-- ══════════ 时长标签(DurationText 注入用) ══════════ --> <!-- ══════════ 时长标签(DurationText 注入用) ══════════ -->
<string name="format_day">d</string> <string name="format_day">d</string>
@ -458,6 +530,7 @@
<string name="action_refresh_balance">Refresh balance</string> <string name="action_refresh_balance">Refresh balance</string>
<string name="action_relogin">Log in again</string> <string name="action_relogin">Log in again</string>
<string name="action_configure_credential">Configure credential</string> <string name="action_configure_credential">Configure credential</string>
<string name="action_recharge">Top up / Recharge</string>
<string name="main_card_current_balance">Current balance</string> <string name="main_card_current_balance">Current balance</string>
<string name="main_card_5h_usage">5h live usage</string> <string name="main_card_5h_usage">5h live usage</string>
<string name="main_card_monthly_balance">Monthly balance</string> <string name="main_card_monthly_balance">Monthly balance</string>

View File

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