i18n 阶段一:全量字符串资源化(行为不变,仍为中文)

- strings.xml 扩充至 ~470 条(原 3 条),全部 UI 硬编码中文迁移为资源引用
- 新增 UiText 抽象(Resource/Dynamic + asString()/resolve()),6 个 ViewModel 错误消息 UiText 化
- AppTips 13 条改为 @StringRes 资源 ID;TimeFilter/ChartGranularity/StatusStyle/DashboardViewModel 徽章改资源 ID
- FormatUtils 保持纯函数,DurationText/标签参数注入(78 个纯 JVM 测试不受影响)
- Widget XML(2) + WidgetProvider 文字资源化;品牌名 translatable=false
- 开启 generateLocaleConfig + resources.properties(unqualifiedResLocale=zh-CN)
- 修复审查发现的每周/每月行为回归(dashboard/widget 保持'每周/每月')
- 删除死资源/死代码;ChartGranularity 粒度标签补 time_last_12h
- 验证:compileDebugKotlin / testDebugUnitTest(78) / lintDebug / assembleDebug 全绿
This commit is contained in:
WaterRain 2026-08-01 07:32:23 +00:00
parent f70b77f5cc
commit a26629027e
No known key found for this signature in database
34 changed files with 1397 additions and 652 deletions

1
.gitignore vendored
View File

@ -20,3 +20,4 @@ release.jks
.kotlin/
*.jks
tmp_cc/
goal.md

View File

@ -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 的余额/配额。

View File

@ -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}"

View File

@ -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)
}
/** 聚合工具 */

View File

@ -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())
}
}
}

View File

@ -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<Tip> = 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<String> = tips.map { it.hint }
/** 首页轮换用的一句话文案(资源 ID 形式)。 */
val shortHintRes: List<Int> = 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
)

View File

@ -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)

View File

@ -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
}

View File

@ -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
)

View File

@ -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<Any> = 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
}

View File

@ -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<String>()
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
)

View File

@ -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()
}
}

View File

@ -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 -> "正常"
}
}

View File

@ -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))
}
}

View File

@ -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()) }

View File

@ -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)) } }
)
}
}

View File

@ -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
)

View File

@ -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())
}
}
}

View File

@ -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<String>()
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
)

View File

@ -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) 表示不限制。 */

View File

@ -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<String, String>): List<CodexWindow>
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<Pair<String, Int>> {
private fun ModelRequestList(models: List<Pair<String, Int>>) {
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))
}
}
}

View File

@ -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()
}

View File

@ -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))
}
}

View File

@ -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<Credential?, suspend () -> Result<com.rainy.token.domain.model.ServiceBalance>>,
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
)

View File

@ -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)

View File

@ -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)
}
}
}

View File

@ -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
)

View File

@ -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))
}
}
)

View File

@ -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
)

View File

@ -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)
}
}

View File

@ -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" />
</LinearLayout>
@ -146,7 +146,7 @@
<TextView android:id="@+id/row2_label"
android:layout_width="36dp" android:layout_height="wrap_content"
android:text="本周" android:textColor="#8A7A82" android:textSize="11sp" />
android:text="@string/window_weekly" android:textColor="#8A7A82" android:textSize="11sp" />
<TextView android:id="@+id/row2_pct"
android:layout_width="38dp" android:layout_height="wrap_content"
@ -175,7 +175,7 @@
<TextView android:id="@+id/row3_label"
android:layout_width="36dp" android:layout_height="wrap_content"
android:text="本月" android:textColor="#8A7A82" android:textSize="11sp" />
android:text="@string/window_monthly" android:textColor="#8A7A82" android:textSize="11sp" />
<TextView android:id="@+id/row3_pct"
android:layout_width="38dp" android:layout_height="wrap_content"
@ -213,7 +213,7 @@
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="DeepSeek 余额"
android:text="@string/widget_deepseek_balance"
android:textColor="#8A7A82"
android:textSize="11sp" />
@ -249,7 +249,7 @@
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="雨晴喵"
android:text="@string/widget_brand"
android:textColor="#D4C4CC"
android:textSize="9sp" />
</LinearLayout>

View File

@ -41,7 +41,7 @@
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="自动刷新"
android:text="@string/widget_auto_refresh"
android:textColor="#C4A0AB"
android:textSize="10sp" />
</LinearLayout>
@ -63,7 +63,7 @@
<TextView
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" />
</LinearLayout>
@ -103,7 +103,7 @@
<TextView
android:layout_width="36dp" android:layout_height="wrap_content"
android:text="本周" android:textColor="#8A7A82" android:textSize="11sp" />
android:text="@string/window_weekly" android:textColor="#8A7A82" android:textSize="11sp" />
<TextView
android:layout_width="38dp" android:layout_height="wrap_content"
@ -128,7 +128,7 @@
<TextView
android:layout_width="36dp" android:layout_height="wrap_content"
android:text="本月" android:textColor="#8A7A82" android:textSize="11sp" />
android:text="@string/window_monthly" android:textColor="#8A7A82" android:textSize="11sp" />
<TextView
android:layout_width="38dp" android:layout_height="wrap_content"
@ -161,7 +161,7 @@
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="DeepSeek 余额"
android:text="@string/widget_deepseek_balance"
android:textColor="#8A7A82"
android:textSize="11sp" />

View File

@ -0,0 +1,4 @@
# 默认(无 qualifier)资源对应的语言:简体中文
# AGP 据此 + values-en 等目录生成 locale config,Android 13+ 系统设置中
# 会显示 RainyToken 的独立语言选项(简体中文 / English)
unqualifiedResLocale=zh-CN

View File

@ -1,5 +1,470 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">雨晴Token</string>
<string name="widget_name">AI用量面板</string>
<string name="widget_description">OpenCode Go 用量 + DeepSeek 余额,自动刷新</string>
<!-- ══════════ 应用品牌(translatable=false,不参与翻译) ══════════ -->
<string name="app_name" translatable="false">雨晴Token</string>
<string name="widget_name" translatable="false">AI用量面板</string>
<string name="widget_description" translatable="false">OpenCode Go 用量 + DeepSeek 余额,自动刷新</string>
<string name="widget_brand" translatable="false">雨晴喵</string>
<string name="brand_token" translatable="false">RainyToken</string>
<!-- ══════════ 通用动作 ══════════ -->
<string name="action_back">返回</string>
<string name="action_cancel">取消</string>
<string name="action_confirm_add">确认添加</string>
<string name="action_got_it">我知道了</string>
<string name="action_refresh">刷新</string>
<string name="action_settings">设置</string>
<string name="action_delete">删除</string>
<string name="action_clear">清除</string>
<string name="action_save">保存</string>
<string name="action_jump">跳转</string>
<string name="action_prev_page">◀ 上一页</string>
<string name="action_next_page">下一页 ▶</string>
<string name="action_add_widget">添加小组件到桌面</string>
<string name="action_view_detail">查看详情</string>
<string name="action_view_usage_detail">查看用量详情</string>
<string name="action_view_raw_data">详细数据</string>
<string name="action_sync_usage">同步用量数据</string>
<string name="action_login_save">我已登录,保存</string>
<string name="action_keep_waiting">继续等待</string>
<!-- ══════════ 通用文案 ══════════ -->
<string name="common_loading">加载中…</string>
<string name="common_unknown">未知错误</string>
<string name="common_all_models">全部模型</string>
<!-- ══════════ 页面标题 ══════════ -->
<string name="nav_empty_title">👈 选择服务查看详情</string>
<string name="nav_empty_subtitle">点击左侧卡片即可</string>
<string name="title_settings">设置</string>
<string name="title_credentials">凭据管理</string>
<string name="title_about">关于 RainyToken</string>
<string name="title_tips">使用小技巧</string>
<string name="title_debug_log">调试日志</string>
<string name="title_usage_detail">用量详情</string>
<string name="title_overview">总统计</string>
<string name="title_raw_data">详细数据</string>
<string name="title_chart">统计图表</string>
<!-- ══════════ 状态徽章 ══════════ -->
<string name="status_not_configured">未配置</string>
<string name="status_configured">已配置</string>
<string name="status_expired">已过期</string>
<string name="status_relogin">需重登</string>
<string name="status_refresh_failed">刷新失败</string>
<string name="status_not_fetched">未获取</string>
<string name="status_waiting">待获取</string>
<string name="status_normal">正常</string>
<string name="status_not_configured_add">未配置 · 点击添加</string>
<string name="status_ok_valid">已配置 · 凭据有效</string>
<string name="status_expired_reconfigure">已过期 · 点击重新配置</string>
<string name="status_need_reverify">需要重新验证</string>
<string name="status_manual_mode">手动输入模式</string>
<string name="status_credential_configured">凭据已配置</string>
<string name="status_not_configured_cred">未配置凭据</string>
<string name="status_loading">加载中</string>
<string name="status_fresh">最新</string>
<string name="status_stale">缓存</string>
<string name="status_failed">失败</string>
<string name="status_pending_input">待输入</string>
<!-- ══════════ 仪表盘 ══════════ -->
<string name="dashboard_subtitle">AI 余额一览</string>
<string name="footer_never_fetched">从未获取</string>
<string name="footer_just_updated">刚刚更新</string>
<string name="footer_minutes_ago">%1$d 分钟前更新</string>
<string name="footer_hours_ago">%1$d 小时前更新</string>
<string name="footer_updated_at">%1$s 更新</string>
<string name="dashboard_drag_hint">💡 长按卡片可拖拽排序</string>
<string name="dashboard_footer_version" translatable="false">RainyToken · v1.0</string>
<string name="dashboard_footer_privacy">本地加密存储,不上传任何数据</string>
<string name="dialog_add_widget_title">添加桌面小组件?</string>
<string name="dialog_add_widget_body">确认后会请求桌面启动器添加雨晴Token小组件。部分系统仍会弹出系统确认窗口。</string>
<string name="toast_shortcut_permission_denied">桌面快捷方式权限已被拒绝,请在系统设置中允许后重试</string>
<string name="toast_add_rejected">桌面未接受添加请求,请检查启动器权限</string>
<string name="toast_picker_unsupported">当前桌面不支持应用内添加,请在桌面长按 → 小组件 → 查找雨晴Token</string>
<!-- ══════════ 服务卡片 ══════════ -->
<string name="service_click_to_configure">点击配置凭据</string>
<string name="service_pull_to_refresh">下拉刷新</string>
<string name="service_unavailable">服务当前不可用</string>
<string name="service_monthly_used">本月已用 %1$s%2$s</string>
<string name="service_remaining">剩余</string>
<string name="service_used_total"> · 已用 %1$s / 共 %2$s</string>
<string name="service_used_label">%1$s 已用</string>
<string name="service_5h_used">5h 已用</string>
<string name="service_plan_suffix">· %1$s</string>
<string name="service_reset_in">%1$s 后重置</string>
<string name="service_desc_deepseek">REST API · ¥</string>
<string name="service_desc_opencode_go">WebView 抓取 · 5h 配额</string>
<string name="service_desc_commandcode_go">JSON API · $</string>
<string name="service_desc_codex">ChatGPT Plus · Codex 额度</string>
<string name="service_desc_ollama">Cookie 抓取 · Cloud 配额</string>
<!-- ══════════ 时长标签(DurationText 注入用) ══════════ -->
<string name="format_day">天</string>
<string name="format_hour">小时</string>
<string name="format_minute">分</string>
<!-- ══════════ 用量窗口 ══════════ -->
<string name="window_5h">5 小时</string>
<string name="window_5h_short">5h</string>
<string name="window_5h_usage">5h 用量</string>
<string name="window_5h_rolling">5 小时滚动</string>
<string name="window_weekly">本周</string>
<string name="window_monthly">本月</string>
<string name="window_every_month">每月</string>
<string name="window_usage">用量</string>
<string name="usage_window_title">用量窗口%1$s</string>
<string name="usage_window_title_plain">用量窗口</string>
<!-- ══════════ 用量统计 ══════════ -->
<string name="usage_title_opencode">OpenCode 用量</string>
<string name="usage_title_commandcode">CommandCode 用量</string>
<string name="usage_record_count">%1$d 条记录</string>
<string name="usage_click_to_sync">点击同步</string>
<string name="usage_input_tokens">输入 Token</string>
<string name="usage_output_tokens">输出 Token</string>
<string name="usage_reasoning_tokens">推理 Token</string>
<string name="usage_total_cost">总花费</string>
<string name="usage_cache_read">缓存读取 %1$s</string>
<string name="usage_cache_write">缓存写入 %1$s</string>
<string name="usage_no_data_sync">暂无数据,点击 🔄 同步</string>
<string name="usage_new_records">✓ 新增 %1$d 条</string>
<string name="usage_click_to_switch"> 点击切换</string>
<string name="usage_utc0_note">所有时间按 UTC+0 计算</string>
<string name="msg_utc8_on">已切换到 UTC+8,图表按北京时间显示</string>
<string name="msg_utc0_on">已切换到 UTC+0,图表按世界协调时显示</string>
<string name="usage_spent_total">已用 $%1$s / 共 $%2$s</string>
<string name="usage_remaining">剩余 $%1$s</string>
<string name="usage_extra_credits">额外充值</string>
<string name="section_by_model">按模型统计</string>
<string name="section_by_day">按天统计(UTC+0)</string>
<string name="model_count">%1$d 个模型</string>
<string name="model_count_short">%1$d 个</string>
<string name="date_start">开始时间</string>
<string name="date_end">结束时间</string>
<!-- ══════════ 图表 ══════════ -->
<string name="chart_cost">消耗金额 (USD)</string>
<string name="chart_requests">API 请求次数</string>
<string name="chart_tokens">Token 消耗</string>
<string name="chart_stack_output">输出</string>
<string name="chart_stack_input">输入(未命中)</string>
<string name="chart_stack_cache">命中缓存</string>
<!-- ══════════ 原始数据 ══════════ -->
<string name="header_time">时间</string>
<string name="header_model">模型</string>
<string name="header_input_cache">输入(缓存)</string>
<string name="header_output">输出</string>
<string name="header_cost">成本</string>
<!-- ══════════ 时间筛选 ══════════ -->
<string name="time_all">全部</string>
<string name="time_last_5h">最近5小时</string>
<string name="time_last_12h">最近12小时</string>
<string name="time_last_24h">最近24小时</string>
<string name="time_today">今天</string>
<string name="time_yesterday">昨天</string>
<string name="time_last_7d">最近7天</string>
<string name="time_last_30d">最近30天</string>
<string name="time_this_month">当月</string>
<string name="time_custom">自定义</string>
<string name="time_custom_day">自定义天</string>
<string name="time_custom_month">自定义月</string>
<string name="time_custom_range">自定义时间</string>
<!-- ══════════ 设置页 ══════════ -->
<string name="about_description">统一查询 AI 余额的小工具。\n本地加密存储凭据,不上传任何数据。</string>
<string name="tips_view_all">查看全部 %1$d 条隐藏操作</string>
<string name="debug_log_desc">查看 token 刷新、网络请求等调试记录</string>
<!-- ══════════ 凭据编辑 ══════════ -->
<string name="credential_configured_cookies">✓ 已配置登录态(%1$d 个 Cookie)</string>
<string name="help_cookie_title">如何获取 Cookie?</string>
<string name="help_cookie_1">1. 在桌面 Chrome 打开下方登录入口</string>
<string name="help_cookie_2">2. 用 GitHub / Google 登录(不会被拦截)</string>
<string name="help_cookie_3">3. 登录后按 F12 → Application → Cookies</string>
<string name="help_cookie_4">4. 复制该站点下的所有 Cookie 粘贴到上面输入框</string>
<string name="help_cookie_5">格式:name1=value1; name2=value2</string>
<string name="help_ocgo_title">如何获取 OpenCode Go 凭据?</string>
<string name="help_ocgo_1">1. 桌面 Chrome 打开 opencode.ai/auth</string>
<string name="help_ocgo_2">2. 用 GitHub / Google 登录(Google 不会拦截 Chrome)</string>
<string name="help_ocgo_3">3. 登录后跳转到 dashboard,URL 形如:</string>
<string name="help_ocgo_4"> https://opencode.ai/workspace/{id}/go</string>
<string name="help_ocgo_5">4. 复制 URL 中的 {id} 段 → workspaceId</string>
<string name="help_ocgo_6">5. F12 → Application → Cookies</string>
<string name="help_ocgo_7">6. 找到域名 opencode.ai 下名为 auth 的 Cookie</string>
<string name="help_ocgo_8">7. 复制它的 Value 字段 → auth cookie</string>
<string name="help_ocgo_note">注意:只填 auth 一个 Cookie 即可,不要整个 Cookie 字符串</string>
<string name="help_ccgo_title">如何获取 CommandCode Go 凭据?</string>
<string name="help_ccgo_1">1. 打开 https://commandcode.ai 并登录 GitHub</string>
<string name="help_ccgo_2">▸ 获取 API Key(必填):</string>
<string name="help_ccgo_3"> 右上角头像 → Settings → API Keys → 创建一个 Key</string>
<string name="help_ccgo_4">▸ 获取全部 Cookie(用于用量记录):</string>
<string name="help_ccgo_5"> F12 → Application → Cookies → commandcode.ai</string>
<string name="help_ccgo_6"> 选中所有 Cookie,复制整个 Cookie 字符串</string>
<string name="help_ccgo_7"> 粘贴到下方输入框即可,无需逐个挑选</string>
<string name="help_ccgo_note">提示:不填 Cookie 也能正常查看余额,只是用量记录为空。</string>
<string name="help_codex_title">如何获取 Codex auth.json?</string>
<string name="help_codex_1">1. 在终端运行 codex-oauth-proxy 或</string>
<string name="help_codex_2"> codex-proxy,完成 ChatGPT 登录</string>
<string name="help_codex_3">2. 凭证文件保存在:</string>
<!-- ══════════ 提示/错误消息 ══════════ -->
<string name="msg_saved">已保存</string>
<string name="msg_credentials_saved">已保存凭据</string>
<string name="msg_connect_success_balance">连接成功!余额: %1$s %2$s</string>
<string name="msg_connect_success_saved">连接成功,凭据已保存</string>
<string name="error_api_key_empty">API Key 不能为空</string>
<string name="error_credential_rejected">服务拒绝该凭据 (401/403)</string>
<string name="error_credential_changed_test">测试期间凭据已变更</string>
<string name="error_rate_limited">请求过于频繁 (429)</string>
<string name="error_server">服务端错误 (%1$d)</string>
<string name="error_network">网络错误:%1$s</string>
<string name="error_rollback_done">,已恢复原凭据</string>
<string name="error_rollback_skipped">,检测到凭据已变化,未执行回滚</string>
<string name="error_test_failed">测试失败。%1$s%2$s</string>
<string name="error_test_not_supported">暂不支持测试此服务</string>
<string name="error_auth_cookie_workspace">auth cookie 和 workspaceId 都需要填写</string>
<string name="error_cookie_empty">Cookie 不能为空</string>
<string name="error_credential_not_configured">凭据未配置</string>
<string name="error_credential_not_configured_expired">凭据未配置或已过期</string>
<string name="error_credential_invalid_reconfigure">凭据无效,请在设置中重新配置</string>
<string name="error_credential_invalid_relogin">凭据无效,请重新登录</string>
<string name="error_credential_changed_reload">凭据已变更,正在重新加载</string>
<string name="error_credential_changed_retry">凭据已变更,请重新操作</string>
<string name="error_network_check">网络异常,请检查网络</string>
<string name="error_network_generic">网络异常</string>
<string name="error_server_http">服务端异常 (HTTP %1$d)</string>
<string name="error_rate_limited_retry">请求过于频繁%1$s</string>
<string name="error_rate_limited_retry_suffix">,请 %1$d 秒后重试</string>
<string name="error_trigger_not_supported">不支持激活用量</string>
<string name="error_select_model_first">请先选择模型</string>
<string name="error_cookie_not_found">未抓到 Cookie,请确认已登录</string>
<string name="error_login_url_missing">未配置登录 URL</string>
<string name="error_oauth_url_prefix">URL 应以 %1$s 开头</string>
<string name="error_oauth_auth_failed">授权失败: %1$s</string>
<string name="error_oauth_state_mismatch">State 不匹配,请重试</string>
<string name="error_oauth_missing_code">回调缺少授权码</string>
<string name="error_oauth_token_exchange">Token 交换失败,请查看调试日志</string>
<!-- ══════════ 触发用量 ══════════ -->
<string name="trigger_success_title">✓ 用量已激活</string>
<string name="trigger_result_title">请求结果</string>
<string name="trigger_error_prefix">错误: %1$s</string>
<string name="trigger_response_body">响应内容:\n%1$s</string>
<!-- ══════════ WebView 登录 ══════════ -->
<string name="title_webview_login">登录 %1$s</string>
<string name="dialog_login_done_title">完成登录了吗?</string>
<string name="dialog_login_done_body">如果你已经在页面上完成登录但页面没有跳转,可点击下方按钮让我抓取 Cookie 并保存登录态。</string>
<!-- ══════════ 调试日志 ══════════ -->
<string name="debug_log_empty">暂无日志记录\n刷新 Codex 用量后这里会出现调试信息</string>
<!-- ══════════ 小组件 ══════════ -->
<string name="widget_title_opencode">OpenCode Go · 用量</string>
<string name="widget_deepseek_balance">DeepSeek 余额</string>
<string name="widget_auto_refresh">自动刷新</string>
<string name="widget_service_quota">%1$s · 额度</string>
<string name="widget_quota_with_plan">%1$s · %2$s</string>
<string name="widget_updated_at">更新 %1$s</string>
<string name="widget_no_data">暂无数据</string>
<string name="widget_refreshing">刷新中…</string>
<!-- ══════════ 小技巧(AppTips) ══════════ -->
<string name="tip_1_title">长按卡片拖拽排序</string>
<string name="tip_1_hint">长按首页卡片可以拖拽排序</string>
<string name="tip_1_detail">在首页长按任意服务卡片即可拖拽到想要的位置,排序会自动保存。</string>
<string name="tip_2_title">小组件点左上角进 APP</string>
<string name="tip_2_hint">小组件左上角标识可打开 APP</string>
<string name="tip_2_detail">桌面小组件左上角 RainyToken › 标识可以点击打开应用,其它区域点击切换服务。</string>
<string name="tip_3_title">图表点击查看详情</string>
<string name="tip_3_hint">点击图表柱子可查看时段详情</string>
<string name="tip_3_detail">在用量图表上点击或滑动手指,可查看对应时段的详细数值。</string>
<string name="tip_4_title">图表标题可点击</string>
<string name="tip_4_hint">点击图表标题弹出按模型明细</string>
<string name="tip_4_detail">图表卡片标题(如「消耗金额」)点击后会弹出按模型拆分的明细。</string>
<string name="tip_5_title">切换图表时区</string>
<string name="tip_5_hint">用量详情页可切换 UTC+0 / UTC+8</string>
<string name="tip_5_detail">用量详情页顶部可切换 UTC+0 / UTC+8,图表标签会跟随切换。</string>
<string name="tip_6_title">CCGO 清除并重新同步</string>
<string name="tip_6_hint">CCGO 详情页可清除缓存重新同步</string>
<string name="tip_6_detail">CCGO 用量详情页右上角「清除」可删除本地缓存并重新拉取全部数据。</string>
<string name="tip_7_title">查看用量详情</string>
<string name="tip_7_hint">服务卡片底部可进入用量图表</string>
<string name="tip_7_detail">有凭据的服务卡片底部会出现「查看用量详情」入口,点击进入图表统计。</string>
<string name="tip_8_title">原始数据浏览</string>
<string name="tip_8_hint">用量详情页右上角可查看原始记录</string>
<string name="tip_8_detail">用量详情页右上角「详细数据」可查看每条原始调用记录,支持分页跳转。</string>
<string name="tip_9_title">下拉刷新联动用量</string>
<string name="tip_9_hint">下拉刷新会同步余额和用量</string>
<string name="tip_9_detail">首页下拉刷新不仅刷新余额,还会自动同步 OCGO / CCGO 用量记录。</string>
<string name="tip_10_title">凭据获取教程</string>
<string name="tip_10_hint">凭据页底部有获取教程</string>
<string name="tip_10_detail">凭据编辑页底部的「如何获取」按钮有针对每个服务的详细操作步骤。</string>
<string name="tip_11_title">剪贴板自动导入</string>
<string name="tip_11_hint">Codex 支持从剪贴板一键导入</string>
<string name="tip_11_detail">Codex 凭据页支持「从剪贴板自动导入」,粘贴 auth.json 后一键填入。</string>
<string name="tip_12_title">详细数据页码跳转</string>
<string name="tip_12_hint">原始数据页可输入页码直跳</string>
<string name="tip_12_detail">原始数据页底部可输入页码直接跳转,数据量大时不必逐页翻。</string>
<string name="tip_13_title">小组件自动刷新</string>
<string name="tip_13_hint">划到小组件会自动刷新余额</string>
<string name="tip_13_detail">桌面小组件划到负一屏时会自动刷新余额,也可手动点 ↻ 按钮刷新。</string>
<!-- ══════════ 图表/明细(B 组迁移) ══════════ -->
<string name="count_times">%1$d次</string>
<string name="date_select_day">选择日期</string>
<string name="date_select_month">选择月份(任意一天)</string>
<string name="date_pick_start">开始日期</string>
<string name="date_pick_end">结束日期</string>
<string name="chart_detail_cost">消费明细</string>
<string name="chart_detail_requests">调用次数明细</string>
<string name="chart_detail_tokens">Token 明细</string>
<string name="dialog_clear_title">清除数据</string>
<string name="dialog_clear_body">此操作将清除所有 CommandCode 用量本地缓存并重新同步,需要几秒钟完成。</string>
<string name="dialog_clear_confirm_question">是否继续?</string>
<string name="action_confirm_countdown">确认(%1$ds)</string>
<string name="action_confirm_clear">确认清除</string>
<string name="action_close">关闭</string>
<string name="range_from">从 %1$s</string>
<string name="range_to">至 %1$s</string>
<string name="action_apply_custom_range">应用自定义范围</string>
<string name="action_confirm">确定</string>
<string name="date_end_before_start">结束日期不能早于开始日期 %1$s</string>
<string name="time_hm">时间 %1$s:%2$s</string>
<string name="common_no_data">暂无数据</string>
<string name="chart_total_label">合计: %1$s</string>
<string name="usage_total">总计</string>
<string name="usage_records">记录数</string>
<string name="stat_cache_read">缓存读取</string>
<string name="stat_cache_write">缓存写入</string>
<string name="usage_cache_breakdown">缓存 Token 细分</string>
<string name="usage_cache_read_note">※ 缓存读取已计入「输入 Token」</string>
<string name="usage_calls">%1$d 次调用</string>
<string name="date_format_md">MM月dd日 EEEE</string>
<string name="daily_count_cost">%1$d 次 · $%2$s</string>
<string name="raw_record_title">原始数据</string>
<string name="stat_cache_write_5m">缓存写入(5m)</string>
<string name="stat_cache_write_1h">缓存写入(1h)</string>
<string name="raw_cost_usd">成本(USD)</string>
<string name="raw_cost_raw">成本(Raw)</string>
<!-- ══════════ 凭据编辑(C 组迁移) ══════════ -->
<string name="help_codex_4"> ~/.config/codex-oauth-proxy/auth.json</string>
<string name="help_codex_5">3. 用文本编辑器打开该文件,</string>
<string name="help_codex_6"> 复制全部内容粘贴到上方输入框</string>
<string name="help_codex_7"> JSON 格式示例:</string>
<string name="help_codex_8"> {"tokens": {</string>
<string name="help_codex_9"> "access_token": "...",</string>
<string name="help_codex_10"> "refresh_token": "...",</string>
<string name="help_codex_11"> "expiresAt": 1783727108252</string>
<string name="help_codex_12"> }}</string>
<string name="help_codex_note">提示:导入后 APP 会自动刷新 token,</string>
<string name="help_codex_note2">不需要手动更新。</string>
<string name="credential_title_ollama">Ollama Pro 凭据</string>
<string name="credential_hint_ollama">需要从浏览器复制完整 Cookie。登录 ollama.com/settings 后,在 DevTools → Network → 请求头里找到 Cookie 行复制。</string>
<string name="credential_update">更新凭据</string>
<string name="credential_save">保存凭据</string>
<string name="action_test_and_save">测试并保存</string>
<string name="action_open_ollama_settings">在浏览器中打开 Ollama Settings</string>
<string name="action_copy_login_url">复制登录 URL 到剪贴板</string>
<string name="activate_api_key_optional">一键激活用量 API Key(可选)</string>
<string name="hint_ollama_trigger_key">填入 Ollama Cloud API Key 后,可在详情页一键发送请求激活用量统计。从 ollama.com/settings → API Keys 获取。</string>
<string name="placeholder_ollama_api_key">ollama-xxx 或 sk-xxx</string>
<string name="action_save_api_key">保存 API Key</string>
<string name="credential_title_opencode_go">OpenCode Go 凭据</string>
<string name="credential_hint_opencode_go">需要 auth cookie + workspaceId 两个值。APP 会用 OkHttp 抓 dashboard 并自动解析 5h/周/月 三档用量。</string>
<string name="action_import_from_clipboard">从剪贴板自动导入</string>
<string name="placeholder_workspace_id">dashboard URL 中的 {id} 段</string>
<string name="field_auth_cookie_value">auth Cookie 值</string>
<string name="placeholder_auth_cookie">F12 → Cookies → opencode.ai → auth 的 Value</string>
<string name="action_how_get_two_values">如何获取这两个值?</string>
<string name="action_open_login_entry">在浏览器中打开登录入口</string>
<string name="hint_opencode_trigger_key">填入 OpenCode Zen API Key 后,可在详情页一键发送请求激活用量统计。从 opencode.ai/settings → API Keys 获取。</string>
<string name="credential_title_ccgo">CommandCode Go 凭据</string>
<string name="credential_hint_ccgo">需要 API Key(拉余额)。Session Cookie(可选)在浏览器 DevTools 复制,用于拉用量记录。不填也可正常查看余额。</string>
<string name="placeholder_api_key_from_settings">从 Settings → API Keys 生成</string>
<string name="field_session_cookie_optional">Session Cookie(可选)</string>
<string name="placeholder_session_cookie">F12 → Cookies → commandcode.ai → 全选复制粘贴即可</string>
<string name="action_how_get_api_key_cookie">如何获取 API Key 和 Cookie?</string>
<string name="api_key_form_title">请输入 API Key</string>
<string name="api_key_form_hint">凭据会使用 Android Keystore (AES-256 GCM) 加密后存储到本机 DataStore。</string>
<string name="action_update">更新</string>
<string name="credential_title_codex">Codex / ChatGPT Plus 凭据</string>
<string name="credential_hint_codex">推荐使用 OAuth 登录自动获取凭据。也可手动粘贴 auth.json 内容。</string>
<string name="action_oauth_login">🔐 OAuth 登录(推荐)</string>
<string name="or_manual_import">── 或手动导入 ──</string>
<string name="field_auth_json">auth.json 完整内容</string>
<string name="placeholder_auth_json">粘贴 {"tokens": {...}} 完整 JSON</string>
<string name="credential_import_save">导入并保存</string>
<string name="action_how_get_auth_json">如何获取 auth.json?</string>
<string name="manual_cookie_title">粘贴登录态</string>
<string name="manual_cookie_hint">Google OAuth 会拒绝 APP 内置 WebView,所以请用浏览器登录后把 Cookie 粘到这里。</string>
<string name="field_cookie_string">Cookie 字符串</string>
<string name="action_save_cookie">保存 Cookie</string>
<!-- ══════════ 服务详情/凭据校验(D 组迁移) ══════════ -->
<string name="error_parse_failed">数据解析失败: %1$s</string>
<string name="window_every_week">每周</string>
<string name="msg_model_call_count">模型调用次数</string>
<string name="msg_stale_cache">缓存数据 · %1$s</string>
<string name="msg_showing_last_balance">显示的是上次成功获取的余额</string>
<string name="msg_monthly_spent">本月消费 %1$s%2$s</string>
<string name="msg_balance_breakdown">余额构成</string>
<string name="msg_granted_balance">赠送余额</string>
<string name="msg_topped_up_balance">自费充值</string>
<string name="msg_loading_models">加载模型列表...</string>
<string name="msg_select_model">选择模型</string>
<string name="action_refresh_models">刷新模型列表</string>
<string name="msg_triggering">激活中...</string>
<string name="action_retry_trigger">重试激活用量</string>
<string name="action_trigger_usage">⚡ 一键激活用量</string>
<string name="action_refresh_balance">刷新余额</string>
<string name="action_relogin">重新登录</string>
<string name="action_configure_credential">配置凭据</string>
<string name="main_card_current_balance">当前余额</string>
<string name="main_card_5h_usage">5h 实时用量</string>
<string name="main_card_monthly_balance">月度余额</string>
<string name="main_card_session_usage">Session 用量</string>
<string name="msg_please_select_model">请选择模型</string>
<string name="trigger_error_detail">错误详情:</string>
<string name="trigger_api_response">API 响应内容:</string>
<string name="action_copy">复制</string>
<string name="msg_key_length">*** (长度 %1$d) ***</string>
<string name="msg_key_masked">%1$s***%2$s (长度 %3$d)</string>
<string name="error_cookie_format">Cookie 格式错误,应为 name1=value1; name2=value2</string>
<string name="msg_cookies_saved">已保存 %1$d 个 Cookie</string>
<string name="error_auth_json_empty">请粘贴 auth.json 内容</string>
<string name="error_auth_json_missing_tokens">auth.json 缺少 access_token 或 refresh_token</string>
<string name="msg_codex_saved">已保存 Codex 凭据,token 到期后会自动刷新</string>
<string name="error_auth_json_parse">解析失败:%1$s</string>
<string name="msg_connect_success_dollar">连接成功!余额: $%1$s</string>
<string name="msg_api_key_saved">API Key 已保存</string>
<string name="msg_connect_success_session">连接成功!Session: %1$s%% · %2$s</string>
<string name="msg_credential_deleted">凭据已删除</string>
<string name="error_clipboard_empty">剪贴板为空</string>
<string name="msg_clipboard_ws_auth">已识别 workspaceId 和 auth cookie</string>
<string name="msg_clipboard_ws_only">已识别 workspaceId,请再粘贴 auth cookie</string>
<string name="msg_clipboard_auth_only">已识别 auth cookie,请再粘贴 workspaceId</string>
<string name="error_clipboard_none">未识别到有效凭据</string>
<!-- ══════════ Codex OAuth 登录页 ══════════ -->
<string name="oauth_screen_title">Codex OAuth 登录</string>
<string name="oauth_exchanging">正在交换 Token...</string>
<string name="action_retry">重试</string>
<string name="oauth_headless_title">无头模式登录</string>
<string name="oauth_steps">1. 点击「在浏览器中打开」或复制下方链接到浏览器\n2. 在 OpenAI 页面完成登录和授权\n3. 浏览器会跳转到一个 localhost 地址(页面打不开是正常的)\n4. 复制浏览器地址栏的完整 URL 粘贴到下方输入框\n5. 点击「提交回调 URL」</string>
<string name="oauth_auth_url_label">授权链接</string>
<string name="action_open_in_browser">在浏览器中打开</string>
<string name="action_copy_link">复制链接</string>
<string name="oauth_paste_callback_hint">── 登录后粘贴回调 URL ──</string>
<string name="oauth_callback_url_label">回调 URL</string>
<string name="oauth_callback_url_placeholder">http://localhost:1455/auth/callback?code=...</string>
<string name="action_submit_callback">提交回调 URL</string>
<string name="action_regenerate_link">重新生成授权链接</string>
<string name="oauth_preparing">正在准备授权...</string>
</resources>