From 8340ded0707a91df3455829e3c7cd4d45c0503fa Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Tue, 4 Aug 2026 06:43:01 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=AF=A6=E6=83=85=E9=A1=B5=E6=96=B0?= =?UTF-8?q?=E9=B2=9C=E7=BC=93=E5=AD=98=E4=B8=8D=E5=86=8D=E6=A0=87=E8=AE=B0?= =?UTF-8?q?=E4=B8=BA=E7=BC=93=E5=AD=98=EF=BC=8C=E7=94=A8=E9=87=8F=E5=8D=A1?= =?UTF-8?q?=E7=89=87=E6=98=BE=E7=A4=BA=E5=88=B7=E6=96=B0=E6=97=B6=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/dashboard/CommandCodeUsageStatsCard.kt | 10 ++++++ .../token/ui/dashboard/ServiceBalanceCards.kt | 17 ++++++++++ .../token/ui/dashboard/UsageStatsCard.kt | 10 ++++++ .../token/ui/dashboard/UsageViewModel.kt | 31 +++++++++++++++-- .../servicedetail/ServiceDetailViewModel.kt | 33 ++++++++++++++++--- 5 files changed, 94 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/CommandCodeUsageStatsCard.kt b/app/src/main/java/com/rainy/token/ui/dashboard/CommandCodeUsageStatsCard.kt index 0c9ee7d..3bb0714 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/CommandCodeUsageStatsCard.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/CommandCodeUsageStatsCard.kt @@ -171,6 +171,16 @@ fun CommandCodeUsageStatsCard( ) } + // ─── 刷新时间(与其余服务卡片底部一致) ─── + if (uiState.lastSyncAt > 0) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = usageUpdatedAtText(uiState.lastSyncAt), + style = MaterialTheme.typography.bodySmall, + color = InkMuted + ) + } + } } } diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt b/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt index b29099e..3fac28b 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt @@ -558,4 +558,21 @@ internal fun footerText(card: DashboardCardUi): UiText { diffMin < 1440 -> UiText.Resource(R.string.footer_hours_ago, listOf(diffMin / 60)) else -> UiText.Resource(R.string.footer_updated_at, listOf(timeStr)) } +} + +/** + * 用量卡片(OCGO / CCGO)底部刷新时间文案,规则与 [footerText] 完全一致: + * <1 分钟"刚刚更新"、<1 小时"X 分钟前"、<24 小时"X 小时前"、更早显示 "MM-dd HH:mm 更新"。 + */ +@Composable +internal fun usageUpdatedAtText(updatedAt: Long): String { + val now = System.currentTimeMillis() + val diffMin = (now - updatedAt) / 60_000 + val sdf = SimpleDateFormat("MM-dd HH:mm", Locale.getDefault()) + return when { + diffMin < 1 -> stringResource(R.string.footer_just_updated) + diffMin < 60 -> stringResource(R.string.footer_minutes_ago, diffMin) + diffMin < 1440 -> stringResource(R.string.footer_hours_ago, diffMin / 60) + else -> stringResource(R.string.footer_updated_at, sdf.format(Date(updatedAt))) + } } \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/UsageStatsCard.kt b/app/src/main/java/com/rainy/token/ui/dashboard/UsageStatsCard.kt index cd088fc..6138ef9 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/UsageStatsCard.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/UsageStatsCard.kt @@ -169,6 +169,16 @@ fun UsageStatsCard( color = MaterialTheme.colorScheme.error ) } + + // ─── 刷新时间(与其余服务卡片底部一致) ─── + if (uiState.lastSyncAt > 0) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = usageUpdatedAtText(uiState.lastSyncAt), + style = MaterialTheme.typography.bodySmall, + color = InkMuted + ) + } } } } diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/UsageViewModel.kt b/app/src/main/java/com/rainy/token/ui/dashboard/UsageViewModel.kt index aae4e15..1ff3534 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/UsageViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/UsageViewModel.kt @@ -88,6 +88,22 @@ class UsageViewModel @Inject constructor( private var workspaceIdOverride: String? = null private var loadGeneration = 0 // 递增:过时的 loadStatsInternal 结果自动丢弃 + /** 用量同步时间持久化(按 workspace 区分 OCGO / CCGO),重启后卡片仍能显示刷新时间。 */ + private fun syncAtPrefs(): android.content.SharedPreferences = + com.rainy.token.RainyTokenApplication.appContext.getSharedPreferences( + "usage_sync_at", + android.content.Context.MODE_PRIVATE + ) + + private fun syncAtKey(wid: String): String = "sync_at_$wid" + + private fun loadLastSyncAt(wid: String): Long = + syncAtPrefs().getLong(syncAtKey(wid), 0L) + + private fun saveLastSyncAt(wid: String, ts: Long) { + syncAtPrefs().edit().putLong(syncAtKey(wid), ts).apply() + } + /** 覆盖 workspaceId,用于 CCGO 等非 OCGO 服务。必须在 loadStats() 前调用。 */ fun setWorkspace(wid: String) { workspaceIdOverride = wid @@ -158,7 +174,8 @@ class UsageViewModel @Inject constructor( dailyStats = dailyStats, recordCount = totalCount, loading = false, - dailyPage = 1 + dailyPage = 1, + lastSyncAt = loadLastSyncAt(wid) ) } } @@ -189,6 +206,7 @@ class UsageViewModel @Inject constructor( fun sync() { viewModelScope.launch { _uiState.update { it.copy(syncing = true) } + val wid = workspaceIdOverride ?: workspaceId() val result = withContext(Dispatchers.Default) { val cache = cacheProvider.get() if (workspaceIdOverride == com.rainy.token.data.repository.CommandCodeUsageRepository.CCGO_WORKSPACE_ID) { @@ -201,12 +219,15 @@ class UsageViewModel @Inject constructor( if (count == 0) useCase.fullSync() else useCase.incrementalSync() } } + val syncedAt = System.currentTimeMillis() + if (result.isSuccess && wid != null) saveLastSyncAt(wid, syncedAt) result.onSuccess { loadStats() } _uiState.update { it.copy( syncing = false, lastSyncResult = result.getOrNull()?.inserted ?: 0, - lastSyncError = result.exceptionOrNull()?.let { syncErrorToUiText(it) } + lastSyncError = result.exceptionOrNull()?.let { syncErrorToUiText(it) }, + lastSyncAt = if (result.isSuccess && wid != null) syncedAt else it.lastSyncAt ) } } @@ -222,12 +243,15 @@ class UsageViewModel @Inject constructor( invalidateData() val useCase = syncCommandCodeUseCaseProvider.get() val result = useCase.fullSync() + val syncedAt = System.currentTimeMillis() + if (result.isSuccess) saveLastSyncAt(wid, syncedAt) result.onSuccess { loadStats() } _uiState.update { it.copy( syncing = false, lastSyncResult = result.getOrNull()?.inserted ?: 0, - lastSyncError = result.exceptionOrNull()?.let { syncErrorToUiText(it) } + lastSyncError = result.exceptionOrNull()?.let { syncErrorToUiText(it) }, + lastSyncAt = if (result.isSuccess) syncedAt else it.lastSyncAt ) } } @@ -247,6 +271,7 @@ data class UsageUiState( val recordCount: Int = 0, val lastSyncResult: Int = 0, val lastSyncError: UiText? = null, + val lastSyncAt: Long = 0, // 最近一次成功同步的 epoch ms(0 = 从未同步) val timeFilter: TimeFilter = TimeFilter.All, val dailyPage: Int = 1, val modelFilter: String? = null // null = 全部模型 diff --git a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModel.kt b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModel.kt index 2c6ac2a..0507084 100644 --- a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModel.kt @@ -65,6 +65,9 @@ class ServiceDetailViewModel @Inject constructor( private const val OCGO_PREFS = "ocgo_trigger_prefs" private const val OLLAMA_PREFS = "ollama_trigger_prefs" + /** 缓存新鲜度阈值:fetchedAt 距今 ≤5 分钟视为最新数据,不再标记为"缓存"。 */ + private const val CACHE_FRESH_THRESHOLD_MS = 5 * 60_000L + internal fun credentialFingerprint(credential: Credential?): String? = CredentialRepository.credentialFingerprint(credential) @@ -441,7 +444,12 @@ class ServiceDetailViewModel @Inject constructor( RepositoryError.InvalidCredential() ) cached != null && status.state == CredentialStatus.State.OK -> - State.Stale(cached.balance, cached.fetchedAt) + // 缓存新鲜(≤5 分钟)时视为最新数据直接展示,避免"刚刷新完进详情却显示缓存"的困惑 + if (isCacheFresh(cached.fetchedAt)) { + State.Fresh(cached.balance) + } else { + State.Stale(cached.balance, cached.fetchedAt) + } cached != null -> State.Error( cached.balance, @@ -460,7 +468,11 @@ class ServiceDetailViewModel @Inject constructor( state = newState ) } - if (!isManual && status.state != CredentialStatus.State.NOT_CONFIGURED && cached == null) { + // 无缓存或缓存已过期(>5 分钟)时自动刷新一次; + // 缓存新鲜时直接展示,避免每次进详情页都发网络请求 + if (!isManual && status.state != CredentialStatus.State.NOT_CONFIGURED && + (cached == null || !isCacheFresh(cached.fetchedAt)) + ) { refresh() } } @@ -486,10 +498,19 @@ class ServiceDetailViewModel @Inject constructor( cacheAdvanced && current.state is State.Fresh -> State.Fresh(mergedCache!!.balance) cacheAdvanced && current.state is State.Stale -> - State.Stale(mergedCache!!.balance, mergedCache.fetchedAt) + if (isCacheFresh(mergedCache!!.fetchedAt)) { + State.Fresh(mergedCache.balance) + } else { + State.Stale(mergedCache.balance, mergedCache.fetchedAt) + } current.state is State.Fresh -> current.state current.state is State.Stale -> current.state - mergedCache != null -> State.Stale(mergedCache.balance, mergedCache.fetchedAt) + mergedCache != null -> + if (isCacheFresh(mergedCache.fetchedAt)) { + State.Fresh(mergedCache.balance) + } else { + State.Stale(mergedCache.balance, mergedCache.fetchedAt) + } else -> current.state } return current.copy( @@ -514,6 +535,10 @@ class ServiceDetailViewModel @Inject constructor( current: CachedBalance? ): Boolean = candidate != null && (current == null || candidate.fetchedAt > current.fetchedAt) + /** 缓存是否仍算"最新":fetchedAt 距今 ≤5 分钟(与 Dashboard 底部"刚刚更新"感知一致)。 */ + private fun isCacheFresh(fetchedAt: Long): Boolean = + System.currentTimeMillis() - fetchedAt <= CACHE_FRESH_THRESHOLD_MS + private fun isStaleRequest(type: ServiceType, serviceGen: Int, refreshGen: Int): Boolean = refreshGen != refreshGeneration || serviceGen != serviceGeneration ||