feat(heatmap): 首页OCGO快捷入口 + 多语言兼容修复

- 首页: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 用例)
This commit is contained in:
WaterRain 2026-08-07 03:28:23 +00:00
parent 03424e6184
commit 025f971f7a
No known key found for this signature in database
20 changed files with 398 additions and 162 deletions

View File

@ -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<ServiceBalance> = withContext(Dispatchers.IO) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<HeatmapDayData>, zone: ZoneOffset): Lis
}
}
/** 从按周分组的每日数据计算月份标签 */
private fun getMonthLabels(weeks: List<List<HeatmapDayData?>>, zone: ZoneOffset): List<MonthLabel> {
/** 从按周分组的每日数据计算月份标签([monthNames] 为当前 locale 的月份名,索引 0~11) */
private fun getMonthLabels(
weeks: List<List<HeatmapDayData?>>,
zone: ZoneOffset,
monthNames: List<String>,
): List<MonthLabel> {
if (weeks.isEmpty()) return emptyList()
val rawLabels = mutableListOf<MonthLabel>()
@ -226,7 +262,7 @@ private fun getMonthLabels(weeks: List<List<HeatmapDayData?>>, 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) {

View File

@ -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 处;

View File

@ -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<String, String>): List<CodexWindow>
@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"])

View File

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

View File

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

View File

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

View File

@ -498,8 +498,9 @@
<string name="heatmap_view_daily">每日</string>
<string name="heatmap_view_weekly">每周</string>
<string name="heatmap_view_cumulative">累计</string>
<string name="heatmap_entry">Token 活动</string>
<string name="heatmap_entry_subtitle">每日 Token 使用热力图</string>
<string name="heatmap_entry">OCGO Token 活动</string>
<string name="heatmap_entry_subtitle">OCGO 每日 Token 使用热力图</string>
<string name="heatmap_quick_entry">Token 活动</string>
<string name="heatmap_no_data">暂无数据</string>
<string name="heatmap_legend_less">少</string>
<string name="heatmap_legend_more">多</string>
@ -513,4 +514,20 @@
<plurals name="heatmap_stats_days">
<item quantity="other">%1$d 天</item>
</plurals>
<string name="heatmap_day_used">%1$s使用了%2$s token</string>
<string name="heatmap_week_used">%1$s使用了%2$s token</string>
<string name="month_1">一月</string>
<string name="month_2">二月</string>
<string name="month_3">三月</string>
<string name="month_4">四月</string>
<string name="month_5">五月</string>
<string name="month_6">六月</string>
<string name="month_7">七月</string>
<string name="month_8">八月</string>
<string name="month_9">九月</string>
<string name="month_10">十月</string>
<string name="month_11">十一月</string>
<string name="month_12">十二月</string>
<string name="field_api_key">API Key</string>
<string name="field_workspace_id">Workspace ID</string>
</resources>

View File

@ -498,8 +498,9 @@
<string name="heatmap_view_daily">每日</string>
<string name="heatmap_view_weekly">每週</string>
<string name="heatmap_view_cumulative">累計</string>
<string name="heatmap_entry">Token 活動</string>
<string name="heatmap_entry_subtitle">每日 Token 使用熱力圖</string>
<string name="heatmap_entry">OCGO Token 活動</string>
<string name="heatmap_entry_subtitle">OCGO 每日 Token 使用熱力圖</string>
<string name="heatmap_quick_entry">Token 活動</string>
<string name="heatmap_no_data">暫無資料</string>
<string name="heatmap_legend_less">少</string>
<string name="heatmap_legend_more">多</string>
@ -513,4 +514,20 @@
<plurals name="heatmap_stats_days">
<item quantity="other">%1$d 天</item>
</plurals>
<string name="heatmap_day_used">%1$s使用了%2$s Token</string>
<string name="heatmap_week_used">%1$s使用了%2$s Token</string>
<string name="month_1">一月</string>
<string name="month_2">二月</string>
<string name="month_3">三月</string>
<string name="month_4">四月</string>
<string name="month_5">五月</string>
<string name="month_6">六月</string>
<string name="month_7">七月</string>
<string name="month_8">八月</string>
<string name="month_9">九月</string>
<string name="month_10">十月</string>
<string name="month_11">十一月</string>
<string name="month_12">十二月</string>
<string name="field_api_key">API Key</string>
<string name="field_workspace_id">Workspace ID</string>
</resources>

View File

@ -501,8 +501,9 @@
<string name="heatmap_view_daily">Daily</string>
<string name="heatmap_view_weekly">Weekly</string>
<string name="heatmap_view_cumulative">Cumulative</string>
<string name="heatmap_entry">Token Activity</string>
<string name="heatmap_entry_subtitle">Daily Token usage heatmap</string>
<string name="heatmap_entry">OCGO Token Activity</string>
<string name="heatmap_entry_subtitle">OCGO daily Token usage heatmap</string>
<string name="heatmap_quick_entry">Token Activity</string>
<string name="heatmap_no_data">No data yet</string>
<string name="heatmap_legend_less">Less</string>
<string name="heatmap_legend_more">More</string>
@ -517,4 +518,20 @@
<item quantity="one">%1$d day</item>
<item quantity="other">%1$d days</item>
</plurals>
<string name="heatmap_day_used">%1$s used %2$s tokens</string>
<string name="heatmap_week_used">%1$s used %2$s tokens</string>
<string name="month_1">Jan</string>
<string name="month_2">Feb</string>
<string name="month_3">Mar</string>
<string name="month_4">Apr</string>
<string name="month_5">May</string>
<string name="month_6">Jun</string>
<string name="month_7">Jul</string>
<string name="month_8">Aug</string>
<string name="month_9">Sep</string>
<string name="month_10">Oct</string>
<string name="month_11">Nov</string>
<string name="month_12">Dec</string>
<string name="field_api_key">API Key</string>
<string name="field_workspace_id">Workspace ID</string>
</resources>

View File

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

View File

@ -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 = """
<html><body>
<div aria-label="Session usage 8.0% used">
@ -209,7 +209,7 @@ class OllamaRepositoryTest {
""".trimIndent()
val result = OllamaRepository.parseUsage(html)
assertNotNull(result)
assertEquals("Unknown", result!!.plan)
assertEquals("", result!!.plan)
}
@Test

View File

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

View File

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

View File

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