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`),仅在写入后失效 - ✅ `UsageCache`(DataStore,~3700 条记录)— 全量 JSON 序列化 + 内存缓存(`@Volatile cachedAll`),仅在写入后失效
- ✅ `SyncUsageUseCase`(OCGO)/ `SyncCommandCodeUsageUseCase`(CCGO) — 首次全量同步(cursor 翻页)、增量同步(逐页比对本地 ID 集合) - ✅ `SyncUsageUseCase`(OCGO)/ `SyncCommandCodeUsageUseCase`(CCGO) — 首次全量同步(cursor 翻页)、增量同步(逐页比对本地 ID 集合)
- ✅ `UsageViewModel` — `loadStatsInternal()` 单次 `getRecords()`→ 内存聚合 Overview/ModelStats/DailyStats,所有重操作包在 `withContext(Dispatchers.Default)` 避免主线程卡顿 - ✅ `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条/页),支持时间+模型筛选,页码输入跳转 - ✅ `UsageDataViewModel` — 原始记录分页浏览(20条/页),支持时间+模型筛选,页码输入跳转
- ✅ 全局刷新绑定 — Dashboard 下拉刷新 → `DashboardViewModel.refresh()` → `UsageViewModel.sync()`(增量) - ✅ 全局刷新绑定 — Dashboard 下拉刷新 → `DashboardViewModel.refresh()` → `UsageViewModel.sync()`(增量)

View File

@ -29,9 +29,9 @@ Android AI 余额与用量查询 APP —— 统一查看 DeepSeek、OpenCode Go
| 特性 | 说明 | | 特性 | 说明 |
|------|------| |------|------|
| 📊 **仪表盘** | DeepSeek 余额(¥)+ OpenCode Go 用量(5h/本周/本月)+ CommandCode 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 曝光刷新) | | 📱 **桌面小组件** | 不打开 APP 也能看用量;可拖入负一屏;划到即自动刷新(MIUI 曝光刷新) |
| 🔄 **自动同步** | 首页下拉自动同步用量;无缓存时启动自动全量同步;CCGO 详情页支持手动清除并重新同步 | | 🔄 **自动同步** | 首页下拉自动同步用量;无缓存时启动自动全量同步;CCGO 详情页支持手动清除并重新同步 |

View File

@ -1,5 +1,8 @@
package com.rainy.token.data.local 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) { enum class ChartGranularity(val label: String) {
LAST_5H_HOURLY("最近5小时"), LAST_5H_HOURLY("最近5小时"),
LAST_12H_10MIN("最近12小时"),
LAST_24H_HOURLY("最近24小时"), LAST_24H_HOURLY("最近24小时"),
TODAY_HOURLY("今天"), TODAY_HOURLY("今天"),
YESTERDAY_HOURLY("昨天"), YESTERDAY_HOURLY("昨天"),
@ -39,26 +43,62 @@ enum class ChartGranularity(val label: String) {
object ChartAggregator { object ChartAggregator {
/** /**
* 按小时聚合(用于5小时/当天)。 * 按 10 分钟聚合(用于最近12小时)。
* hourStart = timeCreated / 3600_000 * 3600_000 * @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 {
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 return records
.groupBy { it.timeCreated / 3600_000L * 3600_000L } .groupBy { it.timeCreated / 3600_000L * 3600_000L }
.map { (hourTs, recs) -> bucketOf(hourTs, recs) } .map { (hourTs, recs) -> bucketOf(hourTs, recs) }
.sortedBy { it.ts } .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 return records
.groupBy { it.timeCreated / 86_400_000L * 86_400_000L } .groupBy { it.timeCreated / 86_400_000L * 86_400_000L }
.map { (dayTs, recs) -> bucketOf(dayTs, recs) } .map { (dayTs, recs) -> bucketOf(dayTs, recs) }
.sortedBy { it.ts } .sortedBy { it.ts }
} }
val offset = ZoneOffset.ofHours(offsetHours)
return records
.groupBy {
val localDate = Instant.ofEpochMilli(it.timeCreated).atOffset(offset).toLocalDate()
localDate.atStartOfDay(offset).toInstant().toEpochMilli()
}
.map { (dayTs, recs) -> bucketOf(dayTs, recs) }
.sortedBy { it.ts }
}
private fun bucketOf(ts: Long, records: List<UsageRecord>): ChartBucket { private fun bucketOf(ts: Long, records: List<UsageRecord>): ChartBucket {
val byModel = records val byModel = records

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 android.content.Context
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences 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.secureStorageDataStore
import com.rainy.token.data.local.usageCacheDataStore import com.rainy.token.data.local.usageCacheDataStore
import dagger.Module import dagger.Module
@ -48,4 +50,10 @@ object StorageModule {
fun provideUsageCacheDataStore( fun provideUsageCacheDataStore(
@ApplicationContext context: Context @ApplicationContext context: Context
): DataStore<Preferences> = context.usageCacheDataStore ): 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) { composable(Routes.USAGE_DETAIL) {
UsageDetailScreen( UsageDetailScreen(
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack(Routes.DASHBOARD, inclusive = false) },
onOpenOverview = { navController.navigate(Routes.USAGE_OVERVIEW) }, onOpenOverview = { navController.navigate(Routes.USAGE_OVERVIEW) },
onOpenData = { navController.navigate(Routes.USAGE_DATA) } onOpenData = { navController.navigate(Routes.USAGE_DATA) }
) )
@ -87,7 +87,7 @@ fun RainyTokenNavHost() {
chartVm.setWorkspace(wid) chartVm.setWorkspace(wid)
} }
UsageDetailScreen( UsageDetailScreen(
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack(Routes.DASHBOARD, inclusive = false) },
onOpenOverview = { navController.navigate(Routes.CCGO_USAGE_OVERVIEW) }, onOpenOverview = { navController.navigate(Routes.CCGO_USAGE_OVERVIEW) },
onOpenData = { navController.navigate(Routes.CCGO_USAGE_DATA) }, onOpenData = { navController.navigate(Routes.CCGO_USAGE_DATA) },
viewModel = chartVm, viewModel = chartVm,
@ -118,7 +118,8 @@ fun RainyTokenNavHost() {
} }
composable(Routes.SETTINGS) { composable(Routes.SETTINGS) {
SettingsScreen( SettingsScreen(
onBack = { navController.popBackStack() }, // 使用 popBackStack 到指定路由而非无参 popBackStack,防止双击返回键导致空白页
onBack = { navController.popBackStack(Routes.DASHBOARD, inclusive = false) },
onEditCredential = { type -> navController.navigate(Routes.credentialEdit(type)) } onEditCredential = { type -> navController.navigate(Routes.credentialEdit(type)) }
) )
} }
@ -155,7 +156,7 @@ fun RainyTokenNavHost() {
val type = parseServiceType(backStackEntry.arguments?.getString("type")) val type = parseServiceType(backStackEntry.arguments?.getString("type"))
ServiceDetailScreen( ServiceDetailScreen(
service = type, service = type,
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack(Routes.DASHBOARD, inclusive = false) },
onConfigureCredential = { svc -> navController.navigate(Routes.credentialEdit(svc)) }, onConfigureCredential = { svc -> navController.navigate(Routes.credentialEdit(svc)) },
onStartWebViewLogin = { svc -> navController.navigate(Routes.webviewLogin(svc)) } onStartWebViewLogin = { svc -> navController.navigate(Routes.webviewLogin(svc)) }
) )

View File

@ -455,7 +455,8 @@ internal fun StackedBarChart(
tooltipReversed: Boolean = false, tooltipReversed: Boolean = false,
formatValue: (Double) -> String, formatValue: (Double) -> String,
granularity: ChartGranularity, granularity: ChartGranularity,
legendItems: List<Pair<String, Color>> = emptyList() legendItems: List<Pair<String, Color>> = emptyList(),
useUtc8: Boolean = false
) { ) {
if (buckets.isEmpty()) { if (buckets.isEmpty()) {
Text("暂无数据", color = InkMuted, style = MaterialTheme.typography.bodySmall) Text("暂无数据", color = InkMuted, style = MaterialTheme.typography.bodySmall)
@ -468,7 +469,7 @@ internal fun StackedBarChart(
val chartHPx = 160f * d val chartHPx = 160f * d
val labelHPx = 20f * 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 refTop = niceCeil(maxVal)
val refHalf = refTop / 2.0 val refHalf = refTop / 2.0
// 以 refTop 为满刻度,柱子不会触顶 // 以 refTop 为满刻度,柱子不会触顶
@ -512,7 +513,7 @@ internal fun StackedBarChart(
drawRect(color, Offset(x, yBase - h), Size(barW, h)) drawRect(color, Offset(x, yBase - h), Size(barW, h))
yBase -= h yBase -= h
} }
val (label, show) = formatChartTime(bucket.ts, granularity, i, barCount) val (label, show) = formatChartTime(bucket.ts, granularity, i, barCount, useUtc8)
if (show) { if (show) {
drawContext.canvas.nativeCanvas.drawText( drawContext.canvas.nativeCanvas.drawText(
label, x + barW / 2, chartHPx + labelHPx - 4f * d, label, x + barW / 2, chartHPx + labelHPx - 4f * d,
@ -552,7 +553,7 @@ internal fun StackedBarChart(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant) colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) { ) {
Column(Modifier.padding(8.dp)) { 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) Text("合计: ${formatValue(total)}", style = MaterialTheme.typography.bodySmall, color = StrawberryPink)
val labels = stackLabels(bucket) val labels = stackLabels(bucket)
val items = stackSelector(bucket).withIndex() val items = stackSelector(bucket).withIndex()
@ -584,7 +585,8 @@ internal fun LineChart(
valueSelector: (ChartBucket) -> Float, valueSelector: (ChartBucket) -> Float,
lineColor: Color, lineColor: Color,
formatValue: (Float) -> String, formatValue: (Float) -> String,
granularity: ChartGranularity granularity: ChartGranularity,
useUtc8: Boolean = false
) { ) {
if (buckets.isEmpty()) { if (buckets.isEmpty()) {
Text("暂无数据", color = InkMuted, style = MaterialTheme.typography.bodySmall) Text("暂无数据", color = InkMuted, style = MaterialTheme.typography.bodySmall)
@ -596,7 +598,7 @@ internal fun LineChart(
val barCount = buckets.size val barCount = buckets.size
val chartHPx = 140f * d val chartHPx = 140f * d
val labelHPx = 20f * 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 refTop = niceCeil(maxVal.toDouble()).toFloat()
val refHalf = refTop / 2f val refHalf = refTop / 2f
val scale = refTop val scale = refTop
@ -641,7 +643,7 @@ internal fun LineChart(
drawCircle(lineColor, radius = 3f * d, center = p) drawCircle(lineColor, radius = 3f * d, center = p)
} }
buckets.forEachIndexed { i, b -> 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) { if (show) {
drawContext.canvas.nativeCanvas.drawText( drawContext.canvas.nativeCanvas.drawText(
label, i * barAreaPx + barAreaPx / 2, chartHPx + labelHPx - 4f * d, label, i * barAreaPx + barAreaPx / 2, chartHPx + labelHPx - 4f * d,
@ -711,8 +713,10 @@ internal fun ChartLegend(items: List<Pair<String, Color>>) {
// ═══════════════════════════════════════════ // ═══════════════════════════════════════════
/** 返回格式化标签 + 是否应该绘制 */ /** 返回格式化标签 + 是否应该绘制 */
private fun formatChartTime(ts: Long, granularity: ChartGranularity, index: Int, total: Int): Pair<String, Boolean> { private fun formatChartTime(ts: Long, granularity: ChartGranularity, index: Int, total: Int, useUtc8: Boolean = false): Pair<String, Boolean> {
val show = when (granularity) { val show = when (granularity) {
ChartGranularity.LAST_12H_10MIN ->
index == 0 || index == total - 1 || index % 12 == 0 // 每2小时(每12个10分钟桶)
ChartGranularity.LAST_24H_HOURLY -> ChartGranularity.LAST_24H_HOURLY ->
index == 0 || index == total - 1 || index % 3 == 0 // 每3小时 index == 0 || index == total - 1 || index % 3 == 0 // 每3小时
ChartGranularity.TODAY_HOURLY, ChartGranularity.TODAY_HOURLY,
@ -726,16 +730,18 @@ private fun formatChartTime(ts: Long, granularity: ChartGranularity, index: Int,
index == 0 || index == total - 1 // 只显示首尾日期 index == 0 || index == total - 1 // 只显示首尾日期
else -> true // 5小时全显示 else -> true // 5小时全显示
} }
val tz = if (useUtc8) java.util.TimeZone.getTimeZone("Asia/Shanghai") else java.util.TimeZone.getTimeZone("UTC")
val fmt = when (granularity) { val fmt = when (granularity) {
ChartGranularity.LAST_12H_10MIN,
ChartGranularity.LAST_5H_HOURLY, ChartGranularity.LAST_5H_HOURLY,
ChartGranularity.LAST_24H_HOURLY, ChartGranularity.LAST_24H_HOURLY,
ChartGranularity.TODAY_HOURLY, ChartGranularity.TODAY_HOURLY,
ChartGranularity.YESTERDAY_HOURLY, ChartGranularity.YESTERDAY_HOURLY,
ChartGranularity.CUSTOM_DAY_HOURLY -> SimpleDateFormat("HH:mm", Locale.getDefault()).apply { ChartGranularity.CUSTOM_DAY_HOURLY -> SimpleDateFormat("HH:mm", Locale.getDefault()).apply {
timeZone = java.util.TimeZone.getTimeZone("UTC") timeZone = tz
} }
else -> SimpleDateFormat("MM/dd", Locale.getDefault()).apply { else -> SimpleDateFormat("MM/dd", Locale.getDefault()).apply {
timeZone = java.util.TimeZone.getTimeZone("UTC") timeZone = tz
} }
} }
return fmt.format(Date(ts)) to show return fmt.format(Date(ts)) to show
@ -760,9 +766,15 @@ private fun niceCeil(v: Double): Double {
val normalized = v / magnitude // 1..10 val normalized = v / magnitude // 1..10
val nice = when { val nice = when {
normalized <= 1.0 -> 1.0 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.0 -> 2.0
normalized <= 2.5 -> 2.5
normalized <= 3.0 -> 3.0 normalized <= 3.0 -> 3.0
normalized <= 4.0 -> 4.0
normalized <= 5.0 -> 5.0 normalized <= 5.0 -> 5.0
normalized <= 7.5 -> 7.5
else -> 10.0 else -> 10.0
} }
return nice * magnitude 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.ChartAggregator
import com.rainy.token.data.local.ChartBucket import com.rainy.token.data.local.ChartBucket
import com.rainy.token.data.local.ChartGranularity 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.local.UsageCache
import com.rainy.token.data.repository.CredentialRepository import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.domain.model.Credential import com.rainy.token.domain.model.Credential
@ -23,21 +24,27 @@ import javax.inject.Provider
@HiltViewModel @HiltViewModel
class UsageChartViewModel @Inject constructor( class UsageChartViewModel @Inject constructor(
private val cacheProvider: Provider<UsageCache>, private val cacheProvider: Provider<UsageCache>,
private val credentialRepository: CredentialRepository private val credentialRepository: CredentialRepository,
private val chartSettingsStore: ChartSettingsStore
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(ChartUiState()) private val _state = MutableStateFlow(
ChartUiState(useUtc8 = chartSettingsStore.getUseUtc8())
)
val state: StateFlow<ChartUiState> = _state.asStateFlow() val state: StateFlow<ChartUiState> = _state.asStateFlow()
private var workspaceIdOverride: String? = null private var workspaceIdOverride: String? = null
private var loadGeneration = 0 // 递增:过时的 load() 结果自动丢弃 private var loadGeneration = 0
/** 是否允许自动降级到更大时间窗口(仅初始加载时允许,用户手动选粒度后关闭) */
private var allowFallback = true
/** 覆盖 workspaceId,用于 CCGO 等非 OCGO 服务。必须在 load() 前调用。 */ /** 覆盖 workspaceId,用于 CCGO 等非 OCGO 服务。必须在 load() 前调用。 */
fun setWorkspace(wid: String) { fun setWorkspace(wid: String) {
workspaceIdOverride = wid workspaceIdOverride = wid
loadGeneration++ loadGeneration++
// 先清空数据防止 init 自动加载的 OCGO 数据闪一下 allowFallback = true // CCGO 首次加载也允许降级
_state.value = ChartUiState() // 先清空数据防止 init 自动加载的 OCGO 数据闪一下,但保留持久化偏好
_state.value = ChartUiState(useUtc8 = _state.value.useUtc8)
load() load()
} }
@ -50,6 +57,7 @@ class UsageChartViewModel @Inject constructor(
// 不在 init 自动加载,由 Composable 层显式触发 load()(OCGO)或 setWorkspace()(CCGO) // 不在 init 自动加载,由 Composable 层显式触发 load()(OCGO)或 setWorkspace()(CCGO)
fun setGranularity(g: ChartGranularity) { fun setGranularity(g: ChartGranularity) {
allowFallback = false // 用户手动选择,关闭自动降级
_state.update { it.copy(granularity = g) } _state.update { it.copy(granularity = g) }
load() load()
} }
@ -73,6 +81,14 @@ class UsageChartViewModel @Inject constructor(
load() load()
} }
fun toggleUtc8() {
_state.update { it.copy(useUtc8 = !it.useUtc8) }
viewModelScope.launch {
chartSettingsStore.setUseUtc8(_state.value.useUtc8)
}
load()
}
fun load() { fun load() {
viewModelScope.launch { viewModelScope.launch {
val wid = workspaceId() ?: return@launch val wid = workspaceId() ?: return@launch
@ -88,18 +104,21 @@ class UsageChartViewModel @Inject constructor(
val filtered = if (selected.isEmpty()) records val filtered = if (selected.isEmpty()) records
else records.filter { it.model in selected } else records.filter { it.model in selected }
val offsetHours = if (_state.value.useUtc8) 8 else 0
val buckets = when (_state.value.granularity) { val buckets = when (_state.value.granularity) {
ChartGranularity.LAST_12H_10MIN ->
ChartAggregator.aggregate10Min(filtered, offsetHours)
ChartGranularity.LAST_5H_HOURLY, ChartGranularity.LAST_5H_HOURLY,
ChartGranularity.LAST_24H_HOURLY, ChartGranularity.LAST_24H_HOURLY,
ChartGranularity.TODAY_HOURLY, ChartGranularity.TODAY_HOURLY,
ChartGranularity.YESTERDAY_HOURLY, ChartGranularity.YESTERDAY_HOURLY,
ChartGranularity.CUSTOM_DAY_HOURLY -> ChartGranularity.CUSTOM_DAY_HOURLY ->
ChartAggregator.aggregateHourly(filtered) ChartAggregator.aggregateHourly(filtered, offsetHours)
ChartGranularity.THIS_MONTH_DAILY, ChartGranularity.THIS_MONTH_DAILY,
ChartGranularity.LAST_7D_DAILY, ChartGranularity.LAST_7D_DAILY,
ChartGranularity.CUSTOM_MONTH_DAILY, ChartGranularity.CUSTOM_MONTH_DAILY,
ChartGranularity.CUSTOM_RANGE_DAILY -> ChartGranularity.CUSTOM_RANGE_DAILY ->
ChartAggregator.aggregateDaily(filtered) ChartAggregator.aggregateDaily(filtered, offsetHours)
} }
// 填充可能空缺的小时/天桶 // 填充可能空缺的小时/天桶
@ -111,6 +130,24 @@ class UsageChartViewModel @Inject constructor(
// 如果 loadGeneration 已经变化(setWorkspace 被调用),丢弃这次结果 // 如果 loadGeneration 已经变化(setWorkspace 被调用),丢弃这次结果
if (loadGeneration != genAtStart) return@launch 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 { _state.update {
it.copy( it.copy(
buckets = result, buckets = result,
@ -145,10 +182,12 @@ class UsageChartViewModel @Inject constructor(
private fun timeRangeFor(g: ChartGranularity): Pair<Long?, Long?> { private fun timeRangeFor(g: ChartGranularity): Pair<Long?, Long?> {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val utc = ZoneOffset.UTC val useUtc8 = _state.value.useUtc8
val today = Instant.ofEpochMilli(now).atOffset(utc).toLocalDate() val zoneOffset = if (useUtc8) ZoneOffset.ofHours(8) else ZoneOffset.UTC
val todayMidnight = today.atStartOfDay(utc).toInstant().toEpochMilli() val today = Instant.ofEpochMilli(now).atOffset(zoneOffset).toLocalDate()
val todayMidnight = today.atStartOfDay(zoneOffset).toInstant().toEpochMilli()
return when (g) { 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_5H_HOURLY -> now - 5 * 3600_000L to null
ChartGranularity.LAST_24H_HOURLY -> now - 24 * 3600_000L to null ChartGranularity.LAST_24H_HOURLY -> now - 24 * 3600_000L to null
ChartGranularity.TODAY_HOURLY -> todayMidnight to todayMidnight + 86400_000L - 1 ChartGranularity.TODAY_HOURLY -> todayMidnight to todayMidnight + 86400_000L - 1
@ -161,19 +200,19 @@ class UsageChartViewModel @Inject constructor(
else _customDayRange.first to _customDayRange.second else _customDayRange.first to _customDayRange.second
} }
ChartGranularity.THIS_MONTH_DAILY -> { 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()) val lastOfMonth = today.withDayOfMonth(today.lengthOfMonth())
.atStartOfDay(utc).toInstant().toEpochMilli() + 86400_000L - 1 .atStartOfDay(zoneOffset).toInstant().toEpochMilli() + 86400_000L - 1
firstOfMonth to lastOfMonth firstOfMonth to lastOfMonth
} }
ChartGranularity.CUSTOM_MONTH_DAILY -> { ChartGranularity.CUSTOM_MONTH_DAILY -> {
if (_customMonthStart == 0L) null to null if (_customMonthStart == 0L) null to null
else { else {
val targetMonth = Instant.ofEpochMilli(_customMonthStart).atOffset(utc).toLocalDate() val targetMonth = Instant.ofEpochMilli(_customMonthStart).atOffset(zoneOffset).toLocalDate()
.withDayOfMonth(1) .withDayOfMonth(1)
val firstOfMonth = targetMonth.atStartOfDay(utc).toInstant().toEpochMilli() val firstOfMonth = targetMonth.atStartOfDay(zoneOffset).toInstant().toEpochMilli()
val lastOfMonth = targetMonth.withDayOfMonth(targetMonth.lengthOfMonth()) val lastOfMonth = targetMonth.withDayOfMonth(targetMonth.lengthOfMonth())
.atStartOfDay(utc).toInstant().toEpochMilli() + 86400_000L - 1 .atStartOfDay(zoneOffset).toInstant().toEpochMilli() + 86400_000L - 1
firstOfMonth to lastOfMonth firstOfMonth to lastOfMonth
} }
} }
@ -192,6 +231,7 @@ class UsageChartViewModel @Inject constructor(
): List<ChartBucket> { ): List<ChartBucket> {
if (buckets.isEmpty() || from == null) return buckets if (buckets.isEmpty() || from == null) return buckets
val interval = when (granularity) { val interval = when (granularity) {
ChartGranularity.LAST_12H_10MIN -> 600_000L // 10分钟
ChartGranularity.LAST_5H_HOURLY, ChartGranularity.LAST_5H_HOURLY,
ChartGranularity.LAST_24H_HOURLY, ChartGranularity.LAST_24H_HOURLY,
ChartGranularity.TODAY_HOURLY, ChartGranularity.TODAY_HOURLY,
@ -202,7 +242,9 @@ class UsageChartViewModel @Inject constructor(
val end = to ?: (System.currentTimeMillis() / interval * interval) val end = to ?: (System.currentTimeMillis() / interval * interval)
val result = mutableListOf<ChartBucket>() val result = mutableListOf<ChartBucket>()
val map = buckets.associateBy { it.ts } 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) { while (t <= end) {
result.add(map[t] ?: ChartBucket(ts = t, totalCost = 0, totalRequests = 0, result.add(map[t] ?: ChartBucket(ts = t, totalCost = 0, totalRequests = 0,
inputTokens = 0, cacheHitTokens = 0, outputTokens = 0)) inputTokens = 0, cacheHitTokens = 0, outputTokens = 0))
@ -217,5 +259,6 @@ data class ChartUiState(
val granularity: ChartGranularity = ChartGranularity.LAST_5H_HOURLY, val granularity: ChartGranularity = ChartGranularity.LAST_5H_HOURLY,
val allModels: List<String> = emptyList(), val allModels: List<String> = emptyList(),
val selectedModels: Set<String> = emptySet(), // empty = all 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.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold 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.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TimePicker import androidx.compose.material3.TimePicker
@ -38,6 +41,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color 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.data.local.ChartGranularity
import com.rainy.token.ui.theme.InkMuted import com.rainy.token.ui.theme.InkMuted
import com.rainy.token.ui.theme.StrawberryPink import com.rainy.token.ui.theme.StrawberryPink
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.time.Instant import java.time.Instant
import java.time.LocalDate import java.time.LocalDate
@ -88,9 +93,12 @@ fun UsageDetailScreen(
var showCostDetail by remember { mutableStateOf(false) } var showCostDetail by remember { mutableStateOf(false) }
var showReqDetail by remember { mutableStateOf(false) } var showReqDetail by remember { mutableStateOf(false) }
var showTokenDetail by remember { mutableStateOf(false) } var showTokenDetail by remember { mutableStateOf(false) }
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
Scaffold( Scaffold(
containerColor = Color.Transparent, containerColor = Color.Transparent,
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("用量详情") }, 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)) Spacer(Modifier.weight(1f))
Box { Box {
val label = if (state.selectedModels.isEmpty()) "全部模型" else "${state.selectedModels.size} 个模型" 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 } }, { bucket -> models.mapNotNull { if (bucket.byModel[it] != null) it else null } },
formatValue = { "$${String.format(Locale.US, "%.4f", it)}" }, formatValue = { "$${String.format(Locale.US, "%.4f", it)}" },
granularity = state.granularity, 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 // 图表2
item { item {
val reqTotal = state.buckets.sumOf { it.totalRequests } val reqTotal = state.buckets.sumOf { it.totalRequests }
ChartCard("API 请求次数", "${reqTotal}次", { showReqDetail = true }) { 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 // 图表3
@ -193,9 +226,11 @@ fun UsageDetailScreen(
{ (it.cacheHitTokens + it.inputTokens + it.outputTokens).toDouble() }, { (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]) }, { bucket -> listOfNotNull(bucket.outputTokens.toDouble() to tokenColors[2], bucket.inputTokens.toDouble() to tokenColors[1], bucket.cacheHitTokens.toDouble() to tokenColors[0]) },
{ listOf("输出", "输入(未命中)", "命中缓存") }, { listOf("输出", "输入(未命中)", "命中缓存") },
tooltipReversed = true,
formatValue = { formatTokenComma(it.toLong()) }, 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])) ChartLegend(listOf("输入(未命中)" to tokenColors[1], "命中缓存" to tokenColors[0], "输出" to tokenColors[2]))
} }