feat(trae): 详情页直接展示 INTL 用量事件,INTL 充值改外部浏览器
This commit is contained in:
parent
2a7e06acf3
commit
dab9b7b272
@ -16,8 +16,8 @@ android {
|
|||||||
applicationId = "com.rainy.token"
|
applicationId = "com.rainy.token"
|
||||||
minSdk = 31
|
minSdk = 31
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 40
|
versionCode = 41
|
||||||
versionName = "1.7.20"
|
versionName = "1.7.21"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
|
|||||||
@ -58,6 +58,79 @@ class TraeRepository constructor(
|
|||||||
|
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
/** 拉取近 30 天按会话聚合的用量事件(官方 Usage Events 数据)。 */
|
||||||
|
suspend fun fetchUsageEvents(
|
||||||
|
accountId: String? = null,
|
||||||
|
startSec: Long = System.currentTimeMillis() / 1000L - 30 * 86400L,
|
||||||
|
endSec: Long = System.currentTimeMillis() / 1000L,
|
||||||
|
pageNum: Int = 1,
|
||||||
|
pageSize: Int = 20
|
||||||
|
): Result<List<TraeUsageEvent>> = withContext(Dispatchers.IO) {
|
||||||
|
val credential = credentialRepository.get(ServiceType.TRAE, accountId)
|
||||||
|
?: return@withContext Result.failure(RepositoryError.InvalidCredential())
|
||||||
|
if (credential !is Credential.TraeCredential || credential.jwt.isBlank()) {
|
||||||
|
return@withContext Result.failure(RepositoryError.InvalidCredential())
|
||||||
|
}
|
||||||
|
val regionKey = credential.region.uppercase().takeIf { it == "INTL" } ?: "CN"
|
||||||
|
val url = if (regionKey == "INTL") {
|
||||||
|
val host = credential.apiHost?.takeIf { it.startsWith("http") }?.trimEnd('/')
|
||||||
|
?: "https://grow-normal.trae.ai"
|
||||||
|
host + "/trae/api/v1/pay/query_user_usage_group_by_session"
|
||||||
|
} else {
|
||||||
|
"https://api.trae.cn/trae/api/v1/pay/query_user_usage_group_by_session"
|
||||||
|
}
|
||||||
|
val body =
|
||||||
|
"{\"start_time\":$startSec,\"end_time\":$endSec,\"page_size\":$pageSize,\"page_num\":$pageNum,\"usage_type\":[7]}"
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.addHeader("Authorization", "Cloud-IDE-JWT " + credential.jwt.trim())
|
||||||
|
.addHeader("Content-Type", "application/json")
|
||||||
|
.post(body.toRequestBody("application/json".toMediaType()))
|
||||||
|
.build()
|
||||||
|
val text = try {
|
||||||
|
okHttpClient.newCall(request).execute().use { resp ->
|
||||||
|
val t = resp.body?.string().orEmpty()
|
||||||
|
DebugLog.i(TAG, "fetchUsageEvents: http=${resp.code} len=${t.length}")
|
||||||
|
if (!resp.isSuccessful) {
|
||||||
|
return@withContext Result.failure(
|
||||||
|
if (resp.code == 401 || resp.code == 403) authErrorFor(credential.jwt.trim())
|
||||||
|
else mapHttpError(resp.code)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
t
|
||||||
|
}
|
||||||
|
} catch (e: IOException) {
|
||||||
|
return@withContext Result.failure(RepositoryError.Network(e))
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
DebugLog.e(TAG, "fetchUsageEvents exception: " + e)
|
||||||
|
return@withContext Result.failure(RepositoryError.Network(e))
|
||||||
|
}
|
||||||
|
val events = try {
|
||||||
|
val root = json.parseToJsonElement(text).jsonObject
|
||||||
|
root["user_usage_group_by_sessions"]?.jsonArray?.mapNotNull { el ->
|
||||||
|
val obj = el.jsonObject
|
||||||
|
val extra = obj["extra_info"]?.jsonObject
|
||||||
|
TraeUsageEvent(
|
||||||
|
usageTime = obj.scalarOr("usage_time")?.toLongOrNull() ?: 0L,
|
||||||
|
modelName = obj.scalarOr("model_name").orEmpty(),
|
||||||
|
mode = obj.scalarOr("mode").orEmpty(),
|
||||||
|
userInputPreview = obj.scalarOr("user_input_preview").orEmpty(),
|
||||||
|
creditsFloat = obj.numberOr("credits_float", "amount_float") ?: 0.0,
|
||||||
|
costMoneyFloat = obj.numberOr("cost_money_float") ?: 0.0,
|
||||||
|
inputToken = extra?.numberOr("input_token")?.toLong() ?: 0L,
|
||||||
|
outputToken = extra?.numberOr("output_token")?.toLong() ?: 0L,
|
||||||
|
cacheReadToken = extra?.numberOr("cache_read_token")?.toLong() ?: 0L,
|
||||||
|
cacheWriteToken = extra?.numberOr("cache_write_token")?.toLong() ?: 0L
|
||||||
|
)
|
||||||
|
} ?: emptyList()
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
DebugLog.e(TAG, "fetchUsageEvents parse error: " + e)
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
Result.success(events.sortedByDescending { it.usageTime }.take(50))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
suspend fun fetchBalance(accountId: String? = null): Result<ServiceBalance> = withContext(Dispatchers.IO) {
|
suspend fun fetchBalance(accountId: String? = null): Result<ServiceBalance> = withContext(Dispatchers.IO) {
|
||||||
val credential = credentialRepository.get(ServiceType.TRAE, accountId)
|
val credential = credentialRepository.get(ServiceType.TRAE, accountId)
|
||||||
?: return@withContext Result.failure(RepositoryError.InvalidCredential())
|
?: return@withContext Result.failure(RepositoryError.InvalidCredential())
|
||||||
@ -763,3 +836,16 @@ class TraeRepository constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/** 官方 Usage Events 按会话聚合的一条用量记录。 */
|
||||||
|
data class TraeUsageEvent(
|
||||||
|
val usageTime: Long,
|
||||||
|
val modelName: String,
|
||||||
|
val mode: String,
|
||||||
|
val userInputPreview: String,
|
||||||
|
val creditsFloat: Double,
|
||||||
|
val costMoneyFloat: Double,
|
||||||
|
val inputToken: Long,
|
||||||
|
val outputToken: Long,
|
||||||
|
val cacheReadToken: Long,
|
||||||
|
val cacheWriteToken: Long
|
||||||
|
)
|
||||||
|
|||||||
@ -102,5 +102,20 @@ object ServiceConfigProvider {
|
|||||||
fun get(type: ServiceType): ServiceConfig =
|
fun get(type: ServiceType): ServiceConfig =
|
||||||
configs.getValue(type)
|
configs.getValue(type)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据账号区域选择外部操作页:Trae 国际版用 trae.ai,国内版用 trae.cn。
|
||||||
|
* 其余服务仍走静态配置。
|
||||||
|
*/
|
||||||
|
fun externalUrlFor(type: ServiceType, region: String? = null): String? =
|
||||||
|
if (type == ServiceType.TRAE) {
|
||||||
|
if (region.equals("INTL", ignoreCase = true)) {
|
||||||
|
"https://www.trae.ai/pricing"
|
||||||
|
} else {
|
||||||
|
"https://www.trae.cn/pricing"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
get(type).externalUrl
|
||||||
|
}
|
||||||
|
|
||||||
fun all(): List<ServiceConfig> = ServiceType.entries.map { get(it) }
|
fun all(): List<ServiceConfig> = ServiceType.entries.map { get(it) }
|
||||||
}
|
}
|
||||||
@ -1,5 +1,8 @@
|
|||||||
package com.rainy.token.ui
|
package com.rainy.token.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
import androidx.compose.animation.AnimatedContentTransitionScope
|
import androidx.compose.animation.AnimatedContentTransitionScope
|
||||||
import androidx.compose.animation.ExitTransition
|
import androidx.compose.animation.ExitTransition
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
@ -63,7 +66,6 @@ import com.rainy.token.ui.theme.StrawberryPink
|
|||||||
import com.rainy.token.ui.webview.CodexOAuthScreen
|
import com.rainy.token.ui.webview.CodexOAuthScreen
|
||||||
import com.rainy.token.ui.webview.WebViewLoginScreen
|
import com.rainy.token.ui.webview.WebViewLoginScreen
|
||||||
import com.rainy.token.ui.webview.WebViewPageScreen
|
import com.rainy.token.ui.webview.WebViewPageScreen
|
||||||
import android.net.Uri
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 应用导航图。
|
* 应用导航图。
|
||||||
@ -140,6 +142,13 @@ private sealed class DetailPane {
|
|||||||
data class WebPage(val url: String, val title: String) : DetailPane()
|
data class WebPage(val url: String, val title: String) : DetailPane()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun Context.openTraeUrl(url: String) {
|
||||||
|
try {
|
||||||
|
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RainyTokenNavHost() {
|
fun RainyTokenNavHost() {
|
||||||
val windowSize = rememberWindowSizeClass()
|
val windowSize = rememberWindowSizeClass()
|
||||||
@ -229,6 +238,7 @@ private fun CompactNavHost() {
|
|||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
composable(Routes.DASHBOARD) {
|
composable(Routes.DASHBOARD) {
|
||||||
|
val context = LocalContext.current
|
||||||
val rechargeLabel = stringResource(R.string.action_recharge)
|
val rechargeLabel = stringResource(R.string.action_recharge)
|
||||||
DashboardScreen(
|
DashboardScreen(
|
||||||
onOpenSettings = { navController.navigate(Routes.SETTINGS) },
|
onOpenSettings = { navController.navigate(Routes.SETTINGS) },
|
||||||
@ -239,21 +249,26 @@ private fun CompactNavHost() {
|
|||||||
onOpenUsageDetail = { navController.navigate(Routes.USAGE_DETAIL) },
|
onOpenUsageDetail = { navController.navigate(Routes.USAGE_DETAIL) },
|
||||||
onOpenCcgoUsageDetail = { navController.navigate(Routes.CCGO_USAGE_DETAIL) },
|
onOpenCcgoUsageDetail = { navController.navigate(Routes.CCGO_USAGE_DETAIL) },
|
||||||
onOpenHeatmap = { navController.navigate(Routes.HEATMAP) },
|
onOpenHeatmap = { navController.navigate(Routes.HEATMAP) },
|
||||||
onOpenRecharge = { type ->
|
onOpenRecharge = { type, region ->
|
||||||
ServiceConfigProvider.get(type).externalUrl?.let { url ->
|
val url = ServiceConfigProvider.externalUrlFor(type, region)
|
||||||
navController.navigate(Routes.webviewPage(url, rechargeLabel))
|
if (url != null) {
|
||||||
|
val intlTrae = type == ServiceType.TRAE && region.equals("INTL", ignoreCase = true)
|
||||||
|
if (intlTrae) context.openTraeUrl(url) else navController.navigate(Routes.webviewPage(url, rechargeLabel))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(Routes.API_MANAGEMENT) {
|
composable(Routes.API_MANAGEMENT) {
|
||||||
|
val context = LocalContext.current
|
||||||
val rechargeLabel = stringResource(R.string.action_recharge)
|
val rechargeLabel = stringResource(R.string.action_recharge)
|
||||||
ApiManagementScreen(
|
ApiManagementScreen(
|
||||||
onBack = guardedPop,
|
onBack = guardedPop,
|
||||||
onConfigure = { type -> navController.navigate(credentialEntry(type)) },
|
onConfigure = { type -> navController.navigate(credentialEntry(type)) },
|
||||||
onRecharge = { type ->
|
onRecharge = { type, region ->
|
||||||
ServiceConfigProvider.get(type).externalUrl?.let { url ->
|
val url = ServiceConfigProvider.externalUrlFor(type, region)
|
||||||
navController.navigate(Routes.webviewPage(url, rechargeLabel))
|
if (url != null) {
|
||||||
|
val intlTrae = type == ServiceType.TRAE && region.equals("INTL", ignoreCase = true)
|
||||||
|
if (intlTrae) context.openTraeUrl(url) else navController.navigate(Routes.webviewPage(url, rechargeLabel))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@ -415,6 +430,7 @@ private fun CompactNavHost() {
|
|||||||
) { backStackEntry ->
|
) { backStackEntry ->
|
||||||
val type = parseServiceType(backStackEntry.arguments?.getString("type"))
|
val type = parseServiceType(backStackEntry.arguments?.getString("type"))
|
||||||
val accountId = backStackEntry.arguments?.getString("account")
|
val accountId = backStackEntry.arguments?.getString("account")
|
||||||
|
val context = LocalContext.current
|
||||||
val rechargeLabel = stringResource(R.string.action_recharge)
|
val rechargeLabel = stringResource(R.string.action_recharge)
|
||||||
ServiceDetailScreen(
|
ServiceDetailScreen(
|
||||||
service = type,
|
service = type,
|
||||||
@ -428,9 +444,11 @@ private fun CompactNavHost() {
|
|||||||
},
|
},
|
||||||
onConfigureCredential = { svc -> navController.navigate(credentialEntry(svc)) },
|
onConfigureCredential = { svc -> navController.navigate(credentialEntry(svc)) },
|
||||||
onStartWebViewLogin = { svc, acc, region -> navController.navigate(Routes.webviewLogin(svc, acc, region)) },
|
onStartWebViewLogin = { svc, acc, region -> navController.navigate(Routes.webviewLogin(svc, acc, region)) },
|
||||||
onOpenRecharge = { svc ->
|
onOpenRecharge = { svc, region ->
|
||||||
ServiceConfigProvider.get(svc).externalUrl?.let { url ->
|
val url = ServiceConfigProvider.externalUrlFor(svc, region)
|
||||||
navController.navigate(Routes.webviewPage(url, rechargeLabel))
|
if (url != null) {
|
||||||
|
val intlTrae = svc == ServiceType.TRAE && region.equals("INTL", ignoreCase = true)
|
||||||
|
if (intlTrae) context.openTraeUrl(url) else navController.navigate(Routes.webviewPage(url, rechargeLabel))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@ -471,6 +489,7 @@ private fun ExpandedLayout() {
|
|||||||
prevPane = detailPane
|
prevPane = detailPane
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
val rechargeLabel = stringResource(R.string.action_recharge)
|
val rechargeLabel = stringResource(R.string.action_recharge)
|
||||||
|
|
||||||
Row(modifier = Modifier.fillMaxSize()) {
|
Row(modifier = Modifier.fillMaxSize()) {
|
||||||
@ -488,9 +507,11 @@ private fun ExpandedLayout() {
|
|||||||
onOpenUsageDetail = { detailPane = DetailPane.OCGOUsage },
|
onOpenUsageDetail = { detailPane = DetailPane.OCGOUsage },
|
||||||
onOpenCcgoUsageDetail = { detailPane = DetailPane.CCGOUsage },
|
onOpenCcgoUsageDetail = { detailPane = DetailPane.CCGOUsage },
|
||||||
onOpenHeatmap = { detailPane = DetailPane.Heatmap },
|
onOpenHeatmap = { detailPane = DetailPane.Heatmap },
|
||||||
onOpenRecharge = { type ->
|
onOpenRecharge = { type, region ->
|
||||||
ServiceConfigProvider.get(type).externalUrl?.let { url ->
|
val url = ServiceConfigProvider.externalUrlFor(type, region)
|
||||||
detailPane = DetailPane.WebPage(url = url, title = rechargeLabel)
|
if (url != null) {
|
||||||
|
val intlTrae = type == ServiceType.TRAE && region.equals("INTL", ignoreCase = true)
|
||||||
|
if (intlTrae) context.openTraeUrl(url) else detailPane = DetailPane.WebPage(url = url, title = rechargeLabel)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
viewModel = dashboardVm
|
viewModel = dashboardVm
|
||||||
@ -546,6 +567,7 @@ private fun ExpandedDetailPane(
|
|||||||
popExitTransition = { ExitTransition.None }
|
popExitTransition = { ExitTransition.None }
|
||||||
) {
|
) {
|
||||||
composable("detail") {
|
composable("detail") {
|
||||||
|
val context = LocalContext.current
|
||||||
val rechargeLabel = LocalContext.current.getString(R.string.action_recharge)
|
val rechargeLabel = LocalContext.current.getString(R.string.action_recharge)
|
||||||
ServiceDetailScreen(
|
ServiceDetailScreen(
|
||||||
service = pane.type,
|
service = pane.type,
|
||||||
@ -554,9 +576,11 @@ private fun ExpandedDetailPane(
|
|||||||
onSwitchAccount = onSwitchAccount,
|
onSwitchAccount = onSwitchAccount,
|
||||||
onConfigureCredential = { onClose() },
|
onConfigureCredential = { onClose() },
|
||||||
onStartWebViewLogin = { _, _, _ -> },
|
onStartWebViewLogin = { _, _, _ -> },
|
||||||
onOpenRecharge = { svc ->
|
onOpenRecharge = { svc, region ->
|
||||||
ServiceConfigProvider.get(svc).externalUrl?.let { url ->
|
val url = ServiceConfigProvider.externalUrlFor(svc, region)
|
||||||
navController.navigate(Routes.webviewPage(url, rechargeLabel))
|
if (url != null) {
|
||||||
|
val intlTrae = svc == ServiceType.TRAE && region.equals("INTL", ignoreCase = true)
|
||||||
|
if (intlTrae) context.openTraeUrl(url) else navController.navigate(Routes.webviewPage(url, rechargeLabel))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@ -664,13 +688,16 @@ private fun ExpandedDetailPane(
|
|||||||
popExitTransition = { ExitTransition.None }
|
popExitTransition = { ExitTransition.None }
|
||||||
) {
|
) {
|
||||||
composable("api_mgmt_main") {
|
composable("api_mgmt_main") {
|
||||||
|
val context = LocalContext.current
|
||||||
val rechargeLabel = LocalContext.current.getString(R.string.action_recharge)
|
val rechargeLabel = LocalContext.current.getString(R.string.action_recharge)
|
||||||
ApiManagementScreen(
|
ApiManagementScreen(
|
||||||
onBack = onClose,
|
onBack = onClose,
|
||||||
onConfigure = { type -> apiNavController.navigate(credentialEntry(type)) },
|
onConfigure = { type -> apiNavController.navigate(credentialEntry(type)) },
|
||||||
onRecharge = { type ->
|
onRecharge = { type, region ->
|
||||||
ServiceConfigProvider.get(type).externalUrl?.let { url ->
|
val url = ServiceConfigProvider.externalUrlFor(type, region)
|
||||||
apiNavController.navigate(Routes.webviewPage(url, rechargeLabel))
|
if (url != null) {
|
||||||
|
val intlTrae = type == ServiceType.TRAE && region.equals("INTL", ignoreCase = true)
|
||||||
|
if (intlTrae) context.openTraeUrl(url) else apiNavController.navigate(Routes.webviewPage(url, rechargeLabel))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@ -85,7 +85,7 @@ import java.util.Locale
|
|||||||
fun ApiManagementScreen(
|
fun ApiManagementScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onConfigure: (ServiceType) -> Unit,
|
onConfigure: (ServiceType) -> Unit,
|
||||||
onRecharge: (ServiceType) -> Unit,
|
onRecharge: (ServiceType, String?) -> Unit,
|
||||||
viewModel: ApiManagementViewModel = hiltViewModel()
|
viewModel: ApiManagementViewModel = hiltViewModel()
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
@ -150,7 +150,7 @@ fun ApiManagementScreen(
|
|||||||
onEdit = { onConfigure(row.service) },
|
onEdit = { onConfigure(row.service) },
|
||||||
onTest = { viewModel.test(row.service) },
|
onTest = { viewModel.test(row.service) },
|
||||||
onDelete = { pendingDelete = row.service },
|
onDelete = { pendingDelete = row.service },
|
||||||
onRecharge = { onRecharge(row.service) }
|
onRecharge = { onRecharge(row.service, row.region) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -316,9 +316,9 @@ private fun ApiRowCard(
|
|||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
onRecharge: () -> Unit
|
onRecharge: () -> Unit
|
||||||
) {
|
) {
|
||||||
// 有官方充值/订阅页的服务(ServiceConfig.externalUrl)才展示充值入口
|
// 有官方充值/订阅页的服务才展示充值入口(Trae 按账号区域分流)
|
||||||
val rechargeUrl = remember(row.service) {
|
val rechargeUrl = remember(row.service, row.region) {
|
||||||
runCatching { ServiceConfigProvider.get(row.service).externalUrl }.getOrNull()
|
runCatching { ServiceConfigProvider.externalUrlFor(row.service, row.region) }.getOrNull()
|
||||||
}
|
}
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|||||||
@ -63,6 +63,15 @@ class ApiManagementViewModel @Inject constructor(
|
|||||||
credentialRepository.accountsFor(type).size
|
credentialRepository.accountsFor(type).size
|
||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
|
},
|
||||||
|
region = when (type) {
|
||||||
|
ServiceType.TRAE -> {
|
||||||
|
(cred as? Credential.TraeCredential)?.region
|
||||||
|
?: credentialRepository.accountsFor(type)
|
||||||
|
.firstOrNull()
|
||||||
|
?.let { (credentialRepository.get(type, it.id) as? Credential.TraeCredential)?.region }
|
||||||
|
}
|
||||||
|
else -> null
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -158,7 +167,9 @@ data class ApiRowUi(
|
|||||||
val cachedBalance: CachedBalance?,
|
val cachedBalance: CachedBalance?,
|
||||||
val maskedCredential: String?,
|
val maskedCredential: String?,
|
||||||
/** 多账号服务的账号个数;单账号服务为 0。 */
|
/** 多账号服务的账号个数;单账号服务为 0。 */
|
||||||
val accountCount: Int = 0
|
val accountCount: Int = 0,
|
||||||
|
/** Trae 账号区域(用于选择官方充值/用量页)。 */
|
||||||
|
val region: String? = null
|
||||||
) {
|
) {
|
||||||
val isConfigured: Boolean get() = state != CredentialStatus.State.NOT_CONFIGURED
|
val isConfigured: Boolean get() = state != CredentialStatus.State.NOT_CONFIGURED
|
||||||
|
|
||||||
|
|||||||
@ -138,7 +138,7 @@ fun DashboardScreen(
|
|||||||
onOpenUsageDetail: () -> Unit,
|
onOpenUsageDetail: () -> Unit,
|
||||||
onOpenCcgoUsageDetail: () -> Unit = {},
|
onOpenCcgoUsageDetail: () -> Unit = {},
|
||||||
onOpenHeatmap: () -> Unit = {},
|
onOpenHeatmap: () -> Unit = {},
|
||||||
onOpenRecharge: (ServiceType) -> Unit = {},
|
onOpenRecharge: (ServiceType, String?) -> Unit = { _, _ -> },
|
||||||
viewModel: DashboardViewModel = hiltViewModel()
|
viewModel: DashboardViewModel = hiltViewModel()
|
||||||
) {
|
) {
|
||||||
// 从设置页返回时重新读取本地凭据状态 + 缓存;若有已配置但无余额缓存的
|
// 从设置页返回时重新读取本地凭据状态 + 缓存;若有已配置但无余额缓存的
|
||||||
@ -549,7 +549,7 @@ private fun rememberDashboardItems(
|
|||||||
onOpenService: (ServiceType, String?) -> Unit,
|
onOpenService: (ServiceType, String?) -> Unit,
|
||||||
onOpenHeatmap: () -> Unit,
|
onOpenHeatmap: () -> Unit,
|
||||||
onOpenSub2PanelManagement: (String?) -> Unit,
|
onOpenSub2PanelManagement: (String?) -> Unit,
|
||||||
onOpenRecharge: (ServiceType) -> Unit,
|
onOpenRecharge: (ServiceType, String?) -> Unit,
|
||||||
refreshTrigger: Int
|
refreshTrigger: Int
|
||||||
): List<DashboardHomeItem> {
|
): List<DashboardHomeItem> {
|
||||||
val defaultItems = buildList {
|
val defaultItems = buildList {
|
||||||
@ -607,8 +607,8 @@ private fun rememberDashboardItems(
|
|||||||
null
|
null
|
||||||
},
|
},
|
||||||
// 充值/订阅入口:有官方 externalUrl 的服务(DeepSeek/Trae/WorkBuddy)在卡片上直开
|
// 充值/订阅入口:有官方 externalUrl 的服务(DeepSeek/Trae/WorkBuddy)在卡片上直开
|
||||||
onOpenRecharge = if (ServiceConfigProvider.get(card.service).externalUrl != null) {
|
onOpenRecharge = if (ServiceConfigProvider.get(card.service).externalUrl != null || card.service == ServiceType.TRAE) {
|
||||||
{ onOpenRecharge(card.service) }
|
{ onOpenRecharge(card.service, card.region) }
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
|
|||||||
@ -287,7 +287,8 @@ class DashboardViewModel @Inject constructor(
|
|||||||
siteIcon = (local.credential as? Credential.Sub2ApiCredential)?.siteIcon,
|
siteIcon = (local.credential as? Credential.Sub2ApiCredential)?.siteIcon,
|
||||||
// 可签到服务(Trae/WorkBuddy)各账号的今日签到角标;其它服务恒 false 不显示
|
// 可签到服务(Trae/WorkBuddy)各账号的今日签到角标;其它服务恒 false 不显示
|
||||||
checkedInToday = local.service in CHECKIN_SERVICES &&
|
checkedInToday = local.service in CHECKIN_SERVICES &&
|
||||||
checkinStatusStore.isCheckedToday(local.service, local.account?.id)
|
checkinStatusStore.isCheckedToday(local.service, local.account?.id),
|
||||||
|
region = (local.credential as? Credential.TraeCredential)?.region
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun newerOf(
|
private fun newerOf(
|
||||||
@ -463,7 +464,9 @@ data class DashboardCardUi(
|
|||||||
/** 站点 Logo URL(Sub2API 面板登录后抓取;非空时主页卡片用它替换内置图标)。 */
|
/** 站点 Logo URL(Sub2API 面板登录后抓取;非空时主页卡片用它替换内置图标)。 */
|
||||||
val siteIcon: String? = null,
|
val siteIcon: String? = null,
|
||||||
/** 今日已签到角标(仅可签到服务有值,见 [CHECKIN_SERVICES])。 */
|
/** 今日已签到角标(仅可签到服务有值,见 [CHECKIN_SERVICES])。 */
|
||||||
val checkedInToday: Boolean = false
|
val checkedInToday: Boolean = false,
|
||||||
|
/** 账号区域(Trae CN/INTL 等,用于选择官方充值/用量页)。 */
|
||||||
|
val region: String? = null
|
||||||
) {
|
) {
|
||||||
/** 余额展示主数字。优先取缓存,错误时也展示(不隐藏,让用户看到旧值 + 红点提示)。 */
|
/** 余额展示主数字。优先取缓存,错误时也展示(不隐藏,让用户看到旧值 + 红点提示)。 */
|
||||||
val displayBalance: ServiceBalance? get() = cachedBalance?.balance
|
val displayBalance: ServiceBalance? get() = cachedBalance?.balance
|
||||||
|
|||||||
@ -57,6 +57,7 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
@ -66,6 +67,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import com.rainy.token.R
|
import com.rainy.token.R
|
||||||
import com.rainy.token.data.repository.ModelUsageRow
|
import com.rainy.token.data.repository.ModelUsageRow
|
||||||
import com.rainy.token.data.repository.OpenCodeGoRepository
|
import com.rainy.token.data.repository.OpenCodeGoRepository
|
||||||
|
import com.rainy.token.data.repository.TraeUsageEvent
|
||||||
import com.rainy.token.data.repository.WorkBuddyRepository
|
import com.rainy.token.data.repository.WorkBuddyRepository
|
||||||
import com.rainy.token.data.repository.WindowModelUsage
|
import com.rainy.token.data.repository.WindowModelUsage
|
||||||
import com.rainy.token.domain.model.ServiceBalance
|
import com.rainy.token.domain.model.ServiceBalance
|
||||||
@ -115,7 +117,7 @@ fun ServiceDetailScreen(
|
|||||||
onSwitchAccount: (ServiceType, String?) -> Unit = { _, _ -> },
|
onSwitchAccount: (ServiceType, String?) -> Unit = { _, _ -> },
|
||||||
onConfigureCredential: (ServiceType) -> Unit,
|
onConfigureCredential: (ServiceType) -> Unit,
|
||||||
onStartWebViewLogin: (ServiceType, String?, String?) -> Unit,
|
onStartWebViewLogin: (ServiceType, String?, String?) -> Unit,
|
||||||
onOpenRecharge: (ServiceType) -> Unit = { },
|
onOpenRecharge: (ServiceType, String?) -> Unit = { _, _ -> },
|
||||||
viewModel: ServiceDetailViewModel = hiltViewModel()
|
viewModel: ServiceDetailViewModel = hiltViewModel()
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(service, accountId) { viewModel.bind(service, accountId) }
|
LaunchedEffect(service, accountId) { viewModel.bind(service, accountId) }
|
||||||
@ -129,6 +131,7 @@ fun ServiceDetailScreen(
|
|||||||
val selectedModel by viewModel.selectedModel.collectAsStateWithLifecycle()
|
val selectedModel by viewModel.selectedModel.collectAsStateWithLifecycle()
|
||||||
val modelsLoading by viewModel.modelsLoading.collectAsStateWithLifecycle()
|
val modelsLoading by viewModel.modelsLoading.collectAsStateWithLifecycle()
|
||||||
val checkinState by viewModel.checkinState.collectAsStateWithLifecycle()
|
val checkinState by viewModel.checkinState.collectAsStateWithLifecycle()
|
||||||
|
val traeUsageEvents by viewModel.traeUsageEvents.collectAsStateWithLifecycle()
|
||||||
val checkinInfo by viewModel.checkinInfo.collectAsStateWithLifecycle()
|
val checkinInfo by viewModel.checkinInfo.collectAsStateWithLifecycle()
|
||||||
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
|
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
@ -235,27 +238,31 @@ fun ServiceDetailScreen(
|
|||||||
item { OllamaUsageCard(uiState.state) }
|
item { OllamaUsageCard(uiState.state) }
|
||||||
}
|
}
|
||||||
ServiceType.TRAE -> {
|
ServiceType.TRAE -> {
|
||||||
// 每日签到卡;未配置凭据时展示引导卡,国际版无签到则展示说明卡
|
val intl = uiState.region != null && uiState.region.equals("INTL", ignoreCase = true)
|
||||||
item {
|
if (intl) {
|
||||||
val intl = uiState.region != null && uiState.region.equals("INTL", ignoreCase = true)
|
// 国际版:单张用量卡(套餐/美元额度/月请求配额),不再重复展示签到卡和积分账户卡
|
||||||
if (intl) {
|
item { TraeIntlUsageCard(uiState.state) }
|
||||||
TraeIntlNoCheckinCard()
|
item { TraeUsageEventsCard(traeUsageEvents) }
|
||||||
} else if (uiState.hasCredential) {
|
} else {
|
||||||
CheckinCard(
|
// 每日签到卡;未配置凭据时展示引导卡,避免暴露必然失败的签到按钮
|
||||||
state = checkinState,
|
item {
|
||||||
todayCheckedIn = checkinInfo?.todayCheckedIn == true,
|
if (uiState.hasCredential) {
|
||||||
streakDays = checkinInfo?.streakDays ?: 0L,
|
CheckinCard(
|
||||||
isTrae = true,
|
state = checkinState,
|
||||||
onCheckin = { viewModel.checkin() }
|
todayCheckedIn = checkinInfo?.todayCheckedIn == true,
|
||||||
)
|
streakDays = checkinInfo?.streakDays ?: 0L,
|
||||||
} else {
|
isTrae = true,
|
||||||
CheckinNeedCredentialCard()
|
onCheckin = { viewModel.checkin() }
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
CheckinNeedCredentialCard()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// 权益包积分明细(拆开显示每包总额/已用/剩余);无 entitlement.* 数据时该卡不渲染
|
||||||
|
item { TraePacksCard(uiState.state) }
|
||||||
|
// 账户信息(总积分/已消耗/计费方式等,本地化标签,不暴露英文原始键)
|
||||||
|
item { TraeAccountInfoCard(uiState.state) }
|
||||||
}
|
}
|
||||||
// 权益包积分明细(拆开显示每包总额/已用/剩余);无 entitlement.* 数据时该卡不渲染
|
|
||||||
item { TraePacksCard(uiState.state) }
|
|
||||||
// 账户信息(总积分/已消耗/计费方式等,本地化标签,不暴露英文原始键)
|
|
||||||
item { TraeAccountInfoCard(uiState.state) }
|
|
||||||
}
|
}
|
||||||
ServiceType.WORKBUDDY -> {
|
ServiceType.WORKBUDDY -> {
|
||||||
item {
|
item {
|
||||||
@ -319,7 +326,9 @@ fun ServiceDetailScreen(
|
|||||||
onRefresh = { viewModel.refresh() },
|
onRefresh = { viewModel.refresh() },
|
||||||
onConfigureCredential = { onConfigureCredential(service) },
|
onConfigureCredential = { onConfigureCredential(service) },
|
||||||
onStartWebViewLogin = { onStartWebViewLogin(service, accountId, uiState.region) },
|
onStartWebViewLogin = { onStartWebViewLogin(service, accountId, uiState.region) },
|
||||||
onOpenRecharge = config.externalUrl?.let { { onOpenRecharge(service) } },
|
onOpenRecharge = if (config.externalUrl != null || service == ServiceType.TRAE) {
|
||||||
|
{ onOpenRecharge(service, uiState.region) }
|
||||||
|
} else null,
|
||||||
onTriggerUsage = { viewModel.triggerUsage() },
|
onTriggerUsage = { viewModel.triggerUsage() },
|
||||||
onSelectModel = { viewModel.selectModel(it) },
|
onSelectModel = { viewModel.selectModel(it) },
|
||||||
onRefreshModels = { viewModel.loadModels(force = true) }
|
onRefreshModels = { viewModel.loadModels(force = true) }
|
||||||
@ -1096,46 +1105,37 @@ private fun MainBalanceCard(state: State, service: ServiceType) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TraeIntlBigNumber(extras: Map<String, String>) {
|
private fun TraeIntlBigNumber(extras: Map<String, String>) {
|
||||||
val planName = extras["intl.plan_name"] ?: "Free plan"
|
val dollarUsed = extras["intl.dollar_used"]?.toDoubleOrNull()
|
||||||
|
val dollarLimit = extras["intl.dollar_limit"]?.toDoubleOrNull()
|
||||||
Text(
|
Text(
|
||||||
text = planName,
|
text = if (dollarLimit != null) {
|
||||||
|
String.format(Locale.US, "\$%.2f / \$%.2f", dollarUsed ?: 0.0, dollarLimit)
|
||||||
|
} else {
|
||||||
|
extras["intl.plan_name"] ?: "Free plan"
|
||||||
|
},
|
||||||
style = MaterialTheme.typography.displayLarge,
|
style = MaterialTheme.typography.displayLarge,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.trae_intl_monthly_quota),
|
text = if (dollarLimit != null) {
|
||||||
|
stringResource(R.string.trae_intl_dollar_usage_title)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.trae_intl_monthly_quota)
|
||||||
|
},
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
color = inkMuted(),
|
color = inkMuted(),
|
||||||
modifier = Modifier.padding(bottom = 8.dp)
|
modifier = Modifier.padding(bottom = 8.dp)
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
|
||||||
val dollarUsed = extras["intl.dollar_used"]?.toDoubleOrNull()
|
|
||||||
val dollarLimit = extras["intl.dollar_limit"]?.toDoubleOrNull()
|
|
||||||
if (dollarLimit != null) {
|
if (dollarLimit != null) {
|
||||||
InfoRow(
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
label = stringResource(R.string.trae_intl_dollar_usage_title),
|
Text(
|
||||||
value = String.format(Locale.US, "\$%.2f / \$%.2f", dollarUsed ?: 0.0, dollarLimit)
|
text = (extras["intl.plan_name"] ?: "Free plan") + " · " + stringResource(R.string.trae_intl_monthly_quota),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = inkMuted()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val quotaUnit = stringResource(R.string.trae_intl_quota_unit)
|
|
||||||
InfoRow(
|
|
||||||
label = stringResource(R.string.trae_intl_premium_fast_title),
|
|
||||||
value = (extras["intl.premium_fast_limit"] ?: "—") + " $quotaUnit"
|
|
||||||
)
|
|
||||||
InfoRow(
|
|
||||||
label = stringResource(R.string.trae_intl_premium_slow_title),
|
|
||||||
value = (extras["intl.premium_slow_limit"] ?: "—") + " $quotaUnit"
|
|
||||||
)
|
|
||||||
InfoRow(
|
|
||||||
label = stringResource(R.string.trae_intl_advanced_title),
|
|
||||||
value = (extras["intl.advanced_limit"] ?: "—") + " $quotaUnit"
|
|
||||||
)
|
|
||||||
InfoRow(
|
|
||||||
label = stringResource(R.string.trae_intl_auto_completion_title),
|
|
||||||
value = (extras["intl.auto_completion_limit"] ?: "—") + " $quotaUnit"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@ -1613,6 +1613,170 @@ internal fun TraeAccountInfoCard(state: State) {
|
|||||||
*
|
*
|
||||||
* 旧缓存无 wb_detail_raw 时回退为「资源 #007」简化行。无任何数据时整卡不渲染。
|
* 旧缓存无 wb_detail_raw 时回退为「资源 #007」简化行。无任何数据时整卡不渲染。
|
||||||
*/
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun TraeUsageEventsCard(state: TraeUsageEventsState) {
|
||||||
|
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)) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.trae_usage_events_title),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
when (state) {
|
||||||
|
is TraeUsageEventsState.Idle,
|
||||||
|
is TraeUsageEventsState.Loading -> Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.height(16.dp).width(16.dp),
|
||||||
|
color = StrawberryPink,
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.trae_usage_events_loading),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = inkMuted()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is TraeUsageEventsState.Error -> Text(
|
||||||
|
text = state.message.asString(),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
is TraeUsageEventsState.Success -> {
|
||||||
|
if (state.events.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.trae_usage_events_empty),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = inkMuted()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val sdf = SimpleDateFormat("MM-dd HH:mm", Locale.getDefault())
|
||||||
|
state.events.forEach { event ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = buildString {
|
||||||
|
append(event.modelName.ifBlank { "Unknown" })
|
||||||
|
if (event.mode.isNotBlank()) append("(${event.mode})")
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = sdf.format(Date(event.usageTime * 1000L)),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = inkMuted()
|
||||||
|
)
|
||||||
|
if (event.userInputPreview.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
text = event.userInputPreview.take(48),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = inkMuted(),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(horizontalAlignment = Alignment.End) {
|
||||||
|
val quotaText = if (event.costMoneyFloat > 0) {
|
||||||
|
String.format(Locale.US, "\$%.4f", event.costMoneyFloat)
|
||||||
|
} else {
|
||||||
|
String.format(Locale.US, "%.2f 积分", event.creditsFloat)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = quotaText,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
val tokens = buildString {
|
||||||
|
if (event.inputToken > 0) append("入 ${event.inputToken}")
|
||||||
|
if (event.outputToken > 0) {
|
||||||
|
if (isNotEmpty()) append(" / ")
|
||||||
|
append("出 ${event.outputToken}")
|
||||||
|
}
|
||||||
|
if (event.cacheReadToken > 0) append(" / 缓存 ${event.cacheReadToken}")
|
||||||
|
}
|
||||||
|
if (tokens.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
text = tokens,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = inkMuted()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun TraeIntlUsageCard(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 rows = buildList {
|
||||||
|
extras["intl.plan_name"]?.let {
|
||||||
|
add(stringResource(R.string.trae_intl_plan_title) to it)
|
||||||
|
}
|
||||||
|
extras["intl.dollar_limit"]?.toDoubleOrNull()?.let { limit ->
|
||||||
|
add(
|
||||||
|
stringResource(R.string.trae_intl_dollar_usage_title) to String.format(
|
||||||
|
Locale.US,
|
||||||
|
"\$%.2f / \$%.2f",
|
||||||
|
extras["intl.dollar_used"]?.toDoubleOrNull() ?: 0.0,
|
||||||
|
limit
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val quotaUnit = stringResource(R.string.trae_intl_quota_unit)
|
||||||
|
extras["intl.premium_fast_limit"]?.let {
|
||||||
|
add(stringResource(R.string.trae_intl_premium_fast_title) to "$it $quotaUnit")
|
||||||
|
}
|
||||||
|
extras["intl.premium_slow_limit"]?.let {
|
||||||
|
add(stringResource(R.string.trae_intl_premium_slow_title) to "$it $quotaUnit")
|
||||||
|
}
|
||||||
|
extras["intl.advanced_limit"]?.let {
|
||||||
|
add(stringResource(R.string.trae_intl_advanced_title) to "$it $quotaUnit")
|
||||||
|
}
|
||||||
|
extras["intl.auto_completion_limit"]?.let {
|
||||||
|
add(stringResource(R.string.trae_intl_auto_completion_title) to "$it $quotaUnit")
|
||||||
|
}
|
||||||
|
val billingLabel = when {
|
||||||
|
extras["is_credits_billing"] == "true" -> stringResource(R.string.extra_value_billing_credits)
|
||||||
|
extras["is_dollar_usage_billing"] == "true" -> stringResource(R.string.extra_value_billing_dollar)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
billingLabel?.let {
|
||||||
|
add(stringResource(R.string.extra_label_billing_mode) to it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (rows.isEmpty()) return
|
||||||
|
AccountInfoCard(title = stringResource(R.string.trae_intl_usage_card_title), rows = rows)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TraeIntlNoCheckinCard() {
|
private fun TraeIntlNoCheckinCard() {
|
||||||
Card(
|
Card(
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import com.rainy.token.data.debug.DebugLog
|
|||||||
import com.rainy.token.data.repository.CredentialRepository
|
import com.rainy.token.data.repository.CredentialRepository
|
||||||
import com.rainy.token.data.repository.RepositoryError
|
import com.rainy.token.data.repository.RepositoryError
|
||||||
import com.rainy.token.data.repository.TraeRepository
|
import com.rainy.token.data.repository.TraeRepository
|
||||||
|
import com.rainy.token.data.repository.TraeUsageEvent
|
||||||
import com.rainy.token.data.repository.WorkBuddyRepository
|
import com.rainy.token.data.repository.WorkBuddyRepository
|
||||||
import com.rainy.token.data.repository.TriggerError
|
import com.rainy.token.data.repository.TriggerError
|
||||||
import com.rainy.token.data.repository.TriggerErrorReason
|
import com.rainy.token.data.repository.TriggerErrorReason
|
||||||
@ -36,7 +37,8 @@ class ServiceDetailViewModel @Inject constructor(
|
|||||||
private val balanceCache: BalanceCache,
|
private val balanceCache: BalanceCache,
|
||||||
private val refreshBalanceUseCase: RefreshBalanceUseCase,
|
private val refreshBalanceUseCase: RefreshBalanceUseCase,
|
||||||
private val checkinStatusStore: com.rainy.token.data.repository.CheckinStatusStore,
|
private val checkinStatusStore: com.rainy.token.data.repository.CheckinStatusStore,
|
||||||
private val sub2ApiRepository: com.rainy.token.data.repository.Sub2ApiRepository
|
private val sub2ApiRepository: com.rainy.token.data.repository.Sub2ApiRepository,
|
||||||
|
private val traeRepository: TraeRepository
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _serviceType = MutableStateFlow<ServiceType?>(null)
|
private val _serviceType = MutableStateFlow<ServiceType?>(null)
|
||||||
@ -75,6 +77,10 @@ class ServiceDetailViewModel @Inject constructor(
|
|||||||
private val _checkinInfo = MutableStateFlow<CheckinInfo?>(null)
|
private val _checkinInfo = MutableStateFlow<CheckinInfo?>(null)
|
||||||
val checkinInfo: StateFlow<CheckinInfo?> = _checkinInfo.asStateFlow()
|
val checkinInfo: StateFlow<CheckinInfo?> = _checkinInfo.asStateFlow()
|
||||||
|
|
||||||
|
/** Trae 国际版 Usage Events 列表(详情页直接展示,不跳网页)。 */
|
||||||
|
private val _traeUsageEvents = MutableStateFlow<TraeUsageEventsState>(TraeUsageEventsState.Idle)
|
||||||
|
val traeUsageEvents: StateFlow<TraeUsageEventsState> = _traeUsageEvents.asStateFlow()
|
||||||
|
|
||||||
/** 今日签到信息摘要(仅字段已确认的才填)。 */
|
/** 今日签到信息摘要(仅字段已确认的才填)。 */
|
||||||
data class CheckinInfo(val todayCheckedIn: Boolean, val streakDays: Long)
|
data class CheckinInfo(val todayCheckedIn: Boolean, val streakDays: Long)
|
||||||
|
|
||||||
@ -112,6 +118,34 @@ class ServiceDetailViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 预取 Trae 国际版用量事件(仅 INTL 有 Usage Events;CN 仍走积分体系)。 */
|
||||||
|
private fun preloadUsageEvents(service: ServiceType, accountId: String?) {
|
||||||
|
if (service != ServiceType.TRAE) return
|
||||||
|
if (_traeUsageEvents.value is TraeUsageEventsState.Loading) return
|
||||||
|
val currentRegion = _uiState.value.region
|
||||||
|
if (!currentRegion.equals("INTL", ignoreCase = true)) return
|
||||||
|
_traeUsageEvents.value = TraeUsageEventsState.Loading
|
||||||
|
val generation = serviceGeneration
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result = traeRepository.fetchUsageEvents(accountId)
|
||||||
|
if (generation != serviceGeneration || _serviceType.value != service) return@launch
|
||||||
|
_traeUsageEvents.value = result.fold(
|
||||||
|
onSuccess = { events ->
|
||||||
|
if (events.isEmpty()) TraeUsageEventsState.Success(emptyList())
|
||||||
|
else TraeUsageEventsState.Success(events)
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
TraeUsageEventsState.Error(
|
||||||
|
UiText.Resource(
|
||||||
|
R.string.trae_usage_events_load_failed,
|
||||||
|
listOf(e.message ?: "")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 触发每日签到:仅 WORKBUDDY / TRAE 有效;结果按语义本地化(不直接显示 Repository 中文串)。 */
|
/** 触发每日签到:仅 WORKBUDDY / TRAE 有效;结果按语义本地化(不直接显示 Repository 中文串)。 */
|
||||||
fun checkin() {
|
fun checkin() {
|
||||||
val service = _serviceType.value ?: return
|
val service = _serviceType.value ?: return
|
||||||
@ -415,6 +449,7 @@ class ServiceDetailViewModel @Inject constructor(
|
|||||||
_triggerState.value = TriggerState.Idle
|
_triggerState.value = TriggerState.Idle
|
||||||
// 切换服务/账号时重置签到状态,避免上一服务/账号的 Done/Error 残留(按钮态/文案串台)
|
// 切换服务/账号时重置签到状态,避免上一服务/账号的 Done/Error 残留(按钮态/文案串台)
|
||||||
_checkinState.value = CheckinState.Idle
|
_checkinState.value = CheckinState.Idle
|
||||||
|
_traeUsageEvents.value = TraeUsageEventsState.Idle
|
||||||
|
|
||||||
if (service == ServiceType.CODEX || service == ServiceType.OPENCODE_GO || service == ServiceType.OLLAMA) {
|
if (service == ServiceType.CODEX || service == ServiceType.OPENCODE_GO || service == ServiceType.OLLAMA) {
|
||||||
loadSelectedModel(service)?.let { _selectedModel.value = it }
|
loadSelectedModel(service)?.let { _selectedModel.value = it }
|
||||||
@ -461,6 +496,9 @@ class ServiceDetailViewModel @Inject constructor(
|
|||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
_uiState.update { it.copy(region = currentRegion) }
|
_uiState.update { it.copy(region = currentRegion) }
|
||||||
|
if (type == ServiceType.TRAE && newHasCredential) {
|
||||||
|
preloadUsageEvents(type, accountId)
|
||||||
|
}
|
||||||
|
|
||||||
when (change) {
|
when (change) {
|
||||||
CredentialChange.NONE_TO_NONE, CredentialChange.UNCHANGED -> {
|
CredentialChange.NONE_TO_NONE, CredentialChange.UNCHANGED -> {
|
||||||
@ -590,6 +628,9 @@ class ServiceDetailViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (type == ServiceType.TRAE && _uiState.value.region.equals("INTL", ignoreCase = true)) {
|
||||||
|
preloadUsageEvents(type, accountId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1012,6 +1053,14 @@ sealed class State {
|
|||||||
data object ManualModeHint : State()
|
data object ManualModeHint : State()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Trae 国际版 Usage Events 加载状态。 */
|
||||||
|
sealed class TraeUsageEventsState {
|
||||||
|
data object Idle : TraeUsageEventsState()
|
||||||
|
data object Loading : TraeUsageEventsState()
|
||||||
|
data class Success(val events: List<TraeUsageEvent>) : TraeUsageEventsState()
|
||||||
|
data class Error(val message: UiText) : TraeUsageEventsState()
|
||||||
|
}
|
||||||
|
|
||||||
data class ServiceDetailUiState(
|
data class ServiceDetailUiState(
|
||||||
val state: State = State.Loading,
|
val state: State = State.Loading,
|
||||||
val hasCredential: Boolean = false,
|
val hasCredential: Boolean = false,
|
||||||
|
|||||||
@ -804,4 +804,10 @@
|
|||||||
<string name="trae_intl_no_checkin_desc">Trae 国际版按美元计费,使用月度请求额度,没有签到积分体系。</string>
|
<string name="trae_intl_no_checkin_desc">Trae 国际版按美元计费,使用月度请求额度,没有签到积分体系。</string>
|
||||||
<string name="trae_intl_monthly_quota">月配额</string>
|
<string name="trae_intl_monthly_quota">月配额</string>
|
||||||
<string name="trae_intl_dollar_usage_title">美元用量额度</string>
|
<string name="trae_intl_dollar_usage_title">美元用量额度</string>
|
||||||
|
<string name="action_usage_events">用量事件</string>
|
||||||
|
<string name="trae_intl_usage_card_title">Dollar Usage</string>
|
||||||
|
<string name="trae_usage_events_title">用量事件</string>
|
||||||
|
<string name="trae_usage_events_loading">正在加载用量事件…</string>
|
||||||
|
<string name="trae_usage_events_empty">最近 30 天暂无用量事件</string>
|
||||||
|
<string name="trae_usage_events_load_failed">用量事件加载失败:%1$s</string>
|
||||||
</resources>
|
</resources>
|
||||||
@ -804,4 +804,10 @@
|
|||||||
<string name="trae_intl_no_checkin_desc">Trae 國際版按美元計費,使用月度請求額度,沒有簽到積分體系。</string>
|
<string name="trae_intl_no_checkin_desc">Trae 國際版按美元計費,使用月度請求額度,沒有簽到積分體系。</string>
|
||||||
<string name="trae_intl_monthly_quota">月額度</string>
|
<string name="trae_intl_monthly_quota">月額度</string>
|
||||||
<string name="trae_intl_dollar_usage_title">美元用量額度</string>
|
<string name="trae_intl_dollar_usage_title">美元用量額度</string>
|
||||||
|
<string name="action_usage_events">用量事件</string>
|
||||||
|
<string name="trae_intl_usage_card_title">Dollar Usage</string>
|
||||||
|
<string name="trae_usage_events_title">用量事件</string>
|
||||||
|
<string name="trae_usage_events_loading">正在載入用量事件…</string>
|
||||||
|
<string name="trae_usage_events_empty">最近 30 天暫無用量事件</string>
|
||||||
|
<string name="trae_usage_events_load_failed">用量事件載入失敗:%1$s</string>
|
||||||
</resources>
|
</resources>
|
||||||
@ -809,4 +809,10 @@
|
|||||||
<string name="trae_intl_no_checkin_desc">TRAE International uses USD billing with monthly request quotas instead of credit check-ins.</string>
|
<string name="trae_intl_no_checkin_desc">TRAE International uses USD billing with monthly request quotas instead of credit check-ins.</string>
|
||||||
<string name="trae_intl_monthly_quota">Monthly quota</string>
|
<string name="trae_intl_monthly_quota">Monthly quota</string>
|
||||||
<string name="trae_intl_dollar_usage_title">Dollar allowance</string>
|
<string name="trae_intl_dollar_usage_title">Dollar allowance</string>
|
||||||
|
<string name="action_usage_events">Usage Events</string>
|
||||||
|
<string name="trae_intl_usage_card_title">Dollar Usage</string>
|
||||||
|
<string name="trae_usage_events_title">Usage Events</string>
|
||||||
|
<string name="trae_usage_events_loading">Loading usage events…</string>
|
||||||
|
<string name="trae_usage_events_empty">No usage events in the last 30 days</string>
|
||||||
|
<string name="trae_usage_events_load_failed">Usage events load failed: %1$s</string>
|
||||||
</resources>
|
</resources>
|
||||||
Loading…
Reference in New Issue
Block a user