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