From 6ac4d06e9fc81a8bf19a098c58d3343a92e450ec Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Sat, 1 Aug 2026 08:19:51 +0000 Subject: [PATCH] =?UTF-8?q?i18n=20=E4=BF=AE=E5=A4=8D=EF=BC=9A=E6=B6=88?= =?UTF-8?q?=E9=99=A4=E8=8B=B1=E6=96=87=E7=8E=AF=E5=A2=83=E4=B8=8B=205=20?= =?UTF-8?q?=E5=A4=84=E4=B8=AD=E6=96=87=E6=B3=84=E6=BC=8F=EF=BC=88=E6=9C=80?= =?UTF-8?q?=E7=BB=88=E5=AE=A1=E6=9F=A5=20P1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 最终独立审查发现英文环境用户可见中文,全部修复: - 激活成功弹窗:三个 parse 函数(OCGO/Codex/Ollama)改为返回结构化 TriggerSummary, 中文摘要拼接移出 Repository;UI 层 buildTriggerSummaryText() 用资源组装本地化文本 - Codex 窗口标签:formatCodexPrimaryLabel / normalizeWindowLabel 增加「每周/每月」字面量分支, Dashboard 与 Widget 英文环境下正确显示 Weekly/Monthly - Dashboard footer:DashboardCardUi.lastFetchError 改 UiText?,errorToUiText() 按类型映射资源 - 凭据编辑页:ParseError→error_parse_failed(RepositoryError.ParseError 新增公开 detail), 「未知」「JSON格式错误」中文 fallback 改资源 - 服务详情页:triggerUsage else 分支复用 errorMessage() 全类型映射; TriggerError「token刷新失败」前缀资源化;「无响应体」不再展示 - 新增 TriggerSummary 数据类与 12 组中英资源(trigger_*/error_token_refresh_failed/等) - 测试:CodexRepositoryTest 6 用例改为结构化断言,FormatUtilsTest 更新 monthly 映射; 113 个用例全绿,lint 0 Error,资源 426/426 对齐 --- .../token/data/repository/CodexRepository.kt | 32 ++++++------ .../token/data/repository/OllamaRepository.kt | 29 +++++------ .../data/repository/OpenCodeGoRepository.kt | 29 +++++------ .../token/data/repository/RepositoryError.kt | 6 +-- .../token/domain/model/TriggerSummary.kt | 24 +++++++++ .../domain/usecase/RefreshBalanceUseCase.kt | 7 +-- .../rainy/token/ui/components/FormatUtils.kt | 11 ++-- .../token/ui/dashboard/DashboardViewModel.kt | 33 ++++++++++-- .../token/ui/dashboard/ServiceBalanceCards.kt | 29 ++++++++--- .../ui/servicedetail/ServiceDetailScreen.kt | 40 ++++++++++++++- .../servicedetail/ServiceDetailViewModel.kt | 22 +++++--- .../ui/settings/CredentialEditViewModel.kt | 15 +++++- .../ui/widget/OpenCodeGoWidgetProvider.kt | 6 ++- app/src/main/res/values-en/strings.xml | 12 +++++ app/src/main/res/values/strings.xml | 12 +++++ .../data/repository/CodexRepositoryTest.kt | 50 +++++++++++-------- .../token/ui/components/FormatUtilsTest.kt | 11 +++- 17 files changed, 263 insertions(+), 105 deletions(-) create mode 100644 app/src/main/java/com/rainy/token/domain/model/TriggerSummary.kt diff --git a/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt b/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt index c399033..7c0dd4c 100644 --- a/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt @@ -3,6 +3,7 @@ package com.rainy.token.data.repository import com.rainy.token.data.cache.BalanceCache import com.rainy.token.domain.model.Credential import com.rainy.token.domain.model.ServiceBalance +import com.rainy.token.domain.model.TriggerSummary import com.rainy.token.domain.service.ServiceConfigProvider import com.rainy.token.domain.service.ServiceType import kotlinx.coroutines.Dispatchers @@ -203,7 +204,7 @@ class CodexRepository( * @param model 用户选择的模型 slug * @return Result.success(响应体原文) — 供 UI 展示 */ - suspend fun triggerUsage(model: String): Result = withContext(Dispatchers.IO) { + suspend fun triggerUsage(model: String): Result = withContext(Dispatchers.IO) { val credential = credentialRepository.get(ServiceType.CODEX) ?: return@withContext Result.failure(RepositoryError.InvalidCredential("未找到 Codex 凭据")) if (credential !is Credential.CodexCredential) @@ -239,10 +240,10 @@ class CodexRepository( DebugLog.i(TAG, "triggerUsage retry: HTTP ${resp2.code}, body=${body2.take(500)}") if (!resp2.isSuccessful) { DebugLog.e(TAG, "triggerUsage retry failed: HTTP ${resp2.code}") - return@use Result.failure( + return@use Result.failure( TriggerError( "HTTP ${resp2.code}", - body2.ifBlank { "无响应体" } + body2.ifBlank { "" } ) ) } @@ -255,8 +256,8 @@ class CodexRepository( } } } else { - return@use Result.failure( - TriggerError("HTTP ${resp.code}", bodyStr.ifBlank { "无响应体" }) + return@use Result.failure( + TriggerError("HTTP ${resp.code}", bodyStr.ifBlank { "" }) ) } } else { @@ -400,7 +401,7 @@ class TriggerError(val summary: String, val responseBody: String) : Exception(su * 将 SSE 流响应解析为简洁的文本摘要。 * 提取:模型回复文本、用量统计(input/output tokens)。 */ -internal fun parseSseResponse(sseText: String, model: String): String { +internal fun parseSseResponse(sseText: String, model: String): TriggerSummary { val sseJson = Json { ignoreUnknownKeys = true } val outputText = StringBuilder() var inputTokens: String? = null @@ -429,16 +430,11 @@ internal fun parseSseResponse(sseText: String, model: String): String { } catch (_: Exception) { } } - val sb = StringBuilder() - sb.appendLine("✓ 激活成功") - sb.appendLine("模型: $model") - if (responseId != null) sb.appendLine("Response ID: $responseId") - sb.appendLine() - sb.appendLine("回复:") - sb.appendLine(outputText.ifEmpty { "(空)" }) - if (inputTokens != null || outputTokens != null) { - sb.appendLine() - sb.appendLine("用量: input=$inputTokens output=$outputTokens tokens") - } - return sb.toString().trim() + return TriggerSummary( + model = model, + reply = outputText.toString(), + inputTokens = inputTokens, + outputTokens = outputTokens, + responseId = responseId + ) } \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/data/repository/OllamaRepository.kt b/app/src/main/java/com/rainy/token/data/repository/OllamaRepository.kt index 0cd1a1b..04a45f8 100644 --- a/app/src/main/java/com/rainy/token/data/repository/OllamaRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/OllamaRepository.kt @@ -4,6 +4,7 @@ import com.rainy.token.data.cache.BalanceCache import com.rainy.token.data.debug.DebugLog import com.rainy.token.domain.model.Credential import com.rainy.token.domain.model.ServiceBalance +import com.rainy.token.domain.model.TriggerSummary import com.rainy.token.domain.service.ServiceType import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -268,7 +269,7 @@ class OllamaRepository( * @param model 用户选择的模型 slug * @return Result.success(响应摘要文本) — 供 UI 展示 */ - suspend fun triggerUsage(model: String): Result = withContext(Dispatchers.IO) { + suspend fun triggerUsage(model: String): Result = withContext(Dispatchers.IO) { val credential = credentialRepository.get(ServiceType.OLLAMA) ?: return@withContext Result.failure(RepositoryError.InvalidCredential("未找到 Ollama 凭据")) if (credential !is Credential.SessionCredential) @@ -296,8 +297,8 @@ class OllamaRepository( val bodyStr = resp.body?.string() ?: "" DebugLog.i(TAG, "triggerUsage: HTTP ${resp.code}, body=${bodyStr.take(500)}") if (!resp.isSuccessful) { - return@withContext Result.failure( - TriggerError("HTTP ${resp.code}", bodyStr.ifBlank { "无响应体" }) + return@withContext Result.failure( + TriggerError("HTTP ${resp.code}", bodyStr.ifBlank { "" }) ) } DebugLog.i(TAG, "triggerUsage: 请求成功,模型=$model") @@ -317,7 +318,7 @@ class OllamaRepository( /** * 解析 Ollama Cloud chat completions 响应,提取回复文本和用量统计。 */ -internal fun parseOllamaChatResponse(responseBody: String, model: String): String { +internal fun parseOllamaChatResponse(responseBody: String, model: String): TriggerSummary { val json = Json { ignoreUnknownKeys = true } return try { val root = json.parseToJsonElement(responseBody) as? JsonObject @@ -330,18 +331,14 @@ internal fun parseOllamaChatResponse(responseBody: String, model: String): Strin val completionTokens = (usage?.get("completion_tokens") as? JsonPrimitive)?.contentOrNull val totalTokens = (usage?.get("total_tokens") as? JsonPrimitive)?.contentOrNull - val sb = StringBuilder() - sb.appendLine("✓ 激活成功") - sb.appendLine("模型: $model") - sb.appendLine() - sb.appendLine("回复:") - sb.appendLine(content?.ifEmpty { "(空)" } ?: "(无回复内容)") - if (promptTokens != null || completionTokens != null) { - sb.appendLine() - sb.appendLine("用量: prompt=$promptTokens completion=$completionTokens total=$totalTokens tokens") - } - sb.toString().trim() + TriggerSummary( + model = model, + reply = content, + inputTokens = promptTokens, + outputTokens = completionTokens, + totalTokens = totalTokens + ) } catch (_: Exception) { - "✓ 激活成功\n模型: $model\n\n(响应解析失败,但请求已发出)" + TriggerSummary(model = model, reply = null, parseFailed = true) } } \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/data/repository/OpenCodeGoRepository.kt b/app/src/main/java/com/rainy/token/data/repository/OpenCodeGoRepository.kt index a7738f4..087719a 100644 --- a/app/src/main/java/com/rainy/token/data/repository/OpenCodeGoRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/OpenCodeGoRepository.kt @@ -4,6 +4,7 @@ import com.rainy.token.data.cache.BalanceCache import com.rainy.token.data.debug.DebugLog import com.rainy.token.domain.model.Credential import com.rainy.token.domain.model.ServiceBalance +import com.rainy.token.domain.model.TriggerSummary import com.rainy.token.domain.service.ServiceConfigProvider import com.rainy.token.domain.service.ServiceType import kotlinx.coroutines.Dispatchers @@ -178,7 +179,7 @@ class OpenCodeGoRepository( * @param model 用户选择的模型 slug * @return Result.success(响应摘要文本) — 供 UI 展示 */ - suspend fun triggerUsage(model: String): Result = withContext(Dispatchers.IO) { + suspend fun triggerUsage(model: String): Result = withContext(Dispatchers.IO) { val credential = credentialRepository.get(ServiceType.OPENCODE_GO) ?: return@withContext Result.failure(RepositoryError.InvalidCredential("未找到 OpenCode Go 凭据")) if (credential !is Credential.SessionCredential) @@ -206,8 +207,8 @@ class OpenCodeGoRepository( val bodyStr = resp.body?.string() ?: "" DebugLog.i(TAG, "triggerUsage: HTTP ${resp.code}, body=${bodyStr.take(500)}") if (!resp.isSuccessful) { - return@withContext Result.failure( - TriggerError("HTTP ${resp.code}", bodyStr.ifBlank { "无响应体" }) + return@withContext Result.failure( + TriggerError("HTTP ${resp.code}", bodyStr.ifBlank { "" }) ) } DebugLog.i(TAG, "triggerUsage: 请求成功,模型=$model") @@ -318,7 +319,7 @@ class OpenCodeGoRepository( /** * 解析 OpenAI 兼容的 chat completions 响应,提取回复文本和用量统计。 */ -internal fun parseChatResponse(responseBody: String, model: String): String { +internal fun parseChatResponse(responseBody: String, model: String): TriggerSummary { val json = Json { ignoreUnknownKeys = true } return try { val root = json.parseToJsonElement(responseBody) as? JsonObject @@ -331,18 +332,14 @@ internal fun parseChatResponse(responseBody: String, model: String): String { val completionTokens = (usage?.get("completion_tokens") as? JsonPrimitive)?.contentOrNull val totalTokens = (usage?.get("total_tokens") as? JsonPrimitive)?.contentOrNull - val sb = StringBuilder() - sb.appendLine("✓ 激活成功") - sb.appendLine("模型: $model") - sb.appendLine() - sb.appendLine("回复:") - sb.appendLine(content?.ifEmpty { "(空)" } ?: "(无回复内容)") - if (promptTokens != null || completionTokens != null) { - sb.appendLine() - sb.appendLine("用量: prompt=$promptTokens completion=$completionTokens total=$totalTokens tokens") - } - sb.toString().trim() + TriggerSummary( + model = model, + reply = content, + inputTokens = promptTokens, + outputTokens = completionTokens, + totalTokens = totalTokens + ) } catch (_: Exception) { - "✓ 激活成功\n模型: $model\n\n(响应解析失败,但请求已发出)" + TriggerSummary(model = model, reply = null, parseFailed = true) } } \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/data/repository/RepositoryError.kt b/app/src/main/java/com/rainy/token/data/repository/RepositoryError.kt index b0c80bd..cfa38dd 100644 --- a/app/src/main/java/com/rainy/token/data/repository/RepositoryError.kt +++ b/app/src/main/java/com/rainy/token/data/repository/RepositoryError.kt @@ -23,9 +23,9 @@ sealed class RepositoryError(message: String, cause: Throwable? = null) : Except class ServerError(val code: Int, cause: Throwable? = null) : RepositoryError("服务端异常 (HTTP $code)", cause) - /** 解析失败(HTML 解析结果为空 / JSON 反序列化失败) */ - class ParseError(message: String, cause: Throwable? = null) : - RepositoryError("解析失败: $message", cause) + /** 解析失败(HTML 解析结果为空 / JSON 反序列化失败),detail 为具体原因 */ + class ParseError(val detail: String, cause: Throwable? = null) : + RepositoryError("解析失败: $detail", cause) /** 未知错误 */ class Unknown(cause: Throwable? = null) : RepositoryError( diff --git a/app/src/main/java/com/rainy/token/domain/model/TriggerSummary.kt b/app/src/main/java/com/rainy/token/domain/model/TriggerSummary.kt new file mode 100644 index 0000000..0345e32 --- /dev/null +++ b/app/src/main/java/com/rainy/token/domain/model/TriggerSummary.kt @@ -0,0 +1,24 @@ +package com.rainy.token.domain.model + +/** + * 一键激活用量的结构化响应摘要。 + * + * Repository 只负责从响应中提取数据,不拼接展示文案; + * UI 层按当前语言环境用 stringResource 组装本地化文本。 + */ +data class TriggerSummary( + /** 发起请求时使用的模型 */ + val model: String, + /** 模型回复文本;null 表示响应中不存在回复内容 */ + val reply: String?, + /** 输入 token 数(OCGO/Ollama 为 prompt_tokens,Codex 为 input_tokens) */ + val inputTokens: String? = null, + /** 输出 token 数(OCGO/Ollama 为 completion_tokens,Codex 为 output_tokens) */ + val outputTokens: String? = null, + /** 总 token 数(OCGO/Ollama 的 total_tokens;Codex 无此字段) */ + val totalTokens: String? = null, + /** Codex 响应 ID */ + val responseId: String? = null, + /** 响应解析失败,但请求已成功发出 */ + val parseFailed: Boolean = false +) diff --git a/app/src/main/java/com/rainy/token/domain/usecase/RefreshBalanceUseCase.kt b/app/src/main/java/com/rainy/token/domain/usecase/RefreshBalanceUseCase.kt index b943352..0fb8b2b 100644 --- a/app/src/main/java/com/rainy/token/domain/usecase/RefreshBalanceUseCase.kt +++ b/app/src/main/java/com/rainy/token/domain/usecase/RefreshBalanceUseCase.kt @@ -10,6 +10,7 @@ import com.rainy.token.data.repository.RefreshWriteSession import com.rainy.token.data.repository.RepositoryError import com.rainy.token.data.repository.retryOnTransientError import com.rainy.token.domain.model.ServiceBalance +import com.rainy.token.domain.model.TriggerSummary import com.rainy.token.domain.service.ServiceType import javax.inject.Inject import javax.inject.Provider @@ -55,7 +56,7 @@ class RefreshBalanceUseCase @Inject constructor( suspend fun fetchCodexModels(): Result> = codexRepositoryProvider.get().fetchModels() - suspend fun triggerCodexUsage(model: String): Result = + suspend fun triggerCodexUsage(model: String): Result = withCredentialSession(ServiceType.CODEX) { codexRepositoryProvider.get().triggerUsage(model) } @@ -63,7 +64,7 @@ class RefreshBalanceUseCase @Inject constructor( suspend fun fetchOpenCodeGoModels(): Result> = openCodeGoRepositoryProvider.get().fetchModels() - suspend fun triggerOpenCodeGoUsage(model: String): Result = + suspend fun triggerOpenCodeGoUsage(model: String): Result = withCredentialSession(ServiceType.OPENCODE_GO) { openCodeGoRepositoryProvider.get().triggerUsage(model) } @@ -71,7 +72,7 @@ class RefreshBalanceUseCase @Inject constructor( suspend fun fetchOllamaModels(): Result> = ollamaRepositoryProvider.get().fetchModels() - suspend fun triggerOllamaUsage(model: String): Result = + suspend fun triggerOllamaUsage(model: String): Result = withCredentialSession(ServiceType.OLLAMA) { ollamaRepositoryProvider.get().triggerUsage(model) } diff --git a/app/src/main/java/com/rainy/token/ui/components/FormatUtils.kt b/app/src/main/java/com/rainy/token/ui/components/FormatUtils.kt index 55ad00c..992156a 100644 --- a/app/src/main/java/com/rainy/token/ui/components/FormatUtils.kt +++ b/app/src/main/java/com/rainy/token/ui/components/FormatUtils.kt @@ -56,8 +56,13 @@ fun formatResetForWidget(sec: Long, text: DurationText = ChineseDurationText): S } } -/** 标准化窗口标签:weekly → 每周,其他原样返回 */ -fun normalizeWindowLabel(label: String, weeklyLabel: String = "每周"): String = when (label.lowercase()) { - "weekly" -> weeklyLabel +/** 标准化窗口标签:weekly/每周 → 每周标签,monthly/每月 → 每月标签,其他原样返回 */ +fun normalizeWindowLabel( + label: String, + weeklyLabel: String = "每周", + monthlyLabel: String = "每月" +): String = when (label.lowercase()) { + "weekly", "每周" -> weeklyLabel + "monthly", "每月" -> monthlyLabel else -> label } \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt index de9e42f..9e8fb69 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt @@ -10,6 +10,8 @@ import com.rainy.token.domain.model.CredentialStatus import com.rainy.token.domain.model.ServiceBalance import com.rainy.token.domain.service.ServiceType import com.rainy.token.domain.usecase.RefreshBalanceUseCase +import com.rainy.token.R +import com.rainy.token.ui.components.UiText import com.rainy.token.ui.widget.OpenCodeGoWidgetProvider import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext @@ -127,10 +129,10 @@ class DashboardViewModel @Inject constructor( val cards = ServiceType.entries.map { type -> val result = results[type] val error = result?.exceptionOrNull() - val errMsg = error + val errorUi = error ?.takeUnless { it is RepositoryError.CredentialChanged } - ?.message - buildCard(localStates.getValue(type), lastFetchError = errMsg) + ?.let { errorToUiText(it) } + buildCard(localStates.getValue(type), lastFetchError = errorUi) } _uiState.update { it.copy(refreshing = false, cards = cards) } // 刷新成功后更新桌面小组件 @@ -145,7 +147,7 @@ class DashboardViewModel @Inject constructor( private fun buildCard( local: CredentialRepository.LocalState, - lastFetchError: String? + lastFetchError: UiText? ): DashboardCardUi = DashboardCardUi( service = local.status.service, credentialState = local.status.state, @@ -181,8 +183,29 @@ data class DashboardCardUi( val credentialState: CredentialStatus.State, val credentialFingerprint: String?, val cachedBalance: CachedBalance?, - val lastFetchError: String? + val lastFetchError: UiText? ) { /** 余额展示主数字。优先取缓存,错误时也展示(不隐藏,让用户看到旧值 + 红点提示)。 */ val displayBalance: ServiceBalance? get() = cachedBalance?.balance } + +/** 把 Repository 错误映射为本地化文案(错误详情 detail 为技术信息,随资源参数展示)。 */ +private fun errorToUiText(error: Throwable): UiText = when (error) { + is RepositoryError.InvalidCredential -> + UiText.Resource(R.string.error_credential_invalid_reconfigure) + is RepositoryError.RateLimited -> UiText.Resource( + R.string.error_rate_limited_retry, + listOf( + error.retryAfterSeconds?.let { + UiText.Resource(R.string.error_rate_limited_retry_suffix, listOf(it)) + } ?: "" + ) + ) + is RepositoryError.Network -> UiText.Resource(R.string.error_network_check) + is RepositoryError.ServerError -> + UiText.Resource(R.string.error_server_http, listOf(error.code)) + is RepositoryError.ParseError -> + UiText.Resource(R.string.error_parse_failed, listOf(error.detail)) + else -> error.message?.let { UiText.Dynamic(it) } + ?: UiText.Resource(R.string.common_unknown) +} 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 666912e..b29099e 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 @@ -279,11 +279,11 @@ internal fun formatCodexPrimaryLabel( usageLabel: String = "用量" ): String = when (raw?.lowercase()) { "5h" -> "5h" - "7d", weeklyLabel -> weeklyLabel - "30d", monthlyLabel -> monthlyLabel + "7d", "每周", weeklyLabel -> weeklyLabel + "30d", "每月", monthlyLabel -> monthlyLabel "usage" -> usageLabel null -> "5h" - else -> raw + else -> raw ?: "5h" } @Composable @@ -295,8 +295,13 @@ internal fun CodexUsageWindows(balance: ServiceBalance) { .maxOrNull()?.plus(1) ?: 0 val weeklyLabel = stringResource(R.string.window_every_week) + val monthlyLabel = stringResource(R.string.window_every_month) val windows = (0 until windowCount).map { i -> - val label = normalizeWindowLabel(extras["window_$i.label"] ?: "Usage", weeklyLabel = weeklyLabel) + val label = normalizeWindowLabel( + extras["window_$i.label"] ?: "Usage", + weeklyLabel = weeklyLabel, + monthlyLabel = monthlyLabel + ) val remainingPct = extras["window_$i.remainingPct"]?.toIntOrNull() val resetAt = extras["window_$i.resetAt"]?.toLongOrNull()?.takeIf { it > 0 } Triple(label, remainingPct, resetAt) @@ -526,12 +531,20 @@ internal fun secondaryLineRes(card: DashboardCardUi): Int = when (card.service) /** * 底部更新时间/错误文案(UiText 形式,UI 层按当前语言解析)。 - * 错误分支直接透传 Repository 中文 message(Dynamic,不参与翻译)。 + * 错误信息由 ViewModel 按 RepositoryError 类型映射为本地化资源。 */ + +/** 截断 UiText 到最长 60 字符(仅对 Dynamic 原始文本生效;Resource 长度由资源控制)。 */ +private fun truncateUiText(text: UiText): UiText = when (text) { + is UiText.Dynamic -> UiText.Dynamic(text.value.take(60) + if (text.value.length > 60) "…" else "") + else -> text +} + internal fun footerText(card: DashboardCardUi): UiText { - if (card.lastFetchError != null) { - val msg = card.lastFetchError.take(60) - return UiText.Dynamic("⚠ $msg${if (card.lastFetchError.length > 60) "…" else ""}") + val error = card.lastFetchError + if (error != null) { + // 截断保护:错误信息最长展示 60 字符(以 UiText 形式保留,UI 层按当前语言解析) + return UiText.Resource(R.string.dashboard_error_prefix, listOf(truncateUiText(error))) } val fetchedAt = card.cachedBalance?.fetchedAt ?: return UiText.Resource(R.string.footer_never_fetched) diff --git a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt index 14a708e..a9ee908 100644 --- a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt @@ -61,6 +61,7 @@ import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.rainy.token.R import com.rainy.token.domain.model.ServiceBalance +import com.rainy.token.domain.model.TriggerSummary import com.rainy.token.domain.service.FetchMethod import com.rainy.token.domain.service.ServiceConfigProvider import com.rainy.token.domain.service.ServiceType @@ -218,7 +219,7 @@ fun ServiceDetailScreen( when (triggerState) { is TriggerState.Success -> ResponseDialog( title = stringResource(R.string.trigger_success_title), - responseBody = (triggerState as TriggerState.Success).responseBody, + responseBody = buildTriggerSummaryText((triggerState as TriggerState.Success).summary), isError = false, onDismiss = { viewModel.dismissTrigger() } ) @@ -1122,6 +1123,43 @@ private fun ModelDropdown( // ── Codex 一键激活用量:响应弹窗 ── +/** 把结构化的激活摘要组装为本地化文本(Repository 不再拼接展示文案)。 */ +@Composable +private fun buildTriggerSummaryText(summary: TriggerSummary): String = buildString { + appendLine(stringResource(R.string.trigger_success_header)) + appendLine(stringResource(R.string.trigger_success_model, summary.model)) + summary.responseId?.let { appendLine(stringResource(R.string.trigger_success_response_id, it)) } + appendLine() + appendLine(stringResource(R.string.trigger_reply_header)) + if (summary.parseFailed) { + appendLine(stringResource(R.string.trigger_parse_failed)) + } else { + appendLine( + summary.reply?.ifEmpty { stringResource(R.string.trigger_reply_empty) } + ?: stringResource(R.string.trigger_reply_missing) + ) + } + if (summary.inputTokens != null || summary.outputTokens != null) { + appendLine() + appendLine( + if (summary.totalTokens != null) { + stringResource( + R.string.trigger_usage_prompt_completion_total, + summary.inputTokens ?: "—", + summary.outputTokens ?: "—", + summary.totalTokens ?: "—" + ) + } else { + stringResource( + R.string.trigger_usage_input_output, + summary.inputTokens ?: "—", + summary.outputTokens ?: "—" + ) + } + ) + } +}.trim() + @Composable private fun ResponseDialog( title: String, 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 675b76a..b82fc98 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 @@ -11,6 +11,7 @@ import com.rainy.token.data.repository.TriggerError import com.rainy.token.domain.model.Credential import com.rainy.token.domain.model.CredentialStatus import com.rainy.token.domain.model.ServiceBalance +import com.rainy.token.domain.model.TriggerSummary import com.rainy.token.R import com.rainy.token.domain.service.ServiceType import com.rainy.token.domain.usecase.RefreshBalanceUseCase @@ -324,8 +325,8 @@ class ServiceDetailViewModel @Inject constructor( if (serviceGen != serviceGeneration || _serviceType.value != service) return@launch result - .onSuccess { responseBody -> - _triggerState.value = TriggerState.Success(responseBody) + .onSuccess { summary -> + _triggerState.value = TriggerState.Success(summary) kotlinx.coroutines.delay(2000) if (serviceGen == serviceGeneration && _serviceType.value == service) { refresh() @@ -336,7 +337,15 @@ class ServiceDetailViewModel @Inject constructor( val responseBody: String? when (error) { is TriggerError -> { - message = UiText.Dynamic(error.summary) + val prefix = "token 刷新失败: " + message = if (error.summary.startsWith(prefix)) { + UiText.Resource( + R.string.error_token_refresh_failed, + listOf(error.summary.removePrefix(prefix)) + ) + } else { + UiText.Dynamic(error.summary) + } responseBody = error.responseBody.ifBlank { null } } @@ -359,8 +368,7 @@ class ServiceDetailViewModel @Inject constructor( message = if ((error as? RepositoryError.Unknown)?.cause is IllegalArgumentException) { UiText.Resource(R.string.error_trigger_not_supported) } else { - error.message?.let { UiText.Dynamic(it) } - ?: UiText.Resource(R.string.common_unknown) + errorMessage(error) } responseBody = null } @@ -529,7 +537,7 @@ class ServiceDetailViewModel @Inject constructor( is RepositoryError.ServerError -> UiText.Resource(R.string.error_server_http, listOf(error.code)) is RepositoryError.ParseError -> - UiText.Resource(R.string.error_parse_failed, listOf(error.message ?: "")) + UiText.Resource(R.string.error_parse_failed, listOf(error.detail)) else -> error.message?.let { UiText.Dynamic(it) } ?: UiText.Resource(R.string.common_unknown) } @@ -579,6 +587,6 @@ data class ServiceDetailUiState( sealed class TriggerState { data object Idle : TriggerState() data object Loading : TriggerState() - data class Success(val responseBody: String) : TriggerState() + data class Success(val summary: TriggerSummary) : TriggerState() data class Error(val message: UiText, val responseBody: String?) : TriggerState() } diff --git a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt index 0df298f..18529d0 100644 --- a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt @@ -170,7 +170,15 @@ class CredentialEditViewModel @Inject constructor( is RepositoryError.ServerError -> UiText.Resource(R.string.error_server, listOf(err.code)) is RepositoryError.Network -> - UiText.Resource(R.string.error_network, listOf(err.cause?.message ?: "未知")) + UiText.Resource( + R.string.error_network, + listOf( + err.cause?.message?.let { UiText.Dynamic(it) } + ?: UiText.Resource(R.string.common_unknown) + ) + ) + is RepositoryError.ParseError -> + UiText.Resource(R.string.error_parse_failed, listOf(err.detail)) else -> err?.message?.let { UiText.Dynamic(it) } ?: UiText.Resource(R.string.common_unknown) } @@ -371,7 +379,10 @@ class CredentialEditViewModel @Inject constructor( it.copy( message = UiText.Resource( R.string.error_auth_json_parse, - listOf(e.message ?: "JSON 格式错误") + listOf( + e.message?.let { UiText.Dynamic(it) } + ?: UiText.Resource(R.string.error_json_format) + ) ) ) } diff --git a/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt b/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt index 89ff5e3..4de4a05 100644 --- a/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt +++ b/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt @@ -193,7 +193,11 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() { val weeklyLabel = context.getString(R.string.window_every_week) val monthlyLabel = context.getString(R.string.window_every_month) val windows = (0 until windowCount).map { index -> - val label = normalizeWindowLabel(extras["window_$index.label"] ?: "Usage", weeklyLabel = weeklyLabel) + val label = normalizeWindowLabel( + extras["window_$index.label"] ?: "Usage", + weeklyLabel = weeklyLabel, + monthlyLabel = monthlyLabel + ) val remaining = extras["window_$index.remainingPct"]?.toIntOrNull() val resetAt = extras["window_$index.resetAt"]?.toLongOrNull()?.takeIf { it > 0 } Triple(label, remaining?.let { (100 - it).coerceIn(0, 100) }, resetAt?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 }) diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index bd9c976..ffdbb6a 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -73,6 +73,7 @@ Updated %1$d h ago Updated %1$s 💡 Long-press a card to reorder + ⚠ %1$s Stored locally & encrypted, nothing is uploaded Add home screen widget? This will ask the launcher to add the RainyToken widget. Some systems may still show their own confirmation dialog. @@ -214,6 +215,7 @@ Connected! Balance: %1$s %2$s Connected, credentials saved API Key cannot be empty + Invalid JSON format Service rejected this credential (401/403) Credentials changed during test Too many requests (429) @@ -248,9 +250,19 @@ ✓ Usage activated + ✓ Activation successful Request result Error: %1$s Response body:\n%1$s + Model: %1$s + Response ID: %1$s + Reply: + (empty) + (no reply content) + (response parsing failed, but the request was sent) + Usage: prompt=%1$s completion=%2$s total=%3$s tokens + Usage: input=%1$s output=%2$s tokens + Token refresh failed: %1$s Sign in to %1$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e27be88..13603a5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -76,6 +76,7 @@ %1$d 小时前更新 %1$s 更新 💡 长按卡片可拖拽排序 + ⚠ %1$s RainyToken · v1.0 本地加密存储,不上传任何数据 添加桌面小组件? @@ -218,6 +219,7 @@ 连接成功!余额: %1$s %2$s 连接成功,凭据已保存 API Key 不能为空 + JSON 格式错误 服务拒绝该凭据 (401/403) 测试期间凭据已变更 请求过于频繁 (429) @@ -252,9 +254,19 @@ ✓ 用量已激活 + ✓ 激活成功 请求结果 错误: %1$s 响应内容:\n%1$s + 模型: %1$s + 响应 ID: %1$s + 回复: + (空) + (无回复内容) + (响应解析失败,但请求已发出) + 用量: prompt=%1$s completion=%2$s total=%3$s tokens + 用量: input=%1$s output=%2$s tokens + token 刷新失败:%1$s 登录 %1$s diff --git a/app/src/test/java/com/rainy/token/data/repository/CodexRepositoryTest.kt b/app/src/test/java/com/rainy/token/data/repository/CodexRepositoryTest.kt index aea46e1..628b263 100644 --- a/app/src/test/java/com/rainy/token/data/repository/CodexRepositoryTest.kt +++ b/app/src/test/java/com/rainy/token/data/repository/CodexRepositoryTest.kt @@ -4,6 +4,8 @@ import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonNull import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -236,11 +238,12 @@ class CodexRepositoryTest { """.trimIndent() val result = parseSseResponse(sse, "gpt-5.6") - assertTrue(result.contains("Hello, world!")) - assertTrue(result.contains("input=100")) - assertTrue(result.contains("output=50")) - assertTrue(result.contains("resp_123")) - assertTrue(result.contains("gpt-5.6")) + assertEquals("Hello, world!", result.reply) + assertEquals("100", result.inputTokens) + assertEquals("50", result.outputTokens) + assertEquals("resp_123", result.responseId) + assertEquals("gpt-5.6", result.model) + assertFalse(result.parseFailed) } @Test @@ -251,9 +254,10 @@ class CodexRepositoryTest { """.trimIndent() val result = parseSseResponse(sse, "o4-mini") - assertTrue(result.contains("(空)")) - assertTrue(result.contains("input=10")) - assertTrue(result.contains("output=5")) + assertEquals("", result.reply) + assertEquals("10", result.inputTokens) + assertEquals("5", result.outputTokens) + assertNull(result.totalTokens) } @Test @@ -265,16 +269,18 @@ class CodexRepositoryTest { """.trimIndent() val result = parseSseResponse(sse, "gpt-5.6") - assertTrue(result.contains("Hi there")) - // Should not contain usage line when no usage data - assertTrue(!result.contains("用量:")) + assertEquals("Hi there", result.reply) + // Should be null when no usage data present + assertNull(result.inputTokens) + assertNull(result.outputTokens) } @Test fun `parseSseResponse handles empty input`() { val result = parseSseResponse("", "gpt-5.6") - assertTrue(result.contains("(空)")) - assertTrue(result.contains("gpt-5.6")) + assertEquals("", result.reply) + assertEquals("gpt-5.6", result.model) + assertNull(result.responseId) } @Test @@ -288,9 +294,9 @@ class CodexRepositoryTest { """.trimIndent() val result = parseSseResponse(sse, "o4-mini") - assertTrue(result.contains("Test reply")) - assertTrue(result.contains("input=200")) - assertTrue(result.contains("output=100")) + assertEquals("Test reply", result.reply) + assertEquals("200", result.inputTokens) + assertEquals("100", result.outputTokens) } @Test @@ -304,18 +310,22 @@ class CodexRepositoryTest { val result = parseSseResponse(sse, "gpt-5.6") // Malformed line should be skipped, valid ones parsed - assertTrue(result.contains("Good")) - assertTrue(result.contains("input=1")) + assertEquals("Good", result.reply) + assertEquals("1", result.inputTokens) } @Test - fun `parseSseResponse contains activation success header`() { + fun `parseSseResponse returns structured metadata`() { val sse = """ data: {"type":"response.completed","response":{"id":"r1","usage":{"input_tokens":"0","output_tokens":"0"}}} data: [DONE] """.trimIndent() val result = parseSseResponse(sse, "gpt-5.6") - assertTrue(result.contains("✓ 激活成功")) + assertEquals("r1", result.responseId) + assertEquals("0", result.inputTokens) + assertEquals("0", result.outputTokens) + assertEquals("", result.reply) + assertFalse(result.parseFailed) } } \ No newline at end of file diff --git a/app/src/test/java/com/rainy/token/ui/components/FormatUtilsTest.kt b/app/src/test/java/com/rainy/token/ui/components/FormatUtilsTest.kt index 79df352..7f13167 100644 --- a/app/src/test/java/com/rainy/token/ui/components/FormatUtilsTest.kt +++ b/app/src/test/java/com/rainy/token/ui/components/FormatUtilsTest.kt @@ -112,12 +112,19 @@ class FormatUtilsTest { assertEquals("每周", normalizeWindowLabel("weekly")) assertEquals("每周", normalizeWindowLabel("Weekly")) assertEquals("每周", normalizeWindowLabel("WEEKLY")) + assertEquals("每周", normalizeWindowLabel("每周")) } @Test - fun `normalizeWindowLabel non-weekly passes through`() { + fun `normalizeWindowLabel monthly maps to monthly label`() { + assertEquals("每月", normalizeWindowLabel("monthly")) + assertEquals("Monthly", normalizeWindowLabel("MONTHLY", monthlyLabel = "Monthly")) + assertEquals("每月", normalizeWindowLabel("每月")) + } + + @Test + fun `normalizeWindowLabel unknown passes through`() { assertEquals("5h", normalizeWindowLabel("5h")) - assertEquals("monthly", normalizeWindowLabel("monthly")) assertEquals("30d", normalizeWindowLabel("30d")) }