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:
parent
03424e6184
commit
025f971f7a
@ -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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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("小時"))
|
||||
}
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 处;
|
||||
|
||||
@ -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"])
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"))
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user