From 43d8df90f6756875a73becc179b97e070c821353 Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Mon, 24 Aug 2026 08:49:46 +0000 Subject: [PATCH] =?UTF-8?q?feat(ui):=20Codex=20=E4=B8=BB=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E4=BD=99=E9=A2=9D=E4=B8=BA=E4=B8=BB=E8=A7=86=E8=A7=89=EF=BC=8C?= =?UTF-8?q?Spark=20=E5=BC=B1=E5=8C=96=E4=B8=BA=E6=AC=A1=E8=A6=81=E6=91=98?= =?UTF-8?q?=E8=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 首页卡片:主模型窗口独占进度行,Spark 收为单行小字摘要\n- 详情页:Spark 收进分隔线 + 小标题次要小节,细条低饱和进度\n- 桌面小组件:跳过 SPARK 窗口,仅保留主模型三行布局\n- 新增 window_spark 多语言文案 --- .../token/ui/dashboard/ServiceBalanceCards.kt | 49 +++++++-- .../ui/servicedetail/ServiceDetailScreen.kt | 104 ++++++++++++++++-- .../ui/widget/OpenCodeGoWidgetProvider.kt | 23 ++-- app/src/main/res/values-b+zh+Hans/strings.xml | 7 +- app/src/main/res/values-b+zh+Hant/strings.xml | 7 +- app/src/main/res/values/strings.xml | 7 +- 6 files changed, 158 insertions(+), 39 deletions(-) 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 1f78a04..4fec0d9 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 @@ -297,21 +297,34 @@ internal fun CodexUsageWindows(balance: ServiceBalance) { val weeklyLabel = stringResource(R.string.window_every_week) val monthlyLabel = stringResource(R.string.window_every_month) + val usageLabel = stringResource(R.string.window_usage) + val sparkTitle = stringResource(R.string.window_spark) + + data class UiWindow(val label: String, val isSpark: Boolean, val remainingPct: Int?, val resetAt: Long?) + val windows = (0 until windowCount).map { i -> + val rawLabel = extras["window_$i.label"] ?: "usage" val label = normalizeWindowLabel( - extras["window_$i.label"] ?: "usage", + rawLabel, weeklyLabel = weeklyLabel, monthlyLabel = monthlyLabel, - usageLabel = stringResource(R.string.window_usage) + usageLabel = usageLabel + ) + UiWindow( + label = label, + isSpark = extras["window_$i.group"] == "SPARK", + remainingPct = extras["window_$i.remainingPct"]?.toIntOrNull(), + resetAt = extras["window_$i.resetAt"]?.toLongOrNull()?.takeIf { it > 0 } ) - val remainingPct = extras["window_$i.remainingPct"]?.toIntOrNull() - val resetAt = extras["window_$i.resetAt"]?.toLongOrNull()?.takeIf { it > 0 } - Triple(label, remainingPct, resetAt) } - // 判断是否有 5h 窗口(与语言无关:匹配 "5h"/"5H" 或本地化标签,用于保留空槽位) + // 主模型(普通 Codex)窗口独占全部进度行;Spark 独立限额是次要通道,只留一行小字摘要 + val mainWindows = windows.filter { !it.isSpark } + val sparkWindows = windows.filter { it.isSpark } + + // 判断是否有 5h 窗口(与语言无关:匹配 "5h"/"5H" 或本地化标签,仅看主模型窗口) val fiveHourLabel = stringResource(R.string.window_5h) - val has5h = windows.any { isFiveHourLabel(it.first, fiveHourLabel, stringResource(R.string.window_5h_short)) } + val has5h = mainWindows.any { isFiveHourLabel(it.label, fiveHourLabel, stringResource(R.string.window_5h_short)) } Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { if (windows.isEmpty()) { @@ -321,14 +334,26 @@ internal fun CodexUsageWindows(balance: ServiceBalance) { if (!has5h) { CompactUsageRowEmpty(label = fiveHourLabel, resetInSec = null) } - windows.forEach { (label, remainingPct, resetAt) -> - if (remainingPct != null) { - val usedPct = (100 - remainingPct).coerceIn(0, 100) - CompactUsageRow(label = label, pct = usedPct, resetInSec = resetAt?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 }) + mainWindows.forEach { w -> + val resetSec = w.resetAt?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 } + if (w.remainingPct != null) { + CompactUsageRow(label = w.label, pct = (100 - w.remainingPct).coerceIn(0, 100), resetInSec = resetSec) } else { - CompactUsageRowEmpty(label = label, resetInSec = resetAt?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 }) + CompactUsageRowEmpty(label = w.label, resetInSec = resetSec) } } + // Spark 次要限额:单行摘要,不与主模型争夺视觉焦点 + if (sparkWindows.isNotEmpty()) { + val summary = sparkWindows.joinToString(" · ") { w -> + val used = w.remainingPct?.let { (100 - it).coerceIn(0, 100) } + if (used != null) "${w.label} $used%" else "${w.label} —" + } + Text( + text = "$sparkTitle · $summary", + style = MaterialTheme.typography.bodySmall, + color = inkMuted() + ) + } } } } 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 ffd1ede..dee9b57 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 @@ -485,10 +485,13 @@ private fun CodexUsageCard(state: State) { } val extras = balance?.extras ?: return val plan = extras["plan"].orEmpty() - val windows = remember(extras) { extractCodexWindows(extras) } - if (windows.isEmpty()) return + val allWindows = remember(extras) { extractCodexWindows(extras) } + if (allWindows.isEmpty()) return + // 主模型窗口占据主视觉;Spark 独立限额收进底部次要小节 + val windows = allWindows.filter { it.group == CodexWindowGroup.CODEX } + val sparkWindows = allWindows.filter { it.group == CodexWindowGroup.SPARK } - // 判断是否有 5h 窗口(与语言无关:匹配 "5h"/"5H" 或本地化标签) + // 判断是否有 5h 窗口(与语言无关:匹配 "5h"/"5H" 或本地化标签;仅看主模型窗口) val has5h = windows.any { isFiveHourLabel(it.rawLabel, stringResource(R.string.window_5h), stringResource(R.string.window_5h_short)) } @@ -547,14 +550,100 @@ private fun CodexUsageCard(state: State) { decimals = 2 ) } + + // Spark 独立限额(次要通道):分隔线 + 小标题 + 紧凑细条,视觉权重远低于主模型 + if (sparkWindows.isNotEmpty()) { + Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringResource(R.string.window_spark), + style = MaterialTheme.typography.labelMedium, + color = inkMuted() + ) + Spacer(modifier = Modifier.height(8.dp)) + sparkWindows.forEach { window -> + val resetInSec = window.resetAt?.let { + (it - System.currentTimeMillis()) / 1000 + }?.takeIf { it > 0 } + SparkUsageRow( + label = codexWindowLabel(window.rawLabel), + pct = window.usedPct, + resetInSec = resetInSec + ) + Spacer(modifier = Modifier.height(8.dp)) + } + } } } } +/** + * Spark 次要限额的紧凑行:小字号 + 细进度条,视觉权重明显低于主模型的 [UsageWindowRow]。 + */ +@Composable +private fun SparkUsageRow(label: String, pct: Float, resetInSec: Long?) { + val durationText = DurationText( + day = stringResource(R.string.format_day), + hour = stringResource(R.string.format_hour), + minute = stringResource(R.string.format_minute) + ) + val pctValue = pct.coerceIn(0f, 100f) + Column { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f) + ) + Text( + text = String.format(Locale.US, "%.1f", pctValue), + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "%", + style = MaterialTheme.typography.labelSmall, + color = inkMuted(), + modifier = Modifier.padding(start = 1.dp) + ) + } + Spacer(modifier = Modifier.height(4.dp)) + LinearProgressIndicator( + progress = { pctValue / 100f }, + modifier = Modifier + .fillMaxWidth() + .height(2.5.dp) + .clip(RoundedCornerShape(1.25.dp)), + color = when { + pctValue >= 80f -> MaterialTheme.colorScheme.error.copy(alpha = 0.75f) + else -> StrawberryPink.copy(alpha = 0.55f) + }, + trackColor = MaterialTheme.colorScheme.surfaceVariant + ) + if (resetInSec != null && resetInSec > 0) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = stringResource(R.string.service_reset_in, formatResetInSec(resetInSec, durationText)), + style = MaterialTheme.typography.labelSmall, + color = inkMuted() + ) + } + } +} + +private enum class CodexWindowGroup { CODEX, SPARK } + private data class CodexWindow( val rawLabel: String, val usedPct: Float, - val resetAt: Long? + val resetAt: Long?, + val group: CodexWindowGroup = CodexWindowGroup.CODEX ) private fun extractCodexWindows(extras: Map): List { @@ -565,7 +654,8 @@ private fun extractCodexWindows(extras: Map): List val remaining = extras["window_${i}.remainingPct"]?.toFloatOrNull() ?: 0f val resetAt = extras["window_${i}.resetAt"]?.toLongOrNull()?.takeIf { it > 0 } val usedPct = (100f - remaining).coerceIn(0f, 100f) - result.add(CodexWindow(rawLabel, usedPct, resetAt)) + val group = if (extras["window_${i}.group"] == "SPARK") CodexWindowGroup.SPARK else CodexWindowGroup.CODEX + result.add(CodexWindow(rawLabel, usedPct, resetAt, group)) i++ } return result @@ -574,14 +664,14 @@ private fun extractCodexWindows(extras: Map): List @Composable private fun codexWindowLabel(raw: String): String = when (raw.lowercase()) { "5h" -> stringResource(R.string.window_5h) - "7d", "weekly", "每周" -> stringResource(R.string.window_weekly) + "7d", "weekly", "每周", "每週" -> stringResource(R.string.window_weekly) "30d", "monthly", "每月" -> stringResource(R.string.window_monthly) "usage" -> stringResource(R.string.window_usage) else -> raw } /** - * Ollama Pro 专属:5h + 每周用量窗口卡 + 模型级调用次数。 + * Ollama 专属:5h + 每周用量窗口卡 + 模型级调用次数。 */ @Composable private fun OllamaUsageCard(state: State) { 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 14d40a0..3b3101b 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 @@ -205,26 +205,27 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() { val weeklyLabel = context.getString(R.string.window_every_week) val monthlyLabel = context.getString(R.string.window_every_month) val usageLabel = context.getString(R.string.window_usage) - val windows = (0 until windowCount).map { index -> + // 仅主模型窗口进入桌面组件;Spark 独立限额为次要通道,不占用组件行位 + val normal = mutableListOf>() + for (index in 0 until windowCount) { + if (extras["window_$index.group"] == "SPARK") continue + val rawLabel = extras["window_$index.label"] ?: "usage" val label = normalizeWindowLabel( - extras["window_$index.label"] ?: "usage", + rawLabel, weeklyLabel = weeklyLabel, monthlyLabel = monthlyLabel, usageLabel = usageLabel ) 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 }) + normal.add(Triple(label, remaining?.let { (100 - it).coerceIn(0, 100) }, resetAt?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 })) } // 判断是否有 5h 窗口 - val has5h = windows.any { it.first.contains("5") } - // 始终保留 5h 槽位在第一行 - val row1 = if (!has5h) { - Triple("5h", null, null) - } else { - windows.firstOrNull { it.first.contains("5") } ?: windows.getOrNull(0) ?: Triple("5h", null, null) - } - val otherWindows = windows.filter { it != row1 } + val has5h = normal.any { it.first.contains("5") } + // 始终保留 5h 槽位在第一行(按索引取行,避免内容相同的窗口被误过滤) + val row1Idx = if (has5h) (normal.indexOfFirst { it.first.contains("5") }.takeIf { it >= 0 } ?: 0) else -1 + val row1 = if (row1Idx >= 0) normal[row1Idx] else Triple("5h", null, null) + val otherWindows = normal.filterIndexed { idx, _ -> idx != row1Idx } val row2 = otherWindows.getOrNull(0) ?: Triple(weeklyLabel, null, null) val row3 = otherWindows.getOrNull(1) ?: Triple(monthlyLabel, null, null) setRowLabel(views, row1.first, row2.first, row3.first) diff --git a/app/src/main/res/values-b+zh+Hans/strings.xml b/app/src/main/res/values-b+zh+Hans/strings.xml index eda4077..0f9c702 100644 --- a/app/src/main/res/values-b+zh+Hans/strings.xml +++ b/app/src/main/res/values-b+zh+Hans/strings.xml @@ -106,7 +106,7 @@ REST API · ¥ WebView 抓取 · 5h 配额 JSON API · $ - ChatGPT Plus · Codex 额度 + ChatGPT · Codex 额度 Cookie 抓取 · Cloud 配额 @@ -385,7 +385,7 @@ }} 提示:导入后 APP 会自动刷新 token, 不需要手动更新。 - Ollama Pro 凭据 + Ollama 凭据 需要从浏览器复制完整 Cookie。登录 ollama.com/settings 后,在 DevTools → Network → 请求头里找到 Cookie 行复制。 更新凭据 保存凭据 @@ -414,7 +414,7 @@ 请输入 API Key 凭据会使用 Android Keystore (AES-256 GCM) 加密后存储到本机 DataStore。 更新 - Codex / ChatGPT Plus 凭据 + Codex / ChatGPT 凭据 推荐使用 OAuth 登录自动获取凭据。也可手动粘贴 auth.json 内容。 🔐 OAuth 登录(推荐) ── 或手动导入 ── @@ -435,6 +435,7 @@ 模型列表为空 响应格式异常 每周 + Spark 模型调用次数 缓存数据 · %1$s 显示的是上次成功获取的余额 diff --git a/app/src/main/res/values-b+zh+Hant/strings.xml b/app/src/main/res/values-b+zh+Hant/strings.xml index edd67f1..10ed321 100644 --- a/app/src/main/res/values-b+zh+Hant/strings.xml +++ b/app/src/main/res/values-b+zh+Hant/strings.xml @@ -106,7 +106,7 @@ REST API · ¥ WebView 抓取 · 5h 配額 JSON API · $ - ChatGPT Plus · Codex 額度 + ChatGPT · Codex 額度 Cookie 抓取 · Cloud 配額 @@ -385,7 +385,7 @@ }} 提示:匯入後 APP 會自動重新整理 token, 不需要手動更新。 - Ollama Pro 憑據 + Ollama 憑據 需要從瀏覽器複製完整 Cookie。登入 ollama.com/settings 後,在 DevTools → Network → 請求頭裡找到 Cookie 行複製。 更新憑據 儲存憑據 @@ -414,7 +414,7 @@ 請輸入 API Key 憑據會使用 Android Keystore (AES-256 GCM) 加密後儲存到本機 DataStore。 更新 - Codex / ChatGPT Plus 憑據 + Codex / ChatGPT 憑據 推薦使用 OAuth 登入自動獲取憑據。也可手動貼上 auth.json 內容。 🔐 OAuth 登入(推薦) ── 或手動匯入 ── @@ -435,6 +435,7 @@ 模型清單為空 回應格式異常 每週 + Spark 模型呼叫次數 快取數據 · %1$s 顯示的是上次成功獲取的餘額 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 79e9473..e86ef98 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -109,7 +109,7 @@ REST API · ¥ WebView fetch · 5h quota JSON API · $ - ChatGPT Plus · Codex quota + ChatGPT · Codex quota Cookie fetch · Cloud quota @@ -388,7 +388,7 @@ }} Tip: the app auto-refreshes the token after import, no manual updates needed. - Ollama Pro credentials + Ollama credentials Copy the full cookie from your browser. After logging into ollama.com/settings, find the Cookie header in DevTools → Network and copy it. Update credentials Save credentials @@ -417,7 +417,7 @@ Enter API Key Credentials are encrypted with Android Keystore (AES-256 GCM) and stored in local DataStore. Update - Codex / ChatGPT Plus credentials + Codex / ChatGPT credentials OAuth login fetches credentials automatically. You can also paste auth.json manually. 🔐 OAuth login (recommended) ── or import manually ── @@ -438,6 +438,7 @@ Model list is empty Response format is abnormal Weekly + Spark Model call count Cached data · %1$s Showing the last successfully fetched balance