From 025f971f7a016efb45aaff108e434118bdc6bc50 Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Fri, 7 Aug 2026 03:28:23 +0000 Subject: [PATCH] =?UTF-8?q?feat(heatmap):=20=E9=A6=96=E9=A1=B5OCGO?= =?UTF-8?q?=E5=BF=AB=E6=8D=B7=E5=85=A5=E5=8F=A3=20+=20=E5=A4=9A=E8=AF=AD?= =?UTF-8?q?=E8=A8=80=E5=85=BC=E5=AE=B9=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 首页:OCGO用量卡与服务卡「查看用量详情」右侧新增 Token 活动快捷入口 - 首页入口描述改为「OCGO Token 活动」;统计卡片改 2×2 布局 - 多语言:热力图月份/日期/数字单位 locale-aware(英文 K/M/B + MMM d) - 浮层/读数条句子走 stringResource;月份名资源数组(三语言) - 遗留修复:Codex 窗口标签中性化(weekly/usage)、Ollama plan 空串兜底、 isFiveHourLabel 语言无关判断、字段标签资源化、Widget usageLabel - 单测同步:Codex/Ollama/FormatUtils/FormatCodexPrimaryLabel 全绿(119 用例) --- .../token/data/repository/CodexRepository.kt | 9 +- .../token/data/repository/OllamaRepository.kt | 2 +- .../rainy/token/ui/components/FormatUtils.kt | 22 +++- .../token/ui/dashboard/DashboardScreen.kt | 61 +++++++-- .../token/ui/dashboard/ServiceBalanceCards.kt | 26 ++-- .../token/ui/dashboard/UsageStatsCard.kt | 47 +++++-- .../rainy/token/ui/heatmap/HeatmapCanvas.kt | 124 ++++++++++++------ .../rainy/token/ui/heatmap/HeatmapScreen.kt | 78 ++++++----- .../ui/servicedetail/ServiceDetailScreen.kt | 13 +- .../token/ui/settings/CredentialEditScreen.kt | 12 +- .../ui/settings/CredentialEditViewModel.kt | 2 +- .../ui/widget/OpenCodeGoWidgetProvider.kt | 6 +- app/src/main/res/values-b+zh+Hans/strings.xml | 21 ++- app/src/main/res/values-b+zh+Hant/strings.xml | 21 ++- app/src/main/res/values/strings.xml | 21 ++- .../data/repository/CodexRepositoryTest.kt | 10 +- .../data/repository/OllamaRepositoryTest.kt | 4 +- .../token/ui/components/FormatUtilsTest.kt | 35 ++++- .../dashboard/FormatCodexPrimaryLabelTest.kt | 31 +++-- docs/heatmap-plan.md | 15 ++- 20 files changed, 398 insertions(+), 162 deletions(-) diff --git a/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt b/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt index a33847d..c97044c 100644 --- a/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt @@ -63,7 +63,14 @@ class CodexRepository( return result } - internal fun durationLabel(seconds: Long?): String = when { seconds == null -> "Usage"; seconds / 60.0 >= 10079 -> "每周"; seconds / 60.0 >= 1439 -> "${(seconds / 86400).toInt()}d"; seconds / 60.0 >= 60 -> "${(seconds / 3600).toInt()}h"; else -> "${maxOf(1, (seconds / 60).toInt())}m" } + /** 窗口时长 → 中性标识(weekly/monthly/usage/数字标签),由 UI 层按语言本地化 */ + internal fun durationLabel(seconds: Long?): String = when { + seconds == null -> "usage" + seconds / 60.0 >= 10079 -> "weekly" + seconds / 60.0 >= 1439 -> "${(seconds / 86400).toInt()}d" + seconds / 60.0 >= 60 -> "${(seconds / 3600).toInt()}h" + else -> "${maxOf(1, (seconds / 60).toInt())}m" + } } suspend fun fetchBalance(): Result = withContext(Dispatchers.IO) { diff --git a/app/src/main/java/com/rainy/token/data/repository/OllamaRepository.kt b/app/src/main/java/com/rainy/token/data/repository/OllamaRepository.kt index a401d65..5c4dbb2 100644 --- a/app/src/main/java/com/rainy/token/data/repository/OllamaRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/OllamaRepository.kt @@ -108,7 +108,7 @@ class OllamaRepository( if (sessionPct == null && weeklyPct == null) return null return ParsedUsage( - plan = plan ?: "Unknown", + plan = plan.orEmpty(), // 空串由 UI 层兜底显示 "—",避免英文 "Unknown" 出现在中文 UI sessionPercent = sessionPct ?: 0f, weeklyPercent = weeklyPct ?: 0f, sessionResetAt = sessionResetAt, diff --git a/app/src/main/java/com/rainy/token/ui/components/FormatUtils.kt b/app/src/main/java/com/rainy/token/ui/components/FormatUtils.kt index 992156a..ddafe88 100644 --- a/app/src/main/java/com/rainy/token/ui/components/FormatUtils.kt +++ b/app/src/main/java/com/rainy/token/ui/components/FormatUtils.kt @@ -56,13 +56,29 @@ fun formatResetForWidget(sec: Long, text: DurationText = ChineseDurationText): S } } -/** 标准化窗口标签:weekly/每周 → 每周标签,monthly/每月 → 每月标签,其他原样返回 */ +/** 标准化窗口标签:weekly/每周 → 每周标签,monthly/每月 → 每月标签,usage/用量 → 用量标签,其他原样返回 */ fun normalizeWindowLabel( label: String, - weeklyLabel: String = "每周", - monthlyLabel: String = "每月" + weeklyLabel: String = "Weekly", + monthlyLabel: String = "Monthly", + usageLabel: String = "Usage" ): String = when (label.lowercase()) { "weekly", "每周" -> weeklyLabel "monthly", "每月" -> monthlyLabel + "usage", "用量" -> usageLabel else -> label +} + +/** + * 判断窗口标签是否为 5h 窗口(与语言无关): + * - 匹配英文缩写(5h/5H) + * - 匹配本地化标签(如 "5小时"/"5 小时"/"5小時") + * - 兼容历史中文数据(含 "5" 且含 "小时") + */ +fun isFiveHourLabel(label: String, fiveHourLabel: String, fiveHourShort: String): Boolean { + val normalized = label.trim() + return normalized.equals(fiveHourShort, ignoreCase = true) || + normalized == fiveHourLabel || + (normalized.contains("5") && normalized.contains("小时")) || + (normalized.contains("5") && normalized.contains("小時")) } \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt index 7444ae0..c2c4e2d 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt @@ -70,6 +70,7 @@ 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.text.style.TextOverflow import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex @@ -368,7 +369,7 @@ private fun rememberDashboardItems( ): List { val defaultItems = buildList { add(DashboardHomeItem(USAGE_OCGO_CARD_ID) { - UsageStatsCard(onOpenDetail = onOpenUsageDetail, refreshTrigger = refreshTrigger) + UsageStatsCard(onOpenDetail = onOpenUsageDetail, onOpenHeatmap = onOpenHeatmap, refreshTrigger = refreshTrigger) }) add(DashboardHomeItem(USAGE_CCGO_CARD_ID) { CommandCodeUsageStatsCard(onOpenDetail = onOpenCcgoUsageDetail, refreshTrigger = refreshTrigger) @@ -385,7 +386,9 @@ private fun rememberDashboardItems( ServiceType.OPENCODE_GO -> onOpenUsageDetail ServiceType.COMMANDCODE_GO -> onOpenCcgoUsageDetail else -> null - } + }, + // Token 活动热力图入口仅 OCGO 服务卡片显示(数据只统计 OCGO) + onOpenHeatmap = if (card.service == ServiceType.OPENCODE_GO) onOpenHeatmap else null, ) }) } @@ -630,7 +633,12 @@ private fun DraggableDashboardCards( } @Composable -private fun DashboardCard(card: DashboardCardUi, onClick: () -> Unit, onOpenUsageDetail: (() -> Unit)? = null) { +private fun DashboardCard( + card: DashboardCardUi, + onClick: () -> Unit, + onOpenUsageDetail: (() -> Unit)? = null, + onOpenHeatmap: (() -> Unit)? = null, +) { Card( modifier = Modifier .fillMaxWidth() @@ -664,18 +672,45 @@ private fun DashboardCard(card: DashboardCardUi, onClick: () -> Unit, onOpenUsag // ─── 主体:服务特定的主信息 ─── BalanceMainArea(card) - // 用量服务卡片底部:用量详情入口(仅当有凭证时显示) + // 用量服务卡片底部:用量详情入口 + Token 活动快捷入口(仅当有凭证时显示) if (onOpenUsageDetail != null && card.credentialState != com.rainy.token.domain.model.CredentialStatus.State.NOT_CONFIGURED) { Spacer(modifier = Modifier.height(8.dp)) - TextButton(onClick = onOpenUsageDetail) { - Text(stringResource(R.string.action_view_usage_detail), color = StrawberryPink) - Spacer(modifier = Modifier.width(2.dp)) - Icon( - imageVector = Icons.Filled.KeyboardArrowRight, - contentDescription = null, - tint = StrawberryPink, - modifier = Modifier.padding(top = 1.dp) - ) + Row(verticalAlignment = Alignment.CenterVertically) { + TextButton(onClick = onOpenUsageDetail) { + Text( + stringResource(R.string.action_view_usage_detail), + color = StrawberryPink, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.width(2.dp)) + Icon( + imageVector = Icons.Filled.KeyboardArrowRight, + contentDescription = null, + tint = StrawberryPink, + modifier = Modifier.padding(top = 1.dp) + ) + } + // 弹性间距:窄屏/英文长文案时优先压缩间距,避免按钮折行变形 + Spacer(modifier = Modifier.weight(1f)) + if (onOpenHeatmap != null) { + // OCGO Token 活动热力图快捷入口(与"查看用量详情"并列,右对齐) + TextButton(onClick = onOpenHeatmap) { + Text( + stringResource(R.string.heatmap_quick_entry), + color = StrawberryPink, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.width(2.dp)) + Icon( + imageVector = Icons.Filled.KeyboardArrowRight, + contentDescription = null, + tint = StrawberryPink, + modifier = Modifier.padding(top = 1.dp) + ) + } + } } } diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt b/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt index 3fac28b..1f78a04 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt @@ -31,6 +31,7 @@ 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.isFiveHourLabel import com.rainy.token.ui.components.normalizeWindowLabel import com.rainy.token.ui.theme.StrawberryPink import com.rainy.token.ui.theme.StatusOrange @@ -228,7 +229,7 @@ internal fun CommandCodeGoUsageWindows(balance: ServiceBalance) { @Composable internal fun CodexMainBalance(balance: ServiceBalance) { - val plan = balance.extras["plan"]?.let { + val plan = balance.extras["plan"]?.takeIf { it.isNotBlank() }?.let { when (it) { "plus" -> "Plus"; "pro" -> "Pro"; "free" -> "Free"; else -> it.replaceFirstChar { c -> c.uppercaseChar() } } } ?: "—" val primaryLabel = formatCodexPrimaryLabel( @@ -274,13 +275,13 @@ internal fun CodexMainBalance(balance: ServiceBalance) { */ internal fun formatCodexPrimaryLabel( raw: String?, - weeklyLabel: String = "每周", - monthlyLabel: String = "每月", - usageLabel: String = "用量" + weeklyLabel: String = "Weekly", + monthlyLabel: String = "Monthly", + usageLabel: String = "Usage" ): String = when (raw?.lowercase()) { "5h" -> "5h" - "7d", "每周", weeklyLabel -> weeklyLabel - "30d", "每月", monthlyLabel -> monthlyLabel + "7d", "weekly", "每周", weeklyLabel -> weeklyLabel + "30d", "monthly", "每月", monthlyLabel -> monthlyLabel "usage" -> usageLabel null -> "5h" else -> raw ?: "5h" @@ -298,22 +299,23 @@ internal fun CodexUsageWindows(balance: ServiceBalance) { val monthlyLabel = stringResource(R.string.window_every_month) val windows = (0 until windowCount).map { i -> val label = normalizeWindowLabel( - extras["window_$i.label"] ?: "Usage", + extras["window_$i.label"] ?: "usage", weeklyLabel = weeklyLabel, - monthlyLabel = monthlyLabel + monthlyLabel = monthlyLabel, + usageLabel = stringResource(R.string.window_usage) ) val remainingPct = extras["window_$i.remainingPct"]?.toIntOrNull() val resetAt = extras["window_$i.resetAt"]?.toLongOrNull()?.takeIf { it > 0 } Triple(label, remainingPct, resetAt) } - // 判断是否有 5h 窗口(用于保留空槽位) + // 判断是否有 5h 窗口(与语言无关:匹配 "5h"/"5H" 或本地化标签,用于保留空槽位) val fiveHourLabel = stringResource(R.string.window_5h) - val has5h = windows.any { it.first.contains("5") && it.first.contains("小时") } + val has5h = windows.any { isFiveHourLabel(it.first, fiveHourLabel, stringResource(R.string.window_5h_short)) } Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { if (windows.isEmpty()) { - CompactUsageRowEmpty(label = "Usage", resetInSec = null) + CompactUsageRowEmpty(label = stringResource(R.string.usage_window_title_plain), resetInSec = null) } else { // 如果没有 5h 窗口,在顶部插入一个空的 5h 槽位(保留位置,等恢复后自动填充) if (!has5h) { @@ -333,7 +335,7 @@ internal fun CodexUsageWindows(balance: ServiceBalance) { @Composable internal fun OllamaMainBalance(balance: ServiceBalance) { - val plan = balance.extras["plan"]?.let { + val plan = balance.extras["plan"]?.takeIf { it.isNotBlank() }?.let { when (it.lowercase()) { "pro" -> "Pro"; "max" -> "Max"; "free" -> "Free"; else -> it } } ?: "—" Row(verticalAlignment = Alignment.Bottom) { diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/UsageStatsCard.kt b/app/src/main/java/com/rainy/token/ui/dashboard/UsageStatsCard.kt index 6138ef9..a5d5a3a 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/UsageStatsCard.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/UsageStatsCard.kt @@ -26,6 +26,7 @@ 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.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -44,6 +45,7 @@ import java.util.Locale @Composable fun UsageStatsCard( onOpenDetail: () -> Unit, + onOpenHeatmap: () -> Unit = {}, refreshTrigger: Int = 0, viewModel: UsageViewModel = hiltViewModel() ) { @@ -137,18 +139,43 @@ fun UsageStatsCard( ) } - // ─── 查看详情 ─── + // ─── 查看详情 + Token 活动快捷入口 ─── if (uiState.overview != null) { Spacer(modifier = Modifier.height(8.dp)) - TextButton(onClick = onOpenDetail) { - Text(stringResource(R.string.action_view_detail), color = StrawberryPink) - Spacer(modifier = Modifier.width(2.dp)) - Icon( - imageVector = Icons.Filled.KeyboardArrowRight, - contentDescription = null, - tint = StrawberryPink, - modifier = Modifier.padding(top = 1.dp) - ) + Row(verticalAlignment = Alignment.CenterVertically) { + TextButton(onClick = onOpenDetail) { + Text( + stringResource(R.string.action_view_detail), + color = StrawberryPink, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.width(2.dp)) + Icon( + imageVector = Icons.Filled.KeyboardArrowRight, + contentDescription = null, + tint = StrawberryPink, + modifier = Modifier.padding(top = 1.dp) + ) + } + // 弹性间距:窄屏/英文长文案时优先压缩间距,避免按钮折行变形 + Spacer(modifier = Modifier.weight(1f)) + // OCGO Token 活动热力图快捷入口(与"查看详情"并列,右对齐) + TextButton(onClick = onOpenHeatmap) { + Text( + stringResource(R.string.heatmap_quick_entry), + color = StrawberryPink, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.width(2.dp)) + Icon( + imageVector = Icons.Filled.KeyboardArrowRight, + contentDescription = null, + tint = StrawberryPink, + modifier = Modifier.padding(top = 1.dp) + ) + } } } diff --git a/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapCanvas.kt b/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapCanvas.kt index 37d3a0a..2258285 100644 --- a/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapCanvas.kt +++ b/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapCanvas.kt @@ -45,16 +45,21 @@ import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInWindow +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.round import androidx.compose.ui.unit.sp +import com.rainy.token.R import com.rainy.token.ui.theme.inkMuted import java.time.Instant import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.util.Locale import kotlin.math.abs import kotlin.math.roundToInt import kotlinx.coroutines.flow.first @@ -81,28 +86,43 @@ private val DarkHeatmapColors = listOf( Color(0xFFD67F9A), // Level 5 ) -/** 中文月份名称 */ -private val ChineseMonthNames = listOf( - "一月", "二月", "三月", "四月", "五月", "六月", - "七月", "八月", "九月", "十月", "十一月", "十二月", +/** 月份名称资源(按月索引 1~12,locale-aware) */ +private val monthNameRes = intArrayOf( + R.string.month_1, R.string.month_2, R.string.month_3, R.string.month_4, + R.string.month_5, R.string.month_6, R.string.month_7, R.string.month_8, + R.string.month_9, R.string.month_10, R.string.month_11, R.string.month_12, ) // ── 格式化辅助函数(HeatmapScreen 浮层与滑动查看读数条共用)── +/** 是否为繁体中文 locale(数字单位用 萬/億) */ +private fun isTraditionalChinese(locale: Locale): Boolean = + locale.language == "zh" && + (locale.country == "TW" || locale.country == "HK" || locale.country == "MO") + /** - * 将 Token 数按中文数量级格式化: - * - 亿级(≥1亿):1.9亿 - * - 万级(≥1万):11.2万 / 1124.5万 - * - 万以下:原始数字 + * 将 Token 数按数量级格式化(locale-aware): + * - 中文(zh):亿级 1.9亿 / 万级 11.2万(繁体用 萬/億) + * - 其他语言:≥1e9 → 1.9B,≥1e6 → 11.2M,≥1e3 → 16.8K,否则原数字 * - * 注意:万级先舍入到一位小数再判断是否升亿(如 99999999 → "1.0亿",而非 "10000.0万")。 + * 注意:中文万级先舍入到一位小数再判断是否升亿(如 99999999 → "1.0亿",而非 "10000.0万")。 */ -internal fun formatTokenChinese(tokens: Long): String { +internal fun formatTokenChinese(tokens: Long, locale: Locale = Locale.getDefault()): String { + if (locale.language != "zh") { + return when { + tokens >= 1_000_000_000L -> "${"%.1f".format(tokens / 1_000_000_000.0)}B" + tokens >= 1_000_000L -> "${"%.1f".format(tokens / 1_000_000.0)}M" + tokens >= 1_000L -> "${"%.1f".format(tokens / 1_000.0)}K" + else -> "$tokens" + } + } + val wan = if (isTraditionalChinese(locale)) "萬" else "万" + val yi = if (isTraditionalChinese(locale)) "億" else "亿" return when { tokens >= 1_0000_0000 -> { val v = tokens / 1_0000_0000.0 - if (v >= 100) "${v.roundToInt()}亿" - else "${"%.1f".format(v)}亿" + if (v >= 100) "${v.roundToInt()}$yi" + else "${"%.1f".format(v)}$yi" } tokens >= 1_0000 -> { val v = tokens / 1_0000.0 @@ -111,10 +131,10 @@ internal fun formatTokenChinese(tokens: Long): String { if (rounded >= 10000) { // 万级溢出升亿:9999.99万 → 1.0亿 val v2 = rounded / 10000.0 - if (v2 >= 100) "${v2.roundToInt()}亿" - else "${"%.1f".format(v2)}亿" + if (v2 >= 100) "${v2.roundToInt()}$yi" + else "${"%.1f".format(v2)}$yi" } else { - "${"%.1f".format(v)}万" // 始终保留一位小数 + "${"%.1f".format(v)}$wan" // 始终保留一位小数 } } else -> "$tokens" @@ -122,21 +142,28 @@ internal fun formatTokenChinese(tokens: Long): String { } /** - * 将时间戳格式化为 "7月9日" 格式(月+日),按与数据分桶一致的时区。 - * 日期所在年份与 [yearContext] 不同时,前缀加年份(如 "2024年7月9日")。 + * 将时间戳格式化为日期文本(locale-aware),按与数据分桶一致的时区: + * - 中文(zh):"7月9日",跨年前缀 "2024年7月9日" + * - 其他语言:按 locale 的 "MMM d",跨年 "MMM d, yyyy"(如 "Jul 9, 2024") * * 日浮层传 [currentYear](查看往年时带年份前缀);周浮层两端传 [selectedYear], - * 使跨年周(如 12月28日-次年1月3日)的结束日带 "2026年" 前缀,避免范围倒挂。 + * 使跨年周(如 12月28日-次年1月3日)的结束日带年份前缀,避免范围倒挂。 */ -internal fun formatDateChinese(ts: Long, useUtc8: Boolean, yearContext: Int): String { +internal fun formatDateChinese(ts: Long, useUtc8: Boolean, yearContext: Int, locale: Locale = Locale.getDefault()): String { val zone = if (useUtc8) ZoneOffset.ofHours(8) else ZoneOffset.UTC val date = Instant.ofEpochMilli(ts).atOffset(zone).toLocalDate() - val yearPrefix = if (date.year != yearContext) "${date.year}年" else "" - return "$yearPrefix${date.monthValue}月${date.dayOfMonth}日" + return if (locale.language == "zh") { + val yearPrefix = if (date.year != yearContext) "${date.year}年" else "" + "$yearPrefix${date.monthValue}月${date.dayOfMonth}日" + } else { + val fmt = if (date.year != yearContext) "MMM d, yyyy" else "MMM d" + DateTimeFormatter.ofPattern(fmt, locale).format(date) + } } /** - * 将周数据格式化为浮层/读数条共用的范围文本:"12月28日-12月31日 使用了X token"。 + * 将周数据格式化为范围文本:"12月28日-12月31日"(不含"使用了X token", + * 句子由调用方用 stringResource 按语言拼接)。 * * - 末周只显示到该周落在所选年内的有效日期([dataEndTs],数据最后一天), * 避免出现未绘制的未来/次年空位日期;数据同源,不依赖实时时钟 @@ -147,18 +174,23 @@ internal fun formatWeekRangeText( useUtc8: Boolean, selectedYear: Int, dataEndTs: Long?, + locale: Locale = Locale.getDefault(), ): String { val zone = if (useUtc8) ZoneOffset.ofHours(8) else ZoneOffset.UTC - val start = formatDateChinese(week.weekStartTs, useUtc8, selectedYear) + val start = formatDateChinese(week.weekStartTs, useUtc8, selectedYear, locale) val endTs = minOf(week.weekStartTs + 6L * 86_400_000L, dataEndTs ?: (week.weekStartTs + 6L * 86_400_000L)) - val startDate = Instant.ofEpochMilli(week.weekStartTs).atOffset(zone).toLocalDate() - val endDate = Instant.ofEpochMilli(endTs).atOffset(zone).toLocalDate() - val end = if (endDate.year != startDate.year) { - "${endDate.year}年${endDate.monthValue}月${endDate.dayOfMonth}日" - } else { - formatDateChinese(endTs, useUtc8, selectedYear) + if (locale.language == "zh") { + val startDate = Instant.ofEpochMilli(week.weekStartTs).atOffset(zone).toLocalDate() + val endDate = Instant.ofEpochMilli(endTs).atOffset(zone).toLocalDate() + val end = if (endDate.year != startDate.year) { + "${endDate.year}年${endDate.monthValue}月${endDate.dayOfMonth}日" + } else { + formatDateChinese(endTs, useUtc8, selectedYear, locale) + } + return "$start-$end" } - return "$start-$end 使用了${formatTokenChinese(week.tokens)}token" + // 非中文:formatDateChinese 已按 yearContext 决定是否带年份 + return "$start-${formatDateChinese(endTs, useUtc8, selectedYear, locale)}" } // ── 辅助类型与函数 ────────────────────────────────────────── @@ -214,8 +246,12 @@ private fun groupByWeeks(dailyData: List, zone: ZoneOffset): Lis } } -/** 从按周分组的每日数据计算月份标签 */ -private fun getMonthLabels(weeks: List>, zone: ZoneOffset): List { +/** 从按周分组的每日数据计算月份标签([monthNames] 为当前 locale 的月份名,索引 0~11) */ +private fun getMonthLabels( + weeks: List>, + zone: ZoneOffset, + monthNames: List, +): List { if (weeks.isEmpty()) return emptyList() val rawLabels = mutableListOf() @@ -226,7 +262,7 @@ private fun getMonthLabels(weeks: List>, zone: ZoneOffset) val month = monthFromTs(firstDay.dayTs, zone) if (weekIndex == 0 || month != prevMonth) { - rawLabels.add(MonthLabel(weekIndex, ChineseMonthNames[month])) + rawLabels.add(MonthLabel(weekIndex, monthNames[month])) prevMonth = month } } @@ -294,6 +330,9 @@ fun HeatmapCanvas( val colors = if (darkTheme) DarkHeatmapColors else LightHeatmapColors val mutedColor = inkMuted() val density = LocalDensity.current + // 应用内语言(LocaleManager 只覆写配置、不改变 Locale.getDefault(),故从配置取; + // 与 UsageOverviewScreen 的 date_format_md 同一做法) + val activeLocale = LocalConfiguration.current.locales[0] // 与全局图表设置一致的时区(分桶与显示口径统一) val zone = if (state.useUtc8) ZoneOffset.ofHours(8) else ZoneOffset.UTC @@ -333,9 +372,10 @@ fun HeatmapCanvas( } } - // 月份标签(三种视图共用同一计算,位置与每日视图完全一致) - val monthLabels = remember(gridWeeks, zone) { - getMonthLabels(gridWeeks, zone) + // 月份标签(三种视图共用同一计算,位置与每日视图完全一致;月份名按当前 locale) + val monthNames = List(12) { stringResource(monthNameRes[it]) } + val monthLabels = remember(gridWeeks, zone, monthNames) { + getMonthLabels(gridWeeks, zone, monthNames) } val cols = gridWeeks.size @@ -654,8 +694,16 @@ fun HeatmapCanvas( val d = indicatorDay val w = indicatorWeek val text = when { - d != null -> "${formatDateChinese(d.dayTs, state.useUtc8, state.currentYear)} 使用了${formatTokenChinese(d.tokens)}token" - w != null -> formatWeekRangeText(w, state.useUtc8, state.selectedYear, state.dailyData.lastOrNull()?.dayTs) + d != null -> stringResource( + R.string.heatmap_day_used, + formatDateChinese(d.dayTs, state.useUtc8, state.currentYear, activeLocale), + formatTokenChinese(d.tokens, activeLocale), + ) + w != null -> stringResource( + R.string.heatmap_week_used, + formatWeekRangeText(w, state.useUtc8, state.selectedYear, state.dailyData.lastOrNull()?.dayTs, activeLocale), + formatTokenChinese(w.tokens, activeLocale), + ) else -> null } if (text != null) { diff --git a/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapScreen.kt b/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapScreen.kt index cce442d..e1aadb9 100644 --- a/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapScreen.kt @@ -72,6 +72,8 @@ fun HeatmapScreen( viewModel: HeatmapViewModel = hiltViewModel() ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + // 应用内语言(LocaleManager 只覆写配置、不改变 Locale.getDefault(),故从配置取) + val activeLocale = LocalConfiguration.current.locales[0] // ── 浮层状态 ── var selectedDay: HeatmapDayData? by remember { mutableStateOf(null) } @@ -184,40 +186,44 @@ fun HeatmapScreen( // ── 年度统计(按所选年份,切换年份跟随变化)── // loading 期间显示占位,避免全 0 造成"无数据"假象 val dash = "–" - Row( + Column( modifier = Modifier .fillMaxWidth() .padding(bottom = 12.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - StatItem( - label = stringResource(R.string.heatmap_stats_total), - value = if (state.loading) dash else formatTokenChinese(state.stats.totalTokens), - modifier = Modifier.weight(1f), - ) - StatItem( - label = stringResource(R.string.heatmap_stats_peak), - value = if (state.loading) dash else formatTokenChinese(state.stats.peakTokens), - modifier = Modifier.weight(1f), - ) - StatItem( - label = stringResource(R.string.heatmap_stats_current_streak), - value = if (state.loading) { - dash - } else { - pluralStringResource(R.plurals.heatmap_stats_days, state.stats.currentStreak, state.stats.currentStreak) - }, - modifier = Modifier.weight(1f), - ) - StatItem( - label = stringResource(R.string.heatmap_stats_max_streak), - value = if (state.loading) { - dash - } else { - pluralStringResource(R.plurals.heatmap_stats_days, state.stats.maxStreak, state.stats.maxStreak) - }, - modifier = Modifier.weight(1f), - ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + StatItem( + label = stringResource(R.string.heatmap_stats_total), + value = if (state.loading) dash else formatTokenChinese(state.stats.totalTokens, activeLocale), + modifier = Modifier.weight(1f), + ) + StatItem( + label = stringResource(R.string.heatmap_stats_peak), + value = if (state.loading) dash else formatTokenChinese(state.stats.peakTokens, activeLocale), + modifier = Modifier.weight(1f), + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + StatItem( + label = stringResource(R.string.heatmap_stats_current_streak), + value = if (state.loading) { + dash + } else { + pluralStringResource(R.plurals.heatmap_stats_days, state.stats.currentStreak, state.stats.currentStreak) + }, + modifier = Modifier.weight(1f), + ) + StatItem( + label = stringResource(R.string.heatmap_stats_max_streak), + value = if (state.loading) { + dash + } else { + pluralStringResource(R.plurals.heatmap_stats_days, state.stats.maxStreak, state.stats.maxStreak) + }, + modifier = Modifier.weight(1f), + ) + } } // ── 热力图 Canvas(带 Crossfade 淡入淡出动画)── @@ -276,8 +282,16 @@ fun HeatmapScreen( if (day != null || week != null) { val useUtc8 = state.useUtc8 val popupText = when { - day != null -> "${formatDateChinese(day.dayTs, useUtc8, state.currentYear)} 使用了${formatTokenChinese(day.tokens)}token" - week != null -> formatWeekRangeText(week, useUtc8, state.selectedYear, state.dailyData.lastOrNull()?.dayTs) + day != null -> stringResource( + R.string.heatmap_day_used, + formatDateChinese(day.dayTs, useUtc8, state.currentYear, activeLocale), + formatTokenChinese(day.tokens, activeLocale), + ) + week != null -> stringResource( + R.string.heatmap_week_used, + formatWeekRangeText(week, useUtc8, state.selectedYear, state.dailyData.lastOrNull()?.dayTs, activeLocale), + formatTokenChinese(week.tokens, activeLocale), + ) else -> "" } // 浮层相对锚点的偏移:水平居中、优先显示在格子上方 8dp 处; diff --git a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt index a9ee908..ffd1ede 100644 --- a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt @@ -73,6 +73,7 @@ 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.components.isFiveHourLabel import com.rainy.token.ui.theme.inkMuted import com.rainy.token.ui.theme.StrawberryPink import java.text.SimpleDateFormat @@ -487,10 +488,9 @@ private fun CodexUsageCard(state: State) { val windows = remember(extras) { extractCodexWindows(extras) } if (windows.isEmpty()) return - // 判断是否有 5h 窗口 + // 判断是否有 5h 窗口(与语言无关:匹配 "5h"/"5H" 或本地化标签) val has5h = windows.any { - it.rawLabel.equals("5h", ignoreCase = true) || - (it.rawLabel.contains("5") && it.rawLabel.contains("小时")) + isFiveHourLabel(it.rawLabel, stringResource(R.string.window_5h), stringResource(R.string.window_5h_short)) } Card( @@ -574,8 +574,9 @@ private fun extractCodexWindows(extras: Map): List @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) + "7d", "weekly", "每周" -> stringResource(R.string.window_weekly) + "30d", "monthly", "每月" -> stringResource(R.string.window_monthly) + "usage" -> stringResource(R.string.window_usage) else -> raw } @@ -591,7 +592,7 @@ private fun OllamaUsageCard(state: State) { else -> null } val extras = balance?.extras ?: return - val plan = extras["plan"] ?: "—" + val plan = extras["plan"]?.takeIf { it.isNotBlank() } ?: "—" val sessionModels = parseModelRequests(extras["session.models"]) val weeklyModels = parseModelRequests(extras["weekly.models"]) diff --git a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt index 3fef1a8..a7afed8 100644 --- a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt @@ -338,7 +338,7 @@ private fun OllamaCookieForm( OutlinedTextField( value = cookie, onValueChange = onCookieChange, - label = { Text("Cookie") }, + label = { Text(stringResource(R.string.field_cookie_string)) }, placeholder = { Text("aid=xxx; __Secure-session=yyy") }, singleLine = false, minLines = 2, @@ -388,7 +388,7 @@ private fun OllamaCookieForm( OutlinedTextField( value = triggerApiKey, onValueChange = onApiKeyChange, - label = { Text("API Key") }, + label = { Text(stringResource(R.string.field_api_key)) }, placeholder = { Text(stringResource(R.string.placeholder_ollama_api_key)) }, singleLine = true, visualTransformation = PasswordVisualTransformation(), @@ -434,7 +434,7 @@ private fun OpenCodeGoForm( OutlinedTextField( value = workspaceId, onValueChange = onWorkspaceIdChange, - label = { Text("Workspace ID") }, + label = { Text(stringResource(R.string.field_workspace_id)) }, placeholder = { Text(stringResource(R.string.placeholder_workspace_id)) }, singleLine = true, modifier = Modifier.fillMaxWidth() @@ -490,7 +490,7 @@ private fun OpenCodeGoForm( OutlinedTextField( value = triggerApiKey, onValueChange = onApiKeyChange, - label = { Text("API Key") }, + label = { Text(stringResource(R.string.field_api_key)) }, placeholder = { Text("opencode-xxx") }, singleLine = true, visualTransformation = PasswordVisualTransformation(), @@ -526,7 +526,7 @@ private fun CommandCodeGoForm( OutlinedTextField( value = apiKey, onValueChange = onApiKeyChange, - label = { Text("API Key") }, + label = { Text(stringResource(R.string.field_api_key)) }, placeholder = { Text(stringResource(R.string.placeholder_api_key_from_settings)) }, singleLine = true, visualTransformation = PasswordVisualTransformation(), @@ -573,7 +573,7 @@ private fun ApiKeyForm( OutlinedTextField( value = apiKey, onValueChange = onApiKeyChange, - label = { Text("API Key") }, + label = { Text(stringResource(R.string.field_api_key)) }, singleLine = true, visualTransformation = PasswordVisualTransformation(), keyboardOptions = androidx.compose.foundation.text.KeyboardOptions( diff --git a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt index 4e7fad4..8c18888 100644 --- a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt @@ -538,7 +538,7 @@ class CredentialEditViewModel @Inject constructor( formatSuccess = { bal -> UiText.Resource( R.string.msg_connect_success_session, - listOf(bal.amount.toString(), bal.extras["plan"] ?: "—") + listOf(bal.amount.toString(), bal.extras["plan"]?.takeIf { it.isNotBlank() } ?: "—") ) }, rollbackOnFailure = true diff --git a/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt b/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt index 83932a5..14d40a0 100644 --- a/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt +++ b/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt @@ -204,11 +204,13 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() { .distinct().maxOrNull()?.plus(1) ?: 0 val weeklyLabel = context.getString(R.string.window_every_week) val monthlyLabel = context.getString(R.string.window_every_month) + val usageLabel = context.getString(R.string.window_usage) val windows = (0 until windowCount).map { index -> val label = normalizeWindowLabel( - extras["window_$index.label"] ?: "Usage", + extras["window_$index.label"] ?: "usage", weeklyLabel = weeklyLabel, - monthlyLabel = monthlyLabel + monthlyLabel = monthlyLabel, + usageLabel = usageLabel ) val remaining = extras["window_$index.remainingPct"]?.toIntOrNull() val resetAt = extras["window_$index.resetAt"]?.toLongOrNull()?.takeIf { it > 0 } diff --git a/app/src/main/res/values-b+zh+Hans/strings.xml b/app/src/main/res/values-b+zh+Hans/strings.xml index bb7541a..8aecaec 100644 --- a/app/src/main/res/values-b+zh+Hans/strings.xml +++ b/app/src/main/res/values-b+zh+Hans/strings.xml @@ -498,8 +498,9 @@ 每日 每周 累计 - Token 活动 - 每日 Token 使用热力图 + OCGO Token 活动 + OCGO 每日 Token 使用热力图 + Token 活动 暂无数据 少 多 @@ -513,4 +514,20 @@ %1$d 天 + %1$s使用了%2$s token + %1$s使用了%2$s token + 一月 + 二月 + 三月 + 四月 + 五月 + 六月 + 七月 + 八月 + 九月 + 十月 + 十一月 + 十二月 + API Key + Workspace ID \ No newline at end of file diff --git a/app/src/main/res/values-b+zh+Hant/strings.xml b/app/src/main/res/values-b+zh+Hant/strings.xml index 9891354..50aee31 100644 --- a/app/src/main/res/values-b+zh+Hant/strings.xml +++ b/app/src/main/res/values-b+zh+Hant/strings.xml @@ -498,8 +498,9 @@ 每日 每週 累計 - Token 活動 - 每日 Token 使用熱力圖 + OCGO Token 活動 + OCGO 每日 Token 使用熱力圖 + Token 活動 暫無資料 少 多 @@ -513,4 +514,20 @@ %1$d 天 + %1$s使用了%2$s Token + %1$s使用了%2$s Token + 一月 + 二月 + 三月 + 四月 + 五月 + 六月 + 七月 + 八月 + 九月 + 十月 + 十一月 + 十二月 + API Key + Workspace ID \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f0c16d1..716811a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -501,8 +501,9 @@ Daily Weekly Cumulative - Token Activity - Daily Token usage heatmap + OCGO Token Activity + OCGO daily Token usage heatmap + Token Activity No data yet Less More @@ -517,4 +518,20 @@ %1$d day %1$d days + %1$s used %2$s tokens + %1$s used %2$s tokens + Jan + Feb + Mar + Apr + May + Jun + Jul + Aug + Sep + Oct + Nov + Dec + API Key + Workspace ID diff --git a/app/src/test/java/com/rainy/token/data/repository/CodexRepositoryTest.kt b/app/src/test/java/com/rainy/token/data/repository/CodexRepositoryTest.kt index 628b263..70ed344 100644 --- a/app/src/test/java/com/rainy/token/data/repository/CodexRepositoryTest.kt +++ b/app/src/test/java/com/rainy/token/data/repository/CodexRepositoryTest.kt @@ -24,14 +24,14 @@ class CodexRepositoryTest { // ── durationLabel ── @Test - fun `durationLabel null returns Usage`() { - assertEquals("Usage", CodexRepository.durationLabel(null)) + fun `durationLabel null returns usage`() { + assertEquals("usage", CodexRepository.durationLabel(null)) } @Test fun `durationLabel weekly threshold`() { // 10079 minutes = ~7 days. 7 * 86400 = 604800 seconds = 10080 minutes ≥ 10079 - assertEquals("每周", CodexRepository.durationLabel(604800L)) + assertEquals("weekly", CodexRepository.durationLabel(604800L)) } @Test @@ -91,7 +91,7 @@ class CodexRepositoryTest { assertEquals(2, windows.size) assertEquals("5h", windows[0].label) assertEquals(58, windows[0].remainingPct) // 100 - 42 = 58 - assertEquals("每周", windows[1].label) + assertEquals("weekly", windows[1].label) assertEquals(85, windows[1].remainingPct) // 100 - 15 = 85 } @@ -114,7 +114,7 @@ class CodexRepositoryTest { val windows = CodexRepository.parseUsageWindows(data) // Only secondary_window should be parsed (primary skipped due to JsonNull used_percent) assertEquals(1, windows.size) - assertEquals("每周", windows[0].label) + assertEquals("weekly", windows[0].label) } @Test diff --git a/app/src/test/java/com/rainy/token/data/repository/OllamaRepositoryTest.kt b/app/src/test/java/com/rainy/token/data/repository/OllamaRepositoryTest.kt index ad1a457..4834db0 100644 --- a/app/src/test/java/com/rainy/token/data/repository/OllamaRepositoryTest.kt +++ b/app/src/test/java/com/rainy/token/data/repository/OllamaRepositoryTest.kt @@ -196,7 +196,7 @@ class OllamaRepositoryTest { } @Test - fun `plan defaults to Unknown when badge missing`() { + fun `plan defaults to empty when badge missing`() { val html = """
@@ -209,7 +209,7 @@ class OllamaRepositoryTest { """.trimIndent() val result = OllamaRepository.parseUsage(html) assertNotNull(result) - assertEquals("Unknown", result!!.plan) + assertEquals("", result!!.plan) } @Test diff --git a/app/src/test/java/com/rainy/token/ui/components/FormatUtilsTest.kt b/app/src/test/java/com/rainy/token/ui/components/FormatUtilsTest.kt index 7f13167..be64871 100644 --- a/app/src/test/java/com/rainy/token/ui/components/FormatUtilsTest.kt +++ b/app/src/test/java/com/rainy/token/ui/components/FormatUtilsTest.kt @@ -108,18 +108,39 @@ class FormatUtilsTest { // ── normalizeWindowLabel ── @Test - fun `normalizeWindowLabel weekly to Chinese`() { - assertEquals("每周", normalizeWindowLabel("weekly")) - assertEquals("每周", normalizeWindowLabel("Weekly")) - assertEquals("每周", normalizeWindowLabel("WEEKLY")) - assertEquals("每周", normalizeWindowLabel("每周")) + fun `normalizeWindowLabel weekly maps to weekly label`() { + assertEquals("Weekly", normalizeWindowLabel("weekly")) + assertEquals("Weekly", normalizeWindowLabel("Weekly")) + assertEquals("Weekly", normalizeWindowLabel("WEEKLY")) + assertEquals("每周", normalizeWindowLabel("每周", weeklyLabel = "每周")) + assertEquals("Weekly", normalizeWindowLabel("每周")) } @Test fun `normalizeWindowLabel monthly maps to monthly label`() { - assertEquals("每月", normalizeWindowLabel("monthly")) + assertEquals("Monthly", normalizeWindowLabel("monthly")) assertEquals("Monthly", normalizeWindowLabel("MONTHLY", monthlyLabel = "Monthly")) - assertEquals("每月", normalizeWindowLabel("每月")) + assertEquals("每月", normalizeWindowLabel("每月", monthlyLabel = "每月")) + assertEquals("Monthly", normalizeWindowLabel("每月")) + } + + @Test + fun `normalizeWindowLabel usage maps to usage label`() { + assertEquals("Usage", normalizeWindowLabel("usage")) + assertEquals("用量", normalizeWindowLabel("usage", usageLabel = "用量")) + assertEquals("Usage", normalizeWindowLabel("用量")) + } + + @Test + fun `isFiveHourLabel matches english short and localized labels`() { + assertEquals(true, isFiveHourLabel("5h", "5 小时", "5h")) + assertEquals(true, isFiveHourLabel("5H", "5 小时", "5h")) + assertEquals(true, isFiveHourLabel("5小时", "5 小时", "5h")) + assertEquals(true, isFiveHourLabel("5 小时", "5 小时", "5h")) + assertEquals(true, isFiveHourLabel("5小時", "5 小時", "5h")) + assertEquals(true, isFiveHourLabel("5 小時", "5 小時", "5h")) + assertEquals(false, isFiveHourLabel("7d", "5 小时", "5h")) + assertEquals(false, isFiveHourLabel("", "5 小时", "5h")) } @Test diff --git a/app/src/test/java/com/rainy/token/ui/dashboard/FormatCodexPrimaryLabelTest.kt b/app/src/test/java/com/rainy/token/ui/dashboard/FormatCodexPrimaryLabelTest.kt index 0175223..d1874f5 100644 --- a/app/src/test/java/com/rainy/token/ui/dashboard/FormatCodexPrimaryLabelTest.kt +++ b/app/src/test/java/com/rainy/token/ui/dashboard/FormatCodexPrimaryLabelTest.kt @@ -17,28 +17,39 @@ class FormatCodexPrimaryLabelTest { } @Test - fun `7d maps to weekly Chinese`() { - assertEquals("每周", formatCodexPrimaryLabel("7d")) + fun `7d maps to weekly label`() { + assertEquals("Weekly", formatCodexPrimaryLabel("7d")) + assertEquals("每周", formatCodexPrimaryLabel("7d", weeklyLabel = "每周")) + // 中性标识(durationLabel 输出) + assertEquals("Weekly", formatCodexPrimaryLabel("weekly")) + assertEquals("每周", formatCodexPrimaryLabel("weekly", weeklyLabel = "每周")) } @Test fun `weekly Chinese stays`() { - assertEquals("每周", formatCodexPrimaryLabel("每周")) + assertEquals("每周", formatCodexPrimaryLabel("每周", weeklyLabel = "每周")) + assertEquals("Weekly", formatCodexPrimaryLabel("每周")) } @Test - fun `30d maps to monthly Chinese`() { - assertEquals("每月", formatCodexPrimaryLabel("30d")) + fun `30d maps to monthly label`() { + assertEquals("Monthly", formatCodexPrimaryLabel("30d")) + assertEquals("每月", formatCodexPrimaryLabel("30d", monthlyLabel = "每月")) + // 中性标识(durationLabel 输出) + assertEquals("Monthly", formatCodexPrimaryLabel("monthly")) + assertEquals("每月", formatCodexPrimaryLabel("monthly", monthlyLabel = "每月")) } @Test fun `monthly Chinese stays`() { - assertEquals("每月", formatCodexPrimaryLabel("每月")) + assertEquals("每月", formatCodexPrimaryLabel("每月", monthlyLabel = "每月")) + assertEquals("Monthly", formatCodexPrimaryLabel("每月")) } @Test - fun `usage maps to Chinese`() { - assertEquals("用量", formatCodexPrimaryLabel("usage")) + fun `usage maps to usage label`() { + assertEquals("Usage", formatCodexPrimaryLabel("usage")) + assertEquals("用量", formatCodexPrimaryLabel("usage", usageLabel = "用量")) } @Test @@ -55,7 +66,7 @@ class FormatCodexPrimaryLabelTest { @Test fun `case insensitive`() { assertEquals("5h", formatCodexPrimaryLabel("5H")) - assertEquals("每周", formatCodexPrimaryLabel("7D")) - assertEquals("每月", formatCodexPrimaryLabel("30D")) + assertEquals("Weekly", formatCodexPrimaryLabel("7D")) + assertEquals("Monthly", formatCodexPrimaryLabel("30D")) } } \ No newline at end of file diff --git a/docs/heatmap-plan.md b/docs/heatmap-plan.md index 2d70f1c..ffec74b 100644 --- a/docs/heatmap-plan.md +++ b/docs/heatmap-plan.md @@ -7,7 +7,8 @@ ## 一、入口与数据源 -- 主页新增一个入口,点击进入独立的 Token 活动热力图页面 +- 主页 OCGO 用量卡片「查看详情」右侧新增「Token 活动」快捷入口,点击进入独立的 Token 活动热力图页面(另有独立入口卡片) +- 首页入口卡片标题「OCGO Token 活动」、副标题「OCGO 每日 Token 使用热力图」(数据来源更准确) - **只统计 OCGO 服务的数据**,不涉及 CCGO / Codex / Ollama ## 二、页面布局 @@ -103,11 +104,11 @@ ## 十、Token 数显示格式 -- 按数量级自然转换为中文读法: - - 亿级:1.9亿 - - 千万级:1124.5万 - - 万级:11.2万 - - 万以下:原始数字(如 16783) +- 按数量级自然转换为中文读法(locale-aware): + - 中文:亿级 1.9亿 / 千万级 1124.5万 / 万级 11.2万 / 万以下原始数字(繁体用 萬/億) + - 非中文:≥1e9 → 1.9B / ≥1e6 → 11.2M / ≥1e3 → 16.8K / 否则原数字 +- 日期格式同理:中文 "7月9日"(跨年 "2024年7月9日");非中文 "Jul 9"(跨年 "Jul 9, 2024") +- 浮层/读数条句子("X月X日 使用了X token")走 stringResource(heatmap_day_used/heatmap_week_used),按语言拼接 ## 十一、年份选择器(2026-08 新增) @@ -131,7 +132,7 @@ ## 十三、年度统计(2026-08 新增) -- 视图切换器下方一行 4 个统计卡片,**按所选年份**计算(切换年份跟随变化),与图表口径一致 +- 视图切换器下方 **2×2 网格** 4 个统计卡片(每行 2 个,窄屏友好),**按所选年份**计算(切换年份跟随变化),与图表口径一致 - **累计 token**:当年全部天之和(= 累计视图最后一天的累计值) - **峰值 token**:当年单日最大 token(无数据=0) - **当前连续**:从数据最后一天往前数连续 >0 的天数(最后一天为 0 则=0)