diff --git a/AGENTS.md b/AGENTS.md index fa834d1..4729b67 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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()`(增量) diff --git a/README.md b/README.md index 31f17a1..cf5c274 100644 --- a/README.md +++ b/README.md @@ -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 详情页支持手动清除并重新同步 | diff --git a/app/src/main/java/com/rainy/token/data/local/ChartBucket.kt b/app/src/main/java/com/rainy/token/data/local/ChartBucket.kt index b54cf9c..348862b 100644 --- a/app/src/main/java/com/rainy/token/data/local/ChartBucket.kt +++ b/app/src/main/java/com/rainy/token/data/local/ChartBucket.kt @@ -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): List { + fun aggregate10Min(records: List, offsetHours: Int = 0): List { + 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, offsetHours: Int = 0): List { + 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): List { + fun aggregateDaily(records: List, offsetHours: Int = 0): List { + 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 } } diff --git a/app/src/main/java/com/rainy/token/data/local/ChartSettingsStore.kt b/app/src/main/java/com/rainy/token/data/local/ChartSettingsStore.kt new file mode 100644 index 0000000..f79ee6a --- /dev/null +++ b/app/src/main/java/com/rainy/token/data/local/ChartSettingsStore.kt @@ -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 by preferencesDataStore( + name = "chart_settings" +) + +/** 图表页偏好设置持久化 */ +class ChartSettingsStore(private val dataStore: DataStore) { + + companion object { + private val KEY_USE_UTC8 = booleanPreferencesKey("use_utc8") + } + + /** 流式读取偏好 */ + val useUtc8Flow: Flow = 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) \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/di/StorageModule.kt b/app/src/main/java/com/rainy/token/di/StorageModule.kt index fe23c82..4eb241b 100644 --- a/app/src/main/java/com/rainy/token/di/StorageModule.kt +++ b/app/src/main/java/com/rainy/token/di/StorageModule.kt @@ -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 = context.usageCacheDataStore + + @Provides + @Singleton + fun provideChartSettingsStore( + @ApplicationContext context: Context + ): ChartSettingsStore = context.chartSettingsStore } \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt b/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt index 0d13118..b79da9c 100644 --- a/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt +++ b/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt @@ -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)) } ) diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/UsageChartScreen.kt b/app/src/main/java/com/rainy/token/ui/dashboard/UsageChartScreen.kt index d79248d..3ba7e3f 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/UsageChartScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/UsageChartScreen.kt @@ -455,7 +455,8 @@ internal fun StackedBarChart( tooltipReversed: Boolean = false, formatValue: (Double) -> String, granularity: ChartGranularity, - legendItems: List> = emptyList() + legendItems: List> = 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>) { // ═══════════════════════════════════════════ /** 返回格式化标签 + 是否应该绘制 */ -private fun formatChartTime(ts: Long, granularity: ChartGranularity, index: Int, total: Int): Pair { - 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 { +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 diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/UsageChartViewModel.kt b/app/src/main/java/com/rainy/token/ui/dashboard/UsageChartViewModel.kt index 4d03632..b7ce61b 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/UsageChartViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/UsageChartViewModel.kt @@ -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, - 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 = _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 { 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 { 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() 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 = emptyList(), val selectedModels: Set = emptySet(), // empty = all - val buckets: List = emptyList() + val buckets: List = emptyList(), + val useUtc8: Boolean = false ) \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/UsageDetailScreen.kt b/app/src/main/java/com/rainy/token/ui/dashboard/UsageDetailScreen.kt index 97c901a..6f43108 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/UsageDetailScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/UsageDetailScreen.kt @@ -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])) }