feat: API 管理 / 用量统计 / 充值 WebView / 首页仅显示已配置;Sub2API 用量明细卡(模型筛选+时间窗口+柱状图)
This commit is contained in:
parent
e4cd9a36f6
commit
fd3d74cfef
@ -22,5 +22,7 @@ data class ServiceBalance(
|
||||
/** 配额周期下次重置时间(订阅型服务,epoch millis) */
|
||||
val nextResetAt: Long? = null,
|
||||
/** 服务特定的附加信息(如 Zen 充值 $20 起,Go 5h 配额) */
|
||||
val extras: Map<String, String> = emptyMap()
|
||||
val extras: Map<String, String> = emptyMap(),
|
||||
/** Sub2API 专有:网关 /v1/usage 返回的结构化用量明细(模型统计 + 每日明细)。其他服务为 null。 */
|
||||
val sub2UsageDetail: Sub2UsageDetail? = null
|
||||
)
|
||||
@ -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
|
||||
)
|
||||
@ -18,12 +18,14 @@ import androidx.compose.material3.VerticalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
@ -36,6 +38,7 @@ import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.rainy.token.R
|
||||
import com.rainy.token.data.repository.CommandCodeUsageRepository
|
||||
import com.rainy.token.domain.service.ServiceConfigProvider
|
||||
import com.rainy.token.domain.service.ServiceType
|
||||
import com.rainy.token.ui.components.rememberWindowSizeClass
|
||||
import com.rainy.token.ui.components.DebugLogScreen
|
||||
@ -47,8 +50,10 @@ import com.rainy.token.ui.dashboard.UsageDataScreen
|
||||
import com.rainy.token.ui.dashboard.UsageDataViewModel
|
||||
import com.rainy.token.ui.dashboard.UsageDetailScreen
|
||||
import com.rainy.token.ui.dashboard.UsageOverviewScreen
|
||||
import com.rainy.token.ui.dashboard.UsageStatScreen
|
||||
import com.rainy.token.ui.dashboard.UsageViewModel
|
||||
import com.rainy.token.ui.heatmap.HeatmapScreen
|
||||
import com.rainy.token.ui.account.ApiManagementScreen
|
||||
import com.rainy.token.ui.servicedetail.ServiceDetailScreen
|
||||
import com.rainy.token.ui.settings.CredentialEditScreen
|
||||
import com.rainy.token.ui.settings.SettingsScreen
|
||||
@ -56,6 +61,8 @@ import com.rainy.token.ui.theme.inkMuted
|
||||
import com.rainy.token.ui.theme.StrawberryPink
|
||||
import com.rainy.token.ui.webview.CodexOAuthScreen
|
||||
import com.rainy.token.ui.webview.WebViewLoginScreen
|
||||
import com.rainy.token.ui.webview.WebViewPageScreen
|
||||
import android.net.Uri
|
||||
|
||||
/**
|
||||
* 应用导航图。
|
||||
@ -69,6 +76,8 @@ import com.rainy.token.ui.webview.WebViewLoginScreen
|
||||
object Routes {
|
||||
const val DASHBOARD = "dashboard"
|
||||
const val SETTINGS = "settings"
|
||||
const val API_MANAGEMENT = "api_management"
|
||||
const val USAGE_STAT = "usage_stat"
|
||||
const val TIPS = "tips"
|
||||
const val DEBUG_LOG = "debug_log"
|
||||
const val CODEX_OAUTH = "codex_oauth"
|
||||
@ -76,6 +85,9 @@ object Routes {
|
||||
fun credentialEdit(type: ServiceType) = "credential_edit/${type.name}"
|
||||
const val WEBVIEW_LOGIN = "webview_login/{type}"
|
||||
fun webviewLogin(type: ServiceType) = "webview_login/${type.name}"
|
||||
const val WEBVIEW_PAGE = "webview_page?url={url}&title={title}"
|
||||
fun webviewPage(url: String, title: String) =
|
||||
"webview_page?url=${Uri.encode(url)}&title=${Uri.encode(title)}"
|
||||
const val SERVICE_DETAIL = "service/{type}"
|
||||
fun serviceDetail(type: ServiceType) = "service/${type.name}"
|
||||
const val USAGE_DETAIL = "usage_detail"
|
||||
@ -98,6 +110,8 @@ private sealed class DetailPane {
|
||||
object OCGOUsage : DetailPane()
|
||||
object CCGOUsage : DetailPane()
|
||||
object Settings : DetailPane()
|
||||
object ApiManagement : DetailPane()
|
||||
object UsageStat : DetailPane()
|
||||
object Heatmap : DetailPane()
|
||||
}
|
||||
|
||||
@ -192,12 +206,29 @@ private fun CompactNavHost() {
|
||||
composable(Routes.DASHBOARD) {
|
||||
DashboardScreen(
|
||||
onOpenSettings = { navController.navigate(Routes.SETTINGS) },
|
||||
onOpenApiManagement = { navController.navigate(Routes.API_MANAGEMENT) },
|
||||
onOpenUsageStat = { navController.navigate(Routes.USAGE_STAT) },
|
||||
onOpenService = { type -> navController.navigate(Routes.serviceDetail(type)) },
|
||||
onOpenUsageDetail = { navController.navigate(Routes.USAGE_DETAIL) },
|
||||
onOpenCcgoUsageDetail = { navController.navigate(Routes.CCGO_USAGE_DETAIL) },
|
||||
onOpenHeatmap = { navController.navigate(Routes.HEATMAP) }
|
||||
)
|
||||
}
|
||||
composable(Routes.API_MANAGEMENT) {
|
||||
val rechargeLabel = stringResource(R.string.action_recharge)
|
||||
ApiManagementScreen(
|
||||
onBack = guardedPop,
|
||||
onConfigure = { type -> navController.navigate(Routes.credentialEdit(type)) },
|
||||
onRecharge = { type ->
|
||||
ServiceConfigProvider.get(type).externalUrl?.let { url ->
|
||||
navController.navigate(Routes.webviewPage(url, rechargeLabel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
composable(Routes.USAGE_STAT) {
|
||||
UsageStatScreen(onBack = guardedPop)
|
||||
}
|
||||
composable(Routes.USAGE_DETAIL) {
|
||||
UsageDetailScreen(
|
||||
onBack = guardedPop,
|
||||
@ -300,13 +331,30 @@ private fun CompactNavHost() {
|
||||
arguments = listOf(navArgument("type") { type = NavType.StringType })
|
||||
) { backStackEntry ->
|
||||
val type = parseServiceType(backStackEntry.arguments?.getString("type"))
|
||||
val rechargeLabel = stringResource(R.string.action_recharge)
|
||||
ServiceDetailScreen(
|
||||
service = type,
|
||||
onBack = guardedPop,
|
||||
onConfigureCredential = { svc -> navController.navigate(Routes.credentialEdit(svc)) },
|
||||
onStartWebViewLogin = { svc -> navController.navigate(Routes.webviewLogin(svc)) }
|
||||
onStartWebViewLogin = { svc -> navController.navigate(Routes.webviewLogin(svc)) },
|
||||
onOpenRecharge = { svc ->
|
||||
ServiceConfigProvider.get(svc).externalUrl?.let { url ->
|
||||
navController.navigate(Routes.webviewPage(url, rechargeLabel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Routes.WEBVIEW_PAGE,
|
||||
arguments = listOf(
|
||||
navArgument("url") { type = NavType.StringType },
|
||||
navArgument("title") { type = NavType.StringType }
|
||||
)
|
||||
) { backStackEntry ->
|
||||
val url = backStackEntry.arguments?.getString("url").orEmpty()
|
||||
val title = backStackEntry.arguments?.getString("title").orEmpty()
|
||||
WebViewPageScreen(url = url, title = title, onBack = guardedPop)
|
||||
}
|
||||
composable(Routes.HEATMAP) {
|
||||
HeatmapScreen(onBack = guardedPop)
|
||||
}
|
||||
@ -340,6 +388,8 @@ private fun ExpandedLayout() {
|
||||
) {
|
||||
DashboardScreen(
|
||||
onOpenSettings = { detailPane = DetailPane.Settings },
|
||||
onOpenApiManagement = { detailPane = DetailPane.ApiManagement },
|
||||
onOpenUsageStat = { detailPane = DetailPane.UsageStat },
|
||||
onOpenService = { type -> detailPane = DetailPane.ServiceDetail(type) },
|
||||
onOpenUsageDetail = { detailPane = DetailPane.OCGOUsage },
|
||||
onOpenCcgoUsageDetail = { detailPane = DetailPane.CCGOUsage },
|
||||
@ -384,12 +434,43 @@ private fun ExpandedDetailPane(
|
||||
}
|
||||
}
|
||||
is DetailPane.ServiceDetail -> {
|
||||
ServiceDetailScreen(
|
||||
service = pane.type,
|
||||
onBack = onClose,
|
||||
onConfigureCredential = { onClose() },
|
||||
onStartWebViewLogin = { }
|
||||
)
|
||||
// key(pane.type):切换服务时重建嵌套导航栈,避免停留在上一个服务的充值页
|
||||
key(pane.type) {
|
||||
val navController = rememberNavController()
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = "detail",
|
||||
popExitTransition = { ExitTransition.None }
|
||||
) {
|
||||
composable("detail") {
|
||||
val rechargeLabel = LocalContext.current.getString(R.string.action_recharge)
|
||||
ServiceDetailScreen(
|
||||
service = pane.type,
|
||||
onBack = onClose,
|
||||
onConfigureCredential = { onClose() },
|
||||
onStartWebViewLogin = { },
|
||||
onOpenRecharge = { svc ->
|
||||
ServiceConfigProvider.get(svc).externalUrl?.let { url ->
|
||||
navController.navigate(Routes.webviewPage(url, rechargeLabel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Routes.WEBVIEW_PAGE,
|
||||
arguments = listOf(
|
||||
navArgument("url") { type = NavType.StringType },
|
||||
navArgument("title") { type = NavType.StringType }
|
||||
)
|
||||
) { entry ->
|
||||
WebViewPageScreen(
|
||||
url = entry.arguments?.getString("url").orEmpty(),
|
||||
title = entry.arguments?.getString("title").orEmpty(),
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
is DetailPane.OCGOUsage -> {
|
||||
val navController = rememberNavController()
|
||||
@ -453,6 +534,85 @@ private fun ExpandedDetailPane(
|
||||
is DetailPane.Heatmap -> {
|
||||
HeatmapScreen(onBack = onClose)
|
||||
}
|
||||
is DetailPane.UsageStat -> {
|
||||
UsageStatScreen(onBack = onClose)
|
||||
}
|
||||
is DetailPane.ApiManagement -> {
|
||||
val apiNavController = rememberNavController()
|
||||
// 从凭据编辑/WebView 返回到 api_mgmt_main 时刷新 Dashboard 凭据状态
|
||||
val routeStack by apiNavController.currentBackStackEntryAsState()
|
||||
val currentRoute = routeStack?.destination?.route
|
||||
var prevRoute by remember { mutableStateOf("api_mgmt_main") }
|
||||
LaunchedEffect(currentRoute) {
|
||||
if (prevRoute != currentRoute && currentRoute == "api_mgmt_main") {
|
||||
onCredentialEditClosed()
|
||||
}
|
||||
prevRoute = currentRoute ?: "api_mgmt_main"
|
||||
}
|
||||
NavHost(
|
||||
navController = apiNavController,
|
||||
startDestination = "api_mgmt_main",
|
||||
popExitTransition = { ExitTransition.None }
|
||||
) {
|
||||
composable("api_mgmt_main") {
|
||||
val rechargeLabel = LocalContext.current.getString(R.string.action_recharge)
|
||||
ApiManagementScreen(
|
||||
onBack = onClose,
|
||||
onConfigure = { type -> apiNavController.navigate(Routes.credentialEdit(type)) },
|
||||
onRecharge = { type ->
|
||||
ServiceConfigProvider.get(type).externalUrl?.let { url ->
|
||||
apiNavController.navigate(Routes.webviewPage(url, rechargeLabel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Routes.CREDENTIAL_EDIT,
|
||||
arguments = listOf(navArgument("type") { type = NavType.StringType })
|
||||
) { entry ->
|
||||
val type = parseServiceType(entry.arguments?.getString("type"))
|
||||
CredentialEditScreen(
|
||||
service = type,
|
||||
onBack = { apiNavController.popBackStack() },
|
||||
onStartWebViewLogin = { svc ->
|
||||
apiNavController.navigate(Routes.webviewLogin(svc))
|
||||
},
|
||||
onWebViewLoginSuccess = { },
|
||||
onStartCodexOAuth = { apiNavController.navigate(Routes.CODEX_OAUTH) }
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Routes.WEBVIEW_LOGIN,
|
||||
arguments = listOf(navArgument("type") { type = NavType.StringType })
|
||||
) { entry ->
|
||||
val type = parseServiceType(entry.arguments?.getString("type"))
|
||||
WebViewLoginScreen(
|
||||
service = type,
|
||||
onBack = { apiNavController.popBackStack() },
|
||||
onLoginSucceeded = { apiNavController.popBackStack() }
|
||||
)
|
||||
}
|
||||
composable(Routes.CODEX_OAUTH) {
|
||||
CodexOAuthScreen(
|
||||
onBack = { apiNavController.popBackStack() },
|
||||
onSuccess = { apiNavController.popBackStack() }
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Routes.WEBVIEW_PAGE,
|
||||
arguments = listOf(
|
||||
navArgument("url") { type = NavType.StringType },
|
||||
navArgument("title") { type = NavType.StringType }
|
||||
)
|
||||
) { entry ->
|
||||
WebViewPageScreen(
|
||||
url = entry.arguments?.getString("url").orEmpty(),
|
||||
title = entry.arguments?.getString("title").orEmpty(),
|
||||
onBack = { apiNavController.popBackStack() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
is DetailPane.Settings -> {
|
||||
val settingsNavController = rememberNavController()
|
||||
// 监听嵌套 NavHost 路由变化:当从凭据编辑页/WebView 返回 settings_main 时
|
||||
|
||||
@ -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("—")
|
||||
}
|
||||
@ -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()
|
||||
)
|
||||
@ -31,6 +31,7 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.DateRange
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.AlertDialog
|
||||
@ -43,6 +44,7 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
@ -121,6 +123,8 @@ import java.util.Locale
|
||||
@Composable
|
||||
fun DashboardScreen(
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenApiManagement: () -> Unit,
|
||||
onOpenUsageStat: () -> Unit,
|
||||
onOpenService: (ServiceType) -> Unit,
|
||||
onOpenUsageDetail: () -> Unit,
|
||||
onOpenCcgoUsageDetail: () -> Unit = {},
|
||||
@ -214,6 +218,13 @@ fun DashboardScreen(
|
||||
tint = StrawberryPink
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onOpenUsageStat) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.DateRange,
|
||||
contentDescription = stringResource(R.string.usage_stat_title),
|
||||
tint = StrawberryPink
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = { viewModel.refresh() },
|
||||
enabled = !uiState.refreshing
|
||||
@ -224,6 +235,13 @@ fun DashboardScreen(
|
||||
tint = StrawberryPink
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onOpenApiManagement) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Lock,
|
||||
contentDescription = stringResource(R.string.api_mgmt_title),
|
||||
tint = StrawberryPink
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onOpenSettings) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Settings,
|
||||
@ -245,7 +263,8 @@ fun DashboardScreen(
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
) {
|
||||
if (uiState.cards.isEmpty()) {
|
||||
// 仅初始加载时显示转圈;加载完即使没有已配置服务也渲染(展示用量卡片 + 空态引导)
|
||||
if (uiState.loading) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
@ -280,6 +299,7 @@ fun DashboardScreen(
|
||||
val items = rememberDashboardItems(
|
||||
cards = uiState.cards,
|
||||
order = cardOrder,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenUsageDetail = onOpenUsageDetail,
|
||||
onOpenCcgoUsageDetail = onOpenCcgoUsageDetail,
|
||||
onOpenService = onOpenService,
|
||||
@ -361,6 +381,7 @@ private data class DashboardHomeItem(
|
||||
private fun rememberDashboardItems(
|
||||
cards: List<DashboardCardUi>,
|
||||
order: List<String>,
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenUsageDetail: () -> Unit,
|
||||
onOpenCcgoUsageDetail: () -> Unit,
|
||||
onOpenService: (ServiceType) -> Unit,
|
||||
@ -368,6 +389,12 @@ private fun rememberDashboardItems(
|
||||
refreshTrigger: Int
|
||||
): List<DashboardHomeItem> {
|
||||
val defaultItems = buildList {
|
||||
// 未配置任何服务时的引导卡片(首页仅显示已配置的服务)
|
||||
if (cards.isEmpty()) {
|
||||
add(DashboardHomeItem("empty_hint") {
|
||||
EmptyServicesHintCard(onOpenSettings = onOpenSettings)
|
||||
})
|
||||
}
|
||||
add(DashboardHomeItem(USAGE_OCGO_CARD_ID) {
|
||||
UsageStatsCard(onOpenDetail = onOpenUsageDetail, onOpenHeatmap = onOpenHeatmap, refreshTrigger = refreshTrigger)
|
||||
})
|
||||
@ -776,6 +803,42 @@ private fun HeatmapEntryCard(onOpenHeatmap: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 空态引导卡:首页仅显示已配置的服务,当一个都没配置时给出引导。
|
||||
*/
|
||||
@Composable
|
||||
private fun EmptyServicesHintCard(onOpenSettings: () -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.dashboard_empty_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.dashboard_empty_subtitle),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = inkMuted()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
OutlinedButton(onClick = onOpenSettings) {
|
||||
Text(stringResource(R.string.dashboard_empty_go_settings))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 仪表盘底部 footer:填空白 + 显示版本号。
|
||||
* 容器透明无边框,让卡片列表与背景融合自然。
|
||||
|
||||
@ -41,7 +41,7 @@ class DashboardViewModel @Inject constructor(
|
||||
@param:ApplicationContext private val appContext: Context
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(DashboardUiState())
|
||||
private val _uiState = MutableStateFlow(DashboardUiState(loading = true))
|
||||
val uiState: StateFlow<DashboardUiState> = _uiState.asStateFlow()
|
||||
|
||||
/** 防止并发 refresh() 调用交错覆盖 UI。 */
|
||||
@ -60,37 +60,53 @@ class DashboardViewModel @Inject constructor(
|
||||
* 重新读取本地凭据状态 + 余额缓存,不发起网络请求。
|
||||
*
|
||||
* 卡片记录不含明文密钥的 SHA-256 指纹。更新时始终基于 _uiState 的最新卡片:
|
||||
* - 凭据变化时采用已清理过的当前账户缓存;
|
||||
* - 凭据未变时按 fetchedAt 采用更新缓存,支持 Widget 后台刷新结果回显;
|
||||
* - 以当前已配置服务(configuredTypes)为准重建,新增服务即时出现、删除服务即时消失;
|
||||
* - 仍存在的服务复用旧卡片做增量合并:
|
||||
* - 凭据变化时采用已清理过的当前账户缓存;
|
||||
* - 凭据未变时按 fetchedAt 采用更新缓存,支持 Widget 后台刷新结果回显;
|
||||
* - 不修改 refreshing,旧缓存快照也不能回滚新数据。
|
||||
*/
|
||||
fun reloadLocalState() {
|
||||
viewModelScope.launch {
|
||||
val localStates = credentialRepository.readLocalStates()
|
||||
val configured = configuredTypes(localStates).toSet()
|
||||
_uiState.update { state ->
|
||||
val previousByService = state.cards.associateBy { it.service }
|
||||
// 保留用户既有顺序;新配置的服务追加到尾部(按枚举顺序)
|
||||
val existingOrder = state.cards
|
||||
.mapIndexed { index, card -> card.service to index }
|
||||
.toMap()
|
||||
val orderedTypes = configured.sortedWith(
|
||||
compareBy({ existingOrder[it] ?: Int.MAX_VALUE }, { it.ordinal })
|
||||
)
|
||||
state.copy(
|
||||
cards = state.cards.map { card ->
|
||||
val local = localStates.getValue(card.service)
|
||||
val credentialChanged = local.fingerprint != card.credentialFingerprint
|
||||
val cacheAdvanced = !credentialChanged && isNewer(
|
||||
candidate = local.cachedBalance,
|
||||
current = card.cachedBalance
|
||||
)
|
||||
card.copy(
|
||||
credentialState = local.status.state,
|
||||
credentialFingerprint = local.fingerprint,
|
||||
cachedBalance = if (credentialChanged) {
|
||||
local.cachedBalance
|
||||
} else {
|
||||
newerOf(card.cachedBalance, local.cachedBalance)
|
||||
},
|
||||
// 凭据变化或出现更新的成功缓存时,旧错误已不再代表当前数据。
|
||||
lastFetchError = if (credentialChanged || cacheAdvanced) {
|
||||
null
|
||||
} else {
|
||||
card.lastFetchError
|
||||
}
|
||||
)
|
||||
cards = orderedTypes.map { type ->
|
||||
val local = localStates.getValue(type)
|
||||
val card = previousByService[type]
|
||||
if (card == null) {
|
||||
buildCard(local, lastFetchError = null)
|
||||
} else {
|
||||
val credentialChanged = local.fingerprint != card.credentialFingerprint
|
||||
val cacheAdvanced = !credentialChanged && isNewer(
|
||||
candidate = local.cachedBalance,
|
||||
current = card.cachedBalance
|
||||
)
|
||||
card.copy(
|
||||
credentialState = local.status.state,
|
||||
credentialFingerprint = local.fingerprint,
|
||||
cachedBalance = if (credentialChanged) {
|
||||
local.cachedBalance
|
||||
} else {
|
||||
newerOf(card.cachedBalance, local.cachedBalance)
|
||||
},
|
||||
// 凭据变化或出现更新的成功缓存时,旧错误已不再代表当前数据。
|
||||
lastFetchError = if (credentialChanged || cacheAdvanced) {
|
||||
null
|
||||
} else {
|
||||
card.lastFetchError
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@ -100,7 +116,8 @@ class DashboardViewModel @Inject constructor(
|
||||
/** 从本地缓存快速填充一次(不阻塞)。挂起函数,供调用方控制执行顺序。 */
|
||||
private suspend fun loadFromCache() {
|
||||
val localStates = credentialRepository.readLocalStates()
|
||||
val cards = ServiceType.entries.map { type ->
|
||||
// 首页仅显示已配置凭据的服务(有凭据就显示,未配置不占位)
|
||||
val cards = configuredTypes(localStates).map { type ->
|
||||
buildCard(localStates.getValue(type), lastFetchError = null)
|
||||
}
|
||||
_uiState.update { it.copy(loading = false, refreshing = false, cards = cards) }
|
||||
@ -126,7 +143,7 @@ class DashboardViewModel @Inject constructor(
|
||||
}.awaitAll().toMap()
|
||||
}
|
||||
val localStates = credentialRepository.readLocalStates()
|
||||
val cards = ServiceType.entries.map { type ->
|
||||
val cards = configuredTypes(localStates).map { type ->
|
||||
val result = results[type]
|
||||
val error = result?.exceptionOrNull()
|
||||
val errorUi = error
|
||||
@ -145,6 +162,13 @@ class DashboardViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** 已配置凭据(state != NOT_CONFIGURED)的服务,首页只显示这些。 */
|
||||
private fun configuredTypes(
|
||||
localStates: Map<ServiceType, CredentialRepository.LocalState>
|
||||
): List<ServiceType> = ServiceType.entries.filter { type ->
|
||||
localStates.getValue(type).status.state != CredentialStatus.State.NOT_CONFIGURED
|
||||
}
|
||||
|
||||
private fun buildCard(
|
||||
local: CredentialRepository.LocalState,
|
||||
lastFetchError: UiText?
|
||||
|
||||
@ -555,6 +555,9 @@ internal fun secondaryLineRes(card: DashboardCardUi): Int = when (card.service)
|
||||
ServiceType.COMMANDCODE_GO -> R.string.service_desc_commandcode_go
|
||||
ServiceType.CODEX -> R.string.service_desc_codex
|
||||
ServiceType.OLLAMA -> R.string.service_desc_ollama
|
||||
ServiceType.TRAE -> R.string.service_desc_trae
|
||||
ServiceType.WORKBUDDY -> R.string.service_desc_workbuddy
|
||||
ServiceType.SUB2API -> R.string.service_desc_sub2api
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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("—")
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
@ -103,6 +103,7 @@ fun ServiceDetailScreen(
|
||||
onBack: () -> Unit,
|
||||
onConfigureCredential: (ServiceType) -> Unit,
|
||||
onStartWebViewLogin: (ServiceType) -> Unit,
|
||||
onOpenRecharge: (ServiceType) -> Unit = { },
|
||||
viewModel: ServiceDetailViewModel = hiltViewModel()
|
||||
) {
|
||||
LaunchedEffect(service) { viewModel.bind(service) }
|
||||
@ -183,6 +184,14 @@ fun ServiceDetailScreen(
|
||||
ServiceType.OLLAMA -> {
|
||||
item { OllamaUsageCard(uiState.state) }
|
||||
}
|
||||
ServiceType.TRAE,
|
||||
ServiceType.WORKBUDDY -> {
|
||||
item { GenericExtrasCard(uiState.state) }
|
||||
}
|
||||
ServiceType.SUB2API -> {
|
||||
// 新明细卡:模型筛选 + 时间窗口 + 柱状图;无结构化明细时内部回退 GenericExtrasCard
|
||||
item { Sub2ApiUsageCard(uiState.state) }
|
||||
}
|
||||
}
|
||||
|
||||
// 错误信息(如有)
|
||||
@ -212,6 +221,7 @@ fun ServiceDetailScreen(
|
||||
onRefresh = { viewModel.refresh() },
|
||||
onConfigureCredential = { onConfigureCredential(service) },
|
||||
onStartWebViewLogin = { onStartWebViewLogin(service) },
|
||||
onOpenRecharge = config.externalUrl?.let { { onOpenRecharge(service) } },
|
||||
onTriggerUsage = { viewModel.triggerUsage() },
|
||||
onSelectModel = { viewModel.selectModel(it) },
|
||||
onRefreshModels = { viewModel.loadModels(force = true) }
|
||||
@ -1056,6 +1066,55 @@ private fun BreakdownRow(label: String, value: Double, unit: String) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用明细卡:把 balance.extras 中的友好键逐行展示(Trae 权益包 / WorkBuddy 资源 /
|
||||
* Sub2API 账户等)。技术键(models / reset* / raw 等)一律过滤。
|
||||
*/
|
||||
@Composable
|
||||
internal fun GenericExtrasCard(state: State) {
|
||||
val balance = when (state) {
|
||||
is State.Fresh -> state.data
|
||||
is State.Stale -> state.data
|
||||
is State.Error -> state.cached
|
||||
else -> null
|
||||
}
|
||||
val extras = balance?.extras ?: return
|
||||
val friendly = extras.filterKeys { key ->
|
||||
val k = key.lowercase()
|
||||
!k.contains("models") &&
|
||||
!k.contains("reset") &&
|
||||
!k.contains("raw") &&
|
||||
!k.endsWith(".unlimited") &&
|
||||
!k.endsWith(".used") &&
|
||||
!k.endsWith(".limit")
|
||||
}
|
||||
if (friendly.isEmpty()) return
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(5.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.detail_extras_title),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
friendly.forEach { (key, value) ->
|
||||
Text(
|
||||
text = "$key: $value",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = inkMuted()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorCard(message: String) {
|
||||
Card(
|
||||
@ -1106,6 +1165,7 @@ private fun ActionButtons(
|
||||
onRefresh: () -> Unit,
|
||||
onConfigureCredential: () -> Unit,
|
||||
onStartWebViewLogin: () -> Unit,
|
||||
onOpenRecharge: (() -> Unit)? = null,
|
||||
onTriggerUsage: () -> Unit,
|
||||
onSelectModel: (String) -> Unit,
|
||||
onRefreshModels: () -> Unit
|
||||
@ -1250,6 +1310,16 @@ private fun ActionButtons(
|
||||
Text(stringResource(R.string.action_configure_credential))
|
||||
}
|
||||
}
|
||||
|
||||
// 外部操作入口(如 DeepSeek 充值页),App 内 WebView 打开
|
||||
if (onOpenRecharge != null) {
|
||||
OutlinedButton(
|
||||
onClick = onOpenRecharge,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(stringResource(R.string.action_recharge))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1260,6 +1330,9 @@ private fun mainCardLabel(service: ServiceType): String = when (service) {
|
||||
ServiceType.COMMANDCODE_GO -> stringResource(R.string.main_card_monthly_balance)
|
||||
ServiceType.CODEX -> stringResource(R.string.window_usage)
|
||||
ServiceType.OLLAMA -> stringResource(R.string.main_card_session_usage)
|
||||
ServiceType.TRAE,
|
||||
ServiceType.WORKBUDDY,
|
||||
ServiceType.SUB2API -> stringResource(R.string.main_card_current_balance)
|
||||
}
|
||||
|
||||
private fun stateToChip(state: State): StatusStyle = when (state) {
|
||||
|
||||
@ -419,6 +419,12 @@ class ServiceDetailViewModel @Inject constructor(
|
||||
credential.copy(lastVerifiedAt = System.currentTimeMillis())
|
||||
is Credential.CodexCredential ->
|
||||
credential.copy(lastVerifiedAt = System.currentTimeMillis())
|
||||
is Credential.TraeCredential ->
|
||||
credential.copy(lastVerifiedAt = System.currentTimeMillis())
|
||||
is Credential.WorkBuddyCredential ->
|
||||
credential.copy(lastVerifiedAt = System.currentTimeMillis())
|
||||
is Credential.Sub2ApiCredential ->
|
||||
credential.copy(lastVerifiedAt = System.currentTimeMillis())
|
||||
}
|
||||
credentialRepository.save(updated)
|
||||
loadFromCache()
|
||||
|
||||
@ -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()
|
||||
}
|
||||
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -240,6 +240,9 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
|
||||
}
|
||||
}
|
||||
ServiceType.DEEPSEEK -> setEmptyState(views, context)
|
||||
ServiceType.TRAE,
|
||||
ServiceType.WORKBUDDY,
|
||||
ServiceType.SUB2API -> setEmptyState(views, context)
|
||||
ServiceType.OLLAMA -> {
|
||||
setRowLabel(views, context.getString(R.string.window_5h_short), context.getString(R.string.window_every_week), "")
|
||||
populateRow(views, R.id.row1_pct, R.id.row1_bar, R.id.row1_reset,
|
||||
@ -359,6 +362,9 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
|
||||
ServiceType.CODEX -> "Codex"
|
||||
ServiceType.DEEPSEEK -> "DS"
|
||||
ServiceType.OLLAMA -> "Ollama"
|
||||
ServiceType.TRAE -> "Trae"
|
||||
ServiceType.WORKBUDDY -> "WB"
|
||||
ServiceType.SUB2API -> "S2API"
|
||||
}
|
||||
|
||||
private fun widgetLogo(service: ServiceType): Int = when (service) {
|
||||
@ -366,6 +372,10 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
|
||||
ServiceType.CODEX -> R.drawable.ic_codex_logo_widget // PNG for RemoteViews compatibility
|
||||
ServiceType.DEEPSEEK -> R.drawable.ic_deepseek_logo
|
||||
ServiceType.OLLAMA -> R.drawable.ic_ollama_logo_widget
|
||||
// 新服务暂不进桌面组件轮播(DISPLAY_SERVICES 未包含),这里用占位 logo 保证穷尽
|
||||
ServiceType.TRAE,
|
||||
ServiceType.WORKBUDDY,
|
||||
ServiceType.SUB2API -> R.drawable.ic_deepseek_logo
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -44,6 +44,31 @@
|
||||
<string name="nav_empty_title">👈 选择服务查看详情</string>
|
||||
<string name="nav_empty_subtitle">点击左侧卡片即可</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_about">关于 RainyToken</string>
|
||||
<string name="title_tips">使用小技巧</string>
|
||||
@ -77,6 +102,9 @@
|
||||
|
||||
<!-- ══════════ 仪表盘 ══════════ -->
|
||||
<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_just_updated">刚刚更新</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_codex">ChatGPT · Codex 额度</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 & 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 注入用) ══════════ -->
|
||||
<string name="format_day">天</string>
|
||||
@ -455,6 +527,7 @@
|
||||
<string name="action_refresh_balance">刷新余额</string>
|
||||
<string name="action_relogin">重新登录</string>
|
||||
<string name="action_configure_credential">配置凭据</string>
|
||||
<string name="action_recharge">充值</string>
|
||||
<string name="main_card_current_balance">当前余额</string>
|
||||
<string name="main_card_5h_usage">5h 实时用量</string>
|
||||
<string name="main_card_monthly_balance">月度余额</string>
|
||||
|
||||
@ -44,6 +44,31 @@
|
||||
<string name="nav_empty_title">👈 選擇服務檢視詳情</string>
|
||||
<string name="nav_empty_subtitle">點選左側卡片即可</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_about">關於 RainyToken</string>
|
||||
<string name="title_tips">使用小技巧</string>
|
||||
@ -77,6 +102,9 @@
|
||||
|
||||
<!-- ══════════ 仪表盘 ══════════ -->
|
||||
<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_just_updated">剛剛更新</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_codex">ChatGPT · Codex 額度</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 & 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 注入用) ══════════ -->
|
||||
<string name="format_day">天</string>
|
||||
@ -455,6 +527,7 @@
|
||||
<string name="action_refresh_balance">重新整理餘額</string>
|
||||
<string name="action_relogin">重新登入</string>
|
||||
<string name="action_configure_credential">配置憑據</string>
|
||||
<string name="action_recharge">儲值</string>
|
||||
<string name="main_card_current_balance">當前餘額</string>
|
||||
<string name="main_card_5h_usage">5h 實時用量</string>
|
||||
<string name="main_card_monthly_balance">月度餘額</string>
|
||||
|
||||
@ -48,6 +48,31 @@
|
||||
<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="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_about">About RainyToken</string>
|
||||
<string name="title_tips">Tips</string>
|
||||
@ -81,6 +106,9 @@
|
||||
|
||||
<!-- ══════════ 仪表盘 ══════════ -->
|
||||
<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_just_updated">Updated just now</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_codex">ChatGPT · Codex 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 & 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 & 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 注入用) ══════════ -->
|
||||
<string name="format_day">d</string>
|
||||
@ -458,6 +530,7 @@
|
||||
<string name="action_refresh_balance">Refresh balance</string>
|
||||
<string name="action_relogin">Log in again</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_5h_usage">5h live usage</string>
|
||||
<string name="main_card_monthly_balance">Monthly balance</string>
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user