fix: 详情页新鲜缓存不再标记为缓存,用量卡片显示刷新时间
This commit is contained in:
parent
acddaae068
commit
8340ded070
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -558,4 +558,21 @@ internal fun footerText(card: DashboardCardUi): UiText {
|
|||||||
diffMin < 1440 -> UiText.Resource(R.string.footer_hours_ago, listOf(diffMin / 60))
|
diffMin < 1440 -> UiText.Resource(R.string.footer_hours_ago, listOf(diffMin / 60))
|
||||||
else -> UiText.Resource(R.string.footer_updated_at, listOf(timeStr))
|
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)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -169,6 +169,16 @@ fun UsageStatsCard(
|
|||||||
color = MaterialTheme.colorScheme.error
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -88,6 +88,22 @@ class UsageViewModel @Inject constructor(
|
|||||||
private var workspaceIdOverride: String? = null
|
private var workspaceIdOverride: String? = null
|
||||||
private var loadGeneration = 0 // 递增:过时的 loadStatsInternal 结果自动丢弃
|
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() 前调用。 */
|
/** 覆盖 workspaceId,用于 CCGO 等非 OCGO 服务。必须在 loadStats() 前调用。 */
|
||||||
fun setWorkspace(wid: String) {
|
fun setWorkspace(wid: String) {
|
||||||
workspaceIdOverride = wid
|
workspaceIdOverride = wid
|
||||||
@ -158,7 +174,8 @@ class UsageViewModel @Inject constructor(
|
|||||||
dailyStats = dailyStats,
|
dailyStats = dailyStats,
|
||||||
recordCount = totalCount,
|
recordCount = totalCount,
|
||||||
loading = false,
|
loading = false,
|
||||||
dailyPage = 1
|
dailyPage = 1,
|
||||||
|
lastSyncAt = loadLastSyncAt(wid)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -189,6 +206,7 @@ class UsageViewModel @Inject constructor(
|
|||||||
fun sync() {
|
fun sync() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.update { it.copy(syncing = true) }
|
_uiState.update { it.copy(syncing = true) }
|
||||||
|
val wid = workspaceIdOverride ?: workspaceId()
|
||||||
val result = withContext(Dispatchers.Default) {
|
val result = withContext(Dispatchers.Default) {
|
||||||
val cache = cacheProvider.get()
|
val cache = cacheProvider.get()
|
||||||
if (workspaceIdOverride == com.rainy.token.data.repository.CommandCodeUsageRepository.CCGO_WORKSPACE_ID) {
|
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()
|
if (count == 0) useCase.fullSync() else useCase.incrementalSync()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val syncedAt = System.currentTimeMillis()
|
||||||
|
if (result.isSuccess && wid != null) saveLastSyncAt(wid, syncedAt)
|
||||||
result.onSuccess { loadStats() }
|
result.onSuccess { loadStats() }
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
syncing = false,
|
syncing = false,
|
||||||
lastSyncResult = result.getOrNull()?.inserted ?: 0,
|
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()
|
invalidateData()
|
||||||
val useCase = syncCommandCodeUseCaseProvider.get()
|
val useCase = syncCommandCodeUseCaseProvider.get()
|
||||||
val result = useCase.fullSync()
|
val result = useCase.fullSync()
|
||||||
|
val syncedAt = System.currentTimeMillis()
|
||||||
|
if (result.isSuccess) saveLastSyncAt(wid, syncedAt)
|
||||||
result.onSuccess { loadStats() }
|
result.onSuccess { loadStats() }
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
syncing = false,
|
syncing = false,
|
||||||
lastSyncResult = result.getOrNull()?.inserted ?: 0,
|
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 recordCount: Int = 0,
|
||||||
val lastSyncResult: Int = 0,
|
val lastSyncResult: Int = 0,
|
||||||
val lastSyncError: UiText? = null,
|
val lastSyncError: UiText? = null,
|
||||||
|
val lastSyncAt: Long = 0, // 最近一次成功同步的 epoch ms(0 = 从未同步)
|
||||||
val timeFilter: TimeFilter = TimeFilter.All,
|
val timeFilter: TimeFilter = TimeFilter.All,
|
||||||
val dailyPage: Int = 1,
|
val dailyPage: Int = 1,
|
||||||
val modelFilter: String? = null // null = 全部模型
|
val modelFilter: String? = null // null = 全部模型
|
||||||
|
|||||||
@ -65,6 +65,9 @@ class ServiceDetailViewModel @Inject constructor(
|
|||||||
private const val OCGO_PREFS = "ocgo_trigger_prefs"
|
private const val OCGO_PREFS = "ocgo_trigger_prefs"
|
||||||
private const val OLLAMA_PREFS = "ollama_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? =
|
internal fun credentialFingerprint(credential: Credential?): String? =
|
||||||
CredentialRepository.credentialFingerprint(credential)
|
CredentialRepository.credentialFingerprint(credential)
|
||||||
|
|
||||||
@ -441,7 +444,12 @@ class ServiceDetailViewModel @Inject constructor(
|
|||||||
RepositoryError.InvalidCredential()
|
RepositoryError.InvalidCredential()
|
||||||
)
|
)
|
||||||
cached != null && status.state == CredentialStatus.State.OK ->
|
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 ->
|
cached != null ->
|
||||||
State.Error(
|
State.Error(
|
||||||
cached.balance,
|
cached.balance,
|
||||||
@ -460,7 +468,11 @@ class ServiceDetailViewModel @Inject constructor(
|
|||||||
state = newState
|
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()
|
refresh()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -486,10 +498,19 @@ class ServiceDetailViewModel @Inject constructor(
|
|||||||
cacheAdvanced && current.state is State.Fresh ->
|
cacheAdvanced && current.state is State.Fresh ->
|
||||||
State.Fresh(mergedCache!!.balance)
|
State.Fresh(mergedCache!!.balance)
|
||||||
cacheAdvanced && current.state is State.Stale ->
|
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.Fresh -> current.state
|
||||||
current.state is State.Stale -> 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
|
else -> current.state
|
||||||
}
|
}
|
||||||
return current.copy(
|
return current.copy(
|
||||||
@ -514,6 +535,10 @@ class ServiceDetailViewModel @Inject constructor(
|
|||||||
current: CachedBalance?
|
current: CachedBalance?
|
||||||
): Boolean = candidate != null && (current == null || candidate.fetchedAt > current.fetchedAt)
|
): 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 =
|
private fun isStaleRequest(type: ServiceType, serviceGen: Int, refreshGen: Int): Boolean =
|
||||||
refreshGen != refreshGeneration ||
|
refreshGen != refreshGeneration ||
|
||||||
serviceGen != serviceGeneration ||
|
serviceGen != serviceGeneration ||
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user