feat: 12小时10分钟粒度 + UTC时区切换持久化 + 自动降级 + 刻度优化
Some checks failed
Release / build (push) Has been cancelled
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:
parent
4a63db2840
commit
26693c92d4
@ -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()`(增量)
|
||||
|
||||
|
||||
@ -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 详情页支持手动清除并重新同步 |
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
@ -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)
|
||||
@ -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
|
||||
}
|
||||
@ -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)) }
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
)
|
||||
@ -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]))
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user