From dab9b7b272a114408d88af2489defab360f47166 Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Sat, 10 Oct 2026 00:17:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(trae):=20=E8=AF=A6=E6=83=85=E9=A1=B5?= =?UTF-8?q?=E7=9B=B4=E6=8E=A5=E5=B1=95=E7=A4=BA=20INTL=20=E7=94=A8?= =?UTF-8?q?=E9=87=8F=E4=BA=8B=E4=BB=B6=EF=BC=8CINTL=20=E5=85=85=E5=80=BC?= =?UTF-8?q?=E6=94=B9=E5=A4=96=E9=83=A8=E6=B5=8F=E8=A7=88=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 4 +- .../token/data/repository/TraeRepository.kt | 86 ++++++ .../domain/service/ServiceConfigProvider.kt | 15 + .../com/rainy/token/ui/RainyTokenNavHost.kt | 65 +++-- .../token/ui/account/ApiManagementScreen.kt | 10 +- .../ui/account/ApiManagementViewModel.kt | 13 +- .../token/ui/dashboard/DashboardScreen.kt | 8 +- .../token/ui/dashboard/DashboardViewModel.kt | 7 +- .../ui/servicedetail/ServiceDetailScreen.kt | 258 ++++++++++++++---- .../servicedetail/ServiceDetailViewModel.kt | 51 +++- app/src/main/res/values-b+zh+Hans/strings.xml | 6 + app/src/main/res/values-b+zh+Hant/strings.xml | 6 + app/src/main/res/values/strings.xml | 6 + 13 files changed, 454 insertions(+), 81 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f1d1e65..45d7e94 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -16,8 +16,8 @@ android { applicationId = "com.rainy.token" minSdk = 31 targetSdk = 35 - versionCode = 40 - versionName = "1.7.20" + versionCode = 41 + versionName = "1.7.21" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/main/java/com/rainy/token/data/repository/TraeRepository.kt b/app/src/main/java/com/rainy/token/data/repository/TraeRepository.kt index c97ddf2..9b6c909 100644 --- a/app/src/main/java/com/rainy/token/data/repository/TraeRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/TraeRepository.kt @@ -58,6 +58,79 @@ class TraeRepository constructor( 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> = 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 = withContext(Dispatchers.IO) { val credential = credentialRepository.get(ServiceType.TRAE, accountId) ?: 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 +) diff --git a/app/src/main/java/com/rainy/token/domain/service/ServiceConfigProvider.kt b/app/src/main/java/com/rainy/token/domain/service/ServiceConfigProvider.kt index fd16a14..a77f9a7 100644 --- a/app/src/main/java/com/rainy/token/domain/service/ServiceConfigProvider.kt +++ b/app/src/main/java/com/rainy/token/domain/service/ServiceConfigProvider.kt @@ -102,5 +102,20 @@ object ServiceConfigProvider { fun get(type: ServiceType): ServiceConfig = 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 = ServiceType.entries.map { get(it) } } \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt b/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt index 56a4de6..a1bf129 100644 --- a/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt +++ b/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt @@ -1,5 +1,8 @@ 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.ExitTransition 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.WebViewLoginScreen 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() } +private fun Context.openTraeUrl(url: String) { + try { + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) + } catch (_: Exception) { + } +} + @Composable fun RainyTokenNavHost() { val windowSize = rememberWindowSizeClass() @@ -229,6 +238,7 @@ private fun CompactNavHost() { } ) { composable(Routes.DASHBOARD) { + val context = LocalContext.current val rechargeLabel = stringResource(R.string.action_recharge) DashboardScreen( onOpenSettings = { navController.navigate(Routes.SETTINGS) }, @@ -239,21 +249,26 @@ private fun CompactNavHost() { onOpenUsageDetail = { navController.navigate(Routes.USAGE_DETAIL) }, onOpenCcgoUsageDetail = { navController.navigate(Routes.CCGO_USAGE_DETAIL) }, onOpenHeatmap = { navController.navigate(Routes.HEATMAP) }, - onOpenRecharge = { type -> - ServiceConfigProvider.get(type).externalUrl?.let { url -> - navController.navigate(Routes.webviewPage(url, rechargeLabel)) + onOpenRecharge = { type, region -> + val url = ServiceConfigProvider.externalUrlFor(type, region) + 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) { + val context = LocalContext.current val rechargeLabel = stringResource(R.string.action_recharge) ApiManagementScreen( onBack = guardedPop, onConfigure = { type -> navController.navigate(credentialEntry(type)) }, - onRecharge = { type -> - ServiceConfigProvider.get(type).externalUrl?.let { url -> - navController.navigate(Routes.webviewPage(url, rechargeLabel)) + onRecharge = { type, region -> + val url = ServiceConfigProvider.externalUrlFor(type, region) + 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 -> val type = parseServiceType(backStackEntry.arguments?.getString("type")) val accountId = backStackEntry.arguments?.getString("account") + val context = LocalContext.current val rechargeLabel = stringResource(R.string.action_recharge) ServiceDetailScreen( service = type, @@ -428,9 +444,11 @@ private fun CompactNavHost() { }, onConfigureCredential = { svc -> navController.navigate(credentialEntry(svc)) }, onStartWebViewLogin = { svc, acc, region -> navController.navigate(Routes.webviewLogin(svc, acc, region)) }, - onOpenRecharge = { svc -> - ServiceConfigProvider.get(svc).externalUrl?.let { url -> - navController.navigate(Routes.webviewPage(url, rechargeLabel)) + onOpenRecharge = { svc, region -> + val url = ServiceConfigProvider.externalUrlFor(svc, region) + 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 } + val context = LocalContext.current val rechargeLabel = stringResource(R.string.action_recharge) Row(modifier = Modifier.fillMaxSize()) { @@ -488,9 +507,11 @@ private fun ExpandedLayout() { onOpenUsageDetail = { detailPane = DetailPane.OCGOUsage }, onOpenCcgoUsageDetail = { detailPane = DetailPane.CCGOUsage }, onOpenHeatmap = { detailPane = DetailPane.Heatmap }, - onOpenRecharge = { type -> - ServiceConfigProvider.get(type).externalUrl?.let { url -> - detailPane = DetailPane.WebPage(url = url, title = rechargeLabel) + onOpenRecharge = { type, region -> + val url = ServiceConfigProvider.externalUrlFor(type, region) + 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 @@ -546,6 +567,7 @@ private fun ExpandedDetailPane( popExitTransition = { ExitTransition.None } ) { composable("detail") { + val context = LocalContext.current val rechargeLabel = LocalContext.current.getString(R.string.action_recharge) ServiceDetailScreen( service = pane.type, @@ -554,9 +576,11 @@ private fun ExpandedDetailPane( onSwitchAccount = onSwitchAccount, onConfigureCredential = { onClose() }, onStartWebViewLogin = { _, _, _ -> }, - onOpenRecharge = { svc -> - ServiceConfigProvider.get(svc).externalUrl?.let { url -> - navController.navigate(Routes.webviewPage(url, rechargeLabel)) + onOpenRecharge = { svc, region -> + val url = ServiceConfigProvider.externalUrlFor(svc, region) + 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 } ) { composable("api_mgmt_main") { + val context = LocalContext.current val rechargeLabel = LocalContext.current.getString(R.string.action_recharge) ApiManagementScreen( onBack = onClose, onConfigure = { type -> apiNavController.navigate(credentialEntry(type)) }, - onRecharge = { type -> - ServiceConfigProvider.get(type).externalUrl?.let { url -> - apiNavController.navigate(Routes.webviewPage(url, rechargeLabel)) + onRecharge = { type, region -> + val url = ServiceConfigProvider.externalUrlFor(type, region) + 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)) } } ) diff --git a/app/src/main/java/com/rainy/token/ui/account/ApiManagementScreen.kt b/app/src/main/java/com/rainy/token/ui/account/ApiManagementScreen.kt index 903d253..f82fe15 100644 --- a/app/src/main/java/com/rainy/token/ui/account/ApiManagementScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/account/ApiManagementScreen.kt @@ -85,7 +85,7 @@ import java.util.Locale fun ApiManagementScreen( onBack: () -> Unit, onConfigure: (ServiceType) -> Unit, - onRecharge: (ServiceType) -> Unit, + onRecharge: (ServiceType, String?) -> Unit, viewModel: ApiManagementViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -150,7 +150,7 @@ fun ApiManagementScreen( onEdit = { onConfigure(row.service) }, onTest = { viewModel.test(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, onRecharge: () -> Unit ) { - // 有官方充值/订阅页的服务(ServiceConfig.externalUrl)才展示充值入口 - val rechargeUrl = remember(row.service) { - runCatching { ServiceConfigProvider.get(row.service).externalUrl }.getOrNull() + // 有官方充值/订阅页的服务才展示充值入口(Trae 按账号区域分流) + val rechargeUrl = remember(row.service, row.region) { + runCatching { ServiceConfigProvider.externalUrlFor(row.service, row.region) }.getOrNull() } Card( modifier = Modifier diff --git a/app/src/main/java/com/rainy/token/ui/account/ApiManagementViewModel.kt b/app/src/main/java/com/rainy/token/ui/account/ApiManagementViewModel.kt index abe5c80..097d831 100644 --- a/app/src/main/java/com/rainy/token/ui/account/ApiManagementViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/account/ApiManagementViewModel.kt @@ -63,6 +63,15 @@ class ApiManagementViewModel @Inject constructor( credentialRepository.accountsFor(type).size } else { 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 maskedCredential: String?, /** 多账号服务的账号个数;单账号服务为 0。 */ - val accountCount: Int = 0 + val accountCount: Int = 0, + /** Trae 账号区域(用于选择官方充值/用量页)。 */ + val region: String? = null ) { val isConfigured: Boolean get() = state != CredentialStatus.State.NOT_CONFIGURED diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt index 63e1ccd..6d57694 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt @@ -138,7 +138,7 @@ fun DashboardScreen( onOpenUsageDetail: () -> Unit, onOpenCcgoUsageDetail: () -> Unit = {}, onOpenHeatmap: () -> Unit = {}, - onOpenRecharge: (ServiceType) -> Unit = {}, + onOpenRecharge: (ServiceType, String?) -> Unit = { _, _ -> }, viewModel: DashboardViewModel = hiltViewModel() ) { // 从设置页返回时重新读取本地凭据状态 + 缓存;若有已配置但无余额缓存的 @@ -549,7 +549,7 @@ private fun rememberDashboardItems( onOpenService: (ServiceType, String?) -> Unit, onOpenHeatmap: () -> Unit, onOpenSub2PanelManagement: (String?) -> Unit, - onOpenRecharge: (ServiceType) -> Unit, + onOpenRecharge: (ServiceType, String?) -> Unit, refreshTrigger: Int ): List { val defaultItems = buildList { @@ -607,8 +607,8 @@ private fun rememberDashboardItems( null }, // 充值/订阅入口:有官方 externalUrl 的服务(DeepSeek/Trae/WorkBuddy)在卡片上直开 - onOpenRecharge = if (ServiceConfigProvider.get(card.service).externalUrl != null) { - { onOpenRecharge(card.service) } + onOpenRecharge = if (ServiceConfigProvider.get(card.service).externalUrl != null || card.service == ServiceType.TRAE) { + { onOpenRecharge(card.service, card.region) } } else { null }, diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt index c9fe07f..903db1c 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt @@ -287,7 +287,8 @@ class DashboardViewModel @Inject constructor( siteIcon = (local.credential as? Credential.Sub2ApiCredential)?.siteIcon, // 可签到服务(Trae/WorkBuddy)各账号的今日签到角标;其它服务恒 false 不显示 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( @@ -463,7 +464,9 @@ data class DashboardCardUi( /** 站点 Logo URL(Sub2API 面板登录后抓取;非空时主页卡片用它替换内置图标)。 */ val siteIcon: String? = null, /** 今日已签到角标(仅可签到服务有值,见 [CHECKIN_SERVICES])。 */ - val checkedInToday: Boolean = false + val checkedInToday: Boolean = false, + /** 账号区域(Trae CN/INTL 等,用于选择官方充值/用量页)。 */ + val region: String? = null ) { /** 余额展示主数字。优先取缓存,错误时也展示(不隐藏,让用户看到旧值 + 红点提示)。 */ val displayBalance: ServiceBalance? get() = cachedBalance?.balance diff --git a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt index 881c93c..c48e1a5 100644 --- a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt @@ -57,6 +57,7 @@ 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.text.style.TextOverflow import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel @@ -66,6 +67,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.rainy.token.R import com.rainy.token.data.repository.ModelUsageRow 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.WindowModelUsage import com.rainy.token.domain.model.ServiceBalance @@ -115,7 +117,7 @@ fun ServiceDetailScreen( onSwitchAccount: (ServiceType, String?) -> Unit = { _, _ -> }, onConfigureCredential: (ServiceType) -> Unit, onStartWebViewLogin: (ServiceType, String?, String?) -> Unit, - onOpenRecharge: (ServiceType) -> Unit = { }, + onOpenRecharge: (ServiceType, String?) -> Unit = { _, _ -> }, viewModel: ServiceDetailViewModel = hiltViewModel() ) { LaunchedEffect(service, accountId) { viewModel.bind(service, accountId) } @@ -129,6 +131,7 @@ fun ServiceDetailScreen( val selectedModel by viewModel.selectedModel.collectAsStateWithLifecycle() val modelsLoading by viewModel.modelsLoading.collectAsStateWithLifecycle() val checkinState by viewModel.checkinState.collectAsStateWithLifecycle() + val traeUsageEvents by viewModel.traeUsageEvents.collectAsStateWithLifecycle() val checkinInfo by viewModel.checkinInfo.collectAsStateWithLifecycle() val accounts by viewModel.accounts.collectAsStateWithLifecycle() @@ -235,27 +238,31 @@ fun ServiceDetailScreen( item { OllamaUsageCard(uiState.state) } } ServiceType.TRAE -> { - // 每日签到卡;未配置凭据时展示引导卡,国际版无签到则展示说明卡 - item { - val intl = uiState.region != null && uiState.region.equals("INTL", ignoreCase = true) - if (intl) { - TraeIntlNoCheckinCard() - } else if (uiState.hasCredential) { - CheckinCard( - state = checkinState, - todayCheckedIn = checkinInfo?.todayCheckedIn == true, - streakDays = checkinInfo?.streakDays ?: 0L, - isTrae = true, - onCheckin = { viewModel.checkin() } - ) - } else { - CheckinNeedCredentialCard() + val intl = uiState.region != null && uiState.region.equals("INTL", ignoreCase = true) + if (intl) { + // 国际版:单张用量卡(套餐/美元额度/月请求配额),不再重复展示签到卡和积分账户卡 + item { TraeIntlUsageCard(uiState.state) } + item { TraeUsageEventsCard(traeUsageEvents) } + } else { + // 每日签到卡;未配置凭据时展示引导卡,避免暴露必然失败的签到按钮 + item { + if (uiState.hasCredential) { + CheckinCard( + state = checkinState, + todayCheckedIn = checkinInfo?.todayCheckedIn == true, + streakDays = checkinInfo?.streakDays ?: 0L, + isTrae = true, + 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 -> { item { @@ -319,7 +326,9 @@ fun ServiceDetailScreen( onRefresh = { viewModel.refresh() }, onConfigureCredential = { onConfigureCredential(service) }, 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() }, onSelectModel = { viewModel.selectModel(it) }, onRefreshModels = { viewModel.loadModels(force = true) } @@ -1096,46 +1105,37 @@ private fun MainBalanceCard(state: State, service: ServiceType) { @Composable private fun TraeIntlBigNumber(extras: Map) { - val planName = extras["intl.plan_name"] ?: "Free plan" + val dollarUsed = extras["intl.dollar_used"]?.toDoubleOrNull() + val dollarLimit = extras["intl.dollar_limit"]?.toDoubleOrNull() 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, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface ) Spacer(modifier = Modifier.width(6.dp)) 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, color = inkMuted(), 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) { - InfoRow( - label = stringResource(R.string.trae_intl_dollar_usage_title), - value = String.format(Locale.US, "\$%.2f / \$%.2f", dollarUsed ?: 0.0, dollarLimit) + Spacer(modifier = Modifier.height(4.dp)) + Text( + 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 @@ -1613,6 +1613,170 @@ internal fun TraeAccountInfoCard(state: State) { * * 旧缓存无 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 private fun TraeIntlNoCheckinCard() { Card( diff --git a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModel.kt b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModel.kt index a7aa4ae..bfdef13 100644 --- a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModel.kt @@ -8,6 +8,7 @@ import com.rainy.token.data.debug.DebugLog import com.rainy.token.data.repository.CredentialRepository import com.rainy.token.data.repository.RepositoryError 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.TriggerError import com.rainy.token.data.repository.TriggerErrorReason @@ -36,7 +37,8 @@ class ServiceDetailViewModel @Inject constructor( private val balanceCache: BalanceCache, private val refreshBalanceUseCase: RefreshBalanceUseCase, 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() { private val _serviceType = MutableStateFlow(null) @@ -75,6 +77,10 @@ class ServiceDetailViewModel @Inject constructor( private val _checkinInfo = MutableStateFlow(null) val checkinInfo: StateFlow = _checkinInfo.asStateFlow() + /** Trae 国际版 Usage Events 列表(详情页直接展示,不跳网页)。 */ + private val _traeUsageEvents = MutableStateFlow(TraeUsageEventsState.Idle) + val traeUsageEvents: StateFlow = _traeUsageEvents.asStateFlow() + /** 今日签到信息摘要(仅字段已确认的才填)。 */ 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 中文串)。 */ fun checkin() { val service = _serviceType.value ?: return @@ -415,6 +449,7 @@ class ServiceDetailViewModel @Inject constructor( _triggerState.value = TriggerState.Idle // 切换服务/账号时重置签到状态,避免上一服务/账号的 Done/Error 残留(按钮态/文案串台) _checkinState.value = CheckinState.Idle + _traeUsageEvents.value = TraeUsageEventsState.Idle if (service == ServiceType.CODEX || service == ServiceType.OPENCODE_GO || service == ServiceType.OLLAMA) { loadSelectedModel(service)?.let { _selectedModel.value = it } @@ -461,6 +496,9 @@ class ServiceDetailViewModel @Inject constructor( else -> null } _uiState.update { it.copy(region = currentRegion) } + if (type == ServiceType.TRAE && newHasCredential) { + preloadUsageEvents(type, accountId) + } when (change) { 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() } +/** Trae 国际版 Usage Events 加载状态。 */ +sealed class TraeUsageEventsState { + data object Idle : TraeUsageEventsState() + data object Loading : TraeUsageEventsState() + data class Success(val events: List) : TraeUsageEventsState() + data class Error(val message: UiText) : TraeUsageEventsState() +} + data class ServiceDetailUiState( val state: State = State.Loading, val hasCredential: Boolean = false, diff --git a/app/src/main/res/values-b+zh+Hans/strings.xml b/app/src/main/res/values-b+zh+Hans/strings.xml index 6295489..1fa83c9 100644 --- a/app/src/main/res/values-b+zh+Hans/strings.xml +++ b/app/src/main/res/values-b+zh+Hans/strings.xml @@ -804,4 +804,10 @@ Trae 国际版按美元计费,使用月度请求额度,没有签到积分体系。 月配额 美元用量额度 + 用量事件 + Dollar Usage + 用量事件 + 正在加载用量事件… + 最近 30 天暂无用量事件 + 用量事件加载失败:%1$s \ No newline at end of file diff --git a/app/src/main/res/values-b+zh+Hant/strings.xml b/app/src/main/res/values-b+zh+Hant/strings.xml index b553c5b..8f6dedd 100644 --- a/app/src/main/res/values-b+zh+Hant/strings.xml +++ b/app/src/main/res/values-b+zh+Hant/strings.xml @@ -804,4 +804,10 @@ Trae 國際版按美元計費,使用月度請求額度,沒有簽到積分體系。 月額度 美元用量額度 + 用量事件 + Dollar Usage + 用量事件 + 正在載入用量事件… + 最近 30 天暫無用量事件 + 用量事件載入失敗:%1$s \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ba80a64..fd8f264 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -809,4 +809,10 @@ TRAE International uses USD billing with monthly request quotas instead of credit check-ins. Monthly quota Dollar allowance + Usage Events + Dollar Usage + Usage Events + Loading usage events… + No usage events in the last 30 days + Usage events load failed: %1$s \ No newline at end of file