feat: 12小时10分钟粒度 + UTC时区切换持久化 + 自动降级 + 刻度优化
Some checks failed
Release / build (push) Has been cancelled

- 新增 LAST_12H_10MIN 枚举及 aggregate10Min 聚合器(10分钟桶)
- ChartSettingsStore 持久化 UTC 偏好,跨会话保留
- 自动降级链:5h无数据→12h→7天→当月(用户手动选择后关闭降级)
- niceCeil 加细阈值,图表刻度不再太空
- setWorkspace 保留 useUtc8,CCGO 切换不丢失
- UTC 按钮追加 Snackbar 反馈 + "点击切换"提示
- 文档同步:AGENTS.md / README.md
This commit is contained in:
WaterRain 2026-06-27 03:49:44 +00:00
parent 4a63db2840
commit 26693c92d4
No known key found for this signature in database
9 changed files with 254 additions and 71 deletions

View File

@ -28,7 +28,8 @@ CommandCode Go 走 JSON API 抓取用量数据。APP 名为「雨晴Token」(
- ✅ `UsageCache`(DataStore,~3700 条记录)— 全量 JSON 序列化 + 内存缓存(`@Volatile cachedAll`),仅在写入后失效
- ✅ `SyncUsageUseCase`(OCGO)/ `SyncCommandCodeUsageUseCase`(CCGO) — 首次全量同步(cursor 翻页)、增量同步(逐页比对本地 ID 集合)
- ✅ `UsageViewModel` — `loadStatsInternal()` 单次 `getRecords()`→ 内存聚合 Overview/ModelStats/DailyStats,所有重操作包在 `withContext(Dispatchers.Default)` 避免主线程卡顿
- ✅ `UsageChartViewModel` — 图表粒度(5h/24h/今天/昨天/7天/当月/自定义日/月/范围),模型多选,3 张 Canvas 图表
- ✅ `UsageChartViewModel` — 图表粒度(5h/**12h(10min)**/24h/今天/昨天/7天/当月/自定义日/月/范围),模型多选,3 张 Canvas 图表;支持 **UTC+0/UTC+8 时区切换**(桶边界+标签双感知);**自动降级**(5h无数据→12h→7天→当月)
- ✅ `ChartSettingsStore`(DataStore Preferences)— 持久化 UTC 偏好,下次进入自动恢复
- ✅ `UsageDataViewModel` — 原始记录分页浏览(20条/页),支持时间+模型筛选,页码输入跳转
- ✅ 全局刷新绑定 — Dashboard 下拉刷新 → `DashboardViewModel.refresh()` → `UsageViewModel.sync()`(增量)

View File

@ -29,9 +29,9 @@ Android AI 余额与用量查询 APP —— 统一查看 DeepSeek、OpenCode Go
| 特性 | 说明 |
|------|------|
| 📊 **仪表盘** | DeepSeek 余额(¥)+ OpenCode Go 用量(5h/本周/本月)+ CommandCode Go 余额($)+ 下拉全局刷新 |
| 📈 **用量图表** | 3 张 Canvas 手绘图表 — 消耗金额 / API 请求次数 / Token 消耗(OCGO & CCGO 双数据源) |
| 📈 **用量图表** | 3 张 Canvas 手绘图表 — 消耗金额 / API 请求次数 / Token 消耗(OCGO & CCGO 双数据源);支持 UTC+0/UTC+8 时区切换;自动降级(近5h无数据→12h→7天→当月) |
| 📋 **详细数据** | 原始记录分页浏览,支持时间 + 模型筛选,点击查看完整字段 |
| 🔍 **多粒度筛选** | 5小时 / 24小时 / 今天 / 昨天 / 最近7天 / 当月 / 自定义日·月·范围 |
| 🔍 **多粒度筛选** | 5小时 / 12小时(10分钟桶) / 24小时 / 今天 / 昨天 / 最近7天 / 当月 / 自定义日·月·范围 |
| 🏷️ **模型筛选** | 多选 / 单选 / 全选,动态图例自适应换行 |
| 📱 **桌面小组件** | 不打开 APP 也能看用量;可拖入负一屏;划到即自动刷新(MIUI 曝光刷新) |
| 🔄 **自动同步** | 首页下拉自动同步用量;无缓存时启动自动全量同步;CCGO 详情页支持手动清除并重新同步 |

View File

@ -1,5 +1,8 @@
package com.rainy.token.data.local
import java.time.Instant
import java.time.ZoneOffset
/**
* 图表数据点——按时间桶聚合后的单条数据。
*/
@ -25,6 +28,7 @@ data class ModelBucket(
/** 时间粒度 */
enum class ChartGranularity(val label: String) {
LAST_5H_HOURLY("最近5小时"),
LAST_12H_10MIN("最近12小时"),
LAST_24H_HOURLY("最近24小时"),
TODAY_HOURLY("今天"),
YESTERDAY_HOURLY("昨天"),
@ -39,23 +43,59 @@ enum class ChartGranularity(val label: String) {
object ChartAggregator {
/**
* 按小时聚合(用于5小时/当天)。
* hourStart = timeCreated / 3600_000 * 3600_000
* 按 10 分钟聚合(用于最近12小时)。
* @param offsetHours 时区偏移。
*/
fun aggregateHourly(records: List<UsageRecord>): List<ChartBucket> {
fun aggregate10Min(records: List<UsageRecord>, offsetHours: Int = 0): List<ChartBucket> {
val offset = if (offsetHours == 0) ZoneOffset.UTC else ZoneOffset.ofHours(offsetHours)
return records
.groupBy { it.timeCreated / 3600_000L * 3600_000L }
.groupBy {
val ldt = Instant.ofEpochMilli(it.timeCreated).atOffset(offset).toLocalDateTime()
val min = ldt.minute / 10 * 10
ldt.withMinute(min).withSecond(0).withNano(0).atOffset(offset).toInstant().toEpochMilli()
}
.map { (ts, recs) -> bucketOf(ts, recs) }
.sortedBy { it.ts }
}
/**
* 按小时聚合。
* @param offsetHours 时区偏移(0=UTC,8=UTC+8),影响桶边界对齐。
*/
fun aggregateHourly(records: List<UsageRecord>, offsetHours: Int = 0): List<ChartBucket> {
if (offsetHours == 0) {
return records
.groupBy { it.timeCreated / 3600_000L * 3600_000L }
.map { (hourTs, recs) -> bucketOf(hourTs, recs) }
.sortedBy { it.ts }
}
val offset = ZoneOffset.ofHours(offsetHours)
return records
.groupBy {
val ldt = Instant.ofEpochMilli(it.timeCreated).atOffset(offset).toLocalDateTime()
ldt.withMinute(0).withSecond(0).withNano(0).atOffset(offset).toInstant().toEpochMilli()
}
.map { (hourTs, recs) -> bucketOf(hourTs, recs) }
.sortedBy { it.ts }
}
/**
* 按天聚合(用于当月/自定义月)。
* dayStart = timeCreated / 86400_000 * 86400_000
* @param offsetHours 时区偏移(0=UTC,8=UTC+8),影响桶边界对齐。
*/
fun aggregateDaily(records: List<UsageRecord>): List<ChartBucket> {
fun aggregateDaily(records: List<UsageRecord>, offsetHours: Int = 0): List<ChartBucket> {
if (offsetHours == 0) {
return records
.groupBy { it.timeCreated / 86_400_000L * 86_400_000L }
.map { (dayTs, recs) -> bucketOf(dayTs, recs) }
.sortedBy { it.ts }
}
val offset = ZoneOffset.ofHours(offsetHours)
return records
.groupBy { it.timeCreated / 86_400_000L * 86_400_000L }
.groupBy {
val localDate = Instant.ofEpochMilli(it.timeCreated).atOffset(offset).toLocalDate()
localDate.atStartOfDay(offset).toInstant().toEpochMilli()
}
.map { (dayTs, recs) -> bucketOf(dayTs, recs) }
.sortedBy { it.ts }
}

View File

@ -0,0 +1,43 @@
package com.rainy.token.data.local
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
private val Context.chartSettingsDataStore: DataStore<Preferences> by preferencesDataStore(
name = "chart_settings"
)
/** 图表页偏好设置持久化 */
class ChartSettingsStore(private val dataStore: DataStore<Preferences>) {
companion object {
private val KEY_USE_UTC8 = booleanPreferencesKey("use_utc8")
}
/** 流式读取偏好 */
val useUtc8Flow: Flow<Boolean> = dataStore.data.map { prefs ->
prefs[KEY_USE_UTC8] ?: false
}
/** 同步读取(用于 ViewModel 初始化) */
fun getUseUtc8(): Boolean = runBlocking {
dataStore.data.map { it[KEY_USE_UTC8] ?: false }.first()
}
/** 写入偏好 */
suspend fun setUseUtc8(value: Boolean) {
dataStore.edit { it[KEY_USE_UTC8] = value }
}
}
/** 顶层 DataStore 委托 */
val Context.chartSettingsStore: ChartSettingsStore
get() = ChartSettingsStore(chartSettingsDataStore)

View File

@ -3,6 +3,8 @@ package com.rainy.token.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import com.rainy.token.data.local.ChartSettingsStore
import com.rainy.token.data.local.chartSettingsStore
import com.rainy.token.data.local.secureStorageDataStore
import com.rainy.token.data.local.usageCacheDataStore
import dagger.Module
@ -48,4 +50,10 @@ object StorageModule {
fun provideUsageCacheDataStore(
@ApplicationContext context: Context
): DataStore<Preferences> = context.usageCacheDataStore
@Provides
@Singleton
fun provideChartSettingsStore(
@ApplicationContext context: Context
): ChartSettingsStore = context.chartSettingsStore
}

View File

@ -62,7 +62,7 @@ fun RainyTokenNavHost() {
}
composable(Routes.USAGE_DETAIL) {
UsageDetailScreen(
onBack = { navController.popBackStack() },
onBack = { navController.popBackStack(Routes.DASHBOARD, inclusive = false) },
onOpenOverview = { navController.navigate(Routes.USAGE_OVERVIEW) },
onOpenData = { navController.navigate(Routes.USAGE_DATA) }
)
@ -87,7 +87,7 @@ fun RainyTokenNavHost() {
chartVm.setWorkspace(wid)
}
UsageDetailScreen(
onBack = { navController.popBackStack() },
onBack = { navController.popBackStack(Routes.DASHBOARD, inclusive = false) },
onOpenOverview = { navController.navigate(Routes.CCGO_USAGE_OVERVIEW) },
onOpenData = { navController.navigate(Routes.CCGO_USAGE_DATA) },
viewModel = chartVm,
@ -118,7 +118,8 @@ fun RainyTokenNavHost() {
}
composable(Routes.SETTINGS) {
SettingsScreen(
onBack = { navController.popBackStack() },
// 使用 popBackStack 到指定路由而非无参 popBackStack,防止双击返回键导致空白页
onBack = { navController.popBackStack(Routes.DASHBOARD, inclusive = false) },
onEditCredential = { type -> navController.navigate(Routes.credentialEdit(type)) }
)
}
@ -155,7 +156,7 @@ fun RainyTokenNavHost() {
val type = parseServiceType(backStackEntry.arguments?.getString("type"))
ServiceDetailScreen(
service = type,
onBack = { navController.popBackStack() },
onBack = { navController.popBackStack(Routes.DASHBOARD, inclusive = false) },
onConfigureCredential = { svc -> navController.navigate(Routes.credentialEdit(svc)) },
onStartWebViewLogin = { svc -> navController.navigate(Routes.webviewLogin(svc)) }
)

View File

@ -455,7 +455,8 @@ internal fun StackedBarChart(
tooltipReversed: Boolean = false,
formatValue: (Double) -> String,
granularity: ChartGranularity,
legendItems: List<Pair<String, Color>> = emptyList()
legendItems: List<Pair<String, Color>> = emptyList(),
useUtc8: Boolean = false
) {
if (buckets.isEmpty()) {
Text("暂无数据", color = InkMuted, style = MaterialTheme.typography.bodySmall)
@ -468,7 +469,7 @@ internal fun StackedBarChart(
val chartHPx = 160f * d
val labelHPx = 20f * d
val maxVal = buckets.maxOf { valueSelector(it) }.coerceAtLeast(1.0)
val maxVal = buckets.maxOf { valueSelector(it) }.coerceAtLeast(0.0)
val refTop = niceCeil(maxVal)
val refHalf = refTop / 2.0
// 以 refTop 为满刻度,柱子不会触顶
@ -512,7 +513,7 @@ internal fun StackedBarChart(
drawRect(color, Offset(x, yBase - h), Size(barW, h))
yBase -= h
}
val (label, show) = formatChartTime(bucket.ts, granularity, i, barCount)
val (label, show) = formatChartTime(bucket.ts, granularity, i, barCount, useUtc8)
if (show) {
drawContext.canvas.nativeCanvas.drawText(
label, x + barW / 2, chartHPx + labelHPx - 4f * d,
@ -552,7 +553,7 @@ internal fun StackedBarChart(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Column(Modifier.padding(8.dp)) {
Text(formatChartTime(bucket.ts, granularity, 0, 1).first, style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold)
Text(formatChartTime(bucket.ts, granularity, 0, 1, useUtc8).first, style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold)
Text("合计: ${formatValue(total)}", style = MaterialTheme.typography.bodySmall, color = StrawberryPink)
val labels = stackLabels(bucket)
val items = stackSelector(bucket).withIndex()
@ -584,7 +585,8 @@ internal fun LineChart(
valueSelector: (ChartBucket) -> Float,
lineColor: Color,
formatValue: (Float) -> String,
granularity: ChartGranularity
granularity: ChartGranularity,
useUtc8: Boolean = false
) {
if (buckets.isEmpty()) {
Text("暂无数据", color = InkMuted, style = MaterialTheme.typography.bodySmall)
@ -596,7 +598,7 @@ internal fun LineChart(
val barCount = buckets.size
val chartHPx = 140f * d
val labelHPx = 20f * d
val maxVal = buckets.maxOf { valueSelector(it) }.coerceAtLeast(1f)
val maxVal = buckets.maxOf { valueSelector(it) }.coerceAtLeast(0f)
val refTop = niceCeil(maxVal.toDouble()).toFloat()
val refHalf = refTop / 2f
val scale = refTop
@ -641,7 +643,7 @@ internal fun LineChart(
drawCircle(lineColor, radius = 3f * d, center = p)
}
buckets.forEachIndexed { i, b ->
val (label, show) = formatChartTime(b.ts, granularity, i, barCount)
val (label, show) = formatChartTime(b.ts, granularity, i, barCount, useUtc8)
if (show) {
drawContext.canvas.nativeCanvas.drawText(
label, i * barAreaPx + barAreaPx / 2, chartHPx + labelHPx - 4f * d,
@ -711,34 +713,38 @@ internal fun ChartLegend(items: List<Pair<String, Color>>) {
// ═══════════════════════════════════════════
/** 返回格式化标签 + 是否应该绘制 */
private fun formatChartTime(ts: Long, granularity: ChartGranularity, index: Int, total: Int): Pair<String, Boolean> {
val show = when (granularity) {
ChartGranularity.LAST_24H_HOURLY ->
index == 0 || index == total - 1 || index % 3 == 0 // 每3小时
ChartGranularity.TODAY_HOURLY,
ChartGranularity.YESTERDAY_HOURLY,
ChartGranularity.CUSTOM_DAY_HOURLY ->
index == 0 || index == total - 1 // 只显示首尾小时
ChartGranularity.LAST_7D_DAILY,
ChartGranularity.THIS_MONTH_DAILY,
ChartGranularity.CUSTOM_MONTH_DAILY,
ChartGranularity.CUSTOM_RANGE_DAILY ->
index == 0 || index == total - 1 // 只显示首尾日期
else -> true // 5小时全显示
}
val fmt = when (granularity) {
ChartGranularity.LAST_5H_HOURLY,
ChartGranularity.LAST_24H_HOURLY,
ChartGranularity.TODAY_HOURLY,
ChartGranularity.YESTERDAY_HOURLY,
ChartGranularity.CUSTOM_DAY_HOURLY -> SimpleDateFormat("HH:mm", Locale.getDefault()).apply {
timeZone = java.util.TimeZone.getTimeZone("UTC")
}
else -> SimpleDateFormat("MM/dd", Locale.getDefault()).apply {
timeZone = java.util.TimeZone.getTimeZone("UTC")
}
}
return fmt.format(Date(ts)) to show
private fun formatChartTime(ts: Long, granularity: ChartGranularity, index: Int, total: Int, useUtc8: Boolean = false): Pair<String, Boolean> {
val show = when (granularity) {
ChartGranularity.LAST_12H_10MIN ->
index == 0 || index == total - 1 || index % 12 == 0 // 每2小时(每12个10分钟桶)
ChartGranularity.LAST_24H_HOURLY ->
index == 0 || index == total - 1 || index % 3 == 0 // 每3小时
ChartGranularity.TODAY_HOURLY,
ChartGranularity.YESTERDAY_HOURLY,
ChartGranularity.CUSTOM_DAY_HOURLY ->
index == 0 || index == total - 1 // 只显示首尾小时
ChartGranularity.LAST_7D_DAILY,
ChartGranularity.THIS_MONTH_DAILY,
ChartGranularity.CUSTOM_MONTH_DAILY,
ChartGranularity.CUSTOM_RANGE_DAILY ->
index == 0 || index == total - 1 // 只显示首尾日期
else -> true // 5小时全显示
}
val tz = if (useUtc8) java.util.TimeZone.getTimeZone("Asia/Shanghai") else java.util.TimeZone.getTimeZone("UTC")
val fmt = when (granularity) {
ChartGranularity.LAST_12H_10MIN,
ChartGranularity.LAST_5H_HOURLY,
ChartGranularity.LAST_24H_HOURLY,
ChartGranularity.TODAY_HOURLY,
ChartGranularity.YESTERDAY_HOURLY,
ChartGranularity.CUSTOM_DAY_HOURLY -> SimpleDateFormat("HH:mm", Locale.getDefault()).apply {
timeZone = tz
}
else -> SimpleDateFormat("MM/dd", Locale.getDefault()).apply {
timeZone = tz
}
}
return fmt.format(Date(ts)) to show
}
private fun formatTokenCount(tokens: Long): String = when {
@ -760,9 +766,15 @@ private fun niceCeil(v: Double): Double {
val normalized = v / magnitude // 1..10
val nice = when {
normalized <= 1.0 -> 1.0
normalized <= 1.15 -> 1.15 // 15% 余量
normalized <= 1.25 -> 1.25 // 25% 余量
normalized <= 1.5 -> 1.5 // 50% 余量
normalized <= 2.0 -> 2.0
normalized <= 2.5 -> 2.5
normalized <= 3.0 -> 3.0
normalized <= 4.0 -> 4.0
normalized <= 5.0 -> 5.0
normalized <= 7.5 -> 7.5
else -> 10.0
}
return nice * magnitude

View File

@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
import com.rainy.token.data.local.ChartAggregator
import com.rainy.token.data.local.ChartBucket
import com.rainy.token.data.local.ChartGranularity
import com.rainy.token.data.local.ChartSettingsStore
import com.rainy.token.data.local.UsageCache
import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.domain.model.Credential
@ -23,21 +24,27 @@ import javax.inject.Provider
@HiltViewModel
class UsageChartViewModel @Inject constructor(
private val cacheProvider: Provider<UsageCache>,
private val credentialRepository: CredentialRepository
private val credentialRepository: CredentialRepository,
private val chartSettingsStore: ChartSettingsStore
) : ViewModel() {
private val _state = MutableStateFlow(ChartUiState())
private val _state = MutableStateFlow(
ChartUiState(useUtc8 = chartSettingsStore.getUseUtc8())
)
val state: StateFlow<ChartUiState> = _state.asStateFlow()
private var workspaceIdOverride: String? = null
private var loadGeneration = 0 // 递增:过时的 load() 结果自动丢弃
private var loadGeneration = 0
/** 是否允许自动降级到更大时间窗口(仅初始加载时允许,用户手动选粒度后关闭) */
private var allowFallback = true
/** 覆盖 workspaceId,用于 CCGO 等非 OCGO 服务。必须在 load() 前调用。 */
fun setWorkspace(wid: String) {
workspaceIdOverride = wid
loadGeneration++
// 先清空数据防止 init 自动加载的 OCGO 数据闪一下
_state.value = ChartUiState()
allowFallback = true // CCGO 首次加载也允许降级
// 先清空数据防止 init 自动加载的 OCGO 数据闪一下,但保留持久化偏好
_state.value = ChartUiState(useUtc8 = _state.value.useUtc8)
load()
}
@ -50,6 +57,7 @@ class UsageChartViewModel @Inject constructor(
// 不在 init 自动加载,由 Composable 层显式触发 load()(OCGO)或 setWorkspace()(CCGO)
fun setGranularity(g: ChartGranularity) {
allowFallback = false // 用户手动选择,关闭自动降级
_state.update { it.copy(granularity = g) }
load()
}
@ -73,6 +81,14 @@ class UsageChartViewModel @Inject constructor(
load()
}
fun toggleUtc8() {
_state.update { it.copy(useUtc8 = !it.useUtc8) }
viewModelScope.launch {
chartSettingsStore.setUseUtc8(_state.value.useUtc8)
}
load()
}
fun load() {
viewModelScope.launch {
val wid = workspaceId() ?: return@launch
@ -88,18 +104,21 @@ class UsageChartViewModel @Inject constructor(
val filtered = if (selected.isEmpty()) records
else records.filter { it.model in selected }
val offsetHours = if (_state.value.useUtc8) 8 else 0
val buckets = when (_state.value.granularity) {
ChartGranularity.LAST_12H_10MIN ->
ChartAggregator.aggregate10Min(filtered, offsetHours)
ChartGranularity.LAST_5H_HOURLY,
ChartGranularity.LAST_24H_HOURLY,
ChartGranularity.TODAY_HOURLY,
ChartGranularity.YESTERDAY_HOURLY,
ChartGranularity.CUSTOM_DAY_HOURLY ->
ChartAggregator.aggregateHourly(filtered)
ChartAggregator.aggregateHourly(filtered, offsetHours)
ChartGranularity.THIS_MONTH_DAILY,
ChartGranularity.LAST_7D_DAILY,
ChartGranularity.CUSTOM_MONTH_DAILY,
ChartGranularity.CUSTOM_RANGE_DAILY ->
ChartAggregator.aggregateDaily(filtered)
ChartAggregator.aggregateDaily(filtered, offsetHours)
}
// 填充可能空缺的小时/天桶
@ -111,6 +130,24 @@ class UsageChartViewModel @Inject constructor(
// 如果 loadGeneration 已经变化(setWorkspace 被调用),丢弃这次结果
if (loadGeneration != genAtStart) return@launch
// 自动降级:当前粒度无实际数据且允许降级 → 尝试更大窗口
val hasData = result.any { it.totalRequests > 0 }
if (allowFallback && !hasData) {
val fallbackTo = when (_state.value.granularity) {
ChartGranularity.LAST_5H_HOURLY -> ChartGranularity.LAST_12H_10MIN
ChartGranularity.LAST_12H_10MIN -> ChartGranularity.LAST_7D_DAILY
ChartGranularity.LAST_7D_DAILY -> ChartGranularity.THIS_MONTH_DAILY
else -> null
}
if (fallbackTo != null) {
_state.update { it.copy(granularity = fallbackTo) }
load() // 递归重新加载
return@launch
}
}
// 降级后或用户手动选择 → 关闭降级,正常显示
allowFallback = false
_state.update {
it.copy(
buckets = result,
@ -145,10 +182,12 @@ class UsageChartViewModel @Inject constructor(
private fun timeRangeFor(g: ChartGranularity): Pair<Long?, Long?> {
val now = System.currentTimeMillis()
val utc = ZoneOffset.UTC
val today = Instant.ofEpochMilli(now).atOffset(utc).toLocalDate()
val todayMidnight = today.atStartOfDay(utc).toInstant().toEpochMilli()
val useUtc8 = _state.value.useUtc8
val zoneOffset = if (useUtc8) ZoneOffset.ofHours(8) else ZoneOffset.UTC
val today = Instant.ofEpochMilli(now).atOffset(zoneOffset).toLocalDate()
val todayMidnight = today.atStartOfDay(zoneOffset).toInstant().toEpochMilli()
return when (g) {
ChartGranularity.LAST_12H_10MIN -> now - 12 * 3600_000L to null
ChartGranularity.LAST_5H_HOURLY -> now - 5 * 3600_000L to null
ChartGranularity.LAST_24H_HOURLY -> now - 24 * 3600_000L to null
ChartGranularity.TODAY_HOURLY -> todayMidnight to todayMidnight + 86400_000L - 1
@ -161,19 +200,19 @@ class UsageChartViewModel @Inject constructor(
else _customDayRange.first to _customDayRange.second
}
ChartGranularity.THIS_MONTH_DAILY -> {
val firstOfMonth = today.withDayOfMonth(1).atStartOfDay(utc).toInstant().toEpochMilli()
val firstOfMonth = today.withDayOfMonth(1).atStartOfDay(zoneOffset).toInstant().toEpochMilli()
val lastOfMonth = today.withDayOfMonth(today.lengthOfMonth())
.atStartOfDay(utc).toInstant().toEpochMilli() + 86400_000L - 1
.atStartOfDay(zoneOffset).toInstant().toEpochMilli() + 86400_000L - 1
firstOfMonth to lastOfMonth
}
ChartGranularity.CUSTOM_MONTH_DAILY -> {
if (_customMonthStart == 0L) null to null
else {
val targetMonth = Instant.ofEpochMilli(_customMonthStart).atOffset(utc).toLocalDate()
val targetMonth = Instant.ofEpochMilli(_customMonthStart).atOffset(zoneOffset).toLocalDate()
.withDayOfMonth(1)
val firstOfMonth = targetMonth.atStartOfDay(utc).toInstant().toEpochMilli()
val firstOfMonth = targetMonth.atStartOfDay(zoneOffset).toInstant().toEpochMilli()
val lastOfMonth = targetMonth.withDayOfMonth(targetMonth.lengthOfMonth())
.atStartOfDay(utc).toInstant().toEpochMilli() + 86400_000L - 1
.atStartOfDay(zoneOffset).toInstant().toEpochMilli() + 86400_000L - 1
firstOfMonth to lastOfMonth
}
}
@ -192,6 +231,7 @@ class UsageChartViewModel @Inject constructor(
): List<ChartBucket> {
if (buckets.isEmpty() || from == null) return buckets
val interval = when (granularity) {
ChartGranularity.LAST_12H_10MIN -> 600_000L // 10分钟
ChartGranularity.LAST_5H_HOURLY,
ChartGranularity.LAST_24H_HOURLY,
ChartGranularity.TODAY_HOURLY,
@ -202,7 +242,9 @@ class UsageChartViewModel @Inject constructor(
val end = to ?: (System.currentTimeMillis() / interval * interval)
val result = mutableListOf<ChartBucket>()
val map = buckets.associateBy { it.ts }
var t = from / interval * interval
// 日粒度:from 已由 timeRangeFor 按当前时区对齐到午夜,直接用
// 小时粒度:from 是滑动窗口起点,需截断到 interval 边界
var t = if (interval == 86_400_000L) from else from / interval * interval
while (t <= end) {
result.add(map[t] ?: ChartBucket(ts = t, totalCost = 0, totalRequests = 0,
inputTokens = 0, cacheHitTokens = 0, outputTokens = 0))
@ -217,5 +259,6 @@ data class ChartUiState(
val granularity: ChartGranularity = ChartGranularity.LAST_5H_HOURLY,
val allModels: List<String> = emptyList(),
val selectedModels: Set<String> = emptySet(), // empty = all
val buckets: List<ChartBucket> = emptyList()
val buckets: List<ChartBucket> = emptyList(),
val useUtc8: Boolean = false
)

View File

@ -25,6 +25,9 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TimePicker
@ -38,6 +41,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@ -49,6 +53,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.rainy.token.data.local.ChartGranularity
import com.rainy.token.ui.theme.InkMuted
import com.rainy.token.ui.theme.StrawberryPink
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.time.Instant
import java.time.LocalDate
@ -88,9 +93,12 @@ fun UsageDetailScreen(
var showCostDetail by remember { mutableStateOf(false) }
var showReqDetail by remember { mutableStateOf(false) }
var showTokenDetail by remember { mutableStateOf(false) }
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
Scaffold(
containerColor = Color.Transparent,
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = { Text("用量详情") },
@ -139,7 +147,28 @@ fun UsageDetailScreen(
}
}
}
Text("UTC+0", style = MaterialTheme.typography.bodySmall, color = InkMuted)
TextButton(onClick = {
viewModel.toggleUtc8()
scope.launch {
snackbarHostState.showSnackbar(
if (state.useUtc8) "已切换到 UTC+8,图表按北京时间显示" else "已切换到 UTC+0,图表按世界协调时显示",
duration = SnackbarDuration.Short
)
}
}) {
Text(
if (state.useUtc8) "UTC+8」" else "UTC+0」",
style = MaterialTheme.typography.bodySmall,
color = if (state.useUtc8) StrawberryPink else InkMuted,
fontWeight = if (state.useUtc8) FontWeight.SemiBold else FontWeight.Normal
)
Text(
" 点击切换",
style = MaterialTheme.typography.bodySmall,
color = InkMuted.copy(alpha = 0.5f),
fontWeight = FontWeight.Normal
)
}
Spacer(Modifier.weight(1f))
Box {
val label = if (state.selectedModels.isEmpty()) "全部模型" else "${state.selectedModels.size} 个模型"
@ -175,14 +204,18 @@ fun UsageDetailScreen(
{ bucket -> models.mapNotNull { if (bucket.byModel[it] != null) it else null } },
formatValue = { "$${String.format(Locale.US, "%.4f", it)}" },
granularity = state.granularity,
legendItems = models.mapIndexed { idx, m -> m to modelColors[idx % modelColors.size] })
legendItems = models.mapIndexed { idx, m ->
m to modelColors[idx % modelColors.size]
},
useUtc8 = state.useUtc8
)
}
}
// 图表2
item {
val reqTotal = state.buckets.sumOf { it.totalRequests }
ChartCard("API 请求次数", "${reqTotal}次", { showReqDetail = true }) {
LineChart(state.buckets, { it.totalRequests.toFloat() }, StrawberryPink, { "${it.toInt()}次" }, state.granularity)
LineChart(state.buckets, { it.totalRequests.toFloat() }, StrawberryPink, { "${it.toInt()}次" }, state.granularity, useUtc8 = state.useUtc8)
}
}
// 图表3
@ -193,9 +226,11 @@ fun UsageDetailScreen(
{ (it.cacheHitTokens + it.inputTokens + it.outputTokens).toDouble() },
{ bucket -> listOfNotNull(bucket.outputTokens.toDouble() to tokenColors[2], bucket.inputTokens.toDouble() to tokenColors[1], bucket.cacheHitTokens.toDouble() to tokenColors[0]) },
{ listOf("输出", "输入(未命中)", "命中缓存") },
tooltipReversed = true,
formatValue = { formatTokenComma(it.toLong()) },
granularity = state.granularity)
granularity = state.granularity,
tooltipReversed = true,
useUtc8 = state.useUtc8
)
}
ChartLegend(listOf("输入(未命中)" to tokenColors[1], "命中缓存" to tokenColors[0], "输出" to tokenColors[2]))
}