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:
parent
f70b77f5cc
commit
a26629027e
1
.gitignore
vendored
1
.gitignore
vendored
@ -20,3 +20,4 @@ release.jks
|
||||
.kotlin/
|
||||
*.jks
|
||||
tmp_cc/
|
||||
goal.md
|
||||
|
||||
@ -1,5 +1,14 @@
|
||||
# 雨晴Token — AI 余额查询 APP
|
||||
|
||||
## ⚠️ 提交/推送许可红线(最高优先级)
|
||||
|
||||
> 本仓库的所有 Git 写操作(`git commit`、`git push`、创建/合并 PR、推送 tag 等)**必须事先获得水晴喵的明确同意**,未经允许一律禁止执行。
|
||||
>
|
||||
> - 任何代码修改完成后,只做本地编辑,**不得擅自 commit / push**。需要提交时,先向水晴喵说明改动内容与提交意图,等她说「可以提交」「push 吧」之类的明确许可后再执行。
|
||||
> - 不得以「PR 审查」「CI 验证」等理由绕过许可——创建 PR、推送分支也属于写操作,同样需要先征求同意。
|
||||
> - 水晴喵说「不用了」「先别提交」「再看看」时,保持当前状态,不 push 任何东西。
|
||||
> - 这条规则优先级高于文件中其它任何流程(包括「重大修改 PR 审计红线」),其它规则只决定**怎么提交**,这条决定**能不能提交**。
|
||||
|
||||
## 项目概述
|
||||
|
||||
Android(Jetpack Compose + Kotlin)APP,统一查询 DeepSeek、OpenCode Go、CommandCode Go、Codex / ChatGPT Plus、Ollama Pro 的余额/配额。
|
||||
|
||||
@ -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}"
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
/** 聚合工具 */
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
|
||||
44
app/src/main/java/com/rainy/token/ui/components/UiText.kt
Normal file
44
app/src/main/java/com/rainy/token/ui/components/UiText.kt
Normal 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
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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 -> "正常"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
@ -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()) }
|
||||
|
||||
@ -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)) } }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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) 表示不限制。 */
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
)
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@ -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
|
||||
)
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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" />
|
||||
|
||||
|
||||
4
app/src/main/res/resources.properties
Normal file
4
app/src/main/res/resources.properties
Normal file
@ -0,0 +1,4 @@
|
||||
# 默认(无 qualifier)资源对应的语言:简体中文
|
||||
# AGP 据此 + values-en 等目录生成 locale config,Android 13+ 系统设置中
|
||||
# 会显示 RainyToken 的独立语言选项(简体中文 / English)
|
||||
unqualifiedResLocale=zh-CN
|
||||
@ -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>
|
||||
Loading…
Reference in New Issue
Block a user