fix: 详情页新鲜缓存不再标记为缓存,用量卡片显示刷新时间

This commit is contained in:
WaterRain 2026-08-04 06:43:01 +00:00
parent acddaae068
commit 8340ded070
No known key found for this signature in database
5 changed files with 94 additions and 7 deletions

View File

@ -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
)
}
}
}
}

View File

@ -559,3 +559,20 @@ internal fun footerText(card: DashboardCardUi): UiText {
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)))
}
}

View File

@ -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
)
}
}
}
}

View File

@ -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 = 全部模型

View File

@ -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 ->
// 缓存新鲜(≤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 ||