From a26629027ed19c3870009e6cfddc7c38d82eb97b Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Sat, 1 Aug 2026 07:32:23 +0000 Subject: [PATCH 01/14] =?UTF-8?q?i18n=20=E9=98=B6=E6=AE=B5=E4=B8=80?= =?UTF-8?q?=EF=BC=9A=E5=85=A8=E9=87=8F=E5=AD=97=E7=AC=A6=E4=B8=B2=E8=B5=84?= =?UTF-8?q?=E6=BA=90=E5=8C=96=EF=BC=88=E8=A1=8C=E4=B8=BA=E4=B8=8D=E5=8F=98?= =?UTF-8?q?=EF=BC=8C=E4=BB=8D=E4=B8=BA=E4=B8=AD=E6=96=87=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - strings.xml 扩充至 ~470 条(原 3 条),全部 UI 硬编码中文迁移为资源引用 - 新增 UiText 抽象(Resource/Dynamic + asString()/resolve()),6 个 ViewModel 错误消息 UiText 化 - AppTips 13 条改为 @StringRes 资源 ID;TimeFilter/ChartGranularity/StatusStyle/DashboardViewModel 徽章改资源 ID - FormatUtils 保持纯函数,DurationText/标签参数注入(78 个纯 JVM 测试不受影响) - Widget XML(2) + WidgetProvider 文字资源化;品牌名 translatable=false - 开启 generateLocaleConfig + resources.properties(unqualifiedResLocale=zh-CN) - 修复审查发现的每周/每月行为回归(dashboard/widget 保持'每周/每月') - 删除死资源/死代码;ChartGranularity 粒度标签补 time_last_12h - 验证:compileDebugKotlin / testDebugUnitTest(78) / lintDebug / assembleDebug 全绿 --- .gitignore | 1 + AGENTS.md | 9 + app/build.gradle.kts | 4 + .../com/rainy/token/data/local/ChartBucket.kt | 26 +- .../com/rainy/token/ui/RainyTokenNavHost.kt | 6 +- .../com/rainy/token/ui/components/AppTips.kt | 54 +- .../token/ui/components/DebugLogScreen.kt | 10 +- .../rainy/token/ui/components/FormatUtils.kt | 40 +- .../rainy/token/ui/components/StatusChip.kt | 6 +- .../com/rainy/token/ui/components/UiText.kt | 44 ++ .../ui/dashboard/CommandCodeUsageStatsCard.kt | 24 +- .../token/ui/dashboard/DashboardScreen.kt | 43 +- .../token/ui/dashboard/DashboardViewModel.kt | 8 - .../token/ui/dashboard/ServiceBalanceCards.kt | 138 +++-- .../token/ui/dashboard/UsageChartScreen.kt | 88 ++-- .../token/ui/dashboard/UsageDataScreen.kt | 72 +-- .../token/ui/dashboard/UsageDetailScreen.kt | 110 ++-- .../token/ui/dashboard/UsageOverviewScreen.kt | 69 +-- .../token/ui/dashboard/UsageStatsCard.kt | 24 +- .../token/ui/dashboard/UsageViewModel.kt | 24 +- .../ui/servicedetail/ServiceDetailScreen.kt | 166 +++--- .../servicedetail/ServiceDetailViewModel.kt | 69 ++- .../token/ui/settings/CredentialEditScreen.kt | 201 ++++---- .../ui/settings/CredentialEditViewModel.kt | 132 +++-- .../rainy/token/ui/settings/SettingsScreen.kt | 37 +- .../token/ui/webview/CodexOAuthScreen.kt | 44 +- .../token/ui/webview/CodexOAuthViewModel.kt | 28 +- .../token/ui/webview/WebViewLoginScreen.kt | 18 +- .../token/ui/webview/WebViewLoginViewModel.kt | 6 +- .../ui/widget/OpenCodeGoWidgetProvider.kt | 53 +- .../main/res/layout/widget_opencode_go.xml | 10 +- .../res/layout/widget_opencode_go_preview.xml | 10 +- app/src/main/res/resources.properties | 4 + app/src/main/res/values/strings.xml | 471 +++++++++++++++++- 34 files changed, 1397 insertions(+), 652 deletions(-) create mode 100644 app/src/main/java/com/rainy/token/ui/components/UiText.kt create mode 100644 app/src/main/res/resources.properties diff --git a/.gitignore b/.gitignore index f8cf874..91b2124 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ release.jks .kotlin/ *.jks tmp_cc/ +goal.md diff --git a/AGENTS.md b/AGENTS.md index c25b15b..ee7f4c2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,14 @@ # 雨晴Token — AI 余额查询 APP +## ⚠️ 提交/推送许可红线(最高优先级) + +> 本仓库的所有 Git 写操作(`git commit`、`git push`、创建/合并 PR、推送 tag 等)**必须事先获得水晴喵的明确同意**,未经允许一律禁止执行。 +> +> - 任何代码修改完成后,只做本地编辑,**不得擅自 commit / push**。需要提交时,先向水晴喵说明改动内容与提交意图,等她说「可以提交」「push 吧」之类的明确许可后再执行。 +> - 不得以「PR 审查」「CI 验证」等理由绕过许可——创建 PR、推送分支也属于写操作,同样需要先征求同意。 +> - 水晴喵说「不用了」「先别提交」「再看看」时,保持当前状态,不 push 任何东西。 +> - 这条规则优先级高于文件中其它任何流程(包括「重大修改 PR 审计红线」),其它规则只决定**怎么提交**,这条决定**能不能提交**。 + ## 项目概述 Android(Jetpack Compose + Kotlin)APP,统一查询 DeepSeek、OpenCode Go、CommandCode Go、Codex / ChatGPT Plus、Ollama Pro 的余额/配额。 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ca55121..d50923a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -76,6 +76,10 @@ android { buildFeatures { compose = true } + androidResources { + // Android 13+ 应用级语言设置:根据 values-* 目录自动生成 locales_config + generateLocaleConfig = true + } packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" diff --git a/app/src/main/java/com/rainy/token/data/local/ChartBucket.kt b/app/src/main/java/com/rainy/token/data/local/ChartBucket.kt index 348862b..b73007e 100644 --- a/app/src/main/java/com/rainy/token/data/local/ChartBucket.kt +++ b/app/src/main/java/com/rainy/token/data/local/ChartBucket.kt @@ -1,5 +1,7 @@ package com.rainy.token.data.local +import androidx.annotation.StringRes +import com.rainy.token.R import java.time.Instant import java.time.ZoneOffset @@ -25,18 +27,18 @@ data class ModelBucket( val outputTokens: Long ) -/** 时间粒度 */ -enum class ChartGranularity(val label: String) { - LAST_5H_HOURLY("最近5小时"), - LAST_12H_10MIN("最近12小时"), - LAST_24H_HOURLY("最近24小时"), - TODAY_HOURLY("今天"), - YESTERDAY_HOURLY("昨天"), - LAST_7D_DAILY("最近7天"), - THIS_MONTH_DAILY("当月"), - CUSTOM_DAY_HOURLY("自定义日"), - CUSTOM_MONTH_DAILY("自定义月"), - CUSTOM_RANGE_DAILY("自定义日期") +/** 时间粒度(label 用字符串资源 ID,UI 层按当前语言解析) */ +enum class ChartGranularity(@StringRes val labelRes: Int) { + LAST_5H_HOURLY(R.string.time_last_5h), + LAST_12H_10MIN(R.string.time_last_12h), + LAST_24H_HOURLY(R.string.time_last_24h), + TODAY_HOURLY(R.string.time_today), + YESTERDAY_HOURLY(R.string.time_yesterday), + LAST_7D_DAILY(R.string.time_last_7d), + THIS_MONTH_DAILY(R.string.time_this_month), + CUSTOM_DAY_HOURLY(R.string.time_custom_day), + CUSTOM_MONTH_DAILY(R.string.time_custom_month), + CUSTOM_RANGE_DAILY(R.string.time_custom_range) } /** 聚合工具 */ 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 49b49cc..9035a0b 100644 --- a/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt +++ b/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt @@ -24,6 +24,7 @@ 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.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel @@ -33,6 +34,7 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState 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.ServiceType import com.rainy.token.ui.components.rememberWindowSizeClass @@ -368,8 +370,8 @@ private fun ExpandedDetailPane( is DetailPane.Empty -> { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text("👈 选择服务查看详情", style = MaterialTheme.typography.titleMedium, color = inkMuted()) - Text("点击左侧卡片即可", style = MaterialTheme.typography.bodySmall, color = inkMuted()) + Text(stringResource(R.string.nav_empty_title), style = MaterialTheme.typography.titleMedium, color = inkMuted()) + Text(stringResource(R.string.nav_empty_subtitle), style = MaterialTheme.typography.bodySmall, color = inkMuted()) } } } diff --git a/app/src/main/java/com/rainy/token/ui/components/AppTips.kt b/app/src/main/java/com/rainy/token/ui/components/AppTips.kt index a688fb8..403d715 100644 --- a/app/src/main/java/com/rainy/token/ui/components/AppTips.kt +++ b/app/src/main/java/com/rainy/token/ui/components/AppTips.kt @@ -28,44 +28,50 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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.annotation.StringRes +import com.rainy.token.R import com.rainy.token.ui.theme.InkMuted import com.rainy.token.ui.theme.StrawberryPink /** * APP 使用小技巧列表。 * Dashboard 卡片上方随机展示一条,设置页可查看全部。 + * + * 文案全部改为字符串资源 ID(@StringRes),由 UI 层按当前语言环境解析, + * 保证英文系统下自动显示英文翻译。 */ object AppTips { data class Tip( - val title: String, - val hint: String, - val detail: String + @StringRes val titleRes: Int, + @StringRes val hintRes: Int, + @StringRes val detailRes: Int ) val tips: List = listOf( - Tip("长按卡片拖拽排序", "长按首页卡片可以拖拽排序", "在首页长按任意服务卡片即可拖拽到想要的位置,排序会自动保存。"), - Tip("小组件点左上角进 APP", "小组件左上角标识可打开 APP", "桌面小组件左上角 RainyToken › 标识可以点击打开应用,其它区域点击切换服务。"), - Tip("图表点击查看详情", "点击图表柱子可查看时段详情", "在用量图表上点击或滑动手指,可查看对应时段的详细数值。"), - Tip("图表标题可点击", "点击图表标题弹出按模型明细", "图表卡片标题(如「消耗金额」)点击后会弹出按模型拆分的明细。"), - Tip("切换图表时区", "用量详情页可切换 UTC+0 / UTC+8", "用量详情页顶部可切换 UTC+0 / UTC+8,图表标签会跟随切换。"), - Tip("CCGO 清除并重新同步", "CCGO 详情页可清除缓存重新同步", "CCGO 用量详情页右上角「清除」可删除本地缓存并重新拉取全部数据。"), - Tip("查看用量详情", "服务卡片底部可进入用量图表", "有凭据的服务卡片底部会出现「查看用量详情」入口,点击进入图表统计。"), - Tip("原始数据浏览", "用量详情页右上角可查看原始记录", "用量详情页右上角「详细数据」可查看每条原始调用记录,支持分页跳转。"), - Tip("下拉刷新联动用量", "下拉刷新会同步余额和用量", "首页下拉刷新不仅刷新余额,还会自动同步 OCGO / CCGO 用量记录。"), - Tip("凭据获取教程", "凭据页底部有获取教程", "凭据编辑页底部的「如何获取」按钮有针对每个服务的详细操作步骤。"), - Tip("剪贴板自动导入", "Codex 支持从剪贴板一键导入", "Codex 凭据页支持「从剪贴板自动导入」,粘贴 auth.json 后一键填入。"), - Tip("详细数据页码跳转", "原始数据页可输入页码直跳", "原始数据页底部可输入页码直接跳转,数据量大时不必逐页翻。"), - Tip("小组件自动刷新", "划到小组件会自动刷新余额", "桌面小组件划到负一屏时会自动刷新余额,也可手动点 ↻ 按钮刷新。"), + Tip(R.string.tip_1_title, R.string.tip_1_hint, R.string.tip_1_detail), + Tip(R.string.tip_2_title, R.string.tip_2_hint, R.string.tip_2_detail), + Tip(R.string.tip_3_title, R.string.tip_3_hint, R.string.tip_3_detail), + Tip(R.string.tip_4_title, R.string.tip_4_hint, R.string.tip_4_detail), + Tip(R.string.tip_5_title, R.string.tip_5_hint, R.string.tip_5_detail), + Tip(R.string.tip_6_title, R.string.tip_6_hint, R.string.tip_6_detail), + Tip(R.string.tip_7_title, R.string.tip_7_hint, R.string.tip_7_detail), + Tip(R.string.tip_8_title, R.string.tip_8_hint, R.string.tip_8_detail), + Tip(R.string.tip_9_title, R.string.tip_9_hint, R.string.tip_9_detail), + Tip(R.string.tip_10_title, R.string.tip_10_hint, R.string.tip_10_detail), + Tip(R.string.tip_11_title, R.string.tip_11_hint, R.string.tip_11_detail), + Tip(R.string.tip_12_title, R.string.tip_12_hint, R.string.tip_12_detail), + Tip(R.string.tip_13_title, R.string.tip_13_hint, R.string.tip_13_detail), ) - /** 首页轮换用的一句话文案。 */ - val shortHints: List = tips.map { it.hint } + /** 首页轮换用的一句话文案(资源 ID 形式)。 */ + val shortHintRes: List = tips.map { it.hintRes } - /** 随机取一条短文案。 */ - fun randomHint(): String = shortHints.random() + /** 随机取一条短文案的资源 ID。 */ + fun randomHintRes(): Int = shortHintRes.random() } @OptIn(ExperimentalMaterial3Api::class) @@ -77,7 +83,7 @@ fun TipsScreen(onBack: () -> Unit) { TopAppBar( title = { Text( - "使用小技巧", + stringResource(R.string.title_tips), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold ) @@ -86,7 +92,7 @@ fun TipsScreen(onBack: () -> Unit) { IconButton(onClick = onBack) { Icon( Icons.Filled.ArrowBack, - contentDescription = "返回", + contentDescription = stringResource(R.string.action_back), tint = StrawberryPink ) } @@ -123,14 +129,14 @@ fun TipsScreen(onBack: () -> Unit) { ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = tip.title, + text = stringResource(tip.titleRes), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold ) } Spacer(modifier = Modifier.height(6.dp)) Text( - text = tip.detail, + text = stringResource(tip.detailRes), style = MaterialTheme.typography.bodyMedium, color = InkMuted ) diff --git a/app/src/main/java/com/rainy/token/ui/components/DebugLogScreen.kt b/app/src/main/java/com/rainy/token/ui/components/DebugLogScreen.kt index 2a180b2..15d7cb8 100644 --- a/app/src/main/java/com/rainy/token/ui/components/DebugLogScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/components/DebugLogScreen.kt @@ -31,10 +31,12 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.rainy.token.R import com.rainy.token.data.debug.DebugLog import com.rainy.token.ui.theme.InkMuted import com.rainy.token.ui.theme.StrawberryPink @@ -50,7 +52,7 @@ fun DebugLogScreen(onBack: () -> Unit) { TopAppBar( title = { Text( - "调试日志", + stringResource(R.string.title_debug_log), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold ) @@ -59,14 +61,14 @@ fun DebugLogScreen(onBack: () -> Unit) { IconButton(onClick = onBack) { Icon( Icons.Filled.ArrowBack, - contentDescription = "返回", + contentDescription = stringResource(R.string.action_back), tint = StrawberryPink ) } }, actions = { TextButton(onClick = { DebugLog.clear() }) { - Text("清空", color = StrawberryPink) + Text(stringResource(R.string.action_clear), color = StrawberryPink) } }, colors = TopAppBarDefaults.topAppBarColors( @@ -83,7 +85,7 @@ fun DebugLogScreen(onBack: () -> Unit) { contentAlignment = Alignment.Center ) { Text( - "暂无日志记录\n刷新 Codex 用量后这里会出现调试信息", + stringResource(R.string.debug_log_empty), style = MaterialTheme.typography.bodyMedium, color = InkMuted, modifier = Modifier.padding(32.dp) diff --git a/app/src/main/java/com/rainy/token/ui/components/FormatUtils.kt b/app/src/main/java/com/rainy/token/ui/components/FormatUtils.kt index 5e98ff9..55ad00c 100644 --- a/app/src/main/java/com/rainy/token/ui/components/FormatUtils.kt +++ b/app/src/main/java/com/rainy/token/ui/components/FormatUtils.kt @@ -6,42 +6,58 @@ import java.util.Locale * 共享格式化工具函数。 * * 从 DashboardScreen / ServiceDetailScreen / WidgetProvider 提取,消除三处重复。 + * + * 本地化策略:函数保持纯 JVM(不依赖 Context / Resources,可被单元测试直接调用), + * 本地化标签通过 [DurationText] / 字符串参数注入。调用方(Compose / Widget)用 + * `context.getString(...)` 取得当前语言标签后传入;测试继续使用默认中文标签。 */ -/** 格式化金额:整数无小数点,带小数保留 2 位 */ +/** 时长标签(用于 "X 天 Y 小时" 这类拼接)。 */ +data class DurationText( + val day: String, + val hour: String, + val minute: String, + /** 无有效时长时展示的占位符 */ + val dash: String = "—" +) + +/** 默认中文标签(保持与原实现输出完全一致,供单元测试与兜底使用)。 */ +val ChineseDurationText = DurationText(day = "天", hour = "小时", minute = "分") + +/** 格式化金额:整数无小数点,带小数保留 2 位(金额数值格式固定 Locale.US,避免小数点歧义) */ fun formatAmount(value: Double): String { return if (value % 1.0 == 0.0) value.toInt().toString() else String.format(Locale.US, "%.2f", value) } /** 格式化重置倒计时(秒 → "X 天 Y 小时" / "X 小时 Y 分" / "Y 分"),无效返回 "—" */ -fun formatResetInSec(sec: Long): String { - if (sec <= 0) return "—" +fun formatResetInSec(sec: Long, text: DurationText = ChineseDurationText): String { + if (sec <= 0) return text.dash val days = sec / 86400 val hours = (sec % 86400) / 3600 val minutes = (sec % 3600) / 60 return when { - days > 0 -> "$days 天 $hours 小时" - hours > 0 -> "$hours 小时 $minutes 分" - else -> "$minutes 分" + days > 0 -> "$days ${text.day} $hours ${text.hour}" + hours > 0 -> "$hours ${text.hour} $minutes ${text.minute}" + else -> "$minutes ${text.minute}" } } /** Widget 版本:逻辑相同但空值返回空串(非 "—"),适配 RemoteViews 布局 */ -fun formatResetForWidget(sec: Long): String { +fun formatResetForWidget(sec: Long, text: DurationText = ChineseDurationText): String { if (sec <= 0) return "" val days = sec / 86400 val hours = (sec % 86400) / 3600 val minutes = (sec % 3600) / 60 return when { - days > 0 -> "$days 天 $hours 小时" - hours > 0 -> "$hours 小时 $minutes 分" - else -> "$minutes 分" + days > 0 -> "$days ${text.day} $hours ${text.hour}" + hours > 0 -> "$hours ${text.hour} $minutes ${text.minute}" + else -> "$minutes ${text.minute}" } } /** 标准化窗口标签:weekly → 每周,其他原样返回 */ -fun normalizeWindowLabel(label: String): String = when (label.lowercase()) { - "weekly" -> "每周" +fun normalizeWindowLabel(label: String, weeklyLabel: String = "每周"): String = when (label.lowercase()) { + "weekly" -> weeklyLabel else -> label } \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/ui/components/StatusChip.kt b/app/src/main/java/com/rainy/token/ui/components/StatusChip.kt index a0ed758..4ae9658 100644 --- a/app/src/main/java/com/rainy/token/ui/components/StatusChip.kt +++ b/app/src/main/java/com/rainy/token/ui/components/StatusChip.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.annotation.StringRes import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -16,6 +17,7 @@ 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.unit.dp import com.rainy.token.ui.theme.StatusBlue import com.rainy.token.ui.theme.StatusGreen @@ -33,7 +35,7 @@ import com.rainy.token.ui.theme.StatusRed enum class StatusLevel { OK, WARNING, ERROR, INFO } data class StatusStyle( - val label: String, + @StringRes val labelRes: Int, val level: StatusLevel ) @@ -64,7 +66,7 @@ fun StatusChip( ) Spacer(modifier = Modifier.width(6.dp)) Text( - text = style.label, + text = stringResource(style.labelRes), style = MaterialTheme.typography.labelSmall, color = textColor ) diff --git a/app/src/main/java/com/rainy/token/ui/components/UiText.kt b/app/src/main/java/com/rainy/token/ui/components/UiText.kt new file mode 100644 index 0000000..339e47b --- /dev/null +++ b/app/src/main/java/com/rainy/token/ui/components/UiText.kt @@ -0,0 +1,44 @@ +package com.rainy.token.ui.components + +import android.content.Context +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource + +/** + * 可本地化的文本抽象。 + * + * ViewModel / Repository 层不直接持有本地化字符串,而是返回 [UiText]: + * - [Resource]:引用字符串资源(可带格式化参数),由 UI 层按当前语言环境解析 + * - [Dynamic]:服务端返回的动态文本(未知内容,无法预翻译),原样透传 + * + * 调试日志保持使用原始 [Throwable.message](Repository 中文消息仅用于排查,不用于 UI 展示)。 + */ +sealed interface UiText { + + data class Resource( + @StringRes val resId: Int, + val args: List = emptyList() + ) : UiText + + data class Dynamic(val value: String) : UiText +} + +/** 在非 Composable 上下文(协程/回调/事件)中把 [UiText] 解析为字符串(嵌套 [UiText] 参数会递归扁平化)。 */ +fun UiText.resolve(context: Context): String = when (this) { + is UiText.Resource -> { + val flatArgs = args.map { arg -> if (arg is UiText) arg.resolve(context) else arg } + context.getString(resId, *flatArgs.toTypedArray()) + } + is UiText.Dynamic -> value +} + +/** 在 Composable 上下文中把 [UiText] 解析为当前语言环境的字符串(嵌套 [UiText] 参数会递归扁平化)。 */ +@Composable +fun UiText.asString(): String = when (this) { + is UiText.Resource -> { + val flatArgs = args.map { arg -> if (arg is UiText) arg.asString() else arg } + stringResource(resId, *flatArgs.toTypedArray()) + } + is UiText.Dynamic -> value +} diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/CommandCodeUsageStatsCard.kt b/app/src/main/java/com/rainy/token/ui/dashboard/CommandCodeUsageStatsCard.kt index 2fd6a5b..22c2ffc 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/CommandCodeUsageStatsCard.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/CommandCodeUsageStatsCard.kt @@ -24,10 +24,12 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +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.data.local.OverviewStats import com.rainy.token.ui.theme.InkMuted import com.rainy.token.ui.theme.StrawberryPink @@ -68,12 +70,12 @@ fun CommandCodeUsageStatsCard( Row(verticalAlignment = Alignment.CenterVertically) { Column(modifier = Modifier.weight(1f)) { Text( - text = "CommandCode 用量", + text = stringResource(R.string.usage_title_commandcode), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold ) Text( - text = if (uiState.recordCount > 0) "${uiState.recordCount} 条记录" else "点击同步", + text = if (uiState.recordCount > 0) stringResource(R.string.usage_record_count, uiState.recordCount) else stringResource(R.string.usage_click_to_sync), style = MaterialTheme.typography.bodySmall, color = InkMuted ) @@ -88,7 +90,7 @@ fun CommandCodeUsageStatsCard( IconButton(onClick = { viewModel.sync() }) { Icon( imageVector = Icons.Filled.Refresh, - contentDescription = "同步用量数据", + contentDescription = stringResource(R.string.action_sync_usage), tint = StrawberryPink ) } @@ -101,13 +103,13 @@ fun CommandCodeUsageStatsCard( // 输入 Token = input + cacheRead val inputTotal = overview.inputTokens + overview.cacheReadTokens - MetricRow("输入 Token", formatTokenCount(inputTotal)) + MetricRow(stringResource(R.string.usage_input_tokens), formatTokenCount(inputTotal)) // 缓存子行 if (overview.cacheReadTokens > 0 || overview.cacheWriteTokens > 0) { val parts = mutableListOf() - if (overview.cacheReadTokens > 0) parts.add("缓存读取 ${formatTokenCount(overview.cacheReadTokens)}") - if (overview.cacheWriteTokens > 0) parts.add("缓存写入 ${formatTokenCount(overview.cacheWriteTokens)}") + if (overview.cacheReadTokens > 0) parts.add(stringResource(R.string.usage_cache_read, formatTokenCount(overview.cacheReadTokens))) + if (overview.cacheWriteTokens > 0) parts.add(stringResource(R.string.usage_cache_write, formatTokenCount(overview.cacheWriteTokens))) Text( text = parts.joinToString(" · "), style = MaterialTheme.typography.bodySmall, @@ -120,8 +122,8 @@ fun CommandCodeUsageStatsCard( // 输出 Token + 总花费 同一排 Row(Modifier.fillMaxWidth()) { - MetricRow("输出 Token", formatTokenCount(overview.outputTokens), Modifier.weight(1f)) - MetricRow("总花费", "$${String.format(Locale.US, "%.4f", overview.totalCost / 100_000_000.0)}", Modifier.weight(1f)) + MetricRow(stringResource(R.string.usage_output_tokens), formatTokenCount(overview.outputTokens), Modifier.weight(1f)) + MetricRow(stringResource(R.string.usage_total_cost), "$${String.format(Locale.US, "%.4f", overview.totalCost / 100_000_000.0)}", Modifier.weight(1f)) } } @@ -129,7 +131,7 @@ fun CommandCodeUsageStatsCard( if (uiState.overview == null && !uiState.syncing) { Spacer(modifier = Modifier.height(8.dp)) Text( - text = if (uiState.loading) "加载中…" else "暂无数据,点击 🔄 同步", + text = if (uiState.loading) stringResource(R.string.common_loading) else stringResource(R.string.usage_no_data_sync), style = MaterialTheme.typography.bodyMedium, color = InkMuted ) @@ -139,7 +141,7 @@ fun CommandCodeUsageStatsCard( if (uiState.overview != null) { Spacer(modifier = Modifier.height(8.dp)) TextButton(onClick = onOpenDetail) { - Text("查看详情", color = StrawberryPink) + Text(stringResource(R.string.action_view_detail), color = StrawberryPink) Spacer(modifier = Modifier.width(2.dp)) Icon( imageVector = Icons.Filled.KeyboardArrowRight, @@ -154,7 +156,7 @@ fun CommandCodeUsageStatsCard( if (uiState.lastSyncResult > 0) { Spacer(modifier = Modifier.height(4.dp)) Text( - text = "✓ 新增 ${uiState.lastSyncResult} 条", + text = stringResource(R.string.usage_new_records, uiState.lastSyncResult), style = MaterialTheme.typography.bodySmall, color = StrawberryPink ) 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 0cdc65c..dcc2e5c 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 @@ -67,6 +67,7 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.positionInWindow import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp @@ -77,6 +78,7 @@ 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.model.ServiceBalance import com.rainy.token.domain.service.ServiceType @@ -85,6 +87,7 @@ import com.rainy.token.ui.components.StatusChip import com.rainy.token.ui.components.StatusLevel import com.rainy.token.ui.components.StatusStyle import com.rainy.token.ui.components.AppTips +import com.rainy.token.ui.components.asString import com.rainy.token.ui.theme.inkMuted import com.rainy.token.ui.theme.StrawberryPink import android.app.AppOpsManager @@ -161,8 +164,8 @@ fun DashboardScreen( if (showAddWidgetConfirm) { AlertDialog( onDismissRequest = { showAddWidgetConfirm = false }, - title = { Text("添加桌面小组件?") }, - text = { Text("确认后会请求桌面启动器添加雨晴Token小组件。部分系统仍会弹出系统确认窗口。") }, + title = { Text(stringResource(R.string.dialog_add_widget_title)) }, + text = { Text(stringResource(R.string.dialog_add_widget_body)) }, confirmButton = { TextButton( onClick = { @@ -170,12 +173,12 @@ fun DashboardScreen( requestRainyTokenWidgetPin(context) } ) { - Text("确认添加", color = StrawberryPink) + Text(stringResource(R.string.action_confirm_add), color = StrawberryPink) } }, dismissButton = { TextButton(onClick = { showAddWidgetConfirm = false }) { - Text("取消") + Text(stringResource(R.string.action_cancel)) } } ) @@ -193,7 +196,7 @@ fun DashboardScreen( fontWeight = FontWeight.Bold ) Text( - text = "AI 余额一览", + text = stringResource(R.string.dashboard_subtitle), style = MaterialTheme.typography.bodySmall, color = inkMuted() ) @@ -203,7 +206,7 @@ fun DashboardScreen( IconButton(onClick = { showAddWidgetConfirm = true }) { Icon( imageVector = Icons.Filled.Add, - contentDescription = "添加小组件到桌面", + contentDescription = stringResource(R.string.action_add_widget), tint = StrawberryPink ) } @@ -213,14 +216,14 @@ fun DashboardScreen( ) { Icon( imageVector = Icons.Filled.Refresh, - contentDescription = "刷新", + contentDescription = stringResource(R.string.action_refresh), tint = StrawberryPink ) } IconButton(onClick = onOpenSettings) { Icon( imageVector = Icons.Filled.Settings, - contentDescription = "设置", + contentDescription = stringResource(R.string.action_settings), tint = StrawberryPink ) } @@ -263,9 +266,9 @@ fun DashboardScreen( verticalArrangement = Arrangement.spacedBy(12.dp) ) { // 随机小技巧提示(每次启动随机一条) - val tipText = remember { AppTips.randomHint() } + val tipTextRes = remember { AppTips.randomHintRes() } Text( - text = "💡 $tipText", + text = "💡 " + stringResource(tipTextRes), style = MaterialTheme.typography.bodySmall, color = inkMuted(), modifier = Modifier.padding(horizontal = 4.dp) @@ -314,13 +317,13 @@ fun DashboardScreen( verticalAlignment = Alignment.CenterVertically ) { Text( - text = "💡 长按卡片可拖拽排序", + text = stringResource(R.string.dashboard_drag_hint), style = MaterialTheme.typography.bodySmall, color = StrawberryPink, modifier = Modifier.weight(1f) ) Text( - text = "知道了", + text = stringResource(R.string.action_got_it), style = MaterialTheme.typography.bodySmall, color = StrawberryPink, fontWeight = FontWeight.SemiBold @@ -639,7 +642,7 @@ private fun DashboardCard(card: DashboardCardUi, onClick: () -> Unit, onOpenUsag fontWeight = FontWeight.SemiBold ) Text( - text = secondaryLine(card), + text = stringResource(secondaryLineRes(card)), style = MaterialTheme.typography.bodySmall, color = inkMuted() ) @@ -656,7 +659,7 @@ private fun DashboardCard(card: DashboardCardUi, onClick: () -> Unit, onOpenUsag if (onOpenUsageDetail != null && card.credentialState != com.rainy.token.domain.model.CredentialStatus.State.NOT_CONFIGURED) { Spacer(modifier = Modifier.height(8.dp)) TextButton(onClick = onOpenUsageDetail) { - Text("查看用量详情", color = StrawberryPink) + Text(stringResource(R.string.action_view_usage_detail), color = StrawberryPink) Spacer(modifier = Modifier.width(2.dp)) Icon( imageVector = Icons.Filled.KeyboardArrowRight, @@ -670,7 +673,7 @@ private fun DashboardCard(card: DashboardCardUi, onClick: () -> Unit, onOpenUsag // ─── 底部:更新时间 / 错误信息 ─── Spacer(modifier = Modifier.height(12.dp)) Text( - text = footerText(card), + text = footerText(card).asString(), style = MaterialTheme.typography.bodySmall, color = if (card.lastFetchError != null) MaterialTheme.colorScheme.error @@ -694,13 +697,13 @@ private fun DashboardFooter() { horizontalAlignment = Alignment.CenterHorizontally ) { Text( - text = "RainyToken · v1.0", + text = stringResource(R.string.dashboard_footer_version), style = MaterialTheme.typography.bodySmall, color = inkMuted() ) Spacer(modifier = Modifier.height(4.dp)) Text( - text = "本地加密存储,不上传任何数据", + text = stringResource(R.string.dashboard_footer_privacy), style = MaterialTheme.typography.bodySmall, color = inkMuted() ) @@ -726,14 +729,14 @@ private fun requestRainyTokenWidgetPin(context: Context) { val appWidgetManager = AppWidgetManager.getInstance(context) val component = ComponentName(context, OpenCodeGoWidgetProvider::class.java) if (!canInstallLauncherShortcut(context)) { - Toast.makeText(context, "桌面快捷方式权限已被拒绝,请在系统设置中允许后重试", Toast.LENGTH_LONG).show() + Toast.makeText(context, context.getString(R.string.toast_shortcut_permission_denied), Toast.LENGTH_LONG).show() return } if (appWidgetManager.isRequestPinAppWidgetSupported) { val requested = appWidgetManager.requestPinAppWidget(component, null, null) if (!requested) { - Toast.makeText(context, "桌面未接受添加请求,请检查启动器权限", Toast.LENGTH_LONG).show() + Toast.makeText(context, context.getString(R.string.toast_add_rejected), Toast.LENGTH_LONG).show() } return } @@ -745,6 +748,6 @@ private fun requestRainyTokenWidgetPin(context: Context) { if (canOpenPicker) { context.startActivity(pickerIntent) } else { - Toast.makeText(context, "当前桌面不支持应用内添加,请在桌面长按 → 小组件 → 查找雨晴Token", Toast.LENGTH_LONG).show() + Toast.makeText(context, context.getString(R.string.toast_picker_unsupported), Toast.LENGTH_LONG).show() } } \ No newline at end of file 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 fa6489b..de9e42f 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 @@ -185,12 +185,4 @@ data class DashboardCardUi( ) { /** 余额展示主数字。优先取缓存,错误时也展示(不隐藏,让用户看到旧值 + 红点提示)。 */ val displayBalance: ServiceBalance? get() = cachedBalance?.balance - - /** 卡片顶部状态徽章。 */ - val statusBadge: String get() = when { - credentialState == CredentialStatus.State.NOT_CONFIGURED -> "未配置" - lastFetchError != null -> "刷新失败" - cachedBalance == null -> "未获取" - else -> "正常" - } } diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt b/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt index b8b69ae..666912e 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt @@ -19,11 +19,16 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.res.stringResource +import androidx.annotation.StringRes +import com.rainy.token.R 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.ui.components.StatusStyle import com.rainy.token.ui.components.StatusLevel +import com.rainy.token.ui.components.DurationText +import com.rainy.token.ui.components.UiText import com.rainy.token.ui.components.formatAmount import com.rainy.token.ui.components.formatResetInSec import com.rainy.token.ui.components.normalizeWindowLabel @@ -47,7 +52,7 @@ internal fun BalanceMainArea(card: DashboardCardUi) { color = inkMuted() ) Text( - text = "点击配置凭据", + text = stringResource(R.string.service_click_to_configure), style = MaterialTheme.typography.bodySmall, color = inkMuted() ) @@ -59,7 +64,7 @@ internal fun BalanceMainArea(card: DashboardCardUi) { color = inkMuted() ) Text( - text = "下拉刷新", + text = stringResource(R.string.service_pull_to_refresh), style = MaterialTheme.typography.bodySmall, color = inkMuted() ) @@ -102,14 +107,14 @@ internal fun BalanceMainArea(card: DashboardCardUi) { } if (!balance.isAvailable) { Text( - text = "服务当前不可用", + text = stringResource(R.string.service_unavailable), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error ) } balance.monthlySpent?.let { spent -> Text( - text = "本月已用 ${formatAmount(spent)}${balance.unit}", + text = stringResource(R.string.service_monthly_used, formatAmount(spent), balance.unit), style = MaterialTheme.typography.bodySmall, color = inkMuted() ) @@ -136,7 +141,7 @@ internal fun OpenCodeGoMainBalance(balance: ServiceBalance) { ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = "5h 用量", + text = stringResource(R.string.window_5h_usage), style = MaterialTheme.typography.titleMedium, color = inkMuted(), modifier = Modifier.padding(bottom = 6.dp) @@ -147,9 +152,9 @@ internal fun OpenCodeGoMainBalance(balance: ServiceBalance) { @Composable internal fun OpenCodeGoUsageWindows(balance: ServiceBalance) { val windows = listOf( - Triple("5 小时", balance.extras["rolling.pct"]?.toIntOrNull(), balance.extras["rolling.resetInSec"]?.toLongOrNull()), - Triple("本周", balance.extras["weekly.pct"]?.toIntOrNull(), balance.extras["weekly.resetInSec"]?.toLongOrNull()), - Triple("本月", balance.extras["monthly.pct"]?.toIntOrNull(), balance.extras["monthly.resetInSec"]?.toLongOrNull()) + Triple(stringResource(R.string.window_5h), balance.extras["rolling.pct"]?.toIntOrNull(), balance.extras["rolling.resetInSec"]?.toLongOrNull()), + Triple(stringResource(R.string.window_weekly), balance.extras["weekly.pct"]?.toIntOrNull(), balance.extras["weekly.resetInSec"]?.toLongOrNull()), + Triple(stringResource(R.string.window_monthly), balance.extras["monthly.pct"]?.toIntOrNull(), balance.extras["monthly.resetInSec"]?.toLongOrNull()) ) Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { windows.forEach { (label, pct, resetSec) -> @@ -181,7 +186,7 @@ internal fun CommandCodeGoMainBalance(balance: ServiceBalance) { ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = "剩余", + text = stringResource(R.string.service_remaining), style = MaterialTheme.typography.titleMedium, color = inkMuted(), modifier = Modifier.padding(bottom = 6.dp) @@ -189,7 +194,7 @@ internal fun CommandCodeGoMainBalance(balance: ServiceBalance) { if (total != null && total > 0) { val used = total - balance.amount Text( - text = " · 已用 ${formatAmount(used)} / 共 ${formatAmount(total)}", + text = stringResource(R.string.service_used_total, formatAmount(used), formatAmount(total)), style = MaterialTheme.typography.bodySmall, color = inkMuted(), modifier = Modifier.padding(bottom = 6.dp) @@ -206,9 +211,9 @@ internal fun CommandCodeGoUsageWindows(balance: ServiceBalance) { return ((used / cap) * 100).toInt().coerceIn(0, 100) } val windows = listOf( - Triple("5 小时", calcPct(extras["fiveHour.used"]?.toDoubleOrNull(), extras["fiveHour.cap"]?.toDoubleOrNull()), extras["fiveHour.resetInSec"]?.toLongOrNull()), - Triple("本周", calcPct(extras["weekly.used"]?.toDoubleOrNull(), extras["weekly.cap"]?.toDoubleOrNull()), extras["weekly.resetInSec"]?.toLongOrNull()), - Triple("本月", calcPct(balance.monthlySpent, balance.totalQuota), extras["monthly.resetInSec"]?.toLongOrNull()) + Triple(stringResource(R.string.window_5h), calcPct(extras["fiveHour.used"]?.toDoubleOrNull(), extras["fiveHour.cap"]?.toDoubleOrNull()), extras["fiveHour.resetInSec"]?.toLongOrNull()), + Triple(stringResource(R.string.window_weekly), calcPct(extras["weekly.used"]?.toDoubleOrNull(), extras["weekly.cap"]?.toDoubleOrNull()), extras["weekly.resetInSec"]?.toLongOrNull()), + Triple(stringResource(R.string.window_monthly), calcPct(balance.monthlySpent, balance.totalQuota), extras["monthly.resetInSec"]?.toLongOrNull()) ) Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { windows.forEach { (label, pct, resetSec) -> @@ -226,7 +231,12 @@ internal fun CodexMainBalance(balance: ServiceBalance) { val plan = balance.extras["plan"]?.let { when (it) { "plus" -> "Plus"; "pro" -> "Pro"; "free" -> "Free"; else -> it.replaceFirstChar { c -> c.uppercaseChar() } } } ?: "—" - val primaryLabel = formatCodexPrimaryLabel(balance.extras["primary.label"]) + val primaryLabel = formatCodexPrimaryLabel( + balance.extras["primary.label"], + weeklyLabel = stringResource(R.string.window_every_week), + monthlyLabel = stringResource(R.string.window_every_month), + usageLabel = stringResource(R.string.window_usage) + ) Row(verticalAlignment = Alignment.Bottom) { Text( text = formatAmount(balance.amount), @@ -243,14 +253,14 @@ internal fun CodexMainBalance(balance: ServiceBalance) { ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = "$primaryLabel 已用", + text = stringResource(R.string.service_used_label, primaryLabel), style = MaterialTheme.typography.titleMedium, color = inkMuted(), modifier = Modifier.padding(bottom = 6.dp) ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = "· $plan", + text = stringResource(R.string.service_plan_suffix, plan), style = MaterialTheme.typography.bodySmall, color = inkMuted(), modifier = Modifier.padding(bottom = 6.dp) @@ -258,12 +268,20 @@ internal fun CodexMainBalance(balance: ServiceBalance) { } } -/** 把 Codex API 返回的 primary label 转成中文短标签 */ -internal fun formatCodexPrimaryLabel(raw: String?): String = when (raw?.lowercase()) { +/** + * 把 Codex API 返回的 primary label 转成本地化短标签。 + * 默认中文标签保持测试兼容;UI 调用方传入 stringResource 结果。 + */ +internal fun formatCodexPrimaryLabel( + raw: String?, + weeklyLabel: String = "每周", + monthlyLabel: String = "每月", + usageLabel: String = "用量" +): String = when (raw?.lowercase()) { "5h" -> "5h" - "7d", "每周" -> "每周" - "30d", "每月" -> "每月" - "usage" -> "用量" + "7d", weeklyLabel -> weeklyLabel + "30d", monthlyLabel -> monthlyLabel + "usage" -> usageLabel null -> "5h" else -> raw } @@ -276,14 +294,16 @@ internal fun CodexUsageWindows(balance: ServiceBalance) { .distinct() .maxOrNull()?.plus(1) ?: 0 + val weeklyLabel = stringResource(R.string.window_every_week) val windows = (0 until windowCount).map { i -> - val label = normalizeWindowLabel(extras["window_$i.label"] ?: "Usage") + val label = normalizeWindowLabel(extras["window_$i.label"] ?: "Usage", weeklyLabel = weeklyLabel) val remainingPct = extras["window_$i.remainingPct"]?.toIntOrNull() val resetAt = extras["window_$i.resetAt"]?.toLongOrNull()?.takeIf { it > 0 } Triple(label, remainingPct, resetAt) } // 判断是否有 5h 窗口(用于保留空槽位) + val fiveHourLabel = stringResource(R.string.window_5h) val has5h = windows.any { it.first.contains("5") && it.first.contains("小时") } Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { @@ -292,7 +312,7 @@ internal fun CodexUsageWindows(balance: ServiceBalance) { } else { // 如果没有 5h 窗口,在顶部插入一个空的 5h 槽位(保留位置,等恢复后自动填充) if (!has5h) { - CompactUsageRowEmpty(label = "5 小时", resetInSec = null) + CompactUsageRowEmpty(label = fiveHourLabel, resetInSec = null) } windows.forEach { (label, remainingPct, resetAt) -> if (remainingPct != null) { @@ -327,14 +347,14 @@ internal fun OllamaMainBalance(balance: ServiceBalance) { ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = "5h 已用", + text = stringResource(R.string.service_5h_used), style = MaterialTheme.typography.titleMedium, color = inkMuted(), modifier = Modifier.padding(bottom = 6.dp) ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = "· $plan", + text = stringResource(R.string.service_plan_suffix, plan), style = MaterialTheme.typography.bodySmall, color = inkMuted(), modifier = Modifier.padding(bottom = 6.dp) @@ -349,25 +369,26 @@ internal fun OllamaUsageWindows(balance: ServiceBalance) { val weeklyPct = extras["weekly.pct"]?.toFloatOrNull() val sessionResetAt = extras["session.resetAt"]?.toLongOrNull() val weeklyResetAt = extras["weekly.resetAt"]?.toLongOrNull() + val weeklyLabel = stringResource(R.string.window_every_week) Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { if (sessionPct != null) { CompactUsageRow( - label = "5h", + label = stringResource(R.string.window_5h_short), pct = sessionPct.toInt().coerceIn(0, 100), resetInSec = sessionResetAt?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 } ) } else { - CompactUsageRowEmpty(label = "5h", resetInSec = null) + CompactUsageRowEmpty(label = stringResource(R.string.window_5h_short), resetInSec = null) } if (weeklyPct != null) { CompactUsageRow( - label = "每周", + label = weeklyLabel, pct = weeklyPct.toInt().coerceIn(0, 100), resetInSec = weeklyResetAt?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 } ) } else { - CompactUsageRowEmpty(label = "每周", resetInSec = null) + CompactUsageRowEmpty(label = weeklyLabel, resetInSec = null) } } } @@ -376,6 +397,7 @@ internal fun OllamaUsageWindows(balance: ServiceBalance) { @Composable internal fun CompactUsageRowEmpty(label: String, resetInSec: Long?) { + val durationText = rememberDurationText() Column { Row( modifier = Modifier.fillMaxWidth(), @@ -407,7 +429,7 @@ internal fun CompactUsageRowEmpty(label: String, resetInSec: Long?) { if (resetInSec != null && resetInSec > 0) { Spacer(modifier = Modifier.height(2.dp)) Text( - text = "${formatResetInSec(resetInSec)}后重置", + text = stringResource(R.string.service_reset_in, formatResetInSec(resetInSec, durationText)), style = MaterialTheme.typography.bodySmall, color = inkMuted() ) @@ -417,6 +439,7 @@ internal fun CompactUsageRowEmpty(label: String, resetInSec: Long?) { @Composable internal fun CompactUsageRow(label: String, pct: Int, resetInSec: Long?) { + val durationText = rememberDurationText() val pctValue = pct.coerceIn(0, 100).toFloat() Column { Row( @@ -458,7 +481,7 @@ internal fun CompactUsageRow(label: String, pct: Int, resetInSec: Long?) { if (resetInSec != null && resetInSec > 0) { Spacer(modifier = Modifier.height(2.dp)) Text( - text = "${formatResetInSec(resetInSec)}后重置", + text = stringResource(R.string.service_reset_in, formatResetInSec(resetInSec, durationText)), style = MaterialTheme.typography.bodySmall, color = inkMuted() ) @@ -466,45 +489,60 @@ internal fun CompactUsageRow(label: String, pct: Int, resetInSec: Long?) { } } +/** 当前语言环境的时长标签("天 / 小时 / 分")。 */ +@Composable +private fun rememberDurationText(): DurationText = DurationText( + day = stringResource(R.string.format_day), + hour = stringResource(R.string.format_hour), + minute = stringResource(R.string.format_minute) +) + // ── Dashboard utility functions ── internal fun DashboardCardUi.statusBadgeStyle(): StatusStyle = when { credentialState == CredentialStatus.State.NOT_CONFIGURED -> - StatusStyle("未配置", StatusLevel.WARNING) + StatusStyle(R.string.status_not_configured, StatusLevel.WARNING) lastFetchError != null -> - StatusStyle("刷新失败", StatusLevel.ERROR) + StatusStyle(R.string.status_refresh_failed, StatusLevel.ERROR) credentialState == CredentialStatus.State.EXPIRED -> - StatusStyle("已过期", StatusLevel.ERROR) + StatusStyle(R.string.status_expired, StatusLevel.ERROR) credentialState == CredentialStatus.State.WARNING -> - StatusStyle("需重登", StatusLevel.WARNING) + StatusStyle(R.string.status_relogin, StatusLevel.WARNING) cachedBalance == null -> - StatusStyle("待获取", StatusLevel.INFO) + StatusStyle(R.string.status_waiting, StatusLevel.INFO) else -> - StatusStyle("正常", StatusLevel.OK) + StatusStyle(R.string.status_normal, StatusLevel.OK) } -internal fun secondaryLine(card: DashboardCardUi): String = when (card.service) { - ServiceType.DEEPSEEK -> "REST API · ¥" - ServiceType.OPENCODE_GO -> "WebView 抓取 · 5h 配额" - ServiceType.COMMANDCODE_GO -> "JSON API · $" - ServiceType.CODEX -> "ChatGPT Plus · Codex 额度" - ServiceType.OLLAMA -> "Cookie 抓取 · Cloud 配额" +/** 服务副标题(资源 ID,由 UI 层解析)。 */ +@StringRes +internal fun secondaryLineRes(card: DashboardCardUi): Int = when (card.service) { + ServiceType.DEEPSEEK -> R.string.service_desc_deepseek + ServiceType.OPENCODE_GO -> R.string.service_desc_opencode_go + ServiceType.COMMANDCODE_GO -> R.string.service_desc_commandcode_go + ServiceType.CODEX -> R.string.service_desc_codex + ServiceType.OLLAMA -> R.string.service_desc_ollama } -internal fun footerText(card: DashboardCardUi): String { +/** + * 底部更新时间/错误文案(UiText 形式,UI 层按当前语言解析)。 + * 错误分支直接透传 Repository 中文 message(Dynamic,不参与翻译)。 + */ +internal fun footerText(card: DashboardCardUi): UiText { if (card.lastFetchError != null) { val msg = card.lastFetchError.take(60) - return "⚠ $msg${if (card.lastFetchError.length > 60) "…" else ""}" + return UiText.Dynamic("⚠ $msg${if (card.lastFetchError.length > 60) "…" else ""}") } - val fetchedAt = card.cachedBalance?.fetchedAt ?: return "从未获取" + val fetchedAt = card.cachedBalance?.fetchedAt + ?: return UiText.Resource(R.string.footer_never_fetched) val sdf = SimpleDateFormat("MM-dd HH:mm", Locale.getDefault()) val now = System.currentTimeMillis() val diffMin = (now - fetchedAt) / 60_000 val timeStr = sdf.format(Date(fetchedAt)) return when { - diffMin < 1 -> "刚刚更新" - diffMin < 60 -> "$diffMin 分钟前更新" - diffMin < 1440 -> "${diffMin / 60} 小时前更新" - else -> "$timeStr 更新" + diffMin < 1 -> UiText.Resource(R.string.footer_just_updated) + diffMin < 60 -> UiText.Resource(R.string.footer_minutes_ago, listOf(diffMin)) + diffMin < 1440 -> UiText.Resource(R.string.footer_hours_ago, listOf(diffMin / 60)) + else -> UiText.Resource(R.string.footer_updated_at, listOf(timeStr)) } } \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/UsageChartScreen.kt b/app/src/main/java/com/rainy/token/ui/dashboard/UsageChartScreen.kt index 9119ebb..a1c92b8 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/UsageChartScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/UsageChartScreen.kt @@ -53,12 +53,15 @@ import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.rainy.token.R import com.rainy.token.data.local.ChartBucket import com.rainy.token.data.local.ChartGranularity import com.rainy.token.ui.theme.InkMuted @@ -100,6 +103,7 @@ fun UsageChartScreen( viewModel: UsageChartViewModel = hiltViewModel() ) { val state by viewModel.state.collectAsStateWithLifecycle() + val context = LocalContext.current var granularityExpanded by remember { mutableStateOf(false) } var modelExpanded by remember { mutableStateOf(false) } var showCustomDayPicker by remember { mutableStateOf(false) } @@ -115,9 +119,9 @@ fun UsageChartScreen( containerColor = Color.Transparent, topBar = { TopAppBar( - title = { Text("统计图表") }, + title = { Text(stringResource(R.string.title_chart)) }, navigationIcon = { - IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, "返回") } + IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, stringResource(R.string.action_back)) } }, colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent) ) @@ -125,7 +129,7 @@ fun UsageChartScreen( ) { innerPadding -> if (state.loading) { Box(Modifier.fillMaxSize().padding(innerPadding), contentAlignment = Alignment.Center) { - Text("加载中…", color = InkMuted) + Text(stringResource(R.string.common_loading), color = InkMuted) } } else { BoxWithConstraints(modifier = Modifier.fillMaxSize().padding(innerPadding)) { @@ -146,13 +150,13 @@ fun UsageChartScreen( // 粒度下拉 Box { TextButton(onClick = { granularityExpanded = true }) { - Text(state.granularity.label, color = StrawberryPink) + Text(stringResource(state.granularity.labelRes), color = StrawberryPink) Icon(Icons.Filled.ArrowDropDown, null, tint = StrawberryPink) } DropdownMenu(granularityExpanded, { granularityExpanded = false }) { ChartGranularity.entries.forEach { g -> DropdownMenuItem( - text = { Text(g.label) }, + text = { Text(stringResource(g.labelRes)) }, onClick = { granularityExpanded = false when (g) { @@ -169,15 +173,15 @@ fun UsageChartScreen( Spacer(Modifier.weight(1f)) // 模型多选下拉 Box { - val modelLabel = if (state.selectedModels.isEmpty()) "全部模型" - else "${state.selectedModels.size} 个模型" + val modelLabel = if (state.selectedModels.isEmpty()) stringResource(R.string.common_all_models) + else stringResource(R.string.model_count, state.selectedModels.size) TextButton(onClick = { modelExpanded = true }) { Text(modelLabel, color = StrawberryPink, style = MaterialTheme.typography.bodySmall) Icon(Icons.Filled.ArrowDropDown, null, tint = StrawberryPink) } DropdownMenu(modelExpanded, { modelExpanded = false }) { DropdownMenuItem( - text = { Text("全部模型", fontWeight = FontWeight.Bold) }, + text = { Text(stringResource(R.string.common_all_models), fontWeight = FontWeight.Bold) }, onClick = { modelExpanded = false; viewModel.selectAllModels() } ) state.allModels.forEach { model -> @@ -210,7 +214,7 @@ fun UsageChartScreen( horizontalArrangement = Arrangement.spacedBy(12.dp) ) { Box(modifier = Modifier.weight(1f)) { - ChartCard("消耗金额 (USD)", "$${String.format(Locale.US, "%.4f", costTotal)}", { showCostDetail = true }) { + ChartCard(stringResource(R.string.chart_cost), "$${String.format(Locale.US, "%.4f", costTotal)}", { showCostDetail = true }) { StackedBarChart( buckets = state.buckets, valueSelector = { it.totalCost.toDouble() / 100_000_000.0 }, @@ -235,12 +239,12 @@ fun UsageChartScreen( } } Box(modifier = Modifier.weight(1f)) { - ChartCard("API 请求次数", "${reqTotal}次", { showReqDetail = true }) { + ChartCard(stringResource(R.string.chart_requests), stringResource(R.string.count_times, reqTotal), { showReqDetail = true }) { LineChart( buckets = state.buckets, valueSelector = { it.totalRequests.toFloat() }, lineColor = StrawberryPink, - formatValue = { "${it.toInt()}次" }, + formatValue = { context.getString(R.string.count_times, it.toInt()) }, granularity = state.granularity, useUtc8 = state.useUtc8 ) @@ -251,7 +255,7 @@ fun UsageChartScreen( // 第三张图表独立一行 item { val tokTotal = state.buckets.sumOf { it.cacheHitTokens + it.inputTokens + it.outputTokens } - ChartCard("Token 消耗", formatTokenComma(tokTotal), { showTokenDetail = true }) { + ChartCard(stringResource(R.string.chart_tokens), formatTokenComma(tokTotal), { showTokenDetail = true }) { StackedBarChart( buckets = state.buckets, valueSelector = { (it.cacheHitTokens + it.inputTokens + it.outputTokens).toDouble() }, @@ -262,7 +266,7 @@ fun UsageChartScreen( bucket.cacheHitTokens.toDouble() to tokenColors[0] ) }, - stackLabels = { listOf("输出", "输入(未命中)", "命中缓存") }, + stackLabels = { listOf(context.getString(R.string.chart_stack_output), context.getString(R.string.chart_stack_input), context.getString(R.string.chart_stack_cache)) }, formatValue = { formatTokenComma(it.toLong()) }, granularity = state.granularity, tooltipReversed = true, @@ -271,9 +275,9 @@ fun UsageChartScreen( } ChartLegend( items = listOf( - "输入(未命中)" to tokenColors[1], - "命中缓存" to tokenColors[0], - "输出" to tokenColors[2] + stringResource(R.string.chart_stack_input) to tokenColors[1], + stringResource(R.string.chart_stack_cache) to tokenColors[0], + stringResource(R.string.chart_stack_output) to tokenColors[2] ) ) } @@ -283,7 +287,7 @@ fun UsageChartScreen( val models = state.selectedModels.ifEmpty { state.allModels.toSet() } val costTotal = state.buckets.sumOf { it.totalCost.toDouble() / 100_000_000.0 } ChartCard( - title = "消耗金额 (USD)", + title = stringResource(R.string.chart_cost), summary = "$${String.format(Locale.US, "%.4f", costTotal)}", onSummaryClick = { showCostDetail = true } ) { @@ -314,15 +318,15 @@ fun UsageChartScreen( item { val reqTotal = state.buckets.sumOf { it.totalRequests } ChartCard( - title = "API 请求次数", - summary = "${reqTotal}次", + title = stringResource(R.string.chart_requests), + summary = stringResource(R.string.count_times, reqTotal), onSummaryClick = { showReqDetail = true } ) { LineChart( buckets = state.buckets, valueSelector = { it.totalRequests.toFloat() }, lineColor = StrawberryPink, - formatValue = { "${it.toInt()}次" }, + formatValue = { context.getString(R.string.count_times, it.toInt()) }, granularity = state.granularity, useUtc8 = state.useUtc8 ) @@ -332,7 +336,7 @@ fun UsageChartScreen( item { val tokTotal = state.buckets.sumOf { it.cacheHitTokens + it.inputTokens + it.outputTokens } ChartCard( - title = "Token 消耗", + title = stringResource(R.string.chart_tokens), summary = formatTokenComma(tokTotal), onSummaryClick = { showTokenDetail = true } ) { @@ -346,7 +350,7 @@ fun UsageChartScreen( bucket.cacheHitTokens.toDouble() to tokenColors[0] ) }, - stackLabels = { listOf("输出", "输入(未命中)", "命中缓存") }, + stackLabels = { listOf(context.getString(R.string.chart_stack_output), context.getString(R.string.chart_stack_input), context.getString(R.string.chart_stack_cache)) }, formatValue = { formatTokenComma(it.toLong()) }, granularity = state.granularity, tooltipReversed = true, @@ -355,9 +359,9 @@ fun UsageChartScreen( } ChartLegend( items = listOf( - "输入(未命中)" to tokenColors[1], - "命中缓存" to tokenColors[0], - "输出" to tokenColors[2] + stringResource(R.string.chart_stack_input) to tokenColors[1], + stringResource(R.string.chart_stack_cache) to tokenColors[0], + stringResource(R.string.chart_stack_output) to tokenColors[2] ) ) } @@ -371,7 +375,7 @@ fun UsageChartScreen( // ─── 自定义日期选择器 ─── if (showCustomDayPicker) { DateTimePickerDialog( - title = "选择日期", + title = stringResource(R.string.date_select_day), onConfirm = { ms -> viewModel.setCustomDay(ms.toUtcLocalDate()) showCustomDayPicker = false @@ -381,7 +385,7 @@ fun UsageChartScreen( } if (showCustomMonthPicker) { DateTimePickerDialog( - title = "选择月份(任意一天)", + title = stringResource(R.string.date_select_month), onConfirm = { ms -> viewModel.setCustomMonth(ms.toUtcLocalDate()) showCustomMonthPicker = false @@ -391,7 +395,7 @@ fun UsageChartScreen( } if (showCustomRangeStart) { DateTimePickerDialog( - title = "开始日期", + title = stringResource(R.string.date_pick_start), onConfirm = { ms -> customRangeStartDate = ms.toUtcLocalDate() showCustomRangeStart = false @@ -402,7 +406,7 @@ fun UsageChartScreen( } if (showCustomRangeEnd) { DateTimePickerDialog( - title = "结束日期", + title = stringResource(R.string.date_pick_end), onConfirm = { ms -> val startDate = customRangeStartDate ?: return@DateTimePickerDialog val endDate = ms.toUtcLocalDate() @@ -420,7 +424,7 @@ fun UsageChartScreen( if (showCostDetail) { AlertDialog( onDismissRequest = { showCostDetail = false }, - title = { Text("消费明细", fontWeight = FontWeight.Bold) }, + title = { Text(stringResource(R.string.chart_detail_cost), fontWeight = FontWeight.Bold) }, text = { Column { models.forEach { model -> @@ -435,13 +439,13 @@ fun UsageChartScreen( } } }, - confirmButton = { TextButton(onClick = { showCostDetail = false }) { Text("关闭") } } + confirmButton = { TextButton(onClick = { showCostDetail = false }) { Text(stringResource(R.string.action_close)) } } ) } if (showReqDetail) { AlertDialog( onDismissRequest = { showReqDetail = false }, - title = { Text("调用次数明细", fontWeight = FontWeight.Bold) }, + title = { Text(stringResource(R.string.chart_detail_requests), fontWeight = FontWeight.Bold) }, text = { Column { models.forEach { model -> @@ -449,13 +453,13 @@ fun UsageChartScreen( if (total > 0) { Row(Modifier.fillMaxWidth().padding(vertical = 2.dp), horizontalArrangement = Arrangement.SpaceBetween) { Text(model, style = MaterialTheme.typography.bodySmall) - Text("${total}次", color = StrawberryPink, style = MaterialTheme.typography.bodySmall) + Text(stringResource(R.string.count_times, total), color = StrawberryPink, style = MaterialTheme.typography.bodySmall) } } } } }, - confirmButton = { TextButton(onClick = { showReqDetail = false }) { Text("关闭") } } + confirmButton = { TextButton(onClick = { showReqDetail = false }) { Text(stringResource(R.string.action_close)) } } ) } if (showTokenDetail) { @@ -464,24 +468,24 @@ fun UsageChartScreen( val outTotal = state.buckets.sumOf { it.outputTokens } AlertDialog( onDismissRequest = { showTokenDetail = false }, - title = { Text("Token 明细", fontWeight = FontWeight.Bold) }, + title = { Text(stringResource(R.string.chart_detail_tokens), fontWeight = FontWeight.Bold) }, text = { Column { Row(Modifier.fillMaxWidth().padding(vertical = 2.dp), horizontalArrangement = Arrangement.SpaceBetween) { - Text("命中缓存", style = MaterialTheme.typography.bodySmall) + Text(stringResource(R.string.chart_stack_cache), style = MaterialTheme.typography.bodySmall) Text(formatTokenComma(hitTotal), color = StrawberryPink, style = MaterialTheme.typography.bodySmall) } Row(Modifier.fillMaxWidth().padding(vertical = 2.dp), horizontalArrangement = Arrangement.SpaceBetween) { - Text("输入(未命中)", style = MaterialTheme.typography.bodySmall) + Text(stringResource(R.string.chart_stack_input), style = MaterialTheme.typography.bodySmall) Text(formatTokenComma(missTotal), color = StrawberryPink, style = MaterialTheme.typography.bodySmall) } Row(Modifier.fillMaxWidth().padding(vertical = 2.dp), horizontalArrangement = Arrangement.SpaceBetween) { - Text("输出", style = MaterialTheme.typography.bodySmall) + Text(stringResource(R.string.chart_stack_output), style = MaterialTheme.typography.bodySmall) Text(formatTokenComma(outTotal), color = StrawberryPink, style = MaterialTheme.typography.bodySmall) } } }, - confirmButton = { TextButton(onClick = { showTokenDetail = false }) { Text("关闭") } } + confirmButton = { TextButton(onClick = { showTokenDetail = false }) { Text(stringResource(R.string.action_close)) } } ) } } @@ -544,7 +548,7 @@ internal fun StackedBarChart( useUtc8: Boolean = false ) { if (buckets.isEmpty()) { - Text("暂无数据", color = InkMuted, style = MaterialTheme.typography.bodySmall) + Text(stringResource(R.string.common_no_data), color = InkMuted, style = MaterialTheme.typography.bodySmall) return } var tooltipBucket by remember { mutableStateOf(buckets.lastOrNull()) } @@ -639,7 +643,7 @@ internal fun StackedBarChart( ) { Column(Modifier.padding(8.dp)) { Text(formatChartTime(bucket.ts, granularity, 0, 1, useUtc8).first, style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold) - Text("合计: ${formatValue(total)}", style = MaterialTheme.typography.bodySmall, color = StrawberryPink) + Text(stringResource(R.string.chart_total_label, formatValue(total)), style = MaterialTheme.typography.bodySmall, color = StrawberryPink) val labels = stackLabels(bucket) val items = stackSelector(bucket).withIndex() val display = if (tooltipReversed) items.toList().reversed() else items.toList() @@ -674,7 +678,7 @@ internal fun LineChart( useUtc8: Boolean = false ) { if (buckets.isEmpty()) { - Text("暂无数据", color = InkMuted, style = MaterialTheme.typography.bodySmall) + Text(stringResource(R.string.common_no_data), color = InkMuted, style = MaterialTheme.typography.bodySmall) return } var tooltipBucket by remember { mutableStateOf(buckets.lastOrNull()) } diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/UsageDataScreen.kt b/app/src/main/java/com/rainy/token/ui/dashboard/UsageDataScreen.kt index ffae0e9..93b1f5f 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/UsageDataScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/UsageDataScreen.kt @@ -46,12 +46,14 @@ 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.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.rainy.token.R import com.rainy.token.data.local.UsageRecord import com.rainy.token.ui.theme.inkMuted import com.rainy.token.ui.theme.StrawberryPink @@ -93,8 +95,8 @@ fun UsageDataScreen( containerColor = Color.Transparent, topBar = { TopAppBar( - title = { Text("详细数据") }, - navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, "返回") } }, + title = { Text(stringResource(R.string.title_raw_data)) }, + navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, stringResource(R.string.action_back)) } }, colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent) ) } @@ -105,7 +107,7 @@ fun UsageDataScreen( // 时间筛选 Box { TextButton(onClick = { timeMenuExpanded = true }) { - Text(state.timeFilter.label, color = StrawberryPink, fontWeight = FontWeight.SemiBold, fontSize = 13.sp) + Text(stringResource(state.timeFilter.labelRes), color = StrawberryPink, fontWeight = FontWeight.SemiBold, fontSize = 13.sp) Icon(Icons.Filled.ArrowDropDown, null, tint = StrawberryPink) } DropdownMenu(timeMenuExpanded, { timeMenuExpanded = false }) { @@ -114,17 +116,17 @@ fun UsageDataScreen( TimeFilter.Today, TimeFilter.Yesterday, TimeFilter.Last7Days, TimeFilter.Last30Days, TimeFilter.ThisMonth ).forEach { f -> - DropdownMenuItem(text = { Text(f.label) }, onClick = { + DropdownMenuItem(text = { Text(stringResource(f.labelRes)) }, onClick = { timeMenuExpanded = false; viewModel.setTimeFilter(f) }) } - DropdownMenuItem(text = { Text("自定义天") }, onClick = { + DropdownMenuItem(text = { Text(stringResource(R.string.time_custom_day)) }, onClick = { timeMenuExpanded = false; showCustomDayPicker = true }) - DropdownMenuItem(text = { Text("自定义月") }, onClick = { + DropdownMenuItem(text = { Text(stringResource(R.string.time_custom_month)) }, onClick = { timeMenuExpanded = false; showCustomMonthPicker = true }) - DropdownMenuItem(text = { Text("自定义时间") }, onClick = { + DropdownMenuItem(text = { Text(stringResource(R.string.time_custom_range)) }, onClick = { timeMenuExpanded = false; viewModel.setTimeFilter(TimeFilter.Custom(0L, 0L)) }) } @@ -133,13 +135,13 @@ fun UsageDataScreen( Spacer(Modifier.weight(1f)) // 模型筛选 Box { - val label = if (state.selectedModels.isEmpty()) "全部模型" else "${state.selectedModels.size} 个" + val label = if (state.selectedModels.isEmpty()) stringResource(R.string.common_all_models) else stringResource(R.string.model_count_short, state.selectedModels.size) TextButton(onClick = { modelMenuExpanded = true }) { Text(label, color = StrawberryPink, fontSize = 13.sp) Icon(Icons.Filled.ArrowDropDown, null, tint = StrawberryPink) } DropdownMenu(modelMenuExpanded, { modelMenuExpanded = false }) { - DropdownMenuItem(text = { Text("全部模型", fontWeight = FontWeight.Bold) }, + DropdownMenuItem(text = { Text(stringResource(R.string.common_all_models), fontWeight = FontWeight.Bold) }, onClick = { modelMenuExpanded = false; viewModel.selectAllModels() }) state.allModels.forEach { m -> DropdownMenuItem(text = { @@ -158,12 +160,12 @@ fun UsageDataScreen( CustomTimeRangeRow(customStartMs, customEndMs, { showStartPicker = true }, { showEndPicker = true }, { viewModel.setTimeFilter(TimeFilter.Custom(customStartMs, customEndMs)) }) - Text("所有时间按 UTC+0 计算", style = MaterialTheme.typography.bodySmall, color = inkMuted()) + Text(stringResource(R.string.usage_utc0_note), style = MaterialTheme.typography.bodySmall, color = inkMuted()) } // ─── 记录数 + 分页 ─── Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), Arrangement.SpaceBetween, Alignment.CenterVertically) { - Text("${state.totalRecords} 条记录", style = MaterialTheme.typography.bodySmall, color = inkMuted()) + Text(stringResource(R.string.usage_record_count, state.totalRecords), style = MaterialTheme.typography.bodySmall, color = inkMuted()) if (state.totalPages > 1) { Row(verticalAlignment = Alignment.CenterVertically) { TextButton(onClick = { viewModel.prevPage() }, enabled = state.currentPage > 1) { @@ -188,7 +190,7 @@ fun UsageDataScreen( val p = pageInput.toIntOrNull() if (p != null) { viewModel.goToPage(p); pageInput = "" } }) { - Text("跳转", color = StrawberryPink, fontSize = 12.sp) + Text(stringResource(R.string.action_jump), color = StrawberryPink, fontSize = 12.sp) } } } @@ -198,22 +200,22 @@ fun UsageDataScreen( Card(Modifier.fillMaxWidth(), RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp), CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) { Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp)) { - HeaderCell("时间", Modifier.weight(1.5f), false) - HeaderCell("模型", Modifier.weight(1.8f), false) - HeaderCell("输入(缓存)", Modifier.weight(1.5f), false) - HeaderCell("输出", Modifier.weight(1.2f), false) - HeaderCell("成本", Modifier.weight(1.0f), false) + HeaderCell(stringResource(R.string.header_time), Modifier.weight(1.5f), false) + HeaderCell(stringResource(R.string.header_model), Modifier.weight(1.8f), false) + HeaderCell(stringResource(R.string.header_input_cache), Modifier.weight(1.5f), false) + HeaderCell(stringResource(R.string.header_output), Modifier.weight(1.2f), false) + HeaderCell(stringResource(R.string.header_cost), Modifier.weight(1.0f), false) } } // ─── 记录列表 ─── if (state.loading) { Box(Modifier.fillMaxWidth().height(200.dp), contentAlignment = Alignment.Center) { - Text("加载中…", color = inkMuted()) + Text(stringResource(R.string.common_loading), color = inkMuted()) } } else if (state.records.isEmpty()) { Box(Modifier.fillMaxWidth().height(200.dp), contentAlignment = Alignment.Center) { - Text("暂无数据", color = inkMuted()) + Text(stringResource(R.string.common_no_data), color = inkMuted()) } } else { LazyColumn(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(1.dp)) { @@ -226,18 +228,18 @@ fun UsageDataScreen( } // 日期选择器 - if (showCustomDayPicker) DateTimePickerDialog("选择日期", { ms -> + if (showCustomDayPicker) DateTimePickerDialog(stringResource(R.string.date_select_day), { ms -> val utc = ZoneOffset.UTC; val ds = Instant.ofEpochMilli(ms).atOffset(utc).toLocalDate().atStartOfDay(utc).toInstant().toEpochMilli() viewModel.setTimeFilter(TimeFilter.Custom(ds, ds + 86400_000L - 1)); showCustomDayPicker = false }, { showCustomDayPicker = false }) - if (showCustomMonthPicker) DateTimePickerDialog("选择月份(任意一天)", { ms -> + if (showCustomMonthPicker) DateTimePickerDialog(stringResource(R.string.date_select_month), { ms -> val utc = ZoneOffset.UTC; val ld = Instant.ofEpochMilli(ms).atOffset(utc).toLocalDate() val msStart = ld.withDayOfMonth(1).atStartOfDay(utc).toInstant().toEpochMilli() val msEnd = ld.withDayOfMonth(ld.lengthOfMonth()).plusDays(1).atStartOfDay(utc).toInstant().toEpochMilli() - 1 viewModel.setTimeFilter(TimeFilter.Custom(msStart, msEnd)); showCustomMonthPicker = false }, { showCustomMonthPicker = false }) - if (showStartPicker) DateTimePickerDialog("开始时间", { customStartMs = it; showStartPicker = false }, { showStartPicker = false }) - if (showEndPicker) DateTimePickerDialog("结束时间", { ms -> + if (showStartPicker) DateTimePickerDialog(stringResource(R.string.date_start), { customStartMs = it; showStartPicker = false }, { showStartPicker = false }) + if (showEndPicker) DateTimePickerDialog(stringResource(R.string.date_end), { ms -> viewModel.setTimeFilter(TimeFilter.Custom(customStartMs, ms)); showEndPicker = false }, { showEndPicker = false }) @@ -245,26 +247,26 @@ fun UsageDataScreen( rawRecord?.let { record -> AlertDialog( onDismissRequest = { rawRecord = null }, - title = { Text("原始数据", fontWeight = FontWeight.Bold) }, + title = { Text(stringResource(R.string.raw_record_title), fontWeight = FontWeight.Bold) }, text = { Column(Modifier.horizontalScroll(rememberScrollState())) { RawField("ID", record.id) - RawField("时间", formatUtcTime(record.timeCreated)) - RawField("模型", record.model) + RawField(stringResource(R.string.header_time), formatUtcTime(record.timeCreated)) + RawField(stringResource(R.string.header_model), record.model) RawField("Provider", record.provider) - RawField("输入 Token", "%,d".format(Locale.US, record.inputTokens)) - RawField("输出 Token", "%,d".format(Locale.US, record.outputTokens)) - RawField("推理 Token", "%,d".format(Locale.US, record.reasoningTokens)) - RawField("缓存读取", "%,d".format(Locale.US, record.cacheReadTokens)) - RawField("缓存写入(5m)", "%,d".format(Locale.US, record.cacheWrite5mTokens)) - RawField("缓存写入(1h)", "%,d".format(Locale.US, record.cacheWrite1hTokens)) - RawField("成本(USD)", "$${String.format(Locale.US, "%.6f", record.costUsd)}") - RawField("成本(Raw)", "${record.cost}") + RawField(stringResource(R.string.usage_input_tokens), "%,d".format(Locale.US, record.inputTokens)) + RawField(stringResource(R.string.usage_output_tokens), "%,d".format(Locale.US, record.outputTokens)) + RawField(stringResource(R.string.usage_reasoning_tokens), "%,d".format(Locale.US, record.reasoningTokens)) + RawField(stringResource(R.string.stat_cache_read), "%,d".format(Locale.US, record.cacheReadTokens)) + RawField(stringResource(R.string.stat_cache_write_5m), "%,d".format(Locale.US, record.cacheWrite5mTokens)) + RawField(stringResource(R.string.stat_cache_write_1h), "%,d".format(Locale.US, record.cacheWrite1hTokens)) + RawField(stringResource(R.string.raw_cost_usd), "$${String.format(Locale.US, "%.6f", record.costUsd)}") + RawField(stringResource(R.string.raw_cost_raw), "${record.cost}") RawField("KeyId", record.keyId) RawField("SessionId", record.sessionId) } }, - confirmButton = { TextButton(onClick = { rawRecord = null }) { Text("关闭") } } + confirmButton = { TextButton(onClick = { rawRecord = null }) { Text(stringResource(R.string.action_close)) } } ) } } diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/UsageDetailScreen.kt b/app/src/main/java/com/rainy/token/ui/dashboard/UsageDetailScreen.kt index 3cf3526..df2a2cd 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/UsageDetailScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/UsageDetailScreen.kt @@ -50,11 +50,14 @@ import androidx.compose.runtime.rememberCoroutineScope 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.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.rainy.token.R import com.rainy.token.data.local.ChartGranularity import com.rainy.token.ui.theme.InkMuted import com.rainy.token.ui.theme.StrawberryPink @@ -80,6 +83,7 @@ fun UsageDetailScreen( clearViewModel: UsageViewModel? = null // non-null = CCGO, 显示清除按钮 ) { val state by viewModel.state.collectAsStateWithLifecycle() + val context = LocalContext.current // OCGO 首次加载(CCGO 由 NavHost 的 setWorkspace 触发,不重复 load) LaunchedEffect(Unit) { @@ -106,16 +110,16 @@ fun UsageDetailScreen( snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { TopAppBar( - title = { Text("用量详情") }, - navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, "返回") } }, + title = { Text(stringResource(R.string.title_usage_detail)) }, + navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, stringResource(R.string.action_back)) } }, actions = { if (clearViewModel != null) { TextButton(onClick = { showClearDialog = true }) { - Text("清除", color = MaterialTheme.colorScheme.error, fontWeight = FontWeight.SemiBold, fontSize = 13.sp) + Text(stringResource(R.string.action_clear), color = MaterialTheme.colorScheme.error, fontWeight = FontWeight.SemiBold, fontSize = 13.sp) } } TextButton(onClick = onOpenData) { - Text("详细数据", color = StrawberryPink, fontWeight = FontWeight.SemiBold, fontSize = 13.sp) + Text(stringResource(R.string.action_view_raw_data), color = StrawberryPink, fontWeight = FontWeight.SemiBold, fontSize = 13.sp) } }, colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent) @@ -124,7 +128,7 @@ fun UsageDetailScreen( ) { innerPadding -> if (state.loading) { Box(Modifier.fillMaxSize().padding(innerPadding), contentAlignment = Alignment.Center) { - Text("加载中…", color = InkMuted) + Text(stringResource(R.string.common_loading), color = InkMuted) } } else { BoxWithConstraints(modifier = Modifier.fillMaxSize().padding(innerPadding)) { @@ -139,12 +143,12 @@ fun UsageDetailScreen( Row(Modifier.fillMaxWidth(), Arrangement.spacedBy(8.dp), Alignment.CenterVertically) { Box { TextButton(onClick = { granularityExpanded = true }) { - Text(state.granularity.label, color = StrawberryPink) + Text(stringResource(state.granularity.labelRes), color = StrawberryPink) Icon(Icons.Filled.ArrowDropDown, null, tint = StrawberryPink) } DropdownMenu(granularityExpanded, { granularityExpanded = false }) { ChartGranularity.entries.forEach { g -> - DropdownMenuItem(text = { Text(g.label) }, onClick = { + DropdownMenuItem(text = { Text(stringResource(g.labelRes)) }, onClick = { granularityExpanded = false when (g) { ChartGranularity.CUSTOM_DAY_HOURLY -> showCustomDayPicker = true @@ -160,7 +164,7 @@ fun UsageDetailScreen( viewModel.toggleUtc8() scope.launch { snackbarHostState.showSnackbar( - if (state.useUtc8) "已切换到 UTC+8,图表按北京时间显示" else "已切换到 UTC+0,图表按世界协调时显示", + if (state.useUtc8) context.getString(R.string.msg_utc8_on) else context.getString(R.string.msg_utc0_on), duration = SnackbarDuration.Short ) } @@ -172,7 +176,7 @@ fun UsageDetailScreen( fontWeight = if (state.useUtc8) FontWeight.SemiBold else FontWeight.Normal ) Text( - " 点击切换", + stringResource(R.string.usage_click_to_switch), style = MaterialTheme.typography.bodySmall, color = InkMuted.copy(alpha = 0.5f), fontWeight = FontWeight.Normal @@ -180,14 +184,14 @@ fun UsageDetailScreen( } Spacer(Modifier.weight(1f)) Box { - val label = if (state.selectedModels.isEmpty()) "全部模型" else "${state.selectedModels.size} 个模型" + val label = if (state.selectedModels.isEmpty()) stringResource(R.string.common_all_models) else stringResource(R.string.model_count, state.selectedModels.size) TextButton(onClick = { modelExpanded = true }) { Text(label, color = StrawberryPink, style = MaterialTheme.typography.bodySmall) Icon(Icons.Filled.ArrowDropDown, null, tint = StrawberryPink) } DropdownMenu(modelExpanded, { modelExpanded = false }) { DropdownMenuItem( - text = { Text("全部模型", fontWeight = FontWeight.Bold) }, + text = { Text(stringResource(R.string.common_all_models), fontWeight = FontWeight.Bold) }, onClick = { modelExpanded = false; viewModel.selectAllModels() }) state.allModels.forEach { model -> DropdownMenuItem(text = { @@ -213,7 +217,7 @@ fun UsageDetailScreen( horizontalArrangement = Arrangement.spacedBy(12.dp) ) { Box(modifier = Modifier.weight(1f)) { - ChartCard("消耗金额 (USD)", "$${String.format(Locale.US, "%.4f", costTotal)}", { showCostDetail = true }) { + ChartCard(stringResource(R.string.chart_cost), "$${String.format(Locale.US, "%.4f", costTotal)}", { showCostDetail = true }) { StackedBarChart(state.buckets, { it.totalCost.toDouble() / 100_000_000.0 }, { bucket -> models.mapIndexedNotNull { idx, m -> val v = bucket.byModel[m]?.cost ?: return@mapIndexedNotNull null; v.toDouble() / 100_000_000.0 to modelColors[idx % modelColors.size] } }, @@ -228,33 +232,33 @@ fun UsageDetailScreen( } } Box(modifier = Modifier.weight(1f)) { - ChartCard("API 请求次数", "${reqTotal}次", { showReqDetail = true }) { - LineChart(state.buckets, { it.totalRequests.toFloat() }, StrawberryPink, { "${it.toInt()}次" }, state.granularity, useUtc8 = state.useUtc8) + ChartCard(stringResource(R.string.chart_requests), stringResource(R.string.count_times, reqTotal), { showReqDetail = true }) { + LineChart(state.buckets, { it.totalRequests.toFloat() }, StrawberryPink, { context.getString(R.string.count_times, it.toInt()) }, state.granularity, useUtc8 = state.useUtc8) } } } } item { val tokTotal = state.buckets.sumOf { it.cacheHitTokens + it.inputTokens + it.outputTokens } - ChartCard("Token 消耗", formatTokenComma(tokTotal), { showTokenDetail = true }) { + ChartCard(stringResource(R.string.chart_tokens), formatTokenComma(tokTotal), { showTokenDetail = true }) { StackedBarChart(state.buckets, { (it.cacheHitTokens + it.inputTokens + it.outputTokens).toDouble() }, { bucket -> listOfNotNull(bucket.outputTokens.toDouble() to tokenColors[2], bucket.inputTokens.toDouble() to tokenColors[1], bucket.cacheHitTokens.toDouble() to tokenColors[0]) }, - { listOf("输出", "输入(未命中)", "命中缓存") }, + { listOf(context.getString(R.string.chart_stack_output), context.getString(R.string.chart_stack_input), context.getString(R.string.chart_stack_cache)) }, formatValue = { formatTokenComma(it.toLong()) }, granularity = state.granularity, tooltipReversed = true, useUtc8 = state.useUtc8 ) } - ChartLegend(listOf("输入(未命中)" to tokenColors[1], "命中缓存" to tokenColors[0], "输出" to tokenColors[2])) + ChartLegend(listOf(stringResource(R.string.chart_stack_input) to tokenColors[1], stringResource(R.string.chart_stack_cache) to tokenColors[0], stringResource(R.string.chart_stack_output) to tokenColors[2])) } } else { // 窄面板:三张图表纵向堆叠 item { val models = state.selectedModels.ifEmpty { state.allModels.toSet() } val costTotal = state.buckets.sumOf { it.totalCost.toDouble() / 100_000_000.0 } - ChartCard("消耗金额 (USD)", "$${String.format(Locale.US, "%.4f", costTotal)}", { showCostDetail = true }) { + ChartCard(stringResource(R.string.chart_cost), "$${String.format(Locale.US, "%.4f", costTotal)}", { showCostDetail = true }) { StackedBarChart(state.buckets, { it.totalCost.toDouble() / 100_000_000.0 }, { bucket -> models.mapIndexedNotNull { idx, m -> val v = bucket.byModel[m]?.cost ?: return@mapIndexedNotNull null; v.toDouble() / 100_000_000.0 to modelColors[idx % modelColors.size] } }, @@ -270,29 +274,29 @@ fun UsageDetailScreen( } item { val reqTotal = state.buckets.sumOf { it.totalRequests } - ChartCard("API 请求次数", "${reqTotal}次", { showReqDetail = true }) { - LineChart(state.buckets, { it.totalRequests.toFloat() }, StrawberryPink, { "${it.toInt()}次" }, state.granularity, useUtc8 = state.useUtc8) + ChartCard(stringResource(R.string.chart_requests), stringResource(R.string.count_times, reqTotal), { showReqDetail = true }) { + LineChart(state.buckets, { it.totalRequests.toFloat() }, StrawberryPink, { context.getString(R.string.count_times, it.toInt()) }, state.granularity, useUtc8 = state.useUtc8) } } item { val tokTotal = state.buckets.sumOf { it.cacheHitTokens + it.inputTokens + it.outputTokens } - ChartCard("Token 消耗", formatTokenComma(tokTotal), { showTokenDetail = true }) { + ChartCard(stringResource(R.string.chart_tokens), formatTokenComma(tokTotal), { showTokenDetail = true }) { StackedBarChart(state.buckets, { (it.cacheHitTokens + it.inputTokens + it.outputTokens).toDouble() }, { bucket -> listOfNotNull(bucket.outputTokens.toDouble() to tokenColors[2], bucket.inputTokens.toDouble() to tokenColors[1], bucket.cacheHitTokens.toDouble() to tokenColors[0]) }, - { listOf("输出", "输入(未命中)", "命中缓存") }, + { listOf(context.getString(R.string.chart_stack_output), context.getString(R.string.chart_stack_input), context.getString(R.string.chart_stack_cache)) }, formatValue = { formatTokenComma(it.toLong()) }, granularity = state.granularity, tooltipReversed = true, useUtc8 = state.useUtc8 ) } - ChartLegend(listOf("输入(未命中)" to tokenColors[1], "命中缓存" to tokenColors[0], "输出" to tokenColors[2])) + ChartLegend(listOf(stringResource(R.string.chart_stack_input) to tokenColors[1], stringResource(R.string.chart_stack_cache) to tokenColors[0], stringResource(R.string.chart_stack_output) to tokenColors[2])) } } item { TextButton(onClick = onOpenOverview, modifier = Modifier.fillMaxWidth()) { - Text("📋 总统计", color = StrawberryPink, fontWeight = FontWeight.Bold) + Text("📋 " + stringResource(R.string.title_overview), color = StrawberryPink, fontWeight = FontWeight.Bold) } } item { Spacer(Modifier.height(32.dp)) } @@ -300,15 +304,15 @@ fun UsageDetailScreen( } } // 自定义日期选择器 - if (showCustomDayPicker) DateOnlyPickerDialog("选择日期", { date -> + if (showCustomDayPicker) DateOnlyPickerDialog(stringResource(R.string.date_select_day), { date -> viewModel.setCustomDay(date) showCustomDayPicker = false }, { showCustomDayPicker = false }) - if (showCustomMonthPicker) DateOnlyPickerDialog("选择月份(任意一天)", { date -> + if (showCustomMonthPicker) DateOnlyPickerDialog(stringResource(R.string.date_select_month), { date -> viewModel.setCustomMonth(date) showCustomMonthPicker = false }, { showCustomMonthPicker = false }) - if (showCustomRangeStart) DateOnlyPickerDialog("开始日期", { date -> + if (showCustomRangeStart) DateOnlyPickerDialog(stringResource(R.string.date_pick_start), { date -> customRangeStartDate = date showCustomRangeStart = false showCustomRangeEnd = true @@ -316,7 +320,7 @@ fun UsageDetailScreen( if (showCustomRangeEnd) { val startDate = customRangeStartDate DateOnlyPickerDialog( - title = "结束日期", + title = stringResource(R.string.date_pick_end), onConfirm = { endDate -> if (startDate != null && !endDate.isBefore(startDate)) { viewModel.setCustomRange(startDate, endDate) @@ -328,27 +332,27 @@ fun UsageDetailScreen( ) } val models = state.selectedModels.ifEmpty { state.allModels.toSet() } - if (showCostDetail) ChartDetailDialog("消费明细", { showCostDetail = false }) { + if (showCostDetail) ChartDetailDialog(stringResource(R.string.chart_detail_cost), { showCostDetail = false }) { models.forEach { model -> val t = state.buckets.sumOf { it.byModel[model]?.cost ?:0L }; if (t>0) DetailRow(model, "$${String.format(Locale.US, "%.4f", t/100_000_000.0)}") } } - if (showReqDetail) ChartDetailDialog("调用次数明细", { showReqDetail = false }) { - models.forEach { model -> val t = state.buckets.sumOf { it.byModel[model]?.requests ?:0 }; if (t>0) DetailRow(model, "${t}次") } + if (showReqDetail) ChartDetailDialog(stringResource(R.string.chart_detail_requests), { showReqDetail = false }) { + models.forEach { model -> val t = state.buckets.sumOf { it.byModel[model]?.requests ?: 0 }; if (t>0) DetailRow(model, stringResource(R.string.count_times, t)) } } - if (showTokenDetail) ChartDetailDialog("Token 明细", { showTokenDetail = false }) { - DetailRow("命中缓存", formatTokenComma(state.buckets.sumOf { it.cacheHitTokens })) - DetailRow("输入(未命中)", formatTokenComma(state.buckets.sumOf { it.inputTokens })) - DetailRow("输出", formatTokenComma(state.buckets.sumOf { it.outputTokens })) + if (showTokenDetail) ChartDetailDialog(stringResource(R.string.chart_detail_tokens), { showTokenDetail = false }) { + DetailRow(stringResource(R.string.chart_stack_cache), formatTokenComma(state.buckets.sumOf { it.cacheHitTokens })) + DetailRow(stringResource(R.string.chart_stack_input), formatTokenComma(state.buckets.sumOf { it.inputTokens })) + DetailRow(stringResource(R.string.chart_stack_output), formatTokenComma(state.buckets.sumOf { it.outputTokens })) } if (showClearDialog && clearViewModel != null) { val cd = clearCountdown androidx.compose.material3.AlertDialog( onDismissRequest = { showClearDialog = false; clearCountdown = 0 }, - title = { Text("⚠️ 清除数据", fontWeight = FontWeight.Bold) }, + title = { Text("⚠️ " + stringResource(R.string.dialog_clear_title), fontWeight = FontWeight.Bold) }, text = { Column { - Text("此操作将清除所有 CommandCode 用量本地缓存并重新同步,需要几秒钟完成。") + Text(stringResource(R.string.dialog_clear_body)) Spacer(Modifier.height(12.dp)) - Text("是否继续?", fontWeight = FontWeight.SemiBold) + Text(stringResource(R.string.dialog_clear_confirm_question), fontWeight = FontWeight.SemiBold) } }, confirmButton = { @@ -362,14 +366,14 @@ fun UsageDetailScreen( enabled = cd == 0 ) { Text( - if (cd > 0) "确认(${cd}s)" else "确认清除", + if (cd > 0) stringResource(R.string.action_confirm_countdown, cd) else stringResource(R.string.action_confirm_clear), color = if (cd == 0) MaterialTheme.colorScheme.error else InkMuted ) } }, dismissButton = { TextButton(onClick = { showClearDialog = false; clearCountdown = 0 }) { - Text("取消") + Text(stringResource(R.string.action_cancel)) } } ) @@ -391,7 +395,7 @@ private fun ChartDetailDialog(title: String, onDismiss: () -> Unit, content: @Co onDismissRequest = onDismiss, title = { Text(title, fontWeight = FontWeight.Bold) }, text = { androidx.compose.foundation.layout.Column { content() } }, - confirmButton = { TextButton(onClick = onDismiss) { Text("关闭") } }) + confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_close)) } }) } @Composable private fun DetailRow(label: String, value: String) { Row(Modifier.fillMaxWidth().padding(vertical = 2.dp), Arrangement.SpaceBetween) { @@ -421,14 +425,14 @@ internal fun CustomTimeRangeRow( ) { TextButton(onClick = onPickStart, modifier = Modifier.weight(1f)) { Text( - text = if (startMs > 0) "从 ${fmt.format(Date(startMs))}" else "开始时间", + text = if (startMs > 0) stringResource(R.string.range_from, fmt.format(Date(startMs))) else stringResource(R.string.date_start), style = MaterialTheme.typography.bodySmall, color = if (startMs > 0) StrawberryPink else InkMuted ) } TextButton(onClick = onPickEnd, modifier = Modifier.weight(1f)) { Text( - text = if (endMs > 0) "至 ${fmt.format(Date(endMs))}" else "结束时间", + text = if (endMs > 0) stringResource(R.string.range_to, fmt.format(Date(endMs))) else stringResource(R.string.date_end), style = MaterialTheme.typography.bodySmall, color = if (endMs > 0) StrawberryPink else InkMuted ) @@ -436,7 +440,7 @@ internal fun CustomTimeRangeRow( } if (startMs > 0 && endMs > 0) { TextButton(onClick = onApply) { - Text("应用自定义范围", color = StrawberryPink, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.action_apply_custom_range), color = StrawberryPink, fontWeight = FontWeight.Bold) } } } @@ -471,10 +475,10 @@ private fun DateOnlyPickerDialog( }, enabled = dateState.selectedDateMillis != null ) { - Text("确定") + Text(stringResource(R.string.action_confirm)) } }, - dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } } + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } } ) { Column { Text( @@ -485,7 +489,7 @@ private fun DateOnlyPickerDialog( ) if (minDate != null) { Text( - text = "结束日期不能早于开始日期 ${minDate.formatDateLabel()}", + text = stringResource(R.string.date_end_before_start, minDate.formatDateLabel()), modifier = Modifier.padding(start = 24.dp, top = 8.dp, end = 24.dp), style = MaterialTheme.typography.bodySmall, color = InkMuted @@ -525,20 +529,20 @@ internal fun DateTimePickerDialog( onConfirm(epochMs) } }) { - Text("确定") + Text(stringResource(R.string.action_confirm)) } }, - dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } } + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } } ) { Column { DatePicker(state = dateState) Spacer(modifier = Modifier.height(8.dp)) Text( - text = "时间 ${ - String.format("%02d", timeState.hour) - }:${ + text = stringResource( + R.string.time_hm, + String.format("%02d", timeState.hour), String.format("%02d", timeState.minute) - }", + ), modifier = Modifier.padding(horizontal = 24.dp), style = MaterialTheme.typography.titleMedium ) diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/UsageOverviewScreen.kt b/app/src/main/java/com/rainy/token/ui/dashboard/UsageOverviewScreen.kt index 0753e6a..28e0d0a 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/UsageOverviewScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/UsageOverviewScreen.kt @@ -40,10 +40,12 @@ 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.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.data.local.ModelStats import com.rainy.token.data.local.OverviewStats import com.rainy.token.ui.theme.inkMuted @@ -80,9 +82,9 @@ fun UsageOverviewScreen( containerColor = Color.Transparent, topBar = { TopAppBar( - title = { Text("总统计") }, + title = { Text(stringResource(R.string.title_overview)) }, navigationIcon = { - IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, "返回") } + IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, stringResource(R.string.action_back)) } }, colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent) ) @@ -96,7 +98,7 @@ fun UsageOverviewScreen( item { Row(verticalAlignment = Alignment.CenterVertically) { TextButton(onClick = { menuExpanded = true }) { - Text(uiState.timeFilter.label, color = StrawberryPink, fontWeight = FontWeight.SemiBold) + Text(stringResource(uiState.timeFilter.labelRes), color = StrawberryPink, fontWeight = FontWeight.SemiBold) Icon(Icons.Filled.ArrowDropDown, null, tint = StrawberryPink) } Text("UTC+0", style = MaterialTheme.typography.bodySmall, color = inkMuted(), modifier = Modifier.padding(start = 4.dp)) @@ -106,12 +108,12 @@ fun UsageOverviewScreen( TimeFilter.Last7Days, TimeFilter.Last30Days, TimeFilter.ThisMonth ).forEach { filter -> DropdownMenuItem( - text = { Text(filter.label) }, + text = { Text(stringResource(filter.labelRes)) }, onClick = { menuExpanded = false; viewModel.setTimeFilter(filter) } ) } DropdownMenuItem( - text = { Text("自定义") }, + text = { Text(stringResource(R.string.time_custom)) }, onClick = { menuExpanded = false; viewModel.setTimeFilter(TimeFilter.Custom(0L, 0L)) } ) } @@ -122,7 +124,7 @@ fun UsageOverviewScreen( CustomTimeRangeRow(customStartMs, customEndMs, { showStartPicker = true }, { showEndPicker = true }, { viewModel.setTimeFilter(TimeFilter.Custom(customStartMs, customEndMs)) }) - Text("所有时间按 UTC+0 计算", style = MaterialTheme.typography.bodySmall, color = inkMuted()) + Text(stringResource(R.string.usage_utc0_note), style = MaterialTheme.typography.bodySmall, color = inkMuted()) } } // 总览 @@ -134,7 +136,7 @@ fun UsageOverviewScreen( } // 按模型 if (uiState.modelStats.isNotEmpty()) { - item { SectionHeader("按模型统计") } + item { SectionHeader(stringResource(R.string.section_by_model)) } items(uiState.modelStats, key = { it.model }) { ModelDetailRow(it) } } // 按天 @@ -144,14 +146,14 @@ fun UsageOverviewScreen( val shown = uiState.dailyStats.drop(startIdx).take(DAILY_PAGE_SIZE) item { Row(verticalAlignment = Alignment.CenterVertically) { - SectionHeader("按天统计(UTC+0)"); Spacer(Modifier.weight(1f)) + SectionHeader(stringResource(R.string.section_by_day)); Spacer(Modifier.weight(1f)) Box { TextButton(onClick = { modelMenuExpanded = true }) { - Text(uiState.modelFilter ?: "全部模型", color = StrawberryPink, style = MaterialTheme.typography.bodySmall) + Text(uiState.modelFilter ?: stringResource(R.string.common_all_models), color = StrawberryPink, style = MaterialTheme.typography.bodySmall) Icon(Icons.Filled.ArrowDropDown, null, tint = StrawberryPink) } DropdownMenu(modelMenuExpanded, { modelMenuExpanded = false }) { - DropdownMenuItem(text = { Text("全部模型", fontWeight = FontWeight.Bold) }, + DropdownMenuItem(text = { Text(stringResource(R.string.common_all_models), fontWeight = FontWeight.Bold) }, onClick = { modelMenuExpanded = false; viewModel.setModelFilter(null) }) uiState.modelStats.forEach { stat -> DropdownMenuItem(text = { Text(stat.model) }, @@ -166,18 +168,18 @@ fun UsageOverviewScreen( item { Row(Modifier.fillMaxWidth(), Arrangement.SpaceBetween, Alignment.CenterVertically) { TextButton(onClick = { viewModel.prevDailyPage() }, enabled = uiState.dailyPage > 1) { - Text("◀ 上一页", color = StrawberryPink, style = MaterialTheme.typography.bodySmall) } + Text(stringResource(R.string.action_prev_page), color = StrawberryPink, style = MaterialTheme.typography.bodySmall) } Text("${uiState.dailyPage} / $totalPages", style = MaterialTheme.typography.bodySmall, color = inkMuted()) TextButton(onClick = { viewModel.nextDailyPage() }, enabled = uiState.dailyPage < totalPages) { - Text("下一页 ▶", color = StrawberryPink, style = MaterialTheme.typography.bodySmall) } + Text(stringResource(R.string.action_next_page), color = StrawberryPink, style = MaterialTheme.typography.bodySmall) } } } } } item { Spacer(Modifier.height(24.dp)) } } - if (showStartPicker) DateTimePickerDialog("开始时间", { customStartMs = it; showStartPicker = false }, { showStartPicker = false }) - if (showEndPicker) DateTimePickerDialog("结束时间", { customEndMs = it; showEndPicker = false }, { showEndPicker = false }) + if (showStartPicker) DateTimePickerDialog(stringResource(R.string.date_start), { customStartMs = it; showStartPicker = false }, { showStartPicker = false }) + if (showEndPicker) DateTimePickerDialog(stringResource(R.string.date_end), { customEndMs = it; showEndPicker = false }, { showEndPicker = false }) } } @@ -193,24 +195,24 @@ fun UsageOverviewScreen( horizontalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { - StatItem("输入 Token", formatTokenCount(inputTotal)) - StatItem("输出 Token", formatTokenCount(overview.outputTokens)) - StatItem("推理 Token", formatTokenCount(overview.reasoningTokens)) - StatItem("总计", formatTokenCount(overview.totalTokens)) - StatItem("总花费", "$${String.format(Locale.US, "%.4f", overview.totalCost / 100_000_000.0)}") - StatItem("记录数", "${overview.totalCount}") + StatItem(stringResource(R.string.usage_input_tokens), formatTokenCount(inputTotal)) + StatItem(stringResource(R.string.usage_output_tokens), formatTokenCount(overview.outputTokens)) + StatItem(stringResource(R.string.usage_reasoning_tokens), formatTokenCount(overview.reasoningTokens)) + StatItem(stringResource(R.string.usage_total), formatTokenCount(overview.totalTokens)) + StatItem(stringResource(R.string.usage_total_cost), "$${String.format(Locale.US, "%.4f", overview.totalCost / 100_000_000.0)}") + StatItem(stringResource(R.string.usage_records), "${overview.totalCount}") } } else { Row(Modifier.fillMaxWidth(), Arrangement.SpaceEvenly) { - StatItem("输入 Token", formatTokenCount(inputTotal)) - StatItem("输出 Token", formatTokenCount(overview.outputTokens)) - StatItem("推理 Token", formatTokenCount(overview.reasoningTokens)) + StatItem(stringResource(R.string.usage_input_tokens), formatTokenCount(inputTotal)) + StatItem(stringResource(R.string.usage_output_tokens), formatTokenCount(overview.outputTokens)) + StatItem(stringResource(R.string.usage_reasoning_tokens), formatTokenCount(overview.reasoningTokens)) } Spacer(Modifier.height(12.dp)) Row(Modifier.fillMaxWidth(), Arrangement.SpaceEvenly) { - StatItem("总计", formatTokenCount(overview.totalTokens)) - StatItem("总花费", "$${String.format(Locale.US, "%.4f", overview.totalCost / 100_000_000.0)}") - StatItem("记录数", "${overview.totalCount}") + StatItem(stringResource(R.string.usage_total), formatTokenCount(overview.totalTokens)) + StatItem(stringResource(R.string.usage_total_cost), "$${String.format(Locale.US, "%.4f", overview.totalCost / 100_000_000.0)}") + StatItem(stringResource(R.string.usage_records), "${overview.totalCount}") } } } @@ -220,15 +222,15 @@ fun UsageOverviewScreen( @Composable private fun CacheBreakdownCard(overview: OverviewStats) { Card(Modifier.fillMaxWidth(), RoundedCornerShape(20.dp), CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) { Column(Modifier.padding(16.dp)) { - Text("缓存 Token 细分", style = MaterialTheme.typography.labelMedium, color = inkMuted()) + Text(stringResource(R.string.usage_cache_breakdown), style = MaterialTheme.typography.labelMedium, color = inkMuted()) Spacer(Modifier.height(8.dp)) Row(Modifier.fillMaxWidth(), Arrangement.SpaceEvenly) { - StatItem("缓存读取", formatTokenCount(overview.cacheReadTokens)) - StatItem("缓存写入", formatTokenCount(overview.cacheWriteTokens)) + StatItem(stringResource(R.string.stat_cache_read), formatTokenCount(overview.cacheReadTokens)) + StatItem(stringResource(R.string.stat_cache_write), formatTokenCount(overview.cacheWriteTokens)) } if (overview.cacheReadTokens > 0) { Spacer(Modifier.height(4.dp)) - Text("※ 缓存读取已计入「输入 Token」", style = MaterialTheme.typography.bodySmall, color = inkMuted()) + Text(stringResource(R.string.usage_cache_read_note), style = MaterialTheme.typography.bodySmall, color = inkMuted()) } } } @@ -243,7 +245,7 @@ fun UsageOverviewScreen( Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) { Column(Modifier.weight(1f)) { Text(stat.model, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) - Text("${stat.count} 次调用", style = MaterialTheme.typography.bodySmall, color = inkMuted()) + Text(stringResource(R.string.usage_calls, stat.count), style = MaterialTheme.typography.bodySmall, color = inkMuted()) } Column(horizontalAlignment = Alignment.End) { Text(formatTokenCount(stat.totalTokens), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold, color = StrawberryPink) @@ -254,13 +256,14 @@ fun UsageOverviewScreen( } @Composable private fun DailyDetailRow(day: com.rainy.token.data.local.DailyStats) { - val utcFmt = remember { SimpleDateFormat("MM月dd日 EEEE", Locale.CHINA).apply { timeZone = java.util.TimeZone.getTimeZone("UTC") } } + val datePattern = stringResource(R.string.date_format_md) + val utcFmt = remember(datePattern) { SimpleDateFormat(datePattern, Locale.CHINA).apply { timeZone = java.util.TimeZone.getTimeZone("UTC") } } Card(Modifier.fillMaxWidth(), RoundedCornerShape(12.dp), CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) { Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) { Text(utcFmt.format(Date(day.dayTs)), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f)) Column(horizontalAlignment = Alignment.End) { Text(formatTokenCount(day.totalTokens), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold, color = StrawberryPink) - Text("${day.count} 次 · $${String.format(Locale.US, "%.4f", day.totalCost / 100_000_000.0)}", style = MaterialTheme.typography.bodySmall, color = inkMuted()) + Text(stringResource(R.string.daily_count_cost, day.count, String.format(Locale.US, "%.4f", day.totalCost / 100_000_000.0)), style = MaterialTheme.typography.bodySmall, color = inkMuted()) } } } diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/UsageStatsCard.kt b/app/src/main/java/com/rainy/token/ui/dashboard/UsageStatsCard.kt index fb266f8..98a2436 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/UsageStatsCard.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/UsageStatsCard.kt @@ -24,10 +24,12 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +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.data.local.OverviewStats import com.rainy.token.ui.theme.InkMuted import com.rainy.token.ui.theme.StrawberryPink @@ -67,12 +69,12 @@ fun UsageStatsCard( Row(verticalAlignment = Alignment.CenterVertically) { Column(modifier = Modifier.weight(1f)) { Text( - text = "OpenCode 用量", + text = stringResource(R.string.usage_title_opencode), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold ) Text( - text = if (uiState.recordCount > 0) "${uiState.recordCount} 条记录" else "点击同步", + text = if (uiState.recordCount > 0) stringResource(R.string.usage_record_count, uiState.recordCount) else stringResource(R.string.usage_click_to_sync), style = MaterialTheme.typography.bodySmall, color = InkMuted ) @@ -87,7 +89,7 @@ fun UsageStatsCard( IconButton(onClick = { viewModel.sync() }) { Icon( imageVector = Icons.Filled.Refresh, - contentDescription = "同步用量数据", + contentDescription = stringResource(R.string.action_sync_usage), tint = StrawberryPink ) } @@ -100,13 +102,13 @@ fun UsageStatsCard( // 输入 Token = input + cacheRead val inputTotal = overview.inputTokens + overview.cacheReadTokens - MetricRow("输入 Token", formatTokenCount(inputTotal)) + MetricRow(stringResource(R.string.usage_input_tokens), formatTokenCount(inputTotal)) // 缓存子行 if (overview.cacheReadTokens > 0 || overview.cacheWriteTokens > 0) { val parts = mutableListOf() - if (overview.cacheReadTokens > 0) parts.add("缓存读取 ${formatTokenCount(overview.cacheReadTokens)}") - if (overview.cacheWriteTokens > 0) parts.add("缓存写入 ${formatTokenCount(overview.cacheWriteTokens)}") + if (overview.cacheReadTokens > 0) parts.add(stringResource(R.string.usage_cache_read, formatTokenCount(overview.cacheReadTokens))) + if (overview.cacheWriteTokens > 0) parts.add(stringResource(R.string.usage_cache_write, formatTokenCount(overview.cacheWriteTokens))) Text( text = parts.joinToString(" · "), style = MaterialTheme.typography.bodySmall, @@ -119,8 +121,8 @@ fun UsageStatsCard( // 输出 Token + 总花费 同一排 Row(Modifier.fillMaxWidth()) { - MetricRow("输出 Token", formatTokenCount(overview.outputTokens), Modifier.weight(1f)) - MetricRow("总花费", "$${String.format(Locale.US, "%.4f", overview.totalCost / 100_000_000.0)}", Modifier.weight(1f)) + MetricRow(stringResource(R.string.usage_output_tokens), formatTokenCount(overview.outputTokens), Modifier.weight(1f)) + MetricRow(stringResource(R.string.usage_total_cost), "$${String.format(Locale.US, "%.4f", overview.totalCost / 100_000_000.0)}", Modifier.weight(1f)) } } @@ -128,7 +130,7 @@ fun UsageStatsCard( if (uiState.overview == null && !uiState.syncing) { Spacer(modifier = Modifier.height(8.dp)) Text( - text = if (uiState.loading) "加载中…" else "暂无数据,点击 🔄 同步", + text = if (uiState.loading) stringResource(R.string.common_loading) else stringResource(R.string.usage_no_data_sync), style = MaterialTheme.typography.bodyMedium, color = InkMuted ) @@ -138,7 +140,7 @@ fun UsageStatsCard( if (uiState.overview != null) { Spacer(modifier = Modifier.height(8.dp)) TextButton(onClick = onOpenDetail) { - Text("查看详情", color = StrawberryPink) + Text(stringResource(R.string.action_view_detail), color = StrawberryPink) Spacer(modifier = Modifier.width(2.dp)) Icon( imageVector = Icons.Filled.KeyboardArrowRight, @@ -153,7 +155,7 @@ fun UsageStatsCard( if (uiState.lastSyncResult > 0) { Spacer(modifier = Modifier.height(4.dp)) Text( - text = "✓ 新增 ${uiState.lastSyncResult} 条", + text = stringResource(R.string.usage_new_records, uiState.lastSyncResult), style = MaterialTheme.typography.bodySmall, color = StrawberryPink ) diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/UsageViewModel.kt b/app/src/main/java/com/rainy/token/ui/dashboard/UsageViewModel.kt index a179941..6cb1a70 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/UsageViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/UsageViewModel.kt @@ -1,7 +1,9 @@ package com.rainy.token.ui.dashboard +import androidx.annotation.StringRes import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.rainy.token.R import com.rainy.token.data.local.OverviewStats import com.rainy.token.data.local.ModelStats import com.rainy.token.data.local.DailyStats @@ -26,17 +28,17 @@ import javax.inject.Provider internal const val DAILY_PAGE_SIZE = 5 -/** 时间筛选条件 */ -sealed class TimeFilter(val label: String) { - data object All : TimeFilter("全部") - data object Last5h : TimeFilter("最近5小时") - data object Last24h : TimeFilter("最近24小时") - data object Today : TimeFilter("今天") - data object Yesterday : TimeFilter("昨天") - data object Last7Days : TimeFilter("最近7天") - data object Last30Days : TimeFilter("最近30天") - data object ThisMonth : TimeFilter("当月") - data class Custom(val from: Long, val to: Long) : TimeFilter("自定义") +/** 时间筛选条件(label 用字符串资源 ID,UI 层按当前语言解析) */ +sealed class TimeFilter(@StringRes val labelRes: Int) { + data object All : TimeFilter(R.string.time_all) + data object Last5h : TimeFilter(R.string.time_last_5h) + data object Last24h : TimeFilter(R.string.time_last_24h) + data object Today : TimeFilter(R.string.time_today) + data object Yesterday : TimeFilter(R.string.time_yesterday) + data object Last7Days : TimeFilter(R.string.time_last_7d) + data object Last30Days : TimeFilter(R.string.time_last_30d) + data object ThisMonth : TimeFilter(R.string.time_this_month) + data class Custom(val from: Long, val to: Long) : TimeFilter(R.string.time_custom) /** 计算筛选的起止 epoch 毫秒(from 含,to 含)。 * 返回 Pair(null, null) 表示不限制。 */ 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 b323d67..14a708e 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 @@ -52,12 +52,14 @@ 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.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.rainy.token.R import com.rainy.token.domain.model.ServiceBalance import com.rainy.token.domain.service.FetchMethod import com.rainy.token.domain.service.ServiceConfigProvider @@ -66,6 +68,8 @@ import com.rainy.token.ui.components.ServiceIcon import com.rainy.token.ui.components.StatusChip import com.rainy.token.ui.components.StatusLevel import com.rainy.token.ui.components.StatusStyle +import com.rainy.token.ui.components.asString +import com.rainy.token.ui.components.DurationText import com.rainy.token.ui.components.formatAmount import com.rainy.token.ui.components.formatResetInSec import com.rainy.token.ui.theme.inkMuted @@ -135,7 +139,7 @@ fun ServiceDetailScreen( IconButton(onClick = onBack) { Icon( Icons.Filled.ArrowBack, - contentDescription = "返回", + contentDescription = stringResource(R.string.action_back), tint = StrawberryPink ) } @@ -177,7 +181,7 @@ fun ServiceDetailScreen( // 错误信息(如有) (uiState.state as? State.Error)?.let { err -> - item { ErrorCard(message = err.message) } + item { ErrorCard(message = err.message.asString()) } } // 凭据状态条 @@ -213,15 +217,18 @@ fun ServiceDetailScreen( // 响应弹窗(成功和失败都弹) when (triggerState) { is TriggerState.Success -> ResponseDialog( - title = "✓ 用量已激活", + title = stringResource(R.string.trigger_success_title), responseBody = (triggerState as TriggerState.Success).responseBody, isError = false, onDismiss = { viewModel.dismissTrigger() } ) is TriggerState.Error -> ResponseDialog( - title = "请求结果", - responseBody = (triggerState as TriggerState.Error).let { - "错误: ${it.message}" + (it.responseBody?.let { body -> "\n\n响应内容:\n$body" } ?: "") + title = stringResource(R.string.trigger_result_title), + responseBody = (triggerState as TriggerState.Error).let { + stringResource(R.string.trigger_error_prefix, it.message.asString()) + + (it.responseBody?.let { body -> + "\n\n" + stringResource(R.string.trigger_response_body, body) + } ?: "") }, isError = true, onDismiss = { viewModel.dismissTrigger() } @@ -259,7 +266,10 @@ private fun CommandCodeGoUsageCard(state: State) { ) { Column(modifier = Modifier.padding(20.dp)) { Text( - text = "用量窗口${if (planName.isNotBlank()) " · $planName" else ""}", + text = stringResource( + R.string.usage_window_title, + if (planName.isNotBlank()) " · $planName" else "" + ), style = MaterialTheme.typography.labelLarge, color = inkMuted() ) @@ -271,7 +281,7 @@ private fun CommandCodeGoUsageCard(state: State) { if (fiveHourUsed != null && fiveHourCap != null && fiveHourCap > 0) { val pct = ((fiveHourUsed / fiveHourCap) * 100).toFloat().coerceIn(0f, 100f) UsageWindowRow( - label = "5 小时滚动", + label = stringResource(R.string.window_5h_rolling), pct = pct, resetInSec = extras["fiveHour.resetInSec"]?.toLongOrNull() ) @@ -286,7 +296,7 @@ private fun CommandCodeGoUsageCard(state: State) { if (weeklyUsed != null && weeklyCap != null && weeklyCap > 0) { val pct = ((weeklyUsed / weeklyCap) * 100).toFloat().coerceIn(0f, 100f) UsageWindowRow( - label = "本周", + label = stringResource(R.string.window_weekly), pct = pct, resetInSec = extras["weekly.resetInSec"]?.toLongOrNull() ) @@ -300,19 +310,23 @@ private fun CommandCodeGoUsageCard(state: State) { val used = monthlyTotal - monthlyRemaining val pct = ((used / monthlyTotal) * 100).toFloat().coerceIn(0f, 100f) UsageWindowRow( - label = "本月", + label = stringResource(R.string.window_monthly), pct = pct, resetInSec = extras["billingPeriodEnd"]?.let { parseIsoDuration(it) } ) Spacer(modifier = Modifier.height(4.dp)) Text( - text = "已用 \$${formatAmount(used)} / 共 \$${formatAmount(monthlyTotal)}", + text = stringResource( + R.string.usage_spent_total, + formatAmount(used), + formatAmount(monthlyTotal) + ), style = MaterialTheme.typography.bodySmall, color = inkMuted() ) } else { Text( - text = "剩余 \$${formatAmount(monthlyRemaining)}", + text = stringResource(R.string.usage_remaining, formatAmount(monthlyRemaining)), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold ) @@ -323,7 +337,7 @@ private fun CommandCodeGoUsageCard(state: State) { Spacer(modifier = Modifier.height(8.dp)) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) Spacer(modifier = Modifier.height(8.dp)) - BreakdownRow("额外充值", purchased, "$") + BreakdownRow(stringResource(R.string.usage_extra_credits), purchased, "$") } } } @@ -365,13 +379,13 @@ private fun OpenCodeGoWindowsCard(state: State) { ) { Column(modifier = Modifier.padding(20.dp)) { Text( - text = "用量窗口", + text = stringResource(R.string.usage_window_title_plain), style = MaterialTheme.typography.labelLarge, color = inkMuted() ) Spacer(modifier = Modifier.height(12.dp)) UsageWindowRow( - label = "5 小时滚动", + label = stringResource(R.string.window_5h_rolling), pct = extras["rolling.pct"]?.toFloatOrNull(), resetInSec = extras["rolling.resetInSec"]?.toLongOrNull() ) @@ -379,7 +393,7 @@ private fun OpenCodeGoWindowsCard(state: State) { HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) Spacer(modifier = Modifier.height(14.dp)) UsageWindowRow( - label = "本周", + label = stringResource(R.string.window_weekly), pct = extras["weekly.pct"]?.toFloatOrNull(), resetInSec = extras["weekly.resetInSec"]?.toLongOrNull() ) @@ -387,7 +401,7 @@ private fun OpenCodeGoWindowsCard(state: State) { HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) Spacer(modifier = Modifier.height(14.dp)) UsageWindowRow( - label = "本月", + label = stringResource(R.string.window_monthly), pct = extras["monthly.pct"]?.toFloatOrNull(), resetInSec = extras["monthly.resetInSec"]?.toLongOrNull() ) @@ -397,6 +411,11 @@ private fun OpenCodeGoWindowsCard(state: State) { @Composable private fun UsageWindowRow(label: String, pct: Float?, resetInSec: Long?, decimals: Int = 0) { + val durationText = DurationText( + day = stringResource(R.string.format_day), + hour = stringResource(R.string.format_hour), + minute = stringResource(R.string.format_minute) + ) val pctValue = (pct ?: 0f).coerceIn(0f, 100f) Column { Row( @@ -440,7 +459,7 @@ private fun UsageWindowRow(label: String, pct: Float?, resetInSec: Long?, decima if (resetInSec != null && resetInSec > 0) { Spacer(modifier = Modifier.height(4.dp)) Text( - text = "${formatResetInSec(resetInSec)}后重置", + text = stringResource(R.string.service_reset_in, formatResetInSec(resetInSec, durationText)), style = MaterialTheme.typography.bodySmall, color = inkMuted() ) @@ -468,7 +487,10 @@ private fun CodexUsageCard(state: State) { if (windows.isEmpty()) return // 判断是否有 5h 窗口 - val has5h = windows.any { it.label.contains("5") && it.label.contains("小时") } + val has5h = windows.any { + it.rawLabel.equals("5h", ignoreCase = true) || + (it.rawLabel.contains("5") && it.rawLabel.contains("小时")) + } Card( modifier = Modifier.fillMaxWidth(), @@ -479,7 +501,7 @@ private fun CodexUsageCard(state: State) { Column(modifier = Modifier.padding(20.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = "用量窗口", + text = stringResource(R.string.usage_window_title_plain), style = MaterialTheme.typography.labelLarge, color = inkMuted() ) @@ -498,7 +520,7 @@ private fun CodexUsageCard(state: State) { // 没有 5h 窗口时,在顶部插入空槽位 if (!has5h) { UsageWindowRow( - label = "5 小时", + label = stringResource(R.string.window_5h), pct = null, resetInSec = null, decimals = 2 @@ -518,7 +540,7 @@ private fun CodexUsageCard(state: State) { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 } UsageWindowRow( - label = window.label, + label = codexWindowLabel(window.rawLabel), pct = window.usedPct, resetInSec = resetInSec, decimals = 2 @@ -529,7 +551,7 @@ private fun CodexUsageCard(state: State) { } private data class CodexWindow( - val label: String, + val rawLabel: String, val usedPct: Float, val resetAt: Long? ) @@ -542,16 +564,17 @@ private fun extractCodexWindows(extras: Map): List val remaining = extras["window_${i}.remainingPct"]?.toFloatOrNull() ?: 0f val resetAt = extras["window_${i}.resetAt"]?.toLongOrNull()?.takeIf { it > 0 } val usedPct = (100f - remaining).coerceIn(0f, 100f) - result.add(CodexWindow(formatCodexWindowLabel(rawLabel), usedPct, resetAt)) + result.add(CodexWindow(rawLabel, usedPct, resetAt)) i++ } return result } -private fun formatCodexWindowLabel(raw: String): String = when (raw.lowercase()) { - "5h" -> "5 小时" - "7d", "每周" -> "本周" - "30d", "每月" -> "本月" +@Composable +private fun codexWindowLabel(raw: String): String = when (raw.lowercase()) { + "5h" -> stringResource(R.string.window_5h) + "7d", "每周" -> stringResource(R.string.window_weekly) + "30d", "每月" -> stringResource(R.string.window_monthly) else -> raw } @@ -581,7 +604,7 @@ private fun OllamaUsageCard(state: State) { Column(modifier = Modifier.padding(20.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = "用量窗口", + text = stringResource(R.string.usage_window_title_plain), style = MaterialTheme.typography.labelLarge, color = inkMuted() ) @@ -595,7 +618,7 @@ private fun OllamaUsageCard(state: State) { } Spacer(modifier = Modifier.height(12.dp)) UsageWindowRow( - label = "5 小时", + label = stringResource(R.string.window_5h), pct = extras["session.pct"]?.toFloatOrNull(), resetInSec = extras["session.resetAt"]?.toLongOrNull()?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 }, decimals = 2 @@ -608,7 +631,7 @@ private fun OllamaUsageCard(state: State) { HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) Spacer(modifier = Modifier.height(14.dp)) UsageWindowRow( - label = "每周", + label = stringResource(R.string.window_every_week), pct = extras["weekly.pct"]?.toFloatOrNull(), resetInSec = extras["weekly.resetAt"]?.toLongOrNull()?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 }, decimals = 2 @@ -645,7 +668,7 @@ private fun parseModelRequests(raw: String?): List> { private fun ModelRequestList(models: List>) { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Text( - text = "模型调用次数", + text = stringResource(R.string.msg_model_call_count), style = MaterialTheme.typography.labelSmall, color = inkMuted() ) @@ -702,7 +725,7 @@ private fun MainBalanceCard(state: State, service: ServiceType) { BalanceBigNumber(state.data) Spacer(modifier = Modifier.height(8.dp)) Text( - text = "缓存数据 · ${formatTime(state.lastFetchedAt)}", + text = stringResource(R.string.msg_stale_cache, formatTime(state.lastFetchedAt)), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error ) @@ -711,7 +734,7 @@ private fun MainBalanceCard(state: State, service: ServiceType) { BalanceBigNumber(state.cached) Spacer(modifier = Modifier.height(8.dp)) Text( - text = "显示的是上次成功获取的余额", + text = stringResource(R.string.msg_showing_last_balance), style = MaterialTheme.typography.bodySmall, color = inkMuted() ) @@ -756,7 +779,7 @@ private fun BalanceBigNumber(balance: ServiceBalance?) { if (!balance.isAvailable) { Spacer(modifier = Modifier.height(4.dp)) Text( - text = "服务当前不可用", + text = stringResource(R.string.service_unavailable), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error ) @@ -764,7 +787,7 @@ private fun BalanceBigNumber(balance: ServiceBalance?) { balance.monthlySpent?.let { spent -> Spacer(modifier = Modifier.height(4.dp)) Text( - text = "本月消费 ${formatAmount(spent)}${balance.unit}", + text = stringResource(R.string.msg_monthly_spent, formatAmount(spent), balance.unit), style = MaterialTheme.typography.bodyMedium, color = inkMuted() ) @@ -795,16 +818,16 @@ private fun DeepSeekBreakdownCard(state: State) { ) { Column(modifier = Modifier.padding(20.dp)) { Text( - text = "余额构成", + text = stringResource(R.string.msg_balance_breakdown), style = MaterialTheme.typography.labelLarge, color = inkMuted() ) Spacer(modifier = Modifier.height(12.dp)) - BreakdownRow("赠送余额", granted, balance!!.unit) + BreakdownRow(stringResource(R.string.msg_granted_balance), granted, balance!!.unit) Spacer(modifier = Modifier.height(8.dp)) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) Spacer(modifier = Modifier.height(8.dp)) - BreakdownRow("自费充值", toppedUp, balance.unit) + BreakdownRow(stringResource(R.string.msg_topped_up_balance), toppedUp, balance.unit) } } } @@ -850,7 +873,7 @@ private fun ErrorCard(message: String) { ) { Column(modifier = Modifier.padding(16.dp)) { Text( - text = "刷新失败", + text = stringResource(R.string.status_refresh_failed), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.error @@ -868,9 +891,9 @@ private fun ErrorCard(message: String) { @Composable private fun CredentialStatusRow(hasCredential: Boolean, isManualMode: Boolean) { val style = when { - isManualMode -> StatusStyle("手动输入模式", StatusLevel.INFO) - hasCredential -> StatusStyle("凭据已配置", StatusLevel.OK) - else -> StatusStyle("未配置凭据", StatusLevel.ERROR) + isManualMode -> StatusStyle(R.string.status_manual_mode, StatusLevel.INFO) + hasCredential -> StatusStyle(R.string.status_credential_configured, StatusLevel.OK) + else -> StatusStyle(R.string.status_not_configured_cred, StatusLevel.ERROR) } StatusChip(style = style) } @@ -908,7 +931,11 @@ private fun ActionButtons( strokeWidth = 2.dp ) Spacer(modifier = Modifier.width(8.dp)) - Text("加载模型列表...", style = MaterialTheme.typography.bodySmall, color = inkMuted()) + Text( + stringResource(R.string.msg_loading_models), + style = MaterialTheme.typography.bodySmall, + color = inkMuted() + ) } } else if (models.isNotEmpty()) { Row( @@ -916,7 +943,7 @@ private fun ActionButtons( verticalAlignment = Alignment.CenterVertically ) { Text( - text = "选择模型", + text = stringResource(R.string.msg_select_model), style = MaterialTheme.typography.labelMedium, color = inkMuted() ) @@ -927,7 +954,7 @@ private fun ActionButtons( ) { Icon( imageVector = Icons.Filled.Refresh, - contentDescription = "刷新模型列表", + contentDescription = stringResource(R.string.action_refresh_models), tint = StrawberryPink, modifier = Modifier.size(18.dp) ) @@ -959,7 +986,7 @@ private fun ActionButtons( strokeWidth = 2.dp ) Spacer(modifier = Modifier.width(8.dp)) - Text("激活中...") + Text(stringResource(R.string.msg_triggering)) } } is TriggerState.Error -> { @@ -971,10 +998,10 @@ private fun ActionButtons( contentColor = MaterialTheme.colorScheme.error ) ) { - Text("重试激活用量") + Text(stringResource(R.string.action_retry_trigger)) } Text( - text = triggerState.message, + text = triggerState.message.asString(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(horizontal = 4.dp) @@ -986,7 +1013,7 @@ private fun ActionButtons( modifier = Modifier.fillMaxWidth(), enabled = state !is State.Loading && selectedModel != null ) { - Text("⚡ 一键激活用量") + Text(stringResource(R.string.action_trigger_usage)) } } is TriggerState.Success -> { @@ -1008,13 +1035,13 @@ private fun ActionButtons( ) { Icon(Icons.Filled.Refresh, contentDescription = null) Spacer(modifier = Modifier.width(8.dp)) - Text("刷新余额") + Text(stringResource(R.string.action_refresh_balance)) } OutlinedButton( onClick = onConfigureCredential, modifier = Modifier.fillMaxWidth() ) { - Text("重新登录") + Text(stringResource(R.string.action_relogin)) } } else if (!hasCredential) { Button( @@ -1025,26 +1052,27 @@ private fun ActionButtons( contentColor = Color.White ) ) { - Text("配置凭据") + Text(stringResource(R.string.action_configure_credential)) } } } } +@Composable private fun mainCardLabel(service: ServiceType): String = when (service) { - ServiceType.DEEPSEEK -> "当前余额" - ServiceType.OPENCODE_GO -> "5h 实时用量" - ServiceType.COMMANDCODE_GO -> "月度余额" - ServiceType.CODEX -> "用量" - ServiceType.OLLAMA -> "Session 用量" + ServiceType.DEEPSEEK -> stringResource(R.string.main_card_current_balance) + ServiceType.OPENCODE_GO -> stringResource(R.string.main_card_5h_usage) + 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) } private fun stateToChip(state: State): StatusStyle = when (state) { - is State.Loading -> StatusStyle("加载中", StatusLevel.INFO) - is State.Fresh -> StatusStyle("最新", StatusLevel.OK) - is State.Stale -> StatusStyle("缓存", StatusLevel.WARNING) - is State.Error -> StatusStyle("失败", StatusLevel.ERROR) - is State.ManualModeHint -> StatusStyle("待输入", StatusLevel.WARNING) + is State.Loading -> StatusStyle(R.string.status_loading, StatusLevel.INFO) + is State.Fresh -> StatusStyle(R.string.status_fresh, StatusLevel.OK) + is State.Stale -> StatusStyle(R.string.status_stale, StatusLevel.WARNING) + is State.Error -> StatusStyle(R.string.status_failed, StatusLevel.ERROR) + is State.ManualModeHint -> StatusStyle(R.string.status_pending_input, StatusLevel.WARNING) } private fun formatTime(epochMillis: Long): String { @@ -1062,7 +1090,7 @@ private fun ModelDropdown( onSelect: (String) -> Unit ) { var expanded by remember { androidx.compose.runtime.mutableStateOf(false) } - val display = selectedModel ?: "请选择模型" + val display = selectedModel ?: stringResource(R.string.msg_please_select_model) ExposedDropdownMenuBox( expanded = expanded, @@ -1110,7 +1138,11 @@ private fun ResponseDialog( text = { Column { Text( - text = if (isError) "错误详情:" else "API 响应内容:", + text = if (isError) { + stringResource(R.string.trigger_error_detail) + } else { + stringResource(R.string.trigger_api_response) + }, style = MaterialTheme.typography.labelMedium, color = if (isError) MaterialTheme.colorScheme.error else inkMuted() ) @@ -1134,7 +1166,7 @@ private fun ResponseDialog( val clipboard = context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager clipboard.setPrimaryClip(android.content.ClipData.newPlainText("API Response", responseBody)) }) { - Text("复制") + Text(stringResource(R.string.action_copy)) } Button( onClick = onDismiss, @@ -1143,7 +1175,7 @@ private fun ResponseDialog( contentColor = Color.White ) ) { - Text("关闭") + Text(stringResource(R.string.action_close)) } } } 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 2aacd16..675b76a 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 @@ -11,8 +11,10 @@ import com.rainy.token.data.repository.TriggerError import com.rainy.token.domain.model.Credential import com.rainy.token.domain.model.CredentialStatus import com.rainy.token.domain.model.ServiceBalance +import com.rainy.token.R import com.rainy.token.domain.service.ServiceType import com.rainy.token.domain.usecase.RefreshBalanceUseCase +import com.rainy.token.ui.components.UiText import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow @@ -150,7 +152,7 @@ class ServiceDetailViewModel @Inject constructor( cached = local.cachedBalance, state = State.Error( local.cachedBalance?.balance, - "凭据未配置", + UiText.Resource(R.string.error_credential_not_configured), RepositoryError.InvalidCredential() ) ) @@ -303,7 +305,10 @@ class ServiceDetailViewModel @Inject constructor( val service = _serviceType.value ?: return val serviceGen = serviceGeneration val model = _selectedModel.value ?: run { - _triggerState.value = TriggerState.Error("请先选择模型", null) + _triggerState.value = TriggerState.Error( + UiText.Resource(R.string.error_select_model_first), + null + ) return } viewModelScope.launch { @@ -327,31 +332,36 @@ class ServiceDetailViewModel @Inject constructor( } } .onFailure { error -> - val message: String + val message: UiText val responseBody: String? when (error) { is TriggerError -> { - message = error.summary + message = UiText.Dynamic(error.summary) responseBody = error.responseBody.ifBlank { null } } is RepositoryError.InvalidCredential -> { - message = error.message ?: "凭据无效,请重新登录" + message = UiText.Resource(R.string.error_credential_invalid_relogin) responseBody = null } is RepositoryError.CredentialChanged -> { - message = "凭据已变更,请重新操作" + message = UiText.Resource(R.string.error_credential_changed_retry) responseBody = null } is RepositoryError.Network -> { - message = "网络异常" + message = UiText.Resource(R.string.error_network_generic) responseBody = null } else -> { - message = error.message ?: "未知错误" + message = if ((error as? RepositoryError.Unknown)?.cause is IllegalArgumentException) { + UiText.Resource(R.string.error_trigger_not_supported) + } else { + error.message?.let { UiText.Dynamic(it) } + ?: UiText.Resource(R.string.common_unknown) + } responseBody = null } } @@ -418,13 +428,17 @@ class ServiceDetailViewModel @Inject constructor( isManual && cached != null -> State.Stale(cached.balance, cached.fetchedAt) isManual -> State.ManualModeHint status.state == CredentialStatus.State.NOT_CONFIGURED -> - State.Error(null, "凭据未配置", RepositoryError.InvalidCredential()) + State.Error( + null, + UiText.Resource(R.string.error_credential_not_configured), + RepositoryError.InvalidCredential() + ) cached != null && status.state == CredentialStatus.State.OK -> State.Stale(cached.balance, cached.fetchedAt) cached != null -> State.Error( cached.balance, - "凭据未配置或已过期", + UiText.Resource(R.string.error_credential_not_configured_expired), RepositoryError.InvalidCredential() ) else -> State.Loading @@ -455,7 +469,7 @@ class ServiceDetailViewModel @Inject constructor( val mergedState: State = when { !hasCredential -> State.Error( mergedCache?.balance, - "凭据未配置", + UiText.Resource(R.string.error_credential_not_configured), RepositoryError.InvalidCredential() ) current.state is State.Loading -> current.state @@ -498,15 +512,26 @@ class ServiceDetailViewModel @Inject constructor( serviceGen != serviceGeneration || _serviceType.value != type - private fun errorMessage(error: Throwable): String = when (error) { - is RepositoryError.InvalidCredential -> "凭据无效,请在设置中重新配置" - is RepositoryError.CredentialChanged -> "凭据已变更,正在重新加载" - is RepositoryError.RateLimited -> - "请求过于频繁${error.retryAfterSeconds?.let { ",请 ${it} 秒后重试" } ?: ""}" - is RepositoryError.Network -> "网络异常,请检查网络" - is RepositoryError.ServerError -> "服务端异常 (HTTP ${error.code})" - is RepositoryError.ParseError -> "数据解析失败: ${error.message}" - else -> error.message ?: "未知错误" + private fun errorMessage(error: Throwable): UiText = when (error) { + is RepositoryError.InvalidCredential -> + UiText.Resource(R.string.error_credential_invalid_reconfigure) + is RepositoryError.CredentialChanged -> + UiText.Resource(R.string.error_credential_changed_reload) + is RepositoryError.RateLimited -> UiText.Resource( + R.string.error_rate_limited_retry, + listOf( + error.retryAfterSeconds?.let { + UiText.Resource(R.string.error_rate_limited_retry_suffix, listOf(it)) + } ?: "" + ) + ) + is RepositoryError.Network -> UiText.Resource(R.string.error_network_check) + is RepositoryError.ServerError -> + UiText.Resource(R.string.error_server_http, listOf(error.code)) + is RepositoryError.ParseError -> + UiText.Resource(R.string.error_parse_failed, listOf(error.message ?: "")) + else -> error.message?.let { UiText.Dynamic(it) } + ?: UiText.Resource(R.string.common_unknown) } private fun prefs(service: ServiceType): android.content.SharedPreferences = @@ -539,7 +564,7 @@ sealed class State { data class Stale(val data: ServiceBalance, val lastFetchedAt: Long) : State() data class Error( val cached: ServiceBalance?, - val message: String, + val message: UiText, val error: Throwable ) : State() data object ManualModeHint : State() @@ -555,5 +580,5 @@ sealed class TriggerState { data object Idle : TriggerState() data object Loading : TriggerState() data class Success(val responseBody: String) : TriggerState() - data class Error(val message: String, val responseBody: String?) : TriggerState() + data class Error(val message: UiText, val responseBody: String?) : TriggerState() } diff --git a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt index 141693f..3fef1a8 100644 --- a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt @@ -41,11 +41,14 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType +import com.rainy.token.ui.components.resolve import androidx.compose.ui.text.input.PasswordVisualTransformation 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.service.ServiceType /** @@ -77,7 +80,7 @@ fun CredentialEditScreen( LaunchedEffect(uiState.message) { uiState.message?.let { msg -> - snackbarHostState.showSnackbar(msg) + snackbarHostState.showSnackbar(msg.resolve(context)) viewModel.clearMessage() } } @@ -88,13 +91,13 @@ fun CredentialEditScreen( title = { Text(service.displayName) }, navigationIcon = { IconButton(onClick = onBack) { - Icon(Icons.Filled.ArrowBack, contentDescription = "返回") + Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back)) } }, actions = { if (uiState.hasExisting) { IconButton(onClick = { viewModel.deleteCredential() }) { - Icon(Icons.Filled.Delete, contentDescription = "删除") + Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.action_delete)) } } } @@ -193,7 +196,7 @@ fun CredentialEditScreen( ) if (uiState.hasExisting) { Text( - text = "✓ 已配置登录态(${uiState.cookieCount} 个 Cookie)", + text = stringResource(R.string.credential_configured_cookies, uiState.cookieCount), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary ) @@ -207,19 +210,19 @@ fun CredentialEditScreen( if (showCookieHelp) { AlertDialog( onDismissRequest = { showCookieHelp = false }, - title = { Text("如何获取 Cookie?") }, + title = { Text(stringResource(R.string.help_cookie_title)) }, text = { Column { - Text("1. 在桌面 Chrome 打开下方登录入口") - Text("2. 用 GitHub / Google 登录(不会被拦截)") - Text("3. 登录后按 F12 → Application → Cookies") - Text("4. 复制该站点下的所有 Cookie 粘贴到上面输入框") - Text("格式:name1=value1; name2=value2") + Text(stringResource(R.string.help_cookie_1)) + Text(stringResource(R.string.help_cookie_2)) + Text(stringResource(R.string.help_cookie_3)) + Text(stringResource(R.string.help_cookie_4)) + Text(stringResource(R.string.help_cookie_5)) } }, confirmButton = { TextButton(onClick = { showCookieHelp = false }) { - Text("我知道了") + Text(stringResource(R.string.action_got_it)) } } ) @@ -228,23 +231,23 @@ fun CredentialEditScreen( if (showGoHelp) { AlertDialog( onDismissRequest = { showGoHelp = false }, - title = { Text("如何获取 OpenCode Go 凭据?") }, + title = { Text(stringResource(R.string.help_ocgo_title)) }, text = { Column { - Text("1. 桌面 Chrome 打开 opencode.ai/auth") - Text("2. 用 GitHub / Google 登录(Google 不会拦截 Chrome)") - Text("3. 登录后跳转到 dashboard,URL 形如:") - Text(" https://opencode.ai/workspace/{id}/go") - Text("4. 复制 URL 中的 {id} 段 → workspaceId") - Text("5. F12 → Application → Cookies") - Text("6. 找到域名 opencode.ai 下名为 auth 的 Cookie") - Text("7. 复制它的 Value 字段 → auth cookie") - Text("注意:只填 auth 一个 Cookie 即可,不要整个 Cookie 字符串") + Text(stringResource(R.string.help_ocgo_1)) + Text(stringResource(R.string.help_ocgo_2)) + Text(stringResource(R.string.help_ocgo_3)) + Text(stringResource(R.string.help_ocgo_4)) + Text(stringResource(R.string.help_ocgo_5)) + Text(stringResource(R.string.help_ocgo_6)) + Text(stringResource(R.string.help_ocgo_7)) + Text(stringResource(R.string.help_ocgo_8)) + Text(stringResource(R.string.help_ocgo_note)) } }, confirmButton = { TextButton(onClick = { showGoHelp = false }) { - Text("我知道了") + Text(stringResource(R.string.action_got_it)) } } ) @@ -253,25 +256,25 @@ fun CredentialEditScreen( if (showOpenCcgoHelp) { AlertDialog( onDismissRequest = { showOpenCcgoHelp = false }, - title = { Text("如何获取 CommandCode Go 凭据?") }, + title = { Text(stringResource(R.string.help_ccgo_title)) }, text = { Column { - Text("1. 打开 https://commandcode.ai 并登录 GitHub") + Text(stringResource(R.string.help_ccgo_1)) Text("") - Text("▸ 获取 API Key(必填):") - Text(" 右上角头像 → Settings → API Keys → 创建一个 Key") + Text(stringResource(R.string.help_ccgo_2)) + Text(stringResource(R.string.help_ccgo_3)) Text("") - Text("▸ 获取全部 Cookie(用于用量记录):") - Text(" F12 → Application → Cookies → commandcode.ai") - Text(" 选中所有 Cookie,复制整个 Cookie 字符串") - Text(" 粘贴到下方输入框即可,无需逐个挑选") + Text(stringResource(R.string.help_ccgo_4)) + Text(stringResource(R.string.help_ccgo_5)) + Text(stringResource(R.string.help_ccgo_6)) + Text(stringResource(R.string.help_ccgo_7)) Text("") - Text("提示:不填 Cookie 也能正常查看余额,只是用量记录为空。") + Text(stringResource(R.string.help_ccgo_note)) } }, confirmButton = { TextButton(onClick = { showOpenCcgoHelp = false }) { - Text("我知道了") + Text(stringResource(R.string.action_got_it)) } } ) @@ -280,32 +283,32 @@ fun CredentialEditScreen( if (showCodexHelp) { AlertDialog( onDismissRequest = { showCodexHelp = false }, - title = { Text("如何获取 Codex auth.json?") }, + title = { Text(stringResource(R.string.help_codex_title)) }, text = { Column { - Text("1. 在终端运行 codex-oauth-proxy 或") - Text(" codex-proxy,完成 ChatGPT 登录") + Text(stringResource(R.string.help_codex_1)) + Text(stringResource(R.string.help_codex_2)) Text("") - Text("2. 凭证文件保存在:") - Text(" ~/.config/codex-oauth-proxy/auth.json") + Text(stringResource(R.string.help_codex_3)) + Text(stringResource(R.string.help_codex_4)) Text("") - Text("3. 用文本编辑器打开该文件,") - Text(" 复制全部内容粘贴到上方输入框") + Text(stringResource(R.string.help_codex_5)) + Text(stringResource(R.string.help_codex_6)) Text("") - Text(" JSON 格式示例:") - Text(" {\"tokens\": {") - Text(" \"access_token\": \"...\",") - Text(" \"refresh_token\": \"...\",") - Text(" \"expiresAt\": 1783727108252") - Text(" }}") + Text(stringResource(R.string.help_codex_7)) + Text(stringResource(R.string.help_codex_8)) + Text(stringResource(R.string.help_codex_9)) + Text(stringResource(R.string.help_codex_10)) + Text(stringResource(R.string.help_codex_11)) + Text(stringResource(R.string.help_codex_12)) Text("") - Text("提示:导入后 APP 会自动刷新 token,") - Text("不需要手动更新。") + Text(stringResource(R.string.help_codex_note)) + Text(stringResource(R.string.help_codex_note2)) } }, confirmButton = { TextButton(onClick = { showCodexHelp = false }) { - Text("我知道了") + Text(stringResource(R.string.action_got_it)) } } ) @@ -326,9 +329,9 @@ private fun OllamaCookieForm( onApiKeyChange: (String) -> Unit, onSaveApiKey: () -> Unit ) { - Text(text = "Ollama Pro 凭据", style = MaterialTheme.typography.titleMedium) + Text(text = stringResource(R.string.credential_title_ollama), style = MaterialTheme.typography.titleMedium) Text( - text = "需要从浏览器复制完整 Cookie。登录 ollama.com/settings 后,在 DevTools → Network → 请求头里找到 Cookie 行复制。", + text = stringResource(R.string.credential_hint_ollama), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.outline ) @@ -351,34 +354,34 @@ private fun OllamaCookieForm( modifier = Modifier.weight(1f), enabled = cookie.isNotBlank() ) { - Text(text = if (hasExisting) "更新凭据" else "保存凭据") + Text(text = if (hasExisting) stringResource(R.string.credential_update) else stringResource(R.string.credential_save)) } OutlinedButton( onClick = onTestAndSave, modifier = Modifier.weight(1f), enabled = cookie.isNotBlank() ) { - Text(text = "测试并保存") + Text(text = stringResource(R.string.action_test_and_save)) } } OutlinedButton( onClick = onOpenLoginUrl, modifier = Modifier.fillMaxWidth() ) { - Text(text = "在浏览器中打开 Ollama Settings") + Text(text = stringResource(R.string.action_open_ollama_settings)) } TextButton(onClick = onCopyLoginUrl, modifier = Modifier.fillMaxWidth()) { - Text(text = "复制登录 URL 到剪贴板") + Text(text = stringResource(R.string.action_copy_login_url)) } // ── 一键激活用量 API Key ── androidx.compose.material3.HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) Text( - text = "一键激活用量 API Key(可选)", + text = stringResource(R.string.activate_api_key_optional), style = MaterialTheme.typography.titleSmall ) Text( - text = "填入 Ollama Cloud API Key 后,可在详情页一键发送请求激活用量统计。从 ollama.com/settings → API Keys 获取。", + text = stringResource(R.string.hint_ollama_trigger_key), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.outline ) @@ -386,7 +389,7 @@ private fun OllamaCookieForm( value = triggerApiKey, onValueChange = onApiKeyChange, label = { Text("API Key") }, - placeholder = { Text("ollama-xxx 或 sk-xxx") }, + placeholder = { Text(stringResource(R.string.placeholder_ollama_api_key)) }, singleLine = true, visualTransformation = PasswordVisualTransformation(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), @@ -397,7 +400,7 @@ private fun OllamaCookieForm( modifier = Modifier.fillMaxWidth(), enabled = triggerApiKey.isNotBlank() ) { - Text("保存 API Key") + Text(stringResource(R.string.action_save_api_key)) } } @@ -419,28 +422,28 @@ private fun OpenCodeGoForm( onApiKeyChange: (String) -> Unit, onSaveApiKey: () -> Unit ) { - Text(text = "OpenCode Go 凭据", style = MaterialTheme.typography.titleMedium) + Text(text = stringResource(R.string.credential_title_opencode_go), style = MaterialTheme.typography.titleMedium) Text( - text = "需要 auth cookie + workspaceId 两个值。APP 会用 OkHttp 抓 dashboard 并自动解析 5h/周/月 三档用量。", + text = stringResource(R.string.credential_hint_opencode_go), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.outline ) OutlinedButton(onClick = onImportFromClipboard, modifier = Modifier.fillMaxWidth()) { - Text(text = "从剪贴板自动导入") + Text(text = stringResource(R.string.action_import_from_clipboard)) } OutlinedTextField( value = workspaceId, onValueChange = onWorkspaceIdChange, label = { Text("Workspace ID") }, - placeholder = { Text("dashboard URL 中的 {id} 段") }, + placeholder = { Text(stringResource(R.string.placeholder_workspace_id)) }, singleLine = true, modifier = Modifier.fillMaxWidth() ) OutlinedTextField( value = authCookie, onValueChange = onAuthCookieChange, - label = { Text("auth Cookie 值") }, - placeholder = { Text("F12 → Cookies → opencode.ai → auth 的 Value") }, + label = { Text(stringResource(R.string.field_auth_cookie_value)) }, + placeholder = { Text(stringResource(R.string.placeholder_auth_cookie)) }, singleLine = true, visualTransformation = PasswordVisualTransformation(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), @@ -451,36 +454,36 @@ private fun OpenCodeGoForm( modifier = Modifier.fillMaxWidth(), enabled = authCookie.isNotBlank() && workspaceId.isNotBlank() ) { - Text(text = if (hasExisting) "更新凭据" else "保存凭据") + Text(text = if (hasExisting) stringResource(R.string.credential_update) else stringResource(R.string.credential_save)) } OutlinedButton( onClick = onTestAndSave, modifier = Modifier.fillMaxWidth(), enabled = authCookie.isNotBlank() && workspaceId.isNotBlank() ) { - Text(text = "测试并保存") + Text(text = stringResource(R.string.action_test_and_save)) } OutlinedButton(onClick = onShowHelp, modifier = Modifier.fillMaxWidth()) { - Text(text = "如何获取这两个值?") + Text(text = stringResource(R.string.action_how_get_two_values)) } OutlinedButton( onClick = onOpenLoginUrl, modifier = Modifier.fillMaxWidth() ) { - Text(text = "在浏览器中打开登录入口") + Text(text = stringResource(R.string.action_open_login_entry)) } TextButton(onClick = onCopyLoginUrl, modifier = Modifier.fillMaxWidth()) { - Text(text = "复制登录 URL 到剪贴板") + Text(text = stringResource(R.string.action_copy_login_url)) } // ── 一键激活用量 API Key ── androidx.compose.material3.HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) Text( - text = "一键激活用量 API Key(可选)", + text = stringResource(R.string.activate_api_key_optional), style = MaterialTheme.typography.titleSmall ) Text( - text = "填入 OpenCode Zen API Key 后,可在详情页一键发送请求激活用量统计。从 opencode.ai/settings → API Keys 获取。", + text = stringResource(R.string.hint_opencode_trigger_key), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.outline ) @@ -499,7 +502,7 @@ private fun OpenCodeGoForm( modifier = Modifier.fillMaxWidth(), enabled = triggerApiKey.isNotBlank() ) { - Text("保存 API Key") + Text(stringResource(R.string.action_save_api_key)) } } @@ -514,9 +517,9 @@ private fun CommandCodeGoForm( onTestAndSave: () -> Unit, onShowHelp: () -> Unit ) { - Text(text = "CommandCode Go 凭据", style = MaterialTheme.typography.titleMedium) + Text(text = stringResource(R.string.credential_title_ccgo), style = MaterialTheme.typography.titleMedium) Text( - text = "需要 API Key(拉余额)。Session Cookie(可选)在浏览器 DevTools 复制,用于拉用量记录。不填也可正常查看余额。", + text = stringResource(R.string.credential_hint_ccgo), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.outline ) @@ -524,7 +527,7 @@ private fun CommandCodeGoForm( value = apiKey, onValueChange = onApiKeyChange, label = { Text("API Key") }, - placeholder = { Text("从 Settings → API Keys 生成") }, + placeholder = { Text(stringResource(R.string.placeholder_api_key_from_settings)) }, singleLine = true, visualTransformation = PasswordVisualTransformation(), keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Password), @@ -533,23 +536,23 @@ private fun CommandCodeGoForm( OutlinedTextField( value = cookieInput, onValueChange = onCookieChange, - label = { Text("Session Cookie(可选)") }, - placeholder = { Text("F12 → Cookies → commandcode.ai → 全选复制粘贴即可") }, + label = { Text(stringResource(R.string.field_session_cookie_optional)) }, + placeholder = { Text(stringResource(R.string.placeholder_session_cookie)) }, modifier = Modifier.fillMaxWidth(), minLines = 2 ) Button(onClick = onSave, modifier = Modifier.fillMaxWidth()) { - Text(if (hasExisting) "更新凭据" else "保存凭据") + Text(if (hasExisting) stringResource(R.string.credential_update) else stringResource(R.string.credential_save)) } OutlinedButton( onClick = onTestAndSave, modifier = Modifier.fillMaxWidth(), enabled = apiKey.isNotBlank() ) { - Text(text = "测试并保存") + Text(text = stringResource(R.string.action_test_and_save)) } OutlinedButton(onClick = onShowHelp, modifier = Modifier.fillMaxWidth()) { - Text(text = "如何获取 API Key 和 Cookie?") + Text(text = stringResource(R.string.action_how_get_api_key_cookie)) } } @@ -561,9 +564,9 @@ private fun ApiKeyForm( onSave: () -> Unit, onTestAndSave: () -> Unit ) { - Text(text = "请输入 API Key", style = MaterialTheme.typography.titleMedium) + Text(text = stringResource(R.string.api_key_form_title), style = MaterialTheme.typography.titleMedium) Text( - text = "凭据会使用 Android Keystore (AES-256 GCM) 加密后存储到本机 DataStore。", + text = stringResource(R.string.api_key_form_hint), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.outline ) @@ -579,14 +582,14 @@ private fun ApiKeyForm( modifier = Modifier.fillMaxWidth() ) Button(onClick = onSave, modifier = Modifier.fillMaxWidth()) { - Text(if (hasExisting) "更新" else "保存") + Text(if (hasExisting) stringResource(R.string.action_update) else stringResource(R.string.action_save)) } OutlinedButton( onClick = onTestAndSave, modifier = Modifier.fillMaxWidth(), enabled = apiKey.isNotBlank() ) { - Text(text = "测试并保存") + Text(text = stringResource(R.string.action_test_and_save)) } } @@ -599,17 +602,17 @@ private fun CodexAuthJsonForm( onShowHelp: () -> Unit, onStartOAuth: () -> Unit = {} ) { - Text(text = "Codex / ChatGPT Plus 凭据", style = MaterialTheme.typography.titleMedium) + Text(text = stringResource(R.string.credential_title_codex), style = MaterialTheme.typography.titleMedium) Text( - text = "推荐使用 OAuth 登录自动获取凭据。也可手动粘贴 auth.json 内容。", + text = stringResource(R.string.credential_hint_codex), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.outline ) Button(onClick = onStartOAuth, modifier = Modifier.fillMaxWidth()) { - Text("🔐 OAuth 登录(推荐)") + Text(stringResource(R.string.action_oauth_login)) } Text( - text = "── 或手动导入 ──", + text = stringResource(R.string.or_manual_import), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline, modifier = Modifier.fillMaxWidth().padding(top = 4.dp), @@ -618,16 +621,16 @@ private fun CodexAuthJsonForm( OutlinedTextField( value = authJson, onValueChange = onAuthJsonChange, - label = { Text("auth.json 完整内容") }, - placeholder = { Text("粘贴 {\"tokens\": {...}} 完整 JSON") }, + label = { Text(stringResource(R.string.field_auth_json)) }, + placeholder = { Text(stringResource(R.string.placeholder_auth_json)) }, modifier = Modifier.fillMaxWidth(), minLines = 6 ) Button(onClick = onSave, modifier = Modifier.fillMaxWidth()) { - Text(if (hasExisting) "更新凭据" else "导入并保存") + Text(if (hasExisting) stringResource(R.string.credential_update) else stringResource(R.string.credential_import_save)) } OutlinedButton(onClick = onShowHelp, modifier = Modifier.fillMaxWidth()) { - Text(text = "如何获取 auth.json?") + Text(text = stringResource(R.string.action_how_get_auth_json)) } } @@ -640,16 +643,16 @@ private fun ManualCookieForm( onOpenLoginUrl: () -> Unit, onShowHelp: () -> Unit ) { - Text(text = "粘贴登录态", style = MaterialTheme.typography.titleMedium) + Text(text = stringResource(R.string.manual_cookie_title), style = MaterialTheme.typography.titleMedium) Text( - text = "Google OAuth 会拒绝 APP 内置 WebView,所以请用浏览器登录后把 Cookie 粘到这里。", + text = stringResource(R.string.manual_cookie_hint), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.outline ) OutlinedTextField( value = cookieValue, onValueChange = onCookieChange, - label = { Text("Cookie 字符串") }, + label = { Text(stringResource(R.string.field_cookie_string)) }, placeholder = { Text("name1=value1; name2=value2; ...") }, modifier = Modifier .fillMaxWidth() @@ -657,19 +660,19 @@ private fun ManualCookieForm( minLines = 4 ) Button(onClick = onSave, modifier = Modifier.fillMaxWidth()) { - Text(text = "保存 Cookie") + Text(text = stringResource(R.string.action_save_cookie)) } OutlinedButton(onClick = onShowHelp, modifier = Modifier.fillMaxWidth()) { - Text(text = "如何获取 Cookie?") + Text(text = stringResource(R.string.help_cookie_title)) } OutlinedButton( onClick = onOpenLoginUrl, modifier = Modifier.fillMaxWidth() ) { - Text(text = "在浏览器中打开登录入口") + Text(text = stringResource(R.string.action_open_login_entry)) } TextButton(onClick = onCopyLoginUrl, modifier = Modifier.fillMaxWidth()) { - Text(text = "复制登录 URL 到剪贴板") + Text(text = stringResource(R.string.action_copy_login_url)) } } diff --git a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt index ea50b05..0df298f 100644 --- a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt @@ -4,6 +4,7 @@ import android.content.ClipboardManager import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.rainy.token.R import com.rainy.token.data.repository.CredentialRepository import com.rainy.token.data.repository.RepositoryError import com.rainy.token.domain.model.CookieEntry @@ -12,6 +13,7 @@ import com.rainy.token.domain.service.FetchMethod import com.rainy.token.domain.service.ServiceConfigProvider import com.rainy.token.domain.service.ServiceType import com.rainy.token.domain.usecase.RefreshBalanceUseCase +import com.rainy.token.ui.components.UiText import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import javax.inject.Provider @@ -117,7 +119,7 @@ class CredentialEditViewModel @Inject constructor( val current = _uiState.value val trimmedKey = current.apiKey.trim() if (trimmedKey.isBlank()) { - _uiState.update { it.copy(message = "API Key 不能为空") } + _uiState.update { it.copy(message = UiText.Resource(R.string.error_api_key_empty)) } return } viewModelScope.launch { @@ -131,7 +133,7 @@ class CredentialEditViewModel @Inject constructor( _uiState.update { it.copy( apiKey = trimmedKey, - message = "已保存", + message = UiText.Resource(R.string.msg_saved), hasExisting = true ) } @@ -142,7 +144,7 @@ class CredentialEditViewModel @Inject constructor( private suspend fun testAndRollback( type: ServiceType, saveAndPrep: suspend () -> Pair Result>, - formatSuccess: (com.rainy.token.domain.model.ServiceBalance) -> String, + formatSuccess: (com.rainy.token.domain.model.ServiceBalance) -> UiText, rollbackOnFailure: Boolean ) { val (previous, testBlock) = saveAndPrep() @@ -158,23 +160,29 @@ class CredentialEditViewModel @Inject constructor( false } val err = result.exceptionOrNull() - val reason = when (err) { - is RepositoryError.InvalidCredential -> "服务拒绝该凭据 (401/403)" - is RepositoryError.CredentialChanged -> "测试期间凭据已变更" - is RepositoryError.RateLimited -> "请求过于频繁 (429)" - is RepositoryError.ServerError -> "服务端错误 (${err.code})" - is RepositoryError.Network -> "网络错误:${err.cause?.message ?: "未知"}" - else -> err?.message ?: "未知错误" + val reason: UiText = when (err) { + is RepositoryError.InvalidCredential -> + UiText.Resource(R.string.error_credential_rejected) + is RepositoryError.CredentialChanged -> + UiText.Resource(R.string.error_credential_changed_test) + is RepositoryError.RateLimited -> + UiText.Resource(R.string.error_rate_limited) + is RepositoryError.ServerError -> + UiText.Resource(R.string.error_server, listOf(err.code)) + is RepositoryError.Network -> + UiText.Resource(R.string.error_network, listOf(err.cause?.message ?: "未知")) + else -> err?.message?.let { UiText.Dynamic(it) } + ?: UiText.Resource(R.string.common_unknown) } val hasExisting = credentialRepository.get(type) != null - val rollbackNote = when { + val rollbackNote: Any = when { !rollbackOnFailure -> "" - rolledBack -> ",已恢复原凭据" - else -> ",检测到凭据已变化,未执行回滚" + rolledBack -> UiText.Resource(R.string.error_rollback_done) + else -> UiText.Resource(R.string.error_rollback_skipped) } _uiState.update { it.copy( - message = "测试失败。$reason$rollbackNote", + message = UiText.Resource(R.string.error_test_failed, listOf(reason, rollbackNote)), hasExisting = hasExisting ) } @@ -184,13 +192,13 @@ class CredentialEditViewModel @Inject constructor( fun testAndSaveApiKey() { val type = serviceType ?: return if (type != ServiceType.DEEPSEEK && type != ServiceType.COMMANDCODE_GO && type != ServiceType.CODEX) { - _uiState.update { it.copy(message = "暂不支持测试此服务") } + _uiState.update { it.copy(message = UiText.Resource(R.string.error_test_not_supported)) } return } val current = _uiState.value val trimmedKey = current.apiKey.trim() if (trimmedKey.isBlank()) { - _uiState.update { it.copy(message = "API Key 不能为空") } + _uiState.update { it.copy(message = UiText.Resource(R.string.error_api_key_empty)) } return } viewModelScope.launch { @@ -205,30 +213,41 @@ class CredentialEditViewModel @Inject constructor( testAndRollback( type = type, saveAndPrep = { existing to { refreshBalanceUseCaseProvider.get().invoke(type) } }, - formatSuccess = { bal -> "连接成功!余额: ${bal.amount} ${bal.unit}" }, + formatSuccess = { bal -> + UiText.Resource( + R.string.msg_connect_success_balance, + listOf(bal.amount.toString(), bal.unit) + ) + }, rollbackOnFailure = false ) } } /** 把 API Key 缩成 'sk-a***xyz' 这种形式,前 4 后 4,中间用 *** 代替。 */ - private fun maskedKeyPreview(key: String): String { - if (key.length <= 8) return "*** (长度 ${key.length}) ***" + private fun maskedKeyPreview(key: String): UiText { + if (key.length <= 8) { + return UiText.Resource(R.string.msg_key_length, listOf(key.length)) + } val head = key.take(4) val tail = key.takeLast(4) - return "$head***$tail (长度 ${key.length})" + return UiText.Resource(R.string.msg_key_masked, listOf(head, tail, key.length)) } fun saveOpenCodeGoSession() { val type = serviceType ?: return val current = _uiState.value if (current.authCookie.isBlank() || current.workspaceId.isBlank()) { - _uiState.update { it.copy(message = "auth cookie 和 workspaceId 都需要填写") } + _uiState.update { + it.copy(message = UiText.Resource(R.string.error_auth_cookie_workspace)) + } return } viewModelScope.launch { doSaveOpenCodeGo(current.workspaceId.trim(), current.authCookie.trim()) - _uiState.update { it.copy(message = "已保存凭据", hasExisting = true) } + _uiState.update { + it.copy(message = UiText.Resource(R.string.msg_credentials_saved), hasExisting = true) + } } } @@ -236,7 +255,9 @@ class CredentialEditViewModel @Inject constructor( val type = serviceType ?: return val current = _uiState.value if (current.authCookie.isBlank() || current.workspaceId.isBlank()) { - _uiState.update { it.copy(message = "auth cookie 和 workspaceId 都需要填写") } + _uiState.update { + it.copy(message = UiText.Resource(R.string.error_auth_cookie_workspace)) + } return } viewModelScope.launch { @@ -245,7 +266,7 @@ class CredentialEditViewModel @Inject constructor( testAndRollback( type = type, saveAndPrep = { previous to { refreshBalanceUseCaseProvider.get().invoke(type) } }, - formatSuccess = { "连接成功,凭据已保存" }, + formatSuccess = { UiText.Resource(R.string.msg_connect_success_saved) }, rollbackOnFailure = true ) } @@ -269,12 +290,12 @@ class CredentialEditViewModel @Inject constructor( val type = serviceType ?: return val current = _uiState.value if (current.cookieInput.isBlank()) { - _uiState.update { it.copy(message = "Cookie 不能为空") } + _uiState.update { it.copy(message = UiText.Resource(R.string.error_cookie_empty)) } return } val cookies = parseCookieString(current.cookieInput) if (cookies.isEmpty()) { - _uiState.update { it.copy(message = "Cookie 格式错误,应为 name1=value1; name2=value2") } + _uiState.update { it.copy(message = UiText.Resource(R.string.error_cookie_format)) } return } viewModelScope.launch { @@ -289,7 +310,7 @@ class CredentialEditViewModel @Inject constructor( credentialRepository.save(updated) _uiState.update { it.copy( - message = "已保存 ${cookies.size} 个 Cookie", + message = UiText.Resource(R.string.msg_cookies_saved, listOf(cookies.size)), hasExisting = true, cookieCount = cookies.size ) @@ -306,7 +327,7 @@ class CredentialEditViewModel @Inject constructor( val current = _uiState.value val text = current.codexAuthJson.trim() if (text.isBlank()) { - _uiState.update { it.copy(message = "请粘贴 auth.json 内容") } + _uiState.update { it.copy(message = UiText.Resource(R.string.error_auth_json_empty)) } return } viewModelScope.launch { @@ -324,7 +345,9 @@ class CredentialEditViewModel @Inject constructor( ?: System.currentTimeMillis() + 10L * 24 * 3600 * 1000 if (accessToken.isNullOrBlank() || refreshToken.isNullOrBlank()) { - _uiState.update { it.copy(message = "auth.json 缺少 access_token 或 refresh_token") } + _uiState.update { + it.copy(message = UiText.Resource(R.string.error_auth_json_missing_tokens)) + } return@launch } @@ -339,12 +362,19 @@ class CredentialEditViewModel @Inject constructor( credentialRepository.save(newCred) _uiState.update { it.copy( - message = "已保存 Codex 凭据,token 到期后会自动刷新", + message = UiText.Resource(R.string.msg_codex_saved), hasExisting = true ) } } catch (e: Exception) { - _uiState.update { it.copy(message = "解析失败:${e.message ?: "JSON 格式错误"}") } + _uiState.update { + it.copy( + message = UiText.Resource( + R.string.error_auth_json_parse, + listOf(e.message ?: "JSON 格式错误") + ) + ) + } } } } @@ -354,7 +384,7 @@ class CredentialEditViewModel @Inject constructor( val current = _uiState.value val trimmedKey = current.apiKey.trim() if (trimmedKey.isBlank()) { - _uiState.update { it.copy(message = "API Key 不能为空") } + _uiState.update { it.copy(message = UiText.Resource(R.string.error_api_key_empty)) } return } val cookies = if (current.cookieInput.isNotBlank()) { @@ -378,7 +408,7 @@ class CredentialEditViewModel @Inject constructor( _uiState.update { it.copy( apiKey = trimmedKey, - message = "已保存凭据", + message = UiText.Resource(R.string.msg_credentials_saved), hasExisting = true, cookieCount = cookies.size ) @@ -391,7 +421,7 @@ class CredentialEditViewModel @Inject constructor( val current = _uiState.value val trimmedKey = current.apiKey.trim() if (trimmedKey.isBlank()) { - _uiState.update { it.copy(message = "API Key 不能为空") } + _uiState.update { it.copy(message = UiText.Resource(R.string.error_api_key_empty)) } return } viewModelScope.launch { @@ -416,7 +446,10 @@ class CredentialEditViewModel @Inject constructor( type = type, saveAndPrep = { existing to { refreshBalanceUseCaseProvider.get().invoke(type) } }, formatSuccess = { bal -> - "连接成功!余额: \$${String.format(java.util.Locale.US, "%.2f", bal.amount)}" + UiText.Resource( + R.string.msg_connect_success_dollar, + listOf(String.format(java.util.Locale.US, "%.2f", bal.amount)) + ) }, rollbackOnFailure = false ) @@ -448,7 +481,7 @@ class CredentialEditViewModel @Inject constructor( _uiState.update { it.copy( triggerApiKey = trimmedKey, - message = "API Key 已保存", + message = UiText.Resource(R.string.msg_api_key_saved), hasExisting = true ) } @@ -459,12 +492,14 @@ class CredentialEditViewModel @Inject constructor( val type = serviceType ?: return val current = _uiState.value if (current.ollamaCookie.isBlank()) { - _uiState.update { it.copy(message = "Cookie 不能为空") } + _uiState.update { it.copy(message = UiText.Resource(R.string.error_cookie_empty)) } return } viewModelScope.launch { doSaveOllama(current.ollamaCookie.trim()) - _uiState.update { it.copy(message = "已保存凭据", hasExisting = true) } + _uiState.update { + it.copy(message = UiText.Resource(R.string.msg_credentials_saved), hasExisting = true) + } } } @@ -472,7 +507,7 @@ class CredentialEditViewModel @Inject constructor( val type = serviceType ?: return val current = _uiState.value if (current.ollamaCookie.isBlank()) { - _uiState.update { it.copy(message = "Cookie 不能为空") } + _uiState.update { it.copy(message = UiText.Resource(R.string.error_cookie_empty)) } return } viewModelScope.launch { @@ -482,7 +517,10 @@ class CredentialEditViewModel @Inject constructor( type = type, saveAndPrep = { previous to { refreshBalanceUseCaseProvider.get().invoke(type) } }, formatSuccess = { bal -> - "连接成功!Session: ${bal.amount}% · ${bal.extras["plan"] ?: "—"}" + UiText.Resource( + R.string.msg_connect_success_session, + listOf(bal.amount.toString(), bal.extras["plan"] ?: "—") + ) }, rollbackOnFailure = true ) @@ -508,7 +546,7 @@ class CredentialEditViewModel @Inject constructor( credentialRepository.remove(type) _uiState.update { it.copy( - message = "凭据已删除", + message = UiText.Resource(R.string.msg_credential_deleted), hasExisting = false, apiKey = "", cookieInput = "", @@ -530,7 +568,7 @@ class CredentialEditViewModel @Inject constructor( val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return val text = cm.primaryClip?.getItemAt(0)?.text?.toString() if (text.isNullOrBlank()) { - _uiState.update { it.copy(message = "剪贴板为空") } + _uiState.update { it.copy(message = UiText.Resource(R.string.error_clipboard_empty)) } return } val ws = extractWorkspaceId(text) @@ -543,13 +581,13 @@ class CredentialEditViewModel @Inject constructor( } when { ws != null && auth != null -> - _uiState.update { it.copy(message = "已识别 workspaceId 和 auth cookie") } + _uiState.update { it.copy(message = UiText.Resource(R.string.msg_clipboard_ws_auth)) } ws != null -> - _uiState.update { it.copy(message = "已识别 workspaceId,请再粘贴 auth cookie") } + _uiState.update { it.copy(message = UiText.Resource(R.string.msg_clipboard_ws_only)) } auth != null -> - _uiState.update { it.copy(message = "已识别 auth cookie,请再粘贴 workspaceId") } + _uiState.update { it.copy(message = UiText.Resource(R.string.msg_clipboard_auth_only)) } else -> - _uiState.update { it.copy(message = "未识别到有效凭据") } + _uiState.update { it.copy(message = UiText.Resource(R.string.error_clipboard_none)) } } } @@ -594,5 +632,5 @@ data class CredentialEditUiState( val ollamaCookie: String = "", /** OCGO / Ollama 的一键激活用量 API Key */ val triggerApiKey: String = "", - val message: String? = null + val message: UiText? = null ) diff --git a/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt b/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt index f0e8039..d0fcea0 100644 --- a/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt @@ -33,6 +33,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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 @@ -45,6 +46,7 @@ import com.rainy.token.ui.components.ServiceIcon import com.rainy.token.ui.components.StatusChip import com.rainy.token.ui.components.StatusLevel import com.rainy.token.ui.components.StatusStyle +import com.rainy.token.R import com.rainy.token.ui.components.AppTips import com.rainy.token.ui.theme.InkMuted import com.rainy.token.ui.theme.StrawberryPink @@ -79,7 +81,7 @@ fun SettingsScreen( TopAppBar( title = { Text( - "设置", + stringResource(R.string.title_settings), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold ) @@ -88,7 +90,7 @@ fun SettingsScreen( IconButton(onClick = onBack) { Icon( Icons.Filled.ArrowBack, - contentDescription = "返回", + contentDescription = stringResource(R.string.action_back), tint = StrawberryPink ) } @@ -118,7 +120,7 @@ fun SettingsScreen( ) { item { Text( - text = "凭据管理", + text = stringResource(R.string.title_credentials), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = InkMuted, @@ -190,13 +192,13 @@ private fun AboutCard() { ) { Column(modifier = Modifier.padding(20.dp)) { Text( - text = "关于 RainyToken", + text = stringResource(R.string.title_about), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold ) Spacer(modifier = Modifier.padding(top = 8.dp)) Text( - text = "统一查询 AI 余额的小工具。\n本地加密存储凭据,不上传任何数据。", + text = stringResource(R.string.about_description), style = MaterialTheme.typography.bodyMedium, color = InkMuted ) @@ -204,18 +206,19 @@ private fun AboutCard() { } } +@Composable private fun stateLabel(state: CredentialStatus.State): String = when (state) { - CredentialStatus.State.NOT_CONFIGURED -> "未配置 · 点击添加" - CredentialStatus.State.OK -> "已配置 · 凭据有效" - CredentialStatus.State.EXPIRED -> "已过期 · 点击重新配置" - CredentialStatus.State.WARNING -> "需要重新验证" + CredentialStatus.State.NOT_CONFIGURED -> stringResource(R.string.status_not_configured_add) + CredentialStatus.State.OK -> stringResource(R.string.status_ok_valid) + CredentialStatus.State.EXPIRED -> stringResource(R.string.status_expired_reconfigure) + CredentialStatus.State.WARNING -> stringResource(R.string.status_need_reverify) } private fun stateToChip(state: CredentialStatus.State): StatusStyle = when (state) { - CredentialStatus.State.NOT_CONFIGURED -> StatusStyle("未配置", StatusLevel.WARNING) - CredentialStatus.State.OK -> StatusStyle("已配置", StatusLevel.OK) - CredentialStatus.State.EXPIRED -> StatusStyle("已过期", StatusLevel.ERROR) - CredentialStatus.State.WARNING -> StatusStyle("需重登", StatusLevel.WARNING) + CredentialStatus.State.NOT_CONFIGURED -> StatusStyle(R.string.status_not_configured, StatusLevel.WARNING) + CredentialStatus.State.OK -> StatusStyle(R.string.status_configured, StatusLevel.OK) + CredentialStatus.State.EXPIRED -> StatusStyle(R.string.status_expired, StatusLevel.ERROR) + CredentialStatus.State.WARNING -> StatusStyle(R.string.status_relogin, StatusLevel.WARNING) } @Composable @@ -239,13 +242,13 @@ private fun TipsCard(onClick: () -> Unit) { Spacer(modifier = Modifier.width(12.dp)) Column(modifier = Modifier.weight(1f)) { Text( - text = "使用小技巧", + text = stringResource(R.string.title_tips), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = StrawberryPink ) Text( - text = "查看全部 ${AppTips.tips.size} 条隐藏操作", + text = stringResource(R.string.tips_view_all, AppTips.tips.size), style = MaterialTheme.typography.bodySmall, color = InkMuted, modifier = Modifier.padding(top = 2.dp) @@ -276,13 +279,13 @@ private fun DebugLogCard(onClick: () -> Unit) { Spacer(modifier = Modifier.width(12.dp)) Column(modifier = Modifier.weight(1f)) { Text( - text = "调试日志", + text = stringResource(R.string.title_debug_log), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = InkMuted ) Text( - text = "查看 token 刷新、网络请求等调试记录", + text = stringResource(R.string.debug_log_desc), style = MaterialTheme.typography.bodySmall, color = InkMuted, modifier = Modifier.padding(top = 2.dp) diff --git a/app/src/main/java/com/rainy/token/ui/webview/CodexOAuthScreen.kt b/app/src/main/java/com/rainy/token/ui/webview/CodexOAuthScreen.kt index ac36eae..2d9d026 100644 --- a/app/src/main/java/com/rainy/token/ui/webview/CodexOAuthScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/webview/CodexOAuthScreen.kt @@ -43,11 +43,15 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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 import androidx.compose.ui.viewinterop.AndroidView import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.rainy.token.R +import com.rainy.token.ui.components.UiText +import com.rainy.token.ui.components.asString import com.rainy.token.ui.theme.StrawberryPink /** @@ -77,10 +81,10 @@ fun CodexOAuthScreen( Scaffold( topBar = { TopAppBar( - title = { Text("Codex OAuth 登录") }, + title = { Text(stringResource(R.string.oauth_screen_title)) }, navigationIcon = { IconButton(onClick = onBack) { - Icon(Icons.Filled.ArrowBack, contentDescription = "返回") + Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back)) } } ) @@ -100,7 +104,7 @@ fun CodexOAuthScreen( ) { CircularProgressIndicator(color = StrawberryPink) Text( - "正在交换 Token...", + stringResource(R.string.oauth_exchanging), modifier = Modifier.padding(top = 16.dp), style = MaterialTheme.typography.bodyLarge ) @@ -116,12 +120,12 @@ fun CodexOAuthScreen( verticalArrangement = Arrangement.Center ) { Text( - uiState.error!!, + uiState.error!!.asString(), style = MaterialTheme.typography.bodyLarge, color = androidx.compose.ui.graphics.Color(0xFFE91E63) ) TextButton(onClick = { viewModel.start(CodexOAuthMode.WEBVIEW) }) { - Text("重试", color = StrawberryPink) + Text(stringResource(R.string.action_retry), color = StrawberryPink) } } } @@ -180,7 +184,7 @@ fun CodexOAuthScreen( ) { CircularProgressIndicator(color = StrawberryPink) Text( - "正在准备授权...", + stringResource(R.string.oauth_preparing), modifier = Modifier.padding(top = 16.dp), style = MaterialTheme.typography.bodyLarge ) @@ -197,7 +201,7 @@ fun CodexOAuthScreen( @Composable private fun HeadlessOAuthContent( authUrl: String, - error: String?, + error: UiText?, onCopyUrl: () -> Unit, onOpenInBrowser: () -> Unit, onSubmit: (String) -> Unit, @@ -213,16 +217,12 @@ private fun HeadlessOAuthContent( verticalArrangement = Arrangement.spacedBy(12.dp) ) { Text( - text = "无头模式登录", + text = stringResource(R.string.oauth_headless_title), style = MaterialTheme.typography.titleMedium, color = StrawberryPink ) Text( - text = "1. 点击「在浏览器中打开」或复制下方链接到浏览器\n" + - "2. 在 OpenAI 页面完成登录和授权\n" + - "3. 浏览器会跳转到一个 localhost 地址(页面打不开是正常的)\n" + - "4. 复制浏览器地址栏的完整 URL 粘贴到下方输入框\n" + - "5. 点击「提交回调 URL」", + text = stringResource(R.string.oauth_steps), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.outline ) @@ -231,7 +231,7 @@ private fun HeadlessOAuthContent( OutlinedTextField( value = authUrl, onValueChange = {}, - label = { Text("授权链接") }, + label = { Text(stringResource(R.string.oauth_auth_url_label)) }, readOnly = true, minLines = 3, maxLines = 5, @@ -246,18 +246,18 @@ private fun HeadlessOAuthContent( onClick = onOpenInBrowser, modifier = Modifier.weight(1f) ) { - Text("在浏览器中打开") + Text(stringResource(R.string.action_open_in_browser)) } OutlinedButton( onClick = onCopyUrl, modifier = Modifier.weight(1f) ) { - Text("复制链接") + Text(stringResource(R.string.action_copy_link)) } } Text( - text = "── 登录后粘贴回调 URL ──", + text = stringResource(R.string.oauth_paste_callback_hint), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline, modifier = Modifier.fillMaxWidth().padding(top = 8.dp), @@ -267,8 +267,8 @@ private fun HeadlessOAuthContent( OutlinedTextField( value = callbackUrl, onValueChange = { callbackUrl = it }, - label = { Text("回调 URL") }, - placeholder = { Text("http://localhost:1455/auth/callback?code=...") }, + label = { Text(stringResource(R.string.oauth_callback_url_label)) }, + placeholder = { Text(stringResource(R.string.oauth_callback_url_placeholder)) }, minLines = 2, maxLines = 6, modifier = Modifier.fillMaxWidth() @@ -279,18 +279,18 @@ private fun HeadlessOAuthContent( modifier = Modifier.fillMaxWidth(), enabled = callbackUrl.isNotBlank() ) { - Text("提交回调 URL") + Text(stringResource(R.string.action_submit_callback)) } if (error != null) { Text( - text = error, + text = error.asString(), style = MaterialTheme.typography.bodySmall, color = androidx.compose.ui.graphics.Color(0xFFE91E63), modifier = Modifier.padding(top = 4.dp) ) TextButton(onClick = onRetry) { - Text("重新生成授权链接", color = StrawberryPink) + Text(stringResource(R.string.action_regenerate_link), color = StrawberryPink) } } } diff --git a/app/src/main/java/com/rainy/token/ui/webview/CodexOAuthViewModel.kt b/app/src/main/java/com/rainy/token/ui/webview/CodexOAuthViewModel.kt index a89b11f..9d42c3a 100644 --- a/app/src/main/java/com/rainy/token/ui/webview/CodexOAuthViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/webview/CodexOAuthViewModel.kt @@ -2,11 +2,13 @@ package com.rainy.token.ui.webview import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.rainy.token.R import com.rainy.token.data.debug.DebugLog import com.rainy.token.data.repository.CodexOAuthHelper import com.rainy.token.data.repository.CredentialRepository import com.rainy.token.domain.model.Credential import com.rainy.token.domain.service.ServiceType +import com.rainy.token.ui.components.UiText import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -80,7 +82,14 @@ class CodexOAuthViewModel @Inject constructor( */ private fun handleCallbackUrl(url: String) { if (!url.startsWith(CodexOAuthHelper.CALLBACK_PREFIX)) { - _uiState.update { it.copy(error = "URL 应以 ${CodexOAuthHelper.CALLBACK_PREFIX} 开头") } + _uiState.update { + it.copy( + error = UiText.Resource( + R.string.error_oauth_url_prefix, + listOf(CodexOAuthHelper.CALLBACK_PREFIX) + ) + ) + } return } @@ -92,19 +101,21 @@ class CodexOAuthViewModel @Inject constructor( if (error != null) { val errorDesc = uri.getQueryParameter("error_description") ?: error DebugLog.e(TAG, "OAuth 授权失败: $errorDesc") - _uiState.update { it.copy(error = "授权失败: $errorDesc") } + _uiState.update { + it.copy(error = UiText.Resource(R.string.error_oauth_auth_failed, listOf(errorDesc))) + } return } if (receivedState != _uiState.value.expectedState) { DebugLog.e(TAG, "OAuth state 不匹配(CSRF 防护)") - _uiState.update { it.copy(error = "State 不匹配,请重试") } + _uiState.update { it.copy(error = UiText.Resource(R.string.error_oauth_state_mismatch)) } return } if (code.isNullOrBlank()) { DebugLog.e(TAG, "OAuth 回调缺少 code 参数") - _uiState.update { it.copy(error = "回调缺少授权码") } + _uiState.update { it.copy(error = UiText.Resource(R.string.error_oauth_missing_code)) } return } @@ -118,7 +129,12 @@ class CodexOAuthViewModel @Inject constructor( if (result == null) { DebugLog.e(TAG, "token 交换失败") - _uiState.update { it.copy(exchanging = false, error = "Token 交换失败,请查看调试日志") } + _uiState.update { + it.copy( + exchanging = false, + error = UiText.Resource(R.string.error_oauth_token_exchange) + ) + } return@launch } @@ -149,5 +165,5 @@ data class CodexOAuthUiState( val expectedState: String? = null, val exchanging: Boolean = false, val loginSucceeded: Boolean = false, - val error: String? = null + val error: UiText? = null ) \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginScreen.kt b/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginScreen.kt index 5151fcd..c2ad9fb 100644 --- a/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginScreen.kt @@ -27,11 +27,14 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.viewinterop.AndroidView 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.service.ServiceType +import com.rainy.token.ui.components.asString /** * 通用 WebView 登录容器。计划 4.1: @@ -65,10 +68,10 @@ fun WebViewLoginScreen( Scaffold( topBar = { TopAppBar( - title = { Text("登录 ${service.displayName}") }, + title = { Text(stringResource(R.string.title_webview_login, service.displayName)) }, navigationIcon = { IconButton(onClick = onBack) { - Icon(Icons.Filled.ArrowBack, contentDescription = "返回") + Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back)) } } ) @@ -113,7 +116,7 @@ fun WebViewLoginScreen( .fillMaxSize() .padding(16.dp)) { Text( - text = "未配置登录 URL", + text = stringResource(R.string.error_login_url_missing), style = MaterialTheme.typography.bodyLarge ) } @@ -125,21 +128,20 @@ fun WebViewLoginScreen( if (uiState.pendingManualConfirm && !uiState.loginSucceeded) { AlertDialog( onDismissRequest = { viewModel.dismissManualPrompt() }, - title = { Text("完成登录了吗?") }, + title = { Text(stringResource(R.string.dialog_login_done_title)) }, text = { Text( - uiState.error ?: "如果你已经在页面上完成登录但页面没有跳转," + - "可点击下方按钮让我抓取 Cookie 并保存登录态。" + uiState.error?.asString() ?: stringResource(R.string.dialog_login_done_body) ) }, confirmButton = { Button(onClick = { viewModel.confirmLoginManually() }) { - Text("我已登录,保存") + Text(stringResource(R.string.action_login_save)) } }, dismissButton = { TextButton(onClick = { viewModel.dismissManualPrompt() }) { - Text("继续等待") + Text(stringResource(R.string.action_keep_waiting)) } } ) diff --git a/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginViewModel.kt b/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginViewModel.kt index 8cb01e8..780f21d 100644 --- a/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginViewModel.kt @@ -1,10 +1,12 @@ package com.rainy.token.ui.webview import androidx.lifecycle.ViewModel +import com.rainy.token.R import com.rainy.token.data.repository.WebViewSessionSaver import com.rainy.token.domain.model.Credential import com.rainy.token.domain.service.ServiceConfigProvider import com.rainy.token.domain.service.ServiceType +import com.rainy.token.ui.components.UiText import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -75,7 +77,7 @@ class WebViewLoginViewModel @Inject constructor( } else { _uiState.update { it.copy( - error = "未抓到 Cookie,请确认已登录", + error = UiText.Resource(R.string.error_cookie_not_found), pendingManualConfirm = true ) } @@ -96,5 +98,5 @@ data class WebViewLoginUiState( val loginSucceeded: Boolean = false, val savedSession: Credential.SessionCredential? = null, val pendingManualConfirm: Boolean = false, - val error: String? = null + val error: UiText? = null ) \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt b/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt index 7c172c6..89ff5e3 100644 --- a/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt +++ b/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt @@ -93,7 +93,10 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() { val cache = BalanceCache(dataStore) val selectedService = currentDisplayService(context) views.setTextViewText(R.id.widget_switch, shortName(selectedService)) - views.setTextViewText(R.id.widget_service_title, "${selectedService.displayName} · 额度") + views.setTextViewText( + R.id.widget_service_title, + context.getString(R.string.widget_service_quota, selectedService.displayName) + ) views.setImageViewResource(R.id.widget_logo, widgetLogo(selectedService)) // Ollama logo 是正方形,XML 默认 22x12 是给宽扁 logo 的 if (selectedService == ServiceType.OLLAMA) { @@ -106,15 +109,21 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() { val cached = cache.get(selectedService) if (cached != null) { selectedHasCachedData = true - populateServiceRows(views, selectedService, cached.balance) + populateServiceRows(views, context, selectedService, cached.balance) val sdf = SimpleDateFormat("MM-dd HH:mm", Locale.getDefault()) - val timeText = "更新 " + sdf.format(Date(cached.fetchedAt)) - views.setTextViewText(R.id.widget_updated, timeText) + val timeText = sdf.format(Date(cached.fetchedAt)) + views.setTextViewText( + R.id.widget_updated, + context.getString(R.string.widget_updated_at, timeText) + ) } else { - setEmptyState(views) + setEmptyState(views, context) views.setTextViewText(R.id.widget_switch, shortName(selectedService)) - views.setTextViewText(R.id.widget_service_title, "${selectedService.displayName} · 额度") + views.setTextViewText( + R.id.widget_service_title, + context.getString(R.string.widget_service_quota, selectedService.displayName) + ) } // DeepSeek 余额 @@ -127,7 +136,7 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() { views.setTextViewText(R.id.widget_ds_amount, "—") } } catch (_: Exception) { - setEmptyState(views) + setEmptyState(views, context) } } @@ -143,13 +152,14 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() { private fun populateServiceRows( views: RemoteViews, + context: Context, service: ServiceType, balance: com.rainy.token.domain.model.ServiceBalance ) { val extras = balance.extras when (service) { ServiceType.OPENCODE_GO -> { - setRowLabel(views, "5h", "本周", "本月") + setRowLabel(views, context.getString(R.string.window_5h_short), context.getString(R.string.window_weekly), context.getString(R.string.window_monthly)) populateRow(views, R.id.row1_pct, R.id.row1_bar, R.id.row1_reset, pct = extras["rolling.pct"]?.toIntOrNull(), resetSec = extras["rolling.resetInSec"]?.toLongOrNull()) @@ -165,7 +175,7 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() { if (used == null || cap == null || cap <= 0) return null return ((used / cap) * 100).toInt().coerceIn(0, 100) } - setRowLabel(views, "5h", "本周", "本月") + setRowLabel(views, context.getString(R.string.window_5h_short), context.getString(R.string.window_weekly), context.getString(R.string.window_monthly)) populateRow(views, R.id.row1_pct, R.id.row1_bar, R.id.row1_reset, pct = calcPct(extras["fiveHour.used"]?.toDoubleOrNull(), extras["fiveHour.cap"]?.toDoubleOrNull()), resetSec = extras["fiveHour.resetInSec"]?.toLongOrNull()) @@ -180,8 +190,10 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() { val windowCount = extras.keys .mapNotNull { key -> key.removePrefix("window_").substringBefore('.').toIntOrNull() } .distinct().maxOrNull()?.plus(1) ?: 0 + val weeklyLabel = context.getString(R.string.window_every_week) + val monthlyLabel = context.getString(R.string.window_every_month) val windows = (0 until windowCount).map { index -> - val label = normalizeWindowLabel(extras["window_$index.label"] ?: "Usage") + val label = normalizeWindowLabel(extras["window_$index.label"] ?: "Usage", weeklyLabel = weeklyLabel) val remaining = extras["window_$index.remainingPct"]?.toIntOrNull() val resetAt = extras["window_$index.resetAt"]?.toLongOrNull()?.takeIf { it > 0 } Triple(label, remaining?.let { (100 - it).coerceIn(0, 100) }, resetAt?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 }) @@ -195,8 +207,8 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() { windows.firstOrNull { it.first.contains("5") } ?: windows.getOrNull(0) ?: Triple("5h", null, null) } val otherWindows = windows.filter { it != row1 } - val row2 = otherWindows.getOrNull(0) ?: Triple("每周", null, null) - val row3 = otherWindows.getOrNull(1) ?: Triple("每月", null, null) + val row2 = otherWindows.getOrNull(0) ?: Triple(weeklyLabel, null, null) + val row3 = otherWindows.getOrNull(1) ?: Triple(monthlyLabel, null, null) setRowLabel(views, row1.first, row2.first, row3.first) listOf( Triple(R.id.row1_pct, R.id.row1_bar, R.id.row1_reset), @@ -207,9 +219,9 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() { populateRow(views, ids.first, ids.second, ids.third, pct = window.second, resetSec = window.third) } } - ServiceType.DEEPSEEK -> setEmptyState(views) + ServiceType.DEEPSEEK -> setEmptyState(views, context) ServiceType.OLLAMA -> { - setRowLabel(views, "5h", "每周", "") + 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, pct = extras["session.pct"]?.toFloatOrNull()?.toInt(), resetSec = extras["session.resetAt"]?.toLongOrNull()?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 }) @@ -223,8 +235,11 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() { views.setTextViewText(R.id.row3_reset, "") // Plan 信息合并到标题行 val plan = extras["plan"] ?: "" - val titleText = if (plan.isNotEmpty()) "${service.displayName} · $plan" - else "${service.displayName} · 额度" + val titleText = if (plan.isNotEmpty()) { + context.getString(R.string.widget_quota_with_plan, service.displayName, plan) + } else { + context.getString(R.string.widget_service_quota, service.displayName) + } views.setTextViewText(R.id.widget_service_title, titleText) } } @@ -269,8 +284,8 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() { views.setColorStateList(barViewId, "setProgressTintList", ColorStateList.valueOf(color)) } - private fun setEmptyState(views: RemoteViews) { - views.setTextViewText(R.id.widget_updated, "暂无数据") + private fun setEmptyState(views: RemoteViews, context: Context) { + views.setTextViewText(R.id.widget_updated, context.getString(R.string.widget_no_data)) for (pctId in listOf(R.id.row1_pct, R.id.row2_pct, R.id.row3_pct)) { views.setTextViewText(pctId, "—") } @@ -357,7 +372,7 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() { val ids = appWidgetManager.getAppWidgetIds(component) ids.forEach { id -> val views = RemoteViews(context.packageName, R.layout.widget_opencode_go) - views.setTextViewText(R.id.widget_updated, "刷新中…") + views.setTextViewText(R.id.widget_updated, context.getString(R.string.widget_refreshing)) appWidgetManager.partiallyUpdateAppWidget(id, views) } } diff --git a/app/src/main/res/layout/widget_opencode_go.xml b/app/src/main/res/layout/widget_opencode_go.xml index b2e8810..4853a95 100644 --- a/app/src/main/res/layout/widget_opencode_go.xml +++ b/app/src/main/res/layout/widget_opencode_go.xml @@ -102,7 +102,7 @@ android:id="@+id/widget_service_title" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:text="OpenCode Go · 用量" + android:text="@string/widget_title_opencode" android:textColor="#8A7A82" android:textSize="11sp" /> @@ -146,7 +146,7 @@ + android:text="@string/window_weekly" android:textColor="#8A7A82" android:textSize="11sp" /> + android:text="@string/window_monthly" android:textColor="#8A7A82" android:textSize="11sp" /> @@ -249,7 +249,7 @@ diff --git a/app/src/main/res/layout/widget_opencode_go_preview.xml b/app/src/main/res/layout/widget_opencode_go_preview.xml index c6927ff..fc2c16d 100644 --- a/app/src/main/res/layout/widget_opencode_go_preview.xml +++ b/app/src/main/res/layout/widget_opencode_go_preview.xml @@ -41,7 +41,7 @@ @@ -63,7 +63,7 @@ @@ -103,7 +103,7 @@ + android:text="@string/window_weekly" android:textColor="#8A7A82" android:textSize="11sp" /> + android:text="@string/window_monthly" android:textColor="#8A7A82" android:textSize="11sp" /> diff --git a/app/src/main/res/resources.properties b/app/src/main/res/resources.properties new file mode 100644 index 0000000..dab4408 --- /dev/null +++ b/app/src/main/res/resources.properties @@ -0,0 +1,4 @@ +# 默认(无 qualifier)资源对应的语言:简体中文 +# AGP 据此 + values-en 等目录生成 locale config,Android 13+ 系统设置中 +# 会显示 RainyToken 的独立语言选项(简体中文 / English) +unqualifiedResLocale=zh-CN diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a787889..6730448 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,5 +1,470 @@ + - 雨晴Token - AI用量面板 -OpenCode Go 用量 + DeepSeek 余额,自动刷新 + + 雨晴Token + AI用量面板 + OpenCode Go 用量 + DeepSeek 余额,自动刷新 + 雨晴喵 + RainyToken + + + 返回 + 取消 + 确认添加 + 我知道了 + 刷新 + 设置 + 删除 + 清除 + 保存 + 跳转 + ◀ 上一页 + 下一页 ▶ + 添加小组件到桌面 + 查看详情 + 查看用量详情 + 详细数据 + 同步用量数据 + 我已登录,保存 + 继续等待 + + + 加载中… + 未知错误 + 全部模型 + + + 👈 选择服务查看详情 + 点击左侧卡片即可 + 设置 + 凭据管理 + 关于 RainyToken + 使用小技巧 + 调试日志 + 用量详情 + 总统计 + 详细数据 + 统计图表 + + + 未配置 + 已配置 + 已过期 + 需重登 + 刷新失败 + 未获取 + 待获取 + 正常 + 未配置 · 点击添加 + 已配置 · 凭据有效 + 已过期 · 点击重新配置 + 需要重新验证 + 手动输入模式 + 凭据已配置 + 未配置凭据 + 加载中 + 最新 + 缓存 + 失败 + 待输入 + + + AI 余额一览 + 从未获取 + 刚刚更新 + %1$d 分钟前更新 + %1$d 小时前更新 + %1$s 更新 + 💡 长按卡片可拖拽排序 + RainyToken · v1.0 + 本地加密存储,不上传任何数据 + 添加桌面小组件? + 确认后会请求桌面启动器添加雨晴Token小组件。部分系统仍会弹出系统确认窗口。 + 桌面快捷方式权限已被拒绝,请在系统设置中允许后重试 + 桌面未接受添加请求,请检查启动器权限 + 当前桌面不支持应用内添加,请在桌面长按 → 小组件 → 查找雨晴Token + + + 点击配置凭据 + 下拉刷新 + 服务当前不可用 + 本月已用 %1$s%2$s + 剩余 + · 已用 %1$s / 共 %2$s + %1$s 已用 + 5h 已用 + · %1$s + %1$s 后重置 + REST API · ¥ + WebView 抓取 · 5h 配额 + JSON API · $ + ChatGPT Plus · Codex 额度 + Cookie 抓取 · Cloud 配额 + + + 天 + 小时 + 分 + + + 5 小时 + 5h + 5h 用量 + 5 小时滚动 + 本周 + 本月 + 每月 + 用量 + 用量窗口%1$s + 用量窗口 + + + OpenCode 用量 + CommandCode 用量 + %1$d 条记录 + 点击同步 + 输入 Token + 输出 Token + 推理 Token + 总花费 + 缓存读取 %1$s + 缓存写入 %1$s + 暂无数据,点击 🔄 同步 + ✓ 新增 %1$d 条 + 点击切换 + 所有时间按 UTC+0 计算 + 已切换到 UTC+8,图表按北京时间显示 + 已切换到 UTC+0,图表按世界协调时显示 + 已用 $%1$s / 共 $%2$s + 剩余 $%1$s + 额外充值 + 按模型统计 + 按天统计(UTC+0) + %1$d 个模型 + %1$d 个 + 开始时间 + 结束时间 + + + 消耗金额 (USD) + API 请求次数 + Token 消耗 + 输出 + 输入(未命中) + 命中缓存 + + + 时间 + 模型 + 输入(缓存) + 输出 + 成本 + + + 全部 + 最近5小时 + 最近12小时 + 最近24小时 + 今天 + 昨天 + 最近7天 + 最近30天 + 当月 + 自定义 + 自定义天 + 自定义月 + 自定义时间 + + + 统一查询 AI 余额的小工具。\n本地加密存储凭据,不上传任何数据。 + 查看全部 %1$d 条隐藏操作 + 查看 token 刷新、网络请求等调试记录 + + + ✓ 已配置登录态(%1$d 个 Cookie) + 如何获取 Cookie? + 1. 在桌面 Chrome 打开下方登录入口 + 2. 用 GitHub / Google 登录(不会被拦截) + 3. 登录后按 F12 → Application → Cookies + 4. 复制该站点下的所有 Cookie 粘贴到上面输入框 + 格式:name1=value1; name2=value2 + 如何获取 OpenCode Go 凭据? + 1. 桌面 Chrome 打开 opencode.ai/auth + 2. 用 GitHub / Google 登录(Google 不会拦截 Chrome) + 3. 登录后跳转到 dashboard,URL 形如: + https://opencode.ai/workspace/{id}/go + 4. 复制 URL 中的 {id} 段 → workspaceId + 5. F12 → Application → Cookies + 6. 找到域名 opencode.ai 下名为 auth 的 Cookie + 7. 复制它的 Value 字段 → auth cookie + 注意:只填 auth 一个 Cookie 即可,不要整个 Cookie 字符串 + 如何获取 CommandCode Go 凭据? + 1. 打开 https://commandcode.ai 并登录 GitHub + ▸ 获取 API Key(必填): + 右上角头像 → Settings → API Keys → 创建一个 Key + ▸ 获取全部 Cookie(用于用量记录): + F12 → Application → Cookies → commandcode.ai + 选中所有 Cookie,复制整个 Cookie 字符串 + 粘贴到下方输入框即可,无需逐个挑选 + 提示:不填 Cookie 也能正常查看余额,只是用量记录为空。 + 如何获取 Codex auth.json? + 1. 在终端运行 codex-oauth-proxy 或 + codex-proxy,完成 ChatGPT 登录 + 2. 凭证文件保存在: + + + 已保存 + 已保存凭据 + 连接成功!余额: %1$s %2$s + 连接成功,凭据已保存 + API Key 不能为空 + 服务拒绝该凭据 (401/403) + 测试期间凭据已变更 + 请求过于频繁 (429) + 服务端错误 (%1$d) + 网络错误:%1$s + ,已恢复原凭据 + ,检测到凭据已变化,未执行回滚 + 测试失败。%1$s%2$s + 暂不支持测试此服务 + auth cookie 和 workspaceId 都需要填写 + Cookie 不能为空 + 凭据未配置 + 凭据未配置或已过期 + 凭据无效,请在设置中重新配置 + 凭据无效,请重新登录 + 凭据已变更,正在重新加载 + 凭据已变更,请重新操作 + 网络异常,请检查网络 + 网络异常 + 服务端异常 (HTTP %1$d) + 请求过于频繁%1$s + ,请 %1$d 秒后重试 + 不支持激活用量 + 请先选择模型 + 未抓到 Cookie,请确认已登录 + 未配置登录 URL + URL 应以 %1$s 开头 + 授权失败: %1$s + State 不匹配,请重试 + 回调缺少授权码 + Token 交换失败,请查看调试日志 + + + ✓ 用量已激活 + 请求结果 + 错误: %1$s + 响应内容:\n%1$s + + + 登录 %1$s + 完成登录了吗? + 如果你已经在页面上完成登录但页面没有跳转,可点击下方按钮让我抓取 Cookie 并保存登录态。 + + + 暂无日志记录\n刷新 Codex 用量后这里会出现调试信息 + + + OpenCode Go · 用量 + DeepSeek 余额 + 自动刷新 + %1$s · 额度 + %1$s · %2$s + 更新 %1$s + 暂无数据 + 刷新中… + + + 长按卡片拖拽排序 + 长按首页卡片可以拖拽排序 + 在首页长按任意服务卡片即可拖拽到想要的位置,排序会自动保存。 + 小组件点左上角进 APP + 小组件左上角标识可打开 APP + 桌面小组件左上角 RainyToken › 标识可以点击打开应用,其它区域点击切换服务。 + 图表点击查看详情 + 点击图表柱子可查看时段详情 + 在用量图表上点击或滑动手指,可查看对应时段的详细数值。 + 图表标题可点击 + 点击图表标题弹出按模型明细 + 图表卡片标题(如「消耗金额」)点击后会弹出按模型拆分的明细。 + 切换图表时区 + 用量详情页可切换 UTC+0 / UTC+8 + 用量详情页顶部可切换 UTC+0 / UTC+8,图表标签会跟随切换。 + CCGO 清除并重新同步 + CCGO 详情页可清除缓存重新同步 + CCGO 用量详情页右上角「清除」可删除本地缓存并重新拉取全部数据。 + 查看用量详情 + 服务卡片底部可进入用量图表 + 有凭据的服务卡片底部会出现「查看用量详情」入口,点击进入图表统计。 + 原始数据浏览 + 用量详情页右上角可查看原始记录 + 用量详情页右上角「详细数据」可查看每条原始调用记录,支持分页跳转。 + 下拉刷新联动用量 + 下拉刷新会同步余额和用量 + 首页下拉刷新不仅刷新余额,还会自动同步 OCGO / CCGO 用量记录。 + 凭据获取教程 + 凭据页底部有获取教程 + 凭据编辑页底部的「如何获取」按钮有针对每个服务的详细操作步骤。 + 剪贴板自动导入 + Codex 支持从剪贴板一键导入 + Codex 凭据页支持「从剪贴板自动导入」,粘贴 auth.json 后一键填入。 + 详细数据页码跳转 + 原始数据页可输入页码直跳 + 原始数据页底部可输入页码直接跳转,数据量大时不必逐页翻。 + 小组件自动刷新 + 划到小组件会自动刷新余额 + 桌面小组件划到负一屏时会自动刷新余额,也可手动点 ↻ 按钮刷新。 + + + %1$d次 + 选择日期 + 选择月份(任意一天) + 开始日期 + 结束日期 + 消费明细 + 调用次数明细 + Token 明细 + 清除数据 + 此操作将清除所有 CommandCode 用量本地缓存并重新同步,需要几秒钟完成。 + 是否继续? + 确认(%1$ds) + 确认清除 + 关闭 + 从 %1$s + 至 %1$s + 应用自定义范围 + 确定 + 结束日期不能早于开始日期 %1$s + 时间 %1$s:%2$s + 暂无数据 + 合计: %1$s + 总计 + 记录数 + 缓存读取 + 缓存写入 + 缓存 Token 细分 + ※ 缓存读取已计入「输入 Token」 + %1$d 次调用 + MM月dd日 EEEE + %1$d 次 · $%2$s + 原始数据 + 缓存写入(5m) + 缓存写入(1h) + 成本(USD) + 成本(Raw) + + + ~/.config/codex-oauth-proxy/auth.json + 3. 用文本编辑器打开该文件, + 复制全部内容粘贴到上方输入框 + JSON 格式示例: + {"tokens": { + "access_token": "...", + "refresh_token": "...", + "expiresAt": 1783727108252 + }} + 提示:导入后 APP 会自动刷新 token, + 不需要手动更新。 + Ollama Pro 凭据 + 需要从浏览器复制完整 Cookie。登录 ollama.com/settings 后,在 DevTools → Network → 请求头里找到 Cookie 行复制。 + 更新凭据 + 保存凭据 + 测试并保存 + 在浏览器中打开 Ollama Settings + 复制登录 URL 到剪贴板 + 一键激活用量 API Key(可选) + 填入 Ollama Cloud API Key 后,可在详情页一键发送请求激活用量统计。从 ollama.com/settings → API Keys 获取。 + ollama-xxx 或 sk-xxx + 保存 API Key + OpenCode Go 凭据 + 需要 auth cookie + workspaceId 两个值。APP 会用 OkHttp 抓 dashboard 并自动解析 5h/周/月 三档用量。 + 从剪贴板自动导入 + dashboard URL 中的 {id} 段 + auth Cookie 值 + F12 → Cookies → opencode.ai → auth 的 Value + 如何获取这两个值? + 在浏览器中打开登录入口 + 填入 OpenCode Zen API Key 后,可在详情页一键发送请求激活用量统计。从 opencode.ai/settings → API Keys 获取。 + CommandCode Go 凭据 + 需要 API Key(拉余额)。Session Cookie(可选)在浏览器 DevTools 复制,用于拉用量记录。不填也可正常查看余额。 + 从 Settings → API Keys 生成 + Session Cookie(可选) + F12 → Cookies → commandcode.ai → 全选复制粘贴即可 + 如何获取 API Key 和 Cookie? + 请输入 API Key + 凭据会使用 Android Keystore (AES-256 GCM) 加密后存储到本机 DataStore。 + 更新 + Codex / ChatGPT Plus 凭据 + 推荐使用 OAuth 登录自动获取凭据。也可手动粘贴 auth.json 内容。 + 🔐 OAuth 登录(推荐) + ── 或手动导入 ── + auth.json 完整内容 + 粘贴 {"tokens": {...}} 完整 JSON + 导入并保存 + 如何获取 auth.json? + 粘贴登录态 + Google OAuth 会拒绝 APP 内置 WebView,所以请用浏览器登录后把 Cookie 粘到这里。 + Cookie 字符串 + 保存 Cookie + + + 数据解析失败: %1$s + 每周 + 模型调用次数 + 缓存数据 · %1$s + 显示的是上次成功获取的余额 + 本月消费 %1$s%2$s + 余额构成 + 赠送余额 + 自费充值 + 加载模型列表... + 选择模型 + 刷新模型列表 + 激活中... + 重试激活用量 + ⚡ 一键激活用量 + 刷新余额 + 重新登录 + 配置凭据 + 当前余额 + 5h 实时用量 + 月度余额 + Session 用量 + 请选择模型 + 错误详情: + API 响应内容: + 复制 + *** (长度 %1$d) *** + %1$s***%2$s (长度 %3$d) + Cookie 格式错误,应为 name1=value1; name2=value2 + 已保存 %1$d 个 Cookie + 请粘贴 auth.json 内容 + auth.json 缺少 access_token 或 refresh_token + 已保存 Codex 凭据,token 到期后会自动刷新 + 解析失败:%1$s + 连接成功!余额: $%1$s + API Key 已保存 + 连接成功!Session: %1$s%% · %2$s + 凭据已删除 + 剪贴板为空 + 已识别 workspaceId 和 auth cookie + 已识别 workspaceId,请再粘贴 auth cookie + 已识别 auth cookie,请再粘贴 workspaceId + 未识别到有效凭据 + + + Codex OAuth 登录 + 正在交换 Token... + 重试 + 无头模式登录 + 1. 点击「在浏览器中打开」或复制下方链接到浏览器\n2. 在 OpenAI 页面完成登录和授权\n3. 浏览器会跳转到一个 localhost 地址(页面打不开是正常的)\n4. 复制浏览器地址栏的完整 URL 粘贴到下方输入框\n5. 点击「提交回调 URL」 + 授权链接 + 在浏览器中打开 + 复制链接 + ── 登录后粘贴回调 URL ── + 回调 URL + http://localhost:1455/auth/callback?code=... + 提交回调 URL + 重新生成授权链接 + 正在准备授权... \ No newline at end of file From 52b86e95e5cc45d31cd0598086f64c4a3039bb62 Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Sat, 1 Aug 2026 07:53:59 +0000 Subject: [PATCH 02/14] =?UTF-8?q?i18n=20=E9=98=B6=E6=AE=B5=E4=BA=8C?= =?UTF-8?q?=EF=BC=9A=E6=96=B0=E5=A2=9E=E8=8B=B1=E6=96=87=E7=BF=BB=E8=AF=91?= =?UTF-8?q?=20values-en=20+=20widget=20=E5=B8=83=E5=B1=80=E8=8B=B1?= =?UTF-8?q?=E6=96=87=E9=80=82=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 values-en/strings.xml:414 条英文资源与中文完全对齐(占位符/转义/格式逐一校验,无缺失无多余,无中文残留) - widget 三套布局(亮色/预览/暗色 night)完成资源化 + 英文布局适配: - row2/row3 行标签宽度 44dp→60dp,避免 "This week"/"This month" 被截断 - widget_updated 增加 maxLines=1 + ellipsize=end,防最小宽度下挤占刷新按钮 - widget_name/widget_description 由 translatable=false 改为正式可翻译资源,补充英文(AI Usage Panel) - 统一英文术语:raw_record_title 改为 Raw data 与 title_raw_data 一致 - 验证:testDebugUnitTest 78 用例通过、lintDebug 0 Error 且 MissingTranslation/ExtraTranslation/Untranslatable 全为 0、assembleDebug 成功 --- .../res/layout-night/widget_opencode_go.xml | 21 +- .../main/res/layout/widget_opencode_go.xml | 11 +- .../res/layout/widget_opencode_go_preview.xml | 9 +- app/src/main/res/values-en/strings.xml | 466 ++++++++++++++++++ app/src/main/res/values/strings.xml | 4 +- 5 files changed, 495 insertions(+), 16 deletions(-) create mode 100644 app/src/main/res/values-en/strings.xml diff --git a/app/src/main/res/layout-night/widget_opencode_go.xml b/app/src/main/res/layout-night/widget_opencode_go.xml index ded7774..43df9ea 100644 --- a/app/src/main/res/layout-night/widget_opencode_go.xml +++ b/app/src/main/res/layout-night/widget_opencode_go.xml @@ -64,6 +64,8 @@ android:id="@+id/widget_updated" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:maxLines="1" + android:ellipsize="end" android:text="--" android:textColor="#D4C4CC" android:textSize="10sp" /> @@ -96,7 +98,7 @@ android:id="@+id/widget_service_title" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:text="OpenCode Go · 用量" + android:text="@string/widget_title_opencode" android:textColor="#C4A0AB" android:textSize="11sp" /> @@ -109,7 +111,8 @@ android:gravity="center_vertical" android:layout_marginBottom="3dp"> + android:layout_width="60dp" android:layout_height="wrap_content" + android:maxLines="1" android:ellipsize="end" + android:text="@string/window_weekly" android:textColor="#C4A0AB" android:textSize="11sp" /> + android:layout_width="60dp" android:layout_height="wrap_content" + android:maxLines="1" android:ellipsize="end" + android:text="@string/window_monthly" android:textColor="#C4A0AB" android:textSize="11sp" /> diff --git a/app/src/main/res/layout/widget_opencode_go.xml b/app/src/main/res/layout/widget_opencode_go.xml index 4853a95..40f68f3 100644 --- a/app/src/main/res/layout/widget_opencode_go.xml +++ b/app/src/main/res/layout/widget_opencode_go.xml @@ -66,6 +66,8 @@ android:id="@+id/widget_updated" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:maxLines="1" + android:ellipsize="end" android:text="--" android:textColor="#C4A0AB" android:textSize="10sp" /> @@ -116,7 +118,8 @@ android:layout_marginBottom="3dp"> + + + AI Usage Panel + OpenCode Go usage + DeepSeek balance, auto-refresh + + + Back + Cancel + Add Widget + Got it + Refresh + Settings + Delete + Clear + Save + Go + ◀ Prev + Next ▶ + Add widget to home screen + View details + View usage details + Raw data + Sync usage data + I\'m logged in, save + Keep waiting + + + Loading… + Unknown error + All models + + + 👈 Select a service to view details + Tap a card on the left + Settings + Credentials + About RainyToken + Tips + Debug log + Usage details + Overview + Raw data + Charts + + + Not set up + Configured + Expired + Re-login + Refresh failed + No data + Pending + OK + Not set up · Tap to add + Configured · Valid + Expired · Tap to reconfigure + Needs re-verification + Manual entry mode + Credential set + No credential + Loading + Latest + Cached + Failed + Awaiting input + + + AI Balance Overview + Never fetched + Updated just now + Updated %1$d min ago + Updated %1$d h ago + Updated %1$s + 💡 Long-press a card to reorder + Stored locally & encrypted, nothing is uploaded + Add home screen widget? + This will ask the launcher to add the RainyToken widget. Some systems may still show their own confirmation dialog. + Shortcut permission denied. Please allow it in system settings and try again. + The launcher rejected the request. Please check launcher permissions. + This launcher does not support in-app adding. Long-press home → Widgets → find RainyToken. + + + Tap to configure credentials + Pull to refresh + Service currently unavailable + %1$s%2$s used this month + Remaining + · %1$s used / %2$s total + %1$s used + 5h used + · %1$s + Resets in %1$s + REST API · ¥ + WebView fetch · 5h quota + JSON API · $ + ChatGPT Plus · Codex quota + Cookie fetch · Cloud quota + + + d + h + m + + + 5 hours + 5h + 5h usage + 5-hour rolling + This week + This month + Monthly + Usage + Usage window%1$s + Usage window + + + OpenCode Usage + CommandCode Usage + %1$d records + Tap to sync + Input tokens + Output tokens + Reasoning tokens + Total cost + Cache read %1$s + Cache write %1$s + No data yet · tap 🔄 to sync + ✓ %1$d new records + tap to switch + All times in UTC+0 + Switched to UTC+8, charts shown in Beijing time + Switched to UTC+0, charts shown in UTC + $%1$s spent / $%2$s total + $%1$s remaining + Extra credits + By model + By day (UTC+0) + %1$d models + %1$d + Start time + End time + + + Spending (USD) + API requests + Token usage + Output + Input (miss) + Cache hit + + + Time + Model + Input(cache) + Output + Cost + + + All + Last 5 hours + Last 12 hours + Last 24 hours + Today + Yesterday + Last 7 days + Last 30 days + This month + Custom + Custom day + Custom month + Custom range + + + A tiny tool to check AI balances.\nCredentials are encrypted locally — nothing is uploaded. + View all %1$d hidden tips + Inspect token refresh, network requests and other debug records + + + ✓ Login state configured (%1$d cookies) + How to get the Cookie? + 1. Open the login page below in desktop Chrome + 2. Sign in with GitHub / Google (won\'t be blocked) + 3. After login, press F12 → Application → Cookies + 4. Copy all cookies of the site and paste them above + Format: name1=value1; name2=value2 + How to get OpenCode Go credentials? + 1. Open opencode.ai/auth in desktop Chrome + 2. Sign in with GitHub / Google (Google won\'t block Chrome) + 3. You\'ll land on the dashboard, URL looks like: + https://opencode.ai/workspace/{id}/go + 4. Copy the {id} segment → workspaceId + 5. F12 → Application → Cookies + 6. Find the cookie named auth under opencode.ai + 7. Copy its Value field → auth cookie + Note: only the auth cookie is needed, not the whole cookie string + How to get CommandCode Go credentials? + 1. Open https://commandcode.ai and sign in with GitHub + ▸ Get API Key (required): + Avatar (top right) → Settings → API Keys → Create a Key + ▸ Get all cookies (for usage records): + F12 → Application → Cookies → commandcode.ai + Select all cookies and copy the whole string + Paste it below — no need to pick them one by one + Tip: balance works without cookies, usage records will just be empty. + How to get Codex auth.json? + 1. Run codex-oauth-proxy or + codex-proxy in a terminal to sign in to ChatGPT + 2. The credential file is saved at: + + + Saved + Credentials saved + Connected! Balance: %1$s %2$s + Connected, credentials saved + API Key cannot be empty + Service rejected this credential (401/403) + Credentials changed during test + Too many requests (429) + Server error (%1$d) + Network error: %1$s + , previous credential restored + , credential changed, rollback skipped + Test failed. %1$s%2$s + Testing is not supported for this service + Both auth cookie and workspaceId are required + Cookie cannot be empty + Credential not configured + Credential missing or expired + Credential invalid, please reconfigure in Settings + Credential invalid, please log in again + Credentials changed, reloading + Credentials changed, please retry + Network error, please check your connection + Network error + Server error (HTTP %1$d) + Too many requests%1$s + , retry in %1$d seconds + Usage activation is not supported + Please select a model first + No cookie captured, please make sure you\'re logged in + No login URL configured + URL should start with %1$s + Authorization failed: %1$s + State mismatch, please retry + Callback missing authorization code + Token exchange failed, check the debug log + + + ✓ Usage activated + Request result + Error: %1$s + Response body:\n%1$s + + + Sign in to %1$s + Done logging in? + If you finished signing in but the page didn\'t redirect, tap the button below to grab the cookie and save your session. + + + No log entries yet\nRefresh Codex usage and debug info will show up here + + + OpenCode Go · Usage + DeepSeek balance + Auto refresh + %1$s · Quota + %1$s · %2$s + Updated %1$s + No data + Refreshing… + + + Reorder cards by long-press + Long-press a card to reorder it + Long-press any service card on the home screen and drag it wherever you like. The order is saved automatically. + Widget top-left opens the app + Tap the widget\'s top-left mark to open the app + The RainyToken › mark at the top-left of the widget opens the app. Tapping elsewhere switches services. + Tap charts for details + Tap a chart bar to see that time slot + Tap or slide on the usage charts to inspect the exact values of a time slot. + Chart titles are clickable + Tap a chart title for a per-model breakdown + Tapping a chart card title (e.g. "Spending") shows a per-model breakdown dialog. + Switch chart timezone + Usage details can toggle UTC+0 / UTC+8 + The usage details page can toggle between UTC+0 and UTC+8; chart labels follow along. + CCGO clear & resync + CCGO details can clear cache and resync + The "Clear" button on the CCGO usage details page deletes local cache and re-fetches everything. + View usage details + Service cards link to usage charts + Configured service cards show a "View usage details" entry at the bottom that opens the charts. + Browse raw data + Top-right of usage details opens raw records + "Raw data" at the top-right of usage details lists every record with pagination. + Pull-to-refresh syncs usage + Pull to refresh syncs balances and usage + Pull-to-refresh on the home screen refreshes balances and also syncs OCGO / CCGO usage records. + Credential guides + Credential pages include step-by-step guides + The "How to get" button at the bottom of credential pages walks through every service step by step. + Clipboard auto-import + Codex supports one-tap import from clipboard + The Codex credential page supports "Auto-import from clipboard": paste auth.json and it fills in with one tap. + Jump to a page number + Raw data page accepts a page number + The raw data page lets you enter a page number to jump directly — no need to flip through pages. + Widget auto-refresh + Swipe to the widget and it auto-refreshes + The widget refreshes balances when you swipe to it, or tap the ↻ button manually. + + + %1$d + Pick a date + Pick a month (any day) + Start date + End date + Spending breakdown + Request breakdown + Token breakdown + Clear data + This clears all cached CommandCode usage and re-syncs. It takes a few seconds. + Continue? + Confirm (%1$ds) + Yes, clear + Close + From %1$s + To %1$s + Apply custom range + OK + End date cannot be earlier than start date %1$s + Time %1$s:%2$s + No data + Total: %1$s + Total + Records + Cache read + Cache write + Cache token breakdown + ※ Cache reads are included in "Input tokens" + %1$d calls + MMM d, EEEE + %1$d calls · $%2$s + Raw data + Cache write (5m) + Cache write (1h) + Cost (USD) + Cost (raw) + + + ~/.config/codex-oauth-proxy/auth.json + 3. Open it with a text editor, + copy everything and paste it above + JSON example: + {"tokens": { + "access_token": "...", + "refresh_token": "...", + "expiresAt": 1783727108252 + }} + Tip: the app auto-refreshes the token after import, + no manual updates needed. + Ollama Pro credentials + Copy the full cookie from your browser. After logging into ollama.com/settings, find the Cookie header in DevTools → Network and copy it. + Update credentials + Save credentials + Test & save + Open Ollama Settings in browser + Copy login URL to clipboard + One-tap usage API Key (optional) + With an Ollama Cloud API Key, the details page can send one request to activate usage stats. Get it at ollama.com/settings → API Keys. + ollama-xxx or sk-xxx + Save API Key + OpenCode Go credentials + Needs both auth cookie and workspaceId. The app fetches the dashboard via OkHttp and parses 5h/week/month usage. + Auto-import from clipboard + The {id} segment of the dashboard URL + auth Cookie value + F12 → Cookies → opencode.ai → Value of auth + How to get these two? + Open login page in browser + With an OpenCode Zen API Key, the details page can send one request to activate usage stats. Get it at opencode.ai/settings → API Keys. + CommandCode Go credentials + Needs an API Key (for balance). Session Cookie (optional) for usage records, copied from browser DevTools. Balance works without it. + Generate at Settings → API Keys + Session Cookie (optional) + F12 → Cookies → commandcode.ai → select all & paste + How to get API Key and Cookie? + Enter API Key + Credentials are encrypted with Android Keystore (AES-256 GCM) and stored in local DataStore. + Update + Codex / ChatGPT Plus credentials + OAuth login fetches credentials automatically. You can also paste auth.json manually. + 🔐 OAuth login (recommended) + ── or import manually ── + Full auth.json content + Paste the full {"tokens": {...}} JSON + Import & save + How to get auth.json? + Paste session + Google OAuth blocks the in-app WebView, so log in with a browser and paste the cookie here. + Cookie string + Save cookie + + + Failed to parse data: %1$s + Weekly + Model call count + Cached data · %1$s + Showing the last successfully fetched balance + %1$s%2$s spent this month + Balance breakdown + Granted balance + Top-ups + Loading model list... + Select model + Refresh model list + Activating... + Retry usage activation + ⚡ One-tap usage activation + Refresh balance + Log in again + Configure credential + Current balance + 5h live usage + Monthly balance + Session usage + Please select a model + Error details: + API response: + Copy + *** (length %1$d) *** + %1$s***%2$s (length %3$d) + Bad cookie format, expected name1=value1; name2=value2 + %1$d cookies saved + Please paste the auth.json content + auth.json is missing access_token or refresh_token + Codex credential saved; token auto-refreshes when expiring + Parse failed: %1$s + Connected! Balance: $%1$s + API Key saved + Connected! Session: %1$s%% · %2$s + Credential deleted + Clipboard is empty + Recognized workspaceId and auth cookie + Recognized workspaceId, please paste the auth cookie too + Recognized auth cookie, please paste the workspaceId too + No valid credential recognized + + + Codex OAuth Login + Exchanging token... + Retry + Headless login + 1. Tap "Open in browser" or copy the link below to a browser\n2. Complete login & authorization on the OpenAI page\n3. The browser redirects to a localhost address (page not loading is normal)\n4. Copy the full URL from the address bar and paste it below\n5. Tap "Submit callback URL" + Authorization link + Open in browser + Copy link + ── paste the callback URL after login ── + Callback URL + http://localhost:1455/auth/callback?code=... + Submit callback URL + Regenerate auth link + Preparing authorization... + \ 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 6730448..e27be88 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2,8 +2,8 @@ 雨晴Token - AI用量面板 - OpenCode Go 用量 + DeepSeek 余额,自动刷新 + AI用量面板 + OpenCode Go 用量 + DeepSeek 余额,自动刷新 雨晴喵 RainyToken From 6ac4d06e9fc81a8bf19a098c58d3343a92e450ec Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Sat, 1 Aug 2026 08:19:51 +0000 Subject: [PATCH 03/14] =?UTF-8?q?i18n=20=E4=BF=AE=E5=A4=8D=EF=BC=9A?= =?UTF-8?q?=E6=B6=88=E9=99=A4=E8=8B=B1=E6=96=87=E7=8E=AF=E5=A2=83=E4=B8=8B?= =?UTF-8?q?=205=20=E5=A4=84=E4=B8=AD=E6=96=87=E6=B3=84=E6=BC=8F=EF=BC=88?= =?UTF-8?q?=E6=9C=80=E7=BB=88=E5=AE=A1=E6=9F=A5=20P1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 最终独立审查发现英文环境用户可见中文,全部修复: - 激活成功弹窗:三个 parse 函数(OCGO/Codex/Ollama)改为返回结构化 TriggerSummary, 中文摘要拼接移出 Repository;UI 层 buildTriggerSummaryText() 用资源组装本地化文本 - Codex 窗口标签:formatCodexPrimaryLabel / normalizeWindowLabel 增加「每周/每月」字面量分支, Dashboard 与 Widget 英文环境下正确显示 Weekly/Monthly - Dashboard footer:DashboardCardUi.lastFetchError 改 UiText?,errorToUiText() 按类型映射资源 - 凭据编辑页:ParseError→error_parse_failed(RepositoryError.ParseError 新增公开 detail), 「未知」「JSON格式错误」中文 fallback 改资源 - 服务详情页:triggerUsage else 分支复用 errorMessage() 全类型映射; TriggerError「token刷新失败」前缀资源化;「无响应体」不再展示 - 新增 TriggerSummary 数据类与 12 组中英资源(trigger_*/error_token_refresh_failed/等) - 测试:CodexRepositoryTest 6 用例改为结构化断言,FormatUtilsTest 更新 monthly 映射; 113 个用例全绿,lint 0 Error,资源 426/426 对齐 --- .../token/data/repository/CodexRepository.kt | 32 ++++++------ .../token/data/repository/OllamaRepository.kt | 29 +++++------ .../data/repository/OpenCodeGoRepository.kt | 29 +++++------ .../token/data/repository/RepositoryError.kt | 6 +-- .../token/domain/model/TriggerSummary.kt | 24 +++++++++ .../domain/usecase/RefreshBalanceUseCase.kt | 7 +-- .../rainy/token/ui/components/FormatUtils.kt | 11 ++-- .../token/ui/dashboard/DashboardViewModel.kt | 33 ++++++++++-- .../token/ui/dashboard/ServiceBalanceCards.kt | 29 ++++++++--- .../ui/servicedetail/ServiceDetailScreen.kt | 40 ++++++++++++++- .../servicedetail/ServiceDetailViewModel.kt | 22 +++++--- .../ui/settings/CredentialEditViewModel.kt | 15 +++++- .../ui/widget/OpenCodeGoWidgetProvider.kt | 6 ++- app/src/main/res/values-en/strings.xml | 12 +++++ app/src/main/res/values/strings.xml | 12 +++++ .../data/repository/CodexRepositoryTest.kt | 50 +++++++++++-------- .../token/ui/components/FormatUtilsTest.kt | 11 +++- 17 files changed, 263 insertions(+), 105 deletions(-) create mode 100644 app/src/main/java/com/rainy/token/domain/model/TriggerSummary.kt diff --git a/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt b/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt index c399033..7c0dd4c 100644 --- a/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt @@ -3,6 +3,7 @@ package com.rainy.token.data.repository import com.rainy.token.data.cache.BalanceCache import com.rainy.token.domain.model.Credential import com.rainy.token.domain.model.ServiceBalance +import com.rainy.token.domain.model.TriggerSummary import com.rainy.token.domain.service.ServiceConfigProvider import com.rainy.token.domain.service.ServiceType import kotlinx.coroutines.Dispatchers @@ -203,7 +204,7 @@ class CodexRepository( * @param model 用户选择的模型 slug * @return Result.success(响应体原文) — 供 UI 展示 */ - suspend fun triggerUsage(model: String): Result = withContext(Dispatchers.IO) { + suspend fun triggerUsage(model: String): Result = withContext(Dispatchers.IO) { val credential = credentialRepository.get(ServiceType.CODEX) ?: return@withContext Result.failure(RepositoryError.InvalidCredential("未找到 Codex 凭据")) if (credential !is Credential.CodexCredential) @@ -239,10 +240,10 @@ class CodexRepository( DebugLog.i(TAG, "triggerUsage retry: HTTP ${resp2.code}, body=${body2.take(500)}") if (!resp2.isSuccessful) { DebugLog.e(TAG, "triggerUsage retry failed: HTTP ${resp2.code}") - return@use Result.failure( + return@use Result.failure( TriggerError( "HTTP ${resp2.code}", - body2.ifBlank { "无响应体" } + body2.ifBlank { "" } ) ) } @@ -255,8 +256,8 @@ class CodexRepository( } } } else { - return@use Result.failure( - TriggerError("HTTP ${resp.code}", bodyStr.ifBlank { "无响应体" }) + return@use Result.failure( + TriggerError("HTTP ${resp.code}", bodyStr.ifBlank { "" }) ) } } else { @@ -400,7 +401,7 @@ class TriggerError(val summary: String, val responseBody: String) : Exception(su * 将 SSE 流响应解析为简洁的文本摘要。 * 提取:模型回复文本、用量统计(input/output tokens)。 */ -internal fun parseSseResponse(sseText: String, model: String): String { +internal fun parseSseResponse(sseText: String, model: String): TriggerSummary { val sseJson = Json { ignoreUnknownKeys = true } val outputText = StringBuilder() var inputTokens: String? = null @@ -429,16 +430,11 @@ internal fun parseSseResponse(sseText: String, model: String): String { } catch (_: Exception) { } } - val sb = StringBuilder() - sb.appendLine("✓ 激活成功") - sb.appendLine("模型: $model") - if (responseId != null) sb.appendLine("Response ID: $responseId") - sb.appendLine() - sb.appendLine("回复:") - sb.appendLine(outputText.ifEmpty { "(空)" }) - if (inputTokens != null || outputTokens != null) { - sb.appendLine() - sb.appendLine("用量: input=$inputTokens output=$outputTokens tokens") - } - return sb.toString().trim() + return TriggerSummary( + model = model, + reply = outputText.toString(), + inputTokens = inputTokens, + outputTokens = outputTokens, + responseId = responseId + ) } \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/data/repository/OllamaRepository.kt b/app/src/main/java/com/rainy/token/data/repository/OllamaRepository.kt index 0cd1a1b..04a45f8 100644 --- a/app/src/main/java/com/rainy/token/data/repository/OllamaRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/OllamaRepository.kt @@ -4,6 +4,7 @@ import com.rainy.token.data.cache.BalanceCache import com.rainy.token.data.debug.DebugLog import com.rainy.token.domain.model.Credential import com.rainy.token.domain.model.ServiceBalance +import com.rainy.token.domain.model.TriggerSummary import com.rainy.token.domain.service.ServiceType import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -268,7 +269,7 @@ class OllamaRepository( * @param model 用户选择的模型 slug * @return Result.success(响应摘要文本) — 供 UI 展示 */ - suspend fun triggerUsage(model: String): Result = withContext(Dispatchers.IO) { + suspend fun triggerUsage(model: String): Result = withContext(Dispatchers.IO) { val credential = credentialRepository.get(ServiceType.OLLAMA) ?: return@withContext Result.failure(RepositoryError.InvalidCredential("未找到 Ollama 凭据")) if (credential !is Credential.SessionCredential) @@ -296,8 +297,8 @@ class OllamaRepository( val bodyStr = resp.body?.string() ?: "" DebugLog.i(TAG, "triggerUsage: HTTP ${resp.code}, body=${bodyStr.take(500)}") if (!resp.isSuccessful) { - return@withContext Result.failure( - TriggerError("HTTP ${resp.code}", bodyStr.ifBlank { "无响应体" }) + return@withContext Result.failure( + TriggerError("HTTP ${resp.code}", bodyStr.ifBlank { "" }) ) } DebugLog.i(TAG, "triggerUsage: 请求成功,模型=$model") @@ -317,7 +318,7 @@ class OllamaRepository( /** * 解析 Ollama Cloud chat completions 响应,提取回复文本和用量统计。 */ -internal fun parseOllamaChatResponse(responseBody: String, model: String): String { +internal fun parseOllamaChatResponse(responseBody: String, model: String): TriggerSummary { val json = Json { ignoreUnknownKeys = true } return try { val root = json.parseToJsonElement(responseBody) as? JsonObject @@ -330,18 +331,14 @@ internal fun parseOllamaChatResponse(responseBody: String, model: String): Strin val completionTokens = (usage?.get("completion_tokens") as? JsonPrimitive)?.contentOrNull val totalTokens = (usage?.get("total_tokens") as? JsonPrimitive)?.contentOrNull - val sb = StringBuilder() - sb.appendLine("✓ 激活成功") - sb.appendLine("模型: $model") - sb.appendLine() - sb.appendLine("回复:") - sb.appendLine(content?.ifEmpty { "(空)" } ?: "(无回复内容)") - if (promptTokens != null || completionTokens != null) { - sb.appendLine() - sb.appendLine("用量: prompt=$promptTokens completion=$completionTokens total=$totalTokens tokens") - } - sb.toString().trim() + TriggerSummary( + model = model, + reply = content, + inputTokens = promptTokens, + outputTokens = completionTokens, + totalTokens = totalTokens + ) } catch (_: Exception) { - "✓ 激活成功\n模型: $model\n\n(响应解析失败,但请求已发出)" + TriggerSummary(model = model, reply = null, parseFailed = true) } } \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/data/repository/OpenCodeGoRepository.kt b/app/src/main/java/com/rainy/token/data/repository/OpenCodeGoRepository.kt index a7738f4..087719a 100644 --- a/app/src/main/java/com/rainy/token/data/repository/OpenCodeGoRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/OpenCodeGoRepository.kt @@ -4,6 +4,7 @@ import com.rainy.token.data.cache.BalanceCache import com.rainy.token.data.debug.DebugLog import com.rainy.token.domain.model.Credential import com.rainy.token.domain.model.ServiceBalance +import com.rainy.token.domain.model.TriggerSummary import com.rainy.token.domain.service.ServiceConfigProvider import com.rainy.token.domain.service.ServiceType import kotlinx.coroutines.Dispatchers @@ -178,7 +179,7 @@ class OpenCodeGoRepository( * @param model 用户选择的模型 slug * @return Result.success(响应摘要文本) — 供 UI 展示 */ - suspend fun triggerUsage(model: String): Result = withContext(Dispatchers.IO) { + suspend fun triggerUsage(model: String): Result = withContext(Dispatchers.IO) { val credential = credentialRepository.get(ServiceType.OPENCODE_GO) ?: return@withContext Result.failure(RepositoryError.InvalidCredential("未找到 OpenCode Go 凭据")) if (credential !is Credential.SessionCredential) @@ -206,8 +207,8 @@ class OpenCodeGoRepository( val bodyStr = resp.body?.string() ?: "" DebugLog.i(TAG, "triggerUsage: HTTP ${resp.code}, body=${bodyStr.take(500)}") if (!resp.isSuccessful) { - return@withContext Result.failure( - TriggerError("HTTP ${resp.code}", bodyStr.ifBlank { "无响应体" }) + return@withContext Result.failure( + TriggerError("HTTP ${resp.code}", bodyStr.ifBlank { "" }) ) } DebugLog.i(TAG, "triggerUsage: 请求成功,模型=$model") @@ -318,7 +319,7 @@ class OpenCodeGoRepository( /** * 解析 OpenAI 兼容的 chat completions 响应,提取回复文本和用量统计。 */ -internal fun parseChatResponse(responseBody: String, model: String): String { +internal fun parseChatResponse(responseBody: String, model: String): TriggerSummary { val json = Json { ignoreUnknownKeys = true } return try { val root = json.parseToJsonElement(responseBody) as? JsonObject @@ -331,18 +332,14 @@ internal fun parseChatResponse(responseBody: String, model: String): String { val completionTokens = (usage?.get("completion_tokens") as? JsonPrimitive)?.contentOrNull val totalTokens = (usage?.get("total_tokens") as? JsonPrimitive)?.contentOrNull - val sb = StringBuilder() - sb.appendLine("✓ 激活成功") - sb.appendLine("模型: $model") - sb.appendLine() - sb.appendLine("回复:") - sb.appendLine(content?.ifEmpty { "(空)" } ?: "(无回复内容)") - if (promptTokens != null || completionTokens != null) { - sb.appendLine() - sb.appendLine("用量: prompt=$promptTokens completion=$completionTokens total=$totalTokens tokens") - } - sb.toString().trim() + TriggerSummary( + model = model, + reply = content, + inputTokens = promptTokens, + outputTokens = completionTokens, + totalTokens = totalTokens + ) } catch (_: Exception) { - "✓ 激活成功\n模型: $model\n\n(响应解析失败,但请求已发出)" + TriggerSummary(model = model, reply = null, parseFailed = true) } } \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/data/repository/RepositoryError.kt b/app/src/main/java/com/rainy/token/data/repository/RepositoryError.kt index b0c80bd..cfa38dd 100644 --- a/app/src/main/java/com/rainy/token/data/repository/RepositoryError.kt +++ b/app/src/main/java/com/rainy/token/data/repository/RepositoryError.kt @@ -23,9 +23,9 @@ sealed class RepositoryError(message: String, cause: Throwable? = null) : Except class ServerError(val code: Int, cause: Throwable? = null) : RepositoryError("服务端异常 (HTTP $code)", cause) - /** 解析失败(HTML 解析结果为空 / JSON 反序列化失败) */ - class ParseError(message: String, cause: Throwable? = null) : - RepositoryError("解析失败: $message", cause) + /** 解析失败(HTML 解析结果为空 / JSON 反序列化失败),detail 为具体原因 */ + class ParseError(val detail: String, cause: Throwable? = null) : + RepositoryError("解析失败: $detail", cause) /** 未知错误 */ class Unknown(cause: Throwable? = null) : RepositoryError( diff --git a/app/src/main/java/com/rainy/token/domain/model/TriggerSummary.kt b/app/src/main/java/com/rainy/token/domain/model/TriggerSummary.kt new file mode 100644 index 0000000..0345e32 --- /dev/null +++ b/app/src/main/java/com/rainy/token/domain/model/TriggerSummary.kt @@ -0,0 +1,24 @@ +package com.rainy.token.domain.model + +/** + * 一键激活用量的结构化响应摘要。 + * + * Repository 只负责从响应中提取数据,不拼接展示文案; + * UI 层按当前语言环境用 stringResource 组装本地化文本。 + */ +data class TriggerSummary( + /** 发起请求时使用的模型 */ + val model: String, + /** 模型回复文本;null 表示响应中不存在回复内容 */ + val reply: String?, + /** 输入 token 数(OCGO/Ollama 为 prompt_tokens,Codex 为 input_tokens) */ + val inputTokens: String? = null, + /** 输出 token 数(OCGO/Ollama 为 completion_tokens,Codex 为 output_tokens) */ + val outputTokens: String? = null, + /** 总 token 数(OCGO/Ollama 的 total_tokens;Codex 无此字段) */ + val totalTokens: String? = null, + /** Codex 响应 ID */ + val responseId: String? = null, + /** 响应解析失败,但请求已成功发出 */ + val parseFailed: Boolean = false +) diff --git a/app/src/main/java/com/rainy/token/domain/usecase/RefreshBalanceUseCase.kt b/app/src/main/java/com/rainy/token/domain/usecase/RefreshBalanceUseCase.kt index b943352..0fb8b2b 100644 --- a/app/src/main/java/com/rainy/token/domain/usecase/RefreshBalanceUseCase.kt +++ b/app/src/main/java/com/rainy/token/domain/usecase/RefreshBalanceUseCase.kt @@ -10,6 +10,7 @@ import com.rainy.token.data.repository.RefreshWriteSession import com.rainy.token.data.repository.RepositoryError import com.rainy.token.data.repository.retryOnTransientError import com.rainy.token.domain.model.ServiceBalance +import com.rainy.token.domain.model.TriggerSummary import com.rainy.token.domain.service.ServiceType import javax.inject.Inject import javax.inject.Provider @@ -55,7 +56,7 @@ class RefreshBalanceUseCase @Inject constructor( suspend fun fetchCodexModels(): Result> = codexRepositoryProvider.get().fetchModels() - suspend fun triggerCodexUsage(model: String): Result = + suspend fun triggerCodexUsage(model: String): Result = withCredentialSession(ServiceType.CODEX) { codexRepositoryProvider.get().triggerUsage(model) } @@ -63,7 +64,7 @@ class RefreshBalanceUseCase @Inject constructor( suspend fun fetchOpenCodeGoModels(): Result> = openCodeGoRepositoryProvider.get().fetchModels() - suspend fun triggerOpenCodeGoUsage(model: String): Result = + suspend fun triggerOpenCodeGoUsage(model: String): Result = withCredentialSession(ServiceType.OPENCODE_GO) { openCodeGoRepositoryProvider.get().triggerUsage(model) } @@ -71,7 +72,7 @@ class RefreshBalanceUseCase @Inject constructor( suspend fun fetchOllamaModels(): Result> = ollamaRepositoryProvider.get().fetchModels() - suspend fun triggerOllamaUsage(model: String): Result = + suspend fun triggerOllamaUsage(model: String): Result = withCredentialSession(ServiceType.OLLAMA) { ollamaRepositoryProvider.get().triggerUsage(model) } diff --git a/app/src/main/java/com/rainy/token/ui/components/FormatUtils.kt b/app/src/main/java/com/rainy/token/ui/components/FormatUtils.kt index 55ad00c..992156a 100644 --- a/app/src/main/java/com/rainy/token/ui/components/FormatUtils.kt +++ b/app/src/main/java/com/rainy/token/ui/components/FormatUtils.kt @@ -56,8 +56,13 @@ fun formatResetForWidget(sec: Long, text: DurationText = ChineseDurationText): S } } -/** 标准化窗口标签:weekly → 每周,其他原样返回 */ -fun normalizeWindowLabel(label: String, weeklyLabel: String = "每周"): String = when (label.lowercase()) { - "weekly" -> weeklyLabel +/** 标准化窗口标签:weekly/每周 → 每周标签,monthly/每月 → 每月标签,其他原样返回 */ +fun normalizeWindowLabel( + label: String, + weeklyLabel: String = "每周", + monthlyLabel: String = "每月" +): String = when (label.lowercase()) { + "weekly", "每周" -> weeklyLabel + "monthly", "每月" -> monthlyLabel else -> label } \ No newline at end of file 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 de9e42f..9e8fb69 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 @@ -10,6 +10,8 @@ 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 com.rainy.token.R +import com.rainy.token.ui.components.UiText import com.rainy.token.ui.widget.OpenCodeGoWidgetProvider import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext @@ -127,10 +129,10 @@ class DashboardViewModel @Inject constructor( val cards = ServiceType.entries.map { type -> val result = results[type] val error = result?.exceptionOrNull() - val errMsg = error + val errorUi = error ?.takeUnless { it is RepositoryError.CredentialChanged } - ?.message - buildCard(localStates.getValue(type), lastFetchError = errMsg) + ?.let { errorToUiText(it) } + buildCard(localStates.getValue(type), lastFetchError = errorUi) } _uiState.update { it.copy(refreshing = false, cards = cards) } // 刷新成功后更新桌面小组件 @@ -145,7 +147,7 @@ class DashboardViewModel @Inject constructor( private fun buildCard( local: CredentialRepository.LocalState, - lastFetchError: String? + lastFetchError: UiText? ): DashboardCardUi = DashboardCardUi( service = local.status.service, credentialState = local.status.state, @@ -181,8 +183,29 @@ data class DashboardCardUi( val credentialState: CredentialStatus.State, val credentialFingerprint: String?, val cachedBalance: CachedBalance?, - val lastFetchError: String? + val lastFetchError: UiText? ) { /** 余额展示主数字。优先取缓存,错误时也展示(不隐藏,让用户看到旧值 + 红点提示)。 */ val displayBalance: ServiceBalance? get() = cachedBalance?.balance } + +/** 把 Repository 错误映射为本地化文案(错误详情 detail 为技术信息,随资源参数展示)。 */ +private fun errorToUiText(error: Throwable): UiText = when (error) { + is RepositoryError.InvalidCredential -> + UiText.Resource(R.string.error_credential_invalid_reconfigure) + is RepositoryError.RateLimited -> UiText.Resource( + R.string.error_rate_limited_retry, + listOf( + error.retryAfterSeconds?.let { + UiText.Resource(R.string.error_rate_limited_retry_suffix, listOf(it)) + } ?: "" + ) + ) + is RepositoryError.Network -> UiText.Resource(R.string.error_network_check) + is RepositoryError.ServerError -> + UiText.Resource(R.string.error_server_http, listOf(error.code)) + is RepositoryError.ParseError -> + UiText.Resource(R.string.error_parse_failed, listOf(error.detail)) + else -> error.message?.let { UiText.Dynamic(it) } + ?: UiText.Resource(R.string.common_unknown) +} diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt b/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt index 666912e..b29099e 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt @@ -279,11 +279,11 @@ internal fun formatCodexPrimaryLabel( usageLabel: String = "用量" ): String = when (raw?.lowercase()) { "5h" -> "5h" - "7d", weeklyLabel -> weeklyLabel - "30d", monthlyLabel -> monthlyLabel + "7d", "每周", weeklyLabel -> weeklyLabel + "30d", "每月", monthlyLabel -> monthlyLabel "usage" -> usageLabel null -> "5h" - else -> raw + else -> raw ?: "5h" } @Composable @@ -295,8 +295,13 @@ internal fun CodexUsageWindows(balance: ServiceBalance) { .maxOrNull()?.plus(1) ?: 0 val weeklyLabel = stringResource(R.string.window_every_week) + val monthlyLabel = stringResource(R.string.window_every_month) val windows = (0 until windowCount).map { i -> - val label = normalizeWindowLabel(extras["window_$i.label"] ?: "Usage", weeklyLabel = weeklyLabel) + val label = normalizeWindowLabel( + extras["window_$i.label"] ?: "Usage", + weeklyLabel = weeklyLabel, + monthlyLabel = monthlyLabel + ) val remainingPct = extras["window_$i.remainingPct"]?.toIntOrNull() val resetAt = extras["window_$i.resetAt"]?.toLongOrNull()?.takeIf { it > 0 } Triple(label, remainingPct, resetAt) @@ -526,12 +531,20 @@ internal fun secondaryLineRes(card: DashboardCardUi): Int = when (card.service) /** * 底部更新时间/错误文案(UiText 形式,UI 层按当前语言解析)。 - * 错误分支直接透传 Repository 中文 message(Dynamic,不参与翻译)。 + * 错误信息由 ViewModel 按 RepositoryError 类型映射为本地化资源。 */ + +/** 截断 UiText 到最长 60 字符(仅对 Dynamic 原始文本生效;Resource 长度由资源控制)。 */ +private fun truncateUiText(text: UiText): UiText = when (text) { + is UiText.Dynamic -> UiText.Dynamic(text.value.take(60) + if (text.value.length > 60) "…" else "") + else -> text +} + internal fun footerText(card: DashboardCardUi): UiText { - if (card.lastFetchError != null) { - val msg = card.lastFetchError.take(60) - return UiText.Dynamic("⚠ $msg${if (card.lastFetchError.length > 60) "…" else ""}") + val error = card.lastFetchError + if (error != null) { + // 截断保护:错误信息最长展示 60 字符(以 UiText 形式保留,UI 层按当前语言解析) + return UiText.Resource(R.string.dashboard_error_prefix, listOf(truncateUiText(error))) } val fetchedAt = card.cachedBalance?.fetchedAt ?: return UiText.Resource(R.string.footer_never_fetched) 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 14a708e..a9ee908 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 @@ -61,6 +61,7 @@ import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.rainy.token.R import com.rainy.token.domain.model.ServiceBalance +import com.rainy.token.domain.model.TriggerSummary import com.rainy.token.domain.service.FetchMethod import com.rainy.token.domain.service.ServiceConfigProvider import com.rainy.token.domain.service.ServiceType @@ -218,7 +219,7 @@ fun ServiceDetailScreen( when (triggerState) { is TriggerState.Success -> ResponseDialog( title = stringResource(R.string.trigger_success_title), - responseBody = (triggerState as TriggerState.Success).responseBody, + responseBody = buildTriggerSummaryText((triggerState as TriggerState.Success).summary), isError = false, onDismiss = { viewModel.dismissTrigger() } ) @@ -1122,6 +1123,43 @@ private fun ModelDropdown( // ── Codex 一键激活用量:响应弹窗 ── +/** 把结构化的激活摘要组装为本地化文本(Repository 不再拼接展示文案)。 */ +@Composable +private fun buildTriggerSummaryText(summary: TriggerSummary): String = buildString { + appendLine(stringResource(R.string.trigger_success_header)) + appendLine(stringResource(R.string.trigger_success_model, summary.model)) + summary.responseId?.let { appendLine(stringResource(R.string.trigger_success_response_id, it)) } + appendLine() + appendLine(stringResource(R.string.trigger_reply_header)) + if (summary.parseFailed) { + appendLine(stringResource(R.string.trigger_parse_failed)) + } else { + appendLine( + summary.reply?.ifEmpty { stringResource(R.string.trigger_reply_empty) } + ?: stringResource(R.string.trigger_reply_missing) + ) + } + if (summary.inputTokens != null || summary.outputTokens != null) { + appendLine() + appendLine( + if (summary.totalTokens != null) { + stringResource( + R.string.trigger_usage_prompt_completion_total, + summary.inputTokens ?: "—", + summary.outputTokens ?: "—", + summary.totalTokens ?: "—" + ) + } else { + stringResource( + R.string.trigger_usage_input_output, + summary.inputTokens ?: "—", + summary.outputTokens ?: "—" + ) + } + ) + } +}.trim() + @Composable private fun ResponseDialog( title: String, 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 675b76a..b82fc98 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 @@ -11,6 +11,7 @@ import com.rainy.token.data.repository.TriggerError import com.rainy.token.domain.model.Credential import com.rainy.token.domain.model.CredentialStatus import com.rainy.token.domain.model.ServiceBalance +import com.rainy.token.domain.model.TriggerSummary import com.rainy.token.R import com.rainy.token.domain.service.ServiceType import com.rainy.token.domain.usecase.RefreshBalanceUseCase @@ -324,8 +325,8 @@ class ServiceDetailViewModel @Inject constructor( if (serviceGen != serviceGeneration || _serviceType.value != service) return@launch result - .onSuccess { responseBody -> - _triggerState.value = TriggerState.Success(responseBody) + .onSuccess { summary -> + _triggerState.value = TriggerState.Success(summary) kotlinx.coroutines.delay(2000) if (serviceGen == serviceGeneration && _serviceType.value == service) { refresh() @@ -336,7 +337,15 @@ class ServiceDetailViewModel @Inject constructor( val responseBody: String? when (error) { is TriggerError -> { - message = UiText.Dynamic(error.summary) + val prefix = "token 刷新失败: " + message = if (error.summary.startsWith(prefix)) { + UiText.Resource( + R.string.error_token_refresh_failed, + listOf(error.summary.removePrefix(prefix)) + ) + } else { + UiText.Dynamic(error.summary) + } responseBody = error.responseBody.ifBlank { null } } @@ -359,8 +368,7 @@ class ServiceDetailViewModel @Inject constructor( message = if ((error as? RepositoryError.Unknown)?.cause is IllegalArgumentException) { UiText.Resource(R.string.error_trigger_not_supported) } else { - error.message?.let { UiText.Dynamic(it) } - ?: UiText.Resource(R.string.common_unknown) + errorMessage(error) } responseBody = null } @@ -529,7 +537,7 @@ class ServiceDetailViewModel @Inject constructor( is RepositoryError.ServerError -> UiText.Resource(R.string.error_server_http, listOf(error.code)) is RepositoryError.ParseError -> - UiText.Resource(R.string.error_parse_failed, listOf(error.message ?: "")) + UiText.Resource(R.string.error_parse_failed, listOf(error.detail)) else -> error.message?.let { UiText.Dynamic(it) } ?: UiText.Resource(R.string.common_unknown) } @@ -579,6 +587,6 @@ data class ServiceDetailUiState( sealed class TriggerState { data object Idle : TriggerState() data object Loading : TriggerState() - data class Success(val responseBody: String) : TriggerState() + data class Success(val summary: TriggerSummary) : TriggerState() data class Error(val message: UiText, val responseBody: String?) : TriggerState() } diff --git a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt index 0df298f..18529d0 100644 --- a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt @@ -170,7 +170,15 @@ class CredentialEditViewModel @Inject constructor( is RepositoryError.ServerError -> UiText.Resource(R.string.error_server, listOf(err.code)) is RepositoryError.Network -> - UiText.Resource(R.string.error_network, listOf(err.cause?.message ?: "未知")) + UiText.Resource( + R.string.error_network, + listOf( + err.cause?.message?.let { UiText.Dynamic(it) } + ?: UiText.Resource(R.string.common_unknown) + ) + ) + is RepositoryError.ParseError -> + UiText.Resource(R.string.error_parse_failed, listOf(err.detail)) else -> err?.message?.let { UiText.Dynamic(it) } ?: UiText.Resource(R.string.common_unknown) } @@ -371,7 +379,10 @@ class CredentialEditViewModel @Inject constructor( it.copy( message = UiText.Resource( R.string.error_auth_json_parse, - listOf(e.message ?: "JSON 格式错误") + listOf( + e.message?.let { UiText.Dynamic(it) } + ?: UiText.Resource(R.string.error_json_format) + ) ) ) } diff --git a/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt b/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt index 89ff5e3..4de4a05 100644 --- a/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt +++ b/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt @@ -193,7 +193,11 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() { val weeklyLabel = context.getString(R.string.window_every_week) val monthlyLabel = context.getString(R.string.window_every_month) val windows = (0 until windowCount).map { index -> - val label = normalizeWindowLabel(extras["window_$index.label"] ?: "Usage", weeklyLabel = weeklyLabel) + val label = normalizeWindowLabel( + extras["window_$index.label"] ?: "Usage", + weeklyLabel = weeklyLabel, + monthlyLabel = monthlyLabel + ) val remaining = extras["window_$index.remainingPct"]?.toIntOrNull() val resetAt = extras["window_$index.resetAt"]?.toLongOrNull()?.takeIf { it > 0 } Triple(label, remaining?.let { (100 - it).coerceIn(0, 100) }, resetAt?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 }) diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index bd9c976..ffdbb6a 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -73,6 +73,7 @@ Updated %1$d h ago Updated %1$s 💡 Long-press a card to reorder + ⚠ %1$s Stored locally & encrypted, nothing is uploaded Add home screen widget? This will ask the launcher to add the RainyToken widget. Some systems may still show their own confirmation dialog. @@ -214,6 +215,7 @@ Connected! Balance: %1$s %2$s Connected, credentials saved API Key cannot be empty + Invalid JSON format Service rejected this credential (401/403) Credentials changed during test Too many requests (429) @@ -248,9 +250,19 @@ ✓ Usage activated + ✓ Activation successful Request result Error: %1$s Response body:\n%1$s + Model: %1$s + Response ID: %1$s + Reply: + (empty) + (no reply content) + (response parsing failed, but the request was sent) + Usage: prompt=%1$s completion=%2$s total=%3$s tokens + Usage: input=%1$s output=%2$s tokens + Token refresh failed: %1$s Sign in to %1$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e27be88..13603a5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -76,6 +76,7 @@ %1$d 小时前更新 %1$s 更新 💡 长按卡片可拖拽排序 + ⚠ %1$s RainyToken · v1.0 本地加密存储,不上传任何数据 添加桌面小组件? @@ -218,6 +219,7 @@ 连接成功!余额: %1$s %2$s 连接成功,凭据已保存 API Key 不能为空 + JSON 格式错误 服务拒绝该凭据 (401/403) 测试期间凭据已变更 请求过于频繁 (429) @@ -252,9 +254,19 @@ ✓ 用量已激活 + ✓ 激活成功 请求结果 错误: %1$s 响应内容:\n%1$s + 模型: %1$s + 响应 ID: %1$s + 回复: + (空) + (无回复内容) + (响应解析失败,但请求已发出) + 用量: prompt=%1$s completion=%2$s total=%3$s tokens + 用量: input=%1$s output=%2$s tokens + token 刷新失败:%1$s 登录 %1$s diff --git a/app/src/test/java/com/rainy/token/data/repository/CodexRepositoryTest.kt b/app/src/test/java/com/rainy/token/data/repository/CodexRepositoryTest.kt index aea46e1..628b263 100644 --- a/app/src/test/java/com/rainy/token/data/repository/CodexRepositoryTest.kt +++ b/app/src/test/java/com/rainy/token/data/repository/CodexRepositoryTest.kt @@ -4,6 +4,8 @@ import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonNull import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -236,11 +238,12 @@ class CodexRepositoryTest { """.trimIndent() val result = parseSseResponse(sse, "gpt-5.6") - assertTrue(result.contains("Hello, world!")) - assertTrue(result.contains("input=100")) - assertTrue(result.contains("output=50")) - assertTrue(result.contains("resp_123")) - assertTrue(result.contains("gpt-5.6")) + assertEquals("Hello, world!", result.reply) + assertEquals("100", result.inputTokens) + assertEquals("50", result.outputTokens) + assertEquals("resp_123", result.responseId) + assertEquals("gpt-5.6", result.model) + assertFalse(result.parseFailed) } @Test @@ -251,9 +254,10 @@ class CodexRepositoryTest { """.trimIndent() val result = parseSseResponse(sse, "o4-mini") - assertTrue(result.contains("(空)")) - assertTrue(result.contains("input=10")) - assertTrue(result.contains("output=5")) + assertEquals("", result.reply) + assertEquals("10", result.inputTokens) + assertEquals("5", result.outputTokens) + assertNull(result.totalTokens) } @Test @@ -265,16 +269,18 @@ class CodexRepositoryTest { """.trimIndent() val result = parseSseResponse(sse, "gpt-5.6") - assertTrue(result.contains("Hi there")) - // Should not contain usage line when no usage data - assertTrue(!result.contains("用量:")) + assertEquals("Hi there", result.reply) + // Should be null when no usage data present + assertNull(result.inputTokens) + assertNull(result.outputTokens) } @Test fun `parseSseResponse handles empty input`() { val result = parseSseResponse("", "gpt-5.6") - assertTrue(result.contains("(空)")) - assertTrue(result.contains("gpt-5.6")) + assertEquals("", result.reply) + assertEquals("gpt-5.6", result.model) + assertNull(result.responseId) } @Test @@ -288,9 +294,9 @@ class CodexRepositoryTest { """.trimIndent() val result = parseSseResponse(sse, "o4-mini") - assertTrue(result.contains("Test reply")) - assertTrue(result.contains("input=200")) - assertTrue(result.contains("output=100")) + assertEquals("Test reply", result.reply) + assertEquals("200", result.inputTokens) + assertEquals("100", result.outputTokens) } @Test @@ -304,18 +310,22 @@ class CodexRepositoryTest { val result = parseSseResponse(sse, "gpt-5.6") // Malformed line should be skipped, valid ones parsed - assertTrue(result.contains("Good")) - assertTrue(result.contains("input=1")) + assertEquals("Good", result.reply) + assertEquals("1", result.inputTokens) } @Test - fun `parseSseResponse contains activation success header`() { + fun `parseSseResponse returns structured metadata`() { val sse = """ data: {"type":"response.completed","response":{"id":"r1","usage":{"input_tokens":"0","output_tokens":"0"}}} data: [DONE] """.trimIndent() val result = parseSseResponse(sse, "gpt-5.6") - assertTrue(result.contains("✓ 激活成功")) + assertEquals("r1", result.responseId) + assertEquals("0", result.inputTokens) + assertEquals("0", result.outputTokens) + assertEquals("", result.reply) + assertFalse(result.parseFailed) } } \ No newline at end of file diff --git a/app/src/test/java/com/rainy/token/ui/components/FormatUtilsTest.kt b/app/src/test/java/com/rainy/token/ui/components/FormatUtilsTest.kt index 79df352..7f13167 100644 --- a/app/src/test/java/com/rainy/token/ui/components/FormatUtilsTest.kt +++ b/app/src/test/java/com/rainy/token/ui/components/FormatUtilsTest.kt @@ -112,12 +112,19 @@ class FormatUtilsTest { assertEquals("每周", normalizeWindowLabel("weekly")) assertEquals("每周", normalizeWindowLabel("Weekly")) assertEquals("每周", normalizeWindowLabel("WEEKLY")) + assertEquals("每周", normalizeWindowLabel("每周")) } @Test - fun `normalizeWindowLabel non-weekly passes through`() { + fun `normalizeWindowLabel monthly maps to monthly label`() { + assertEquals("每月", normalizeWindowLabel("monthly")) + assertEquals("Monthly", normalizeWindowLabel("MONTHLY", monthlyLabel = "Monthly")) + assertEquals("每月", normalizeWindowLabel("每月")) + } + + @Test + fun `normalizeWindowLabel unknown passes through`() { assertEquals("5h", normalizeWindowLabel("5h")) - assertEquals("monthly", normalizeWindowLabel("monthly")) assertEquals("30d", normalizeWindowLabel("30d")) } From 3a724d46fdef6b93d54f146b62c9beb210e38434 Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Sat, 1 Aug 2026 12:22:07 +0000 Subject: [PATCH 04/14] =?UTF-8?q?fix:=20=E6=97=A5=E6=9C=9F=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F=E5=8C=96=E8=B7=9F=E9=9A=8F=E7=B3=BB=E7=BB=9F=E8=AF=AD?= =?UTF-8?q?=E8=A8=80=EF=BC=8C=E4=BF=AE=E5=A4=8D=E8=8B=B1=E6=96=87=E7=95=8C?= =?UTF-8?q?=E9=9D=A2=E4=B8=AD=E6=96=87=E6=97=A5=E6=9C=9F=E7=AC=A6=E5=8F=B7?= =?UTF-8?q?=EF=BC=88Codex=20review=20P2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UsageOverviewScreen.DailyDetailRow 原强制 Locale.CHINA,英文环境下 datePattern 为 MMM d, EEEE 但月份/星期仍渲染中文(如 8月1日, 星期五)。改为 Locale.getDefault() 并加入 remember key,与资源语言保持一致。 --- .../java/com/rainy/token/ui/dashboard/UsageOverviewScreen.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/UsageOverviewScreen.kt b/app/src/main/java/com/rainy/token/ui/dashboard/UsageOverviewScreen.kt index 28e0d0a..03db324 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/UsageOverviewScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/UsageOverviewScreen.kt @@ -257,7 +257,8 @@ fun UsageOverviewScreen( @Composable private fun DailyDetailRow(day: com.rainy.token.data.local.DailyStats) { val datePattern = stringResource(R.string.date_format_md) - val utcFmt = remember(datePattern) { SimpleDateFormat(datePattern, Locale.CHINA).apply { timeZone = java.util.TimeZone.getTimeZone("UTC") } } + // 跟随系统语言(Locale.getDefault()),保证英文环境下日期符号与资源同语言 + val utcFmt = remember(datePattern, Locale.getDefault()) { SimpleDateFormat(datePattern, Locale.getDefault()).apply { timeZone = java.util.TimeZone.getTimeZone("UTC") } } Card(Modifier.fillMaxWidth(), RoundedCornerShape(12.dp), CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) { Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) { Text(utcFmt.format(Date(day.dayTs)), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f)) From 13acca37ed30d9aa0b44686150b31d4e3177db5c Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Sat, 1 Aug 2026 12:28:00 +0000 Subject: [PATCH 05/14] =?UTF-8?q?fix:=20=E9=BB=98=E8=AE=A4=E8=B5=84?= =?UTF-8?q?=E6=BA=90=E6=94=B9=E4=B8=BA=E8=8B=B1=E6=96=87=EF=BC=8C=E9=9D=9E?= =?UTF-8?q?=E4=B8=AD/=E8=8B=B1=E7=B3=BB=E7=BB=9F=E8=AF=AD=E8=A8=80?= =?UTF-8?q?=E5=9B=9E=E9=80=80=E8=8B=B1=E6=96=87=EF=BC=88=E5=8E=9F=E5=9B=9E?= =?UTF-8?q?=E9=80=80=E4=B8=AD=E6=96=87=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原结构:values/(默认)=中文,values-en=英文。系统语言既非中文也非英文时 (如日语/法语)会回退到默认资源显示中文,不符合国际用户预期。 新结构: - values/(默认)=英文(国际回退语言),unqualifiedResLocale=zh-CN → en - 中文移入 values-zh/(中文系统精确匹配) - 品牌资源(app_name/widget_brand/brand_token/dashboard_footer_version, translatable=false)保留在默认资源,所有语言共用中文品牌名 - 删除 values-en/(不再需要,默认即英文) 验证:中英 426/426 key 对齐、lint 0 Error(MissingTranslation/ExtraTranslation 0)、 113 测试全绿、assembleDebug 成功 --- app/src/main/res/resources.properties | 9 +- app/src/main/res/values-en/strings.xml | 478 -------------- app/src/main/res/values-zh/strings.xml | 481 ++++++++++++++ app/src/main/res/values/strings.xml | 840 +++++++++++++------------ 4 files changed, 907 insertions(+), 901 deletions(-) delete mode 100644 app/src/main/res/values-en/strings.xml create mode 100644 app/src/main/res/values-zh/strings.xml diff --git a/app/src/main/res/resources.properties b/app/src/main/res/resources.properties index dab4408..3c5afff 100644 --- a/app/src/main/res/resources.properties +++ b/app/src/main/res/resources.properties @@ -1,4 +1,5 @@ -# 默认(无 qualifier)资源对应的语言:简体中文 -# AGP 据此 + values-en 等目录生成 locale config,Android 13+ 系统设置中 -# 会显示 RainyToken 的独立语言选项(简体中文 / English) -unqualifiedResLocale=zh-CN +# 默认(无 qualifier)资源对应的语言:英文(国际回退语言) +# 非中文/英文系统语言会回退到默认英文资源;中文系统走 values-zh +# AGP 据此 + values-zh 目录生成 locale config,Android 13+ 系统设置中 +# 会显示 RainyToken 的独立语言选项(English / 简体中文) +unqualifiedResLocale=en diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml deleted file mode 100644 index ffdbb6a..0000000 --- a/app/src/main/res/values-en/strings.xml +++ /dev/null @@ -1,478 +0,0 @@ - - - - AI Usage Panel - OpenCode Go usage + DeepSeek balance, auto-refresh - - - Back - Cancel - Add Widget - Got it - Refresh - Settings - Delete - Clear - Save - Go - ◀ Prev - Next ▶ - Add widget to home screen - View details - View usage details - Raw data - Sync usage data - I\'m logged in, save - Keep waiting - - - Loading… - Unknown error - All models - - - 👈 Select a service to view details - Tap a card on the left - Settings - Credentials - About RainyToken - Tips - Debug log - Usage details - Overview - Raw data - Charts - - - Not set up - Configured - Expired - Re-login - Refresh failed - No data - Pending - OK - Not set up · Tap to add - Configured · Valid - Expired · Tap to reconfigure - Needs re-verification - Manual entry mode - Credential set - No credential - Loading - Latest - Cached - Failed - Awaiting input - - - AI Balance Overview - Never fetched - Updated just now - Updated %1$d min ago - Updated %1$d h ago - Updated %1$s - 💡 Long-press a card to reorder - ⚠ %1$s - Stored locally & encrypted, nothing is uploaded - Add home screen widget? - This will ask the launcher to add the RainyToken widget. Some systems may still show their own confirmation dialog. - Shortcut permission denied. Please allow it in system settings and try again. - The launcher rejected the request. Please check launcher permissions. - This launcher does not support in-app adding. Long-press home → Widgets → find RainyToken. - - - Tap to configure credentials - Pull to refresh - Service currently unavailable - %1$s%2$s used this month - Remaining - · %1$s used / %2$s total - %1$s used - 5h used - · %1$s - Resets in %1$s - REST API · ¥ - WebView fetch · 5h quota - JSON API · $ - ChatGPT Plus · Codex quota - Cookie fetch · Cloud quota - - - d - h - m - - - 5 hours - 5h - 5h usage - 5-hour rolling - This week - This month - Monthly - Usage - Usage window%1$s - Usage window - - - OpenCode Usage - CommandCode Usage - %1$d records - Tap to sync - Input tokens - Output tokens - Reasoning tokens - Total cost - Cache read %1$s - Cache write %1$s - No data yet · tap 🔄 to sync - ✓ %1$d new records - tap to switch - All times in UTC+0 - Switched to UTC+8, charts shown in Beijing time - Switched to UTC+0, charts shown in UTC - $%1$s spent / $%2$s total - $%1$s remaining - Extra credits - By model - By day (UTC+0) - %1$d models - %1$d - Start time - End time - - - Spending (USD) - API requests - Token usage - Output - Input (miss) - Cache hit - - - Time - Model - Input(cache) - Output - Cost - - - All - Last 5 hours - Last 12 hours - Last 24 hours - Today - Yesterday - Last 7 days - Last 30 days - This month - Custom - Custom day - Custom month - Custom range - - - A tiny tool to check AI balances.\nCredentials are encrypted locally — nothing is uploaded. - View all %1$d hidden tips - Inspect token refresh, network requests and other debug records - - - ✓ Login state configured (%1$d cookies) - How to get the Cookie? - 1. Open the login page below in desktop Chrome - 2. Sign in with GitHub / Google (won\'t be blocked) - 3. After login, press F12 → Application → Cookies - 4. Copy all cookies of the site and paste them above - Format: name1=value1; name2=value2 - How to get OpenCode Go credentials? - 1. Open opencode.ai/auth in desktop Chrome - 2. Sign in with GitHub / Google (Google won\'t block Chrome) - 3. You\'ll land on the dashboard, URL looks like: - https://opencode.ai/workspace/{id}/go - 4. Copy the {id} segment → workspaceId - 5. F12 → Application → Cookies - 6. Find the cookie named auth under opencode.ai - 7. Copy its Value field → auth cookie - Note: only the auth cookie is needed, not the whole cookie string - How to get CommandCode Go credentials? - 1. Open https://commandcode.ai and sign in with GitHub - ▸ Get API Key (required): - Avatar (top right) → Settings → API Keys → Create a Key - ▸ Get all cookies (for usage records): - F12 → Application → Cookies → commandcode.ai - Select all cookies and copy the whole string - Paste it below — no need to pick them one by one - Tip: balance works without cookies, usage records will just be empty. - How to get Codex auth.json? - 1. Run codex-oauth-proxy or - codex-proxy in a terminal to sign in to ChatGPT - 2. The credential file is saved at: - - - Saved - Credentials saved - Connected! Balance: %1$s %2$s - Connected, credentials saved - API Key cannot be empty - Invalid JSON format - Service rejected this credential (401/403) - Credentials changed during test - Too many requests (429) - Server error (%1$d) - Network error: %1$s - , previous credential restored - , credential changed, rollback skipped - Test failed. %1$s%2$s - Testing is not supported for this service - Both auth cookie and workspaceId are required - Cookie cannot be empty - Credential not configured - Credential missing or expired - Credential invalid, please reconfigure in Settings - Credential invalid, please log in again - Credentials changed, reloading - Credentials changed, please retry - Network error, please check your connection - Network error - Server error (HTTP %1$d) - Too many requests%1$s - , retry in %1$d seconds - Usage activation is not supported - Please select a model first - No cookie captured, please make sure you\'re logged in - No login URL configured - URL should start with %1$s - Authorization failed: %1$s - State mismatch, please retry - Callback missing authorization code - Token exchange failed, check the debug log - - - ✓ Usage activated - ✓ Activation successful - Request result - Error: %1$s - Response body:\n%1$s - Model: %1$s - Response ID: %1$s - Reply: - (empty) - (no reply content) - (response parsing failed, but the request was sent) - Usage: prompt=%1$s completion=%2$s total=%3$s tokens - Usage: input=%1$s output=%2$s tokens - Token refresh failed: %1$s - - - Sign in to %1$s - Done logging in? - If you finished signing in but the page didn\'t redirect, tap the button below to grab the cookie and save your session. - - - No log entries yet\nRefresh Codex usage and debug info will show up here - - - OpenCode Go · Usage - DeepSeek balance - Auto refresh - %1$s · Quota - %1$s · %2$s - Updated %1$s - No data - Refreshing… - - - Reorder cards by long-press - Long-press a card to reorder it - Long-press any service card on the home screen and drag it wherever you like. The order is saved automatically. - Widget top-left opens the app - Tap the widget\'s top-left mark to open the app - The RainyToken › mark at the top-left of the widget opens the app. Tapping elsewhere switches services. - Tap charts for details - Tap a chart bar to see that time slot - Tap or slide on the usage charts to inspect the exact values of a time slot. - Chart titles are clickable - Tap a chart title for a per-model breakdown - Tapping a chart card title (e.g. "Spending") shows a per-model breakdown dialog. - Switch chart timezone - Usage details can toggle UTC+0 / UTC+8 - The usage details page can toggle between UTC+0 and UTC+8; chart labels follow along. - CCGO clear & resync - CCGO details can clear cache and resync - The "Clear" button on the CCGO usage details page deletes local cache and re-fetches everything. - View usage details - Service cards link to usage charts - Configured service cards show a "View usage details" entry at the bottom that opens the charts. - Browse raw data - Top-right of usage details opens raw records - "Raw data" at the top-right of usage details lists every record with pagination. - Pull-to-refresh syncs usage - Pull to refresh syncs balances and usage - Pull-to-refresh on the home screen refreshes balances and also syncs OCGO / CCGO usage records. - Credential guides - Credential pages include step-by-step guides - The "How to get" button at the bottom of credential pages walks through every service step by step. - Clipboard auto-import - Codex supports one-tap import from clipboard - The Codex credential page supports "Auto-import from clipboard": paste auth.json and it fills in with one tap. - Jump to a page number - Raw data page accepts a page number - The raw data page lets you enter a page number to jump directly — no need to flip through pages. - Widget auto-refresh - Swipe to the widget and it auto-refreshes - The widget refreshes balances when you swipe to it, or tap the ↻ button manually. - - - %1$d - Pick a date - Pick a month (any day) - Start date - End date - Spending breakdown - Request breakdown - Token breakdown - Clear data - This clears all cached CommandCode usage and re-syncs. It takes a few seconds. - Continue? - Confirm (%1$ds) - Yes, clear - Close - From %1$s - To %1$s - Apply custom range - OK - End date cannot be earlier than start date %1$s - Time %1$s:%2$s - No data - Total: %1$s - Total - Records - Cache read - Cache write - Cache token breakdown - ※ Cache reads are included in "Input tokens" - %1$d calls - MMM d, EEEE - %1$d calls · $%2$s - Raw data - Cache write (5m) - Cache write (1h) - Cost (USD) - Cost (raw) - - - ~/.config/codex-oauth-proxy/auth.json - 3. Open it with a text editor, - copy everything and paste it above - JSON example: - {"tokens": { - "access_token": "...", - "refresh_token": "...", - "expiresAt": 1783727108252 - }} - Tip: the app auto-refreshes the token after import, - no manual updates needed. - Ollama Pro credentials - Copy the full cookie from your browser. After logging into ollama.com/settings, find the Cookie header in DevTools → Network and copy it. - Update credentials - Save credentials - Test & save - Open Ollama Settings in browser - Copy login URL to clipboard - One-tap usage API Key (optional) - With an Ollama Cloud API Key, the details page can send one request to activate usage stats. Get it at ollama.com/settings → API Keys. - ollama-xxx or sk-xxx - Save API Key - OpenCode Go credentials - Needs both auth cookie and workspaceId. The app fetches the dashboard via OkHttp and parses 5h/week/month usage. - Auto-import from clipboard - The {id} segment of the dashboard URL - auth Cookie value - F12 → Cookies → opencode.ai → Value of auth - How to get these two? - Open login page in browser - With an OpenCode Zen API Key, the details page can send one request to activate usage stats. Get it at opencode.ai/settings → API Keys. - CommandCode Go credentials - Needs an API Key (for balance). Session Cookie (optional) for usage records, copied from browser DevTools. Balance works without it. - Generate at Settings → API Keys - Session Cookie (optional) - F12 → Cookies → commandcode.ai → select all & paste - How to get API Key and Cookie? - Enter API Key - Credentials are encrypted with Android Keystore (AES-256 GCM) and stored in local DataStore. - Update - Codex / ChatGPT Plus credentials - OAuth login fetches credentials automatically. You can also paste auth.json manually. - 🔐 OAuth login (recommended) - ── or import manually ── - Full auth.json content - Paste the full {"tokens": {...}} JSON - Import & save - How to get auth.json? - Paste session - Google OAuth blocks the in-app WebView, so log in with a browser and paste the cookie here. - Cookie string - Save cookie - - - Failed to parse data: %1$s - Weekly - Model call count - Cached data · %1$s - Showing the last successfully fetched balance - %1$s%2$s spent this month - Balance breakdown - Granted balance - Top-ups - Loading model list... - Select model - Refresh model list - Activating... - Retry usage activation - ⚡ One-tap usage activation - Refresh balance - Log in again - Configure credential - Current balance - 5h live usage - Monthly balance - Session usage - Please select a model - Error details: - API response: - Copy - *** (length %1$d) *** - %1$s***%2$s (length %3$d) - Bad cookie format, expected name1=value1; name2=value2 - %1$d cookies saved - Please paste the auth.json content - auth.json is missing access_token or refresh_token - Codex credential saved; token auto-refreshes when expiring - Parse failed: %1$s - Connected! Balance: $%1$s - API Key saved - Connected! Session: %1$s%% · %2$s - Credential deleted - Clipboard is empty - Recognized workspaceId and auth cookie - Recognized workspaceId, please paste the auth cookie too - Recognized auth cookie, please paste the workspaceId too - No valid credential recognized - - - Codex OAuth Login - Exchanging token... - Retry - Headless login - 1. Tap "Open in browser" or copy the link below to a browser\n2. Complete login & authorization on the OpenAI page\n3. The browser redirects to a localhost address (page not loading is normal)\n4. Copy the full URL from the address bar and paste it below\n5. Tap "Submit callback URL" - Authorization link - Open in browser - Copy link - ── paste the callback URL after login ── - Callback URL - http://localhost:1455/auth/callback?code=... - Submit callback URL - Regenerate auth link - Preparing authorization... - \ No newline at end of file diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml new file mode 100644 index 0000000..cc3d234 --- /dev/null +++ b/app/src/main/res/values-zh/strings.xml @@ -0,0 +1,481 @@ + + + + AI用量面板 + OpenCode Go 用量 + DeepSeek 余额,自动刷新 + + + + + 返回 + 取消 + 确认添加 + 我知道了 + 刷新 + 设置 + 删除 + 清除 + 保存 + 跳转 + ◀ 上一页 + 下一页 ▶ + 添加小组件到桌面 + 查看详情 + 查看用量详情 + 详细数据 + 同步用量数据 + 我已登录,保存 + 继续等待 + + + 加载中… + 未知错误 + 全部模型 + + + 👈 选择服务查看详情 + 点击左侧卡片即可 + 设置 + 凭据管理 + 关于 RainyToken + 使用小技巧 + 调试日志 + 用量详情 + 总统计 + 详细数据 + 统计图表 + + + 未配置 + 已配置 + 已过期 + 需重登 + 刷新失败 + 未获取 + 待获取 + 正常 + 未配置 · 点击添加 + 已配置 · 凭据有效 + 已过期 · 点击重新配置 + 需要重新验证 + 手动输入模式 + 凭据已配置 + 未配置凭据 + 加载中 + 最新 + 缓存 + 失败 + 待输入 + + + AI 余额一览 + 从未获取 + 刚刚更新 + %1$d 分钟前更新 + %1$d 小时前更新 + %1$s 更新 + 💡 长按卡片可拖拽排序 + ⚠ %1$s + + 本地加密存储,不上传任何数据 + 添加桌面小组件? + 确认后会请求桌面启动器添加雨晴Token小组件。部分系统仍会弹出系统确认窗口。 + 桌面快捷方式权限已被拒绝,请在系统设置中允许后重试 + 桌面未接受添加请求,请检查启动器权限 + 当前桌面不支持应用内添加,请在桌面长按 → 小组件 → 查找雨晴Token + + + 点击配置凭据 + 下拉刷新 + 服务当前不可用 + 本月已用 %1$s%2$s + 剩余 + · 已用 %1$s / 共 %2$s + %1$s 已用 + 5h 已用 + · %1$s + %1$s 后重置 + REST API · ¥ + WebView 抓取 · 5h 配额 + JSON API · $ + ChatGPT Plus · Codex 额度 + Cookie 抓取 · Cloud 配额 + + + 天 + 小时 + 分 + + + 5 小时 + 5h + 5h 用量 + 5 小时滚动 + 本周 + 本月 + 每月 + 用量 + 用量窗口%1$s + 用量窗口 + + + OpenCode 用量 + CommandCode 用量 + %1$d 条记录 + 点击同步 + 输入 Token + 输出 Token + 推理 Token + 总花费 + 缓存读取 %1$s + 缓存写入 %1$s + 暂无数据,点击 🔄 同步 + ✓ 新增 %1$d 条 + 点击切换 + 所有时间按 UTC+0 计算 + 已切换到 UTC+8,图表按北京时间显示 + 已切换到 UTC+0,图表按世界协调时显示 + 已用 $%1$s / 共 $%2$s + 剩余 $%1$s + 额外充值 + 按模型统计 + 按天统计(UTC+0) + %1$d 个模型 + %1$d 个 + 开始时间 + 结束时间 + + + 消耗金额 (USD) + API 请求次数 + Token 消耗 + 输出 + 输入(未命中) + 命中缓存 + + + 时间 + 模型 + 输入(缓存) + 输出 + 成本 + + + 全部 + 最近5小时 + 最近12小时 + 最近24小时 + 今天 + 昨天 + 最近7天 + 最近30天 + 当月 + 自定义 + 自定义天 + 自定义月 + 自定义时间 + + + 统一查询 AI 余额的小工具。\n本地加密存储凭据,不上传任何数据。 + 查看全部 %1$d 条隐藏操作 + 查看 token 刷新、网络请求等调试记录 + + + ✓ 已配置登录态(%1$d 个 Cookie) + 如何获取 Cookie? + 1. 在桌面 Chrome 打开下方登录入口 + 2. 用 GitHub / Google 登录(不会被拦截) + 3. 登录后按 F12 → Application → Cookies + 4. 复制该站点下的所有 Cookie 粘贴到上面输入框 + 格式:name1=value1; name2=value2 + 如何获取 OpenCode Go 凭据? + 1. 桌面 Chrome 打开 opencode.ai/auth + 2. 用 GitHub / Google 登录(Google 不会拦截 Chrome) + 3. 登录后跳转到 dashboard,URL 形如: + https://opencode.ai/workspace/{id}/go + 4. 复制 URL 中的 {id} 段 → workspaceId + 5. F12 → Application → Cookies + 6. 找到域名 opencode.ai 下名为 auth 的 Cookie + 7. 复制它的 Value 字段 → auth cookie + 注意:只填 auth 一个 Cookie 即可,不要整个 Cookie 字符串 + 如何获取 CommandCode Go 凭据? + 1. 打开 https://commandcode.ai 并登录 GitHub + ▸ 获取 API Key(必填): + 右上角头像 → Settings → API Keys → 创建一个 Key + ▸ 获取全部 Cookie(用于用量记录): + F12 → Application → Cookies → commandcode.ai + 选中所有 Cookie,复制整个 Cookie 字符串 + 粘贴到下方输入框即可,无需逐个挑选 + 提示:不填 Cookie 也能正常查看余额,只是用量记录为空。 + 如何获取 Codex auth.json? + 1. 在终端运行 codex-oauth-proxy 或 + codex-proxy,完成 ChatGPT 登录 + 2. 凭证文件保存在: + + + 已保存 + 已保存凭据 + 连接成功!余额: %1$s %2$s + 连接成功,凭据已保存 + API Key 不能为空 + JSON 格式错误 + 服务拒绝该凭据 (401/403) + 测试期间凭据已变更 + 请求过于频繁 (429) + 服务端错误 (%1$d) + 网络错误:%1$s + ,已恢复原凭据 + ,检测到凭据已变化,未执行回滚 + 测试失败。%1$s%2$s + 暂不支持测试此服务 + auth cookie 和 workspaceId 都需要填写 + Cookie 不能为空 + 凭据未配置 + 凭据未配置或已过期 + 凭据无效,请在设置中重新配置 + 凭据无效,请重新登录 + 凭据已变更,正在重新加载 + 凭据已变更,请重新操作 + 网络异常,请检查网络 + 网络异常 + 服务端异常 (HTTP %1$d) + 请求过于频繁%1$s + ,请 %1$d 秒后重试 + 不支持激活用量 + 请先选择模型 + 未抓到 Cookie,请确认已登录 + 未配置登录 URL + URL 应以 %1$s 开头 + 授权失败: %1$s + State 不匹配,请重试 + 回调缺少授权码 + Token 交换失败,请查看调试日志 + + + ✓ 用量已激活 + ✓ 激活成功 + 请求结果 + 错误: %1$s + 响应内容:\n%1$s + 模型: %1$s + 响应 ID: %1$s + 回复: + (空) + (无回复内容) + (响应解析失败,但请求已发出) + 用量: prompt=%1$s completion=%2$s total=%3$s tokens + 用量: input=%1$s output=%2$s tokens + token 刷新失败:%1$s + + + 登录 %1$s + 完成登录了吗? + 如果你已经在页面上完成登录但页面没有跳转,可点击下方按钮让我抓取 Cookie 并保存登录态。 + + + 暂无日志记录\n刷新 Codex 用量后这里会出现调试信息 + + + OpenCode Go · 用量 + DeepSeek 余额 + 自动刷新 + %1$s · 额度 + %1$s · %2$s + 更新 %1$s + 暂无数据 + 刷新中… + + + 长按卡片拖拽排序 + 长按首页卡片可以拖拽排序 + 在首页长按任意服务卡片即可拖拽到想要的位置,排序会自动保存。 + 小组件点左上角进 APP + 小组件左上角标识可打开 APP + 桌面小组件左上角 RainyToken › 标识可以点击打开应用,其它区域点击切换服务。 + 图表点击查看详情 + 点击图表柱子可查看时段详情 + 在用量图表上点击或滑动手指,可查看对应时段的详细数值。 + 图表标题可点击 + 点击图表标题弹出按模型明细 + 图表卡片标题(如「消耗金额」)点击后会弹出按模型拆分的明细。 + 切换图表时区 + 用量详情页可切换 UTC+0 / UTC+8 + 用量详情页顶部可切换 UTC+0 / UTC+8,图表标签会跟随切换。 + CCGO 清除并重新同步 + CCGO 详情页可清除缓存重新同步 + CCGO 用量详情页右上角「清除」可删除本地缓存并重新拉取全部数据。 + 查看用量详情 + 服务卡片底部可进入用量图表 + 有凭据的服务卡片底部会出现「查看用量详情」入口,点击进入图表统计。 + 原始数据浏览 + 用量详情页右上角可查看原始记录 + 用量详情页右上角「详细数据」可查看每条原始调用记录,支持分页跳转。 + 下拉刷新联动用量 + 下拉刷新会同步余额和用量 + 首页下拉刷新不仅刷新余额,还会自动同步 OCGO / CCGO 用量记录。 + 凭据获取教程 + 凭据页底部有获取教程 + 凭据编辑页底部的「如何获取」按钮有针对每个服务的详细操作步骤。 + 剪贴板自动导入 + Codex 支持从剪贴板一键导入 + Codex 凭据页支持「从剪贴板自动导入」,粘贴 auth.json 后一键填入。 + 详细数据页码跳转 + 原始数据页可输入页码直跳 + 原始数据页底部可输入页码直接跳转,数据量大时不必逐页翻。 + 小组件自动刷新 + 划到小组件会自动刷新余额 + 桌面小组件划到负一屏时会自动刷新余额,也可手动点 ↻ 按钮刷新。 + + + %1$d次 + 选择日期 + 选择月份(任意一天) + 开始日期 + 结束日期 + 消费明细 + 调用次数明细 + Token 明细 + 清除数据 + 此操作将清除所有 CommandCode 用量本地缓存并重新同步,需要几秒钟完成。 + 是否继续? + 确认(%1$ds) + 确认清除 + 关闭 + 从 %1$s + 至 %1$s + 应用自定义范围 + 确定 + 结束日期不能早于开始日期 %1$s + 时间 %1$s:%2$s + 暂无数据 + 合计: %1$s + 总计 + 记录数 + 缓存读取 + 缓存写入 + 缓存 Token 细分 + ※ 缓存读取已计入「输入 Token」 + %1$d 次调用 + MM月dd日 EEEE + %1$d 次 · $%2$s + 原始数据 + 缓存写入(5m) + 缓存写入(1h) + 成本(USD) + 成本(Raw) + + + ~/.config/codex-oauth-proxy/auth.json + 3. 用文本编辑器打开该文件, + 复制全部内容粘贴到上方输入框 + JSON 格式示例: + {"tokens": { + "access_token": "...", + "refresh_token": "...", + "expiresAt": 1783727108252 + }} + 提示:导入后 APP 会自动刷新 token, + 不需要手动更新。 + Ollama Pro 凭据 + 需要从浏览器复制完整 Cookie。登录 ollama.com/settings 后,在 DevTools → Network → 请求头里找到 Cookie 行复制。 + 更新凭据 + 保存凭据 + 测试并保存 + 在浏览器中打开 Ollama Settings + 复制登录 URL 到剪贴板 + 一键激活用量 API Key(可选) + 填入 Ollama Cloud API Key 后,可在详情页一键发送请求激活用量统计。从 ollama.com/settings → API Keys 获取。 + ollama-xxx 或 sk-xxx + 保存 API Key + OpenCode Go 凭据 + 需要 auth cookie + workspaceId 两个值。APP 会用 OkHttp 抓 dashboard 并自动解析 5h/周/月 三档用量。 + 从剪贴板自动导入 + dashboard URL 中的 {id} 段 + auth Cookie 值 + F12 → Cookies → opencode.ai → auth 的 Value + 如何获取这两个值? + 在浏览器中打开登录入口 + 填入 OpenCode Zen API Key 后,可在详情页一键发送请求激活用量统计。从 opencode.ai/settings → API Keys 获取。 + CommandCode Go 凭据 + 需要 API Key(拉余额)。Session Cookie(可选)在浏览器 DevTools 复制,用于拉用量记录。不填也可正常查看余额。 + 从 Settings → API Keys 生成 + Session Cookie(可选) + F12 → Cookies → commandcode.ai → 全选复制粘贴即可 + 如何获取 API Key 和 Cookie? + 请输入 API Key + 凭据会使用 Android Keystore (AES-256 GCM) 加密后存储到本机 DataStore。 + 更新 + Codex / ChatGPT Plus 凭据 + 推荐使用 OAuth 登录自动获取凭据。也可手动粘贴 auth.json 内容。 + 🔐 OAuth 登录(推荐) + ── 或手动导入 ── + auth.json 完整内容 + 粘贴 {"tokens": {...}} 完整 JSON + 导入并保存 + 如何获取 auth.json? + 粘贴登录态 + Google OAuth 会拒绝 APP 内置 WebView,所以请用浏览器登录后把 Cookie 粘到这里。 + Cookie 字符串 + 保存 Cookie + + + 数据解析失败: %1$s + 每周 + 模型调用次数 + 缓存数据 · %1$s + 显示的是上次成功获取的余额 + 本月消费 %1$s%2$s + 余额构成 + 赠送余额 + 自费充值 + 加载模型列表... + 选择模型 + 刷新模型列表 + 激活中... + 重试激活用量 + ⚡ 一键激活用量 + 刷新余额 + 重新登录 + 配置凭据 + 当前余额 + 5h 实时用量 + 月度余额 + Session 用量 + 请选择模型 + 错误详情: + API 响应内容: + 复制 + *** (长度 %1$d) *** + %1$s***%2$s (长度 %3$d) + Cookie 格式错误,应为 name1=value1; name2=value2 + 已保存 %1$d 个 Cookie + 请粘贴 auth.json 内容 + auth.json 缺少 access_token 或 refresh_token + 已保存 Codex 凭据,token 到期后会自动刷新 + 解析失败:%1$s + 连接成功!余额: $%1$s + API Key 已保存 + 连接成功!Session: %1$s%% · %2$s + 凭据已删除 + 剪贴板为空 + 已识别 workspaceId 和 auth cookie + 已识别 workspaceId,请再粘贴 auth cookie + 已识别 auth cookie,请再粘贴 workspaceId + 未识别到有效凭据 + + + Codex OAuth 登录 + 正在交换 Token... + 重试 + 无头模式登录 + 1. 点击「在浏览器中打开」或复制下方链接到浏览器\n2. 在 OpenAI 页面完成登录和授权\n3. 浏览器会跳转到一个 localhost 地址(页面打不开是正常的)\n4. 复制浏览器地址栏的完整 URL 粘贴到下方输入框\n5. 点击「提交回调 URL」 + 授权链接 + 在浏览器中打开 + 复制链接 + ── 登录后粘贴回调 URL ── + 回调 URL + http://localhost:1455/auth/callback?code=... + 提交回调 URL + 重新生成授权链接 + 正在准备授权... + \ 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 13603a5..81830e8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,482 +1,484 @@ - - 雨晴Token - AI用量面板 - OpenCode Go 用量 + DeepSeek 余额,自动刷新 - 雨晴喵 - RainyToken + +雨晴Token +雨晴喵 +RainyToken +RainyToken · v1.0 + + + AI Usage Panel + OpenCode Go usage + DeepSeek balance, auto-refresh - 返回 - 取消 - 确认添加 - 我知道了 - 刷新 - 设置 - 删除 - 清除 - 保存 - 跳转 - ◀ 上一页 - 下一页 ▶ - 添加小组件到桌面 - 查看详情 - 查看用量详情 - 详细数据 - 同步用量数据 - 我已登录,保存 - 继续等待 + Back + Cancel + Add Widget + Got it + Refresh + Settings + Delete + Clear + Save + Go + ◀ Prev + Next ▶ + Add widget to home screen + View details + View usage details + Raw data + Sync usage data + I\'m logged in, save + Keep waiting - 加载中… - 未知错误 - 全部模型 + Loading… + Unknown error + All models - 👈 选择服务查看详情 - 点击左侧卡片即可 - 设置 - 凭据管理 - 关于 RainyToken - 使用小技巧 - 调试日志 - 用量详情 - 总统计 - 详细数据 - 统计图表 + 👈 Select a service to view details + Tap a card on the left + Settings + Credentials + About RainyToken + Tips + Debug log + Usage details + Overview + Raw data + Charts - 未配置 - 已配置 - 已过期 - 需重登 - 刷新失败 - 未获取 - 待获取 - 正常 - 未配置 · 点击添加 - 已配置 · 凭据有效 - 已过期 · 点击重新配置 - 需要重新验证 - 手动输入模式 - 凭据已配置 - 未配置凭据 - 加载中 - 最新 - 缓存 - 失败 - 待输入 + Not set up + Configured + Expired + Re-login + Refresh failed + No data + Pending + OK + Not set up · Tap to add + Configured · Valid + Expired · Tap to reconfigure + Needs re-verification + Manual entry mode + Credential set + No credential + Loading + Latest + Cached + Failed + Awaiting input - AI 余额一览 - 从未获取 - 刚刚更新 - %1$d 分钟前更新 - %1$d 小时前更新 - %1$s 更新 - 💡 长按卡片可拖拽排序 + AI Balance Overview + Never fetched + Updated just now + Updated %1$d min ago + Updated %1$d h ago + Updated %1$s + 💡 Long-press a card to reorder ⚠ %1$s - RainyToken · v1.0 - 本地加密存储,不上传任何数据 - 添加桌面小组件? - 确认后会请求桌面启动器添加雨晴Token小组件。部分系统仍会弹出系统确认窗口。 - 桌面快捷方式权限已被拒绝,请在系统设置中允许后重试 - 桌面未接受添加请求,请检查启动器权限 - 当前桌面不支持应用内添加,请在桌面长按 → 小组件 → 查找雨晴Token + Stored locally & encrypted, nothing is uploaded + Add home screen widget? + This will ask the launcher to add the RainyToken widget. Some systems may still show their own confirmation dialog. + Shortcut permission denied. Please allow it in system settings and try again. + The launcher rejected the request. Please check launcher permissions. + This launcher does not support in-app adding. Long-press home → Widgets → find RainyToken. - 点击配置凭据 - 下拉刷新 - 服务当前不可用 - 本月已用 %1$s%2$s - 剩余 - · 已用 %1$s / 共 %2$s - %1$s 已用 - 5h 已用 + Tap to configure credentials + Pull to refresh + Service currently unavailable + %1$s%2$s used this month + Remaining + · %1$s used / %2$s total + %1$s used + 5h used · %1$s - %1$s 后重置 + Resets in %1$s REST API · ¥ - WebView 抓取 · 5h 配额 + WebView fetch · 5h quota JSON API · $ - ChatGPT Plus · Codex 额度 - Cookie 抓取 · Cloud 配额 + ChatGPT Plus · Codex quota + Cookie fetch · Cloud quota - 天 - 小时 - 分 + d + h + m - 5 小时 + 5 hours 5h - 5h 用量 - 5 小时滚动 - 本周 - 本月 - 每月 - 用量 - 用量窗口%1$s - 用量窗口 + 5h usage + 5-hour rolling + This week + This month + Monthly + Usage + Usage window%1$s + Usage window - OpenCode 用量 - CommandCode 用量 - %1$d 条记录 - 点击同步 - 输入 Token - 输出 Token - 推理 Token - 总花费 - 缓存读取 %1$s - 缓存写入 %1$s - 暂无数据,点击 🔄 同步 - ✓ 新增 %1$d 条 - 点击切换 - 所有时间按 UTC+0 计算 - 已切换到 UTC+8,图表按北京时间显示 - 已切换到 UTC+0,图表按世界协调时显示 - 已用 $%1$s / 共 $%2$s - 剩余 $%1$s - 额外充值 - 按模型统计 - 按天统计(UTC+0) - %1$d 个模型 - %1$d 个 - 开始时间 - 结束时间 + OpenCode Usage + CommandCode Usage + %1$d records + Tap to sync + Input tokens + Output tokens + Reasoning tokens + Total cost + Cache read %1$s + Cache write %1$s + No data yet · tap 🔄 to sync + ✓ %1$d new records + tap to switch + All times in UTC+0 + Switched to UTC+8, charts shown in Beijing time + Switched to UTC+0, charts shown in UTC + $%1$s spent / $%2$s total + $%1$s remaining + Extra credits + By model + By day (UTC+0) + %1$d models + %1$d + Start time + End time - 消耗金额 (USD) - API 请求次数 - Token 消耗 - 输出 - 输入(未命中) - 命中缓存 + Spending (USD) + API requests + Token usage + Output + Input (miss) + Cache hit - 时间 - 模型 - 输入(缓存) - 输出 - 成本 + Time + Model + Input(cache) + Output + Cost - 全部 - 最近5小时 - 最近12小时 - 最近24小时 - 今天 - 昨天 - 最近7天 - 最近30天 - 当月 - 自定义 - 自定义天 - 自定义月 - 自定义时间 + All + Last 5 hours + Last 12 hours + Last 24 hours + Today + Yesterday + Last 7 days + Last 30 days + This month + Custom + Custom day + Custom month + Custom range - 统一查询 AI 余额的小工具。\n本地加密存储凭据,不上传任何数据。 - 查看全部 %1$d 条隐藏操作 - 查看 token 刷新、网络请求等调试记录 + A tiny tool to check AI balances.\nCredentials are encrypted locally — nothing is uploaded. + View all %1$d hidden tips + Inspect token refresh, network requests and other debug records - ✓ 已配置登录态(%1$d 个 Cookie) - 如何获取 Cookie? - 1. 在桌面 Chrome 打开下方登录入口 - 2. 用 GitHub / Google 登录(不会被拦截) - 3. 登录后按 F12 → Application → Cookies - 4. 复制该站点下的所有 Cookie 粘贴到上面输入框 - 格式:name1=value1; name2=value2 - 如何获取 OpenCode Go 凭据? - 1. 桌面 Chrome 打开 opencode.ai/auth - 2. 用 GitHub / Google 登录(Google 不会拦截 Chrome) - 3. 登录后跳转到 dashboard,URL 形如: + ✓ Login state configured (%1$d cookies) + How to get the Cookie? + 1. Open the login page below in desktop Chrome + 2. Sign in with GitHub / Google (won\'t be blocked) + 3. After login, press F12 → Application → Cookies + 4. Copy all cookies of the site and paste them above + Format: name1=value1; name2=value2 + How to get OpenCode Go credentials? + 1. Open opencode.ai/auth in desktop Chrome + 2. Sign in with GitHub / Google (Google won\'t block Chrome) + 3. You\'ll land on the dashboard, URL looks like: https://opencode.ai/workspace/{id}/go - 4. 复制 URL 中的 {id} 段 → workspaceId + 4. Copy the {id} segment → workspaceId 5. F12 → Application → Cookies - 6. 找到域名 opencode.ai 下名为 auth 的 Cookie - 7. 复制它的 Value 字段 → auth cookie - 注意:只填 auth 一个 Cookie 即可,不要整个 Cookie 字符串 - 如何获取 CommandCode Go 凭据? - 1. 打开 https://commandcode.ai 并登录 GitHub - ▸ 获取 API Key(必填): - 右上角头像 → Settings → API Keys → 创建一个 Key - ▸ 获取全部 Cookie(用于用量记录): + 6. Find the cookie named auth under opencode.ai + 7. Copy its Value field → auth cookie + Note: only the auth cookie is needed, not the whole cookie string + How to get CommandCode Go credentials? + 1. Open https://commandcode.ai and sign in with GitHub + ▸ Get API Key (required): + Avatar (top right) → Settings → API Keys → Create a Key + ▸ Get all cookies (for usage records): F12 → Application → Cookies → commandcode.ai - 选中所有 Cookie,复制整个 Cookie 字符串 - 粘贴到下方输入框即可,无需逐个挑选 - 提示:不填 Cookie 也能正常查看余额,只是用量记录为空。 - 如何获取 Codex auth.json? - 1. 在终端运行 codex-oauth-proxy 或 - codex-proxy,完成 ChatGPT 登录 - 2. 凭证文件保存在: + Select all cookies and copy the whole string + Paste it below — no need to pick them one by one + Tip: balance works without cookies, usage records will just be empty. + How to get Codex auth.json? + 1. Run codex-oauth-proxy or + codex-proxy in a terminal to sign in to ChatGPT + 2. The credential file is saved at: - 已保存 - 已保存凭据 - 连接成功!余额: %1$s %2$s - 连接成功,凭据已保存 - API Key 不能为空 - JSON 格式错误 - 服务拒绝该凭据 (401/403) - 测试期间凭据已变更 - 请求过于频繁 (429) - 服务端错误 (%1$d) - 网络错误:%1$s - ,已恢复原凭据 - ,检测到凭据已变化,未执行回滚 - 测试失败。%1$s%2$s - 暂不支持测试此服务 - auth cookie 和 workspaceId 都需要填写 - Cookie 不能为空 - 凭据未配置 - 凭据未配置或已过期 - 凭据无效,请在设置中重新配置 - 凭据无效,请重新登录 - 凭据已变更,正在重新加载 - 凭据已变更,请重新操作 - 网络异常,请检查网络 - 网络异常 - 服务端异常 (HTTP %1$d) - 请求过于频繁%1$s - ,请 %1$d 秒后重试 - 不支持激活用量 - 请先选择模型 - 未抓到 Cookie,请确认已登录 - 未配置登录 URL - URL 应以 %1$s 开头 - 授权失败: %1$s - State 不匹配,请重试 - 回调缺少授权码 - Token 交换失败,请查看调试日志 + Saved + Credentials saved + Connected! Balance: %1$s %2$s + Connected, credentials saved + API Key cannot be empty + Invalid JSON format + Service rejected this credential (401/403) + Credentials changed during test + Too many requests (429) + Server error (%1$d) + Network error: %1$s + , previous credential restored + , credential changed, rollback skipped + Test failed. %1$s%2$s + Testing is not supported for this service + Both auth cookie and workspaceId are required + Cookie cannot be empty + Credential not configured + Credential missing or expired + Credential invalid, please reconfigure in Settings + Credential invalid, please log in again + Credentials changed, reloading + Credentials changed, please retry + Network error, please check your connection + Network error + Server error (HTTP %1$d) + Too many requests%1$s + , retry in %1$d seconds + Usage activation is not supported + Please select a model first + No cookie captured, please make sure you\'re logged in + No login URL configured + URL should start with %1$s + Authorization failed: %1$s + State mismatch, please retry + Callback missing authorization code + Token exchange failed, check the debug log - ✓ 用量已激活 - ✓ 激活成功 - 请求结果 - 错误: %1$s - 响应内容:\n%1$s - 模型: %1$s - 响应 ID: %1$s - 回复: - (空) - (无回复内容) - (响应解析失败,但请求已发出) - 用量: prompt=%1$s completion=%2$s total=%3$s tokens - 用量: input=%1$s output=%2$s tokens - token 刷新失败:%1$s + ✓ Usage activated + ✓ Activation successful + Request result + Error: %1$s + Response body:\n%1$s + Model: %1$s + Response ID: %1$s + Reply: + (empty) + (no reply content) + (response parsing failed, but the request was sent) + Usage: prompt=%1$s completion=%2$s total=%3$s tokens + Usage: input=%1$s output=%2$s tokens + Token refresh failed: %1$s - 登录 %1$s - 完成登录了吗? - 如果你已经在页面上完成登录但页面没有跳转,可点击下方按钮让我抓取 Cookie 并保存登录态。 + Sign in to %1$s + Done logging in? + If you finished signing in but the page didn\'t redirect, tap the button below to grab the cookie and save your session. - 暂无日志记录\n刷新 Codex 用量后这里会出现调试信息 + No log entries yet\nRefresh Codex usage and debug info will show up here - OpenCode Go · 用量 - DeepSeek 余额 - 自动刷新 - %1$s · 额度 + OpenCode Go · Usage + DeepSeek balance + Auto refresh + %1$s · Quota %1$s · %2$s - 更新 %1$s - 暂无数据 - 刷新中… + Updated %1$s + No data + Refreshing… - 长按卡片拖拽排序 - 长按首页卡片可以拖拽排序 - 在首页长按任意服务卡片即可拖拽到想要的位置,排序会自动保存。 - 小组件点左上角进 APP - 小组件左上角标识可打开 APP - 桌面小组件左上角 RainyToken › 标识可以点击打开应用,其它区域点击切换服务。 - 图表点击查看详情 - 点击图表柱子可查看时段详情 - 在用量图表上点击或滑动手指,可查看对应时段的详细数值。 - 图表标题可点击 - 点击图表标题弹出按模型明细 - 图表卡片标题(如「消耗金额」)点击后会弹出按模型拆分的明细。 - 切换图表时区 - 用量详情页可切换 UTC+0 / UTC+8 - 用量详情页顶部可切换 UTC+0 / UTC+8,图表标签会跟随切换。 - CCGO 清除并重新同步 - CCGO 详情页可清除缓存重新同步 - CCGO 用量详情页右上角「清除」可删除本地缓存并重新拉取全部数据。 - 查看用量详情 - 服务卡片底部可进入用量图表 - 有凭据的服务卡片底部会出现「查看用量详情」入口,点击进入图表统计。 - 原始数据浏览 - 用量详情页右上角可查看原始记录 - 用量详情页右上角「详细数据」可查看每条原始调用记录,支持分页跳转。 - 下拉刷新联动用量 - 下拉刷新会同步余额和用量 - 首页下拉刷新不仅刷新余额,还会自动同步 OCGO / CCGO 用量记录。 - 凭据获取教程 - 凭据页底部有获取教程 - 凭据编辑页底部的「如何获取」按钮有针对每个服务的详细操作步骤。 - 剪贴板自动导入 - Codex 支持从剪贴板一键导入 - Codex 凭据页支持「从剪贴板自动导入」,粘贴 auth.json 后一键填入。 - 详细数据页码跳转 - 原始数据页可输入页码直跳 - 原始数据页底部可输入页码直接跳转,数据量大时不必逐页翻。 - 小组件自动刷新 - 划到小组件会自动刷新余额 - 桌面小组件划到负一屏时会自动刷新余额,也可手动点 ↻ 按钮刷新。 + Reorder cards by long-press + Long-press a card to reorder it + Long-press any service card on the home screen and drag it wherever you like. The order is saved automatically. + Widget top-left opens the app + Tap the widget\'s top-left mark to open the app + The RainyToken › mark at the top-left of the widget opens the app. Tapping elsewhere switches services. + Tap charts for details + Tap a chart bar to see that time slot + Tap or slide on the usage charts to inspect the exact values of a time slot. + Chart titles are clickable + Tap a chart title for a per-model breakdown + Tapping a chart card title (e.g. "Spending") shows a per-model breakdown dialog. + Switch chart timezone + Usage details can toggle UTC+0 / UTC+8 + The usage details page can toggle between UTC+0 and UTC+8; chart labels follow along. + CCGO clear & resync + CCGO details can clear cache and resync + The "Clear" button on the CCGO usage details page deletes local cache and re-fetches everything. + View usage details + Service cards link to usage charts + Configured service cards show a "View usage details" entry at the bottom that opens the charts. + Browse raw data + Top-right of usage details opens raw records + "Raw data" at the top-right of usage details lists every record with pagination. + Pull-to-refresh syncs usage + Pull to refresh syncs balances and usage + Pull-to-refresh on the home screen refreshes balances and also syncs OCGO / CCGO usage records. + Credential guides + Credential pages include step-by-step guides + The "How to get" button at the bottom of credential pages walks through every service step by step. + Clipboard auto-import + Codex supports one-tap import from clipboard + The Codex credential page supports "Auto-import from clipboard": paste auth.json and it fills in with one tap. + Jump to a page number + Raw data page accepts a page number + The raw data page lets you enter a page number to jump directly — no need to flip through pages. + Widget auto-refresh + Swipe to the widget and it auto-refreshes + The widget refreshes balances when you swipe to it, or tap the ↻ button manually. - - %1$d次 - 选择日期 - 选择月份(任意一天) - 开始日期 - 结束日期 - 消费明细 - 调用次数明细 - Token 明细 - 清除数据 - 此操作将清除所有 CommandCode 用量本地缓存并重新同步,需要几秒钟完成。 - 是否继续? - 确认(%1$ds) - 确认清除 - 关闭 - 从 %1$s - 至 %1$s - 应用自定义范围 - 确定 - 结束日期不能早于开始日期 %1$s - 时间 %1$s:%2$s - 暂无数据 - 合计: %1$s - 总计 - 记录数 - 缓存读取 - 缓存写入 - 缓存 Token 细分 - ※ 缓存读取已计入「输入 Token」 - %1$d 次调用 - MM月dd日 EEEE - %1$d 次 · $%2$s - 原始数据 - 缓存写入(5m) - 缓存写入(1h) - 成本(USD) - 成本(Raw) + + %1$d + Pick a date + Pick a month (any day) + Start date + End date + Spending breakdown + Request breakdown + Token breakdown + Clear data + This clears all cached CommandCode usage and re-syncs. It takes a few seconds. + Continue? + Confirm (%1$ds) + Yes, clear + Close + From %1$s + To %1$s + Apply custom range + OK + End date cannot be earlier than start date %1$s + Time %1$s:%2$s + No data + Total: %1$s + Total + Records + Cache read + Cache write + Cache token breakdown + ※ Cache reads are included in "Input tokens" + %1$d calls + MMM d, EEEE + %1$d calls · $%2$s + Raw data + Cache write (5m) + Cache write (1h) + Cost (USD) + Cost (raw) - + ~/.config/codex-oauth-proxy/auth.json - 3. 用文本编辑器打开该文件, - 复制全部内容粘贴到上方输入框 - JSON 格式示例: + 3. Open it with a text editor, + copy everything and paste it above + JSON example: {"tokens": { "access_token": "...", "refresh_token": "...", "expiresAt": 1783727108252 }} - 提示:导入后 APP 会自动刷新 token, - 不需要手动更新。 - Ollama Pro 凭据 - 需要从浏览器复制完整 Cookie。登录 ollama.com/settings 后,在 DevTools → Network → 请求头里找到 Cookie 行复制。 - 更新凭据 - 保存凭据 - 测试并保存 - 在浏览器中打开 Ollama Settings - 复制登录 URL 到剪贴板 - 一键激活用量 API Key(可选) - 填入 Ollama Cloud API Key 后,可在详情页一键发送请求激活用量统计。从 ollama.com/settings → API Keys 获取。 - ollama-xxx 或 sk-xxx - 保存 API Key - OpenCode Go 凭据 - 需要 auth cookie + workspaceId 两个值。APP 会用 OkHttp 抓 dashboard 并自动解析 5h/周/月 三档用量。 - 从剪贴板自动导入 - dashboard URL 中的 {id} 段 - auth Cookie 值 - F12 → Cookies → opencode.ai → auth 的 Value - 如何获取这两个值? - 在浏览器中打开登录入口 - 填入 OpenCode Zen API Key 后,可在详情页一键发送请求激活用量统计。从 opencode.ai/settings → API Keys 获取。 - CommandCode Go 凭据 - 需要 API Key(拉余额)。Session Cookie(可选)在浏览器 DevTools 复制,用于拉用量记录。不填也可正常查看余额。 - 从 Settings → API Keys 生成 - Session Cookie(可选) - F12 → Cookies → commandcode.ai → 全选复制粘贴即可 - 如何获取 API Key 和 Cookie? - 请输入 API Key - 凭据会使用 Android Keystore (AES-256 GCM) 加密后存储到本机 DataStore。 - 更新 - Codex / ChatGPT Plus 凭据 - 推荐使用 OAuth 登录自动获取凭据。也可手动粘贴 auth.json 内容。 - 🔐 OAuth 登录(推荐) - ── 或手动导入 ── - auth.json 完整内容 - 粘贴 {"tokens": {...}} 完整 JSON - 导入并保存 - 如何获取 auth.json? - 粘贴登录态 - Google OAuth 会拒绝 APP 内置 WebView,所以请用浏览器登录后把 Cookie 粘到这里。 - Cookie 字符串 - 保存 Cookie + Tip: the app auto-refreshes the token after import, + no manual updates needed. + Ollama Pro credentials + Copy the full cookie from your browser. After logging into ollama.com/settings, find the Cookie header in DevTools → Network and copy it. + Update credentials + Save credentials + Test & save + Open Ollama Settings in browser + Copy login URL to clipboard + One-tap usage API Key (optional) + With an Ollama Cloud API Key, the details page can send one request to activate usage stats. Get it at ollama.com/settings → API Keys. + ollama-xxx or sk-xxx + Save API Key + OpenCode Go credentials + Needs both auth cookie and workspaceId. The app fetches the dashboard via OkHttp and parses 5h/week/month usage. + Auto-import from clipboard + The {id} segment of the dashboard URL + auth Cookie value + F12 → Cookies → opencode.ai → Value of auth + How to get these two? + Open login page in browser + With an OpenCode Zen API Key, the details page can send one request to activate usage stats. Get it at opencode.ai/settings → API Keys. + CommandCode Go credentials + Needs an API Key (for balance). Session Cookie (optional) for usage records, copied from browser DevTools. Balance works without it. + Generate at Settings → API Keys + Session Cookie (optional) + F12 → Cookies → commandcode.ai → select all & paste + How to get API Key and Cookie? + Enter API Key + Credentials are encrypted with Android Keystore (AES-256 GCM) and stored in local DataStore. + Update + Codex / ChatGPT Plus credentials + OAuth login fetches credentials automatically. You can also paste auth.json manually. + 🔐 OAuth login (recommended) + ── or import manually ── + Full auth.json content + Paste the full {"tokens": {...}} JSON + Import & save + How to get auth.json? + Paste session + Google OAuth blocks the in-app WebView, so log in with a browser and paste the cookie here. + Cookie string + Save cookie - - 数据解析失败: %1$s - 每周 - 模型调用次数 - 缓存数据 · %1$s - 显示的是上次成功获取的余额 - 本月消费 %1$s%2$s - 余额构成 - 赠送余额 - 自费充值 - 加载模型列表... - 选择模型 - 刷新模型列表 - 激活中... - 重试激活用量 - ⚡ 一键激活用量 - 刷新余额 - 重新登录 - 配置凭据 - 当前余额 - 5h 实时用量 - 月度余额 - Session 用量 - 请选择模型 - 错误详情: - API 响应内容: - 复制 - *** (长度 %1$d) *** - %1$s***%2$s (长度 %3$d) - Cookie 格式错误,应为 name1=value1; name2=value2 - 已保存 %1$d 个 Cookie - 请粘贴 auth.json 内容 - auth.json 缺少 access_token 或 refresh_token - 已保存 Codex 凭据,token 到期后会自动刷新 - 解析失败:%1$s - 连接成功!余额: $%1$s - API Key 已保存 - 连接成功!Session: %1$s%% · %2$s - 凭据已删除 - 剪贴板为空 - 已识别 workspaceId 和 auth cookie - 已识别 workspaceId,请再粘贴 auth cookie - 已识别 auth cookie,请再粘贴 workspaceId - 未识别到有效凭据 + + Failed to parse data: %1$s + Weekly + Model call count + Cached data · %1$s + Showing the last successfully fetched balance + %1$s%2$s spent this month + Balance breakdown + Granted balance + Top-ups + Loading model list... + Select model + Refresh model list + Activating... + Retry usage activation + ⚡ One-tap usage activation + Refresh balance + Log in again + Configure credential + Current balance + 5h live usage + Monthly balance + Session usage + Please select a model + Error details: + API response: + Copy + *** (length %1$d) *** + %1$s***%2$s (length %3$d) + Bad cookie format, expected name1=value1; name2=value2 + %1$d cookies saved + Please paste the auth.json content + auth.json is missing access_token or refresh_token + Codex credential saved; token auto-refreshes when expiring + Parse failed: %1$s + Connected! Balance: $%1$s + API Key saved + Connected! Session: %1$s%% · %2$s + Credential deleted + Clipboard is empty + Recognized workspaceId and auth cookie + Recognized workspaceId, please paste the auth cookie too + Recognized auth cookie, please paste the workspaceId too + No valid credential recognized - Codex OAuth 登录 - 正在交换 Token... - 重试 - 无头模式登录 - 1. 点击「在浏览器中打开」或复制下方链接到浏览器\n2. 在 OpenAI 页面完成登录和授权\n3. 浏览器会跳转到一个 localhost 地址(页面打不开是正常的)\n4. 复制浏览器地址栏的完整 URL 粘贴到下方输入框\n5. 点击「提交回调 URL」 - 授权链接 - 在浏览器中打开 - 复制链接 - ── 登录后粘贴回调 URL ── - 回调 URL + Codex OAuth Login + Exchanging token... + Retry + Headless login + 1. Tap "Open in browser" or copy the link below to a browser\n2. Complete login & authorization on the OpenAI page\n3. The browser redirects to a localhost address (page not loading is normal)\n4. Copy the full URL from the address bar and paste it below\n5. Tap "Submit callback URL" + Authorization link + Open in browser + Copy link + ── paste the callback URL after login ── + Callback URL http://localhost:1455/auth/callback?code=... - 提交回调 URL - 重新生成授权链接 - 正在准备授权... - \ No newline at end of file + Submit callback URL + Regenerate auth link + Preparing authorization... + From 4107954080c19b967101227b478234c7aa0d05c2 Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:43:05 +0000 Subject: [PATCH 06/14] =?UTF-8?q?feat:=20=E5=BA=94=E7=94=A8=E5=86=85?= =?UTF-8?q?=E8=AF=AD=E8=A8=80=E5=88=87=E6=8D=A2=EF=BC=88=E8=B7=9F=E9=9A=8F?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F/=E7=AE=80/=E7=B9=81/=E8=8B=B1=EF=BC=89+=20?= =?UTF-8?q?=E7=B9=81=E4=BD=93=E4=B8=AD=E6=96=87=E6=94=AF=E6=8C=81=20+=20?= =?UTF-8?q?=E5=BA=94=E7=94=A8=E5=90=8D/=E7=BD=B2=E5=90=8D=E5=88=86?= =?UTF-8?q?=E8=AF=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 LocaleManager:语言偏好存 SharedPreferences,createConfigurationContext + LocaleList 包装 Application/MainActivity 的 attachBaseContext(零新依赖) - 设置页新增语言卡片与选择对话框:跟随系统 / 简体中文 / 繁體中文 / English - 新增繁体中文 values-b+zh+Hant(434 条,OpenCC s2twp 台湾标准转换+人工修正) - 简体目录改为 values-b+zh+Hans(BCP-47,与繁体对称;AGP9 不接受 values-zh-Hans) - widget_brand 可翻译:英=Rainy / 简繁=雨晴喵;app_name 可翻译:英=RainyToken / 简繁=雨晴Token - 资源三语言 434/434/434 对齐,占位符零差异 --- .../main/java/com/rainy/token/MainActivity.kt | 6 + .../com/rainy/token/RainyTokenApplication.kt | 6 + .../rainy/token/ui/settings/SettingsScreen.kt | 108 ++++ .../com/rainy/token/util/LocaleManager.kt | 55 ++ app/src/main/res/values-b+zh+Hans/strings.xml | 489 ++++++++++++++++++ app/src/main/res/values-b+zh+Hant/strings.xml | 489 ++++++++++++++++++ app/src/main/res/values/strings.xml | 22 +- 7 files changed, 1168 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/com/rainy/token/util/LocaleManager.kt create mode 100644 app/src/main/res/values-b+zh+Hans/strings.xml create mode 100644 app/src/main/res/values-b+zh+Hant/strings.xml diff --git a/app/src/main/java/com/rainy/token/MainActivity.kt b/app/src/main/java/com/rainy/token/MainActivity.kt index 98065c8..98ab02e 100644 --- a/app/src/main/java/com/rainy/token/MainActivity.kt +++ b/app/src/main/java/com/rainy/token/MainActivity.kt @@ -1,5 +1,6 @@ package com.rainy.token +import android.content.Context import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -20,6 +21,11 @@ import dagger.hilt.android.AndroidEntryPoint */ @AndroidEntryPoint class MainActivity : ComponentActivity() { + override fun attachBaseContext(newBase: Context) { + // 应用内语言偏好在此层生效:recreate() 后资源按所选语言重新解析 + super.attachBaseContext(com.rainy.token.util.LocaleManager.wrapContext(newBase)) + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() diff --git a/app/src/main/java/com/rainy/token/RainyTokenApplication.kt b/app/src/main/java/com/rainy/token/RainyTokenApplication.kt index 6b981ec..1939403 100644 --- a/app/src/main/java/com/rainy/token/RainyTokenApplication.kt +++ b/app/src/main/java/com/rainy/token/RainyTokenApplication.kt @@ -12,6 +12,12 @@ import dagger.hilt.android.HiltAndroidApp */ @HiltAndroidApp class RainyTokenApplication : Application() { + override fun attachBaseContext(base: Context) { + // 应用内语言偏好(跟随系统 / 中文 / English)在 Application 层生效, + // 保证 appContext 与系统级回调(如小组件)之外的代码都使用所选语言。 + super.attachBaseContext(com.rainy.token.util.LocaleManager.wrapContext(base)) + } + override fun onCreate() { super.onCreate() appContext = applicationContext diff --git a/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt b/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt index d0fcea0..8ad5f32 100644 --- a/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt @@ -1,5 +1,6 @@ package com.rainy.token.ui.settings +import android.app.Activity import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -17,6 +18,7 @@ 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.material3.AlertDialog import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator @@ -26,13 +28,18 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults 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.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 @@ -50,6 +57,7 @@ import com.rainy.token.R import com.rainy.token.ui.components.AppTips import com.rainy.token.ui.theme.InkMuted import com.rainy.token.ui.theme.StrawberryPink +import com.rainy.token.util.LocaleManager /** * 设置页(雨晴风格重做版)。 @@ -74,6 +82,7 @@ fun SettingsScreen( } val uiState by viewModel.uiState.collectAsStateWithLifecycle() + var showLanguageDialog by remember { mutableStateOf(false) } Scaffold( containerColor = Color.Transparent, @@ -133,6 +142,9 @@ fun SettingsScreen( onClick = { onEditCredential(status.service) } ) } + item { + LanguageCard(onClick = { showLanguageDialog = true }) + } item { TipsCard(onClick = { onOpenTips() }) } @@ -146,6 +158,102 @@ fun SettingsScreen( } } } + + if (showLanguageDialog) { + LanguageDialog(onDismiss = { showLanguageDialog = false }) + } +} + +@Composable +private fun LanguageCard(onClick: () -> Unit) { + val context = LocalContext.current + val current = LocaleManager.getLocaleCode(context) + val labelRes = when (current) { + "zh" -> R.string.app_locale_zh + "zh-Hant" -> R.string.app_locale_zh_hant + "en" -> R.string.app_locale_en + else -> R.string.app_locale_follow_system + } + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { onClick() }, + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "🌐", + style = MaterialTheme.typography.titleLarge + ) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.title_language), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + Text( + text = stringResource(labelRes), + style = MaterialTheme.typography.bodySmall, + color = InkMuted, + modifier = Modifier.padding(top = 2.dp) + ) + } + } + } +} + +@Composable +private fun LanguageDialog(onDismiss: () -> Unit) { + val context = LocalContext.current + val current = LocaleManager.getLocaleCode(context) + val options: List> = listOf( + null to R.string.app_locale_follow_system, + "zh" to R.string.app_locale_zh, + "zh-Hant" to R.string.app_locale_zh_hant, + "en" to R.string.app_locale_en + ) + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.dialog_language_title)) }, + text = { + Column { + options.forEach { (code, labelRes) -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + LocaleManager.saveLocale(context, code) + onDismiss() + // 重建 Activity 让资源按新语言重新解析 + (context as? Activity)?.recreate() + } + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(labelRes), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f) + ) + if (current == code) { + Text("✓", color = StrawberryPink, style = MaterialTheme.typography.bodyMedium) + } + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.action_cancel)) + } + } + ) } @Composable diff --git a/app/src/main/java/com/rainy/token/util/LocaleManager.kt b/app/src/main/java/com/rainy/token/util/LocaleManager.kt new file mode 100644 index 0000000..1152bc3 --- /dev/null +++ b/app/src/main/java/com/rainy/token/util/LocaleManager.kt @@ -0,0 +1,55 @@ +package com.rainy.token.util + +import android.content.Context +import android.content.res.Configuration +import java.util.Locale + +/** + * 应用内语言偏好管理(自建轻量方案,不引入 appcompat 依赖)。 + * + * 语言值约定: + * - `null`(未设置)= 跟随系统 + * - `"zh"` = 简体中文(匹配 values-b+zh+Hans,BCP-47 写法,覆盖中国大陆等简体地区) + * - `"zh-Hant"` = 繁体中文(匹配 values-b+zh+Hant,BCP-47 写法,覆盖台/港/澳所有繁体地区) + * - `"en"` = English(匹配默认 values/ 英文资源) + * + * 注:AGP 9 资源合并器不接受 values-zh-Hans/values-zh-Hant 目录名,故用等价的 + * BCP-47 写法 values-b+zh+Hans / values-b+zh+Hant;编译产物同为 + * language=zh + script=Hans/Hant 配置。 + * + * 在 [Application] 与 [MainActivity] 的 attachBaseContext 中调用 [wrapContext] + * 应用偏好;切换语言后由设置页保存偏好并 recreate Activity 触发重新解析。 + */ +object LocaleManager { + + private const val PREFS_NAME = "locale_prefs" + private const val KEY_LOCALE = "locale" + + /** 当前保存的语言代码;null = 跟随系统 */ + fun getLocaleCode(context: Context): String? { + val code = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .getString(KEY_LOCALE, null) ?: return null + return code.takeIf { it == "zh" || it == "zh-Hant" || it == "en" } + } + + /** 保存语言偏好;传 null 表示跟随系统 */ + fun saveLocale(context: Context, code: String?) { + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .putString(KEY_LOCALE, code) + .apply() + } + + /** 把 base context 包装上应用语言配置;未设置偏好时原样返回 */ + fun wrapContext(base: Context): Context { + val code = getLocaleCode(base) ?: return base + val locale = when (code) { + "zh" -> Locale.SIMPLIFIED_CHINESE + "zh-Hant" -> Locale.forLanguageTag("zh-Hant") + else -> Locale.ENGLISH + } + val config = Configuration(base.resources.configuration) + config.setLocales(android.os.LocaleList(locale)) + return base.createConfigurationContext(config) + } +} diff --git a/app/src/main/res/values-b+zh+Hans/strings.xml b/app/src/main/res/values-b+zh+Hans/strings.xml new file mode 100644 index 0000000..8bc27aa --- /dev/null +++ b/app/src/main/res/values-b+zh+Hans/strings.xml @@ -0,0 +1,489 @@ + + + + AI用量面板 + OpenCode Go 用量 + DeepSeek 余额,自动刷新 + 雨晴喵 + 雨晴Token + + + + + 返回 + 取消 + 确认添加 + 我知道了 + 刷新 + 设置 + 删除 + 清除 + 保存 + 跳转 + ◀ 上一页 + 下一页 ▶ + 添加小组件到桌面 + 语言 + 选择语言 + 跟随系统 + 简体中文 + 繁體中文 + English + 查看详情 + 查看用量详情 + 详细数据 + 同步用量数据 + 我已登录,保存 + 继续等待 + + + 加载中… + 未知错误 + 全部模型 + + + 👈 选择服务查看详情 + 点击左侧卡片即可 + 设置 + 凭据管理 + 关于 RainyToken + 使用小技巧 + 调试日志 + 用量详情 + 总统计 + 详细数据 + 统计图表 + + + 未配置 + 已配置 + 已过期 + 需重登 + 刷新失败 + 未获取 + 待获取 + 正常 + 未配置 · 点击添加 + 已配置 · 凭据有效 + 已过期 · 点击重新配置 + 需要重新验证 + 手动输入模式 + 凭据已配置 + 未配置凭据 + 加载中 + 最新 + 缓存 + 失败 + 待输入 + + + AI 余额一览 + 从未获取 + 刚刚更新 + %1$d 分钟前更新 + %1$d 小时前更新 + %1$s 更新 + 💡 长按卡片可拖拽排序 + ⚠ %1$s + + 本地加密存储,不上传任何数据 + 添加桌面小组件? + 确认后会请求桌面启动器添加雨晴Token小组件。部分系统仍会弹出系统确认窗口。 + 桌面快捷方式权限已被拒绝,请在系统设置中允许后重试 + 桌面未接受添加请求,请检查启动器权限 + 当前桌面不支持应用内添加,请在桌面长按 → 小组件 → 查找雨晴Token + + + 点击配置凭据 + 下拉刷新 + 服务当前不可用 + 本月已用 %1$s%2$s + 剩余 + · 已用 %1$s / 共 %2$s + %1$s 已用 + 5h 已用 + · %1$s + %1$s 后重置 + REST API · ¥ + WebView 抓取 · 5h 配额 + JSON API · $ + ChatGPT Plus · Codex 额度 + Cookie 抓取 · Cloud 配额 + + + 天 + 小时 + 分 + + + 5 小时 + 5h + 5h 用量 + 5 小时滚动 + 本周 + 本月 + 每月 + 用量 + 用量窗口%1$s + 用量窗口 + + + OpenCode 用量 + CommandCode 用量 + %1$d 条记录 + 点击同步 + 输入 Token + 输出 Token + 推理 Token + 总花费 + 缓存读取 %1$s + 缓存写入 %1$s + 暂无数据,点击 🔄 同步 + ✓ 新增 %1$d 条 + 点击切换 + 所有时间按 UTC+0 计算 + 已切换到 UTC+8,图表按北京时间显示 + 已切换到 UTC+0,图表按世界协调时显示 + 已用 $%1$s / 共 $%2$s + 剩余 $%1$s + 额外充值 + 按模型统计 + 按天统计(UTC+0) + %1$d 个模型 + %1$d 个 + 开始时间 + 结束时间 + + + 消耗金额 (USD) + API 请求次数 + Token 消耗 + 输出 + 输入(未命中) + 命中缓存 + + + 时间 + 模型 + 输入(缓存) + 输出 + 成本 + + + 全部 + 最近5小时 + 最近12小时 + 最近24小时 + 今天 + 昨天 + 最近7天 + 最近30天 + 当月 + 自定义 + 自定义天 + 自定义月 + 自定义时间 + + + 统一查询 AI 余额的小工具。\n本地加密存储凭据,不上传任何数据。 + 查看全部 %1$d 条隐藏操作 + 查看 token 刷新、网络请求等调试记录 + + + ✓ 已配置登录态(%1$d 个 Cookie) + 如何获取 Cookie? + 1. 在桌面 Chrome 打开下方登录入口 + 2. 用 GitHub / Google 登录(不会被拦截) + 3. 登录后按 F12 → Application → Cookies + 4. 复制该站点下的所有 Cookie 粘贴到上面输入框 + 格式:name1=value1; name2=value2 + 如何获取 OpenCode Go 凭据? + 1. 桌面 Chrome 打开 opencode.ai/auth + 2. 用 GitHub / Google 登录(Google 不会拦截 Chrome) + 3. 登录后跳转到 dashboard,URL 形如: + https://opencode.ai/workspace/{id}/go + 4. 复制 URL 中的 {id} 段 → workspaceId + 5. F12 → Application → Cookies + 6. 找到域名 opencode.ai 下名为 auth 的 Cookie + 7. 复制它的 Value 字段 → auth cookie + 注意:只填 auth 一个 Cookie 即可,不要整个 Cookie 字符串 + 如何获取 CommandCode Go 凭据? + 1. 打开 https://commandcode.ai 并登录 GitHub + ▸ 获取 API Key(必填): + 右上角头像 → Settings → API Keys → 创建一个 Key + ▸ 获取全部 Cookie(用于用量记录): + F12 → Application → Cookies → commandcode.ai + 选中所有 Cookie,复制整个 Cookie 字符串 + 粘贴到下方输入框即可,无需逐个挑选 + 提示:不填 Cookie 也能正常查看余额,只是用量记录为空。 + 如何获取 Codex auth.json? + 1. 在终端运行 codex-oauth-proxy 或 + codex-proxy,完成 ChatGPT 登录 + 2. 凭证文件保存在: + + + 已保存 + 已保存凭据 + 连接成功!余额: %1$s %2$s + 连接成功,凭据已保存 + API Key 不能为空 + JSON 格式错误 + 服务拒绝该凭据 (401/403) + 测试期间凭据已变更 + 请求过于频繁 (429) + 服务端错误 (%1$d) + 网络错误:%1$s + ,已恢复原凭据 + ,检测到凭据已变化,未执行回滚 + 测试失败。%1$s%2$s + 暂不支持测试此服务 + auth cookie 和 workspaceId 都需要填写 + Cookie 不能为空 + 凭据未配置 + 凭据未配置或已过期 + 凭据无效,请在设置中重新配置 + 凭据无效,请重新登录 + 凭据已变更,正在重新加载 + 凭据已变更,请重新操作 + 网络异常,请检查网络 + 网络异常 + 服务端异常 (HTTP %1$d) + 请求过于频繁%1$s + ,请 %1$d 秒后重试 + 不支持激活用量 + 请先选择模型 + 未抓到 Cookie,请确认已登录 + 未配置登录 URL + URL 应以 %1$s 开头 + 授权失败: %1$s + State 不匹配,请重试 + 回调缺少授权码 + Token 交换失败,请查看调试日志 + + + ✓ 用量已激活 + ✓ 激活成功 + 请求结果 + 错误: %1$s + 响应内容:\n%1$s + 模型: %1$s + 响应 ID: %1$s + 回复: + (空) + (无回复内容) + (响应解析失败,但请求已发出) + 用量: prompt=%1$s completion=%2$s total=%3$s tokens + 用量: input=%1$s output=%2$s tokens + token 刷新失败:%1$s + + + 登录 %1$s + 完成登录了吗? + 如果你已经在页面上完成登录但页面没有跳转,可点击下方按钮让我抓取 Cookie 并保存登录态。 + + + 暂无日志记录\n刷新 Codex 用量后这里会出现调试信息 + + + OpenCode Go · 用量 + DeepSeek 余额 + 自动刷新 + %1$s · 额度 + %1$s · %2$s + 更新 %1$s + 暂无数据 + 刷新中… + + + 长按卡片拖拽排序 + 长按首页卡片可以拖拽排序 + 在首页长按任意服务卡片即可拖拽到想要的位置,排序会自动保存。 + 小组件点左上角进 APP + 小组件左上角标识可打开 APP + 桌面小组件左上角 RainyToken › 标识可以点击打开应用,其它区域点击切换服务。 + 图表点击查看详情 + 点击图表柱子可查看时段详情 + 在用量图表上点击或滑动手指,可查看对应时段的详细数值。 + 图表标题可点击 + 点击图表标题弹出按模型明细 + 图表卡片标题(如「消耗金额」)点击后会弹出按模型拆分的明细。 + 切换图表时区 + 用量详情页可切换 UTC+0 / UTC+8 + 用量详情页顶部可切换 UTC+0 / UTC+8,图表标签会跟随切换。 + CCGO 清除并重新同步 + CCGO 详情页可清除缓存重新同步 + CCGO 用量详情页右上角「清除」可删除本地缓存并重新拉取全部数据。 + 查看用量详情 + 服务卡片底部可进入用量图表 + 有凭据的服务卡片底部会出现「查看用量详情」入口,点击进入图表统计。 + 原始数据浏览 + 用量详情页右上角可查看原始记录 + 用量详情页右上角「详细数据」可查看每条原始调用记录,支持分页跳转。 + 下拉刷新联动用量 + 下拉刷新会同步余额和用量 + 首页下拉刷新不仅刷新余额,还会自动同步 OCGO / CCGO 用量记录。 + 凭据获取教程 + 凭据页底部有获取教程 + 凭据编辑页底部的「如何获取」按钮有针对每个服务的详细操作步骤。 + 剪贴板自动导入 + Codex 支持从剪贴板一键导入 + Codex 凭据页支持「从剪贴板自动导入」,粘贴 auth.json 后一键填入。 + 详细数据页码跳转 + 原始数据页可输入页码直跳 + 原始数据页底部可输入页码直接跳转,数据量大时不必逐页翻。 + 小组件自动刷新 + 划到小组件会自动刷新余额 + 桌面小组件划到负一屏时会自动刷新余额,也可手动点 ↻ 按钮刷新。 + + + %1$d次 + 选择日期 + 选择月份(任意一天) + 开始日期 + 结束日期 + 消费明细 + 调用次数明细 + Token 明细 + 清除数据 + 此操作将清除所有 CommandCode 用量本地缓存并重新同步,需要几秒钟完成。 + 是否继续? + 确认(%1$ds) + 确认清除 + 关闭 + 从 %1$s + 至 %1$s + 应用自定义范围 + 确定 + 结束日期不能早于开始日期 %1$s + 时间 %1$s:%2$s + 暂无数据 + 合计: %1$s + 总计 + 记录数 + 缓存读取 + 缓存写入 + 缓存 Token 细分 + ※ 缓存读取已计入「输入 Token」 + %1$d 次调用 + MM月dd日 EEEE + %1$d 次 · $%2$s + 原始数据 + 缓存写入(5m) + 缓存写入(1h) + 成本(USD) + 成本(Raw) + + + ~/.config/codex-oauth-proxy/auth.json + 3. 用文本编辑器打开该文件, + 复制全部内容粘贴到上方输入框 + JSON 格式示例: + {"tokens": { + "access_token": "...", + "refresh_token": "...", + "expiresAt": 1783727108252 + }} + 提示:导入后 APP 会自动刷新 token, + 不需要手动更新。 + Ollama Pro 凭据 + 需要从浏览器复制完整 Cookie。登录 ollama.com/settings 后,在 DevTools → Network → 请求头里找到 Cookie 行复制。 + 更新凭据 + 保存凭据 + 测试并保存 + 在浏览器中打开 Ollama Settings + 复制登录 URL 到剪贴板 + 一键激活用量 API Key(可选) + 填入 Ollama Cloud API Key 后,可在详情页一键发送请求激活用量统计。从 ollama.com/settings → API Keys 获取。 + ollama-xxx 或 sk-xxx + 保存 API Key + OpenCode Go 凭据 + 需要 auth cookie + workspaceId 两个值。APP 会用 OkHttp 抓 dashboard 并自动解析 5h/周/月 三档用量。 + 从剪贴板自动导入 + dashboard URL 中的 {id} 段 + auth Cookie 值 + F12 → Cookies → opencode.ai → auth 的 Value + 如何获取这两个值? + 在浏览器中打开登录入口 + 填入 OpenCode Zen API Key 后,可在详情页一键发送请求激活用量统计。从 opencode.ai/settings → API Keys 获取。 + CommandCode Go 凭据 + 需要 API Key(拉余额)。Session Cookie(可选)在浏览器 DevTools 复制,用于拉用量记录。不填也可正常查看余额。 + 从 Settings → API Keys 生成 + Session Cookie(可选) + F12 → Cookies → commandcode.ai → 全选复制粘贴即可 + 如何获取 API Key 和 Cookie? + 请输入 API Key + 凭据会使用 Android Keystore (AES-256 GCM) 加密后存储到本机 DataStore。 + 更新 + Codex / ChatGPT Plus 凭据 + 推荐使用 OAuth 登录自动获取凭据。也可手动粘贴 auth.json 内容。 + 🔐 OAuth 登录(推荐) + ── 或手动导入 ── + auth.json 完整内容 + 粘贴 {"tokens": {...}} 完整 JSON + 导入并保存 + 如何获取 auth.json? + 粘贴登录态 + Google OAuth 会拒绝 APP 内置 WebView,所以请用浏览器登录后把 Cookie 粘到这里。 + Cookie 字符串 + 保存 Cookie + + + 数据解析失败: %1$s + 每周 + 模型调用次数 + 缓存数据 · %1$s + 显示的是上次成功获取的余额 + 本月消费 %1$s%2$s + 余额构成 + 赠送余额 + 自费充值 + 加载模型列表... + 选择模型 + 刷新模型列表 + 激活中... + 重试激活用量 + ⚡ 一键激活用量 + 刷新余额 + 重新登录 + 配置凭据 + 当前余额 + 5h 实时用量 + 月度余额 + Session 用量 + 请选择模型 + 错误详情: + API 响应内容: + 复制 + *** (长度 %1$d) *** + %1$s***%2$s (长度 %3$d) + Cookie 格式错误,应为 name1=value1; name2=value2 + 已保存 %1$d 个 Cookie + 请粘贴 auth.json 内容 + auth.json 缺少 access_token 或 refresh_token + 已保存 Codex 凭据,token 到期后会自动刷新 + 解析失败:%1$s + 连接成功!余额: $%1$s + API Key 已保存 + 连接成功!Session: %1$s%% · %2$s + 凭据已删除 + 剪贴板为空 + 已识别 workspaceId 和 auth cookie + 已识别 workspaceId,请再粘贴 auth cookie + 已识别 auth cookie,请再粘贴 workspaceId + 未识别到有效凭据 + + + Codex OAuth 登录 + 正在交换 Token... + 重试 + 无头模式登录 + 1. 点击「在浏览器中打开」或复制下方链接到浏览器\n2. 在 OpenAI 页面完成登录和授权\n3. 浏览器会跳转到一个 localhost 地址(页面打不开是正常的)\n4. 复制浏览器地址栏的完整 URL 粘贴到下方输入框\n5. 点击「提交回调 URL」 + 授权链接 + 在浏览器中打开 + 复制链接 + ── 登录后粘贴回调 URL ── + 回调 URL + http://localhost:1455/auth/callback?code=... + 提交回调 URL + 重新生成授权链接 + 正在准备授权... + \ 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 new file mode 100644 index 0000000..9ed8670 --- /dev/null +++ b/app/src/main/res/values-b+zh+Hant/strings.xml @@ -0,0 +1,489 @@ + + + + AI用量面板 + OpenCode Go 用量 + DeepSeek 餘額,自動重新整理 + 雨晴喵 + 雨晴Token + + + + + 返回 + 取消 + 確認新增 + 我知道了 + 重新整理 + 設定 + 刪除 + 清除 + 儲存 + 跳轉 + ◀ 上一頁 + 下一頁 ▶ + 新增小工具到桌面 + 語言 + 選擇語言 + 跟隨系統 + 簡體中文 + 繁體中文 + English + 檢視詳情 + 檢視用量詳情 + 詳細數據 + 同步用量數據 + 我已登入,儲存 + 繼續等待 + + + 載入中… + 未知錯誤 + 全部模型 + + + 👈 選擇服務檢視詳情 + 點選左側卡片即可 + 設定 + 憑據管理 + 關於 RainyToken + 使用小技巧 + 除錯日誌 + 用量詳情 + 總統計 + 詳細數據 + 統計圖表 + + + 未配置 + 已配置 + 已過期 + 需重登 + 重新整理失敗 + 未獲取 + 待獲取 + 正常 + 未配置 · 點選新增 + 已配置 · 憑據有效 + 已過期 · 點選重新配置 + 需要重新驗證 + 手動輸入模式 + 憑據已配置 + 未配置憑據 + 載入中 + 最新 + 快取 + 失敗 + 待輸入 + + + AI 餘額一覽 + 從未獲取 + 剛剛更新 + %1$d 分鐘前更新 + %1$d 小時前更新 + %1$s 更新 + 💡 長按卡片可拖曳排序 + ⚠ %1$s + + 本地加密儲存,不上傳任何數據 + 新增桌面小工具? + 確認後會請求桌面啟動器新增雨晴Token小工具。部分系統仍會彈出系統確認視窗。 + 桌面快捷方式權限已被拒絕,請在系統設定中允許後重試 + 桌面未接受新增請求,請檢查啟動器權限 + 當前桌面不支援應用內新增,請在桌面長按 → 小工具 → 查詢雨晴Token + + + 點選配置憑據 + 下拉重新整理 + 服務當前不可用 + 本月已用 %1$s%2$s + 剩餘 + · 已用 %1$s / 共 %2$s + %1$s 已用 + 5h 已用 + · %1$s + %1$s 後重置 + REST API · ¥ + WebView 抓取 · 5h 配額 + JSON API · $ + ChatGPT Plus · Codex 額度 + Cookie 抓取 · Cloud 配額 + + + 天 + 小時 + 分 + + + 5 小時 + 5h + 5h 用量 + 5 小時滾動 + 本週 + 本月 + 每月 + 用量 + 用量視窗%1$s + 用量視窗 + + + OpenCode 用量 + CommandCode 用量 + %1$d 條記錄 + 點選同步 + 輸入 Token + 輸出 Token + 推理 Token + 總花費 + 快取讀取 %1$s + 快取寫入 %1$s + 暫無數據,點選 🔄 同步 + ✓ 新增 %1$d 條 + 點選切換 + 所有時間按 UTC+0 計算 + 已切換到 UTC+8,圖表按北京時間顯示 + 已切換到 UTC+0,圖表按世界協調時顯示 + 已用 $%1$s / 共 $%2$s + 剩餘 $%1$s + 額外充值 + 按模型統計 + 按天統計(UTC+0) + %1$d 個模型 + %1$d 個 + 開始時間 + 結束時間 + + + 消耗金額 (USD) + API 請求次數 + Token 消耗 + 輸出 + 輸入(未命中) + 命中快取 + + + 時間 + 模型 + 輸入(快取) + 輸出 + 成本 + + + 全部 + 最近5小時 + 最近12小時 + 最近24小時 + 今天 + 昨天 + 最近7天 + 最近30天 + 當月 + 自定義 + 自定義天 + 自定義月 + 自定義時間 + + + 統一查詢 AI 餘額的小工具。\n本地加密儲存憑據,不上傳任何數據。 + 檢視全部 %1$d 條隱藏操作 + 檢視 token 重新整理、網路請求等除錯記錄 + + + ✓ 已配置登入態(%1$d 個 Cookie) + 如何獲取 Cookie? + 1. 在桌面 Chrome 開啟下方登入入口 + 2. 用 GitHub / Google 登入(不會被攔截) + 3. 登入後按 F12 → Application → Cookies + 4. 複製該站點下的所有 Cookie 貼上到上面輸入框 + 格式:name1=value1; name2=value2 + 如何獲取 OpenCode Go 憑據? + 1. 桌面 Chrome 開啟 opencode.ai/auth + 2. 用 GitHub / Google 登入(Google 不會攔截 Chrome) + 3. 登入後跳轉到 dashboard,URL 形如: + https://opencode.ai/workspace/{id}/go + 4. 複製 URL 中的 {id} 段 → workspaceId + 5. F12 → Application → Cookies + 6. 找到域名 opencode.ai 下名為 auth 的 Cookie + 7. 複製它的 Value 欄位 → auth cookie + 注意:只填 auth 一個 Cookie 即可,不要整個 Cookie 字串 + 如何獲取 CommandCode Go 憑據? + 1. 開啟 https://commandcode.ai 並登入 GitHub + ▸ 獲取 API Key(必填): + 右上角頭像 → Settings → API Keys → 建立一個 Key + ▸ 獲取全部 Cookie(用於用量記錄): + F12 → Application → Cookies → commandcode.ai + 選中所有 Cookie,複製整個 Cookie 字串 + 貼上到下方輸入框即可,無需逐個挑選 + 提示:不填 Cookie 也能正常檢視餘額,只是用量記錄為空。 + 如何獲取 Codex auth.json? + 1. 在終端執行 codex-oauth-proxy 或 + codex-proxy,完成 ChatGPT 登入 + 2. 憑證檔案儲存在: + + + 已儲存 + 已儲存憑據 + 連線成功!餘額: %1$s %2$s + 連線成功,憑據已儲存 + API Key 不能為空 + JSON 格式錯誤 + 服務拒絕該憑據 (401/403) + 測試期間憑據已變更 + 請求過於頻繁 (429) + 服務端錯誤 (%1$d) + 網路錯誤:%1$s + ,已恢復原憑據 + ,檢測到憑據已變化,未執行回滾 + 測試失敗。%1$s%2$s + 暫不支援測試此服務 + auth cookie 和 workspaceId 都需要填寫 + Cookie 不能為空 + 憑據未配置 + 憑據未配置或已過期 + 憑據無效,請在設定中重新配置 + 憑據無效,請重新登入 + 憑據已變更,正在重新載入 + 憑據已變更,請重新操作 + 網路異常,請檢查網路 + 網路異常 + 服務端異常 (HTTP %1$d) + 請求過於頻繁%1$s + ,請 %1$d 秒後重試 + 不支援啟用用量 + 請先選擇模型 + 未抓到 Cookie,請確認已登入 + 未配置登入 URL + URL 應以 %1$s 開頭 + 授權失敗: %1$s + State 不匹配,請重試 + 回撥缺少授權碼 + Token 交換失敗,請檢視除錯日誌 + + + ✓ 用量已啟用 + ✓ 啟用成功 + 請求結果 + 錯誤: %1$s + 響應內容:\n%1$s + 模型: %1$s + 響應 ID: %1$s + 回覆: + (空) + (無回覆內容) + (響應解析失敗,但請求已發出) + 用量: prompt=%1$s completion=%2$s total=%3$s tokens + 用量: input=%1$s output=%2$s tokens + token 重新整理失敗:%1$s + + + 登入 %1$s + 完成登入了嗎? + 如果你已經在頁面上完成登入但頁面沒有跳轉,可點選下方按鈕讓我抓取 Cookie 並儲存登入態。 + + + 暫無日誌記錄\n重新整理 Codex 用量後這裡會出現除錯資訊 + + + OpenCode Go · 用量 + DeepSeek 餘額 + 自動重新整理 + %1$s · 額度 + %1$s · %2$s + 更新 %1$s + 暫無數據 + 重新整理中… + + + 長按卡片拖曳排序 + 長按首頁卡片可以拖曳排序 + 在首頁長按任意服務卡片即可拖曳到想要的位置,排序會自動儲存。 + 小工具點左上角進 APP + 小工具左上角標識可開啟 APP + 桌面小工具左上角 RainyToken › 標識可以點選開啟應用,其它區域點選切換服務。 + 圖表點選檢視詳情 + 點選圖表柱子可檢視時段詳情 + 在用量圖表上點選或滑動手指,可檢視對應時段的詳細數值。 + 圖表標題可點選 + 點選圖表標題彈出按模型明細 + 圖表卡片標題(如「消耗金額」)點選後會彈出按模型拆分的明細。 + 切換圖表時區 + 用量詳情頁可切換 UTC+0 / UTC+8 + 用量詳情頁頂部可切換 UTC+0 / UTC+8,圖表標籤會跟隨切換。 + CCGO 清除並重新同步 + CCGO 詳情頁可清除快取重新同步 + CCGO 用量詳情頁右上角「清除」可刪除本地快取並重新拉取全部數據。 + 檢視用量詳情 + 服務卡片底部可進入用量圖表 + 有憑據的服務卡片底部會出現「檢視用量詳情」入口,點選進入圖表統計。 + 原始數據瀏覽 + 用量詳情頁右上角可檢視原始記錄 + 用量詳情頁右上角「詳細數據」可檢視每條原始呼叫記錄,支援分頁跳轉。 + 下拉重新整理聯動用量 + 下拉重新整理會同步餘額和用量 + 首頁下拉重新整理不僅重新整理餘額,還會自動同步 OCGO / CCGO 用量記錄。 + 憑據獲取教程 + 憑據頁底部有獲取教程 + 憑據編輯頁底部的「如何獲取」按鈕有針對每個服務的詳細操作步驟。 + 剪貼簿自動匯入 + Codex 支援從剪貼簿一鍵匯入 + Codex 憑據頁支援「從剪貼簿自動匯入」,貼上 auth.json 後一鍵填入。 + 詳細數據頁碼跳轉 + 原始數據頁可輸入頁碼直跳 + 原始數據頁底部可輸入頁碼直接跳轉,數據量大時不必逐頁翻。 + 小工具自動重新整理 + 劃到小工具會自動重新整理餘額 + 桌面小工具劃到負一屏時會自動重新整理餘額,也可手動點 ↻ 按鈕重新整理。 + + + %1$d次 + 選擇日期 + 選擇月份(任意一天) + 開始日期 + 結束日期 + 消費明細 + 呼叫次數明細 + Token 明細 + 清除數據 + 此操作將清除所有 CommandCode 用量本地快取並重新同步,需要幾秒鐘完成。 + 是否繼續? + 確認(%1$ds) + 確認清除 + 關閉 + 從 %1$s + 至 %1$s + 應用自定義範圍 + 確定 + 結束日期不能早於開始日期 %1$s + 時間 %1$s:%2$s + 暫無數據 + 合計: %1$s + 總計 + 記錄數 + 快取讀取 + 快取寫入 + 快取 Token 細分 + ※ 快取讀取已計入「輸入 Token」 + %1$d 次呼叫 + MM月dd日 EEEE + %1$d 次 · $%2$s + 原始數據 + 快取寫入(5m) + 快取寫入(1h) + 成本(USD) + 成本(Raw) + + + ~/.config/codex-oauth-proxy/auth.json + 3. 用文字編輯器開啟該檔案, + 複製全部內容貼上到上方輸入框 + JSON 格式示例: + {"tokens": { + "access_token": "...", + "refresh_token": "...", + "expiresAt": 1783727108252 + }} + 提示:匯入後 APP 會自動重新整理 token, + 不需要手動更新。 + Ollama Pro 憑據 + 需要從瀏覽器複製完整 Cookie。登入 ollama.com/settings 後,在 DevTools → Network → 請求頭裡找到 Cookie 行複製。 + 更新憑據 + 儲存憑據 + 測試並儲存 + 在瀏覽器中開啟 Ollama Settings + 複製登入 URL 到剪貼簿 + 一鍵啟用用量 API Key(可選) + 填入 Ollama Cloud API Key 後,可在詳情頁一鍵傳送請求啟用用量統計。從 ollama.com/settings → API Keys 獲取。 + ollama-xxx 或 sk-xxx + 儲存 API Key + OpenCode Go 憑據 + 需要 auth cookie + workspaceId 兩個值。APP 會用 OkHttp 抓 dashboard 並自動解析 5h/周/月 三檔用量。 + 從剪貼簿自動匯入 + dashboard URL 中的 {id} 段 + auth Cookie 值 + F12 → Cookies → opencode.ai → auth 的 Value + 如何獲取這兩個值? + 在瀏覽器中開啟登入入口 + 填入 OpenCode Zen API Key 後,可在詳情頁一鍵傳送請求啟用用量統計。從 opencode.ai/settings → API Keys 獲取。 + CommandCode Go 憑據 + 需要 API Key(拉餘額)。Session Cookie(可選)在瀏覽器 DevTools 複製,用於拉用量記錄。不填也可正常檢視餘額。 + 從 Settings → API Keys 生成 + Session Cookie(可選) + F12 → Cookies → commandcode.ai → 全選複製貼上即可 + 如何獲取 API Key 和 Cookie? + 請輸入 API Key + 憑據會使用 Android Keystore (AES-256 GCM) 加密後儲存到本機 DataStore。 + 更新 + Codex / ChatGPT Plus 憑據 + 推薦使用 OAuth 登入自動獲取憑據。也可手動貼上 auth.json 內容。 + 🔐 OAuth 登入(推薦) + ── 或手動匯入 ── + auth.json 完整內容 + 貼上 {"tokens": {...}} 完整 JSON + 匯入並儲存 + 如何獲取 auth.json? + 貼上登入態 + Google OAuth 會拒絕 APP 內建 WebView,所以請用瀏覽器登入後把 Cookie 粘到這裡。 + Cookie 字串 + 儲存 Cookie + + + 數據解析失敗: %1$s + 每週 + 模型呼叫次數 + 快取數據 · %1$s + 顯示的是上次成功獲取的餘額 + 本月消費 %1$s%2$s + 餘額構成 + 贈送餘額 + 自費充值 + 載入模型列表... + 選擇模型 + 重新整理模型列表 + 啟用中... + 重試啟用用量 + ⚡ 一鍵啟用用量 + 重新整理餘額 + 重新登入 + 配置憑據 + 當前餘額 + 5h 實時用量 + 月度餘額 + Session 用量 + 請選擇模型 + 錯誤詳情: + API 響應內容: + 複製 + *** (長度 %1$d) *** + %1$s***%2$s (長度 %3$d) + Cookie 格式錯誤,應為 name1=value1; name2=value2 + 已儲存 %1$d 個 Cookie + 請貼上 auth.json 內容 + auth.json 缺少 access_token 或 refresh_token + 已儲存 Codex 憑據,token 到期後會自動重新整理 + 解析失敗:%1$s + 連線成功!餘額: $%1$s + API Key 已儲存 + 連線成功!Session: %1$s%% · %2$s + 憑據已刪除 + 剪貼簿為空 + 已識別 workspaceId 和 auth cookie + 已識別 workspaceId,請再貼上 auth cookie + 已識別 auth cookie,請再貼上 workspaceId + 未識別到有效憑據 + + + Codex OAuth 登入 + 正在交換 Token... + 重試 + 無頭模式登入 + 1. 點選「在瀏覽器中開啟」或複製下方連結到瀏覽器\n2. 在 OpenAI 頁面完成登入和授權\n3. 瀏覽器會跳轉到一個 localhost 地址(頁面打不開是正常的)\n4. 複製瀏覽器位址列的完整 URL 貼上到下方輸入框\n5. 點選「提交回撥 URL」 + 授權連結 + 在瀏覽器中開啟 + 複製連結 + ── 登入後貼上回撥 URL ── + 回撥 URL + http://localhost:1455/auth/callback?code=... + 提交回撥 URL + 重新生成授權連結 + 正在準備授權... + \ 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 81830e8..67546ab 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,14 +1,16 @@ -雨晴Token -雨晴喵 -RainyToken -RainyToken · v1.0 + RainyToken + RainyToken · v1.0 - + + RainyToken + + AI Usage Panel OpenCode Go usage + DeepSeek balance, auto-refresh + Rainy Back @@ -24,6 +26,12 @@ ◀ Prev Next ▶ Add widget to home screen + Language + Select language + Follow system + 简体中文 + 繁體中文 + English View details View usage details Raw data @@ -114,8 +122,8 @@ 5h 5h usage 5-hour rolling - This week - This month + Week + Month Monthly Usage Usage window%1$s From b51644048d80256219a2142f5c610c7f365a7b08 Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:43:05 +0000 Subject: [PATCH 07/14] =?UTF-8?q?fix:=20widget=20=E7=94=A8=E9=87=8F?= =?UTF-8?q?=E6=9D=A1=E5=B7=A6=E5=AF=B9=E9=BD=90=EF=BC=88label=20=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=2044dp=EF=BC=89+=20=E7=BD=B2=E5=90=8D=E5=8A=A8?= =?UTF-8?q?=E6=80=81=E5=8C=96=E8=B7=9F=E9=9A=8F=E5=BA=94=E7=94=A8=E5=86=85?= =?UTF-8?q?=E8=AF=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 三套布局三行 label 统一 44dp:此前 row2/3 为 60dp、row1 44dp, 进度条起点差 16dp 导致不对齐;英文标签简化为 Week/Month 后 44dp 足够 - 署名 TextView 补 @+id/widget_brand,Provider 动态 setTextViewText: 与行标签同源(跟随应用内语言),避免宿主进程按系统语言渲染混搭 --- .../com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt | 4 ++++ app/src/main/res/layout-night/widget_opencode_go.xml | 5 +++-- app/src/main/res/layout/widget_opencode_go.xml | 5 +++-- app/src/main/res/layout/widget_opencode_go_preview.xml | 4 ++-- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt b/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt index 4de4a05..f9f3dfb 100644 --- a/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt +++ b/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt @@ -60,6 +60,10 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() { for (widgetId in appWidgetIds) { val views = RemoteViews(context.packageName, R.layout.widget_opencode_go) + // 署名动态设置:与行标签同源(跟随应用内语言), + // 避免 XML 静态文本由宿主进程按系统语言渲染造成中英混搭 + views.setTextViewText(R.id.widget_brand, context.getString(R.string.widget_brand)) + // 点击左上角品牌 → 打开 APP;点击其它内容区域 → 切换服务;刷新按钮单独刷新。 val openAppIntent = Intent(context, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP diff --git a/app/src/main/res/layout-night/widget_opencode_go.xml b/app/src/main/res/layout-night/widget_opencode_go.xml index 43df9ea..01e6fa9 100644 --- a/app/src/main/res/layout-night/widget_opencode_go.xml +++ b/app/src/main/res/layout-night/widget_opencode_go.xml @@ -137,7 +137,7 @@ android:gravity="center_vertical" android:layout_marginBottom="3dp"> @@ -178,7 +178,7 @@ android:layout_marginBottom="3dp"> @@ -252,6 +252,7 @@ android:indeterminate="false" /> @@ -129,7 +129,7 @@ android:layout_marginBottom="8dp"> From 5e7942c5cc9b54c80d6880b008c1683bfd2a145b Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:43:37 +0000 Subject: [PATCH 08/14] =?UTF-8?q?chore:=20=E7=A7=BB=E9=99=A4=E5=B7=B2?= =?UTF-8?q?=E8=BF=81=E7=A7=BB=E7=9A=84=20values-zh=EF=BC=88=E7=AE=80?= =?UTF-8?q?=E4=BD=93=E8=B5=84=E6=BA=90=E5=B7=B2=E8=BF=81=E8=87=B3=20values?= =?UTF-8?q?-b+zh+Hans=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/res/values-zh/strings.xml | 481 ------------------------- 1 file changed, 481 deletions(-) delete mode 100644 app/src/main/res/values-zh/strings.xml diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml deleted file mode 100644 index cc3d234..0000000 --- a/app/src/main/res/values-zh/strings.xml +++ /dev/null @@ -1,481 +0,0 @@ - - - - AI用量面板 - OpenCode Go 用量 + DeepSeek 余额,自动刷新 - - - - - 返回 - 取消 - 确认添加 - 我知道了 - 刷新 - 设置 - 删除 - 清除 - 保存 - 跳转 - ◀ 上一页 - 下一页 ▶ - 添加小组件到桌面 - 查看详情 - 查看用量详情 - 详细数据 - 同步用量数据 - 我已登录,保存 - 继续等待 - - - 加载中… - 未知错误 - 全部模型 - - - 👈 选择服务查看详情 - 点击左侧卡片即可 - 设置 - 凭据管理 - 关于 RainyToken - 使用小技巧 - 调试日志 - 用量详情 - 总统计 - 详细数据 - 统计图表 - - - 未配置 - 已配置 - 已过期 - 需重登 - 刷新失败 - 未获取 - 待获取 - 正常 - 未配置 · 点击添加 - 已配置 · 凭据有效 - 已过期 · 点击重新配置 - 需要重新验证 - 手动输入模式 - 凭据已配置 - 未配置凭据 - 加载中 - 最新 - 缓存 - 失败 - 待输入 - - - AI 余额一览 - 从未获取 - 刚刚更新 - %1$d 分钟前更新 - %1$d 小时前更新 - %1$s 更新 - 💡 长按卡片可拖拽排序 - ⚠ %1$s - - 本地加密存储,不上传任何数据 - 添加桌面小组件? - 确认后会请求桌面启动器添加雨晴Token小组件。部分系统仍会弹出系统确认窗口。 - 桌面快捷方式权限已被拒绝,请在系统设置中允许后重试 - 桌面未接受添加请求,请检查启动器权限 - 当前桌面不支持应用内添加,请在桌面长按 → 小组件 → 查找雨晴Token - - - 点击配置凭据 - 下拉刷新 - 服务当前不可用 - 本月已用 %1$s%2$s - 剩余 - · 已用 %1$s / 共 %2$s - %1$s 已用 - 5h 已用 - · %1$s - %1$s 后重置 - REST API · ¥ - WebView 抓取 · 5h 配额 - JSON API · $ - ChatGPT Plus · Codex 额度 - Cookie 抓取 · Cloud 配额 - - - 天 - 小时 - 分 - - - 5 小时 - 5h - 5h 用量 - 5 小时滚动 - 本周 - 本月 - 每月 - 用量 - 用量窗口%1$s - 用量窗口 - - - OpenCode 用量 - CommandCode 用量 - %1$d 条记录 - 点击同步 - 输入 Token - 输出 Token - 推理 Token - 总花费 - 缓存读取 %1$s - 缓存写入 %1$s - 暂无数据,点击 🔄 同步 - ✓ 新增 %1$d 条 - 点击切换 - 所有时间按 UTC+0 计算 - 已切换到 UTC+8,图表按北京时间显示 - 已切换到 UTC+0,图表按世界协调时显示 - 已用 $%1$s / 共 $%2$s - 剩余 $%1$s - 额外充值 - 按模型统计 - 按天统计(UTC+0) - %1$d 个模型 - %1$d 个 - 开始时间 - 结束时间 - - - 消耗金额 (USD) - API 请求次数 - Token 消耗 - 输出 - 输入(未命中) - 命中缓存 - - - 时间 - 模型 - 输入(缓存) - 输出 - 成本 - - - 全部 - 最近5小时 - 最近12小时 - 最近24小时 - 今天 - 昨天 - 最近7天 - 最近30天 - 当月 - 自定义 - 自定义天 - 自定义月 - 自定义时间 - - - 统一查询 AI 余额的小工具。\n本地加密存储凭据,不上传任何数据。 - 查看全部 %1$d 条隐藏操作 - 查看 token 刷新、网络请求等调试记录 - - - ✓ 已配置登录态(%1$d 个 Cookie) - 如何获取 Cookie? - 1. 在桌面 Chrome 打开下方登录入口 - 2. 用 GitHub / Google 登录(不会被拦截) - 3. 登录后按 F12 → Application → Cookies - 4. 复制该站点下的所有 Cookie 粘贴到上面输入框 - 格式:name1=value1; name2=value2 - 如何获取 OpenCode Go 凭据? - 1. 桌面 Chrome 打开 opencode.ai/auth - 2. 用 GitHub / Google 登录(Google 不会拦截 Chrome) - 3. 登录后跳转到 dashboard,URL 形如: - https://opencode.ai/workspace/{id}/go - 4. 复制 URL 中的 {id} 段 → workspaceId - 5. F12 → Application → Cookies - 6. 找到域名 opencode.ai 下名为 auth 的 Cookie - 7. 复制它的 Value 字段 → auth cookie - 注意:只填 auth 一个 Cookie 即可,不要整个 Cookie 字符串 - 如何获取 CommandCode Go 凭据? - 1. 打开 https://commandcode.ai 并登录 GitHub - ▸ 获取 API Key(必填): - 右上角头像 → Settings → API Keys → 创建一个 Key - ▸ 获取全部 Cookie(用于用量记录): - F12 → Application → Cookies → commandcode.ai - 选中所有 Cookie,复制整个 Cookie 字符串 - 粘贴到下方输入框即可,无需逐个挑选 - 提示:不填 Cookie 也能正常查看余额,只是用量记录为空。 - 如何获取 Codex auth.json? - 1. 在终端运行 codex-oauth-proxy 或 - codex-proxy,完成 ChatGPT 登录 - 2. 凭证文件保存在: - - - 已保存 - 已保存凭据 - 连接成功!余额: %1$s %2$s - 连接成功,凭据已保存 - API Key 不能为空 - JSON 格式错误 - 服务拒绝该凭据 (401/403) - 测试期间凭据已变更 - 请求过于频繁 (429) - 服务端错误 (%1$d) - 网络错误:%1$s - ,已恢复原凭据 - ,检测到凭据已变化,未执行回滚 - 测试失败。%1$s%2$s - 暂不支持测试此服务 - auth cookie 和 workspaceId 都需要填写 - Cookie 不能为空 - 凭据未配置 - 凭据未配置或已过期 - 凭据无效,请在设置中重新配置 - 凭据无效,请重新登录 - 凭据已变更,正在重新加载 - 凭据已变更,请重新操作 - 网络异常,请检查网络 - 网络异常 - 服务端异常 (HTTP %1$d) - 请求过于频繁%1$s - ,请 %1$d 秒后重试 - 不支持激活用量 - 请先选择模型 - 未抓到 Cookie,请确认已登录 - 未配置登录 URL - URL 应以 %1$s 开头 - 授权失败: %1$s - State 不匹配,请重试 - 回调缺少授权码 - Token 交换失败,请查看调试日志 - - - ✓ 用量已激活 - ✓ 激活成功 - 请求结果 - 错误: %1$s - 响应内容:\n%1$s - 模型: %1$s - 响应 ID: %1$s - 回复: - (空) - (无回复内容) - (响应解析失败,但请求已发出) - 用量: prompt=%1$s completion=%2$s total=%3$s tokens - 用量: input=%1$s output=%2$s tokens - token 刷新失败:%1$s - - - 登录 %1$s - 完成登录了吗? - 如果你已经在页面上完成登录但页面没有跳转,可点击下方按钮让我抓取 Cookie 并保存登录态。 - - - 暂无日志记录\n刷新 Codex 用量后这里会出现调试信息 - - - OpenCode Go · 用量 - DeepSeek 余额 - 自动刷新 - %1$s · 额度 - %1$s · %2$s - 更新 %1$s - 暂无数据 - 刷新中… - - - 长按卡片拖拽排序 - 长按首页卡片可以拖拽排序 - 在首页长按任意服务卡片即可拖拽到想要的位置,排序会自动保存。 - 小组件点左上角进 APP - 小组件左上角标识可打开 APP - 桌面小组件左上角 RainyToken › 标识可以点击打开应用,其它区域点击切换服务。 - 图表点击查看详情 - 点击图表柱子可查看时段详情 - 在用量图表上点击或滑动手指,可查看对应时段的详细数值。 - 图表标题可点击 - 点击图表标题弹出按模型明细 - 图表卡片标题(如「消耗金额」)点击后会弹出按模型拆分的明细。 - 切换图表时区 - 用量详情页可切换 UTC+0 / UTC+8 - 用量详情页顶部可切换 UTC+0 / UTC+8,图表标签会跟随切换。 - CCGO 清除并重新同步 - CCGO 详情页可清除缓存重新同步 - CCGO 用量详情页右上角「清除」可删除本地缓存并重新拉取全部数据。 - 查看用量详情 - 服务卡片底部可进入用量图表 - 有凭据的服务卡片底部会出现「查看用量详情」入口,点击进入图表统计。 - 原始数据浏览 - 用量详情页右上角可查看原始记录 - 用量详情页右上角「详细数据」可查看每条原始调用记录,支持分页跳转。 - 下拉刷新联动用量 - 下拉刷新会同步余额和用量 - 首页下拉刷新不仅刷新余额,还会自动同步 OCGO / CCGO 用量记录。 - 凭据获取教程 - 凭据页底部有获取教程 - 凭据编辑页底部的「如何获取」按钮有针对每个服务的详细操作步骤。 - 剪贴板自动导入 - Codex 支持从剪贴板一键导入 - Codex 凭据页支持「从剪贴板自动导入」,粘贴 auth.json 后一键填入。 - 详细数据页码跳转 - 原始数据页可输入页码直跳 - 原始数据页底部可输入页码直接跳转,数据量大时不必逐页翻。 - 小组件自动刷新 - 划到小组件会自动刷新余额 - 桌面小组件划到负一屏时会自动刷新余额,也可手动点 ↻ 按钮刷新。 - - - %1$d次 - 选择日期 - 选择月份(任意一天) - 开始日期 - 结束日期 - 消费明细 - 调用次数明细 - Token 明细 - 清除数据 - 此操作将清除所有 CommandCode 用量本地缓存并重新同步,需要几秒钟完成。 - 是否继续? - 确认(%1$ds) - 确认清除 - 关闭 - 从 %1$s - 至 %1$s - 应用自定义范围 - 确定 - 结束日期不能早于开始日期 %1$s - 时间 %1$s:%2$s - 暂无数据 - 合计: %1$s - 总计 - 记录数 - 缓存读取 - 缓存写入 - 缓存 Token 细分 - ※ 缓存读取已计入「输入 Token」 - %1$d 次调用 - MM月dd日 EEEE - %1$d 次 · $%2$s - 原始数据 - 缓存写入(5m) - 缓存写入(1h) - 成本(USD) - 成本(Raw) - - - ~/.config/codex-oauth-proxy/auth.json - 3. 用文本编辑器打开该文件, - 复制全部内容粘贴到上方输入框 - JSON 格式示例: - {"tokens": { - "access_token": "...", - "refresh_token": "...", - "expiresAt": 1783727108252 - }} - 提示:导入后 APP 会自动刷新 token, - 不需要手动更新。 - Ollama Pro 凭据 - 需要从浏览器复制完整 Cookie。登录 ollama.com/settings 后,在 DevTools → Network → 请求头里找到 Cookie 行复制。 - 更新凭据 - 保存凭据 - 测试并保存 - 在浏览器中打开 Ollama Settings - 复制登录 URL 到剪贴板 - 一键激活用量 API Key(可选) - 填入 Ollama Cloud API Key 后,可在详情页一键发送请求激活用量统计。从 ollama.com/settings → API Keys 获取。 - ollama-xxx 或 sk-xxx - 保存 API Key - OpenCode Go 凭据 - 需要 auth cookie + workspaceId 两个值。APP 会用 OkHttp 抓 dashboard 并自动解析 5h/周/月 三档用量。 - 从剪贴板自动导入 - dashboard URL 中的 {id} 段 - auth Cookie 值 - F12 → Cookies → opencode.ai → auth 的 Value - 如何获取这两个值? - 在浏览器中打开登录入口 - 填入 OpenCode Zen API Key 后,可在详情页一键发送请求激活用量统计。从 opencode.ai/settings → API Keys 获取。 - CommandCode Go 凭据 - 需要 API Key(拉余额)。Session Cookie(可选)在浏览器 DevTools 复制,用于拉用量记录。不填也可正常查看余额。 - 从 Settings → API Keys 生成 - Session Cookie(可选) - F12 → Cookies → commandcode.ai → 全选复制粘贴即可 - 如何获取 API Key 和 Cookie? - 请输入 API Key - 凭据会使用 Android Keystore (AES-256 GCM) 加密后存储到本机 DataStore。 - 更新 - Codex / ChatGPT Plus 凭据 - 推荐使用 OAuth 登录自动获取凭据。也可手动粘贴 auth.json 内容。 - 🔐 OAuth 登录(推荐) - ── 或手动导入 ── - auth.json 完整内容 - 粘贴 {"tokens": {...}} 完整 JSON - 导入并保存 - 如何获取 auth.json? - 粘贴登录态 - Google OAuth 会拒绝 APP 内置 WebView,所以请用浏览器登录后把 Cookie 粘到这里。 - Cookie 字符串 - 保存 Cookie - - - 数据解析失败: %1$s - 每周 - 模型调用次数 - 缓存数据 · %1$s - 显示的是上次成功获取的余额 - 本月消费 %1$s%2$s - 余额构成 - 赠送余额 - 自费充值 - 加载模型列表... - 选择模型 - 刷新模型列表 - 激活中... - 重试激活用量 - ⚡ 一键激活用量 - 刷新余额 - 重新登录 - 配置凭据 - 当前余额 - 5h 实时用量 - 月度余额 - Session 用量 - 请选择模型 - 错误详情: - API 响应内容: - 复制 - *** (长度 %1$d) *** - %1$s***%2$s (长度 %3$d) - Cookie 格式错误,应为 name1=value1; name2=value2 - 已保存 %1$d 个 Cookie - 请粘贴 auth.json 内容 - auth.json 缺少 access_token 或 refresh_token - 已保存 Codex 凭据,token 到期后会自动刷新 - 解析失败:%1$s - 连接成功!余额: $%1$s - API Key 已保存 - 连接成功!Session: %1$s%% · %2$s - 凭据已删除 - 剪贴板为空 - 已识别 workspaceId 和 auth cookie - 已识别 workspaceId,请再粘贴 auth cookie - 已识别 auth cookie,请再粘贴 workspaceId - 未识别到有效凭据 - - - Codex OAuth 登录 - 正在交换 Token... - 重试 - 无头模式登录 - 1. 点击「在浏览器中打开」或复制下方链接到浏览器\n2. 在 OpenAI 页面完成登录和授权\n3. 浏览器会跳转到一个 localhost 地址(页面打不开是正常的)\n4. 复制浏览器地址栏的完整 URL 粘贴到下方输入框\n5. 点击「提交回调 URL」 - 授权链接 - 在浏览器中打开 - 复制链接 - ── 登录后粘贴回调 URL ── - 回调 URL - http://localhost:1455/auth/callback?code=... - 提交回调 URL - 重新生成授权链接 - 正在准备授权... - \ No newline at end of file From f463751fb16787218f127743b83f78d64cc9f8ad Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Sat, 1 Aug 2026 20:32:27 +0000 Subject: [PATCH 09/14] =?UTF-8?q?fix:=20=E6=97=A5=E6=9C=9F=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F=E4=B8=8E=E5=BA=94=E7=94=A8=E5=86=85=E8=AF=AD=E8=A8=80?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=20+=20DeepSeek=20=E6=A0=87=E7=AD=BE=E5=8A=A8?= =?UTF-8?q?=E6=80=81=E5=8C=96=EF=BC=88Codex=20review=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/rainy/token/ui/dashboard/UsageOverviewScreen.kt | 8 ++++++-- .../com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt | 3 +++ app/src/main/res/layout-night/widget_opencode_go.xml | 1 + app/src/main/res/layout/widget_opencode_go.xml | 1 + 4 files changed, 11 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/UsageOverviewScreen.kt b/app/src/main/java/com/rainy/token/ui/dashboard/UsageOverviewScreen.kt index 03db324..463adda 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/UsageOverviewScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/UsageOverviewScreen.kt @@ -40,6 +40,7 @@ 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.LocalConfiguration import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -257,8 +258,11 @@ fun UsageOverviewScreen( @Composable private fun DailyDetailRow(day: com.rainy.token.data.local.DailyStats) { val datePattern = stringResource(R.string.date_format_md) - // 跟随系统语言(Locale.getDefault()),保证英文环境下日期符号与资源同语言 - val utcFmt = remember(datePattern, Locale.getDefault()) { SimpleDateFormat(datePattern, Locale.getDefault()).apply { timeZone = java.util.TimeZone.getTimeZone("UTC") } } + // 从当前资源 configuration 取 locale(跟随应用内语言切换), + // 不能依赖 Locale.getDefault():LocaleManager 只覆写 activity Configuration,不改变 JVM 默认 locale + val configuration = LocalConfiguration.current + val activeLocale = remember(configuration) { configuration.locales[0] } + val utcFmt = remember(datePattern, activeLocale) { SimpleDateFormat(datePattern, activeLocale).apply { timeZone = java.util.TimeZone.getTimeZone("UTC") } } Card(Modifier.fillMaxWidth(), RoundedCornerShape(12.dp), CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) { Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) { Text(utcFmt.format(Date(day.dayTs)), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f)) diff --git a/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt b/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt index f9f3dfb..99d974d 100644 --- a/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt +++ b/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt @@ -64,6 +64,9 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() { // 避免 XML 静态文本由宿主进程按系统语言渲染造成中英混搭 views.setTextViewText(R.id.widget_brand, context.getString(R.string.widget_brand)) + // DeepSeek 余额标签同样动态设置(静态 XML 文本由 Launcher 按系统语言解析) + views.setTextViewText(R.id.widget_ds_label, context.getString(R.string.widget_deepseek_balance)) + // 点击左上角品牌 → 打开 APP;点击其它内容区域 → 切换服务;刷新按钮单独刷新。 val openAppIntent = Intent(context, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP diff --git a/app/src/main/res/layout-night/widget_opencode_go.xml b/app/src/main/res/layout-night/widget_opencode_go.xml index 01e6fa9..a686d54 100644 --- a/app/src/main/res/layout-night/widget_opencode_go.xml +++ b/app/src/main/res/layout-night/widget_opencode_go.xml @@ -195,6 +195,7 @@ android:adjustViewBounds="true" android:layout_marginEnd="5dp" /> Date: Sun, 2 Aug 2026 00:12:35 +0000 Subject: [PATCH 10/14] =?UTF-8?q?fix:=20=E4=B8=8E=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E5=BA=94=E7=94=A8=E8=AF=AD=E8=A8=80=E9=A1=B5=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=EF=BC=88framework=20=E5=94=AF=E4=B8=80=E6=9D=83=E5=A8=81?= =?UTF-8?q?=E6=BA=90=EF=BC=89+=20widget=20=E6=96=87=E6=9C=AC=E8=B7=9F?= =?UTF-8?q?=E9=9A=8F=E6=9C=80=E6=96=B0=E5=81=8F=E5=A5=BD=EF=BC=88Codex=20r?= =?UTF-8?q?eview=20+=20=E5=AE=A1=E8=AE=A1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../rainy/token/ui/settings/SettingsScreen.kt | 8 +++- .../ui/widget/OpenCodeGoWidgetProvider.kt | 25 +++++++---- .../com/rainy/token/util/LocaleManager.kt | 45 ++++++++++++++++++- 3 files changed, 66 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt b/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt index 8ad5f32..da5d181 100644 --- a/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt @@ -1,6 +1,7 @@ package com.rainy.token.ui.settings import android.app.Activity +import android.os.Build import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -230,8 +231,11 @@ private fun LanguageDialog(onDismiss: () -> Unit) { .clickable { LocaleManager.saveLocale(context, code) onDismiss() - // 重建 Activity 让资源按新语言重新解析 - (context as? Activity)?.recreate() + // Android 13+:framework 设置语言后系统自动重建 Activity; + // 旧系统才需要手动 recreate 让资源按新语言重新解析 + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + (context as? Activity)?.recreate() + } } .padding(vertical = 12.dp), verticalAlignment = Alignment.CenterVertically diff --git a/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt b/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt index 99d974d..83932a5 100644 --- a/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt +++ b/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt @@ -15,6 +15,7 @@ import com.rainy.token.data.cache.BalanceCache import com.rainy.token.data.cache.balanceCacheDataStore import com.rainy.token.domain.service.ServiceType import com.rainy.token.ui.components.normalizeWindowLabel +import com.rainy.token.util.LocaleManager import kotlinx.coroutines.runBlocking import java.text.SimpleDateFormat import java.util.Date @@ -57,15 +58,19 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() { ) { var selectedHasCachedData = false + // 每次渲染都用最新语言偏好重新包装 context:Application 层的 wrap 在进程启动时固定, + // 应用内切换语言后不会自动更新,必须在这里按当前偏好重建,否则 widget 文本停留在旧语言 + val localized = LocaleManager.wrapContext(context) + for (widgetId in appWidgetIds) { val views = RemoteViews(context.packageName, R.layout.widget_opencode_go) // 署名动态设置:与行标签同源(跟随应用内语言), // 避免 XML 静态文本由宿主进程按系统语言渲染造成中英混搭 - views.setTextViewText(R.id.widget_brand, context.getString(R.string.widget_brand)) + views.setTextViewText(R.id.widget_brand, localized.getString(R.string.widget_brand)) // DeepSeek 余额标签同样动态设置(静态 XML 文本由 Launcher 按系统语言解析) - views.setTextViewText(R.id.widget_ds_label, context.getString(R.string.widget_deepseek_balance)) + views.setTextViewText(R.id.widget_ds_label, localized.getString(R.string.widget_deepseek_balance)) // 点击左上角品牌 → 打开 APP;点击其它内容区域 → 切换服务;刷新按钮单独刷新。 val openAppIntent = Intent(context, MainActivity::class.java).apply { @@ -102,7 +107,7 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() { views.setTextViewText(R.id.widget_switch, shortName(selectedService)) views.setTextViewText( R.id.widget_service_title, - context.getString(R.string.widget_service_quota, selectedService.displayName) + localized.getString(R.string.widget_service_quota, selectedService.displayName) ) views.setImageViewResource(R.id.widget_logo, widgetLogo(selectedService)) // Ollama logo 是正方形,XML 默认 22x12 是给宽扁 logo 的 @@ -116,20 +121,20 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() { val cached = cache.get(selectedService) if (cached != null) { selectedHasCachedData = true - populateServiceRows(views, context, selectedService, cached.balance) + populateServiceRows(views, localized, selectedService, cached.balance) val sdf = SimpleDateFormat("MM-dd HH:mm", Locale.getDefault()) val timeText = sdf.format(Date(cached.fetchedAt)) views.setTextViewText( R.id.widget_updated, - context.getString(R.string.widget_updated_at, timeText) + localized.getString(R.string.widget_updated_at, timeText) ) } else { - setEmptyState(views, context) + setEmptyState(views, localized) views.setTextViewText(R.id.widget_switch, shortName(selectedService)) views.setTextViewText( R.id.widget_service_title, - context.getString(R.string.widget_service_quota, selectedService.displayName) + localized.getString(R.string.widget_service_quota, selectedService.displayName) ) } @@ -143,7 +148,7 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() { views.setTextViewText(R.id.widget_ds_amount, "—") } } catch (_: Exception) { - setEmptyState(views, context) + setEmptyState(views, localized) } } @@ -378,12 +383,14 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() { } fun showRefreshing(context: Context) { + // 与 onUpdate 同理:用最新语言偏好包装,避免进程启动后切换语言不生效 + val localized = LocaleManager.wrapContext(context) val appWidgetManager = AppWidgetManager.getInstance(context) val component = ComponentName(context, OpenCodeGoWidgetProvider::class.java) val ids = appWidgetManager.getAppWidgetIds(component) ids.forEach { id -> val views = RemoteViews(context.packageName, R.layout.widget_opencode_go) - views.setTextViewText(R.id.widget_updated, context.getString(R.string.widget_refreshing)) + views.setTextViewText(R.id.widget_updated, localized.getString(R.string.widget_refreshing)) appWidgetManager.partiallyUpdateAppWidget(id, views) } } diff --git a/app/src/main/java/com/rainy/token/util/LocaleManager.kt b/app/src/main/java/com/rainy/token/util/LocaleManager.kt index 1152bc3..1d6258b 100644 --- a/app/src/main/java/com/rainy/token/util/LocaleManager.kt +++ b/app/src/main/java/com/rainy/token/util/LocaleManager.kt @@ -2,6 +2,8 @@ package com.rainy.token.util import android.content.Context import android.content.res.Configuration +import android.os.Build +import android.os.LocaleList import java.util.Locale /** @@ -19,21 +21,62 @@ import java.util.Locale * * 在 [Application] 与 [MainActivity] 的 attachBaseContext 中调用 [wrapContext] * 应用偏好;切换语言后由设置页保存偏好并 recreate Activity 触发重新解析。 + * + * Android 13+(TIRAMISU)与系统"应用语言"页同步:读写统一走 framework 的 + * [android.app.LocaleManager],私有 SharedPreferences 仅在 API < 33 或 + * framework 未设置时作为兜底——避免应用内偏好与系统级设置互相覆盖造成双源冲突。 */ object LocaleManager { private const val PREFS_NAME = "locale_prefs" private const val KEY_LOCALE = "locale" - /** 当前保存的语言代码;null = 跟随系统 */ + /** 当前生效的语言代码;null = 跟随系统 */ fun getLocaleCode(context: Context): String? { + // Android 13+:framework 是唯一权威源(系统"应用语言"页与应用内选择共用) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val locales = context.getSystemService(android.app.LocaleManager::class.java)?.applicationLocales + if (locales != null && !locales.isEmpty) { + for (i in 0 until locales.size()) { + val l = locales.get(i) + when { + l.language == "zh" && l.script != "Hant" -> return "zh" + l.language == "zh" && l.script == "Hant" -> return "zh-Hant" + l.language == "en" -> return "en" + } + } + return null // 系统选了不支持的第三方语言 → 回退默认英文资源 + } + // framework 未设置(跟随系统):一次性迁移旧 SharedPreferences 偏好到 + // framework 后清除,避免升级设备上过期偏好复活(framework 才是唯一权威源) + migrateLegacyPrefs(context) + return null + } + // API < 33:回退私有偏好 val code = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) .getString(KEY_LOCALE, null) ?: return null return code.takeIf { it == "zh" || it == "zh-Hant" || it == "en" } } + /** 把 API < 33 时代写入的私有偏好一次性迁移到 framework 并清除 */ + private fun migrateLegacyPrefs(context: Context) { + // 方法体内守卫:lint 据此识别 API33 调用(@RequiresApi 注解在本环境解析异常) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + val code = prefs.getString(KEY_LOCALE, null) ?: return + context.getSystemService(android.app.LocaleManager::class.java) + ?.setApplicationLocales(LocaleList.forLanguageTags(code)) + prefs.edit().remove(KEY_LOCALE).apply() + } + /** 保存语言偏好;传 null 表示跟随系统 */ fun saveLocale(context: Context, code: String?) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + // Android 13+:写入系统"应用语言",framework 生效并自动重建 Activity + context.getSystemService(android.app.LocaleManager::class.java) + ?.setApplicationLocales(LocaleList.forLanguageTags(code ?: "")) + return + } context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) .edit() .putString(KEY_LOCALE, code) From 039f27da182b2ce81655f3646d5f3a6e9c168aae Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Sun, 2 Aug 2026 01:16:52 +0000 Subject: [PATCH 11/14] =?UTF-8?q?fix:=20ParseError=20=E5=8E=9F=E5=9B=A0?= =?UTF-8?q?=E7=BB=93=E6=9E=84=E5=8C=96=E6=9C=AC=E5=9C=B0=E5=8C=96=EF=BC=8C?= =?UTF-8?q?=E6=B6=88=E9=99=A4=E8=8B=B1=E6=96=87/=E7=B9=81=E4=BD=93?= =?UTF-8?q?=E7=95=8C=E9=9D=A2=E7=9A=84=E4=B8=AD=E6=96=87=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E9=80=8F=E4=BC=A0=EF=BC=88Codex=20review=20+=20=E5=8F=8C?= =?UTF-8?q?=E5=AE=A1=E8=AE=A1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../token/data/repository/CodexRepository.kt | 16 +++++++-------- .../repository/CommandCodeUsageRepository.kt | 2 +- .../token/data/repository/OllamaRepository.kt | 11 +++++----- .../data/repository/OpenCodeGoRepository.kt | 11 +++++----- .../repository/OpenCodeUsageRepository.kt | 2 +- .../token/data/repository/RepositoryError.kt | 20 +++++++++++++++++-- .../token/ui/dashboard/DashboardViewModel.kt | 12 ++++++++--- .../servicedetail/ServiceDetailViewModel.kt | 10 ++++++++-- .../ui/settings/CredentialEditViewModel.kt | 10 ++++++++-- app/src/main/res/resources.properties | 6 +++--- app/src/main/res/values-b+zh+Hans/strings.xml | 7 ++++++- app/src/main/res/values-b+zh+Hant/strings.xml | 7 ++++++- app/src/main/res/values/strings.xml | 7 ++++++- 13 files changed, 86 insertions(+), 35 deletions(-) diff --git a/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt b/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt index 7c0dd4c..c775f51 100644 --- a/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt @@ -115,9 +115,9 @@ class CodexRepository( parseUsageWindows(usageResult) } catch (e: Exception) { DebugLog.e(TAG, "解析 Codex 用量失败: ${e::class.simpleName}: ${e.message}") - return@withContext Result.failure(RepositoryError.ParseError("Codex 用量响应格式异常: ${e.message ?: e::class.simpleName}")) + return@withContext Result.failure(RepositoryError.ParseError(RepositoryError.ParseErrorReason.MALFORMED_RESPONSE, "Codex 用量响应格式异常: ${e.message ?: e::class.simpleName}")) } - if (windows.isEmpty()) return@withContext Result.failure(RepositoryError.ParseError("未找到 Codex 用量窗口数据")) + if (windows.isEmpty()) return@withContext Result.failure(RepositoryError.ParseError(RepositoryError.ParseErrorReason.NO_WINDOWS, "未找到 Codex 用量窗口数据")) val config = ServiceConfigProvider.get(ServiceType.CODEX) val primary = windows.firstOrNull { it.label.contains("h") } ?: windows.first() @@ -155,16 +155,16 @@ class CodexRepository( DebugLog.e(TAG, "fetchModels: HTTP ${resp.code}") return@withContext Result.failure(RepositoryError.ServerError(resp.code)) } - val root = json.parseToJsonElement(resp.body?.string() ?: throw RepositoryError.ParseError("响应体为空")) as? JsonObject - ?: throw RepositoryError.ParseError("响应根节点不是 JSON 对象") + val root = json.parseToJsonElement(resp.body?.string() ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空")) as? JsonObject + ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.NOT_JSON_OBJECT, "响应根节点不是 JSON 对象") // 结构: { "openai": { "models": { "gpt-5.6": {...}, ... } } } val openaiProvider = root["openai"] as? JsonObject val modelsObj = openaiProvider?.get("models") as? JsonObject modelsObj?.keys?.toList()?.sorted() - ?: throw RepositoryError.ParseError("未找到 OpenAI 模型列表") + ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.NO_MODELS, "未找到 OpenAI 模型列表") } if (models.isEmpty()) { - return@withContext Result.failure(RepositoryError.ParseError("模型列表为空")) + return@withContext Result.failure(RepositoryError.ParseError(RepositoryError.ParseErrorReason.MODELS_EMPTY, "模型列表为空")) } DebugLog.i(TAG, "fetchModels: 获取到 ${models.size} 个模型") Result.success(models) @@ -386,8 +386,8 @@ class CodexRepository( DebugLog.e(TAG, "fetchJson failed: HTTP ${it.code} ${it.message} url=$url") throw if (it.code in listOf(401, 403)) RepositoryError.InvalidCredential("HTTP ${it.code}") else RepositoryError.ServerError(it.code) } - return (json.parseToJsonElement(it.body?.string() ?: throw RepositoryError.ParseError("响应体为空")) as? JsonObject) - ?: throw RepositoryError.ParseError("响应根节点不是 JSON 对象") + return (json.parseToJsonElement(it.body?.string() ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空")) as? JsonObject) + ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.NOT_JSON_OBJECT, "响应根节点不是 JSON 对象") } } diff --git a/app/src/main/java/com/rainy/token/data/repository/CommandCodeUsageRepository.kt b/app/src/main/java/com/rainy/token/data/repository/CommandCodeUsageRepository.kt index 12ea98b..2ae118c 100644 --- a/app/src/main/java/com/rainy/token/data/repository/CommandCodeUsageRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/CommandCodeUsageRepository.kt @@ -106,7 +106,7 @@ class CommandCodeUsageRepository( } if (body == null) return@withContext Result.failure( - RepositoryError.ParseError("响应体为空") + RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空") ) val records = parseUsageResponse(body) diff --git a/app/src/main/java/com/rainy/token/data/repository/OllamaRepository.kt b/app/src/main/java/com/rainy/token/data/repository/OllamaRepository.kt index 04a45f8..a401d65 100644 --- a/app/src/main/java/com/rainy/token/data/repository/OllamaRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/OllamaRepository.kt @@ -178,13 +178,14 @@ class OllamaRepository( } val html = resp.body?.string() ?: return@withContext Result.failure( - RepositoryError.ParseError("响应体为空") + RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空") ) val parsed = parseUsage(html) if (parsed == null) { return@withContext Result.failure( RepositoryError.ParseError( + RepositoryError.ParseErrorReason.NO_WINDOWS, "解析失败:未找到 Ollama Cloud 用量数据。HTML=${html.length} 字节。Cookie 可能已过期。" ) ) @@ -241,15 +242,15 @@ class OllamaRepository( DebugLog.e(TAG, "fetchModels: HTTP ${resp.code}") return@withContext Result.failure(RepositoryError.ServerError(resp.code)) } - val root = json.parseToJsonElement(resp.body?.string() ?: throw RepositoryError.ParseError("响应体为空")) as? JsonObject - ?: throw RepositoryError.ParseError("响应根节点不是 JSON 对象") + val root = json.parseToJsonElement(resp.body?.string() ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空")) as? JsonObject + ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.NOT_JSON_OBJECT, "响应根节点不是 JSON 对象") val provider = root["ollama-cloud"] as? JsonObject val modelsObj = provider?.get("models") as? JsonObject modelsObj?.keys?.toList()?.sorted() - ?: throw RepositoryError.ParseError("未找到 Ollama Cloud 模型列表") + ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.NO_MODELS, "未找到 Ollama Cloud 模型列表") } if (models.isEmpty()) { - return@withContext Result.failure(RepositoryError.ParseError("模型列表为空")) + return@withContext Result.failure(RepositoryError.ParseError(RepositoryError.ParseErrorReason.MODELS_EMPTY, "模型列表为空")) } DebugLog.i(TAG, "fetchModels: 获取到 ${models.size} 个模型") Result.success(models) diff --git a/app/src/main/java/com/rainy/token/data/repository/OpenCodeGoRepository.kt b/app/src/main/java/com/rainy/token/data/repository/OpenCodeGoRepository.kt index 087719a..fb56f8d 100644 --- a/app/src/main/java/com/rainy/token/data/repository/OpenCodeGoRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/OpenCodeGoRepository.kt @@ -86,13 +86,14 @@ class OpenCodeGoRepository( } val html = resp.body?.string() ?: return@withContext Result.failure( - RepositoryError.ParseError("响应体为空") + RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空") ) val windows = parseWindows(html) if (windows.isEmpty()) { return@withContext Result.failure( RepositoryError.ParseError( + RepositoryError.ParseErrorReason.NO_WINDOWS, "解析失败:未找到任何 OpenCode Go 配额窗口。HTML=${html.length} 字节。" ) ) @@ -151,15 +152,15 @@ class OpenCodeGoRepository( DebugLog.e(TAG, "fetchModels: HTTP ${resp.code}") return@withContext Result.failure(RepositoryError.ServerError(resp.code)) } - val root = json.parseToJsonElement(resp.body?.string() ?: throw RepositoryError.ParseError("响应体为空")) as? JsonObject - ?: throw RepositoryError.ParseError("响应根节点不是 JSON 对象") + val root = json.parseToJsonElement(resp.body?.string() ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空")) as? JsonObject + ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.NOT_JSON_OBJECT, "响应根节点不是 JSON 对象") val provider = root["opencode-go"] as? JsonObject val modelsObj = provider?.get("models") as? JsonObject modelsObj?.keys?.toList()?.sorted() - ?: throw RepositoryError.ParseError("未找到 OpenCode Go 模型列表") + ?: throw RepositoryError.ParseError(RepositoryError.ParseErrorReason.NO_MODELS, "未找到 OpenCode Go 模型列表") } if (models.isEmpty()) { - return@withContext Result.failure(RepositoryError.ParseError("模型列表为空")) + return@withContext Result.failure(RepositoryError.ParseError(RepositoryError.ParseErrorReason.MODELS_EMPTY, "模型列表为空")) } DebugLog.i(TAG, "fetchModels: 获取到 ${models.size} 个模型") Result.success(models) diff --git a/app/src/main/java/com/rainy/token/data/repository/OpenCodeUsageRepository.kt b/app/src/main/java/com/rainy/token/data/repository/OpenCodeUsageRepository.kt index 26c8f30..55f0a6f 100644 --- a/app/src/main/java/com/rainy/token/data/repository/OpenCodeUsageRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/OpenCodeUsageRepository.kt @@ -88,7 +88,7 @@ class OpenCodeUsageRepository( } val body = resp.body?.string() ?: return@withContext Result.failure( - RepositoryError.ParseError("响应体为空") + RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空") ) val records = parseUsageRecords(body) diff --git a/app/src/main/java/com/rainy/token/data/repository/RepositoryError.kt b/app/src/main/java/com/rainy/token/data/repository/RepositoryError.kt index cfa38dd..08395d4 100644 --- a/app/src/main/java/com/rainy/token/data/repository/RepositoryError.kt +++ b/app/src/main/java/com/rainy/token/data/repository/RepositoryError.kt @@ -23,8 +23,24 @@ sealed class RepositoryError(message: String, cause: Throwable? = null) : Except class ServerError(val code: Int, cause: Throwable? = null) : RepositoryError("服务端异常 (HTTP $code)", cause) - /** 解析失败(HTML 解析结果为空 / JSON 反序列化失败),detail 为具体原因 */ - class ParseError(val detail: String, cause: Throwable? = null) : + /** 解析失败的具体原因(结构化错误码,UI 层据此映射本地化文案,detail 仅作日志/调试) */ + enum class ParseErrorReason { + /** 响应体为空 */ + EMPTY_BODY, + /** 响应根节点不是 JSON 对象 */ + NOT_JSON_OBJECT, + /** 未找到用量窗口数据 */ + NO_WINDOWS, + /** 未找到模型列表 */ + NO_MODELS, + /** 模型列表为空 */ + MODELS_EMPTY, + /** 响应格式异常(detail 含异常信息,仅供日志) */ + MALFORMED_RESPONSE + } + + /** 解析失败(HTML 解析结果为空 / JSON 反序列化失败),detail 为具体原因(日志用,不直接透传 UI) */ + class ParseError(val reason: ParseErrorReason, val detail: String, cause: Throwable? = null) : RepositoryError("解析失败: $detail", cause) /** 未知错误 */ 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 9e8fb69..4fe502b 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 @@ -189,7 +189,7 @@ data class DashboardCardUi( val displayBalance: ServiceBalance? get() = cachedBalance?.balance } -/** 把 Repository 错误映射为本地化文案(错误详情 detail 为技术信息,随资源参数展示)。 */ +/** 把 Repository 错误映射为本地化文案(ParseError 按 reason 映射资源,detail 仅作日志,不流向 UI)。 */ private fun errorToUiText(error: Throwable): UiText = when (error) { is RepositoryError.InvalidCredential -> UiText.Resource(R.string.error_credential_invalid_reconfigure) @@ -204,8 +204,14 @@ private fun errorToUiText(error: Throwable): UiText = when (error) { is RepositoryError.Network -> UiText.Resource(R.string.error_network_check) is RepositoryError.ServerError -> UiText.Resource(R.string.error_server_http, listOf(error.code)) - is RepositoryError.ParseError -> - UiText.Resource(R.string.error_parse_failed, listOf(error.detail)) + is RepositoryError.ParseError -> when (error.reason) { + RepositoryError.ParseErrorReason.EMPTY_BODY -> UiText.Resource(R.string.error_parse_empty_body) + RepositoryError.ParseErrorReason.NOT_JSON_OBJECT -> UiText.Resource(R.string.error_parse_not_json) + RepositoryError.ParseErrorReason.NO_WINDOWS -> UiText.Resource(R.string.error_parse_no_windows) + RepositoryError.ParseErrorReason.NO_MODELS -> UiText.Resource(R.string.error_parse_no_models) + RepositoryError.ParseErrorReason.MODELS_EMPTY -> UiText.Resource(R.string.error_parse_models_empty) + RepositoryError.ParseErrorReason.MALFORMED_RESPONSE -> UiText.Resource(R.string.error_parse_malformed) + } else -> error.message?.let { UiText.Dynamic(it) } ?: UiText.Resource(R.string.common_unknown) } 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 b82fc98..79f0da0 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 @@ -536,8 +536,14 @@ class ServiceDetailViewModel @Inject constructor( is RepositoryError.Network -> UiText.Resource(R.string.error_network_check) is RepositoryError.ServerError -> UiText.Resource(R.string.error_server_http, listOf(error.code)) - is RepositoryError.ParseError -> - UiText.Resource(R.string.error_parse_failed, listOf(error.detail)) + is RepositoryError.ParseError -> when (error.reason) { + RepositoryError.ParseErrorReason.EMPTY_BODY -> UiText.Resource(R.string.error_parse_empty_body) + RepositoryError.ParseErrorReason.NOT_JSON_OBJECT -> UiText.Resource(R.string.error_parse_not_json) + RepositoryError.ParseErrorReason.NO_WINDOWS -> UiText.Resource(R.string.error_parse_no_windows) + RepositoryError.ParseErrorReason.NO_MODELS -> UiText.Resource(R.string.error_parse_no_models) + RepositoryError.ParseErrorReason.MODELS_EMPTY -> UiText.Resource(R.string.error_parse_models_empty) + RepositoryError.ParseErrorReason.MALFORMED_RESPONSE -> UiText.Resource(R.string.error_parse_malformed) + } else -> error.message?.let { UiText.Dynamic(it) } ?: UiText.Resource(R.string.common_unknown) } diff --git a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt index 18529d0..e0811ef 100644 --- a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt @@ -177,8 +177,14 @@ class CredentialEditViewModel @Inject constructor( ?: UiText.Resource(R.string.common_unknown) ) ) - is RepositoryError.ParseError -> - UiText.Resource(R.string.error_parse_failed, listOf(err.detail)) + is RepositoryError.ParseError -> when (err.reason) { + RepositoryError.ParseErrorReason.EMPTY_BODY -> UiText.Resource(R.string.error_parse_empty_body) + RepositoryError.ParseErrorReason.NOT_JSON_OBJECT -> UiText.Resource(R.string.error_parse_not_json) + RepositoryError.ParseErrorReason.NO_WINDOWS -> UiText.Resource(R.string.error_parse_no_windows) + RepositoryError.ParseErrorReason.NO_MODELS -> UiText.Resource(R.string.error_parse_no_models) + RepositoryError.ParseErrorReason.MODELS_EMPTY -> UiText.Resource(R.string.error_parse_models_empty) + RepositoryError.ParseErrorReason.MALFORMED_RESPONSE -> UiText.Resource(R.string.error_parse_malformed) + } else -> err?.message?.let { UiText.Dynamic(it) } ?: UiText.Resource(R.string.common_unknown) } diff --git a/app/src/main/res/resources.properties b/app/src/main/res/resources.properties index 3c5afff..6005758 100644 --- a/app/src/main/res/resources.properties +++ b/app/src/main/res/resources.properties @@ -1,5 +1,5 @@ # 默认(无 qualifier)资源对应的语言:英文(国际回退语言) -# 非中文/英文系统语言会回退到默认英文资源;中文系统走 values-zh -# AGP 据此 + values-zh 目录生成 locale config,Android 13+ 系统设置中 -# 会显示 RainyToken 的独立语言选项(English / 简体中文) +# 非中文/英文系统语言会回退到默认英文资源;中文系统走 values-b+zh+Hans / values-b+zh+Hant +# AGP 据此 + 中文资源目录生成 locale config,Android 13+ 系统设置中 +# 会显示 RainyToken 的独立语言选项(English / 简体中文 / 繁體中文) unqualifiedResLocale=en 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 8bc27aa..52383f4 100644 --- a/app/src/main/res/values-b+zh+Hans/strings.xml +++ b/app/src/main/res/values-b+zh+Hans/strings.xml @@ -427,7 +427,12 @@ 保存 Cookie - 数据解析失败: %1$s + 响应体为空 + 响应根节点不是 JSON 对象 + 未找到用量窗口数据 + 未找到模型列表 + 模型列表为空 + 响应格式异常 每周 模型调用次数 缓存数据 · %1$s 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 9ed8670..12821b7 100644 --- a/app/src/main/res/values-b+zh+Hant/strings.xml +++ b/app/src/main/res/values-b+zh+Hant/strings.xml @@ -427,7 +427,12 @@ 儲存 Cookie - 數據解析失敗: %1$s + 回應體為空 + 回應根節點不是 JSON 物件 + 未找到用量視窗資料 + 未找到模型清單 + 模型清單為空 + 回應格式異常 每週 模型呼叫次數 快取數據 · %1$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 67546ab..5d82a0a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -430,7 +430,12 @@ Save cookie - Failed to parse data: %1$s + Empty response body + Response root is not a JSON object + No usage window data found + Model list not found + Model list is empty + Response format is abnormal Weekly Model call count Cached data · %1$s From f788d57798075fc24bc2f851c7d2ebf2b452624f Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Sun, 2 Aug 2026 03:02:58 +0000 Subject: [PATCH 12/14] =?UTF-8?q?fix:=20=E5=90=8C=E6=AD=A5/=E8=A7=A6?= =?UTF-8?q?=E5=8F=91=E9=94=99=E8=AF=AF=E5=85=A8=E9=93=BE=E8=B7=AF=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E5=8C=96=EF=BC=88count=5Ftimes=20=E5=8D=95=E4=BD=8D?= =?UTF-8?q?=20+=20Unknown/PartialSync/TriggerError=20=E7=BB=93=E6=9E=84?= =?UTF-8?q?=E5=8C=96=E6=98=A0=E5=B0=84=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../token/data/repository/CodexRepository.kt | 10 ++++- .../ui/dashboard/CommandCodeUsageStatsCard.kt | 3 +- .../token/ui/dashboard/DashboardViewModel.kt | 2 + .../token/ui/dashboard/UsageStatsCard.kt | 3 +- .../token/ui/dashboard/UsageViewModel.kt | 42 +++++++++++++++++-- .../servicedetail/ServiceDetailViewModel.kt | 13 +++--- .../ui/settings/CredentialEditViewModel.kt | 2 + app/src/main/res/values-b+zh+Hans/strings.xml | 3 +- app/src/main/res/values-b+zh+Hant/strings.xml | 3 +- app/src/main/res/values/strings.xml | 5 ++- 10 files changed, 68 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt b/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt index c775f51..a33847d 100644 --- a/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt @@ -252,7 +252,7 @@ class CodexRepository( } is RefreshResult.Failure -> { DebugLog.e(TAG, "triggerUsage: 二次刷新失败: ${r.reason}") - return@use Result.failure(TriggerError("token 刷新失败: ${r.reason}", "")) + return@use Result.failure(TriggerError("token 刷新失败: ${r.reason}", "", TriggerErrorReason.TOKEN_REFRESH)) } } } else { @@ -394,8 +394,14 @@ class CodexRepository( @Serializable data class OAuthRefreshResponse(@kotlinx.serialization.SerialName("access_token") val accessToken: String, @kotlinx.serialization.SerialName("refresh_token") val refreshToken: String? = null, @kotlinx.serialization.SerialName("expires_in") val expiresIn: Long = 3600, @kotlinx.serialization.SerialName("token_type") val tokenType: String? = null) } +/** TriggerError 的结构化原因(UI 层据此映射本地化文案;null = HTTP 错误,summary 为 ASCII 可直显) */ +enum class TriggerErrorReason { + /** token 刷新失败(summary 中为中文用户提示,不直接透传 UI) */ + TOKEN_REFRESH +} + /** 携带完整响应体的错误类,供 UI 展示服务端返回的详细信息 */ -class TriggerError(val summary: String, val responseBody: String) : Exception(summary) +class TriggerError(val summary: String, val responseBody: String, val reason: TriggerErrorReason? = null) : Exception(summary) /** * 将 SSE 流响应解析为简洁的文本摘要。 diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/CommandCodeUsageStatsCard.kt b/app/src/main/java/com/rainy/token/ui/dashboard/CommandCodeUsageStatsCard.kt index 22c2ffc..0c9ee7d 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/CommandCodeUsageStatsCard.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/CommandCodeUsageStatsCard.kt @@ -30,6 +30,7 @@ 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.ui.components.asString import com.rainy.token.data.local.OverviewStats import com.rainy.token.ui.theme.InkMuted import com.rainy.token.ui.theme.StrawberryPink @@ -164,7 +165,7 @@ fun CommandCodeUsageStatsCard( uiState.lastSyncError?.let { err -> Spacer(modifier = Modifier.height(4.dp)) Text( - text = "✗ $err", + text = "✗ ${err.asString()}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error ) 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 4fe502b..530ac1b 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 @@ -212,6 +212,8 @@ private fun errorToUiText(error: Throwable): UiText = when (error) { RepositoryError.ParseErrorReason.MODELS_EMPTY -> UiText.Resource(R.string.error_parse_models_empty) RepositoryError.ParseErrorReason.MALFORMED_RESPONSE -> UiText.Resource(R.string.error_parse_malformed) } + // Unknown 的 message 以硬编码中文"未知错误"开头,不能透传 UI,统一映射本地化文案 + is RepositoryError.Unknown -> UiText.Resource(R.string.common_unknown) else -> error.message?.let { UiText.Dynamic(it) } ?: UiText.Resource(R.string.common_unknown) } diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/UsageStatsCard.kt b/app/src/main/java/com/rainy/token/ui/dashboard/UsageStatsCard.kt index 98a2436..cd088fc 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/UsageStatsCard.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/UsageStatsCard.kt @@ -31,6 +31,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.rainy.token.R import com.rainy.token.data.local.OverviewStats +import com.rainy.token.ui.components.asString import com.rainy.token.ui.theme.InkMuted import com.rainy.token.ui.theme.StrawberryPink import java.util.Locale @@ -163,7 +164,7 @@ fun UsageStatsCard( uiState.lastSyncError?.let { err -> Spacer(modifier = Modifier.height(4.dp)) Text( - text = "✗ $err", + text = "✗ ${err.asString()}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error ) diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/UsageViewModel.kt b/app/src/main/java/com/rainy/token/ui/dashboard/UsageViewModel.kt index 6cb1a70..aae4e15 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/UsageViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/UsageViewModel.kt @@ -9,10 +9,13 @@ import com.rainy.token.data.local.ModelStats import com.rainy.token.data.local.DailyStats import com.rainy.token.data.local.UsageCache 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.service.ServiceType import com.rainy.token.domain.usecase.SyncCommandCodeUsageUseCase +import com.rainy.token.domain.usecase.SyncError import com.rainy.token.domain.usecase.SyncUsageUseCase +import com.rainy.token.ui.components.UiText import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -203,7 +206,7 @@ class UsageViewModel @Inject constructor( it.copy( syncing = false, lastSyncResult = result.getOrNull()?.inserted ?: 0, - lastSyncError = result.exceptionOrNull()?.message + lastSyncError = result.exceptionOrNull()?.let { syncErrorToUiText(it) } ) } } @@ -224,7 +227,7 @@ class UsageViewModel @Inject constructor( it.copy( syncing = false, lastSyncResult = result.getOrNull()?.inserted ?: 0, - lastSyncError = result.exceptionOrNull()?.message + lastSyncError = result.exceptionOrNull()?.let { syncErrorToUiText(it) } ) } } @@ -243,7 +246,7 @@ data class UsageUiState( val dailyStats: List = emptyList(), val recordCount: Int = 0, val lastSyncResult: Int = 0, - val lastSyncError: String? = null, + val lastSyncError: UiText? = null, val timeFilter: TimeFilter = TimeFilter.All, val dailyPage: Int = 1, val modelFilter: String? = null // null = 全部模型 @@ -255,4 +258,35 @@ private data class LoadResult( val modelStats: List, val dailyStats: List, val totalCount: Int -) \ No newline at end of file +) + +/** 把同步错误映射为本地化文案:SyncError / RepositoryError 全部走资源,不透传中文 message。 */ +private fun syncErrorToUiText(error: Throwable): UiText = when (error) { + is SyncError.PartialSync -> + UiText.Resource(R.string.sync_partial, listOf(error.inserted, error.errors.size)) + is RepositoryError.InvalidCredential -> + UiText.Resource(R.string.error_credential_invalid_reconfigure) + is RepositoryError.CredentialChanged -> + UiText.Resource(R.string.error_credential_changed_reload) + is RepositoryError.RateLimited -> UiText.Resource( + R.string.error_rate_limited_retry, + listOf( + error.retryAfterSeconds?.let { + UiText.Resource(R.string.error_rate_limited_retry_suffix, listOf(it)) + } ?: "" + ) + ) + is RepositoryError.Network -> UiText.Resource(R.string.error_network_check) + is RepositoryError.ServerError -> + UiText.Resource(R.string.error_server_http, listOf(error.code)) + is RepositoryError.ParseError -> when (error.reason) { + RepositoryError.ParseErrorReason.EMPTY_BODY -> UiText.Resource(R.string.error_parse_empty_body) + RepositoryError.ParseErrorReason.NOT_JSON_OBJECT -> UiText.Resource(R.string.error_parse_not_json) + RepositoryError.ParseErrorReason.NO_WINDOWS -> UiText.Resource(R.string.error_parse_no_windows) + RepositoryError.ParseErrorReason.NO_MODELS -> UiText.Resource(R.string.error_parse_no_models) + RepositoryError.ParseErrorReason.MODELS_EMPTY -> UiText.Resource(R.string.error_parse_models_empty) + RepositoryError.ParseErrorReason.MALFORMED_RESPONSE -> UiText.Resource(R.string.error_parse_malformed) + } + is RepositoryError.Unknown -> UiText.Resource(R.string.common_unknown) + else -> UiText.Resource(R.string.common_unknown) +} \ No newline at end of file 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 79f0da0..2c6ac2a 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.TriggerError +import com.rainy.token.data.repository.TriggerErrorReason import com.rainy.token.domain.model.Credential import com.rainy.token.domain.model.CredentialStatus import com.rainy.token.domain.model.ServiceBalance @@ -337,12 +338,10 @@ class ServiceDetailViewModel @Inject constructor( val responseBody: String? when (error) { is TriggerError -> { - val prefix = "token 刷新失败: " - message = if (error.summary.startsWith(prefix)) { - UiText.Resource( - R.string.error_token_refresh_failed, - listOf(error.summary.removePrefix(prefix)) - ) + // reason 结构化:token 刷新失败显示本地化文案(summary 中文提示只进日志); + // 其余(HTTP 错误)summary 为 ASCII 可直接展示 + message = if (error.reason == TriggerErrorReason.TOKEN_REFRESH) { + UiText.Resource(R.string.error_token_refresh_generic) } else { UiText.Dynamic(error.summary) } @@ -544,6 +543,8 @@ class ServiceDetailViewModel @Inject constructor( RepositoryError.ParseErrorReason.MODELS_EMPTY -> UiText.Resource(R.string.error_parse_models_empty) RepositoryError.ParseErrorReason.MALFORMED_RESPONSE -> UiText.Resource(R.string.error_parse_malformed) } + // Unknown 的 message 以硬编码中文"未知错误"开头,不能透传 UI,统一映射本地化文案 + is RepositoryError.Unknown -> UiText.Resource(R.string.common_unknown) else -> error.message?.let { UiText.Dynamic(it) } ?: UiText.Resource(R.string.common_unknown) } diff --git a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt index e0811ef..4e7fad4 100644 --- a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt @@ -185,6 +185,8 @@ class CredentialEditViewModel @Inject constructor( RepositoryError.ParseErrorReason.MODELS_EMPTY -> UiText.Resource(R.string.error_parse_models_empty) RepositoryError.ParseErrorReason.MALFORMED_RESPONSE -> UiText.Resource(R.string.error_parse_malformed) } + // Unknown 的 message 以硬编码中文"未知错误"开头,不能透传 UI,统一映射本地化文案 + is RepositoryError.Unknown -> UiText.Resource(R.string.common_unknown) else -> err?.message?.let { UiText.Dynamic(it) } ?: UiText.Resource(R.string.common_unknown) } 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 52383f4..8579044 100644 --- a/app/src/main/res/values-b+zh+Hans/strings.xml +++ b/app/src/main/res/values-b+zh+Hans/strings.xml @@ -139,6 +139,7 @@ 缓存写入 %1$s 暂无数据,点击 🔄 同步 ✓ 新增 %1$d 条 + 部分同步完成:插入 %1$d条,%2$d页失败 点击切换 所有时间按 UTC+0 计算 已切换到 UTC+8,图表按北京时间显示 @@ -273,7 +274,7 @@ (响应解析失败,但请求已发出) 用量: prompt=%1$s completion=%2$s total=%3$s tokens 用量: input=%1$s output=%2$s tokens - token 刷新失败:%1$s + token 刷新失败,请重新登录或重新导入 auth.json 登录 %1$s 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 12821b7..fcdde38 100644 --- a/app/src/main/res/values-b+zh+Hant/strings.xml +++ b/app/src/main/res/values-b+zh+Hant/strings.xml @@ -139,6 +139,7 @@ 快取寫入 %1$s 暫無數據,點選 🔄 同步 ✓ 新增 %1$d 條 + 部分同步完成:插入 %1$d條,%2$d頁失敗 點選切換 所有時間按 UTC+0 計算 已切換到 UTC+8,圖表按北京時間顯示 @@ -273,7 +274,7 @@ (響應解析失敗,但請求已發出) 用量: prompt=%1$s completion=%2$s total=%3$s tokens 用量: input=%1$s output=%2$s tokens - token 重新整理失敗:%1$s + token 重新整理失敗,請重新登入或重新匯入 auth.json 登入 %1$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5d82a0a..e32c4bc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -142,6 +142,7 @@ Cache write %1$s No data yet · tap 🔄 to sync ✓ %1$d new records + Partial sync: %1$d records inserted, %2$d pages failed tap to switch All times in UTC+0 Switched to UTC+8, charts shown in Beijing time @@ -276,7 +277,7 @@ (response parsing failed, but the request was sent) Usage: prompt=%1$s completion=%2$s total=%3$s tokens Usage: input=%1$s output=%2$s tokens - Token refresh failed: %1$s + Token refresh failed. Please log in again or import auth.json. Sign in to %1$s @@ -338,7 +339,7 @@ The widget refreshes balances when you swipe to it, or tap the ↻ button manually. - %1$d + %1$d calls Pick a date Pick a month (any day) Start date From 7795b553d046dc9f3f04079365c3b7e760832aa2 Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Sun, 2 Aug 2026 03:05:30 +0000 Subject: [PATCH 13/14] =?UTF-8?q?docs:=20README=20=E8=A1=A5=E5=85=85?= =?UTF-8?q?=E5=A4=9A=E8=AF=AD=E8=A8=80=E5=8A=9F=E8=83=BD=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index bd23c2b..fab0e32 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ Android AI 余额与用量查询 APP —— 统一查看 DeepSeek、OpenCode Go | ⚡ **Codex 一键激活用量** | Codex 详情页可向 ChatGPT API 发送简短请求触发用量统计;模型列表从 models.dev 动态获取并持久化,支持手动刷新;响应弹窗可复制 | | ⚡ **OCGO / Ollama 一键激活用量** | OCGO 详情页可向 `opencode.ai/zen/v1` 发送请求,Ollama 详情页可向 `ollama.com/v1` 发送请求触发用量统计;API Key 在设置页手动填写,模型列表从 models.dev 动态获取 | | 🐛 **调试日志** | APP 内「调试日志」页面,查看 Repository 网络请求、Token 刷新、解析错误等详细日志,无需连接电脑 | +| 🌐 **多语言** | 简体中文 / 繁體中文 / English 三语言界面;设置页一键切换(跟随系统 / 简体 / 繁体 / English);Android 13+ 与系统「应用语言」页双向同步;非中英系统语言自动回退英文;桌面 APP 名称与小组件跟随语言 | --- From 041df0c24057cbcff14919f72717c2ff4d734ca0 Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Sun, 2 Aug 2026 03:11:48 +0000 Subject: [PATCH 14/14] =?UTF-8?q?docs:=20README=20=E6=8E=AA=E8=BE=9E?= =?UTF-8?q?=E6=AD=A3=E5=BC=8F=E5=8C=96=20+=20=E6=96=B0=E5=A2=9E=E8=8B=B1?= =?UTF-8?q?=E6=96=87=E7=89=88=20README.en.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.en.md | 224 +++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 10 +-- 2 files changed, 229 insertions(+), 5 deletions(-) create mode 100644 README.en.md diff --git a/README.en.md b/README.en.md new file mode 100644 index 0000000..b90c814 --- /dev/null +++ b/README.en.md @@ -0,0 +1,224 @@ +# RainyToken (雨晴Token) + +> *"AI Balance & Usage at a Glance"* + +[![CI](https://github.com/CATMIAOZHI/Rainytoken/actions/workflows/ci.yml/badge.svg)](https://github.com/CATMIAOZHI/Rainytoken/actions/workflows/ci.yml) +[![Release](https://github.com/CATMIAOZHI/Rainytoken/actions/workflows/release.yml/badge.svg)](https://github.com/CATMIAOZHI/Rainytoken/actions) +[![Version](https://img.shields.io/github/v/release/CATMIAOZHI/Rainytoken?color=ff85a2)](https://github.com/CATMIAOZHI/Rainytoken/releases) +Android app for checking AI balance & usage — unified view of DeepSeek, OpenCode Go, CommandCode Go, Codex / ChatGPT Plus, and Ollama Pro balances and usage quotas. Pink-toned brand UI, with companion home screen widgets. +RainyToken (雨晴Token) — AI Balance & Usage Quota Query · the Rainy Family tools. + +--- + +## 📸 Screenshots + +

+ Dashboard (light) + Dashboard (dark) + Usage charts + Ollama Pro model call counts +

+ +

+ Dashboard (light) · Dashboard (dark) · Usage charts · Ollama Pro model call counts +

+ +

+ Home screen widget + Ollama Pro card +

+ +

+ Home screen widget · Ollama Pro home card +

+ +--- + +## ✨ Features + +| Feature | Description | +|------|------| +| 📊 **Dashboard** | DeepSeek balance (¥) + usage/balance cards for each service; OCGO/CCGO cards link directly to usage details; long-press & drag to reorder (persisted); pull-to-refresh globally · tablet-adaptive two-pane layout | +| 📈 **Usage charts** | 3 Canvas-drawn charts — amount spent / API request count / token consumption (dual data sources: OCGO & CCGO); UTC+0/UTC+8 timezone switching and custom day/month/range; automatic fallback (no data in last 5h → 12h → 7 days → current month); side-by-side on tablets | +| 📱 **Tablet adaptation** | Global `BoxWithConstraints` adaptive container width; ≥600dp dual-column cards, ≥700dp side-by-side charts; two-pane 35/65 split (Expanded mode); supports Android 13+ predictive back gestures | +| 📋 **Detailed data** | Paginated browsing of raw records with time + model filtering; tap to view full fields | +| 🔍 **Multi-granularity filters** | 5 hours / 12 hours (10-minute buckets) / 24 hours / today / yesterday / last 7 days / current month / custom day · month · range | +| 🏷️ **Model filtering** | Multi-select / single-select / select all, with dynamic legends that wrap automatically | +| 📱 **Home screen widget** | Check usage without opening the app; switch between four services (OCGO/CCGO/Codex/Ollama) + DeepSeek balance; top-left corner → open app, tap elsewhere to switch, ↻ to refresh; can be added to the At a Glance screen; auto-refreshes when scrolled into view (MIUI exposure refresh) | +| 🔄 **Auto sync** | Pull-to-refresh on home auto-syncs usage; auto full sync on startup when no cache; CCGO detail page supports manual clear & re-sync | +| 🌙 **Dark mode** | Fully adaptive — in-app text/icons/background switch automatically; widget adapts independently with a dark layout | +| ➕ **One-tap add to home screen** | Tap + inside the app to add the widget directly, no need to browse the system list; double confirmation + permission check | +| 💡 **Usage tips** | Home page shows a random operation tip (refreshed on each launch); settings page lists all 13 hidden tips | +| ⚡ **Room database** | Usage records stored in Room (indexed on workspaceId+timeCreated); DAO queries replace full JSON serialization; auto-migration from the legacy DataStore on first launch | +| 🎀 **Rainy pink theme** | Material Design 3 · Strawberry Pink #FF85A2 · Sakura Pink #FFD1DC | +| 🔐 **Codex OAuth sign-in** | Headless OAuth PKCE: the app generates an authorization link → sign in in an external browser → paste the callback URL to complete authorization; no manual auth.json export needed | +| ⚡ **Codex one-tap usage activation** | Codex detail page can send a short request to the ChatGPT API to trigger usage tracking; model list fetched dynamically from models.dev and persisted, with manual refresh; response dialog is copyable | +| ⚡ **OCGO / Ollama one-tap usage activation** | OCGO detail page can send a request to `opencode.ai/zen/v1`, Ollama detail page to `ollama.com/v1` to trigger usage tracking; API keys are entered manually in settings; model lists fetched dynamically from models.dev | +| 🐛 **Debug logs** | In-app "Debug Logs" page showing detailed logs of Repository network requests, token refreshes, parse errors, etc.; no computer needed | +| 🌐 **Multilingual** | Simplified Chinese / 繁體中文 / English interface; one-tap switching in settings (follow system / Simplified / Traditional / English); two-way sync with the system "App languages" page on Android 13+; non-CN/EN system languages fall back to English; home screen app name and widget follow the language | + +--- + +## 📦 Download + +Download the latest APK from [Releases](https://github.com/CATMIAOZHI/Rainytoken/releases). + +> ⚠️ You need a DeepSeek API Key, OpenCode Go login credentials, CommandCode Go API Key, Codex (OAuth sign-in or pasted auth.json), or an Ollama Pro Cookie to fetch data. + +--- + +## 🏗️ Architecture + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ Android App │ +│ │ +│ ┌────────────────────────────────────────────────────────────┐ │ +│ │ Compose UI (3-layer screens) │ │ +│ │ Dashboard · Usage Charts · Totals · Detail · Settings │ │ +│ └──────────────────────────────┬─────────────────────────────┘ │ +│ │ │ +│ ┌──────────────────────────────▼─────────────────────────────┐ │ +│ │ ViewModel Layer (MVVM) │ │ +│ │ DashboardVM · UsageVM · UsageChartVM │ │ +│ │ · UsageDataVM (Hilt injected) │ │ +│ └──────────────────────────────┬─────────────────────────────┘ │ +│ │ │ +│ ┌──────────────────────────────▼─────────────────────────────┐ │ +│ │ UseCase Layer │ │ +│ │ RefreshBalanceUseCase (balance) │ │ +│ │ SyncUsageUseCase / SyncCommandCodeUsageUseCase │ │ +│ └──────────────────────────────┬─────────────────────────────┘ │ +│ │ │ +│ ┌──────────────────────────────▼─────────────────────────────┐ │ +│ │ Repository + Network │ │ +│ │ DeepSeekApi (Retrofit) · OpenCodeGo web scraping │ │ +│ │ · OpenCodeUsageRepository · CommandCodeUsageRepository │ │ +│ │ · CodexRepository · OllamaRepository (OkHttp) │ │ +│ └──────────────────────────────┬─────────────────────────────┘ │ +│ │ │ +│ ┌──────────────────────────────▼─────────────────────────────┐ │ +│ │ Local Storage │ │ +│ │ BalanceCache (DataStore) │ │ +│ │ UsageCache (Room, indexed on workspaceId+timeCreated) │ │ +│ │ CredentialRepository (Keystore AES-256 GCM) │ │ +│ └────────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 📁 Project Structure + +``` +Rainytoken/ +├── app/src/main/java/com/rainy/token/ +│ ├── data/ +│ │ ├── cache/ # BalanceCache (DataStore) +│ │ ├── local/ # UsageCache (Room), UsageRecordEntity, UsageDao, UsageDatabase, ChartBucket +│ │ ├── remote/ # DeepSeekApi (Retrofit) + OpenCodeGo scraping + UsageRepository +│ │ └── repository/ # DeepSeek / OpenCodeGo / CommandCode / Codex / Ollama / Credential repositories +│ ├── domain/ +│ │ ├── model/ # ServiceBalance, Credential, etc. +│ │ ├── service/ # ServiceType enum +│ │ └── usecase/ # RefreshBalanceUseCase / SyncUsageUseCase / SyncCommandCodeUsageUseCase +│ ├── ui/ +│ │ ├── dashboard/ # DashboardScreen / UsageDetailScreen / UsageOverviewScreen / UsageDataScreen +│ │ ├── widget/ # Home screen widget (OpenCodeGoWidgetProvider) +│ │ ├── components/ # ServiceIcon / StatusChip, etc. +│ │ ├── theme/ # Rainy pink theme (StrawberryPink / InkMuted) +│ │ └── RainyTokenNavHost.kt # Navigation routes +│ └── di/ # Hilt modules +├── gradle/libs.versions.toml # Version Catalog dependency management +├── build.gradle.kts # Project-level configuration +└── settings.gradle.kts # Project settings +``` + +--- + +## 🛠️ Build + +### Option 1: Android Studio (recommended) +1. Clone the repository: `git clone https://github.com/CATMIAOZHI/Rainytoken.git` +2. Open the project in Android Studio +3. Sync Gradle, connect a device, and hit Run ▶️ + +### Option 2: Command line +```bash +# Run unit tests +./gradlew testDebugUnitTest + +# Build a Debug APK +./gradlew assembleDebug +# APK output: app/build/outputs/apk/debug/app-debug.apk + +# Build a Release APK +./gradlew assembleRelease +``` + +
+🔧 ARM64 environment notes (optional) + +The project bundles an ARM64 AAPT2 binary, which is enabled automatically in ARM64 environments such as Proot/Termux: +```bash +chmod +x ./setup_android_env.sh +./setup_android_env.sh +``` +The script configures `$ANDROID_HOME` and uses the project-bundled ARM64 build-tools. + +**Extra Release build configuration**: Under ARM64 Proot, AGP 9.0 passes `--resource-path-shortening-map=` (equals-sign syntax) to `optimizeReleaseResources`, which the ARM64 AAPT2 does not accept. Configure the AAPT2 wrapper in `~/.gradle/gradle.properties` (global, not committed to the repository): +```properties +android.aapt2FromMavenOverride=/path/to/android-aapt2-wrapper/aapt2 +``` +The wrapper script splits the equals-sign syntax into two separate space-delimited argv entries. The project's `build.gradle.kts` includes a built-in `guardReleaseResources` task as a build safeguard (falls back to the linked `.ap_` automatically when the optimized `.ap_` is missing). +> On x86_64 environments (GitHub Actions / regular Linux), the official AAPT2 is used automatically — no extra steps required. +
+ +--- + +## 📦 Dependency Management + +The project manages all dependencies with a Gradle Version Catalog (`gradle/libs.versions.toml`). + +| Dependency | Purpose | +|------|------| +| `androidx.compose:compose-bom` | Jetpack Compose BOM | +| `androidx.navigation:navigation-compose` | Page navigation | +| `com.squareup.retrofit2:retrofit` | DeepSeek REST API | +| `com.squareup.okhttp3:okhttp` | OpenCode Go web scraping | +| `org.jetbrains.kotlinx:kotlinx-serialization-json` | JSON serialization | +| `androidx.room:room-runtime` | Local usage database (Room) | +| `androidx.datastore:datastore-preferences` | Local caching | +| `com.google.dagger:hilt-android` | Dependency injection | +| `com.google.devtools.ksp:symbol-processing-api` | KSP annotation processing | + +--- + +## 🔒 Security + +- ✅ API keys / session credentials are stored in the **Android Keystore** (AES-256 GCM encrypted) +- ✅ Network requests are only sent to the official DeepSeek / OpenCode APIs +- ✅ `allowBackup=false`: encrypted credentials and usage data are excluded from system backups, preventing undecryptable ciphertext when restoring on a new device +- ✅ Fixed signing key — every Release can be installed over the previous version +- ✅ Signing keys and passwords are stored encrypted in GitHub Secrets and decrypted during CI + +--- + +## 🐱 About + +RainyToken (雨晴Token) is the 4th member of the "Rainy Family" tools, maintained by [雨晴喵 (Rainy)](https://github.com/CATMIAOZHI), released alongside the [Rainy Family](https://github.com/CATMIAOZHI?tab=repositories): + +- [RainyLLM](https://github.com/CATMIAOZHI/RainyLLM) — fully offline Android local LLM inference server (Gemma + OpenAI-compatible API) +- [RainyScanner](https://github.com/CATMIAOZHI/RainyScanner) — Android QR scanner that neither intercepts nor redirects +- [Rainy2FA](https://github.com/CATMIAOZHI/Rainy2FA) — fully local · zero network · biometric-protected TOTP authenticator +- **RainyToken** — AI Balance & Usage Quota Query (this project) + +--- + +## 📄 License + +MIT License © 2026 Rainy + +--- + +

RainyToken · the Rainy Family tools

diff --git a/README.md b/README.md index fab0e32..5723e06 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # 🌧️ 雨晴Token (RainyToken) -> *"AI 用量,尽在掌握 — AI Balance & Usage at a Glance"* 🐱✨ +> *"AI 用量,尽在掌握 — AI Balance & Usage at a Glance"* [![CI](https://github.com/CATMIAOZHI/Rainytoken/actions/workflows/ci.yml/badge.svg)](https://github.com/CATMIAOZHI/Rainytoken/actions/workflows/ci.yml) [![Release](https://github.com/CATMIAOZHI/Rainytoken/actions/workflows/release.yml/badge.svg)](https://github.com/CATMIAOZHI/Rainytoken/actions) [![Version](https://img.shields.io/github/v/release/CATMIAOZHI/Rainytoken?color=ff85a2)](https://github.com/CATMIAOZHI/Rainytoken/releases) Android AI 余额与用量查询 APP —— 统一查看 DeepSeek、OpenCode Go、CommandCode Go、Codex / ChatGPT Plus、Ollama Pro 的余额与用量配额。粉色调品牌 UI,配套桌面小组件。 -🐱 雨晴Token — AI Balance & Usage Quota Query | the Rainy Family tools. +RainyToken(雨晴Token)— AI Balance & Usage Quota Query · the Rainy Family tools. --- @@ -205,14 +205,14 @@ wrapper 脚本将等号形式拆分为空格分隔的两个独立 argv。项目 ## 🐱 关于 -RainyToken(雨晴Token)是「雨晴系列」的第 4 个成员,由 [雨晴喵](https://github.com/CATMIAOZHI) 编纂,随 [雨晴系列](https://github.com/CATMIAOZHI?tab=repositories) 发布: +RainyToken(雨晴Token)是「雨晴系列」的第 4 个成员,由 [雨晴喵](https://github.com/CATMIAOZHI) 开发维护,随 [雨晴系列](https://github.com/CATMIAOZHI?tab=repositories) 发布: - [RainyLLM](https://github.com/CATMIAOZHI/RainyLLM) — 纯离线 Android 本地 LLM 推理服务器(Gemma + OpenAI 兼容 API) - [RainyScanner](https://github.com/CATMIAOZHI/RainyScanner) — 不拦截不跳转的 Android 扫码工具 - [Rainy2FA](https://github.com/CATMIAOZHI/Rainy2FA) — 纯本地 · 零联网 · 生物识别保护的 TOTP 验证器 - **RainyToken** — AI 余额与用量查询 APP | AI Balance & Usage Quota Query(本项目) -> 🎀 Made with love by 雨晴喵 — 守护主人的每一分算力预算 💖 +> 守护每一分 AI 算力预算 💖 --- @@ -222,4 +222,4 @@ MIT License © 2026 Rainy --- -

💖 Made with love by 雨晴喵

\ No newline at end of file +

RainyToken · the Rainy Family tools

\ No newline at end of file