Compare commits
No commits in common. "dev" and "feat/checkin-localization" have entirely different histories.
dev
...
feat/check
@ -59,7 +59,7 @@ RainyToken (雨晴Token) — AI Balance & Usage Quota Query · the Rainy Family
|
|||||||
| ➕ **One-tap add to home screen** | Tap + inside the app to add the widget directly, no need to browse the system list; double confirmation + permission check |
|
| ➕ **One-tap add to home screen** | Tap + inside the app to add the widget directly, no need to browse the system list; double confirmation + permission check |
|
||||||
| 💡 **Usage tips** | Home page shows a random operation tip (refreshed on each launch); settings page lists all 13 hidden tips |
|
| 💡 **Usage tips** | Home page shows a random operation tip (refreshed on each launch); settings page lists all 13 hidden tips |
|
||||||
| ⚡ **Room database** | Usage records stored in Room (indexed on workspaceId+timeCreated); DAO queries replace full JSON serialization; auto-migration from the legacy DataStore on first launch |
|
| ⚡ **Room database** | Usage records stored in Room (indexed on workspaceId+timeCreated); DAO queries replace full JSON serialization; auto-migration from the legacy DataStore on first launch |
|
||||||
| 🎀 **Rainy pink theme** | Material Design 3 · Refined Rose primary #C9436C · Blush white background #FDEFF4 |
|
| 🎀 **Rainy pink theme** | Material Design 3 · Strawberry Pink #FF85A2 · Sakura Pink #FFD1DC |
|
||||||
| 🔐 **Codex OAuth sign-in** | Headless OAuth PKCE: the app generates an authorization link → sign in in an external browser → paste the callback URL to complete authorization; no manual auth.json export needed |
|
| 🔐 **Codex OAuth sign-in** | Headless OAuth PKCE: the app generates an authorization link → sign in in an external browser → paste the callback URL to complete authorization; no manual auth.json export needed |
|
||||||
| ⚡ **Codex one-tap usage activation** | Codex detail page can send a short request to the ChatGPT API to trigger usage tracking; model list fetched dynamically from models.dev and persisted, with manual refresh; response dialog is copyable |
|
| ⚡ **Codex one-tap usage activation** | Codex detail page can send a short request to the ChatGPT API to trigger usage tracking; model list fetched dynamically from models.dev and persisted, with manual refresh; response dialog is copyable |
|
||||||
| ⚡ **OCGO / Ollama one-tap usage activation** | OCGO detail page can send a request to `opencode.ai/zen/v1`, Ollama detail page to `ollama.com/v1` to trigger usage tracking; API keys are entered manually in settings; model lists fetched dynamically from models.dev |
|
| ⚡ **OCGO / Ollama one-tap usage activation** | OCGO detail page can send a request to `opencode.ai/zen/v1`, Ollama detail page to `ollama.com/v1` to trigger usage tracking; API keys are entered manually in settings; model lists fetched dynamically from models.dev |
|
||||||
|
|||||||
@ -59,7 +59,7 @@ RainyToken(雨晴Token)— AI Balance & Usage Quota Query · the Rainy Famil
|
|||||||
| ➕ **一键添桌面** | APP 内点 + 直接添加小组件,不用去系统列表翻;二次确认 + 权限检测 |
|
| ➕ **一键添桌面** | APP 内点 + 直接添加小组件,不用去系统列表翻;二次确认 + 权限检测 |
|
||||||
| 💡 **使用小技巧** | 首页随机展示一条操作提示(每次启动刷新);设置页可查看全部 13 条隐藏操作技巧 |
|
| 💡 **使用小技巧** | 首页随机展示一条操作提示(每次启动刷新);设置页可查看全部 13 条隐藏操作技巧 |
|
||||||
| ⚡ **Room 数据库** | 用量记录存 Room(indexed on workspaceId+timeCreated),DAO 查询替代全量 JSON 序列化;首次启动自动从旧 DataStore 迁移 |
|
| ⚡ **Room 数据库** | 用量记录存 Room(indexed on workspaceId+timeCreated),DAO 查询替代全量 JSON 序列化;首次启动自动从旧 DataStore 迁移 |
|
||||||
| 🎀 **雨晴粉主题** | Material Design 3 · 精致玫瑰粉 主色 #C9436C · 粉白背景 #FDEFF4 |
|
| 🎀 **雨晴粉主题** | Material Design 3 · 草莓粉 #FF85A2 · 樱粉 #FFD1DC |
|
||||||
| 🔐 **Codex OAuth 登录** | 无头模式 OAuth PKCE:APP 生成授权链接 → 外部浏览器登录 → 粘贴回调 URL 完成授权,无需手动导出 auth.json |
|
| 🔐 **Codex OAuth 登录** | 无头模式 OAuth PKCE:APP 生成授权链接 → 外部浏览器登录 → 粘贴回调 URL 完成授权,无需手动导出 auth.json |
|
||||||
| ⚡ **Codex 一键激活用量** | Codex 详情页可向 ChatGPT API 发送简短请求触发用量统计;模型列表从 models.dev 动态获取并持久化,支持手动刷新;响应弹窗可复制 |
|
| ⚡ **Codex 一键激活用量** | Codex 详情页可向 ChatGPT API 发送简短请求触发用量统计;模型列表从 models.dev 动态获取并持久化,支持手动刷新;响应弹窗可复制 |
|
||||||
| ⚡ **OCGO / Ollama 一键激活用量** | OCGO 详情页可向 `opencode.ai/zen/v1` 发送请求,Ollama 详情页可向 `ollama.com/v1` 发送请求触发用量统计;API Key 在设置页手动填写,模型列表从 models.dev 动态获取 |
|
| ⚡ **OCGO / Ollama 一键激活用量** | OCGO 详情页可向 `opencode.ai/zen/v1` 发送请求,Ollama 详情页可向 `ollama.com/v1` 发送请求触发用量统计;API Key 在设置页手动填写,模型列表从 models.dev 动态获取 |
|
||||||
|
|||||||
@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="com.android.launcher.permission.INSTALL_SHORTCUT" />
|
<uses-permission android:name="com.android.launcher.permission.INSTALL_SHORTCUT" />
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".RainyTokenApplication"
|
android:name=".RainyTokenApplication"
|
||||||
@ -34,15 +33,6 @@
|
|||||||
android:name=".ui.widget.WidgetRefreshReceiver"
|
android:name=".ui.widget.WidgetRefreshReceiver"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
||||||
<!-- 每日自动签到:零点闹钟广播 + 开机后排程(Hilt 经 EntryPoint 注入,无 @AndroidEntryPoint) -->
|
|
||||||
<receiver
|
|
||||||
android:name=".ui.dashboard.autocheckin.AutoCheckinReceiver"
|
|
||||||
android:exported="false">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
|
||||||
</intent-filter>
|
|
||||||
</receiver>
|
|
||||||
|
|
||||||
<!-- OpenCode Go 小组件(MIUI Widget) -->
|
<!-- OpenCode Go 小组件(MIUI Widget) -->
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".ui.widget.OpenCodeGoWidgetProvider"
|
android:name=".ui.widget.OpenCodeGoWidgetProvider"
|
||||||
@ -76,16 +66,6 @@
|
|||||||
android:name="miuiWidgetVersion"
|
android:name="miuiWidgetVersion"
|
||||||
android:value="1" />
|
android:value="1" />
|
||||||
|
|
||||||
<provider
|
|
||||||
android:name="androidx.core.content.FileProvider"
|
|
||||||
android:authorities="${applicationId}.fileprovider"
|
|
||||||
android:exported="false"
|
|
||||||
android:grantUriPermissions="true">
|
|
||||||
<meta-data
|
|
||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
|
||||||
android:resource="@xml/file_paths" />
|
|
||||||
</provider>
|
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
@ -15,7 +15,7 @@ import dagger.hilt.android.AndroidEntryPoint
|
|||||||
*
|
*
|
||||||
* 布局策略:
|
* 布局策略:
|
||||||
* - 外层只套 RainyTokenTheme(统一品牌色 + 字体)
|
* - 外层只套 RainyTokenTheme(统一品牌色 + 字体)
|
||||||
* - 再套 RainyBackground(精致粉调渐变背景,全局共享)
|
* - 再套 RainyBackground(樱粉渐变背景,全局共享)
|
||||||
* - NavHost 在背景之上,每个页面自己用 Scaffold 处理 TopAppBar 和 padding
|
* - NavHost 在背景之上,每个页面自己用 Scaffold 处理 TopAppBar 和 padding
|
||||||
* - **不**在外层再套 Scaffold —— 避免双 Scaffold 嵌套的 padding 计算混乱
|
* - **不**在外层再套 Scaffold —— 避免双 Scaffold 嵌套的 padding 计算混乱
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -21,11 +21,7 @@ import kotlinx.serialization.json.Json
|
|||||||
*
|
*
|
||||||
* 计划 7.1:断网时 UI 仍可展示 stale 数据。DataStore 文件名 `balance_cache`。
|
* 计划 7.1:断网时 UI 仍可展示 stale 数据。DataStore 文件名 `balance_cache`。
|
||||||
*
|
*
|
||||||
* 存储结构:
|
* 存储结构:JSON 序列化的 `Map<ServiceType, CachedBalance>`。
|
||||||
* - `balance_cache_v3`:JSON 序列化的 `Map<ServiceType, CachedBalance>`,供单账号服务
|
|
||||||
* (以及多账号服务的默认账号镜像,兼容旧读取路径)。
|
|
||||||
* - `balance_cache_accounts_v1`:JSON 序列化的 `Map<String, CachedBalance>`,复合键
|
|
||||||
* `"{service.storageKey}:{accountId}"`,存放多账号服务每个账号的独立缓存。
|
|
||||||
*/
|
*/
|
||||||
class BalanceCache(
|
class BalanceCache(
|
||||||
private val dataStore: DataStore<Preferences>,
|
private val dataStore: DataStore<Preferences>,
|
||||||
@ -35,9 +31,6 @@ class BalanceCache(
|
|||||||
// v3: 2026-07 统一 Codex amount 为 usedPct(之前是 remainingPct),旧缓存失效
|
// v3: 2026-07 统一 Codex amount 为 usedPct(之前是 remainingPct),旧缓存失效
|
||||||
private val cacheKey = stringPreferencesKey("balance_cache_v3")
|
private val cacheKey = stringPreferencesKey("balance_cache_v3")
|
||||||
|
|
||||||
// v1: 2026-10 多账号服务按账号独立缓存
|
|
||||||
private val accountsCacheKey = stringPreferencesKey("balance_cache_accounts_v1")
|
|
||||||
|
|
||||||
suspend fun getAll(): Map<ServiceType, CachedBalance> {
|
suspend fun getAll(): Map<ServiceType, CachedBalance> {
|
||||||
val raw = dataStore.data.map { it[cacheKey] }.first() ?: return emptyMap()
|
val raw = dataStore.data.map { it[cacheKey] }.first() ?: return emptyMap()
|
||||||
return decode(raw)
|
return decode(raw)
|
||||||
@ -45,46 +38,18 @@ class BalanceCache(
|
|||||||
|
|
||||||
suspend fun get(service: ServiceType): CachedBalance? = getAll()[service]
|
suspend fun get(service: ServiceType): CachedBalance? = getAll()[service]
|
||||||
|
|
||||||
/** 读取缓存:无 [accountId] 时读旧按服务缓存;指定账号时读账号分片缓存。 */
|
suspend fun put(service: ServiceType, balance: ServiceBalance) {
|
||||||
suspend fun get(service: ServiceType, accountId: String?): CachedBalance? =
|
|
||||||
if (accountId.isNullOrBlank()) {
|
|
||||||
getAll()[service]
|
|
||||||
} else {
|
|
||||||
getAllAccounts()[accountKey(service, accountId)]
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 所有按账号分片的缓存,复合键 `"{service.storageKey}:{accountId}"`。 */
|
|
||||||
suspend fun getAllAccounts(): Map<String, CachedBalance> {
|
|
||||||
val raw = dataStore.data.map { it[accountsCacheKey] }.first() ?: return emptyMap()
|
|
||||||
return decodeAccounts(raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 读取指定账号的缓存(不含默认账号的旧镜像回退,由调用方处理)。 */
|
|
||||||
suspend fun getAccountCached(service: ServiceType, accountId: String): CachedBalance? =
|
|
||||||
getAllAccounts()[accountKey(service, accountId)]
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 写入余额缓存。
|
|
||||||
*
|
|
||||||
* - [accountId] 为 null:写入旧的按服务缓存(单账号服务 / 多账号默认账号镜像)。
|
|
||||||
* - [accountId] 非 null:写入按账号缓存。
|
|
||||||
* 在 [RefreshWriteSession] 上下文中一律只暂存,由会话提交时按快照账号路由。
|
|
||||||
*/
|
|
||||||
suspend fun put(
|
|
||||||
service: ServiceType,
|
|
||||||
balance: ServiceBalance,
|
|
||||||
accountId: String? = null
|
|
||||||
) {
|
|
||||||
currentCoroutineContext()[RefreshWriteSession]?.let { session ->
|
currentCoroutineContext()[RefreshWriteSession]?.let { session ->
|
||||||
session.stageBalance(service, balance)
|
session.stageBalance(service, balance)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val cached = CachedBalance(balance = balance, fetchedAt = System.currentTimeMillis())
|
putCached(
|
||||||
if (accountId.isNullOrBlank()) {
|
service = service,
|
||||||
putCached(service, cached)
|
cachedBalance = CachedBalance(
|
||||||
} else {
|
balance = balance,
|
||||||
putAccountCached(service, accountId, cached)
|
fetchedAt = System.currentTimeMillis()
|
||||||
}
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 恢复一份已有缓存并保留原 fetchedAt;仅供凭据测试安全回滚使用。 */
|
/** 恢复一份已有缓存并保留原 fetchedAt;仅供凭据测试安全回滚使用。 */
|
||||||
@ -96,58 +61,8 @@ class BalanceCache(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 以账号复合键写入缓存(保留原 fetchedAt)。 */
|
/** 删除单个服务缓存。凭据新增、替换或删除时由 CredentialRepository 调用。 */
|
||||||
internal suspend fun putAccountCached(
|
|
||||||
service: ServiceType,
|
|
||||||
accountId: String,
|
|
||||||
cachedBalance: CachedBalance
|
|
||||||
) {
|
|
||||||
val key = accountKey(service, accountId)
|
|
||||||
dataStore.edit { prefs ->
|
|
||||||
val updated = decodeAccounts(prefs[accountsCacheKey]).toMutableMap()
|
|
||||||
updated[key] = cachedBalance
|
|
||||||
prefs[accountsCacheKey] = encodeAccounts(updated)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 删除单个服务的旧结构缓存。凭据新增、替换或删除时由 CredentialRepository 调用。 */
|
|
||||||
suspend fun remove(service: ServiceType) {
|
suspend fun remove(service: ServiceType) {
|
||||||
dataStore.edit { prefs ->
|
|
||||||
var changed = false
|
|
||||||
val current = decode(prefs[cacheKey])
|
|
||||||
if (service in current) {
|
|
||||||
val updated = current.toMutableMap()
|
|
||||||
updated.remove(service)
|
|
||||||
if (updated.isEmpty()) {
|
|
||||||
prefs.remove(cacheKey)
|
|
||||||
} else {
|
|
||||||
prefs[cacheKey] = encode(updated)
|
|
||||||
}
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
// 顺带清理该服务全部账号缓存(多账号服务删除整个服务时)
|
|
||||||
val accounts = decodeAccounts(prefs[accountsCacheKey])
|
|
||||||
val prefix = service.storageKey + ":"
|
|
||||||
val kept = accounts.filterKeys { !it.startsWith(prefix) }
|
|
||||||
if (kept.size != accounts.size) {
|
|
||||||
if (kept.isEmpty()) {
|
|
||||||
prefs.remove(accountsCacheKey)
|
|
||||||
} else {
|
|
||||||
prefs[accountsCacheKey] = encodeAccounts(kept)
|
|
||||||
}
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
if (!changed) return@edit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 仅删除旧结构镜像缓存([balance_cache_v3] 中该服务的条目),不动账号分片缓存。
|
|
||||||
*
|
|
||||||
* 多账号服务切换/删除默认账号时调用:旧镜像属于旧默认账号,若不清除,新默认账号
|
|
||||||
* 在尚无独立分片缓存时会回退读到旧默认账号的余额(缓存串号)。
|
|
||||||
*/
|
|
||||||
suspend fun removeLegacy(service: ServiceType) {
|
|
||||||
dataStore.edit { prefs ->
|
dataStore.edit { prefs ->
|
||||||
val current = decode(prefs[cacheKey])
|
val current = decode(prefs[cacheKey])
|
||||||
if (service !in current) return@edit
|
if (service !in current) return@edit
|
||||||
@ -161,31 +76,9 @@ class BalanceCache(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 删除单个账号的缓存(默认账号的旧结构镜像由调用方另行清理)。 */
|
|
||||||
suspend fun removeAccount(service: ServiceType, accountId: String) {
|
|
||||||
val key = accountKey(service, accountId)
|
|
||||||
dataStore.edit { prefs ->
|
|
||||||
val current = decodeAccounts(prefs[accountsCacheKey])
|
|
||||||
if (key !in current) return@edit
|
|
||||||
val updated = current.toMutableMap()
|
|
||||||
updated.remove(key)
|
|
||||||
if (updated.isEmpty()) {
|
|
||||||
prefs.remove(accountsCacheKey)
|
|
||||||
} else {
|
|
||||||
prefs[accountsCacheKey] = encodeAccounts(updated)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun clear() {
|
suspend fun clear() {
|
||||||
dataStore.edit {
|
dataStore.edit { it.remove(cacheKey) }
|
||||||
it.remove(cacheKey)
|
|
||||||
it.remove(accountsCacheKey)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private fun accountKey(service: ServiceType, accountId: String) =
|
|
||||||
"${service.storageKey}:$accountId"
|
|
||||||
|
|
||||||
private fun decode(raw: String?): Map<ServiceType, CachedBalance> {
|
private fun decode(raw: String?): Map<ServiceType, CachedBalance> {
|
||||||
if (raw == null) return emptyMap()
|
if (raw == null) return emptyMap()
|
||||||
@ -203,22 +96,6 @@ class BalanceCache(
|
|||||||
value
|
value
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun decodeAccounts(raw: String?): Map<String, CachedBalance> {
|
|
||||||
if (raw == null) return emptyMap()
|
|
||||||
return runCatching {
|
|
||||||
json.decodeFromString(
|
|
||||||
MapSerializer(String.serializer(), CachedBalance.serializer()),
|
|
||||||
raw
|
|
||||||
)
|
|
||||||
}.getOrDefault(emptyMap())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun encodeAccounts(value: Map<String, CachedBalance>): String =
|
|
||||||
json.encodeToString(
|
|
||||||
MapSerializer(String.serializer(), CachedBalance.serializer()),
|
|
||||||
value
|
|
||||||
)
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
val DefaultJson = Json {
|
val DefaultJson = Json {
|
||||||
ignoreUnknownKeys = true
|
ignoreUnknownKeys = true
|
||||||
|
|||||||
@ -1,141 +0,0 @@
|
|||||||
package com.rainy.token.data.repository
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import com.rainy.token.domain.service.ServiceType
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import java.time.LocalDate
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
import org.json.JSONObject
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 每日签到状态的统一本地记录(SharedPreferences "checkin_status")。
|
|
||||||
*
|
|
||||||
* 一条记录包含:
|
|
||||||
* - checkinDate:签到日期(本地时区 yyyy-MM-dd)
|
|
||||||
* - status:签到结果状态(SUCCESS/ALREADY/FAILED/RETRYABLE/DEVICE_BLOCKED)
|
|
||||||
* - checkinId:签到 id(服务端未提供时为空;新签到成功时生成本地 UUID 便于追溯)
|
|
||||||
* - credits:签到获得数量
|
|
||||||
* - updatedAt:本地记录更新时间戳
|
|
||||||
*
|
|
||||||
* 「今日已签到」的判定 = checkinDate == 今天 && status ∈ {SUCCESS, ALREADY}。
|
|
||||||
* 因此 FAILED/RETRYABLE/DEVICE_BLOCKED 的记录不会挡住当天重试:标识位和时间
|
|
||||||
* 对不上时,重开 App 会再次唤起签到流程。
|
|
||||||
*/
|
|
||||||
@Singleton
|
|
||||||
class CheckinStatusStore @Inject constructor(
|
|
||||||
@ApplicationContext private val context: Context
|
|
||||||
) {
|
|
||||||
|
|
||||||
private fun prefs() =
|
|
||||||
context.getSharedPreferences("checkin_status", Context.MODE_PRIVATE)
|
|
||||||
|
|
||||||
/** 该服务今日是否已「成功」签到(本地时区 yyyy-MM-dd 比对)。 */
|
|
||||||
fun isCheckedToday(service: ServiceType, accountId: String? = null): Boolean {
|
|
||||||
val record = lastRecord(service, accountId) ?: return false
|
|
||||||
return record.checkinDate == LocalDate.now().toString() &&
|
|
||||||
(record.status == STATUS_SUCCESS || record.status == STATUS_ALREADY)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 最近一次签到记录(含失败记录),无记录返回 null。 */
|
|
||||||
fun lastRecord(service: ServiceType, accountId: String? = null): CheckinRecord? {
|
|
||||||
val raw = prefs().getString(recordKey(service, accountId), null)
|
|
||||||
if (raw != null) {
|
|
||||||
return runCatching {
|
|
||||||
val obj = JSONObject(raw)
|
|
||||||
val id = if (obj.isNull(KEY_ID)) null else obj.optString(KEY_ID).takeIf { it.isNotBlank() }
|
|
||||||
CheckinRecord(
|
|
||||||
service = service.storageKey,
|
|
||||||
accountId = accountId,
|
|
||||||
checkinDate = obj.optString(KEY_DATE),
|
|
||||||
status = obj.optString(KEY_STATUS),
|
|
||||||
checkinId = id,
|
|
||||||
credits = obj.optLong(KEY_CREDITS),
|
|
||||||
updatedAt = obj.optLong(KEY_UPDATED_AT)
|
|
||||||
)
|
|
||||||
}.getOrNull()
|
|
||||||
}
|
|
||||||
// 旧版 date-only 记录迁移:没有 JSON 记录时回退读旧键
|
|
||||||
val legacyDate = legacyDate(service, accountId) ?: return null
|
|
||||||
return CheckinRecord(
|
|
||||||
service = service.storageKey,
|
|
||||||
accountId = accountId,
|
|
||||||
checkinDate = legacyDate,
|
|
||||||
status = STATUS_SUCCESS,
|
|
||||||
checkinId = null,
|
|
||||||
credits = 0,
|
|
||||||
updatedAt = 0
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 最近一次成功签到日期(yyyy-MM-dd),无记录返回 null。 */
|
|
||||||
fun lastCheckinDate(service: ServiceType, accountId: String? = null): String? =
|
|
||||||
lastRecord(service, accountId)
|
|
||||||
?.takeIf { it.status == STATUS_SUCCESS || it.status == STATUS_ALREADY }
|
|
||||||
?.checkinDate
|
|
||||||
|
|
||||||
/** 记录一条签到结果(成功或失败都会落库,便于当天重试/统计)。 */
|
|
||||||
fun record(
|
|
||||||
service: ServiceType,
|
|
||||||
accountId: String? = null,
|
|
||||||
status: String,
|
|
||||||
checkinId: String? = null,
|
|
||||||
credits: Long = 0,
|
|
||||||
updatedAt: Long = System.currentTimeMillis()
|
|
||||||
) {
|
|
||||||
val obj = JSONObject()
|
|
||||||
obj.put(KEY_DATE, LocalDate.now().toString())
|
|
||||||
obj.put(KEY_STATUS, status)
|
|
||||||
obj.put(KEY_ID, checkinId ?: JSONObject.NULL)
|
|
||||||
obj.put(KEY_CREDITS, credits)
|
|
||||||
obj.put(KEY_UPDATED_AT, updatedAt)
|
|
||||||
prefs().edit().putString(recordKey(service, accountId), obj.toString()).apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 旧版「已成功签到」快捷写入(服务端确认已签/手动已签场景)。 */
|
|
||||||
fun markChecked(service: ServiceType, accountId: String? = null) {
|
|
||||||
record(service, accountId, STATUS_ALREADY)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun recordKey(service: ServiceType, accountId: String?): String =
|
|
||||||
"checkin_record_${service.storageKey}" + if (accountId.isNullOrBlank()) "" else ":$accountId"
|
|
||||||
|
|
||||||
private fun legacyDate(service: ServiceType, accountId: String?): String? {
|
|
||||||
val p = prefs()
|
|
||||||
val scopedKey = "last_checkin_${service.storageKey}" + if (accountId.isNullOrBlank()) "" else ":$accountId"
|
|
||||||
val scoped = p.getString(scopedKey, null)
|
|
||||||
if (scoped != null) return scoped
|
|
||||||
// 仅旧版迁移来的 “default” 账号回退读旧版单账号键(其他账号没有旧记录,不能共用),
|
|
||||||
// 避免升级当日重复签到请求
|
|
||||||
return if (accountId == CredentialRepository.LEGACY_DEFAULT_ACCOUNT_ID) {
|
|
||||||
p.getString("last_checkin_${service.storageKey}", null)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 单条签到记录。 */
|
|
||||||
data class CheckinRecord(
|
|
||||||
val service: String,
|
|
||||||
val accountId: String?,
|
|
||||||
val checkinDate: String,
|
|
||||||
val status: String,
|
|
||||||
val checkinId: String?,
|
|
||||||
val credits: Long,
|
|
||||||
val updatedAt: Long
|
|
||||||
)
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val STATUS_SUCCESS = "SUCCESS"
|
|
||||||
const val STATUS_ALREADY = "ALREADY"
|
|
||||||
const val STATUS_FAILED = "FAILED"
|
|
||||||
const val STATUS_RETRYABLE = "RETRYABLE"
|
|
||||||
const val STATUS_DEVICE_BLOCKED = "DEVICE_BLOCKED"
|
|
||||||
|
|
||||||
private const val KEY_DATE = "date"
|
|
||||||
private const val KEY_STATUS = "status"
|
|
||||||
private const val KEY_ID = "id"
|
|
||||||
private const val KEY_CREDITS = "credits"
|
|
||||||
private const val KEY_UPDATED_AT = "updated_at"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -3,14 +3,11 @@ package com.rainy.token.data.repository
|
|||||||
import com.rainy.token.data.cache.BalanceCache
|
import com.rainy.token.data.cache.BalanceCache
|
||||||
import com.rainy.token.data.cache.CachedBalance
|
import com.rainy.token.data.cache.CachedBalance
|
||||||
import com.rainy.token.data.local.SecureStorage
|
import com.rainy.token.data.local.SecureStorage
|
||||||
import com.rainy.token.domain.model.AccountStore
|
|
||||||
import com.rainy.token.domain.model.Credential
|
import com.rainy.token.domain.model.Credential
|
||||||
import com.rainy.token.domain.model.CredentialStatus
|
import com.rainy.token.domain.model.CredentialStatus
|
||||||
import com.rainy.token.domain.model.ServiceAccountInfo
|
|
||||||
import com.rainy.token.domain.service.ServiceConfigProvider
|
import com.rainy.token.domain.service.ServiceConfigProvider
|
||||||
import com.rainy.token.domain.service.ServiceType
|
import com.rainy.token.domain.service.ServiceType
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import java.util.UUID
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import kotlinx.coroutines.currentCoroutineContext
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
@ -22,12 +19,6 @@ import kotlinx.coroutines.sync.withLock
|
|||||||
*
|
*
|
||||||
* 所有凭据变更都经过 [mutationMutex] 串行化,并维护进程内 revision。网络刷新先取得
|
* 所有凭据变更都经过 [mutationMutex] 串行化,并维护进程内 revision。网络刷新先取得
|
||||||
* [CredentialSnapshot];请求结束后只有快照仍为当前版本时,暂存的凭据与余额才会提交。
|
* [CredentialSnapshot];请求结束后只有快照仍为当前版本时,暂存的凭据与余额才会提交。
|
||||||
*
|
|
||||||
* 多账号服务(Trae / WorkBuddy):
|
|
||||||
* - 账号元数据([ServiceAccountInfo])存 `accounts_{storageKey}`(加密 DataStore);
|
|
||||||
* - 每个账号的凭据独立存 `credential_{storageKey}_{accountId}`;
|
|
||||||
* - 旧版本单凭据存 `credential_{storageKey}`,首次读取时自动迁移为默认账号
|
|
||||||
* (固定 id = [LEGACY_DEFAULT_ACCOUNT_ID]),旧键保留作为兼容回退。
|
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class CredentialRepository @Inject constructor(
|
class CredentialRepository @Inject constructor(
|
||||||
@ -38,11 +29,7 @@ class CredentialRepository @Inject constructor(
|
|||||||
internal data class CredentialSnapshot(
|
internal data class CredentialSnapshot(
|
||||||
val credential: Credential,
|
val credential: Credential,
|
||||||
val revision: Long,
|
val revision: Long,
|
||||||
val fingerprint: String,
|
val fingerprint: String
|
||||||
/** 快照所属账号;单账号服务为 null。 */
|
|
||||||
val accountId: String? = null,
|
|
||||||
/** 创建快照时调用方请求的账号 ID(多账号服务可能是 null=默认账号),用于会话内精确匹配。 */
|
|
||||||
val requestedAccountId: String? = accountId
|
|
||||||
) {
|
) {
|
||||||
val service: ServiceType get() = credential.service
|
val service: ServiceType get() = credential.service
|
||||||
}
|
}
|
||||||
@ -55,16 +42,6 @@ class CredentialRepository @Inject constructor(
|
|||||||
val credential: Credential? = null
|
val credential: Credential? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
/** 按账号展开的本地状态(多账号服务一个账号一条;单账号服务一条 [account]=null)。 */
|
|
||||||
data class AccountLocalState(
|
|
||||||
val service: ServiceType,
|
|
||||||
val account: ServiceAccountInfo?,
|
|
||||||
val status: CredentialStatus,
|
|
||||||
val fingerprint: String?,
|
|
||||||
val cachedBalance: CachedBalance?,
|
|
||||||
val credential: Credential?
|
|
||||||
)
|
|
||||||
|
|
||||||
private data class CacheRollbackEntry(
|
private data class CacheRollbackEntry(
|
||||||
val testedFingerprint: String,
|
val testedFingerprint: String,
|
||||||
val previousIdentity: String?,
|
val previousIdentity: String?,
|
||||||
@ -72,232 +49,69 @@ class CredentialRepository @Inject constructor(
|
|||||||
)
|
)
|
||||||
|
|
||||||
private val mutationMutex = Mutex()
|
private val mutationMutex = Mutex()
|
||||||
private val revisions = mutableMapOf<String, Long>()
|
private val revisions = mutableMapOf<ServiceType, Long>()
|
||||||
private val cacheRollbackEntries = mutableMapOf<String, CacheRollbackEntry>()
|
private val cacheRollbackEntries = mutableMapOf<ServiceType, CacheRollbackEntry>()
|
||||||
|
|
||||||
private fun accountDocKeyFor(service: ServiceType): String = "accounts_${service.storageKey}"
|
private fun keyFor(service: ServiceType): String = "credential_${service.storageKey}"
|
||||||
|
|
||||||
/** 凭据存储键:单账号(或兼容回退)为 `credential_{storageKey}`。 */
|
suspend fun save(credential: Credential) {
|
||||||
private fun keyFor(service: ServiceType, accountId: String?): String =
|
|
||||||
if (accountId.isNullOrBlank()) {
|
|
||||||
"credential_${service.storageKey}"
|
|
||||||
} else {
|
|
||||||
"credential_${service.storageKey}_$accountId"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun accountCacheKey(service: ServiceType, accountId: String): String =
|
|
||||||
"${service.storageKey}:$accountId"
|
|
||||||
|
|
||||||
private fun revisionKey(service: ServiceType, accountId: String?): String =
|
|
||||||
"${service.storageKey}:${accountId ?: ""}"
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
// 公开 API(多账号 + 单账号统一入口)
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** 列出某服务的账号(多账号服务;单账号服务恒为空列表)。 */
|
|
||||||
suspend fun accountsFor(service: ServiceType): List<ServiceAccountInfo> =
|
|
||||||
mutationMutex.withLock { readAccountsUnlocked(service) }
|
|
||||||
|
|
||||||
/** 当前默认账号;无账号返回 null。 */
|
|
||||||
suspend fun getDefaultAccount(service: ServiceType): ServiceAccountInfo? =
|
|
||||||
mutationMutex.withLock { resolveAccountUnlocked(service, null) }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 新建账号(不写入凭据)。返回新账号;非多账号服务返回 null。
|
|
||||||
* 首个账号自动设为默认账号,label 为空时 UI 按“账号 N”兜底显示。
|
|
||||||
*/
|
|
||||||
suspend fun createAccount(service: ServiceType, label: String? = null): ServiceAccountInfo? =
|
|
||||||
mutationMutex.withLock {
|
|
||||||
if (!ServiceType.isMultiAccount(service)) return@withLock null
|
|
||||||
createAccountUnlocked(service, label)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 设置默认账号(账号不存在时忽略)。切换默认账号时清除旧结构镜像缓存,避免新默认账号读到旧默认账号的余额。 */
|
|
||||||
suspend fun setDefaultAccount(service: ServiceType, accountId: String) {
|
|
||||||
mutationMutex.withLock {
|
|
||||||
val accounts = readAccountsUnlocked(service)
|
|
||||||
if (accounts.none { it.id == accountId }) return@withLock
|
|
||||||
val previousDefault = accounts.firstOrNull { it.isDefault } ?: accounts.firstOrNull()
|
|
||||||
putAccountsUnlocked(
|
|
||||||
service,
|
|
||||||
accounts.map { it.copy(isDefault = it.id == accountId) }
|
|
||||||
)
|
|
||||||
if (previousDefault?.id != accountId) {
|
|
||||||
balanceCache.removeLegacy(service)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 重命名账号(空白 label 视为清除自定义名称)。 */
|
|
||||||
suspend fun renameAccount(service: ServiceType, accountId: String, label: String) {
|
|
||||||
mutationMutex.withLock {
|
|
||||||
val accounts = readAccountsUnlocked(service)
|
|
||||||
putAccountsUnlocked(
|
|
||||||
service,
|
|
||||||
accounts.map {
|
|
||||||
if (it.id == accountId) it.copy(label = label.takeIf { l -> l.isNotBlank() }) else it
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 删除单个账号:删除该账号凭据 + 独立缓存;若删除的是默认账号则提升第一个
|
|
||||||
* 剩余账号为默认;最后一个账号删除后整个服务回到未配置状态。
|
|
||||||
*/
|
|
||||||
suspend fun removeAccount(service: ServiceType, accountId: String) {
|
|
||||||
mutationMutex.withLock {
|
|
||||||
if (!ServiceType.isMultiAccount(service)) return@withLock
|
|
||||||
val accounts = readAccountsUnlocked(service)
|
|
||||||
val removed = accounts.firstOrNull { it.id == accountId } ?: return@withLock
|
|
||||||
val remaining = accounts.filterNot { it.id == accountId }
|
|
||||||
|
|
||||||
bumpRevision(service, accountId)
|
|
||||||
cacheRollbackEntries.remove(accountCacheKey(service, accountId))
|
|
||||||
balanceCache.removeAccount(service, accountId)
|
|
||||||
secureStorage.remove(keyFor(service, accountId))
|
|
||||||
|
|
||||||
if (remaining.isEmpty()) {
|
|
||||||
secureStorage.remove(accountDocKeyFor(service))
|
|
||||||
secureStorage.remove(keyFor(service, null))
|
|
||||||
balanceCache.remove(service)
|
|
||||||
clearRollbacksFor(service)
|
|
||||||
} else {
|
|
||||||
var fixed = remaining
|
|
||||||
if (fixed.none { it.isDefault }) {
|
|
||||||
val first = fixed.first()
|
|
||||||
fixed = fixed.map { if (it.id == first.id) it.copy(isDefault = true) else it }
|
|
||||||
}
|
|
||||||
putAccountsUnlocked(service, fixed)
|
|
||||||
// 删除的是默认账号时,旧结构镜像缓存属于被删账号,必须只清镜像(保留其余账号的独立分片缓存,
|
|
||||||
// 避免全部账号缓存被误清导致都要重新联网拉取)
|
|
||||||
if (removed.isDefault) {
|
|
||||||
balanceCache.removeLegacy(service)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 保存凭据。
|
|
||||||
*
|
|
||||||
* - 多账号服务:[accountId] 为 null 时**总是新建账号**(导航层保证 null 只来自
|
|
||||||
* 「添加账号」流程),首个新建账号自动成为默认账号;显式 [accountId] 覆盖对应账号。
|
|
||||||
* 返回实际写入的账号 ID(用于新建账号后继续编辑)。
|
|
||||||
* - 单账号服务:忽略 [accountId],行为与旧版一致,返回空串。
|
|
||||||
* - 在 [RefreshWriteSession] 上下文中只暂存,返回快照账号 ID。
|
|
||||||
*/
|
|
||||||
suspend fun save(credential: Credential, accountId: String? = null): String {
|
|
||||||
currentCoroutineContext()[RefreshWriteSession]?.let { session ->
|
currentCoroutineContext()[RefreshWriteSession]?.let { session ->
|
||||||
session.stageCredential(credential)
|
session.stageCredential(credential)
|
||||||
return session.snapshot.accountId ?: ""
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
mutationMutex.withLock {
|
mutationMutex.withLock {
|
||||||
val service = credential.service
|
val service = credential.service
|
||||||
val effectiveId = if (ServiceType.isMultiAccount(service)) {
|
val current = getUnlocked(service)
|
||||||
if (accountId.isNullOrBlank()) {
|
|
||||||
// 多账号服务的 null accountId = “新建账号”模式(导航层保证),总是新建,
|
|
||||||
// 避免覆盖默认账号;首个账号自动成为默认账号。
|
|
||||||
createAccountUnlocked(service, null).id
|
|
||||||
} else {
|
|
||||||
resolveAccountUnlocked(service, accountId)?.id
|
|
||||||
?: createAccountUnlocked(service, null).id
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
val key = revisionKey(service, effectiveId)
|
|
||||||
val current = getUnlocked(service, effectiveId)
|
|
||||||
val currentIdentity = cacheIdentityFingerprint(current)
|
val currentIdentity = cacheIdentityFingerprint(current)
|
||||||
val newIdentity = cacheIdentityFingerprint(credential)
|
val newIdentity = cacheIdentityFingerprint(credential)
|
||||||
val cacheIdentityChanged = currentIdentity != newIdentity
|
val cacheIdentityChanged = currentIdentity != newIdentity
|
||||||
|
|
||||||
bumpRevision(service, effectiveId)
|
bumpRevision(service)
|
||||||
if (cacheIdentityChanged) {
|
if (cacheIdentityChanged) {
|
||||||
cacheRollbackEntries[key] = CacheRollbackEntry(
|
cacheRollbackEntries[service] = CacheRollbackEntry(
|
||||||
testedFingerprint = credentialFingerprint(credential)!!,
|
testedFingerprint = credentialFingerprint(credential)!!,
|
||||||
previousIdentity = currentIdentity,
|
previousIdentity = currentIdentity,
|
||||||
cachedBalance = if (current != null) {
|
cachedBalance = if (current != null) balanceCache.get(service) else null
|
||||||
accountCacheFor(service, effectiveId)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
removeCacheFor(service, effectiveId)
|
balanceCache.remove(service)
|
||||||
} else {
|
} else {
|
||||||
cacheRollbackEntries.remove(key)
|
cacheRollbackEntries.remove(service)
|
||||||
}
|
}
|
||||||
putUnlocked(credential, effectiveId)
|
putUnlocked(credential)
|
||||||
return effectiveId ?: ""
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun get(service: ServiceType, accountId: String? = null): Credential? {
|
suspend fun get(service: ServiceType): Credential? {
|
||||||
currentCoroutineContext()[RefreshWriteSession]?.let { session ->
|
currentCoroutineContext()[RefreshWriteSession]?.let { session ->
|
||||||
if (session.snapshot.service == service) {
|
if (session.snapshot.service == service) {
|
||||||
val matches = if (ServiceType.isMultiAccount(service)) {
|
return session.credentialForRead()
|
||||||
val requested = accountId?.takeIf { it.isNotBlank() }
|
|
||||||
// 只允许读取与快照同一账号的暂存凭据;null=默认账号也要看快照当初请求的是不是默认账号,
|
|
||||||
// 避免 A 账号刷新期间把 A 的暂存凭据误交给默认账号(B)的读请求。
|
|
||||||
requested == session.snapshot.requestedAccountId
|
|
||||||
} else {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
if (matches) return session.credentialForRead()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return mutationMutex.withLock { getUnlocked(service, accountId) }
|
return mutationMutex.withLock { getUnlocked(service) }
|
||||||
}
|
}
|
||||||
|
|
||||||
internal suspend fun readLocalState(
|
internal suspend fun readLocalState(service: ServiceType): LocalState =
|
||||||
service: ServiceType,
|
|
||||||
accountId: String? = null
|
|
||||||
): LocalState =
|
|
||||||
mutationMutex.withLock {
|
mutationMutex.withLock {
|
||||||
val credential = getUnlocked(service, accountId)
|
val credential = getUnlocked(service)
|
||||||
localStateOf(service, credential, accountCacheFor(service, accountId))
|
localStateOf(service, credential, balanceCache.get(service))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 全部服务的按账号本地状态(保留单账号服务旧语义,多账号展开一条/账号)。 */
|
|
||||||
suspend fun readAccountStates(): List<AccountLocalState> =
|
|
||||||
mutationMutex.withLock { readAccountStatesUnlocked() }
|
|
||||||
|
|
||||||
internal suspend fun readLocalStates(): Map<ServiceType, LocalState> =
|
internal suspend fun readLocalStates(): Map<ServiceType, LocalState> =
|
||||||
mutationMutex.withLock {
|
mutationMutex.withLock {
|
||||||
val accountStates = readAccountStatesUnlocked()
|
val cached = balanceCache.getAll()
|
||||||
val legacyCached = balanceCache.getAll()
|
|
||||||
ServiceType.entries.associateWith { service ->
|
ServiceType.entries.associateWith { service ->
|
||||||
// 默认账号优先(多账号服务中自动签到/小组件使用的账号)
|
val credential = getUnlocked(service)
|
||||||
val entry = accountStates
|
localStateOf(service, credential, cached[service])
|
||||||
.filter { it.service == service }
|
|
||||||
.minByOrNull { if (it.account?.isDefault == true) 0 else 1 }
|
|
||||||
if (entry != null) {
|
|
||||||
localStateOf(service, entry.credential, entry.cachedBalance)
|
|
||||||
} else {
|
|
||||||
val credential = readLegacyCredentialUnlocked(service)
|
|
||||||
localStateOf(service, credential, legacyCached[service])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun remove(service: ServiceType) {
|
suspend fun remove(service: ServiceType) {
|
||||||
mutationMutex.withLock {
|
mutationMutex.withLock {
|
||||||
val accounts = if (ServiceType.isMultiAccount(service)) {
|
bumpRevision(service)
|
||||||
readAccountsUnlocked(service)
|
cacheRollbackEntries.remove(service)
|
||||||
} else {
|
|
||||||
emptyList()
|
|
||||||
}
|
|
||||||
clearRollbacksFor(service)
|
|
||||||
bumpRevision(service, null)
|
|
||||||
accounts.forEach { account ->
|
|
||||||
bumpRevision(service, account.id)
|
|
||||||
secureStorage.remove(keyFor(service, account.id))
|
|
||||||
}
|
|
||||||
secureStorage.remove(keyFor(service, null))
|
|
||||||
secureStorage.remove(accountDocKeyFor(service))
|
|
||||||
balanceCache.remove(service)
|
balanceCache.remove(service)
|
||||||
|
secureStorage.remove(keyFor(service))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -310,13 +124,12 @@ class CredentialRepository @Inject constructor(
|
|||||||
previous: Credential?
|
previous: Credential?
|
||||||
): Boolean = mutationMutex.withLock {
|
): Boolean = mutationMutex.withLock {
|
||||||
val service = testedSnapshot.service
|
val service = testedSnapshot.service
|
||||||
val accountId = testedSnapshot.accountId
|
val current = getUnlocked(service) ?: return@withLock false
|
||||||
val current = getUnlocked(service, accountId) ?: return@withLock false
|
|
||||||
if (
|
if (
|
||||||
!snapshotMatches(
|
!snapshotMatches(
|
||||||
snapshotRevision = testedSnapshot.revision,
|
snapshotRevision = testedSnapshot.revision,
|
||||||
snapshotFingerprint = testedSnapshot.fingerprint,
|
snapshotFingerprint = testedSnapshot.fingerprint,
|
||||||
currentRevision = revisionFor(service, accountId),
|
currentRevision = revisionFor(service),
|
||||||
currentFingerprint = credentialFingerprint(current)
|
currentFingerprint = credentialFingerprint(current)
|
||||||
)
|
)
|
||||||
) return@withLock false
|
) return@withLock false
|
||||||
@ -324,54 +137,35 @@ class CredentialRepository @Inject constructor(
|
|||||||
val currentIdentity = cacheIdentityFingerprint(current)
|
val currentIdentity = cacheIdentityFingerprint(current)
|
||||||
val previousIdentity = cacheIdentityFingerprint(previous)
|
val previousIdentity = cacheIdentityFingerprint(previous)
|
||||||
val identityChanged = currentIdentity != previousIdentity
|
val identityChanged = currentIdentity != previousIdentity
|
||||||
val rollbackEntry = cacheRollbackEntries[revisionKey(service, accountId)]?.takeIf {
|
val rollbackEntry = cacheRollbackEntries[service]?.takeIf {
|
||||||
it.testedFingerprint == testedSnapshot.fingerprint &&
|
it.testedFingerprint == testedSnapshot.fingerprint &&
|
||||||
it.previousIdentity == previousIdentity
|
it.previousIdentity == previousIdentity
|
||||||
}
|
}
|
||||||
|
|
||||||
bumpRevision(service, accountId)
|
bumpRevision(service)
|
||||||
if (identityChanged) {
|
if (identityChanged) {
|
||||||
removeCacheFor(service, accountId)
|
balanceCache.remove(service)
|
||||||
}
|
}
|
||||||
if (previous == null) {
|
if (previous == null) {
|
||||||
secureStorage.remove(keyFor(service, accountId))
|
secureStorage.remove(keyFor(service))
|
||||||
} else {
|
} else {
|
||||||
require(previous.service == service) { "回滚凭据服务不匹配" }
|
require(previous.service == service) { "回滚凭据服务不匹配" }
|
||||||
putUnlocked(previous, accountId)
|
putUnlocked(previous)
|
||||||
}
|
}
|
||||||
if (identityChanged) {
|
if (identityChanged) {
|
||||||
rollbackEntry?.cachedBalance?.let {
|
rollbackEntry?.cachedBalance?.let { balanceCache.putCached(service, it) }
|
||||||
if (accountId.isNullOrBlank()) {
|
|
||||||
balanceCache.putCached(service, it)
|
|
||||||
} else {
|
|
||||||
balanceCache.putAccountCached(service, accountId, it)
|
|
||||||
if (resolveAccountUnlocked(service, null)?.id == accountId) {
|
|
||||||
balanceCache.putCached(service, it)
|
|
||||||
}
|
}
|
||||||
}
|
cacheRollbackEntries.remove(service)
|
||||||
}
|
|
||||||
}
|
|
||||||
cacheRollbackEntries.remove(revisionKey(service, accountId))
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
internal suspend fun snapshot(
|
internal suspend fun snapshot(service: ServiceType): CredentialSnapshot? =
|
||||||
service: ServiceType,
|
|
||||||
accountId: String? = null
|
|
||||||
): CredentialSnapshot? =
|
|
||||||
mutationMutex.withLock {
|
mutationMutex.withLock {
|
||||||
val resolvedId = if (ServiceType.isMultiAccount(service)) {
|
val credential = getUnlocked(service) ?: return@withLock null
|
||||||
resolveAccountUnlocked(service, accountId)?.id
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
val credential = getUnlocked(service, resolvedId) ?: return@withLock null
|
|
||||||
CredentialSnapshot(
|
CredentialSnapshot(
|
||||||
credential = credential,
|
credential = credential,
|
||||||
revision = revisionFor(service, resolvedId),
|
revision = revisionFor(service),
|
||||||
fingerprint = credentialFingerprint(credential)!!,
|
fingerprint = credentialFingerprint(credential)!!
|
||||||
accountId = resolvedId,
|
|
||||||
requestedAccountId = accountId?.takeIf { it.isNotBlank() }
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -391,11 +185,11 @@ class CredentialRepository @Inject constructor(
|
|||||||
val hasPendingWrites = pendingCredential != null || pendingBalance != null
|
val hasPendingWrites = pendingCredential != null || pendingBalance != null
|
||||||
|
|
||||||
val snapshot = session.snapshot
|
val snapshot = session.snapshot
|
||||||
val service = snapshot.service
|
val current = getUnlocked(snapshot.service) ?: return@withLock false
|
||||||
val accountId = snapshot.accountId
|
|
||||||
val current = getUnlocked(service, accountId) ?: return@withLock false
|
|
||||||
val currentFingerprint = credentialFingerprint(current)
|
val currentFingerprint = credentialFingerprint(current)
|
||||||
|
|
||||||
|
// 一键激活等只读会话不应被同凭据刷新造成的 revision 变化误伤;但凭据真正
|
||||||
|
// 替换/删除后,旧账户的成功响应和失败信息都必须丢弃。
|
||||||
if (!hasPendingWrites) {
|
if (!hasPendingWrites) {
|
||||||
return@withLock currentFingerprint == snapshot.fingerprint
|
return@withLock currentFingerprint == snapshot.fingerprint
|
||||||
}
|
}
|
||||||
@ -403,7 +197,7 @@ class CredentialRepository @Inject constructor(
|
|||||||
val snapshotStillCurrent = snapshotMatches(
|
val snapshotStillCurrent = snapshotMatches(
|
||||||
snapshotRevision = snapshot.revision,
|
snapshotRevision = snapshot.revision,
|
||||||
snapshotFingerprint = snapshot.fingerprint,
|
snapshotFingerprint = snapshot.fingerprint,
|
||||||
currentRevision = revisionFor(service, accountId),
|
currentRevision = revisionFor(snapshot.service),
|
||||||
currentFingerprint = currentFingerprint
|
currentFingerprint = currentFingerprint
|
||||||
)
|
)
|
||||||
val canMergeRotatedCredential = pendingCredential != null &&
|
val canMergeRotatedCredential = pendingCredential != null &&
|
||||||
@ -419,33 +213,22 @@ class CredentialRepository @Inject constructor(
|
|||||||
cacheIdentityFingerprint(current) != cacheIdentityFingerprint(finalCredential) &&
|
cacheIdentityFingerprint(current) != cacheIdentityFingerprint(finalCredential) &&
|
||||||
!sameLineage
|
!sameLineage
|
||||||
|
|
||||||
bumpRevision(service, accountId)
|
bumpRevision(snapshot.service)
|
||||||
if (cacheIdentityChanged) {
|
if (cacheIdentityChanged) {
|
||||||
removeCacheFor(service, accountId)
|
balanceCache.remove(snapshot.service)
|
||||||
}
|
}
|
||||||
if (pendingCredential != null) {
|
if (pendingCredential != null) {
|
||||||
putUnlocked(pendingCredential, accountId)
|
putUnlocked(pendingCredential)
|
||||||
}
|
}
|
||||||
if (pendingBalance != null) {
|
if (pendingBalance != null) {
|
||||||
val cached = CachedBalance(balance = pendingBalance, fetchedAt = System.currentTimeMillis())
|
balanceCache.put(snapshot.service, pendingBalance)
|
||||||
if (accountId.isNullOrBlank()) {
|
cacheRollbackEntries.remove(snapshot.service)
|
||||||
balanceCache.putCached(service, cached)
|
|
||||||
} else {
|
|
||||||
balanceCache.putAccountCached(service, accountId, cached)
|
|
||||||
if (resolveAccountUnlocked(service, null)?.id == accountId) {
|
|
||||||
balanceCache.putCached(service, cached)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cacheRollbackEntries.remove(revisionKey(service, accountId))
|
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun statusFor(
|
suspend fun statusFor(service: ServiceType): CredentialStatus {
|
||||||
service: ServiceType,
|
val credential = get(service) ?: return CredentialStatus(
|
||||||
accountId: String? = null
|
|
||||||
): CredentialStatus {
|
|
||||||
val credential = get(service, accountId) ?: return CredentialStatus(
|
|
||||||
service = service,
|
service = service,
|
||||||
state = CredentialStatus.State.NOT_CONFIGURED,
|
state = CredentialStatus.State.NOT_CONFIGURED,
|
||||||
lastVerifiedAt = 0L
|
lastVerifiedAt = 0L
|
||||||
@ -463,74 +246,6 @@ class CredentialRepository @Inject constructor(
|
|||||||
suspend fun statusForAll(): List<CredentialStatus> =
|
suspend fun statusForAll(): List<CredentialStatus> =
|
||||||
ServiceConfigProvider.all().map { statusFor(it.type) }
|
ServiceConfigProvider.all().map { statusFor(it.type) }
|
||||||
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
// 实现细节
|
|
||||||
// ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
private suspend fun readAccountStatesUnlocked(): List<AccountLocalState> {
|
|
||||||
val cached = balanceCache.getAllAccounts()
|
|
||||||
val legacyCached = balanceCache.getAll()
|
|
||||||
return ServiceType.entries.flatMap { service ->
|
|
||||||
if (ServiceType.isMultiAccount(service)) {
|
|
||||||
val accounts = readAccountsUnlocked(service)
|
|
||||||
if (accounts.isEmpty()) {
|
|
||||||
val legacy = readLegacyCredentialUnlocked(service)
|
|
||||||
if (legacy == null) {
|
|
||||||
emptyList()
|
|
||||||
} else {
|
|
||||||
listOf(accountLocalStateOf(service, null, legacy, legacyCached[service]))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
accounts.map { account ->
|
|
||||||
val credential = getUnlocked(service, account.id)
|
|
||||||
val cache = cached[accountCacheKey(service, account.id)]
|
|
||||||
?: if (account.isDefault) legacyCached[service] else null
|
|
||||||
accountLocalStateOf(service, account, credential, cache)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
val credential = readLegacyCredentialUnlocked(service)
|
|
||||||
if (credential == null) {
|
|
||||||
emptyList()
|
|
||||||
} else {
|
|
||||||
listOf(accountLocalStateOf(service, null, credential, legacyCached[service]))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun accountLocalStateOf(
|
|
||||||
service: ServiceType,
|
|
||||||
account: ServiceAccountInfo?,
|
|
||||||
credential: Credential?,
|
|
||||||
cached: CachedBalance?
|
|
||||||
): AccountLocalState {
|
|
||||||
val status = if (credential == null) {
|
|
||||||
CredentialStatus(
|
|
||||||
service = service,
|
|
||||||
state = CredentialStatus.State.NOT_CONFIGURED,
|
|
||||||
lastVerifiedAt = 0L
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
CredentialStatus(
|
|
||||||
service = service,
|
|
||||||
state = determineCredentialState(
|
|
||||||
credential.lastVerifiedAt,
|
|
||||||
System.currentTimeMillis()
|
|
||||||
),
|
|
||||||
lastVerifiedAt = credential.lastVerifiedAt
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return AccountLocalState(
|
|
||||||
service = service,
|
|
||||||
account = account,
|
|
||||||
status = status,
|
|
||||||
fingerprint = credentialFingerprint(credential),
|
|
||||||
cachedBalance = cached,
|
|
||||||
credential = credential
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun localStateOf(
|
private fun localStateOf(
|
||||||
service: ServiceType,
|
service: ServiceType,
|
||||||
credential: Credential?,
|
credential: Credential?,
|
||||||
@ -560,162 +275,27 @@ class CredentialRepository @Inject constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 读取账号元数据;多账号服务且文档不存在时尝试从旧单凭据迁移。 */
|
private suspend fun putUnlocked(credential: Credential) {
|
||||||
private suspend fun readAccountsUnlocked(service: ServiceType): List<ServiceAccountInfo> {
|
|
||||||
if (!ServiceType.isMultiAccount(service)) return emptyList()
|
|
||||||
val stored = secureStorage.get(accountDocKeyFor(service), AccountStore.serializer())
|
|
||||||
?: return migrateLegacyUnlocked(service)
|
|
||||||
return stored.accounts
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 旧版本单凭据迁移为默认账号(幂等,失败重试安全)。 */
|
|
||||||
private suspend fun migrateLegacyUnlocked(service: ServiceType): List<ServiceAccountInfo> {
|
|
||||||
val legacy = readLegacyCredentialUnlocked(service) ?: return emptyList()
|
|
||||||
val account = ServiceAccountInfo(
|
|
||||||
id = LEGACY_DEFAULT_ACCOUNT_ID,
|
|
||||||
label = null,
|
|
||||||
isDefault = true,
|
|
||||||
createdAt = System.currentTimeMillis()
|
|
||||||
)
|
|
||||||
// 先复制凭据、再写账号文档;半途失败时下次读到旧键会重新迁移
|
|
||||||
secureStorage.put(keyFor(service, account.id), legacy, Credential.serializer())
|
|
||||||
putAccountsUnlocked(service, listOf(account))
|
|
||||||
return listOf(account)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun putAccountsUnlocked(
|
|
||||||
service: ServiceType,
|
|
||||||
accounts: List<ServiceAccountInfo>
|
|
||||||
) {
|
|
||||||
secureStorage.put(
|
secureStorage.put(
|
||||||
key = accountDocKeyFor(service),
|
key = keyFor(credential.service),
|
||||||
value = AccountStore(service = service, accounts = accounts),
|
|
||||||
serializer = AccountStore.serializer()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun createAccountUnlocked(
|
|
||||||
service: ServiceType,
|
|
||||||
label: String?
|
|
||||||
): ServiceAccountInfo {
|
|
||||||
val accounts = readAccountsUnlocked(service).toMutableList()
|
|
||||||
val account = ServiceAccountInfo(
|
|
||||||
id = "acct_" + UUID.randomUUID().toString().replace("-", "").take(12),
|
|
||||||
label = label?.takeIf { it.isNotBlank() },
|
|
||||||
isDefault = accounts.isEmpty(),
|
|
||||||
createdAt = System.currentTimeMillis()
|
|
||||||
)
|
|
||||||
accounts += account
|
|
||||||
putAccountsUnlocked(service, accounts)
|
|
||||||
return account
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 解析目标账号:显式 ID → 默认账号(无显式时)。 */
|
|
||||||
private suspend fun resolveAccountUnlocked(
|
|
||||||
service: ServiceType,
|
|
||||||
accountId: String?
|
|
||||||
): ServiceAccountInfo? {
|
|
||||||
if (!ServiceType.isMultiAccount(service)) return null
|
|
||||||
val accounts = readAccountsUnlocked(service)
|
|
||||||
if (accounts.isEmpty()) return null
|
|
||||||
val id = accountId?.takeIf { it.isNotBlank() }
|
|
||||||
return if (id != null) {
|
|
||||||
accounts.firstOrNull { it.id == id }
|
|
||||||
} else {
|
|
||||||
accounts.firstOrNull { it.isDefault } ?: accounts.first()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun accountsDocExistsUnlocked(service: ServiceType): Boolean =
|
|
||||||
secureStorage.get(accountDocKeyFor(service), AccountStore.serializer()) != null
|
|
||||||
|
|
||||||
/** 读取旧结构单凭据(多账号服务的旧版本数据 / 单账号服务的标准凭据)。 */
|
|
||||||
private suspend fun readLegacyCredentialUnlocked(service: ServiceType): Credential? =
|
|
||||||
secureStorage.get(keyFor(service, null), Credential.serializer())
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 按账号读取凭据:
|
|
||||||
* - 多账号服务:解析账号后读独立键;无账号文档时回退旧键;
|
|
||||||
* - 单账号服务:恒读旧键。
|
|
||||||
*/
|
|
||||||
private suspend fun getUnlocked(
|
|
||||||
service: ServiceType,
|
|
||||||
accountId: String? = null
|
|
||||||
): Credential? {
|
|
||||||
if (ServiceType.isMultiAccount(service)) {
|
|
||||||
val resolved = resolveAccountUnlocked(service, accountId)
|
|
||||||
if (resolved != null) {
|
|
||||||
return secureStorage.get(keyFor(service, resolved.id), Credential.serializer())
|
|
||||||
}
|
|
||||||
return if (accountId.isNullOrBlank() && !accountsDocExistsUnlocked(service)) {
|
|
||||||
readLegacyCredentialUnlocked(service)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return readLegacyCredentialUnlocked(service)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun putUnlocked(credential: Credential, accountId: String?) {
|
|
||||||
secureStorage.put(
|
|
||||||
key = keyFor(credential.service, accountId),
|
|
||||||
value = credential,
|
value = credential,
|
||||||
serializer = Credential.serializer()
|
serializer = Credential.serializer()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 按账号读缓存:优先账号分片,默认账号回退旧结构镜像。 */
|
private suspend fun getUnlocked(service: ServiceType): Credential? =
|
||||||
private suspend fun accountCacheFor(
|
secureStorage.get(
|
||||||
service: ServiceType,
|
key = keyFor(service),
|
||||||
accountId: String?
|
serializer = Credential.serializer()
|
||||||
): CachedBalance? {
|
)
|
||||||
if (!ServiceType.isMultiAccount(service)) return balanceCache.get(service)
|
|
||||||
val id = accountId?.takeIf { it.isNotBlank() }
|
|
||||||
return if (id != null) {
|
|
||||||
balanceCache.getAccountCached(service, id)
|
|
||||||
?: if (resolveAccountUnlocked(service, null)?.id == id) balanceCache.get(service) else null
|
|
||||||
} else {
|
|
||||||
resolveAccountUnlocked(service, null)?.let { account ->
|
|
||||||
balanceCache.getAccountCached(service, account.id) ?: balanceCache.get(service)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 删除某账号(或单账号服务)关联的余额缓存;默认账号会一并清理旧结构镜像。 */
|
private fun revisionFor(service: ServiceType): Long = revisions[service] ?: 0L
|
||||||
private suspend fun removeCacheFor(service: ServiceType, accountId: String?) {
|
|
||||||
if (ServiceType.isMultiAccount(service)) {
|
|
||||||
if (accountId.isNullOrBlank()) {
|
|
||||||
balanceCache.removeLegacy(service)
|
|
||||||
} else {
|
|
||||||
balanceCache.removeAccount(service, accountId)
|
|
||||||
if (resolveAccountUnlocked(service, null)?.id == accountId) {
|
|
||||||
// 默认账号被替换:旧结构镜像属于旧默认账号,只清镜像,保留其余账号分片缓存
|
|
||||||
balanceCache.removeLegacy(service)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
balanceCache.remove(service)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun clearRollbacksFor(service: ServiceType) {
|
private fun bumpRevision(service: ServiceType) {
|
||||||
val prefix = service.storageKey + ":"
|
revisions[service] = revisionFor(service) + 1L
|
||||||
cacheRollbackEntries.keys
|
|
||||||
.filter { it == service.storageKey || it.startsWith(prefix) }
|
|
||||||
.forEach { cacheRollbackEntries.remove(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun revisionFor(service: ServiceType, accountId: String?): Long =
|
|
||||||
revisions[revisionKey(service, accountId)] ?: 0L
|
|
||||||
|
|
||||||
private fun bumpRevision(service: ServiceType, accountId: String?) {
|
|
||||||
revisions[revisionKey(service, accountId)] = revisionFor(service, accountId) + 1L
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** 旧单凭据迁移为默认账号时使用的固定账号 ID。 */
|
|
||||||
const val LEGACY_DEFAULT_ACCOUNT_ID = "default"
|
|
||||||
|
|
||||||
internal fun determineCredentialState(
|
internal fun determineCredentialState(
|
||||||
lastVerifiedAt: Long,
|
lastVerifiedAt: Long,
|
||||||
now: Long
|
now: Long
|
||||||
@ -859,10 +439,6 @@ class CredentialRepository @Inject constructor(
|
|||||||
return sha256(material)
|
return sha256(material)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 账号存储键(与 BalanceCache 的复合键格式一致,供测试断言)。 */
|
|
||||||
internal fun accountCacheKeyFor(service: ServiceType, accountId: String): String =
|
|
||||||
"${service.storageKey}:$accountId"
|
|
||||||
|
|
||||||
private fun List<com.rainy.token.domain.model.CookieEntry>.sortedForFingerprint() =
|
private fun List<com.rainy.token.domain.model.CookieEntry>.sortedForFingerprint() =
|
||||||
sortedWith(
|
sortedWith(
|
||||||
compareBy(
|
compareBy(
|
||||||
|
|||||||
@ -19,9 +19,6 @@ internal class RefreshWriteSession(
|
|||||||
|
|
||||||
companion object Key : CoroutineContext.Key<RefreshWriteSession>
|
companion object Key : CoroutineContext.Key<RefreshWriteSession>
|
||||||
|
|
||||||
/** 快照对应的账号 ID;单账号服务为 null。 */
|
|
||||||
val accountId: String? get() = snapshot.accountId
|
|
||||||
|
|
||||||
private val stateLock = Any()
|
private val stateLock = Any()
|
||||||
private var pendingCredential: Credential? = null
|
private var pendingCredential: Credential? = null
|
||||||
private var pendingBalance: ServiceBalance? = null
|
private var pendingBalance: ServiceBalance? = null
|
||||||
|
|||||||
@ -9,9 +9,6 @@ sealed class RepositoryError(message: String, cause: Throwable? = null) : Except
|
|||||||
class InvalidCredential(detail: String? = null, cause: Throwable? = null) :
|
class InvalidCredential(detail: String? = null, cause: Throwable? = null) :
|
||||||
RepositoryError("凭据无效" + (detail?.let { ": $it" } ?: ""), cause)
|
RepositoryError("凭据无效" + (detail?.let { ": $it" } ?: ""), cause)
|
||||||
|
|
||||||
/** 服务端 CheckLogin 确认登录态已失效(未登录或 JWT 过期),需重新登录 */
|
|
||||||
class SessionExpired : RepositoryError("登录已过期,请重新登录")
|
|
||||||
|
|
||||||
/** 请求期间凭据被保存、替换或删除;旧结果已安全丢弃。 */
|
/** 请求期间凭据被保存、替换或删除;旧结果已安全丢弃。 */
|
||||||
class CredentialChanged : RepositoryError("凭据已变更,本次请求结果已丢弃")
|
class CredentialChanged : RepositoryError("凭据已变更,本次请求结果已丢弃")
|
||||||
|
|
||||||
|
|||||||
@ -64,8 +64,8 @@ class Sub2ApiRepository constructor(
|
|||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
private val jsonMedia = "application/json".toMediaType()
|
private val jsonMedia = "application/json".toMediaType()
|
||||||
|
|
||||||
suspend fun fetchBalance(accountId: String? = null): Result<ServiceBalance> = withContext(Dispatchers.IO) {
|
suspend fun fetchBalance(): Result<ServiceBalance> = withContext(Dispatchers.IO) {
|
||||||
val credential = credentialRepository.get(ServiceType.SUB2API, accountId)
|
val credential = credentialRepository.get(ServiceType.SUB2API)
|
||||||
?: return@withContext Result.failure(RepositoryError.InvalidCredential())
|
?: return@withContext Result.failure(RepositoryError.InvalidCredential())
|
||||||
if (credential !is Credential.Sub2ApiCredential) {
|
if (credential !is Credential.Sub2ApiCredential) {
|
||||||
return@withContext Result.failure(RepositoryError.InvalidCredential())
|
return@withContext Result.failure(RepositoryError.InvalidCredential())
|
||||||
@ -73,14 +73,10 @@ class Sub2ApiRepository constructor(
|
|||||||
val base = normalizeBase(credential.baseUrl)
|
val base = normalizeBase(credential.baseUrl)
|
||||||
if (base.isBlank()) return@withContext Result.failure(RepositoryError.InvalidCredential())
|
if (base.isBlank()) return@withContext Result.failure(RepositoryError.InvalidCredential())
|
||||||
|
|
||||||
// 面板模式:WebView 登录获取的 authToken 走用户级接口(全账号数据,与网页一致)。
|
// 面板模式:WebView 登录获取的 authToken 走用户级接口(全账号数据,与网页一致)
|
||||||
// token 过期(401/403)时自动用邮箱+密码重新登录换新 token 后重试一次;
|
|
||||||
// 只有自动重登也失败(账号问题)才返回错误,由 UI 引导手动登录。
|
|
||||||
val panelToken = credential.authToken?.trim().takeUnless { it.isNullOrEmpty() }
|
val panelToken = credential.authToken?.trim().takeUnless { it.isNullOrEmpty() }
|
||||||
if (panelToken != null) {
|
if (panelToken != null) {
|
||||||
return@withContext withPanelAutoRefresh(accountId) { b, cred, token ->
|
return@withContext fetchPanelBalance(base, panelToken)
|
||||||
fetchPanelBalance(b, token, cred, accountId)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val auth = resolveAuth(base, credential) ?: return@withContext Result.failure(
|
val auth = resolveAuth(base, credential) ?: return@withContext Result.failure(
|
||||||
@ -129,8 +125,8 @@ class Sub2ApiRepository constructor(
|
|||||||
extras = stats.extras,
|
extras = stats.extras,
|
||||||
sub2UsageDetail = parseUsageDetail(resp.body).takeIf { it.isNotEmpty }
|
sub2UsageDetail = parseUsageDetail(resp.body).takeIf { it.isNotEmpty }
|
||||||
)
|
)
|
||||||
balanceCache.put(ServiceType.SUB2API, balance, accountId)
|
balanceCache.put(ServiceType.SUB2API, balance)
|
||||||
credentialRepository.save(credential.copy(lastVerifiedAt = System.currentTimeMillis()), accountId)
|
credentialRepository.save(credential.copy(lastVerifiedAt = System.currentTimeMillis()))
|
||||||
Result.success(balance)
|
Result.success(balance)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -262,12 +258,7 @@ class Sub2ApiRepository constructor(
|
|||||||
* - GET /api/v1/usage/dashboard/models?… → 全账号模型分布
|
* - GET /api/v1/usage/dashboard/models?… → 全账号模型分布
|
||||||
* - GET /api/v1/usage/dashboard/snapshot-v2?granularity=day → 每日趋势(近30天)
|
* - GET /api/v1/usage/dashboard/snapshot-v2?granularity=day → 每日趋势(近30天)
|
||||||
*/
|
*/
|
||||||
private suspend fun fetchPanelBalance(
|
private suspend fun fetchPanelBalance(base: String, token: String): Result<ServiceBalance> {
|
||||||
base: String,
|
|
||||||
token: String,
|
|
||||||
credential: Credential.Sub2ApiCredential,
|
|
||||||
accountId: String? = null
|
|
||||||
): Result<ServiceBalance> {
|
|
||||||
val tz = java.net.URLEncoder.encode(TimeZone.getDefault().id, "UTF-8")
|
val tz = java.net.URLEncoder.encode(TimeZone.getDefault().id, "UTF-8")
|
||||||
val today = java.time.LocalDate.now()
|
val today = java.time.LocalDate.now()
|
||||||
val monthStart = today.withDayOfMonth(1).toString()
|
val monthStart = today.withDayOfMonth(1).toString()
|
||||||
@ -357,15 +348,7 @@ class Sub2ApiRepository constructor(
|
|||||||
)
|
)
|
||||||
// 面板模式提前 return 时也要写余额缓存,否则主页列表读不到(显示「待获取」)。
|
// 面板模式提前 return 时也要写余额缓存,否则主页列表读不到(显示「待获取」)。
|
||||||
// 通过 RefreshWriteSession 机制暂存,随本次成功刷新一起提交。
|
// 通过 RefreshWriteSession 机制暂存,随本次成功刷新一起提交。
|
||||||
balanceCache.put(ServiceType.SUB2API, panelBalance, accountId)
|
balanceCache.put(ServiceType.SUB2API, panelBalance)
|
||||||
// 面板请求成功即证明凭据验证通过:同步更新 lastVerifiedAt(对齐非面板 fetchBalance 路径),
|
|
||||||
// 否则面板登录超过 7 天(token 仍有效)后详情页会把凭据误标为「未配置或已过期」。
|
|
||||||
// 注意这里必须回写本次请求实际使用的 [token]:自动重登后 [token] 已是新值,
|
|
||||||
// 若沿用旧的 credential 会覆盖掉刚刷新的 token,导致下一个请求再次 401。
|
|
||||||
credentialRepository.save(
|
|
||||||
credential.copy(authToken = token, lastVerifiedAt = System.currentTimeMillis()),
|
|
||||||
accountId
|
|
||||||
)
|
|
||||||
return Result.success(panelBalance)
|
return Result.success(panelBalance)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -376,38 +359,17 @@ class Sub2ApiRepository constructor(
|
|||||||
* 用于「今天」窗口——按天只有一根柱子没有信息量,改为按小时展示。
|
* 用于「今天」窗口——按天只有一根柱子没有信息量,改为按小时展示。
|
||||||
* 若面板版本不支持 hour 粒度(返回空/报错),返回空列表,由调用方回退到按天。
|
* 若面板版本不支持 hour 粒度(返回空/报错),返回空列表,由调用方回退到按天。
|
||||||
*/
|
*/
|
||||||
suspend fun fetchHourlyTrend(date: LocalDate, accountId: String? = null): Result<List<Sub2DailyUsage>> = withContext(Dispatchers.IO) {
|
suspend fun fetchHourlyTrend(date: LocalDate): Result<List<Sub2DailyUsage>> = withContext(Dispatchers.IO) {
|
||||||
val credential = credentialRepository.get(ServiceType.SUB2API, accountId) as? Credential.Sub2ApiCredential
|
val credential = credentialRepository.get(ServiceType.SUB2API) as? Credential.Sub2ApiCredential
|
||||||
?: return@withContext Result.success(emptyList())
|
?: return@withContext Result.success(emptyList())
|
||||||
val base = normalizeBase(credential.baseUrl)
|
val base = normalizeBase(credential.baseUrl)
|
||||||
if (base.isBlank()) return@withContext Result.success(emptyList())
|
if (base.isBlank()) return@withContext Result.success(emptyList())
|
||||||
val panelToken = credential.authToken?.trim().takeUnless { it.isNullOrEmpty() }
|
val panelToken = credential.authToken?.trim().takeUnless { it.isNullOrEmpty() }
|
||||||
if (panelToken != null) {
|
val auth = if (panelToken != null) {
|
||||||
// 面板模式:token 过期自动重登后重试;自动重登失败仍静默回退(小时趋势非主链路)
|
AuthHeader("Authorization", "Bearer " + panelToken)
|
||||||
return@withContext withPanelAutoRefresh(accountId) panelTrendBlock@ { b, _, token ->
|
} else {
|
||||||
val auth = AuthHeader("Authorization", "Bearer " + token)
|
resolveAuth(base, credential) ?: return@withContext Result.success(emptyList())
|
||||||
val day = date.toString()
|
|
||||||
val resp = get(
|
|
||||||
b,
|
|
||||||
"/api/v1/usage/dashboard/snapshot-v2?start_date=$day&end_date=$day" +
|
|
||||||
"&granularity=hour&include_trend=true&include_model_stats=false" +
|
|
||||||
"&include_group_stats=false&timezone=" + tzParam(),
|
|
||||||
auth
|
|
||||||
)
|
|
||||||
if (resp == null) return@panelTrendBlock Result.success(emptyList())
|
|
||||||
// token 过期要交给 withPanelAutoRefresh 自动重登重试,不能静默归零
|
|
||||||
if (resp.code == 401 || resp.code == 403) {
|
|
||||||
return@panelTrendBlock Result.failure(
|
|
||||||
RepositoryError.InvalidCredential("Sub2API 面板登录已失效,请重新网页登录")
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
// 其余不支持/错误:静默回退,让「今天」仍能显示按天柱状图
|
|
||||||
if (resp.code !in 200..299) return@panelTrendBlock Result.success(emptyList())
|
|
||||||
val list = runCatching { parsePanelTrend(resp.body) }.getOrDefault(emptyList())
|
|
||||||
Result.success(list)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val auth = resolveAuth(base, credential) ?: return@withContext Result.success(emptyList())
|
|
||||||
val day = date.toString()
|
val day = date.toString()
|
||||||
val resp = get(
|
val resp = get(
|
||||||
base,
|
base,
|
||||||
@ -588,12 +550,11 @@ class Sub2ApiRepository constructor(
|
|||||||
data class PanelGroup(val id: Long, val name: String, val rateMultiplier: Double, val description: String = "")
|
data class PanelGroup(val id: Long, val name: String, val rateMultiplier: Double, val description: String = "")
|
||||||
|
|
||||||
/** Key 列表。 */
|
/** Key 列表。 */
|
||||||
suspend fun fetchPanelKeys(accountId: String? = null): Result<List<PanelApiKey>> =
|
suspend fun fetchPanelKeys(): Result<List<PanelApiKey>> = withContext(Dispatchers.IO) {
|
||||||
withPanelAutoRefresh(accountId) panelKeysBlock@ { base, _, token ->
|
val (auth, base) = panelAuthOrNull() ?: return@withContext Result.failure(RepositoryError.InvalidCredential("请先登录面板(账号密码或网页登录)"))
|
||||||
val auth = AuthHeader("Authorization", "Bearer " + token)
|
|
||||||
val resp = get(base, "/api/v1/keys?page=1&page_size=100&sort_by=created_at&sort_order=desc&timezone=" + tzParam(), auth)
|
val resp = get(base, "/api/v1/keys?page=1&page_size=100&sort_by=created_at&sort_order=desc&timezone=" + tzParam(), auth)
|
||||||
?: return@panelKeysBlock Result.failure(RepositoryError.Unknown(IllegalStateException("获取 Key 列表失败")))
|
?: return@withContext Result.failure(RepositoryError.Unknown(IllegalStateException("获取 Key 列表失败")))
|
||||||
panelFailure(resp)?.let { return@panelKeysBlock Result.failure(it) }
|
panelFailure(resp)?.let { return@withContext Result.failure(it) }
|
||||||
val body = resp.body
|
val body = resp.body
|
||||||
runCatching {
|
runCatching {
|
||||||
val root = json.parseToJsonElement(body).jsonObject
|
val root = json.parseToJsonElement(body).jsonObject
|
||||||
@ -624,9 +585,9 @@ class Sub2ApiRepository constructor(
|
|||||||
* 面板模式:GET /api/v1/usage/dashboard/models?start_date=&end_date=…
|
* 面板模式:GET /api/v1/usage/dashboard/models?start_date=&end_date=…
|
||||||
* Key 模式:GET /v1/usage?days=&start_date=&end_date=…(404/400 自动兜底 /api/v1/usage)。
|
* Key 模式:GET /v1/usage?days=&start_date=&end_date=…(404/400 自动兜底 /api/v1/usage)。
|
||||||
*/
|
*/
|
||||||
suspend fun fetchWindowModelStats(startDate: LocalDate, endDate: LocalDate, accountId: String? = null): Result<WindowModelStats> =
|
suspend fun fetchWindowModelStats(startDate: LocalDate, endDate: LocalDate): Result<WindowModelStats> =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val credential = credentialRepository.get(ServiceType.SUB2API, accountId) as? Credential.Sub2ApiCredential
|
val credential = credentialRepository.get(ServiceType.SUB2API) as? Credential.Sub2ApiCredential
|
||||||
?: return@withContext Result.failure(RepositoryError.InvalidCredential("请先配置 Sub2API 凭据"))
|
?: return@withContext Result.failure(RepositoryError.InvalidCredential("请先配置 Sub2API 凭据"))
|
||||||
val base = normalizeBase(credential.baseUrl)
|
val base = normalizeBase(credential.baseUrl)
|
||||||
if (base.isBlank()) return@withContext Result.failure(RepositoryError.InvalidCredential())
|
if (base.isBlank()) return@withContext Result.failure(RepositoryError.InvalidCredential())
|
||||||
@ -635,23 +596,18 @@ class Sub2ApiRepository constructor(
|
|||||||
val tz = tzParam()
|
val tz = tzParam()
|
||||||
val panelToken = credential.authToken?.trim().takeUnless { it.isNullOrEmpty() }
|
val panelToken = credential.authToken?.trim().takeUnless { it.isNullOrEmpty() }
|
||||||
|
|
||||||
if (panelToken != null) {
|
val models: WindowModelStats = if (panelToken != null) {
|
||||||
// 面板模式:token 过期(401/403)自动重登换新 token 后重试一次
|
val auth = AuthHeader("Authorization", "Bearer " + panelToken)
|
||||||
return@withContext withPanelAutoRefresh(accountId) block@ { b, _, token ->
|
val resp = get(base, "/api/v1/usage/dashboard/models?start_date=$start&end_date=$end&model_source=requested&timezone=$tz", auth)
|
||||||
val auth = AuthHeader("Authorization", "Bearer " + token)
|
?: return@withContext Result.failure(RepositoryError.Unknown(IllegalStateException("获取窗口模型统计失败")))
|
||||||
val resp = get(b, "/api/v1/usage/dashboard/models?start_date=$start&end_date=$end&model_source=requested&timezone=$tz", auth)
|
panelFailure(resp)?.let { return@withContext Result.failure(it) }
|
||||||
?: return@block Result.failure(RepositoryError.Unknown(IllegalStateException("获取窗口模型统计失败")))
|
|
||||||
panelFailure(resp)?.let { return@block Result.failure(it) }
|
|
||||||
val list = runCatching { parsePanelModels(resp.body) }.getOrElse { e ->
|
val list = runCatching { parsePanelModels(resp.body) }.getOrElse { e ->
|
||||||
return@block Result.failure(
|
return@withContext Result.failure(
|
||||||
RepositoryError.ParseError(RepositoryError.ParseErrorReason.MALFORMED_RESPONSE, "面板响应解析失败: " + e.message)
|
RepositoryError.ParseError(RepositoryError.ParseErrorReason.MALFORMED_RESPONSE, "面板响应解析失败: " + e.message)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Result.success(WindowModelStats(list.sortedByDescending { it.cost }, allTime = false))
|
WindowModelStats(list.sortedByDescending { it.cost }, allTime = false)
|
||||||
}
|
} else {
|
||||||
}
|
|
||||||
|
|
||||||
// Key 模式:resolveAuth 每次请求都用邮箱+密码(或 apiKey)现取 token,不存在长期过期问题
|
|
||||||
val auth = resolveAuth(base, credential)
|
val auth = resolveAuth(base, credential)
|
||||||
?: return@withContext Result.failure(RepositoryError.InvalidCredential("Sub2API 认证失败,请检查 API Key"))
|
?: return@withContext Result.failure(RepositoryError.InvalidCredential("Sub2API 认证失败,请检查 API Key"))
|
||||||
// 实测(xxcsn.site v0.2.1):下游 /v1/usage 的 model_stats 是「历史累计」口径,
|
// 实测(xxcsn.site v0.2.1):下游 /v1/usage 的 model_stats 是「历史累计」口径,
|
||||||
@ -679,7 +635,9 @@ class Sub2ApiRepository constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
// allTime=true:这是历史累计口径,UI 必须标注「累计」,不能冒充所选周期
|
// allTime=true:这是历史累计口径,UI 必须标注「累计」,不能冒充所选周期
|
||||||
Result.success(WindowModelStats(list.sortedByDescending { it.cost }, allTime = true))
|
WindowModelStats(list.sortedByDescending { it.cost }, allTime = true)
|
||||||
|
}
|
||||||
|
Result.success(models)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -737,22 +695,18 @@ class Sub2ApiRepository constructor(
|
|||||||
* /api/v1/keys 上的 usage_1d/5h/7d 实测恒为 0),所以必须逐个查询,
|
* /api/v1/keys 上的 usage_1d/5h/7d 实测恒为 0),所以必须逐个查询,
|
||||||
* 故用 coroutineScope + async 并行,避免 Key 多时串行等待过久。
|
* 故用 coroutineScope + async 并行,避免 Key 多时串行等待过久。
|
||||||
*/
|
*/
|
||||||
suspend fun fetchKeyUsageStats(startDate: LocalDate, endDate: LocalDate, accountId: String? = null): Result<List<KeyUsageStat>> =
|
suspend fun fetchKeyUsageStats(startDate: LocalDate, endDate: LocalDate): Result<List<KeyUsageStat>> =
|
||||||
withPanelAutoRefresh(accountId) keyStatsBlock@ { base, _, token ->
|
withContext(Dispatchers.IO) {
|
||||||
// Key 列表由 fetchPanelKeys 获取(其内部同样带自动刷新,可能已换新 token)
|
val (auth, base) = panelAuthOrNull()
|
||||||
val keys = fetchPanelKeys(accountId).getOrElse { return@keyStatsBlock Result.failure(it) }
|
?: return@withContext Result.failure(RepositoryError.InvalidCredential("请先登录面板"))
|
||||||
if (keys.isEmpty()) return@keyStatsBlock Result.success(emptyList())
|
val keys = fetchPanelKeys().getOrElse { return@withContext Result.failure(it) }
|
||||||
// fetchPanelKeys 刷新成功后会落库新 token,这里重读一次最新凭据;
|
if (keys.isEmpty()) return@withContext Result.success(emptyList())
|
||||||
// 未刷新则沿用传入 token
|
|
||||||
val freshCredential = credentialRepository.get(ServiceType.SUB2API, accountId) as? Credential.Sub2ApiCredential
|
|
||||||
val authToken = freshCredential?.authToken?.trim()?.takeIf { it.isNotEmpty() } ?: token
|
|
||||||
val auth = AuthHeader("Authorization", "Bearer " + authToken)
|
|
||||||
|
|
||||||
val start = startDate.toString()
|
val start = startDate.toString()
|
||||||
val end = endDate.toString()
|
val end = endDate.toString()
|
||||||
val tz = tzParam()
|
val tz = tzParam()
|
||||||
// 分组名映射(展示「Key · 分组」),拉取失败不影响主流程
|
// 分组名映射(展示「Key · 分组」),拉取失败不影响主流程
|
||||||
val groups = runCatching { fetchPanelGroups(accountId).getOrNull().orEmpty() }
|
val groups = runCatching { fetchPanelGroups().getOrNull().orEmpty() }
|
||||||
.getOrDefault(emptyList())
|
.getOrDefault(emptyList())
|
||||||
.associateBy { it.id }
|
.associateBy { it.id }
|
||||||
|
|
||||||
@ -805,19 +759,14 @@ class Sub2ApiRepository constructor(
|
|||||||
* 兼容多种响应形态:data 为对象(key / api_key / sk_key / token 字段)、data 为字符串,
|
* 兼容多种响应形态:data 为对象(key / api_key / sk_key / token 字段)、data 为字符串,
|
||||||
* 并把网关错误信息(message / error)透传给 UI,避免「响应异常」掩盖真实原因。
|
* 并把网关错误信息(message / error)透传给 UI,避免「响应异常」掩盖真实原因。
|
||||||
*/
|
*/
|
||||||
suspend fun createPanelKey(name: String, groupId: Long? = null, accountId: String? = null): Result<String> =
|
suspend fun createPanelKey(name: String, groupId: Long? = null): Result<String> = withContext(Dispatchers.IO) {
|
||||||
withPanelAutoRefresh(accountId) block@ { base, _, token ->
|
val (auth, base) = panelAuthOrNull() ?: return@withContext Result.failure(RepositoryError.InvalidCredential("请先登录面板"))
|
||||||
val auth = AuthHeader("Authorization", "Bearer " + token)
|
|
||||||
val body = buildJsonObject {
|
val body = buildJsonObject {
|
||||||
put("name", name)
|
put("name", name)
|
||||||
if (groupId != null) put("group_id", groupId)
|
if (groupId != null) put("group_id", groupId)
|
||||||
}.toString()
|
}.toString()
|
||||||
val resp = post(base, "/api/v1/keys", auth, body)
|
val resp = post(base, "/api/v1/keys", auth, body)
|
||||||
?: return@block Result.failure(RepositoryError.Unknown(IllegalStateException("创建 Key 失败(网络异常)")))
|
?: return@withContext Result.failure(RepositoryError.Unknown(IllegalStateException("创建 Key 失败(网络异常)")))
|
||||||
// HTTP 401/403 表示面板登录失效(自动重登后重试);其余按业务/网络错误透传
|
|
||||||
if (resp.code == 401 || resp.code == 403) {
|
|
||||||
return@block Result.failure(RepositoryError.InvalidCredential("Sub2API 面板登录已失效,请重新网页登录"))
|
|
||||||
}
|
|
||||||
runCatching {
|
runCatching {
|
||||||
val obj = json.parseToJsonElement(resp.body).jsonObject
|
val obj = json.parseToJsonElement(resp.body).jsonObject
|
||||||
// 业务码字段并集:code / status 任一存在即按其判定;再叠加 success 布尔
|
// 业务码字段并集:code / status 任一存在即按其判定;再叠加 success 布尔
|
||||||
@ -847,17 +796,17 @@ class Sub2ApiRepository constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 修改 Key(切换分组 / 增删黑白名单 / 额度等):PUT /api/v1/keys/{id}。 */
|
/** 修改 Key(切换分组 / 增删黑白名单 / 额度等):PUT /api/v1/keys/{id}。 */
|
||||||
suspend fun updatePanelKey(id: Long, groupId: Long? = null, patch: Map<String, String> = emptyMap(), accountId: String? = null): Result<Boolean> =
|
suspend fun updatePanelKey(id: Long, groupId: Long? = null, patch: Map<String, String> = emptyMap()): Result<Boolean> =
|
||||||
withPanelAutoRefresh(accountId) block@ { base, _, token ->
|
withContext(Dispatchers.IO) {
|
||||||
val auth = AuthHeader("Authorization", "Bearer " + token)
|
val (auth, base) = panelAuthOrNull() ?: return@withContext Result.failure(RepositoryError.InvalidCredential("请先登录面板"))
|
||||||
val body = buildJsonObject {
|
val body = buildJsonObject {
|
||||||
if (groupId != null) put("group_id", groupId)
|
if (groupId != null) put("group_id", groupId)
|
||||||
patch.forEach { (k, v) -> put(k, v) }
|
patch.forEach { (k, v) -> put(k, v) }
|
||||||
}.toString()
|
}.toString()
|
||||||
val resp = put(base, "/api/v1/keys/" + id, auth, body)
|
val resp = put(base, "/api/v1/keys/" + id, auth, body)
|
||||||
?: return@block Result.failure(RepositoryError.Unknown(IllegalStateException("修改 Key 失败")))
|
?: return@withContext Result.failure(RepositoryError.Unknown(IllegalStateException("修改 Key 失败")))
|
||||||
panelFailure(resp)?.let { return@block Result.failure(it) }
|
panelFailure(resp)?.let { return@withContext Result.failure(it) }
|
||||||
return@block runCatching {
|
return@withContext runCatching {
|
||||||
val obj = json.parseToJsonElement(resp.body).jsonObject
|
val obj = json.parseToJsonElement(resp.body).jsonObject
|
||||||
// 兼容 {code:0} 与 {success:true} 两种面板响应形态
|
// 兼容 {code:0} 与 {success:true} 两种面板响应形态
|
||||||
val ok = obj.numberOr("code", "status")?.let { it == 0.0 }
|
val ok = obj.numberOr("code", "status")?.let { it == 0.0 }
|
||||||
@ -877,13 +826,13 @@ class Sub2ApiRepository constructor(
|
|||||||
* 面板返回形态不统一({code:0} / {success:true} / 204 空体),因此判定顺序为:
|
* 面板返回形态不统一({code:0} / {success:true} / 204 空体),因此判定顺序为:
|
||||||
* HTTP 2xx 且没有业务错误码即视为成功;解析不出 JSON 也不当作失败(部分版本返回空体)。
|
* HTTP 2xx 且没有业务错误码即视为成功;解析不出 JSON 也不当作失败(部分版本返回空体)。
|
||||||
*/
|
*/
|
||||||
suspend fun deletePanelKey(id: Long, accountId: String? = null): Result<Boolean> =
|
suspend fun deletePanelKey(id: Long): Result<Boolean> = withContext(Dispatchers.IO) {
|
||||||
withPanelAutoRefresh(accountId) block@ { base, _, token ->
|
val (auth, base) = panelAuthOrNull()
|
||||||
val auth = AuthHeader("Authorization", "Bearer " + token)
|
?: return@withContext Result.failure(RepositoryError.InvalidCredential("请先登录面板"))
|
||||||
val resp = delete(base, "/api/v1/keys/" + id, auth)
|
val resp = delete(base, "/api/v1/keys/" + id, auth)
|
||||||
?: return@block Result.failure(RepositoryError.Unknown(IllegalStateException("删除 Key 失败(网络异常)")))
|
?: return@withContext Result.failure(RepositoryError.Unknown(IllegalStateException("删除 Key 失败(网络异常)")))
|
||||||
if (resp.code !in 200..299) {
|
if (resp.code !in 200..299) {
|
||||||
return@block Result.failure(mapSubHttpError(resp.code))
|
return@withContext Result.failure(mapSubHttpError(resp.code))
|
||||||
}
|
}
|
||||||
// 2xx 但体里带业务错误码(部分面板用 200 + code!=0 表达失败)
|
// 2xx 但体里带业务错误码(部分面板用 200 + code!=0 表达失败)
|
||||||
val bizError = runCatching {
|
val bizError = runCatching {
|
||||||
@ -902,12 +851,11 @@ class Sub2ApiRepository constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 可用分组列表(切换分组选择器)。 */
|
/** 可用分组列表(切换分组选择器)。 */
|
||||||
suspend fun fetchPanelGroups(accountId: String? = null): Result<List<PanelGroup>> =
|
suspend fun fetchPanelGroups(): Result<List<PanelGroup>> = withContext(Dispatchers.IO) {
|
||||||
withPanelAutoRefresh(accountId) panelGroupsBlock@ { base, _, token ->
|
val (auth, base) = panelAuthOrNull() ?: return@withContext Result.failure(RepositoryError.InvalidCredential("请先登录面板"))
|
||||||
val auth = AuthHeader("Authorization", "Bearer " + token)
|
|
||||||
val resp = get(base, "/api/v1/groups/available", auth)
|
val resp = get(base, "/api/v1/groups/available", auth)
|
||||||
?: return@panelGroupsBlock Result.failure(RepositoryError.Unknown(IllegalStateException("获取分组失败")))
|
?: return@withContext Result.failure(RepositoryError.Unknown(IllegalStateException("获取分组失败")))
|
||||||
panelFailure(resp)?.let { return@panelGroupsBlock Result.failure(it) }
|
panelFailure(resp)?.let { return@withContext Result.failure(it) }
|
||||||
val body = resp.body
|
val body = resp.body
|
||||||
runCatching {
|
runCatching {
|
||||||
val root = json.parseToJsonElement(body).jsonObject
|
val root = json.parseToJsonElement(body).jsonObject
|
||||||
@ -927,17 +875,16 @@ class Sub2ApiRepository constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 兑换卡密:POST /api/v1/redeem {code} → code=0 或业务错误(REDEEM_*)。 */
|
/** 兑换卡密:POST /api/v1/redeem {code} → code=0 或业务错误(REDEEM_*)。 */
|
||||||
suspend fun redeemPanelCode(code: String, accountId: String? = null): Result<String> =
|
suspend fun redeemPanelCode(code: String): Result<String> = withContext(Dispatchers.IO) {
|
||||||
withPanelAutoRefresh(accountId) block@ { base, _, token ->
|
val (auth, base) = panelAuthOrNull() ?: return@withContext Result.failure(RepositoryError.InvalidCredential("请先登录面板"))
|
||||||
val auth = AuthHeader("Authorization", "Bearer " + token)
|
|
||||||
val body = buildJsonObject { put("code", code.trim()) }.toString()
|
val body = buildJsonObject { put("code", code.trim()) }.toString()
|
||||||
val resp = post(base, "/api/v1/redeem", auth, body)
|
val resp = post(base, "/api/v1/redeem", auth, body)
|
||||||
?: return@block Result.failure(RepositoryError.Unknown(IllegalStateException("兑换失败")))
|
?: return@withContext Result.failure(RepositoryError.Unknown(IllegalStateException("兑换失败")))
|
||||||
// 兑换失败时面板用 HTTP 404/400 + {code,message,reason} 表达业务错误
|
// 兑换失败时面板用 HTTP 404/400 + {code,message,reason} 表达业务错误
|
||||||
// (实测:code=404, message="redeem code not found", reason=REDEEM_CODE_NOT_FOUND)。
|
// (实测:code=404, message="redeem code not found", reason=REDEEM_CODE_NOT_FOUND)。
|
||||||
// 因此这里必须先解析 body 再判定状态,否则会把「兑换码不存在」等具体原因吞成通用 HTTP 错误。
|
// 因此这里必须先解析 body 再判定状态,否则会把「兑换码不存在」等具体原因吞成通用 HTTP 错误。
|
||||||
val parsed = runCatching { json.parseToJsonElement(resp.body).jsonObject }.getOrNull()
|
val parsed = runCatching { json.parseToJsonElement(resp.body).jsonObject }.getOrNull()
|
||||||
?: return@block Result.failure(
|
?: return@withContext Result.failure(
|
||||||
RepositoryError.ParseError(RepositoryError.ParseErrorReason.MALFORMED_RESPONSE, "兑换响应解析失败(HTTP " + resp.code + ")")
|
RepositoryError.ParseError(RepositoryError.ParseErrorReason.MALFORMED_RESPONSE, "兑换响应解析失败(HTTP " + resp.code + ")")
|
||||||
)
|
)
|
||||||
val codeNum = parsed.numberOr("code")
|
val codeNum = parsed.numberOr("code")
|
||||||
@ -968,7 +915,7 @@ class Sub2ApiRepository constructor(
|
|||||||
401, 403 -> RepositoryError.InvalidCredential("Sub2API 面板登录已失效,请重新网页登录")
|
401, 403 -> RepositoryError.InvalidCredential("Sub2API 面板登录已失效,请重新网页登录")
|
||||||
else -> business
|
else -> business
|
||||||
}
|
}
|
||||||
return@block Result.failure(failure)
|
return@withContext Result.failure(failure)
|
||||||
}
|
}
|
||||||
// 实测成功响应:{"code":0,"data":{"id":57,"type":"balance","value":50,"status":"used",...}}
|
// 实测成功响应:{"code":0,"data":{"id":57,"type":"balance","value":50,"status":"used",...}}
|
||||||
// 注意 value 是数字(50)而非字符串,必须走 numberOr,否则金额显示不出来。
|
// 注意 value 是数字(50)而非字符串,必须走 numberOr,否则金额显示不出来。
|
||||||
@ -984,67 +931,14 @@ class Sub2ApiRepository constructor(
|
|||||||
Result.success("兑换成功" + amountText)
|
Result.success("兑换成功" + amountText)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 面板凭证获取(token 非空时);返回 (auth, base) 或 null。 */
|
||||||
* 面板操作统一包装:为指定账号读取凭据,用 authToken 执行 [block];
|
private suspend fun panelAuthOrNull(): Pair<AuthHeader, String>? {
|
||||||
*
|
val credential = credentialRepository.get(ServiceType.SUB2API) as? Credential.Sub2ApiCredential ?: return null
|
||||||
* 自动刷新语义:
|
val token = credential.authToken?.trim().takeUnless { it.isNullOrEmpty() } ?: return null
|
||||||
* - 首次请求返回 [RepositoryError.InvalidCredential](HTTP 401/403 = token 过期/登录失效)时,
|
|
||||||
* 用账号的邮箱+密码自动重新登录换取新 token,保存到该账号后【重试一次】;
|
|
||||||
* - 自动重登失败(邮箱/密码已变、面板关闭账号密码登录、网络异常)→ 返回原错误,
|
|
||||||
* 由 UI 引导用户真正去手动登录;
|
|
||||||
* - 重试后仍失败 → 返回第二次请求的错误。
|
|
||||||
*
|
|
||||||
* 多账号注意:accountId=null 语义是「默认账号」(不是新建)。回写刷新后的 token 前
|
|
||||||
* 必须先解析出真实账号 ID,否则 [CredentialRepository.save] 对多账号服务会把
|
|
||||||
* null accountId 当成「新建账号」,造成每次过期重登都新建一个重复账号。
|
|
||||||
*/
|
|
||||||
private suspend fun <T> withPanelAutoRefresh(
|
|
||||||
accountId: String?,
|
|
||||||
block: suspend (base: String, credential: Credential.Sub2ApiCredential, token: String) -> Result<T>
|
|
||||||
): Result<T> = withContext(Dispatchers.IO) {
|
|
||||||
val targetAccountId = if (accountId.isNullOrBlank()) {
|
|
||||||
credentialRepository.getDefaultAccount(ServiceType.SUB2API)?.id
|
|
||||||
} else {
|
|
||||||
accountId
|
|
||||||
}
|
|
||||||
val credential = credentialRepository.get(ServiceType.SUB2API, targetAccountId) as? Credential.Sub2ApiCredential
|
|
||||||
?: return@withContext Result.failure(RepositoryError.InvalidCredential("请先配置 Sub2API 凭据"))
|
|
||||||
val base = normalizeBase(credential.baseUrl)
|
val base = normalizeBase(credential.baseUrl)
|
||||||
if (base.isBlank()) return@withContext Result.failure(RepositoryError.InvalidCredential())
|
if (base.isBlank()) return null
|
||||||
val initialToken = credential.authToken?.trim()?.takeIf { it.isNotEmpty() }
|
return AuthHeader("Authorization", "Bearer " + token) to base
|
||||||
?: return@withContext Result.failure(RepositoryError.InvalidCredential("请先登录面板(账号密码或网页登录)"))
|
|
||||||
|
|
||||||
var token = initialToken
|
|
||||||
repeat(2) { attempt ->
|
|
||||||
val result = block(base, credential, token)
|
|
||||||
val failure = result.exceptionOrNull()
|
|
||||||
// block 内出现的 InvalidCredential 均来自面板 HTTP 401/403(登录失效)
|
|
||||||
if (attempt == 0 && failure is RepositoryError.InvalidCredential) {
|
|
||||||
val lockKey = targetAccountId ?: credential.service.storageKey
|
|
||||||
val lock = panelRefreshLocks.getOrPut(lockKey) { kotlinx.coroutines.sync.Mutex() }
|
|
||||||
// 同一账号并发刷新去重:锁被占用说明已有请求在重登,本次不再并发登录
|
|
||||||
if (lock.tryLock()) {
|
|
||||||
try {
|
|
||||||
val refreshed = loginPanel(base, credential.email, credential.password).getOrNull()
|
|
||||||
if (refreshed != null && refreshed.isNotBlank()) {
|
|
||||||
token = refreshed
|
|
||||||
credentialRepository.save(credential.copy(authToken = refreshed), targetAccountId)
|
|
||||||
return@repeat
|
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
lock.unlock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 自动重登失败(或被并发刷新占用)= 账号问题,返回原错误引导手动登录
|
|
||||||
return@withContext result
|
|
||||||
}
|
|
||||||
return@withContext result
|
|
||||||
}
|
|
||||||
Result.failure(RepositoryError.InvalidCredential("Sub2API 自动登录失败,请手动重新登录"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 按账号的面板刷新互斥锁(避免并发 401 时多次 loginPanel 互相作废 token)。 */
|
|
||||||
private val panelRefreshLocks = java.util.concurrent.ConcurrentHashMap<String, kotlinx.coroutines.sync.Mutex>()
|
|
||||||
|
|
||||||
private fun tzParam(): String = java.net.URLEncoder.encode(TimeZone.getDefault().id, "UTF-8")
|
private fun tzParam(): String = java.net.URLEncoder.encode(TimeZone.getDefault().id, "UTF-8")
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
package com.rainy.token.data.repository
|
package com.rainy.token.data.repository
|
||||||
|
|
||||||
import com.rainy.token.data.cache.BalanceCache
|
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.Credential
|
||||||
import com.rainy.token.domain.model.ServiceBalance
|
import com.rainy.token.domain.model.ServiceBalance
|
||||||
import com.rainy.token.domain.service.ServiceType
|
import com.rainy.token.domain.service.ServiceType
|
||||||
@ -13,13 +12,11 @@ import kotlinx.serialization.json.Json
|
|||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
import kotlinx.serialization.json.booleanOrNull
|
|
||||||
import kotlinx.serialization.json.contentOrNull
|
import kotlinx.serialization.json.contentOrNull
|
||||||
import kotlinx.serialization.json.doubleOrNull
|
import kotlinx.serialization.json.doubleOrNull
|
||||||
import kotlinx.serialization.json.jsonArray
|
import kotlinx.serialization.json.jsonArray
|
||||||
import kotlinx.serialization.json.jsonObject
|
import kotlinx.serialization.json.jsonObject
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
import kotlinx.serialization.json.longOrNull
|
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
@ -58,8 +55,8 @@ class TraeRepository constructor(
|
|||||||
|
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
suspend fun fetchBalance(accountId: String? = null): Result<ServiceBalance> = withContext(Dispatchers.IO) {
|
suspend fun fetchBalance(): Result<ServiceBalance> = withContext(Dispatchers.IO) {
|
||||||
val credential = credentialRepository.get(ServiceType.TRAE, accountId)
|
val credential = credentialRepository.get(ServiceType.TRAE)
|
||||||
?: return@withContext Result.failure(RepositoryError.InvalidCredential())
|
?: return@withContext Result.failure(RepositoryError.InvalidCredential())
|
||||||
if (credential !is Credential.TraeCredential || credential.jwt.isBlank()) {
|
if (credential !is Credential.TraeCredential || credential.jwt.isBlank()) {
|
||||||
return@withContext Result.failure(RepositoryError.InvalidCredential())
|
return@withContext Result.failure(RepositoryError.InvalidCredential())
|
||||||
@ -80,15 +77,8 @@ class TraeRepository constructor(
|
|||||||
val body = try {
|
val body = try {
|
||||||
okHttpClient.newCall(request).execute().use { resp ->
|
okHttpClient.newCall(request).execute().use { resp ->
|
||||||
val text = resp.body?.string().orEmpty()
|
val text = resp.body?.string().orEmpty()
|
||||||
DebugLog.i(TAG, "fetchBalance: http=${resp.code} body=${text.take(300)}")
|
|
||||||
if (!resp.isSuccessful) {
|
if (!resp.isSuccessful) {
|
||||||
return@withContext Result.failure(
|
return@withContext Result.failure(mapHttpError(resp.code))
|
||||||
if (resp.code == 401 || resp.code == 403) {
|
|
||||||
authErrorFor(credential.jwt.trim())
|
|
||||||
} else {
|
|
||||||
mapHttpError(resp.code)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
text
|
text
|
||||||
}
|
}
|
||||||
@ -112,8 +102,8 @@ class TraeRepository constructor(
|
|||||||
totalQuota = parsed.totalQuota,
|
totalQuota = parsed.totalQuota,
|
||||||
extras = parsed.extras
|
extras = parsed.extras
|
||||||
)
|
)
|
||||||
balanceCache.put(ServiceType.TRAE, balance, accountId)
|
balanceCache.put(ServiceType.TRAE, balance)
|
||||||
credentialRepository.save(credential.copy(lastVerifiedAt = System.currentTimeMillis()), accountId)
|
credentialRepository.save(credential.copy(lastVerifiedAt = System.currentTimeMillis()))
|
||||||
Result.success(balance)
|
Result.success(balance)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -137,12 +127,7 @@ class TraeRepository constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2) 权益包明细(前若干条 + 计数),同时累计兜底求和
|
// 2) 权益包明细(前若干条 + 计数),同时累计兜底求和
|
||||||
// 用固定前缀键 entitlement.$i.* 输出,供 TraePacksCard 拆开渲染(每包的
|
|
||||||
// 总额/已用/剩余)。旧「中文名.limit/.used」键形式存在歧义且被
|
|
||||||
// GenericExtrasCard 的过滤规则吞掉,导致权益明细从未展示(2026-10 用户反馈)。
|
|
||||||
var fallbackSum = 0.0
|
var fallbackSum = 0.0
|
||||||
var generalSum = 0.0 // available_endpoint=0:通用积分(TraeCode/TraeWork 均可用)
|
|
||||||
var workSum = 0.0 // available_endpoint=1:Work 专属积分(仅 TraeWork 可用)
|
|
||||||
var packShown = 0
|
var packShown = 0
|
||||||
var packCount = 0
|
var packCount = 0
|
||||||
val packs = root["user_entitlement_pack_list"]?.jsonArray
|
val packs = root["user_entitlement_pack_list"]?.jsonArray
|
||||||
@ -150,47 +135,26 @@ class TraeRepository constructor(
|
|||||||
val pack = packEl.jsonObject
|
val pack = packEl.jsonObject
|
||||||
val base = pack["entitlement_base_info"]?.jsonObject
|
val base = pack["entitlement_base_info"]?.jsonObject
|
||||||
val quota = base?.get("quota")?.jsonObject
|
val quota = base?.get("quota")?.jsonObject
|
||||||
val name = pack.getString("display_desc")
|
val name = pack.getString("display_desc") ?: base?.getString("name") ?: "pack[$index]"
|
||||||
?: pack.getString("group_name")
|
|
||||||
?: base?.getString("name")
|
|
||||||
?: base?.get("product_extra")?.jsonObject
|
|
||||||
?.get("package_extra")?.jsonObject?.getString("package_name")
|
|
||||||
?: "pack[$index]"
|
|
||||||
val limit = quota?.getNumber("credits_limit")?.toLong()
|
val limit = quota?.getNumber("credits_limit")?.toLong()
|
||||||
val used = pack.getNumber("usage", "credits_amount")
|
val used = pack.getNumber("usage", "credits_amount")
|
||||||
?: base?.get("usage")?.jsonObject?.get("credits_amount")?.safeDouble()
|
?: base?.get("usage")?.jsonObject?.get("credits_amount")?.safeDouble()
|
||||||
// available_endpoint 是通用 / Work 专属的唯一判别位(实测 2026-09 对照官方用量页:
|
|
||||||
// 0=通用积分,TraeCode/TraeWork 均可用;1=Work 专属,仅 TraeWork)。缺失按 0(通用)。
|
|
||||||
val isWork = (base?.get("available_endpoint")?.safeDouble() ?: 0.0) >= 1.0
|
|
||||||
val endpointKey = if (isWork) "1" else "0"
|
|
||||||
packCount++
|
packCount++
|
||||||
when {
|
when {
|
||||||
limit == null -> { /* 结构变化:跳过 */ }
|
limit == null -> { /* 结构变化:跳过 */ }
|
||||||
limit < 0 -> {
|
limit < 0 -> extras["$name.unlimited"] = "true"
|
||||||
extras["entitlement.$index.name"] = name
|
|
||||||
extras["entitlement.$index.unlimited"] = "true"
|
|
||||||
extras["entitlement.$index.endpoint"] = endpointKey
|
|
||||||
}
|
|
||||||
else -> {
|
else -> {
|
||||||
val remaining = (limit - (used ?: 0.0)).coerceAtLeast(0.0)
|
val remaining = (limit - (used ?: 0.0)).coerceAtLeast(0.0)
|
||||||
fallbackSum += remaining
|
fallbackSum += remaining
|
||||||
if (isWork) workSum += remaining else generalSum += remaining
|
|
||||||
if (packShown < 8) {
|
if (packShown < 8) {
|
||||||
extras["entitlement.$index.name"] = name
|
extras["$name.limit"] = limit.toString()
|
||||||
extras["entitlement.$index.limit"] = limit.toString()
|
extras["$name.used"] = used?.let { trimNum(it) } ?: "?"
|
||||||
extras["entitlement.$index.used"] = used?.let { trimNum(it) } ?: "0"
|
|
||||||
extras["entitlement.$index.remaining"] = trimNum(remaining)
|
|
||||||
extras["entitlement.$index.endpoint"] = endpointKey
|
|
||||||
packShown++
|
packShown++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (packCount > 0) {
|
if (packCount > 0) extras["pack_count"] = packCount.toString()
|
||||||
extras["pack_count"] = packCount.toString()
|
|
||||||
extras["general_credits"] = trimNum(generalSum)
|
|
||||||
extras["work_credits"] = trimNum(workSum)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3) 金额:汇总优先,缺省回退包级
|
// 3) 金额:汇总优先,缺省回退包级
|
||||||
val amount = if (total != null && consumed != null) {
|
val amount = if (total != null && consumed != null) {
|
||||||
@ -231,39 +195,6 @@ class TraeRepository constructor(
|
|||||||
|
|
||||||
// ---------- 每日签到(checkin_credits)----------
|
// ---------- 每日签到(checkin_credits)----------
|
||||||
|
|
||||||
/**
|
|
||||||
* 轻量查询「今日是否已签到」(只读,不发起 claim)。
|
|
||||||
*
|
|
||||||
* 供详情页进入时预取状态用:先查本地记录(credential.lastCheckinDate /
|
|
||||||
* [CheckinStatusStore]),未命中再调 status 接口(服务端权威,跨设备已签也能识别)。
|
|
||||||
* 返回 true=今日已签;false=未签/活动未开放;失败=网络/协议异常(调用方按未知处理)。
|
|
||||||
*/
|
|
||||||
suspend fun fetchCheckinStatus(accountId: String? = null): Result<Boolean> = withContext(Dispatchers.IO) {
|
|
||||||
val credential = credentialRepository.get(ServiceType.TRAE, accountId)
|
|
||||||
?: return@withContext Result.failure(RepositoryError.InvalidCredential())
|
|
||||||
if (credential !is Credential.TraeCredential || credential.jwt.isBlank()) {
|
|
||||||
return@withContext Result.failure(RepositoryError.InvalidCredential())
|
|
||||||
}
|
|
||||||
if (credential.region.uppercase() == "INTL") {
|
|
||||||
// INTL 区签到接口未实测,status 同样不可用
|
|
||||||
return@withContext Result.success(false)
|
|
||||||
}
|
|
||||||
val jwt = credential.jwt.trim()
|
|
||||||
if (credential.lastCheckinDate == java.time.LocalDate.now().toString()) {
|
|
||||||
return@withContext Result.success(true)
|
|
||||||
}
|
|
||||||
val deviceId = credential.checkinDeviceId?.trim()?.takeIf { it.isNotEmpty() }
|
|
||||||
?: ACCOUNT_BOUND_DEVICE_ID
|
|
||||||
val body = checkinRequest(jwt, deviceId, "bound", CHECKIN_STATUS_PATH)
|
|
||||||
body.fold(
|
|
||||||
onSuccess = { text ->
|
|
||||||
val info = parseStatus(text).getOrNull()
|
|
||||||
Result.success(info?.checkedIn == true)
|
|
||||||
},
|
|
||||||
onFailure = { Result.failure(it) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 签到动作语义结果(UI 层据此本地化,不直接展示 Repository 中文串)。 */
|
/** 签到动作语义结果(UI 层据此本地化,不直接展示 Repository 中文串)。 */
|
||||||
enum class CheckinOutcome {
|
enum class CheckinOutcome {
|
||||||
/** 签到成功 */
|
/** 签到成功 */
|
||||||
@ -272,8 +203,6 @@ class TraeRepository constructor(
|
|||||||
ALREADY,
|
ALREADY,
|
||||||
/** 设备级拦截(提示而非报错) */
|
/** 设备级拦截(提示而非报错) */
|
||||||
DEVICE_BLOCKED,
|
DEVICE_BLOCKED,
|
||||||
/** 服务端临时限流(如「参与用户太多,请稍后重试」):提示而非报错,可稍后重试 */
|
|
||||||
RETRYABLE,
|
|
||||||
/** INTL 区签到接口未实测,暂不支持 */
|
/** INTL 区签到接口未实测,暂不支持 */
|
||||||
INTL_UNSUPPORTED,
|
INTL_UNSUPPORTED,
|
||||||
/** 其他失败(服务端给了原因/错误码) */
|
/** 其他失败(服务端给了原因/错误码) */
|
||||||
@ -295,19 +224,16 @@ class TraeRepository constructor(
|
|||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 执行 Trae 每日签到(CN 区),流程对齐社区实测工具(trae-daily-checkin /
|
* 执行 Trae 每日签到(CN 区)。
|
||||||
* trae-checkin-helper 逆向口径):
|
* 接口:POST https://api.trae.cn/trae/api/v2/ug/checkin_credits/claim body {}。
|
||||||
* 1. POST /trae/api/v2/ug/checkin_credits/status(body {})查状态:
|
* 头:Cloud-IDE-JWT <jwt> + x-device-id(claim 按设备做每日去重——用 JWT 派生的稳定
|
||||||
* - enable=false → 签到功能暂未开放(中性提示,非错误)
|
* 伪 deviceId,同账号稳定、不同账号不同,避免共享 device-id 被拦)。
|
||||||
* - checked_in=true → 今日已签(幂等短路,不重复 claim)
|
* 语义:code 0/200 成功;code 1001 或消息含「已签到/已领取/already」=账号已签;
|
||||||
* 2. POST /trae/api/v2/ug/checkin_credits/claim(body {})执行签到:
|
* 消息含「设备/device」=设备级拦截(提示而非报错)。
|
||||||
* code 0/200 成功;code 10001 或文案含「已签到/already」=已签。
|
|
||||||
* 头:Cloud-IDE-JWT <jwt> + x-device-id(登录会话绑定的 App 持久化设备 ID;
|
|
||||||
* 不可用 userJwt.ClientID——固定客户端标识,所有用户相同,会被按设备风控拒绝)。
|
|
||||||
* INTL 区签到接口未实测,暂不支持(返回明确提示)。
|
* INTL 区签到接口未实测,暂不支持(返回明确提示)。
|
||||||
*/
|
*/
|
||||||
suspend fun checkIn(accountId: String? = null): Result<CheckinResult> = withContext(Dispatchers.IO) {
|
suspend fun checkIn(): Result<CheckinResult> = withContext(Dispatchers.IO) {
|
||||||
val credential = credentialRepository.get(ServiceType.TRAE, accountId)
|
val credential = credentialRepository.get(ServiceType.TRAE)
|
||||||
?: return@withContext Result.failure(RepositoryError.InvalidCredential())
|
?: return@withContext Result.failure(RepositoryError.InvalidCredential())
|
||||||
if (credential !is Credential.TraeCredential || credential.jwt.isBlank()) {
|
if (credential !is Credential.TraeCredential || credential.jwt.isBlank()) {
|
||||||
return@withContext Result.failure(RepositoryError.InvalidCredential())
|
return@withContext Result.failure(RepositoryError.InvalidCredential())
|
||||||
@ -324,197 +250,49 @@ class TraeRepository constructor(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val jwt = credential.jwt.trim()
|
val request = Request.Builder()
|
||||||
// 本地签到状态短路:今天已成功签到则不再发起任何网络请求(防重复签到/重复 claim)
|
.url("https://api.trae.cn/trae/api/v2/ug/checkin_credits/claim")
|
||||||
val today = java.time.LocalDate.now().toString()
|
.addHeader("Authorization", "Cloud-IDE-JWT " + credential.jwt.trim())
|
||||||
if (credential.lastCheckinDate == today) {
|
.addHeader("Content-Type", "application/json")
|
||||||
DebugLog.i(TAG, "checkin: 本地已记录今日签到,短路跳过网络请求")
|
.addHeader("x-device-id", stableDeviceId(credential.jwt.trim()))
|
||||||
return@withContext Result.success(
|
.post("{}".toRequestBody("application/json".toMediaType()))
|
||||||
CheckinResult(
|
.build()
|
||||||
success = true,
|
|
||||||
alreadyChecked = true,
|
|
||||||
points = 0,
|
|
||||||
message = "",
|
|
||||||
outcome = CheckinOutcome.ALREADY
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// 签到设备(x-device-id):必须使用账号绑定的官方客户端设备——服务端按设备
|
|
||||||
// 做签到风控/去重,App 自生成的随机设备 ID 会稳定触发 9074「参与用户太多」。
|
|
||||||
// 实测(2026-09-13):改用 PC 客户端真实 did 后 claim 成功。优先级:
|
|
||||||
// 用户设置页填写的 did(换设备后可自行更新)> 内置默认(同账号客户端 did)。
|
|
||||||
val deviceId = credential.checkinDeviceId?.trim()?.takeIf { it.isNotEmpty() }
|
|
||||||
?: ACCOUNT_BOUND_DEVICE_ID
|
|
||||||
val deviceSrc = "bound"
|
|
||||||
// Step 1:先查签到状态(enable 活动开关 + checked_in 防重复短路,
|
|
||||||
// 对齐 trae-checkin-helper 实测口径:enable/checked_in 必须是布尔才算协议正常)
|
|
||||||
val statusBody = checkinRequest(jwt, deviceId, deviceSrc, CHECKIN_STATUS_PATH)
|
|
||||||
if (statusBody.isFailure) {
|
|
||||||
return@withContext Result.failure(
|
|
||||||
statusBody.exceptionOrNull() ?: RepositoryError.Unknown()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// status 解析失败(协议异常/被风控设备无状态可读)记录日志后继续走 claim 兜底
|
|
||||||
val statusParsed = parseStatus(statusBody.getOrThrow())
|
|
||||||
if (statusParsed.isFailure) {
|
|
||||||
DebugLog.w(
|
|
||||||
TAG,
|
|
||||||
"checkin: status 解析失败,走 claim 兜底: ${statusParsed.exceptionOrNull()?.message}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
statusParsed.getOrNull()?.let { status ->
|
|
||||||
when {
|
|
||||||
status.enable == false -> {
|
|
||||||
// 签到活动未开放/间歇期:中性提示,非错误(enable=false 属正常现象)
|
|
||||||
credentialRepository.save(
|
|
||||||
credential.copy(lastVerifiedAt = System.currentTimeMillis()),
|
|
||||||
accountId
|
|
||||||
)
|
|
||||||
return@withContext Result.success(
|
|
||||||
CheckinResult(
|
|
||||||
success = false,
|
|
||||||
alreadyChecked = false,
|
|
||||||
points = 0,
|
|
||||||
message = status.message.ifBlank { "签到功能暂未开放,请稍后再试" },
|
|
||||||
outcome = CheckinOutcome.RETRYABLE
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
status.checkedIn == true -> {
|
|
||||||
// 今日已签:幂等短路,不再发起 claim;落本地签到日期防重复
|
|
||||||
credentialRepository.save(
|
|
||||||
credential.copy(
|
|
||||||
lastVerifiedAt = System.currentTimeMillis(),
|
|
||||||
lastCheckinDate = today
|
|
||||||
),
|
|
||||||
accountId
|
|
||||||
)
|
|
||||||
return@withContext Result.success(
|
|
||||||
CheckinResult(
|
|
||||||
success = true,
|
|
||||||
alreadyChecked = true,
|
|
||||||
points = status.credits?.toLong() ?: 0L,
|
|
||||||
message = "",
|
|
||||||
outcome = CheckinOutcome.ALREADY
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// status 解析失败(协议变化等情况)继续走 claim——claim 的 code 判定仍权威
|
|
||||||
|
|
||||||
// Step 2:执行签到。claim body 按官方契约探测(对齐 cpa-multi-plugins v0.12.41/43:
|
val body = try {
|
||||||
// 09-04 上游收紧活动校验后,body 必须与 token 谱系匹配;官方契约空 {} 实测到账,
|
okHttpClient.newCall(request).execute().use { resp ->
|
||||||
// SOLO/TraeWork 官方同款 req_source=2,TraeCode 用 req_source=1;9074 时顺次换 body)。
|
val text = resp.body?.string().orEmpty()
|
||||||
val claimBodies = listOf("{}", """{"req_source":2}""", """{"req_source":1}""")
|
if (resp.code == 401 || resp.code == 403) {
|
||||||
var claimResult: Result<CheckinResult>? = null
|
return@withContext Result.failure(RepositoryError.InvalidCredential())
|
||||||
for (body in claimBodies) {
|
|
||||||
val cl = checkinRequest(jwt, deviceId, deviceSrc, CHECKIN_CLAIM_PATH, body)
|
|
||||||
if (cl.isFailure) {
|
|
||||||
claimResult = Result.failure(
|
|
||||||
cl.exceptionOrNull() ?: RepositoryError.Unknown()
|
|
||||||
)
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
val parsed = parseCheckin(cl.getOrThrow())
|
if (!resp.isSuccessful && resp.code != 400) {
|
||||||
claimResult = parsed
|
return@withContext Result.failure(mapHttpError(resp.code))
|
||||||
// 9074 已解析为 RETRYABLE;换 body 再试,其它语义(成功/已签/设备拦截/硬错误)直接定稿
|
|
||||||
if (parsed.getOrNull()?.outcome != CheckinOutcome.RETRYABLE) break
|
|
||||||
}
|
}
|
||||||
val parsed = claimResult
|
// 400 也可能是业务失败(已签/设备拦截),交给 body 判断
|
||||||
?: return@withContext Result.failure(RepositoryError.Unknown())
|
text
|
||||||
// 仅「凭据被服务端接受」时才算已验证:SUCCESS/ALREADY/DEVICE_BLOCKED/RETRYABLE 都说明
|
}
|
||||||
// JWT 有效(401/403 已在 checkinRequest 提前失败);FAILED(含 HTTP 400 业务失败)不标记,
|
} catch (e: IOException) {
|
||||||
// 避免把无效凭据误标为已验证。SUCCESS/ALREADY 同时落本地签到日期(防当日重复签到)。
|
return@withContext Result.failure(RepositoryError.Network(e))
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
return@withContext Result.failure(RepositoryError.Unknown(e))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 仅「凭据被服务端接受」时才算已验证:SUCCESS/ALREADY/DEVICE_BLOCKED 都说明
|
||||||
|
// JWT 有效(401/403 已在上面提前返回);FAILED(含 HTTP 400 业务失败)不标记,
|
||||||
|
// 避免把无效凭据误标为已验证。
|
||||||
|
val parsed = parseCheckin(body)
|
||||||
val outcome = parsed.getOrNull()?.outcome
|
val outcome = parsed.getOrNull()?.outcome
|
||||||
if (outcome != null && outcome != CheckinOutcome.FAILED) {
|
if (outcome != null && outcome != CheckinOutcome.FAILED) {
|
||||||
val checkedToday =
|
credentialRepository.save(credential.copy(lastVerifiedAt = System.currentTimeMillis()))
|
||||||
outcome == CheckinOutcome.SUCCESS || outcome == CheckinOutcome.ALREADY
|
|
||||||
credentialRepository.save(
|
|
||||||
credential.copy(
|
|
||||||
lastVerifiedAt = System.currentTimeMillis(),
|
|
||||||
lastCheckinDate = if (checkedToday) today else credential.lastCheckinDate
|
|
||||||
),
|
|
||||||
accountId
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
parsed
|
parsed
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 由 JWT 派生稳定伪 deviceId:同账号恒定、不同账号不同(防 claim 设备级去重误伤)。 */
|
||||||
* 签到系 POST(status/claim),请求头逐头对齐 cpa-multi-plugins v0.12.45
|
private fun stableDeviceId(seed: String): String = runCatching {
|
||||||
* 抓包指纹(2026-09-03 成功签到请求复刻,用户账号实测:旧头多日 9074,
|
val digest = java.security.MessageDigest.getInstance("SHA-256")
|
||||||
* 新头当日 claim code=0):
|
.digest(("rainytoken:" + seed).toByteArray())
|
||||||
* - UA 必须是 VSCode 插件进程身份 "VSCode 1.107.1 (TRAE SOLO CN)"——签到走
|
digest.take(12).joinToString("") { "%02x".format(it) }
|
||||||
* VSCode 插件进程,与 IDE 主进程 Trae/0.1.x / web 均不同,混用身份 = 风控画像对不上;
|
}.getOrDefault("rainytoken-device")
|
||||||
* - X-User-Region / X-Request-Id / X-TT-Trace-Id / Package-Type / App-Version
|
|
||||||
* 等均随真实客户端;Authorization: Cloud-IDE-JWT <jwt>;X-Device-Id 为签到设备。
|
|
||||||
* body 固定 `{}`(官方口径,加任何业务参数都会导致业务失败)。
|
|
||||||
* 401/403 → [RepositoryError.InvalidCredential];其它非 200/400 → [mapHttpError];
|
|
||||||
* 400 或 2xx → 返回 body 交给业务解析(400 也可能是已签/设备拦截)。
|
|
||||||
*/
|
|
||||||
private fun checkinRequest(
|
|
||||||
jwt: String,
|
|
||||||
deviceId: String,
|
|
||||||
deviceSrc: String,
|
|
||||||
path: String,
|
|
||||||
bodyJson: String = "{}"
|
|
||||||
): Result<String> {
|
|
||||||
val requestId = java.util.UUID.randomUUID().toString()
|
|
||||||
val request = Request.Builder()
|
|
||||||
.url("https://api.trae.cn" + path)
|
|
||||||
.addHeader("Content-Type", "application/json")
|
|
||||||
.addHeader("Accept", "*/*")
|
|
||||||
.addHeader("User-Agent", "VSCode 1.107.1 (TRAE SOLO CN)")
|
|
||||||
.addHeader("Authorization", "Cloud-IDE-JWT " + jwt)
|
|
||||||
.addHeader("X-User-Region", "CN")
|
|
||||||
.addHeader("Accept-Language", "zh-CN")
|
|
||||||
.addHeader("Package-Type", "stable_cn")
|
|
||||||
.addHeader("X-Lgw-Req-Sdk-Type", "3")
|
|
||||||
.addHeader("X-Market-Client-Id", "VSCode 1.107.1")
|
|
||||||
.addHeader("X-Device-Brand", "83DG")
|
|
||||||
.addHeader("X-Device-Type", "windows")
|
|
||||||
.addHeader("X-OS-Version", "Windows 11 Pro")
|
|
||||||
.addHeader("App-Version", "0.1.61")
|
|
||||||
.addHeader("X-Request-Id", requestId)
|
|
||||||
.addHeader("X-TT-Trace-Id", ttTraceId(requestId))
|
|
||||||
.addHeader("X-Device-Id", deviceId)
|
|
||||||
.addHeader("Sec-Fetch-Dest", "empty")
|
|
||||||
.addHeader("Sec-Fetch-Mode", "no-cors")
|
|
||||||
.addHeader("Sec-Fetch-Site", "none")
|
|
||||||
.post(bodyJson.toRequestBody("application/json".toMediaType()))
|
|
||||||
.build()
|
|
||||||
return try {
|
|
||||||
okHttpClient.newCall(request).execute().use { resp ->
|
|
||||||
val text = resp.body?.string().orEmpty()
|
|
||||||
// 本地调试日志:HTTP 码、路径与 deviceId 来源(bound=服务端绑定/real=持久化/fallback=伪ID)。
|
|
||||||
// 不记录 Authorization 与 deviceId 明文(敏感)。
|
|
||||||
DebugLog.i(
|
|
||||||
TAG,
|
|
||||||
"checkin: path=$path body=$bodyJson device=$deviceSrc http=${resp.code} body=${text.take(200)}"
|
|
||||||
)
|
|
||||||
when {
|
|
||||||
resp.code == 401 || resp.code == 403 ->
|
|
||||||
Result.failure(authErrorFor(jwt))
|
|
||||||
!resp.isSuccessful && resp.code != 400 ->
|
|
||||||
Result.failure(mapHttpError(resp.code))
|
|
||||||
else -> Result.success(text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: IOException) {
|
|
||||||
Result.failure(RepositoryError.Network(e))
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Result.failure(RepositoryError.Unknown(e))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 生成抓包格式 tt-trace-id:00-<32hex>-<16hex>-01,16hex 取自请求 ID。 */
|
|
||||||
private fun ttTraceId(requestId: String): String {
|
|
||||||
val bytes = ByteArray(16)
|
|
||||||
java.security.SecureRandom().nextBytes(bytes)
|
|
||||||
val hex32 = bytes.joinToString("") { "%02x".format(it) }
|
|
||||||
return "00-$hex32-${requestId.replace("-", "").take(16)}-01"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun mapHttpError(code: Int): RepositoryError = when (code) {
|
private fun mapHttpError(code: Int): RepositoryError = when (code) {
|
||||||
401, 403 -> RepositoryError.InvalidCredential()
|
401, 403 -> RepositoryError.InvalidCredential()
|
||||||
@ -523,44 +301,6 @@ class TraeRepository constructor(
|
|||||||
else -> RepositoryError.Unknown()
|
else -> RepositoryError.Unknown()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 401/403 时用官方 CheckLogin 鉴别真实原因(服务端权威,非启发式猜测):
|
|
||||||
* - IsLogin=false 或 ExpiredAt 已过 → [RepositoryError.SessionExpired](提示重新登录)
|
|
||||||
* - 其它情况(含 CheckLogin 自身失败)→ [RepositoryError.InvalidCredential](保持原语义)
|
|
||||||
*/
|
|
||||||
private fun authErrorFor(jwt: String): RepositoryError {
|
|
||||||
val state = runCatching {
|
|
||||||
val request = Request.Builder()
|
|
||||||
.url("https://api.trae.cn/cloudide/api/v3/trae/CheckLogin")
|
|
||||||
.addHeader("Content-Type", "application/json")
|
|
||||||
.addHeader("Accept", "application/json")
|
|
||||||
.addHeader("User-Agent", "Trae/0.1.43")
|
|
||||||
.addHeader("X-Cloudide-Token", jwt)
|
|
||||||
.post("""{"IDEVersion":"0.1.43"}""".toRequestBody("application/json".toMediaType()))
|
|
||||||
.build()
|
|
||||||
okHttpClient.newCall(request).execute().use { resp ->
|
|
||||||
if (!resp.isSuccessful) return@runCatching null
|
|
||||||
parseCheckLogin(resp.body?.string().orEmpty())
|
|
||||||
}
|
|
||||||
}.getOrNull()
|
|
||||||
val expired = state != null &&
|
|
||||||
(state.isLogin == false || (state.expiredAt in 1 until System.currentTimeMillis()))
|
|
||||||
return if (expired) RepositoryError.SessionExpired() else RepositoryError.InvalidCredential()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** CheckLogin 结果:isLogin 为服务端权威登录态;expiredAt 为 JWT 过期时间戳(ms)。 */
|
|
||||||
internal data class CheckLoginState(val isLogin: Boolean?, val expiredAt: Long?)
|
|
||||||
|
|
||||||
/** 解析 CheckLogin 响应(Result.IsLogin / Result.ExpiredAt),协议异常返回 null。 */
|
|
||||||
internal fun parseCheckLogin(body: String): CheckLoginState? = runCatching {
|
|
||||||
val root = parseJson.parseToJsonElement(body).jsonObject
|
|
||||||
val result = root["Result"] as? JsonObject ?: return null
|
|
||||||
CheckLoginState(
|
|
||||||
isLogin = (result["IsLogin"] as? JsonPrimitive)?.booleanOrNull,
|
|
||||||
expiredAt = (result["ExpiredAt"] as? JsonPrimitive)?.longOrNull
|
|
||||||
)
|
|
||||||
}.getOrNull()
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 数值转简短显示串(去掉多余的 0 与小数点)。
|
* 数值转简短显示串(去掉多余的 0 与小数点)。
|
||||||
* 注意不能用 v.toString().trimEnd('0','.'):trimEnd 会连续剥离集合内任意字符,
|
* 注意不能用 v.toString().trimEnd('0','.'):trimEnd 会连续剥离集合内任意字符,
|
||||||
@ -576,20 +316,6 @@ class TraeRepository constructor(
|
|||||||
)
|
)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val TAG = "Trae"
|
|
||||||
|
|
||||||
/** 官方客户端固定 ClientID(所有用户相同,不可作 x-device-id,会被按设备风控拒绝)。 */
|
|
||||||
const val TRAE_CLIENT_ID = "en1oxy7wnw8j9n"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 账号绑定的官方 PC 客户端设备 ID(x-device-id)。
|
|
||||||
* 实测根因(2026-09-13):签到 claim 的 9074「当前参与用户太多」是服务端设备
|
|
||||||
* 风控——App 自生成设备 ID 必被拒,改用官方客户端真实 did 后 claim 成功。
|
|
||||||
* 来源:客户端请求日志(api.trae.com.cn icube config/query did=…)。
|
|
||||||
* ⚠️ 账号级:换账号或官方端重置设备标识后需更新此值。
|
|
||||||
*/
|
|
||||||
const val ACCOUNT_BOUND_DEVICE_ID = "895482260673817"
|
|
||||||
|
|
||||||
/** 解析专用 Json(companion 内独立持有,使 parseCheckin 可静态调用/单测)。 */
|
/** 解析专用 Json(companion 内独立持有,使 parseCheckin 可静态调用/单测)。 */
|
||||||
private val parseJson = Json { ignoreUnknownKeys = true }
|
private val parseJson = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
@ -605,43 +331,19 @@ class TraeRepository constructor(
|
|||||||
*/
|
*/
|
||||||
internal fun parseCheckin(body: String): Result<CheckinResult> = runCatching {
|
internal fun parseCheckin(body: String): Result<CheckinResult> = runCatching {
|
||||||
val root = parseJson.parseToJsonElement(body).jsonObject
|
val root = parseJson.parseToJsonElement(body).jsonObject
|
||||||
// data 可能为 null/非对象(如 {"code":0}),安全转换后退回根层
|
val data = root["data"]?.jsonObject ?: root
|
||||||
val data = root["data"] as? JsonObject ?: root
|
|
||||||
val code = data.numberOr("code")?.toLong() ?: root.numberOr("code")?.toLong() ?: -1L
|
val code = data.numberOr("code")?.toLong() ?: root.numberOr("code")?.toLong() ?: -1L
|
||||||
val msg = data.scalarOr("message", "msg") ?: root.scalarOr("message", "msg") ?: ""
|
val msg = data.scalarOr("message", "msg") ?: root.scalarOr("message", "msg") ?: ""
|
||||||
// points 与 credit 双候选(社区实测:claim 成功响应可能用 credit 字段)
|
val points = (data.numberOr("points") ?: root.numberOr("points"))?.toLong() ?: 0L
|
||||||
val points = (data.numberOr("points", "credit")
|
|
||||||
?: root.numberOr("points", "credit"))?.toLong() ?: 0L
|
|
||||||
|
|
||||||
val succeeded = code == 0L || code == 200L
|
val succeeded = code == 0L || code == 200L
|
||||||
val lowerMsg = msg.lowercase()
|
val lowerMsg = msg.lowercase()
|
||||||
val deviceBlock = msg.contains("设备") || lowerMsg.contains("device") || lowerMsg.contains("machine")
|
val deviceBlock = msg.contains("设备") || lowerMsg.contains("device") || lowerMsg.contains("machine")
|
||||||
// 权威口径幂等兜底码是 10001(trae-checkin-helper 逆向);历史实现用 1001,
|
val already = code == 1001L || (!deviceBlock && (
|
||||||
// 两者都认,避免把已签误判为 FAILED 硬错误。
|
|
||||||
val already = (code == 1001L || code == 10001L) || (!deviceBlock && (
|
|
||||||
msg.contains("已签到") || msg.contains("已经签到") || msg.contains("明日再来") ||
|
msg.contains("已签到") || msg.contains("已经签到") || msg.contains("明日再来") ||
|
||||||
msg.contains("今日已完成") || msg.contains("已领取") ||
|
msg.contains("今日已完成") || msg.contains("已领取") ||
|
||||||
lowerMsg.contains("already") || lowerMsg.contains("checked") || lowerMsg.contains("claimed")
|
lowerMsg.contains("already") || lowerMsg.contains("checked") || lowerMsg.contains("claimed")
|
||||||
))
|
))
|
||||||
// 服务端临时限流/排队(账号、设备或活动人数限制):提示可稍后重试,不算硬错误。
|
|
||||||
val retryable = !already && !deviceBlock && (
|
|
||||||
msg.contains("参与") || msg.contains("稍后重试") || msg.contains("火爆") ||
|
|
||||||
msg.contains("繁忙") || msg.contains("限流") || msg.contains("排队") ||
|
|
||||||
lowerMsg.contains("too many") || lowerMsg.contains("retry") || lowerMsg.contains("busy") ||
|
|
||||||
lowerMsg.contains("popular") || lowerMsg.contains("later")
|
|
||||||
)
|
|
||||||
|
|
||||||
DebugLog.i(
|
|
||||||
TAG,
|
|
||||||
"checkIn 解析: code=$code msg=$msg points=$points -> " +
|
|
||||||
when {
|
|
||||||
succeeded -> "SUCCESS"
|
|
||||||
already -> "ALREADY"
|
|
||||||
deviceBlock -> "DEVICE_BLOCKED"
|
|
||||||
retryable -> "RETRYABLE"
|
|
||||||
else -> "FAILED"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
when {
|
when {
|
||||||
succeeded -> Result.success(
|
succeeded -> Result.success(
|
||||||
@ -659,64 +361,12 @@ class TraeRepository constructor(
|
|||||||
deviceBlock -> Result.success(
|
deviceBlock -> Result.success(
|
||||||
CheckinResult(false, false, 0L, msg, CheckinOutcome.DEVICE_BLOCKED)
|
CheckinResult(false, false, 0L, msg, CheckinOutcome.DEVICE_BLOCKED)
|
||||||
)
|
)
|
||||||
retryable -> Result.success(
|
|
||||||
CheckinResult(false, false, 0L, msg, CheckinOutcome.RETRYABLE)
|
|
||||||
)
|
|
||||||
else -> Result.success(
|
else -> Result.success(
|
||||||
CheckinResult(false, false, 0L, msg, CheckinOutcome.FAILED, code)
|
CheckinResult(false, false, 0L, msg, CheckinOutcome.FAILED, code)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}.getOrElse { Result.failure(RepositoryError.Unknown(it)) }
|
}.getOrElse { Result.failure(RepositoryError.Unknown(it)) }
|
||||||
|
|
||||||
/** 官方签到接口路径(trae-checkin-helper 逆向口径)。 */
|
|
||||||
const val CHECKIN_STATUS_PATH = "/trae/api/v2/ug/checkin_credits/status"
|
|
||||||
const val CHECKIN_CLAIM_PATH = "/trae/api/v2/ug/checkin_credits/claim"
|
|
||||||
|
|
||||||
/** status 接口解析结果(字段缺失为 null;enable/checked_in 以布尔为权威)。 */
|
|
||||||
internal data class TraeStatusInfo(
|
|
||||||
val enable: Boolean?,
|
|
||||||
val checkedIn: Boolean?,
|
|
||||||
val credits: Double?,
|
|
||||||
val message: String
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 解析 status 响应(对齐 trae-checkin-helper 实测口径):
|
|
||||||
* 响应为平铺结构(code/enable/checked_in/credits 同层),个别版本包一层 data,兼容两种;
|
|
||||||
* 业务码非 0/200 或 enable/checked_in 非布尔 → 解析失败(调用方继续走 claim 兜底)。
|
|
||||||
*/
|
|
||||||
internal fun parseStatus(body: String): Result<TraeStatusInfo> = runCatching {
|
|
||||||
val root = parseJson.parseToJsonElement(body).jsonObject
|
|
||||||
val data = root["data"] as? JsonObject ?: root
|
|
||||||
val code = data.numberOr("code")?.toLong()
|
|
||||||
?: root.numberOr("code")?.toLong()
|
|
||||||
if (code != null && code != 0L && code != 200L) {
|
|
||||||
error("status 业务失败 code=$code")
|
|
||||||
}
|
|
||||||
val enable = data.boolOr("enable") ?: root.boolOr("enable")
|
|
||||||
val checkedIn = data.boolOr("checked_in") ?: root.boolOr("checked_in")
|
|
||||||
if (enable == null || checkedIn == null) {
|
|
||||||
error("status 响应缺 enable/checked_in(协议可能已变更)")
|
|
||||||
}
|
|
||||||
TraeStatusInfo(
|
|
||||||
enable = enable,
|
|
||||||
checkedIn = checkedIn,
|
|
||||||
credits = data.numberOr("credits") ?: root.numberOr("credits"),
|
|
||||||
message = data.scalarOr("message", "msg")
|
|
||||||
?: root.scalarOr("message", "msg") ?: ""
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 布尔字段解析(兼容原生 JSON boolean 与 "true"/"false" 字符串,非布尔返回 null)。 */
|
|
||||||
private fun JsonObject.boolOr(key: String): Boolean? {
|
|
||||||
val prim = this[key] as? JsonPrimitive ?: return null
|
|
||||||
return when (prim.content.lowercase()) {
|
|
||||||
"true" -> true
|
|
||||||
"false" -> false
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun JsonObject.scalarOr(vararg keys: String): String? {
|
private fun JsonObject.scalarOr(vararg keys: String): String? {
|
||||||
for (key in keys) {
|
for (key in keys) {
|
||||||
this[key]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }?.let { return it }
|
this[key]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }?.let { return it }
|
||||||
|
|||||||
@ -1,112 +0,0 @@
|
|||||||
package com.rainy.token.data.repository
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import com.rainy.token.BuildConfig
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import java.io.File
|
|
||||||
import java.io.IOException
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.Request
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 应用更新仓库:从 Gitea 仓库的 update.json 读取版本清单,
|
|
||||||
* 下载 APK 到应用缓存目录供 FileProvider 安装。
|
|
||||||
*/
|
|
||||||
@Singleton
|
|
||||||
class UpdateRepository @Inject constructor(
|
|
||||||
private val okHttpClient: OkHttpClient,
|
|
||||||
@ApplicationContext context: Context
|
|
||||||
) {
|
|
||||||
private val appContext = context.applicationContext
|
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
|
||||||
|
|
||||||
/** 检查是否有新版本。null = 已是最新;非空 = 远端有更新。 */
|
|
||||||
suspend fun checkForUpdate(): Result<UpdateInfo?> = withContext(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
val request = Request.Builder()
|
|
||||||
.url(UPDATE_MANIFEST_URL)
|
|
||||||
.get()
|
|
||||||
.build()
|
|
||||||
val text = okHttpClient.newCall(request).execute().use { resp ->
|
|
||||||
if (!resp.isSuccessful) {
|
|
||||||
return@withContext Result.failure(IOException("HTTP " + resp.code))
|
|
||||||
}
|
|
||||||
resp.body?.string().orEmpty()
|
|
||||||
}
|
|
||||||
val manifest = json.decodeFromString<UpdateManifest>(text)
|
|
||||||
val hasUpdate = manifest.versionCode > BuildConfig.VERSION_CODE
|
|
||||||
Result.success(if (hasUpdate) {
|
|
||||||
UpdateInfo(
|
|
||||||
versionCode = manifest.versionCode,
|
|
||||||
versionName = manifest.versionName,
|
|
||||||
apkUrl = manifest.apkUrl,
|
|
||||||
notes = manifest.notes
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
})
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Result.failure(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 下载 APK 到缓存目录。onProgress 回调下载字节数/总字节数(total 可能为 -1)。 */
|
|
||||||
suspend fun downloadApk(
|
|
||||||
info: UpdateInfo,
|
|
||||||
onProgress: (downloaded: Long, total: Long) -> Unit
|
|
||||||
): Result<File> = withContext(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
val dir = File(appContext.cacheDir, "apk_downloads").apply { mkdirs() }
|
|
||||||
val file = File(dir, "rainytoken-" + info.versionName + ".apk")
|
|
||||||
val request = Request.Builder().url(info.apkUrl).get().build()
|
|
||||||
okHttpClient.newCall(request).execute().use { resp ->
|
|
||||||
if (!resp.isSuccessful) {
|
|
||||||
return@withContext Result.failure(IOException("HTTP " + resp.code))
|
|
||||||
}
|
|
||||||
val body = resp.body ?: return@withContext Result.failure(IOException("empty body"))
|
|
||||||
val total = body.contentLength()
|
|
||||||
file.outputStream().buffered().use { output ->
|
|
||||||
body.byteStream().use { input ->
|
|
||||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
|
||||||
var downloaded = 0L
|
|
||||||
var read: Int
|
|
||||||
while (input.read(buffer).also { read = it } != -1) {
|
|
||||||
output.write(buffer, 0, read)
|
|
||||||
downloaded += read
|
|
||||||
onProgress(downloaded, total)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Result.success(file)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Result.failure(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class UpdateManifest(
|
|
||||||
val versionCode: Int,
|
|
||||||
val versionName: String,
|
|
||||||
val apkUrl: String,
|
|
||||||
val notes: String = ""
|
|
||||||
)
|
|
||||||
|
|
||||||
data class UpdateInfo(
|
|
||||||
val versionCode: Int,
|
|
||||||
val versionName: String,
|
|
||||||
val apkUrl: String,
|
|
||||||
val notes: String
|
|
||||||
)
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val UPDATE_MANIFEST_URL =
|
|
||||||
"http://124.222.97.212:8443/maya/Rainytoken/raw/branch/dev/update.json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -22,12 +22,6 @@ class WebViewSessionSaver @Inject constructor(
|
|||||||
private val credentialRepository: CredentialRepository
|
private val credentialRepository: CredentialRepository
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/** Sub2API 面板登录保存结果:凭据 + 实际写入的账号 ID(新建账号时返回新 ID)。 */
|
|
||||||
data class SavedSub2PanelSession(
|
|
||||||
val credential: Credential.Sub2ApiCredential,
|
|
||||||
val accountId: String
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 提取并保存 Cookie 列表。返回保存后的凭据。
|
* 提取并保存 Cookie 列表。返回保存后的凭据。
|
||||||
*/
|
*/
|
||||||
@ -63,19 +57,17 @@ class WebViewSessionSaver @Inject constructor(
|
|||||||
* @param authToken localStorage.auth_token 的值;为空表示没抓到,返回 null 不覆盖旧凭据
|
* @param authToken localStorage.auth_token 的值;为空表示没抓到,返回 null 不覆盖旧凭据
|
||||||
* @param siteIcon 站点 Logo(可选,登录页 favicon/og:image)
|
* @param siteIcon 站点 Logo(可选,登录页 favicon/og:image)
|
||||||
* @param email 登录邮箱(可选,仅用于展示与回退登录)
|
* @param email 登录邮箱(可选,仅用于展示与回退登录)
|
||||||
* @param accountId 目标账号(多账号服务;null=默认账号/新建账号)
|
|
||||||
*/
|
*/
|
||||||
suspend fun saveSub2ApiPanelSession(
|
suspend fun saveSub2ApiPanelSession(
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
authToken: String,
|
authToken: String,
|
||||||
siteIcon: String? = null,
|
siteIcon: String? = null,
|
||||||
email: String? = null,
|
email: String? = null
|
||||||
accountId: String? = null
|
): Credential.Sub2ApiCredential? {
|
||||||
): SavedSub2PanelSession? {
|
|
||||||
val token = authToken.trim()
|
val token = authToken.trim()
|
||||||
if (token.isEmpty()) return null
|
if (token.isEmpty()) return null
|
||||||
val base = normalizeBaseUrl(baseUrl) ?: return null
|
val base = normalizeBaseUrl(baseUrl) ?: return null
|
||||||
val existing = credentialRepository.get(ServiceType.SUB2API, accountId) as? Credential.Sub2ApiCredential
|
val existing = credentialRepository.get(ServiceType.SUB2API) as? Credential.Sub2ApiCredential
|
||||||
val credential = Credential.Sub2ApiCredential(
|
val credential = Credential.Sub2ApiCredential(
|
||||||
service = ServiceType.SUB2API,
|
service = ServiceType.SUB2API,
|
||||||
baseUrl = base,
|
baseUrl = base,
|
||||||
@ -86,37 +78,8 @@ class WebViewSessionSaver @Inject constructor(
|
|||||||
siteIcon = siteIcon?.takeIf { it.isNotBlank() } ?: existing?.siteIcon,
|
siteIcon = siteIcon?.takeIf { it.isNotBlank() } ?: existing?.siteIcon,
|
||||||
lastVerifiedAt = System.currentTimeMillis()
|
lastVerifiedAt = System.currentTimeMillis()
|
||||||
)
|
)
|
||||||
// 返回实际写入的账号 ID:新建账号 / 默认账号统一回填,避免 UI 停留在「新建」空表单
|
credentialRepository.save(credential)
|
||||||
val effectiveId = credentialRepository.save(credential, accountId)
|
return credential
|
||||||
return SavedSub2PanelSession(credential = credential, accountId = effectiveId)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* OpenCode Go 应用内登录成功后自动落库。
|
|
||||||
*
|
|
||||||
* WebView 里完成 GitHub/Google OAuth 后落到 dashboard,登录态表现为
|
|
||||||
* `auth` cookie + URL 中的 workspaceId——正是 [OpenCodeGoRepository] 需要的
|
|
||||||
* 两个凭据字段,用户不再需要手动开 DevTools 抄 cookie。
|
|
||||||
*
|
|
||||||
* @param authCookie dashboard 的 `auth` cookie 值(CookieManager 抓取)
|
|
||||||
* @param workspaceId dashboard URL `/workspace/{id}/go` 中的 id
|
|
||||||
*/
|
|
||||||
suspend fun saveOpenCodeGoSession(
|
|
||||||
authCookie: String,
|
|
||||||
workspaceId: String
|
|
||||||
): Credential.SessionCredential? {
|
|
||||||
val auth = authCookie.trim()
|
|
||||||
val ws = workspaceId.trim()
|
|
||||||
if (auth.isEmpty() || ws.isEmpty()) return null
|
|
||||||
// 与手动保存一致:基于既有凭据合并,保留 cookies/token/apiKey 等字段不丢
|
|
||||||
val existing = credentialRepository.get(ServiceType.OPENCODE_GO) as? Credential.SessionCredential
|
|
||||||
val session = (existing ?: Credential.SessionCredential(service = ServiceType.OPENCODE_GO)).copy(
|
|
||||||
authCookie = auth,
|
|
||||||
workspaceId = ws,
|
|
||||||
lastVerifiedAt = System.currentTimeMillis()
|
|
||||||
)
|
|
||||||
credentialRepository.save(session)
|
|
||||||
return session
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
package com.rainy.token.data.repository
|
package com.rainy.token.data.repository
|
||||||
|
|
||||||
import com.rainy.token.data.cache.BalanceCache
|
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.Credential
|
||||||
import com.rainy.token.domain.model.ServiceBalance
|
import com.rainy.token.domain.model.ServiceBalance
|
||||||
import com.rainy.token.domain.service.ServiceType
|
import com.rainy.token.domain.service.ServiceType
|
||||||
@ -64,8 +63,8 @@ class WorkBuddyRepository constructor(
|
|||||||
put("QueryResourceId", buildJsonArray { })
|
put("QueryResourceId", buildJsonArray { })
|
||||||
}.toString()
|
}.toString()
|
||||||
|
|
||||||
suspend fun fetchBalance(accountId: String? = null): Result<ServiceBalance> = withContext(Dispatchers.IO) {
|
suspend fun fetchBalance(): Result<ServiceBalance> = withContext(Dispatchers.IO) {
|
||||||
val credential = credentialRepository.get(ServiceType.WORKBUDDY, accountId)
|
val credential = credentialRepository.get(ServiceType.WORKBUDDY)
|
||||||
?: return@withContext Result.failure(RepositoryError.InvalidCredential())
|
?: return@withContext Result.failure(RepositoryError.InvalidCredential())
|
||||||
if (credential !is Credential.WorkBuddyCredential) {
|
if (credential !is Credential.WorkBuddyCredential) {
|
||||||
return@withContext Result.failure(RepositoryError.InvalidCredential())
|
return@withContext Result.failure(RepositoryError.InvalidCredential())
|
||||||
@ -114,8 +113,7 @@ class WorkBuddyRepository constructor(
|
|||||||
} else {
|
} else {
|
||||||
credential.expiresAt.coerceAtLeast(System.currentTimeMillis())
|
credential.expiresAt.coerceAtLeast(System.currentTimeMillis())
|
||||||
}
|
}
|
||||||
),
|
)
|
||||||
accountId
|
|
||||||
)
|
)
|
||||||
access = refreshed.accessToken
|
access = refreshed.accessToken
|
||||||
refresh = refreshed.refreshToken
|
refresh = refreshed.refreshToken
|
||||||
@ -139,15 +137,6 @@ class WorkBuddyRepository constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 细粒度账户明细(官网个人中心同源数据):每个资源包的已用/总量/剩余/到期时间。
|
|
||||||
// 独立请求,失败仅降级(保留 summary 的简化 extras),不影响余额主流程。
|
|
||||||
val detailExtras = runCatching { fetchResourceDetailExtras(access) }.getOrNull()
|
|
||||||
val mergedExtras = if (detailExtras != null) {
|
|
||||||
parsed.extras + detailExtras
|
|
||||||
} else {
|
|
||||||
parsed.extras
|
|
||||||
}
|
|
||||||
|
|
||||||
val balance = ServiceBalance(
|
val balance = ServiceBalance(
|
||||||
service = ServiceType.WORKBUDDY,
|
service = ServiceType.WORKBUDDY,
|
||||||
amount = parsed.amount,
|
amount = parsed.amount,
|
||||||
@ -155,37 +144,13 @@ class WorkBuddyRepository constructor(
|
|||||||
monthlySpent = parsed.used.takeIf { it > 0 },
|
monthlySpent = parsed.used.takeIf { it > 0 },
|
||||||
totalQuota = parsed.size.takeIf { it > 0 },
|
totalQuota = parsed.size.takeIf { it > 0 },
|
||||||
isAvailable = parsed.amount > 0 || parsed.size > 0,
|
isAvailable = parsed.amount > 0 || parsed.size > 0,
|
||||||
extras = mergedExtras
|
extras = parsed.extras
|
||||||
)
|
)
|
||||||
balanceCache.put(ServiceType.WORKBUDDY, balance, accountId)
|
balanceCache.put(ServiceType.WORKBUDDY, balance)
|
||||||
credentialRepository.save(credential.copy(lastVerifiedAt = System.currentTimeMillis()), accountId)
|
credentialRepository.save(credential.copy(lastVerifiedAt = System.currentTimeMillis()))
|
||||||
Result.success(balance)
|
Result.success(balance)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 拉取细粒度账户明细并转成 extras 增量([KEY_DETAIL_RAW] / [KEY_PLAN_CODE])。
|
|
||||||
* 请求失败或解析失败返回 null(调用方降级到 summary 的简化 extras)。
|
|
||||||
*/
|
|
||||||
private fun fetchResourceDetailExtras(accessToken: String): Map<String, String>? {
|
|
||||||
val request = Request.Builder()
|
|
||||||
.url("https://www.codebuddy.cn/v2/billing/meter/get-user-resource")
|
|
||||||
.addHeader("Authorization", "Bearer $accessToken")
|
|
||||||
.addHeader("Accept", "application/json")
|
|
||||||
.addHeader("Content-Type", "application/json")
|
|
||||||
.post(codebuddyBody.toRequestBody(jsonMedia))
|
|
||||||
.build()
|
|
||||||
val body = try {
|
|
||||||
okHttpClient.newCall(request).execute().use { resp ->
|
|
||||||
if (!resp.isSuccessful) return null
|
|
||||||
resp.body?.string().orEmpty()
|
|
||||||
}
|
|
||||||
} catch (_: Throwable) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
if (body.isBlank()) return null
|
|
||||||
return parseResourceDetail(body)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** workbuddy.cn 官网个人中心实测端点(2026-09 确认 200)。 */
|
/** workbuddy.cn 官网个人中心实测端点(2026-09 确认 200)。 */
|
||||||
private fun summaryRequest(accessToken: String): Request =
|
private fun summaryRequest(accessToken: String): Request =
|
||||||
Request.Builder()
|
Request.Builder()
|
||||||
@ -240,71 +205,7 @@ class WorkBuddyRepository constructor(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 解析 Packages/Accounts:CycleRemain/Used/TotalCapacity(多候选)。无任何包/数据时抛异常。 */
|
||||||
* 解析 codebuddy.cn `/v2/billing/meter/get-user-resource` 的细粒度账户明细。
|
|
||||||
*
|
|
||||||
* 响应:`data.Response.Data.Accounts[]`,每个账户是一个独立资源包,字段含
|
|
||||||
* PackageCode / PackageName / CycleCapacityRemainPrecise / CycleCapacityUsedPrecise /
|
|
||||||
* CycleCapacitySizePrecise / CycleStartTime / CycleEndTime / Status / ExpiredTime。
|
|
||||||
*
|
|
||||||
* 按 PackageCode 分组:n==1 且非运营裂变包 → 套餐积分;其余(n>1 的裂变包)→ 平台奖励积分。
|
|
||||||
* 返回 extras 增量(键见 [KEY_DETAIL_RAW])+ 是否解析成功。失败时调用方保留 summary 的 extras。
|
|
||||||
*/
|
|
||||||
private fun parseResourceDetail(body: String): Map<String, String>? {
|
|
||||||
val root = runCatching { json.parseToJsonElement(body).jsonObject }.getOrNull() ?: return null
|
|
||||||
val data = root["data"]?.jsonObject?.let { d ->
|
|
||||||
(d["Response"] as? JsonObject)?.let { r -> (r["Data"] as? JsonObject) ?: d } ?: d
|
|
||||||
} ?: root
|
|
||||||
val accounts = data["Accounts"]?.jsonArray ?: return null
|
|
||||||
val list = accounts.mapNotNull { it.jsonObject.takeIf { o -> o.isNotEmpty() } }
|
|
||||||
if (list.isEmpty()) return null
|
|
||||||
|
|
||||||
val out = linkedMapOf<String, String>()
|
|
||||||
val detail = buildJsonArray {
|
|
||||||
for (a in list) {
|
|
||||||
val code = a.scalar("PackageCode") ?: continue
|
|
||||||
val name = a.scalar("PackageName") ?: code
|
|
||||||
val remain = a.scalar("CycleCapacityRemainPrecise")
|
|
||||||
?: a.number("CycleCapacityRemain", "CapacityRemainPrecise", "CapacityRemain")?.let { trimNum(it) }
|
|
||||||
?: continue
|
|
||||||
val used = a.scalar("CycleCapacityUsedPrecise")
|
|
||||||
?: a.number("CycleCapacityUsed", "CapacityUsedPrecise", "CapacityUsed")?.let { trimNum(it) }
|
|
||||||
?: "0"
|
|
||||||
val total = a.scalar("CycleCapacitySizePrecise")
|
|
||||||
?: a.number("CycleCapacitySize", "CapacitySizePrecise", "CapacitySize")?.let { trimNum(it) }
|
|
||||||
?: "0"
|
|
||||||
val cycleEnd = a.scalar("CycleEndTime")?.takeIf { it.isNotBlank() }
|
|
||||||
val expiredAt = a.scalar("ExpiredTime")?.takeIf { it.isNotBlank() }
|
|
||||||
val status = a.number("Status")?.toInt()
|
|
||||||
add(buildJsonObject {
|
|
||||||
put("code", JsonPrimitive(code))
|
|
||||||
put("name", JsonPrimitive(name))
|
|
||||||
put("remain", JsonPrimitive(remain))
|
|
||||||
put("used", JsonPrimitive(used))
|
|
||||||
put("total", JsonPrimitive(total))
|
|
||||||
put("cycleEnd", JsonPrimitive(cycleEnd ?: ""))
|
|
||||||
put("expiredAt", JsonPrimitive(expiredAt ?: ""))
|
|
||||||
put("status", JsonPrimitive(status ?: -1))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (detail.isEmpty()) return null
|
|
||||||
out[KEY_DETAIL_RAW] = detail.toString()
|
|
||||||
|
|
||||||
// 分组:每个 PackageCode 的账户数 + 包名
|
|
||||||
val groups = list.groupBy { it.scalar("PackageCode").orEmpty() }
|
|
||||||
.filterKeys { it.isNotBlank() }
|
|
||||||
// 套餐候选:账户数最少且不是「裂变/奖励」字样的包;奖励池:账户数多或名字带裂变/奖励
|
|
||||||
val planCode = groups.entries
|
|
||||||
.filter { (_, v) -> v.size == 1 }
|
|
||||||
.filter { (k, _) -> list.firstOrNull { it.scalar("PackageCode") == k }
|
|
||||||
?.scalar("PackageName")?.let { n -> !n.contains("裂变") && !n.contains("奖励") } == true }
|
|
||||||
.minByOrNull { it.key }?.key
|
|
||||||
if (planCode != null) out[KEY_PLAN_CODE] = planCode
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 解析 Packages/Accounts:CycleRemain/Used/TotalCapacity(多候选)。无任何包/数据时抛异常。 */
|
|
||||||
private fun parseResource(body: String): ParsedWorkBuddy {
|
private fun parseResource(body: String): ParsedWorkBuddy {
|
||||||
val root = json.parseToJsonElement(body).jsonObject
|
val root = json.parseToJsonElement(body).jsonObject
|
||||||
val data = root["data"]?.jsonObject ?: root
|
val data = root["data"]?.jsonObject ?: root
|
||||||
@ -320,9 +221,6 @@ private fun parseResourceDetail(body: String): Map<String, String>? {
|
|||||||
var frozen = 0.0
|
var frozen = 0.0
|
||||||
var unitRaw: String? = null
|
var unitRaw: String? = null
|
||||||
val extras = linkedMapOf<String, String>()
|
val extras = linkedMapOf<String, String>()
|
||||||
// 结构化包明细(名称/编码/已用/总量/剩余/到期/周期重置),详情页按官方语义分组展示。
|
|
||||||
// 键名带 raw 以复用通用卡的「技术键过滤」,不被用量页/通用明细卡当普通键展示。
|
|
||||||
val detail = buildJsonArray {
|
|
||||||
for (item in list.take(20)) {
|
for (item in list.take(20)) {
|
||||||
val remain = item.number("CycleRemainCapacity", "CycleCapacityRemain", "Remain", "balance") ?: 0.0
|
val remain = item.number("CycleRemainCapacity", "CycleCapacityRemain", "Remain", "balance") ?: 0.0
|
||||||
val u = item.number("CycleUsedCapacity", "CycleCapacityUsed", "Used") ?: 0.0
|
val u = item.number("CycleUsedCapacity", "CycleCapacityUsed", "Used") ?: 0.0
|
||||||
@ -337,37 +235,12 @@ private fun parseResourceDetail(body: String): Map<String, String>? {
|
|||||||
if (!name.isNullOrBlank() && extras.size < 3) {
|
if (!name.isNullOrBlank() && extras.size < 3) {
|
||||||
extras[name] = "${trimNum(remain)} ${normalizeUnit(unitRaw) ?: "Credits"}"
|
extras[name] = "${trimNum(remain)} ${normalizeUnit(unitRaw) ?: "Credits"}"
|
||||||
}
|
}
|
||||||
add(buildJsonObject {
|
|
||||||
put("name", JsonPrimitive(name ?: ""))
|
|
||||||
put("code", JsonPrimitive(item.scalar("PackageCode", "ResourceCode", "Code") ?: ""))
|
|
||||||
put("remain", JsonPrimitive(trimNum(remain)))
|
|
||||||
put("used", JsonPrimitive(trimNum(u)))
|
|
||||||
put("total", JsonPrimitive(trimNum(s)))
|
|
||||||
put(
|
|
||||||
"expireAt",
|
|
||||||
JsonPrimitive(
|
|
||||||
item.timeField("ExpireTime", "ExpireAt", "ExpiredAt", "InvalidTime", "ExpireDate") ?: ""
|
|
||||||
)
|
|
||||||
)
|
|
||||||
put(
|
|
||||||
"cycleReset",
|
|
||||||
JsonPrimitive(
|
|
||||||
item.timeField("NextResetTime", "CycleResetTime", "NextCycleTime", "ResetTime", "ResetAt") ?: ""
|
|
||||||
)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (list.size > 0) extras["packages"] = list.size.toString()
|
if (list.size > 0) extras["packages"] = list.size.toString()
|
||||||
if (frozen > 0) extras["frozen"] = trimNum(frozen)
|
if (frozen > 0) extras["frozen"] = trimNum(frozen)
|
||||||
extras["wb_detail_raw"] = detail.toString()
|
|
||||||
data["IsPaidUser"]?.jsonPrimitive?.contentOrNull?.let { extras["is_paid_user"] = it }
|
data["IsPaidUser"]?.jsonPrimitive?.contentOrNull?.let { extras["is_paid_user"] = it }
|
||||||
data["SubscriptionPackageCode"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }
|
data["SubscriptionPackageCode"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }
|
||||||
?.let {
|
?.let { extras["subscription"] = "true" }
|
||||||
extras["subscription"] = "true"
|
|
||||||
// 订阅套餐的包编码:详情页据此把该包归入「套餐积分」区,其余归「平台奖励积分」
|
|
||||||
extras["subscription_code"] = it
|
|
||||||
}
|
|
||||||
|
|
||||||
return ParsedWorkBuddy(
|
return ParsedWorkBuddy(
|
||||||
amount = amount,
|
amount = amount,
|
||||||
@ -407,27 +280,6 @@ private fun parseResourceDetail(body: String): Map<String, String>? {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 时间字段读取:兼容字符串日期(原样返回)与秒/毫秒时间戳(格式化为本地 yyyy-MM-dd HH:mm:ss)。
|
|
||||||
* 找不到或解析失败返回 null(展示层隐藏该行)。
|
|
||||||
*/
|
|
||||||
private fun JsonObject.timeField(vararg keys: String): String? {
|
|
||||||
for (key in keys) {
|
|
||||||
val prim = this[key]?.jsonPrimitive ?: continue
|
|
||||||
val text = prim.contentOrNull?.takeIf { it.isNotBlank() } ?: continue
|
|
||||||
prim.doubleOrNull?.takeIf { it > 0 }?.let { epoch ->
|
|
||||||
val millis = if (epoch > 1e12) epoch.toLong() else (epoch * 1000).toLong()
|
|
||||||
return runCatching {
|
|
||||||
java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.getDefault())
|
|
||||||
.format(java.util.Date(millis))
|
|
||||||
}.getOrNull()
|
|
||||||
}
|
|
||||||
// 纯字符串日期(如 "2026-10-01 16:32:41")直接返回
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- 每日签到(Buddy 加油站 / codebuddy.cn)----------
|
// ---------- 每日签到(Buddy 加油站 / codebuddy.cn)----------
|
||||||
|
|
||||||
/** 签到动作语义结果(UI 层据此本地化,不直接展示 Repository 中文串)。 */
|
/** 签到动作语义结果(UI 层据此本地化,不直接展示 Repository 中文串)。 */
|
||||||
@ -460,8 +312,8 @@ private fun parseResourceDetail(body: String): Map<String, String>? {
|
|||||||
)
|
)
|
||||||
|
|
||||||
/** 查询今日签到状态。uid 未知时省略 X-User-Id 头(源码确认该头可选)。 */
|
/** 查询今日签到状态。uid 未知时省略 X-User-Id 头(源码确认该头可选)。 */
|
||||||
suspend fun fetchCheckinStatus(accountId: String? = null): Result<CheckinStatus> = withContext(Dispatchers.IO) {
|
suspend fun fetchCheckinStatus(): Result<CheckinStatus> = withContext(Dispatchers.IO) {
|
||||||
val credential = credentialRepository.get(ServiceType.WORKBUDDY, accountId)
|
val credential = credentialRepository.get(ServiceType.WORKBUDDY)
|
||||||
?: return@withContext Result.failure(RepositoryError.InvalidCredential())
|
?: return@withContext Result.failure(RepositoryError.InvalidCredential())
|
||||||
if (credential !is Credential.WorkBuddyCredential) {
|
if (credential !is Credential.WorkBuddyCredential) {
|
||||||
return@withContext Result.failure(RepositoryError.InvalidCredential())
|
return@withContext Result.failure(RepositoryError.InvalidCredential())
|
||||||
@ -470,7 +322,6 @@ private fun parseResourceDetail(body: String): Map<String, String>? {
|
|||||||
// 共享轮换状态:前一条路径轮换出的新 token 会用于下一条,且失败也先落库。
|
// 共享轮换状态:前一条路径轮换出的新 token 会用于下一条,且失败也先落库。
|
||||||
val body = authedPostFirstSuccessful(
|
val body = authedPostFirstSuccessful(
|
||||||
credential,
|
credential,
|
||||||
accountId,
|
|
||||||
"/v2/billing/meter/checkin-activity-status",
|
"/v2/billing/meter/checkin-activity-status",
|
||||||
"/v2/billing/meter/checkin-status"
|
"/v2/billing/meter/checkin-status"
|
||||||
) ?: return@withContext Result.failure(RepositoryError.Unknown())
|
) ?: return@withContext Result.failure(RepositoryError.Unknown())
|
||||||
@ -491,67 +342,49 @@ private fun parseResourceDetail(body: String): Map<String, String>? {
|
|||||||
* 执行今日签到(幂等:code=0 成功 / code=10001 已签,两种都算成功;源码确认 HTTP 400 也可能是已签)。
|
* 执行今日签到(幂等:code=0 成功 / code=10001 已签,两种都算成功;源码确认 HTTP 400 也可能是已签)。
|
||||||
* 成功/已签后刷新一次状态并附带 message 提示。
|
* 成功/已签后刷新一次状态并附带 message 提示。
|
||||||
*/
|
*/
|
||||||
suspend fun checkIn(accountId: String? = null): Result<CheckinStatus> = withContext(Dispatchers.IO) {
|
suspend fun checkIn(): Result<CheckinStatus> = withContext(Dispatchers.IO) {
|
||||||
val credential = credentialRepository.get(ServiceType.WORKBUDDY, accountId)
|
val credential = credentialRepository.get(ServiceType.WORKBUDDY)
|
||||||
?: return@withContext Result.failure(RepositoryError.InvalidCredential())
|
?: return@withContext Result.failure(RepositoryError.InvalidCredential())
|
||||||
if (credential !is Credential.WorkBuddyCredential) {
|
if (credential !is Credential.WorkBuddyCredential) {
|
||||||
return@withContext Result.failure(RepositoryError.InvalidCredential())
|
return@withContext Result.failure(RepositoryError.InvalidCredential())
|
||||||
}
|
}
|
||||||
val body = when (val r = authedPost(credential, "/v2/billing/meter/daily-checkin")) {
|
val body = when (val r = authedPost(credential, "/v2/billing/meter/daily-checkin")) {
|
||||||
is AuthedPostResult.Ok -> {
|
is AuthedPostResult.Ok -> {
|
||||||
persistCheckinOutcome(credential, accountId, r.rotatedCredential)
|
persistCheckinOutcome(credential, r.rotatedCredential)
|
||||||
r.body
|
r.body
|
||||||
}
|
}
|
||||||
is AuthedPostResult.Failed -> {
|
is AuthedPostResult.Failed -> {
|
||||||
// 即便签到请求失败,也必须先落库轮换出的新 token,否则账号卡在死 token 上;
|
// 即便签到请求失败,也必须先落库轮换出的新 token,否则账号卡在死 token 上;
|
||||||
// 但未发生轮换(纯网络失败)时不写入——避免把失败请求误标为「刚验证通过」。
|
// 但未发生轮换(纯网络失败)时不写入——避免把失败请求误标为「刚验证通过」。
|
||||||
if (r.rotatedCredential != null) {
|
if (r.rotatedCredential != null) {
|
||||||
persistCheckinOutcome(credential, accountId, r.rotatedCredential)
|
persistCheckinOutcome(credential, r.rotatedCredential)
|
||||||
}
|
}
|
||||||
// HTTP 400 也可能是业务失败且 body 携带签到语义(如已签),透传交给下方解析。
|
|
||||||
if (r.body.isBlank()) {
|
|
||||||
return@withContext Result.failure(RepositoryError.Unknown())
|
return@withContext Result.failure(RepositoryError.Unknown())
|
||||||
}
|
}
|
||||||
r.body
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// 签到响应为官方 envelope:{"code":0,"msg":"...","data":{...}},code/msg 在根层,
|
|
||||||
// data 为可选明细。必须根层优先 + data 层兜底——否则 data 存在时根层 code 被漏读
|
|
||||||
// 成 -1,导致「实际签到成功却显示失败」(2026-10 实测回归)。
|
|
||||||
// 整体包 runCatching 兜底非法 JSON/"data":null 等边界,避免强转异常冒泡。
|
|
||||||
val code = runCatching {
|
val code = runCatching {
|
||||||
val r = json.parseToJsonElement(body).jsonObject
|
val root = json.parseToJsonElement(body).jsonObject
|
||||||
r.scalar("code", "Code")?.toLongOrNull()
|
val obj = root["data"]?.jsonObject ?: root
|
||||||
?: (r["data"] as? JsonObject)?.scalar("code", "Code")?.toLongOrNull()
|
obj.scalar("code", "Code")?.toLongOrNull() ?: -1L
|
||||||
}.getOrNull() ?: -1L
|
}.getOrDefault(-1L)
|
||||||
val msg = runCatching {
|
val status = fetchCheckinStatus().getOrNull()
|
||||||
val r = json.parseToJsonElement(body).jsonObject
|
|
||||||
r.scalar("msg", "message", "Message")
|
|
||||||
?: (r["data"] as? JsonObject)?.scalar("msg", "message", "Message")
|
|
||||||
}.getOrNull().orEmpty()
|
|
||||||
val status = fetchCheckinStatus(accountId).getOrNull()
|
|
||||||
// 已签到语义与上游 cpa-multi-plugins 对齐:code=10001 或文案含「已签/今日/already」。
|
|
||||||
val lowerMsg = msg.lowercase()
|
|
||||||
val already = code == 10001L || msg.contains("已签") || msg.contains("已经签到") ||
|
|
||||||
msg.contains("今日") || lowerMsg.contains("already") || lowerMsg.contains("checked") ||
|
|
||||||
lowerMsg.contains("claimed")
|
|
||||||
val statusInfo = if (status != null) " checked=${status.todayCheckedIn}/streak=${status.streakDays}" else ""
|
|
||||||
DebugLog.i(
|
|
||||||
TAG,
|
|
||||||
"checkIn 判定: code=$code msg=$msg already=$already statusOk=${status != null}$statusInfo"
|
|
||||||
)
|
|
||||||
when {
|
when {
|
||||||
code == 0L -> Result.success(
|
code == 0L -> Result.success(
|
||||||
(status ?: CheckinStatus(true, true, 1, 0, 0, "")).copy(
|
(status ?: CheckinStatus(true, true, 1, 0, 0, "")).copy(
|
||||||
outcome = CheckinOutcome.SUCCESS
|
outcome = CheckinOutcome.SUCCESS
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
already -> Result.success(
|
code == 10001L -> Result.success(
|
||||||
(status ?: CheckinStatus(true, true, 0, 0, 0, "")).copy(
|
(status ?: CheckinStatus(true, true, 0, 0, 0, "")).copy(
|
||||||
outcome = CheckinOutcome.ALREADY
|
outcome = CheckinOutcome.ALREADY
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
else -> {
|
else -> {
|
||||||
|
val msg = runCatching {
|
||||||
|
val root = json.parseToJsonElement(body).jsonObject
|
||||||
|
val obj = root["data"]?.jsonObject ?: root
|
||||||
|
obj.scalar("msg", "message", "Message") ?: ""
|
||||||
|
}.getOrDefault("")
|
||||||
if (status != null) {
|
if (status != null) {
|
||||||
Result.success(status.copy(message = msg, outcome = CheckinOutcome.FAILED))
|
Result.success(status.copy(message = msg, outcome = CheckinOutcome.FAILED))
|
||||||
} else {
|
} else {
|
||||||
@ -577,11 +410,9 @@ private fun parseResourceDetail(body: String): Map<String, String>? {
|
|||||||
override val rotatedCredential: Credential.WorkBuddyCredential?
|
override val rotatedCredential: Credential.WorkBuddyCredential?
|
||||||
) : AuthedPostResult
|
) : AuthedPostResult
|
||||||
|
|
||||||
/** 请求失败,但过程中发生过 token 轮换(新凭据必须落库)。[body] 携带业务失败响应体
|
/** 请求失败,但过程中发生过 token 轮换(新凭据必须落库)。 */
|
||||||
* (如 HTTP 400 也返回签到语义),可能为空。 */
|
|
||||||
data class Failed(
|
data class Failed(
|
||||||
override val rotatedCredential: Credential.WorkBuddyCredential?,
|
override val rotatedCredential: Credential.WorkBuddyCredential?
|
||||||
val body: String = ""
|
|
||||||
) : AuthedPostResult
|
) : AuthedPostResult
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -603,15 +434,12 @@ private fun parseResourceDetail(body: String): Map<String, String>? {
|
|||||||
.build()
|
.build()
|
||||||
val resp = try {
|
val resp = try {
|
||||||
okHttpClient.newCall(request).execute()
|
okHttpClient.newCall(request).execute()
|
||||||
} catch (e: Throwable) {
|
} catch (_: Throwable) {
|
||||||
DebugLog.w(TAG, "authedPost 异常: path=$path err=${e::class.simpleName}: ${e.message}")
|
|
||||||
return AuthedPostResult.Failed(rotated)
|
return AuthedPostResult.Failed(rotated)
|
||||||
}
|
}
|
||||||
val code = resp.code
|
val code = resp.code
|
||||||
val body = runCatching { resp.body?.string().orEmpty() }.getOrDefault("")
|
val body = runCatching { resp.body?.string().orEmpty() }.getOrDefault("")
|
||||||
resp.close()
|
resp.close()
|
||||||
// 本地调试日志:记录请求路径/HTTP 码/响应摘要,不记录 Authorization(敏感)。
|
|
||||||
DebugLog.i(TAG, "authedPost: path=$path attempt=$attempt http=$code body=${body.take(200)}")
|
|
||||||
when {
|
when {
|
||||||
code in 200..299 -> return AuthedPostResult.Ok(body, rotated)
|
code in 200..299 -> return AuthedPostResult.Ok(body, rotated)
|
||||||
(code == 401 || code == 403) && attempt == 0 -> {
|
(code == 401 || code == 403) && attempt == 0 -> {
|
||||||
@ -629,7 +457,7 @@ private fun parseResourceDetail(body: String): Map<String, String>? {
|
|||||||
access = refreshed.accessToken
|
access = refreshed.accessToken
|
||||||
refresh = refreshed.refreshToken
|
refresh = refreshed.refreshToken
|
||||||
}
|
}
|
||||||
else -> return AuthedPostResult.Failed(rotated, body)
|
else -> return AuthedPostResult.Failed(rotated)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return AuthedPostResult.Failed(rotated)
|
return AuthedPostResult.Failed(rotated)
|
||||||
@ -642,20 +470,19 @@ private fun parseResourceDetail(body: String): Map<String, String>? {
|
|||||||
*/
|
*/
|
||||||
private suspend fun authedPostFirstSuccessful(
|
private suspend fun authedPostFirstSuccessful(
|
||||||
credential: Credential.WorkBuddyCredential,
|
credential: Credential.WorkBuddyCredential,
|
||||||
accountId: String?,
|
|
||||||
vararg paths: String
|
vararg paths: String
|
||||||
): String? {
|
): String? {
|
||||||
var current = credential
|
var current = credential
|
||||||
for (path in paths) {
|
for (path in paths) {
|
||||||
when (val r = authedPost(current, path)) {
|
when (val r = authedPost(current, path)) {
|
||||||
is AuthedPostResult.Ok -> {
|
is AuthedPostResult.Ok -> {
|
||||||
persistCheckinOutcome(credential, accountId, r.rotatedCredential)
|
persistCheckinOutcome(credential, r.rotatedCredential)
|
||||||
return r.body
|
return r.body
|
||||||
}
|
}
|
||||||
is AuthedPostResult.Failed -> {
|
is AuthedPostResult.Failed -> {
|
||||||
// 即便本路径失败,也要先把轮换出的新 token 落库并继续用新 token 试下一条
|
// 即便本路径失败,也要先把轮换出的新 token 落库并继续用新 token 试下一条
|
||||||
if (r.rotatedCredential != null) {
|
if (r.rotatedCredential != null) {
|
||||||
persistCheckinOutcome(credential, accountId, r.rotatedCredential)
|
persistCheckinOutcome(credential, r.rotatedCredential)
|
||||||
current = r.rotatedCredential
|
current = r.rotatedCredential
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -670,11 +497,10 @@ private fun parseResourceDetail(body: String): Map<String, String>? {
|
|||||||
*/
|
*/
|
||||||
private suspend fun persistCheckinOutcome(
|
private suspend fun persistCheckinOutcome(
|
||||||
credential: Credential.WorkBuddyCredential,
|
credential: Credential.WorkBuddyCredential,
|
||||||
accountId: String?,
|
|
||||||
rotated: Credential.WorkBuddyCredential?
|
rotated: Credential.WorkBuddyCredential?
|
||||||
) {
|
) {
|
||||||
val base = rotated ?: credential
|
val base = rotated ?: credential
|
||||||
credentialRepository.save(base.copy(lastVerifiedAt = System.currentTimeMillis()), accountId)
|
credentialRepository.save(base.copy(lastVerifiedAt = System.currentTimeMillis()))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun mapHttpError(code: Int, body: String): RepositoryError = when (code) {
|
private fun mapHttpError(code: Int, body: String): RepositoryError = when (code) {
|
||||||
@ -700,14 +526,6 @@ private fun parseResourceDetail(body: String): Map<String, String>? {
|
|||||||
)
|
)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val TAG = "WorkBuddy"
|
|
||||||
|
|
||||||
/** extras 键:细粒度账户明细 JSON(结构见 [parseResourceDetail]),详情页消费。 */
|
|
||||||
const val KEY_DETAIL_RAW = "wb_detail_raw"
|
|
||||||
|
|
||||||
/** extras 键:套餐积分的 PackageCode(详情页据此区分套餐 vs 奖励积分)。 */
|
|
||||||
const val KEY_PLAN_CODE = "wb_plan_code"
|
|
||||||
|
|
||||||
/** 解析专用 Json(companion 内独立持有,使 parseCheckin 可静态调用/单测)。 */
|
/** 解析专用 Json(companion 内独立持有,使 parseCheckin 可静态调用/单测)。 */
|
||||||
private val parseJson = Json { ignoreUnknownKeys = true }
|
private val parseJson = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
|||||||
@ -19,7 +19,6 @@ import com.rainy.token.data.repository.OpenCodeUsageRepository
|
|||||||
import com.rainy.token.data.repository.OllamaRepository
|
import com.rainy.token.data.repository.OllamaRepository
|
||||||
import com.rainy.token.data.repository.Sub2ApiRepository
|
import com.rainy.token.data.repository.Sub2ApiRepository
|
||||||
import com.rainy.token.data.repository.TraeRepository
|
import com.rainy.token.data.repository.TraeRepository
|
||||||
import com.rainy.token.data.repository.UpdateRepository
|
|
||||||
import com.rainy.token.data.repository.WorkBuddyRepository
|
import com.rainy.token.data.repository.WorkBuddyRepository
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
@ -205,16 +204,6 @@ object NetworkModule {
|
|||||||
/**
|
/**
|
||||||
* Sub2API 仓库:自托管实例(凭据里的 baseUrl)+ 管理面板 API。
|
* Sub2API 仓库:自托管实例(凭据里的 baseUrl)+ 管理面板 API。
|
||||||
*/
|
*/
|
||||||
/**
|
|
||||||
* 更新仓库:读取 Gitea 上的 update.json 并下载 APK。
|
|
||||||
*/
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideUpdateRepository(
|
|
||||||
okHttpClient: OkHttpClient,
|
|
||||||
@ApplicationContext context: Context
|
|
||||||
): UpdateRepository = UpdateRepository(okHttpClient, context)
|
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideSub2ApiRepository(
|
fun provideSub2ApiRepository(
|
||||||
|
|||||||
@ -66,13 +66,6 @@ sealed class Credential {
|
|||||||
/**
|
/**
|
||||||
* 用于 Trae(CN/Intl):官方 IDE 扩展使用的 Cloud-IDE-JWT。
|
* 用于 Trae(CN/Intl):官方 IDE 扩展使用的 Cloud-IDE-JWT。
|
||||||
* 单个 JWT(~14 天 RS256),配 [region] 决定域名与请求头。
|
* 单个 JWT(~14 天 RS256),配 [region] 决定域名与请求头。
|
||||||
*
|
|
||||||
* [deviceId] 为本 App 持久化生成的每机唯一设备 ID(与登录授权 URL 中
|
|
||||||
* device_id 一致,即 OAuth 会话绑定的设备)。Trae claim 签到接口按 x-device-id
|
|
||||||
* 做设备级每日去重/风控,必须用与登录会话一致的设备 ID;不可用 userJwt.ClientID
|
|
||||||
* (那是固定客户端标识,所有用户相同,会被服务端按设备风控拒绝)。
|
|
||||||
* [userId] 为 OAuth 用户 ID(回调 userInfo),签到按 trae-check 口径拼
|
|
||||||
* `${deviceId}-${userId}`。旧凭据无这些字段时退回由 JWT 派生的稳定伪 ID。
|
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class TraeCredential(
|
data class TraeCredential(
|
||||||
@ -81,19 +74,6 @@ sealed class Credential {
|
|||||||
val jwt: String,
|
val jwt: String,
|
||||||
/** 区域:"CN"(api.trae.cn)/ "INTL"(grow-normal.trae.ai) */
|
/** 区域:"CN"(api.trae.cn)/ "INTL"(grow-normal.trae.ai) */
|
||||||
val region: String = "CN",
|
val region: String = "CN",
|
||||||
/** 登录会话绑定的设备 ID(App 持久化每机唯一),null=旧凭据未保存 */
|
|
||||||
val deviceId: String? = null,
|
|
||||||
/** OAuth 用户 ID(回调 userInfo.userId),null=旧凭据未保存 */
|
|
||||||
val userId: String? = null,
|
|
||||||
/** 最近一次成功签到日期(yyyy-MM-dd,本地时区);当天命中则本地短路,不重复发起签到请求 */
|
|
||||||
val lastCheckinDate: String? = null,
|
|
||||||
/**
|
|
||||||
* 用户在设置页手动填写的签到设备 ID(官方客户端绑定的 did)。
|
|
||||||
* 服务端按设备风控签到(9074 根因),官方客户端真实 did 才能通过;
|
|
||||||
* null/空白 = 使用内置默认(ACCOUNT_BOUND_DEVICE_ID)。
|
|
||||||
* 获取路径见设置页提示(客户端日志 main.log 搜索 did=)。
|
|
||||||
*/
|
|
||||||
val checkinDeviceId: String? = null,
|
|
||||||
override val lastVerifiedAt: Long = 0L
|
override val lastVerifiedAt: Long = 0L
|
||||||
) : Credential()
|
) : Credential()
|
||||||
|
|
||||||
|
|||||||
@ -1,32 +0,0 @@
|
|||||||
package com.rainy.token.domain.model
|
|
||||||
|
|
||||||
import com.rainy.token.domain.service.ServiceType
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 单个服务的账号元信息(多账号服务专用,如 Trae / WorkBuddy)。
|
|
||||||
*
|
|
||||||
* 凭据本身按 [id] 独立加密存储,这里只保存展示与排序所需的元数据:
|
|
||||||
* - [id]:稳定账号标识(迁移出的首个账号固定为 "default";新建账号为随机 ID)
|
|
||||||
* - [label]:用户可见名称;null/空白时 UI 按“账号 N”兜底显示
|
|
||||||
* - [isDefault]:是否为默认账号(小组件 / 每日自动签到仅作用于默认账号)
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class ServiceAccountInfo(
|
|
||||||
val id: String,
|
|
||||||
val label: String? = null,
|
|
||||||
val isDefault: Boolean = false,
|
|
||||||
val createdAt: Long = 0L
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 单个服务的账号元数据集合,经 [com.rainy.token.data.local.SecureStorage] 加密持久化,
|
|
||||||
* 存储键为 `accounts_{service.storageKey}`。
|
|
||||||
*
|
|
||||||
* 单账号服务不使用本结构,仍沿用旧的单凭据存储键。
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class AccountStore(
|
|
||||||
val service: ServiceType,
|
|
||||||
val accounts: List<ServiceAccountInfo> = emptyList()
|
|
||||||
)
|
|
||||||
@ -78,17 +78,13 @@ object ServiceConfigProvider {
|
|||||||
type = ServiceType.TRAE,
|
type = ServiceType.TRAE,
|
||||||
method = FetchMethod.REST_API,
|
method = FetchMethod.REST_API,
|
||||||
loginUrl = "",
|
loginUrl = "",
|
||||||
displayUnit = "Credits",
|
displayUnit = "Credits"
|
||||||
// 官网定价页(docs.trae.cn 订阅管理:购买会员套餐/增购积分入口)
|
|
||||||
externalUrl = "https://www.trae.cn/pricing"
|
|
||||||
),
|
),
|
||||||
ServiceType.WORKBUDDY to ServiceConfig(
|
ServiceType.WORKBUDDY to ServiceConfig(
|
||||||
type = ServiceType.WORKBUDDY,
|
type = ServiceType.WORKBUDDY,
|
||||||
method = FetchMethod.REST_API,
|
method = FetchMethod.REST_API,
|
||||||
loginUrl = "",
|
loginUrl = "",
|
||||||
displayUnit = "Credits",
|
displayUnit = "Credits"
|
||||||
// 官网订阅定价页
|
|
||||||
externalUrl = "https://www.workbuddy.cn/pricing/"
|
|
||||||
),
|
),
|
||||||
ServiceType.SUB2API to ServiceConfig(
|
ServiceType.SUB2API to ServiceConfig(
|
||||||
type = ServiceType.SUB2API,
|
type = ServiceType.SUB2API,
|
||||||
|
|||||||
@ -26,16 +26,5 @@ enum class ServiceType(
|
|||||||
companion object {
|
companion object {
|
||||||
fun fromStorageKey(key: String): ServiceType? =
|
fun fromStorageKey(key: String): ServiceType? =
|
||||||
entries.firstOrNull { it.storageKey == key }
|
entries.firstOrNull { it.storageKey == key }
|
||||||
|
|
||||||
/** 支持多账号的服务(Trae / WorkBuddy / Sub2API 已启用,见需求范围)。 */
|
|
||||||
val MULTI_ACCOUNT_SERVICES: Set<ServiceType> = setOf(
|
|
||||||
ServiceType.TRAE,
|
|
||||||
ServiceType.WORKBUDDY,
|
|
||||||
ServiceType.SUB2API
|
|
||||||
)
|
|
||||||
|
|
||||||
/** 该服务是否启用多账号。 */
|
|
||||||
fun isMultiAccount(service: ServiceType): Boolean =
|
|
||||||
service in MULTI_ACCOUNT_SERVICES
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -3,7 +3,6 @@ package com.rainy.token.domain.usecase
|
|||||||
import com.rainy.token.data.repository.CodexRepository
|
import com.rainy.token.data.repository.CodexRepository
|
||||||
import com.rainy.token.data.repository.CommandCodeGoRepository
|
import com.rainy.token.data.repository.CommandCodeGoRepository
|
||||||
import com.rainy.token.data.repository.CredentialRepository
|
import com.rainy.token.data.repository.CredentialRepository
|
||||||
import com.rainy.token.data.repository.CheckinStatusStore
|
|
||||||
import com.rainy.token.data.repository.DeepSeekRepository
|
import com.rainy.token.data.repository.DeepSeekRepository
|
||||||
import com.rainy.token.data.repository.OllamaRepository
|
import com.rainy.token.data.repository.OllamaRepository
|
||||||
import com.rainy.token.data.repository.OpenCodeGoRepository
|
import com.rainy.token.data.repository.OpenCodeGoRepository
|
||||||
@ -30,7 +29,6 @@ import kotlinx.coroutines.withContext
|
|||||||
*/
|
*/
|
||||||
class RefreshBalanceUseCase @Inject constructor(
|
class RefreshBalanceUseCase @Inject constructor(
|
||||||
private val credentialRepository: CredentialRepository,
|
private val credentialRepository: CredentialRepository,
|
||||||
private val checkinStatusStore: com.rainy.token.data.repository.CheckinStatusStore,
|
|
||||||
private val deepSeekRepositoryProvider: Provider<DeepSeekRepository>,
|
private val deepSeekRepositoryProvider: Provider<DeepSeekRepository>,
|
||||||
private val openCodeGoRepositoryProvider: Provider<OpenCodeGoRepository>,
|
private val openCodeGoRepositoryProvider: Provider<OpenCodeGoRepository>,
|
||||||
private val commandCodeGoRepositoryProvider: Provider<CommandCodeGoRepository>,
|
private val commandCodeGoRepositoryProvider: Provider<CommandCodeGoRepository>,
|
||||||
@ -40,11 +38,8 @@ class RefreshBalanceUseCase @Inject constructor(
|
|||||||
private val workBuddyRepositoryProvider: Provider<WorkBuddyRepository>,
|
private val workBuddyRepositoryProvider: Provider<WorkBuddyRepository>,
|
||||||
private val sub2ApiRepositoryProvider: Provider<Sub2ApiRepository>
|
private val sub2ApiRepositoryProvider: Provider<Sub2ApiRepository>
|
||||||
) {
|
) {
|
||||||
suspend operator fun invoke(
|
suspend operator fun invoke(service: ServiceType): Result<ServiceBalance> =
|
||||||
service: ServiceType,
|
withCredentialSession(service) {
|
||||||
accountId: String? = null
|
|
||||||
): Result<ServiceBalance> =
|
|
||||||
withCredentialSession(service, accountId) {
|
|
||||||
when (service) {
|
when (service) {
|
||||||
ServiceType.DEEPSEEK -> retryOnTransientError {
|
ServiceType.DEEPSEEK -> retryOnTransientError {
|
||||||
deepSeekRepositoryProvider.get().fetchBalance()
|
deepSeekRepositoryProvider.get().fetchBalance()
|
||||||
@ -62,13 +57,13 @@ class RefreshBalanceUseCase @Inject constructor(
|
|||||||
ollamaRepositoryProvider.get().fetchBalance()
|
ollamaRepositoryProvider.get().fetchBalance()
|
||||||
}
|
}
|
||||||
ServiceType.TRAE -> retryOnTransientError {
|
ServiceType.TRAE -> retryOnTransientError {
|
||||||
traeRepositoryProvider.get().fetchBalance(accountId)
|
traeRepositoryProvider.get().fetchBalance()
|
||||||
}
|
}
|
||||||
ServiceType.WORKBUDDY -> retryOnTransientError {
|
ServiceType.WORKBUDDY -> retryOnTransientError {
|
||||||
workBuddyRepositoryProvider.get().fetchBalance(accountId)
|
workBuddyRepositoryProvider.get().fetchBalance()
|
||||||
}
|
}
|
||||||
ServiceType.SUB2API -> retryOnTransientError {
|
ServiceType.SUB2API -> retryOnTransientError {
|
||||||
sub2ApiRepositoryProvider.get().fetchBalance(accountId)
|
sub2ApiRepositoryProvider.get().fetchBalance()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -104,79 +99,27 @@ class RefreshBalanceUseCase @Inject constructor(
|
|||||||
* access/refresh),轮换结果需按凭据快照校验后提交,避免并发改凭据被旧快照覆盖;
|
* access/refresh),轮换结果需按凭据快照校验后提交,避免并发改凭据被旧快照覆盖;
|
||||||
* 同时会话内重试才能读到已轮换的新 token(否则旧 refreshToken 单次消费后直接失效)。
|
* 同时会话内重试才能读到已轮换的新 token(否则旧 refreshToken 单次消费后直接失效)。
|
||||||
*/
|
*/
|
||||||
suspend fun checkInWorkBuddy(accountId: String? = null): Result<WorkBuddyRepository.CheckinStatus> =
|
suspend fun checkInWorkBuddy(): Result<WorkBuddyRepository.CheckinStatus> =
|
||||||
withCredentialSession(ServiceType.WORKBUDDY, accountId) {
|
withCredentialSession(ServiceType.WORKBUDDY) {
|
||||||
workBuddyRepositoryProvider.get().checkIn(accountId).also { result ->
|
workBuddyRepositoryProvider.get().checkIn()
|
||||||
result.getOrNull()?.let { s ->
|
|
||||||
val isChecked = s.outcome == WorkBuddyRepository.CheckinOutcome.SUCCESS ||
|
|
||||||
s.outcome == WorkBuddyRepository.CheckinOutcome.ALREADY ||
|
|
||||||
s.outcome == WorkBuddyRepository.CheckinOutcome.QUERY
|
|
||||||
if (isChecked) {
|
|
||||||
checkinStatusStore.record(
|
|
||||||
service = ServiceType.WORKBUDDY,
|
|
||||||
accountId = accountId,
|
|
||||||
status = if (s.outcome == WorkBuddyRepository.CheckinOutcome.QUERY) {
|
|
||||||
CheckinStatusStore.STATUS_ALREADY
|
|
||||||
} else {
|
|
||||||
s.outcome.name
|
|
||||||
},
|
|
||||||
checkinId = if (s.outcome == WorkBuddyRepository.CheckinOutcome.SUCCESS) {
|
|
||||||
java.util.UUID.randomUUID().toString()
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
credits = if (s.outcome == WorkBuddyRepository.CheckinOutcome.SUCCESS) {
|
|
||||||
s.todayCredit.takeIf { it > 0 } ?: s.dailyCredit
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun checkInTrae(accountId: String? = null): Result<TraeRepository.CheckinResult> =
|
suspend fun checkInTrae(): Result<TraeRepository.CheckinResult> =
|
||||||
withCredentialSession(ServiceType.TRAE, accountId) {
|
withCredentialSession(ServiceType.TRAE) {
|
||||||
traeRepositoryProvider.get().checkIn(accountId).also { result ->
|
traeRepositoryProvider.get().checkIn()
|
||||||
result.getOrNull()?.let { r ->
|
|
||||||
if (r.outcome == TraeRepository.CheckinOutcome.SUCCESS ||
|
|
||||||
r.outcome == TraeRepository.CheckinOutcome.ALREADY
|
|
||||||
) {
|
|
||||||
checkinStatusStore.record(
|
|
||||||
service = ServiceType.TRAE,
|
|
||||||
accountId = accountId,
|
|
||||||
status = r.outcome.name,
|
|
||||||
checkinId = if (r.outcome == TraeRepository.CheckinOutcome.SUCCESS) {
|
|
||||||
java.util.UUID.randomUUID().toString()
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
credits = if (r.outcome == TraeRepository.CheckinOutcome.SUCCESS) r.points else 0
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 查询今日签到状态(仅 WORKBUDDY 有该接口)。走会话以保证状态查询的凭据写入不覆盖并发编辑。 */
|
/** 查询今日签到状态(仅 WORKBUDDY 有该接口)。走会话以保证状态查询的凭据写入不覆盖并发编辑。 */
|
||||||
suspend fun fetchCheckinStatus(accountId: String? = null): Result<WorkBuddyRepository.CheckinStatus> =
|
suspend fun fetchCheckinStatus(): Result<WorkBuddyRepository.CheckinStatus> =
|
||||||
withCredentialSession(ServiceType.WORKBUDDY, accountId) {
|
withCredentialSession(ServiceType.WORKBUDDY) {
|
||||||
workBuddyRepositoryProvider.get().fetchCheckinStatus(accountId)
|
workBuddyRepositoryProvider.get().fetchCheckinStatus()
|
||||||
}
|
|
||||||
|
|
||||||
/** TRAE 今日签到状态(只读 status 接口,供详情页进入时预取「已签到」展示)。 */
|
|
||||||
suspend fun fetchTraeCheckinStatus(accountId: String? = null): Result<Boolean> =
|
|
||||||
withCredentialSession(ServiceType.TRAE, accountId) {
|
|
||||||
traeRepositoryProvider.get().fetchCheckinStatus(accountId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun <T> withCredentialSession(
|
private suspend fun <T> withCredentialSession(
|
||||||
service: ServiceType,
|
service: ServiceType,
|
||||||
accountId: String? = null,
|
|
||||||
block: suspend () -> Result<T>
|
block: suspend () -> Result<T>
|
||||||
): Result<T> {
|
): Result<T> {
|
||||||
val snapshot = credentialRepository.snapshot(service, accountId)
|
val snapshot = credentialRepository.snapshot(service)
|
||||||
?: return Result.failure(RepositoryError.InvalidCredential())
|
?: return Result.failure(RepositoryError.InvalidCredential())
|
||||||
val session = RefreshWriteSession(snapshot)
|
val session = RefreshWriteSession(snapshot)
|
||||||
|
|
||||||
|
|||||||
@ -50,13 +50,12 @@ import com.rainy.token.ui.dashboard.UsageDataScreen
|
|||||||
import com.rainy.token.ui.dashboard.UsageDataViewModel
|
import com.rainy.token.ui.dashboard.UsageDataViewModel
|
||||||
import com.rainy.token.ui.dashboard.UsageDetailScreen
|
import com.rainy.token.ui.dashboard.UsageDetailScreen
|
||||||
import com.rainy.token.ui.dashboard.UsageOverviewScreen
|
import com.rainy.token.ui.dashboard.UsageOverviewScreen
|
||||||
|
import com.rainy.token.ui.dashboard.UsageStatScreen
|
||||||
import com.rainy.token.ui.dashboard.UsageViewModel
|
import com.rainy.token.ui.dashboard.UsageViewModel
|
||||||
import com.rainy.token.ui.heatmap.HeatmapScreen
|
import com.rainy.token.ui.heatmap.HeatmapScreen
|
||||||
import com.rainy.token.ui.account.ApiManagementScreen
|
import com.rainy.token.ui.account.ApiManagementScreen
|
||||||
import com.rainy.token.ui.servicedetail.ServiceDetailScreen
|
import com.rainy.token.ui.servicedetail.ServiceDetailScreen
|
||||||
import com.rainy.token.ui.settings.AccountManagementScreen
|
|
||||||
import com.rainy.token.ui.settings.CredentialEditScreen
|
import com.rainy.token.ui.settings.CredentialEditScreen
|
||||||
import com.rainy.token.ui.settings.CredentialEditViewModel
|
|
||||||
import com.rainy.token.ui.settings.SettingsScreen
|
import com.rainy.token.ui.settings.SettingsScreen
|
||||||
import com.rainy.token.ui.theme.inkMuted
|
import com.rainy.token.ui.theme.inkMuted
|
||||||
import com.rainy.token.ui.theme.StrawberryPink
|
import com.rainy.token.ui.theme.StrawberryPink
|
||||||
@ -78,35 +77,19 @@ object Routes {
|
|||||||
const val DASHBOARD = "dashboard"
|
const val DASHBOARD = "dashboard"
|
||||||
const val SETTINGS = "settings"
|
const val SETTINGS = "settings"
|
||||||
const val API_MANAGEMENT = "api_management"
|
const val API_MANAGEMENT = "api_management"
|
||||||
|
const val USAGE_STAT = "usage_stat"
|
||||||
const val TIPS = "tips"
|
const val TIPS = "tips"
|
||||||
const val DEBUG_LOG = "debug_log"
|
const val DEBUG_LOG = "debug_log"
|
||||||
const val CODEX_OAUTH = "codex_oauth"
|
const val CODEX_OAUTH = "codex_oauth"
|
||||||
const val CREDENTIAL_EDIT = "credential_edit/{type}?account={account}"
|
const val CREDENTIAL_EDIT = "credential_edit/{type}"
|
||||||
fun credentialEdit(type: ServiceType, accountId: String? = null): String =
|
fun credentialEdit(type: ServiceType) = "credential_edit/${type.name}"
|
||||||
if (accountId.isNullOrBlank()) {
|
const val WEBVIEW_LOGIN = "webview_login/{type}"
|
||||||
"credential_edit/${type.name}"
|
fun webviewLogin(type: ServiceType) = "webview_login/${type.name}"
|
||||||
} else {
|
|
||||||
"credential_edit/${type.name}?account=${Uri.encode(accountId)}"
|
|
||||||
}
|
|
||||||
const val ACCOUNTS = "accounts/{type}"
|
|
||||||
fun accounts(type: ServiceType) = "accounts/${type.name}"
|
|
||||||
const val WEBVIEW_LOGIN = "webview_login/{type}?account={account}"
|
|
||||||
fun webviewLogin(type: ServiceType, accountId: String? = null): String =
|
|
||||||
if (accountId.isNullOrBlank()) {
|
|
||||||
"webview_login/${type.name}"
|
|
||||||
} else {
|
|
||||||
"webview_login/${type.name}?account=${Uri.encode(accountId)}"
|
|
||||||
}
|
|
||||||
const val WEBVIEW_PAGE = "webview_page?url={url}&title={title}"
|
const val WEBVIEW_PAGE = "webview_page?url={url}&title={title}"
|
||||||
fun webviewPage(url: String, title: String) =
|
fun webviewPage(url: String, title: String) =
|
||||||
"webview_page?url=${Uri.encode(url)}&title=${Uri.encode(title)}"
|
"webview_page?url=${Uri.encode(url)}&title=${Uri.encode(title)}"
|
||||||
const val SERVICE_DETAIL = "service/{type}?account={account}"
|
const val SERVICE_DETAIL = "service/{type}"
|
||||||
fun serviceDetail(type: ServiceType, accountId: String? = null): String =
|
fun serviceDetail(type: ServiceType) = "service/${type.name}"
|
||||||
if (accountId.isNullOrBlank()) {
|
|
||||||
"service/${type.name}"
|
|
||||||
} else {
|
|
||||||
"service/${type.name}?account=${Uri.encode(accountId)}"
|
|
||||||
}
|
|
||||||
const val USAGE_DETAIL = "usage_detail"
|
const val USAGE_DETAIL = "usage_detail"
|
||||||
const val USAGE_OVERVIEW = "usage_overview"
|
const val USAGE_OVERVIEW = "usage_overview"
|
||||||
const val USAGE_DATA = "usage_data"
|
const val USAGE_DATA = "usage_data"
|
||||||
@ -121,20 +104,15 @@ private fun parseServiceType(typeName: String?): ServiceType =
|
|||||||
ServiceType.fromStorageKey(it) ?: runCatching { ServiceType.valueOf(it) }.getOrNull()
|
ServiceType.fromStorageKey(it) ?: runCatching { ServiceType.valueOf(it) }.getOrNull()
|
||||||
} ?: ServiceType.DEEPSEEK
|
} ?: ServiceType.DEEPSEEK
|
||||||
|
|
||||||
/** 凭据入口:多账号服务先进账号管理页,其余直达凭据编辑页。 */
|
|
||||||
private fun credentialEntry(type: ServiceType): String =
|
|
||||||
if (ServiceType.isMultiAccount(type)) Routes.accounts(type) else Routes.credentialEdit(type)
|
|
||||||
|
|
||||||
private sealed class DetailPane {
|
private sealed class DetailPane {
|
||||||
object Empty : DetailPane()
|
object Empty : DetailPane()
|
||||||
data class ServiceDetail(val type: ServiceType, val accountId: String? = null) : DetailPane()
|
data class ServiceDetail(val type: ServiceType) : DetailPane()
|
||||||
object OCGOUsage : DetailPane()
|
object OCGOUsage : DetailPane()
|
||||||
object CCGOUsage : DetailPane()
|
object CCGOUsage : DetailPane()
|
||||||
object Settings : DetailPane()
|
object Settings : DetailPane()
|
||||||
object ApiManagement : DetailPane()
|
object ApiManagement : DetailPane()
|
||||||
|
object UsageStat : DetailPane()
|
||||||
object Heatmap : DetailPane()
|
object Heatmap : DetailPane()
|
||||||
/** 通用外部页面(充值/订阅页),App 内 WebView 打开。 */
|
|
||||||
data class WebPage(val url: String, val title: String) : DetailPane()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@ -226,28 +204,21 @@ private fun CompactNavHost() {
|
|||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
composable(Routes.DASHBOARD) {
|
composable(Routes.DASHBOARD) {
|
||||||
val rechargeLabel = stringResource(R.string.action_recharge)
|
|
||||||
DashboardScreen(
|
DashboardScreen(
|
||||||
onOpenSettings = { navController.navigate(Routes.SETTINGS) },
|
onOpenSettings = { navController.navigate(Routes.SETTINGS) },
|
||||||
onOpenApiManagement = { navController.navigate(Routes.API_MANAGEMENT) },
|
onOpenApiManagement = { navController.navigate(Routes.API_MANAGEMENT) },
|
||||||
onOpenService = { type, accountId ->
|
onOpenUsageStat = { navController.navigate(Routes.USAGE_STAT) },
|
||||||
navController.navigate(Routes.serviceDetail(type, accountId))
|
onOpenService = { type -> navController.navigate(Routes.serviceDetail(type)) },
|
||||||
},
|
|
||||||
onOpenUsageDetail = { navController.navigate(Routes.USAGE_DETAIL) },
|
onOpenUsageDetail = { navController.navigate(Routes.USAGE_DETAIL) },
|
||||||
onOpenCcgoUsageDetail = { navController.navigate(Routes.CCGO_USAGE_DETAIL) },
|
onOpenCcgoUsageDetail = { navController.navigate(Routes.CCGO_USAGE_DETAIL) },
|
||||||
onOpenHeatmap = { navController.navigate(Routes.HEATMAP) },
|
onOpenHeatmap = { navController.navigate(Routes.HEATMAP) }
|
||||||
onOpenRecharge = { type ->
|
|
||||||
ServiceConfigProvider.get(type).externalUrl?.let { url ->
|
|
||||||
navController.navigate(Routes.webviewPage(url, rechargeLabel))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(Routes.API_MANAGEMENT) {
|
composable(Routes.API_MANAGEMENT) {
|
||||||
val rechargeLabel = stringResource(R.string.action_recharge)
|
val rechargeLabel = stringResource(R.string.action_recharge)
|
||||||
ApiManagementScreen(
|
ApiManagementScreen(
|
||||||
onBack = guardedPop,
|
onBack = guardedPop,
|
||||||
onConfigure = { type -> navController.navigate(credentialEntry(type)) },
|
onConfigure = { type -> navController.navigate(Routes.credentialEdit(type)) },
|
||||||
onRecharge = { type ->
|
onRecharge = { type ->
|
||||||
ServiceConfigProvider.get(type).externalUrl?.let { url ->
|
ServiceConfigProvider.get(type).externalUrl?.let { url ->
|
||||||
navController.navigate(Routes.webviewPage(url, rechargeLabel))
|
navController.navigate(Routes.webviewPage(url, rechargeLabel))
|
||||||
@ -255,6 +226,9 @@ private fun CompactNavHost() {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
composable(Routes.USAGE_STAT) {
|
||||||
|
UsageStatScreen(onBack = guardedPop)
|
||||||
|
}
|
||||||
composable(Routes.USAGE_DETAIL) {
|
composable(Routes.USAGE_DETAIL) {
|
||||||
UsageDetailScreen(
|
UsageDetailScreen(
|
||||||
onBack = guardedPop,
|
onBack = guardedPop,
|
||||||
@ -311,22 +285,11 @@ private fun CompactNavHost() {
|
|||||||
composable(Routes.SETTINGS) {
|
composable(Routes.SETTINGS) {
|
||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
onBack = guardedPop,
|
onBack = guardedPop,
|
||||||
onEditCredential = { type -> navController.navigate(credentialEntry(type)) },
|
onEditCredential = { type -> navController.navigate(Routes.credentialEdit(type)) },
|
||||||
onManageAccounts = { type -> navController.navigate(Routes.accounts(type)) },
|
|
||||||
onOpenTips = { navController.navigate(Routes.TIPS) },
|
onOpenTips = { navController.navigate(Routes.TIPS) },
|
||||||
onOpenDebugLog = { navController.navigate(Routes.DEBUG_LOG) }
|
onOpenDebugLog = { navController.navigate(Routes.DEBUG_LOG) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(Routes.ACCOUNTS) { backStackEntry ->
|
|
||||||
val type = parseServiceType(backStackEntry.arguments?.getString("type"))
|
|
||||||
AccountManagementScreen(
|
|
||||||
service = type,
|
|
||||||
onBack = guardedPop,
|
|
||||||
onEditAccount = { svc, accountId ->
|
|
||||||
navController.navigate(Routes.credentialEdit(svc, accountId))
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable(Routes.TIPS) {
|
composable(Routes.TIPS) {
|
||||||
TipsScreen(onBack = guardedPop)
|
TipsScreen(onBack = guardedPop)
|
||||||
}
|
}
|
||||||
@ -341,83 +304,39 @@ private fun CompactNavHost() {
|
|||||||
}
|
}
|
||||||
composable(
|
composable(
|
||||||
route = Routes.CREDENTIAL_EDIT,
|
route = Routes.CREDENTIAL_EDIT,
|
||||||
arguments = listOf(
|
arguments = listOf(navArgument("type") { type = NavType.StringType })
|
||||||
navArgument("type") { type = NavType.StringType },
|
|
||||||
navArgument("account") {
|
|
||||||
type = NavType.StringType
|
|
||||||
nullable = true
|
|
||||||
defaultValue = null
|
|
||||||
}
|
|
||||||
)
|
|
||||||
) { backStackEntry ->
|
) { backStackEntry ->
|
||||||
val type = parseServiceType(backStackEntry.arguments?.getString("type"))
|
val type = parseServiceType(backStackEntry.arguments?.getString("type"))
|
||||||
val accountId = backStackEntry.arguments?.getString("account")
|
|
||||||
CredentialEditScreen(
|
CredentialEditScreen(
|
||||||
service = type,
|
service = type,
|
||||||
accountId = accountId,
|
|
||||||
onBack = guardedPop,
|
onBack = guardedPop,
|
||||||
onStartWebViewLogin = { svc, acc -> navController.navigate(Routes.webviewLogin(svc, acc)) },
|
onStartWebViewLogin = { service -> navController.navigate(Routes.webviewLogin(service)) },
|
||||||
onWebViewLoginSuccess = { },
|
onWebViewLoginSuccess = { },
|
||||||
onStartCodexOAuth = { navController.navigate(Routes.CODEX_OAUTH) }
|
onStartCodexOAuth = { navController.navigate(Routes.CODEX_OAUTH) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(
|
composable(
|
||||||
route = Routes.WEBVIEW_LOGIN,
|
route = Routes.WEBVIEW_LOGIN,
|
||||||
arguments = listOf(
|
arguments = listOf(navArgument("type") { type = NavType.StringType })
|
||||||
navArgument("type") { type = NavType.StringType },
|
|
||||||
navArgument("account") {
|
|
||||||
type = NavType.StringType
|
|
||||||
nullable = true
|
|
||||||
defaultValue = null
|
|
||||||
}
|
|
||||||
)
|
|
||||||
) { backStackEntry ->
|
) { backStackEntry ->
|
||||||
val type = parseServiceType(backStackEntry.arguments?.getString("type"))
|
val type = parseServiceType(backStackEntry.arguments?.getString("type"))
|
||||||
val accountId = backStackEntry.arguments?.getString("account")
|
|
||||||
WebViewLoginScreen(
|
WebViewLoginScreen(
|
||||||
service = type,
|
service = type,
|
||||||
accountId = accountId,
|
|
||||||
onBack = guardedPop,
|
onBack = guardedPop,
|
||||||
onLoginSucceeded = { _, effectiveId ->
|
onLoginSucceeded = { guardedPop() }
|
||||||
// 把实际写入的账号 ID 回传给上一层(凭据编辑页),新建账号模式用于回填
|
|
||||||
if (!effectiveId.isNullOrBlank()) {
|
|
||||||
navController.previousBackStackEntry?.savedStateHandle?.set(
|
|
||||||
CredentialEditViewModel.KEY_LOGIN_ACCOUNT_ID, effectiveId
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// 登录成功后返回上一层(详情页或凭据编辑页),由 ON_RESUME 刷新内容
|
|
||||||
if (!navController.popBackStack(Routes.SERVICE_DETAIL, inclusive = false)) {
|
|
||||||
guardedPop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(
|
composable(
|
||||||
route = Routes.SERVICE_DETAIL,
|
route = Routes.SERVICE_DETAIL,
|
||||||
arguments = listOf(
|
arguments = listOf(navArgument("type") { type = NavType.StringType })
|
||||||
navArgument("type") { type = NavType.StringType },
|
|
||||||
navArgument("account") {
|
|
||||||
type = NavType.StringType
|
|
||||||
nullable = true
|
|
||||||
defaultValue = null
|
|
||||||
}
|
|
||||||
)
|
|
||||||
) { backStackEntry ->
|
) { backStackEntry ->
|
||||||
val type = parseServiceType(backStackEntry.arguments?.getString("type"))
|
val type = parseServiceType(backStackEntry.arguments?.getString("type"))
|
||||||
val accountId = backStackEntry.arguments?.getString("account")
|
|
||||||
val rechargeLabel = stringResource(R.string.action_recharge)
|
val rechargeLabel = stringResource(R.string.action_recharge)
|
||||||
ServiceDetailScreen(
|
ServiceDetailScreen(
|
||||||
service = type,
|
service = type,
|
||||||
accountId = accountId,
|
|
||||||
onBack = guardedPop,
|
onBack = guardedPop,
|
||||||
onSwitchAccount = { svc, acc ->
|
onConfigureCredential = { svc -> navController.navigate(Routes.credentialEdit(svc)) },
|
||||||
// 切换账号时替换当前详情页(避免同路由栈堆积)
|
onStartWebViewLogin = { svc -> navController.navigate(Routes.webviewLogin(svc)) },
|
||||||
navController.navigate(Routes.serviceDetail(svc, acc)) {
|
|
||||||
popUpTo(Routes.SERVICE_DETAIL) { inclusive = true }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onConfigureCredential = { svc -> navController.navigate(credentialEntry(svc)) },
|
|
||||||
onStartWebViewLogin = { svc, acc -> navController.navigate(Routes.webviewLogin(svc, acc)) },
|
|
||||||
onOpenRecharge = { svc ->
|
onOpenRecharge = { svc ->
|
||||||
ServiceConfigProvider.get(svc).externalUrl?.let { url ->
|
ServiceConfigProvider.get(svc).externalUrl?.let { url ->
|
||||||
navController.navigate(Routes.webviewPage(url, rechargeLabel))
|
navController.navigate(Routes.webviewPage(url, rechargeLabel))
|
||||||
@ -461,8 +380,6 @@ private fun ExpandedLayout() {
|
|||||||
prevPane = detailPane
|
prevPane = detailPane
|
||||||
}
|
}
|
||||||
|
|
||||||
val rechargeLabel = stringResource(R.string.action_recharge)
|
|
||||||
|
|
||||||
Row(modifier = Modifier.fillMaxSize()) {
|
Row(modifier = Modifier.fillMaxSize()) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@ -472,17 +389,11 @@ private fun ExpandedLayout() {
|
|||||||
DashboardScreen(
|
DashboardScreen(
|
||||||
onOpenSettings = { detailPane = DetailPane.Settings },
|
onOpenSettings = { detailPane = DetailPane.Settings },
|
||||||
onOpenApiManagement = { detailPane = DetailPane.ApiManagement },
|
onOpenApiManagement = { detailPane = DetailPane.ApiManagement },
|
||||||
onOpenService = { type, accountId ->
|
onOpenUsageStat = { detailPane = DetailPane.UsageStat },
|
||||||
detailPane = DetailPane.ServiceDetail(type, accountId)
|
onOpenService = { type -> detailPane = DetailPane.ServiceDetail(type) },
|
||||||
},
|
|
||||||
onOpenUsageDetail = { detailPane = DetailPane.OCGOUsage },
|
onOpenUsageDetail = { detailPane = DetailPane.OCGOUsage },
|
||||||
onOpenCcgoUsageDetail = { detailPane = DetailPane.CCGOUsage },
|
onOpenCcgoUsageDetail = { detailPane = DetailPane.CCGOUsage },
|
||||||
onOpenHeatmap = { detailPane = DetailPane.Heatmap },
|
onOpenHeatmap = { detailPane = DetailPane.Heatmap },
|
||||||
onOpenRecharge = { type ->
|
|
||||||
ServiceConfigProvider.get(type).externalUrl?.let { url ->
|
|
||||||
detailPane = DetailPane.WebPage(url = url, title = rechargeLabel)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
viewModel = dashboardVm
|
viewModel = dashboardVm
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -501,9 +412,6 @@ private fun ExpandedLayout() {
|
|||||||
ExpandedDetailPane(
|
ExpandedDetailPane(
|
||||||
pane = detailPane,
|
pane = detailPane,
|
||||||
onClose = { detailPane = DetailPane.Empty },
|
onClose = { detailPane = DetailPane.Empty },
|
||||||
onSwitchAccount = { svc, acc ->
|
|
||||||
detailPane = DetailPane.ServiceDetail(svc, acc)
|
|
||||||
},
|
|
||||||
onCredentialEditClosed = { dashboardVm.reloadLocalState() }
|
onCredentialEditClosed = { dashboardVm.reloadLocalState() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -514,7 +422,6 @@ private fun ExpandedLayout() {
|
|||||||
private fun ExpandedDetailPane(
|
private fun ExpandedDetailPane(
|
||||||
pane: DetailPane,
|
pane: DetailPane,
|
||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
onSwitchAccount: (ServiceType, String?) -> Unit = { _, _ -> },
|
|
||||||
onCredentialEditClosed: () -> Unit = {}
|
onCredentialEditClosed: () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
when (pane) {
|
when (pane) {
|
||||||
@ -527,8 +434,8 @@ private fun ExpandedDetailPane(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
is DetailPane.ServiceDetail -> {
|
is DetailPane.ServiceDetail -> {
|
||||||
// key(pane.type + accountId):切换服务/账号时重建嵌套导航栈,避免停留在上一个页面
|
// key(pane.type):切换服务时重建嵌套导航栈,避免停留在上一个服务的充值页
|
||||||
key("${pane.type.name}:${pane.accountId.orEmpty()}") {
|
key(pane.type) {
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
NavHost(
|
NavHost(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
@ -539,11 +446,9 @@ private fun ExpandedDetailPane(
|
|||||||
val rechargeLabel = LocalContext.current.getString(R.string.action_recharge)
|
val rechargeLabel = LocalContext.current.getString(R.string.action_recharge)
|
||||||
ServiceDetailScreen(
|
ServiceDetailScreen(
|
||||||
service = pane.type,
|
service = pane.type,
|
||||||
accountId = pane.accountId,
|
|
||||||
onBack = onClose,
|
onBack = onClose,
|
||||||
onSwitchAccount = onSwitchAccount,
|
|
||||||
onConfigureCredential = { onClose() },
|
onConfigureCredential = { onClose() },
|
||||||
onStartWebViewLogin = { _, _ -> },
|
onStartWebViewLogin = { },
|
||||||
onOpenRecharge = { svc ->
|
onOpenRecharge = { svc ->
|
||||||
ServiceConfigProvider.get(svc).externalUrl?.let { url ->
|
ServiceConfigProvider.get(svc).externalUrl?.let { url ->
|
||||||
navController.navigate(Routes.webviewPage(url, rechargeLabel))
|
navController.navigate(Routes.webviewPage(url, rechargeLabel))
|
||||||
@ -629,12 +534,8 @@ private fun ExpandedDetailPane(
|
|||||||
is DetailPane.Heatmap -> {
|
is DetailPane.Heatmap -> {
|
||||||
HeatmapScreen(onBack = onClose)
|
HeatmapScreen(onBack = onClose)
|
||||||
}
|
}
|
||||||
is DetailPane.WebPage -> {
|
is DetailPane.UsageStat -> {
|
||||||
WebViewPageScreen(
|
UsageStatScreen(onBack = onClose)
|
||||||
url = pane.url,
|
|
||||||
title = pane.title,
|
|
||||||
onBack = onClose
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
is DetailPane.ApiManagement -> {
|
is DetailPane.ApiManagement -> {
|
||||||
val apiNavController = rememberNavController()
|
val apiNavController = rememberNavController()
|
||||||
@ -657,7 +558,7 @@ private fun ExpandedDetailPane(
|
|||||||
val rechargeLabel = LocalContext.current.getString(R.string.action_recharge)
|
val rechargeLabel = LocalContext.current.getString(R.string.action_recharge)
|
||||||
ApiManagementScreen(
|
ApiManagementScreen(
|
||||||
onBack = onClose,
|
onBack = onClose,
|
||||||
onConfigure = { type -> apiNavController.navigate(credentialEntry(type)) },
|
onConfigure = { type -> apiNavController.navigate(Routes.credentialEdit(type)) },
|
||||||
onRecharge = { type ->
|
onRecharge = { type ->
|
||||||
ServiceConfigProvider.get(type).externalUrl?.let { url ->
|
ServiceConfigProvider.get(type).externalUrl?.let { url ->
|
||||||
apiNavController.navigate(Routes.webviewPage(url, rechargeLabel))
|
apiNavController.navigate(Routes.webviewPage(url, rechargeLabel))
|
||||||
@ -665,35 +566,16 @@ private fun ExpandedDetailPane(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(Routes.ACCOUNTS) { entry ->
|
|
||||||
val type = parseServiceType(entry.arguments?.getString("type"))
|
|
||||||
AccountManagementScreen(
|
|
||||||
service = type,
|
|
||||||
onBack = { apiNavController.popBackStack() },
|
|
||||||
onEditAccount = { svc, accountId ->
|
|
||||||
apiNavController.navigate(Routes.credentialEdit(svc, accountId))
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable(
|
composable(
|
||||||
route = Routes.CREDENTIAL_EDIT,
|
route = Routes.CREDENTIAL_EDIT,
|
||||||
arguments = listOf(
|
arguments = listOf(navArgument("type") { type = NavType.StringType })
|
||||||
navArgument("type") { type = NavType.StringType },
|
|
||||||
navArgument("account") {
|
|
||||||
type = NavType.StringType
|
|
||||||
nullable = true
|
|
||||||
defaultValue = null
|
|
||||||
}
|
|
||||||
)
|
|
||||||
) { entry ->
|
) { entry ->
|
||||||
val type = parseServiceType(entry.arguments?.getString("type"))
|
val type = parseServiceType(entry.arguments?.getString("type"))
|
||||||
val accountId = entry.arguments?.getString("account")
|
|
||||||
CredentialEditScreen(
|
CredentialEditScreen(
|
||||||
service = type,
|
service = type,
|
||||||
accountId = accountId,
|
|
||||||
onBack = { apiNavController.popBackStack() },
|
onBack = { apiNavController.popBackStack() },
|
||||||
onStartWebViewLogin = { svc, acc ->
|
onStartWebViewLogin = { svc ->
|
||||||
apiNavController.navigate(Routes.webviewLogin(svc, acc))
|
apiNavController.navigate(Routes.webviewLogin(svc))
|
||||||
},
|
},
|
||||||
onWebViewLoginSuccess = { },
|
onWebViewLoginSuccess = { },
|
||||||
onStartCodexOAuth = { apiNavController.navigate(Routes.CODEX_OAUTH) }
|
onStartCodexOAuth = { apiNavController.navigate(Routes.CODEX_OAUTH) }
|
||||||
@ -701,29 +583,13 @@ private fun ExpandedDetailPane(
|
|||||||
}
|
}
|
||||||
composable(
|
composable(
|
||||||
route = Routes.WEBVIEW_LOGIN,
|
route = Routes.WEBVIEW_LOGIN,
|
||||||
arguments = listOf(
|
arguments = listOf(navArgument("type") { type = NavType.StringType })
|
||||||
navArgument("type") { type = NavType.StringType },
|
|
||||||
navArgument("account") {
|
|
||||||
type = NavType.StringType
|
|
||||||
nullable = true
|
|
||||||
defaultValue = null
|
|
||||||
}
|
|
||||||
)
|
|
||||||
) { entry ->
|
) { entry ->
|
||||||
val type = parseServiceType(entry.arguments?.getString("type"))
|
val type = parseServiceType(entry.arguments?.getString("type"))
|
||||||
val accountId = entry.arguments?.getString("account")
|
|
||||||
WebViewLoginScreen(
|
WebViewLoginScreen(
|
||||||
service = type,
|
service = type,
|
||||||
accountId = accountId,
|
|
||||||
onBack = { apiNavController.popBackStack() },
|
onBack = { apiNavController.popBackStack() },
|
||||||
onLoginSucceeded = { _, effectiveId ->
|
onLoginSucceeded = { apiNavController.popBackStack() }
|
||||||
if (!effectiveId.isNullOrBlank()) {
|
|
||||||
apiNavController.previousBackStackEntry?.savedStateHandle?.set(
|
|
||||||
CredentialEditViewModel.KEY_LOGIN_ACCOUNT_ID, effectiveId
|
|
||||||
)
|
|
||||||
}
|
|
||||||
apiNavController.popBackStack()
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(Routes.CODEX_OAUTH) {
|
composable(Routes.CODEX_OAUTH) {
|
||||||
@ -769,25 +635,12 @@ private fun ExpandedDetailPane(
|
|||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
onBack = onClose,
|
onBack = onClose,
|
||||||
onEditCredential = { type ->
|
onEditCredential = { type ->
|
||||||
settingsNavController.navigate(credentialEntry(type))
|
settingsNavController.navigate(Routes.credentialEdit(type))
|
||||||
},
|
|
||||||
onManageAccounts = { type ->
|
|
||||||
settingsNavController.navigate(Routes.accounts(type))
|
|
||||||
},
|
},
|
||||||
onOpenTips = { settingsNavController.navigate("tips") },
|
onOpenTips = { settingsNavController.navigate("tips") },
|
||||||
onOpenDebugLog = { settingsNavController.navigate("debug_log") }
|
onOpenDebugLog = { settingsNavController.navigate("debug_log") }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(Routes.ACCOUNTS) { entry ->
|
|
||||||
val type = parseServiceType(entry.arguments?.getString("type"))
|
|
||||||
AccountManagementScreen(
|
|
||||||
service = type,
|
|
||||||
onBack = { settingsNavController.popBackStack() },
|
|
||||||
onEditAccount = { svc, accountId ->
|
|
||||||
settingsNavController.navigate(Routes.credentialEdit(svc, accountId))
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable("tips") {
|
composable("tips") {
|
||||||
TipsScreen(onBack = { settingsNavController.popBackStack() })
|
TipsScreen(onBack = { settingsNavController.popBackStack() })
|
||||||
}
|
}
|
||||||
@ -802,23 +655,14 @@ private fun ExpandedDetailPane(
|
|||||||
}
|
}
|
||||||
composable(
|
composable(
|
||||||
route = Routes.CREDENTIAL_EDIT,
|
route = Routes.CREDENTIAL_EDIT,
|
||||||
arguments = listOf(
|
arguments = listOf(navArgument("type") { type = NavType.StringType })
|
||||||
navArgument("type") { type = NavType.StringType },
|
|
||||||
navArgument("account") {
|
|
||||||
type = NavType.StringType
|
|
||||||
nullable = true
|
|
||||||
defaultValue = null
|
|
||||||
}
|
|
||||||
)
|
|
||||||
) { entry ->
|
) { entry ->
|
||||||
val type = parseServiceType(entry.arguments?.getString("type"))
|
val type = parseServiceType(entry.arguments?.getString("type"))
|
||||||
val accountId = entry.arguments?.getString("account")
|
|
||||||
CredentialEditScreen(
|
CredentialEditScreen(
|
||||||
service = type,
|
service = type,
|
||||||
accountId = accountId,
|
|
||||||
onBack = { settingsNavController.popBackStack() },
|
onBack = { settingsNavController.popBackStack() },
|
||||||
onStartWebViewLogin = { svc, acc ->
|
onStartWebViewLogin = { svc ->
|
||||||
settingsNavController.navigate(Routes.webviewLogin(svc, acc))
|
settingsNavController.navigate(Routes.webviewLogin(svc))
|
||||||
},
|
},
|
||||||
onWebViewLoginSuccess = { },
|
onWebViewLoginSuccess = { },
|
||||||
onStartCodexOAuth = { settingsNavController.navigate(Routes.CODEX_OAUTH) }
|
onStartCodexOAuth = { settingsNavController.navigate(Routes.CODEX_OAUTH) }
|
||||||
@ -826,29 +670,13 @@ private fun ExpandedDetailPane(
|
|||||||
}
|
}
|
||||||
composable(
|
composable(
|
||||||
route = Routes.WEBVIEW_LOGIN,
|
route = Routes.WEBVIEW_LOGIN,
|
||||||
arguments = listOf(
|
arguments = listOf(navArgument("type") { type = NavType.StringType })
|
||||||
navArgument("type") { type = NavType.StringType },
|
|
||||||
navArgument("account") {
|
|
||||||
type = NavType.StringType
|
|
||||||
nullable = true
|
|
||||||
defaultValue = null
|
|
||||||
}
|
|
||||||
)
|
|
||||||
) { entry ->
|
) { entry ->
|
||||||
val type = parseServiceType(entry.arguments?.getString("type"))
|
val type = parseServiceType(entry.arguments?.getString("type"))
|
||||||
val accountId = entry.arguments?.getString("account")
|
|
||||||
WebViewLoginScreen(
|
WebViewLoginScreen(
|
||||||
service = type,
|
service = type,
|
||||||
accountId = accountId,
|
|
||||||
onBack = { settingsNavController.popBackStack() },
|
onBack = { settingsNavController.popBackStack() },
|
||||||
onLoginSucceeded = { _, effectiveId ->
|
onLoginSucceeded = { settingsNavController.popBackStack() }
|
||||||
if (!effectiveId.isNullOrBlank()) {
|
|
||||||
settingsNavController.previousBackStackEntry?.savedStateHandle?.set(
|
|
||||||
CredentialEditViewModel.KEY_LOGIN_ACCOUNT_ID, effectiveId
|
|
||||||
)
|
|
||||||
}
|
|
||||||
settingsNavController.popBackStack()
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
package com.rainy.token.ui.account
|
package com.rainy.token.ui.account
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@ -11,7 +10,6 @@ import androidx.compose.foundation.layout.Spacer
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.heightIn
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
@ -19,7 +17,6 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
|
||||||
import androidx.compose.material.icons.filled.ArrowBack
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
@ -32,6 +29,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
|||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Snackbar
|
import androidx.compose.material3.Snackbar
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
@ -52,7 +50,6 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
@ -60,13 +57,11 @@ import androidx.lifecycle.compose.LifecycleEventEffect
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.rainy.token.R
|
import com.rainy.token.R
|
||||||
import com.rainy.token.domain.model.CredentialStatus
|
import com.rainy.token.domain.model.CredentialStatus
|
||||||
import com.rainy.token.domain.service.ServiceConfigProvider
|
|
||||||
import com.rainy.token.domain.service.ServiceType
|
import com.rainy.token.domain.service.ServiceType
|
||||||
import com.rainy.token.ui.components.ServiceIcon
|
import com.rainy.token.ui.components.ServiceIcon
|
||||||
import com.rainy.token.ui.components.formatAmount
|
import com.rainy.token.ui.components.formatAmount
|
||||||
import com.rainy.token.ui.components.resolve
|
import com.rainy.token.ui.components.resolve
|
||||||
import com.rainy.token.ui.theme.StrawberryPink
|
import com.rainy.token.ui.theme.StrawberryPink
|
||||||
import com.rainy.token.ui.theme.StatusGreen
|
|
||||||
import com.rainy.token.ui.theme.StatusOrange
|
import com.rainy.token.ui.theme.StatusOrange
|
||||||
import com.rainy.token.ui.theme.inkMuted
|
import com.rainy.token.ui.theme.inkMuted
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
@ -74,11 +69,10 @@ import java.util.Date
|
|||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API 管理页(记事本式)。
|
* API 管理页:按供应商集中展示/管理已保存的凭据。
|
||||||
*
|
*
|
||||||
* 心智模型像记事本:正文只有「已添加」的服务条目,右上角 **+** 从剩余服务里选一个
|
* 每个供应商一张卡:状态、Key 掩码、最后验证、缓存余额,以及
|
||||||
* 添加(进入对应配置流程);未配置的服务不占卡片。条目点击即编辑,行内提供
|
* 配置/编辑、测试连接、删除(需确认)操作;DeepSeek 额外提供充值入口。
|
||||||
* 测试连接 / 删除(需确认),有官方充值/订阅页的服务额外给入口。
|
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@ -94,7 +88,6 @@ fun ApiManagementScreen(
|
|||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var pendingDelete by remember { mutableStateOf<ServiceType?>(null) }
|
var pendingDelete by remember { mutableStateOf<ServiceType?>(null) }
|
||||||
var showAddPicker by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
LaunchedEffect(message) {
|
LaunchedEffect(message) {
|
||||||
message?.let {
|
message?.let {
|
||||||
@ -119,15 +112,6 @@ fun ApiManagementScreen(
|
|||||||
contentDescription = stringResource(R.string.action_back)
|
contentDescription = stringResource(R.string.action_back)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
|
||||||
actions = {
|
|
||||||
IconButton(onClick = { showAddPicker = true }) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.Add,
|
|
||||||
contentDescription = stringResource(R.string.api_mgmt_add),
|
|
||||||
tint = StrawberryPink
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@ -140,14 +124,11 @@ fun ApiManagementScreen(
|
|||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding = PaddingValues(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
if (uiState.rows.isEmpty()) {
|
|
||||||
item { EmptyNotesHint(onAdd = { showAddPicker = true }) }
|
|
||||||
}
|
|
||||||
items(uiState.rows, key = { it.service.storageKey }) { row ->
|
items(uiState.rows, key = { it.service.storageKey }) { row ->
|
||||||
ApiRowCard(
|
ApiRowCard(
|
||||||
row = row,
|
row = row,
|
||||||
testing = row.service in testing,
|
testing = row.service in testing,
|
||||||
onEdit = { onConfigure(row.service) },
|
onConfigure = { onConfigure(row.service) },
|
||||||
onTest = { viewModel.test(row.service) },
|
onTest = { viewModel.test(row.service) },
|
||||||
onDelete = { pendingDelete = row.service },
|
onDelete = { pendingDelete = row.service },
|
||||||
onRecharge = { onRecharge(row.service) }
|
onRecharge = { onRecharge(row.service) }
|
||||||
@ -156,17 +137,6 @@ fun ApiManagementScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showAddPicker) {
|
|
||||||
AddServicePickerDialog(
|
|
||||||
addable = uiState.addable,
|
|
||||||
onPick = { service ->
|
|
||||||
showAddPicker = false
|
|
||||||
onConfigure(service)
|
|
||||||
},
|
|
||||||
onDismiss = { showAddPicker = false }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pendingDelete?.let { service ->
|
pendingDelete?.let { service ->
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { pendingDelete = null },
|
onDismissRequest = { pendingDelete = null },
|
||||||
@ -193,137 +163,17 @@ fun ApiManagementScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 空态引导:还没有添加任何条目时展示,给出「+ 添加」动作。 */
|
|
||||||
@Composable
|
|
||||||
private fun EmptyNotesHint(onAdd: () -> Unit) {
|
|
||||||
Card(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
shape = RoundedCornerShape(20.dp),
|
|
||||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(24.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.api_mgmt_empty_title),
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.SemiBold
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.api_mgmt_empty_body),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = inkMuted()
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
Button(
|
|
||||||
onClick = onAdd,
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = StrawberryPink,
|
|
||||||
contentColor = Color.White
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Icon(imageVector = Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
|
||||||
Text(stringResource(R.string.api_mgmt_add))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 「+ 添加」服务选择弹窗:列出尚未添加的服务,点选即进入配置流程。 */
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
private fun AddServicePickerDialog(
|
|
||||||
addable: List<ServiceType>,
|
|
||||||
onPick: (ServiceType) -> Unit,
|
|
||||||
onDismiss: () -> Unit
|
|
||||||
) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
title = { Text(stringResource(R.string.api_mgmt_add_picker_title)) },
|
|
||||||
text = {
|
|
||||||
if (addable.isEmpty()) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.api_mgmt_all_added),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = inkMuted()
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier.heightIn(max = 320.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
|
||||||
) {
|
|
||||||
items(addable, key = { it.storageKey }) { service ->
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clip(RoundedCornerShape(12.dp))
|
|
||||||
.clickable { onPick(service) }
|
|
||||||
.padding(vertical = 8.dp, horizontal = 4.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
ServiceIcon(service = service, size = 36)
|
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
text = service.displayName,
|
|
||||||
style = MaterialTheme.typography.bodyLarge
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = stringResource(serviceDescriptionRes(service)),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = inkMuted()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = onDismiss) {
|
|
||||||
Text(stringResource(R.string.action_cancel))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 添加弹窗里的服务一句话说明(与首页卡片副标题同源,保持口径一致)。 */
|
|
||||||
@Composable
|
|
||||||
private fun serviceDescriptionRes(service: ServiceType): Int = when (service) {
|
|
||||||
ServiceType.DEEPSEEK -> R.string.service_desc_deepseek
|
|
||||||
ServiceType.OPENCODE_GO -> R.string.service_desc_opencode_go
|
|
||||||
ServiceType.COMMANDCODE_GO -> R.string.service_desc_commandcode_go
|
|
||||||
ServiceType.CODEX -> R.string.service_desc_codex
|
|
||||||
ServiceType.OLLAMA -> R.string.service_desc_ollama
|
|
||||||
ServiceType.TRAE -> R.string.service_desc_trae
|
|
||||||
ServiceType.WORKBUDDY -> R.string.service_desc_workbuddy
|
|
||||||
ServiceType.SUB2API -> R.string.service_desc_sub2api
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 已添加条目卡:整卡点击进编辑,行内给测试 / 删除,有官方充值页的服务附充值按钮。 */
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ApiRowCard(
|
private fun ApiRowCard(
|
||||||
row: ApiRowUi,
|
row: ApiRowUi,
|
||||||
testing: Boolean,
|
testing: Boolean,
|
||||||
onEdit: () -> Unit,
|
onConfigure: () -> Unit,
|
||||||
onTest: () -> Unit,
|
onTest: () -> Unit,
|
||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
onRecharge: () -> Unit
|
onRecharge: () -> Unit
|
||||||
) {
|
) {
|
||||||
// 有官方充值/订阅页的服务(ServiceConfig.externalUrl)才展示充值入口
|
|
||||||
val rechargeUrl = remember(row.service) {
|
|
||||||
runCatching { ServiceConfigProvider.get(row.service).externalUrl }.getOrNull()
|
|
||||||
}
|
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth(),
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable { onEdit() },
|
|
||||||
shape = RoundedCornerShape(20.dp),
|
shape = RoundedCornerShape(20.dp),
|
||||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
|
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
|
||||||
@ -339,11 +189,7 @@ private fun ApiRowCard(
|
|||||||
fontWeight = FontWeight.SemiBold
|
fontWeight = FontWeight.SemiBold
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = if (row.isMultiAccountRow) {
|
text = row.maskedCredential ?: stringResource(R.string.api_mgmt_unconfigured),
|
||||||
stringResource(R.string.api_mgmt_accounts_subtitle, row.accountCount)
|
|
||||||
} else {
|
|
||||||
row.maskedCredential ?: stringResource(R.string.api_mgmt_unconfigured)
|
|
||||||
},
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = inkMuted()
|
color = inkMuted()
|
||||||
)
|
)
|
||||||
@ -379,8 +225,7 @@ private fun ApiRowCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
// 测试连接(进行中转圈)+ 删除;多账号服务行内不提供(避免只对默认账号操作 / 误删全部账号)
|
// 测试连接(进行中转圈)+ 删除
|
||||||
if (!row.isMultiAccountRow) {
|
|
||||||
if (testing) {
|
if (testing) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
modifier = Modifier.size(20.dp),
|
modifier = Modifier.size(20.dp),
|
||||||
@ -388,30 +233,45 @@ private fun ApiRowCard(
|
|||||||
strokeWidth = 2.dp
|
strokeWidth = 2.dp
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
TextButton(onClick = onTest) {
|
TextButton(onClick = onTest, enabled = row.isConfigured) {
|
||||||
Text(
|
Text(stringResource(R.string.api_mgmt_test))
|
||||||
stringResource(R.string.api_mgmt_test),
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
IconButton(onClick = onDelete) {
|
IconButton(onClick = onDelete, enabled = row.isConfigured) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.Delete,
|
imageVector = Icons.Filled.Delete,
|
||||||
contentDescription = stringResource(R.string.action_delete),
|
contentDescription = stringResource(R.string.action_delete),
|
||||||
tint = MaterialTheme.colorScheme.error
|
tint = if (row.isConfigured) MaterialTheme.colorScheme.error else inkMuted().copy(alpha = 0.4f)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (rechargeUrl != null) {
|
|
||||||
TextButton(onClick = onRecharge) {
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = onConfigure,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = StrawberryPink,
|
||||||
|
contentColor = Color.White
|
||||||
|
)
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.action_recharge),
|
if (row.isConfigured) {
|
||||||
maxLines = 1,
|
stringResource(R.string.api_mgmt_edit)
|
||||||
overflow = TextOverflow.Ellipsis
|
} else {
|
||||||
|
stringResource(R.string.api_mgmt_configure)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (row.service == ServiceType.DEEPSEEK) {
|
||||||
|
OutlinedButton(onClick = onRecharge, modifier = Modifier.weight(1f)) {
|
||||||
|
Text(stringResource(R.string.action_recharge))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -421,7 +281,7 @@ private fun ApiRowCard(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun StatusDot(state: CredentialStatus.State) {
|
private fun StatusDot(state: CredentialStatus.State) {
|
||||||
val color = when (state) {
|
val color = when (state) {
|
||||||
CredentialStatus.State.OK -> StatusGreen
|
CredentialStatus.State.OK -> Color(0xFF3BB273)
|
||||||
CredentialStatus.State.WARNING -> StatusOrange
|
CredentialStatus.State.WARNING -> StatusOrange
|
||||||
CredentialStatus.State.EXPIRED -> MaterialTheme.colorScheme.error
|
CredentialStatus.State.EXPIRED -> MaterialTheme.colorScheme.error
|
||||||
CredentialStatus.State.NOT_CONFIGURED -> inkMuted().copy(alpha = 0.5f)
|
CredentialStatus.State.NOT_CONFIGURED -> inkMuted().copy(alpha = 0.5f)
|
||||||
|
|||||||
@ -21,11 +21,10 @@ import kotlinx.coroutines.flow.update
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API 管理页 ViewModel(记事本式)。
|
* API 管理页 ViewModel。
|
||||||
*
|
*
|
||||||
* 列表只展示「已添加」的凭据条目(Key 掩码、最后验证、缓存余额),
|
* 按供应商集中展示已保存凭据的状态与摘要(Key 掩码、最后验证、缓存余额),
|
||||||
* 未配置的服务不占位;右上角 + 从 [addable] 里选服务新增。
|
* 提供测试连接 / 删除操作。编辑走既有 credential_edit 页面。
|
||||||
* 提供测试连接 / 删除操作,编辑走既有 credential_edit 页面。
|
|
||||||
*/
|
*/
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class ApiManagementViewModel @Inject constructor(
|
class ApiManagementViewModel @Inject constructor(
|
||||||
@ -50,7 +49,7 @@ class ApiManagementViewModel @Inject constructor(
|
|||||||
fun load() {
|
fun load() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val states = credentialRepository.readLocalStates()
|
val states = credentialRepository.readLocalStates()
|
||||||
val all = ServiceType.entries.map { type ->
|
val rows = ServiceType.entries.map { type ->
|
||||||
val local = states.getValue(type)
|
val local = states.getValue(type)
|
||||||
val cred = credentialRepository.get(type)
|
val cred = credentialRepository.get(type)
|
||||||
ApiRowUi(
|
ApiRowUi(
|
||||||
@ -58,21 +57,10 @@ class ApiManagementViewModel @Inject constructor(
|
|||||||
state = local.status.state,
|
state = local.status.state,
|
||||||
lastVerifiedAt = local.status.lastVerifiedAt,
|
lastVerifiedAt = local.status.lastVerifiedAt,
|
||||||
cachedBalance = local.cachedBalance,
|
cachedBalance = local.cachedBalance,
|
||||||
maskedCredential = maskCredential(cred),
|
maskedCredential = maskCredential(cred)
|
||||||
accountCount = if (ServiceType.isMultiAccount(type)) {
|
|
||||||
credentialRepository.accountsFor(type).size
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
_uiState.update {
|
|
||||||
it.copy(
|
|
||||||
// 记事本式:列表只放已添加的条目,未配置的服务收进「+」选择列表
|
|
||||||
rows = all.filter { row -> row.isConfigured },
|
|
||||||
addable = all.filterNot { row -> row.isConfigured }.map { row -> row.service }
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
_uiState.update { it.copy(rows = rows) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -140,7 +128,6 @@ class ApiManagementViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun errorSummary(error: Throwable?): UiText = when (error) {
|
private fun errorSummary(error: Throwable?): UiText = when (error) {
|
||||||
is RepositoryError.SessionExpired -> UiText.Resource(R.string.error_credential_session_expired)
|
|
||||||
is RepositoryError.InvalidCredential -> UiText.Resource(R.string.api_mgmt_err_invalid)
|
is RepositoryError.InvalidCredential -> UiText.Resource(R.string.api_mgmt_err_invalid)
|
||||||
is RepositoryError.CredentialChanged -> UiText.Resource(R.string.api_mgmt_err_changed)
|
is RepositoryError.CredentialChanged -> UiText.Resource(R.string.api_mgmt_err_changed)
|
||||||
is RepositoryError.RateLimited -> UiText.Resource(R.string.api_mgmt_err_ratelimited)
|
is RepositoryError.RateLimited -> UiText.Resource(R.string.api_mgmt_err_ratelimited)
|
||||||
@ -156,19 +143,11 @@ data class ApiRowUi(
|
|||||||
val state: CredentialStatus.State,
|
val state: CredentialStatus.State,
|
||||||
val lastVerifiedAt: Long,
|
val lastVerifiedAt: Long,
|
||||||
val cachedBalance: CachedBalance?,
|
val cachedBalance: CachedBalance?,
|
||||||
val maskedCredential: String?,
|
val maskedCredential: String?
|
||||||
/** 多账号服务的账号个数;单账号服务为 0。 */
|
|
||||||
val accountCount: Int = 0
|
|
||||||
) {
|
) {
|
||||||
val isConfigured: Boolean get() = state != CredentialStatus.State.NOT_CONFIGURED
|
val isConfigured: Boolean get() = state != CredentialStatus.State.NOT_CONFIGURED
|
||||||
|
|
||||||
/** 多账号服务且已有账号:行内不提供单服务的测试/删除,引导去账号管理页。 */
|
|
||||||
val isMultiAccountRow: Boolean get() = ServiceType.isMultiAccount(service) && accountCount > 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
data class ApiManagementUiState(
|
data class ApiManagementUiState(
|
||||||
/** 已添加的凭据条目(记事本正文)。 */
|
val rows: List<ApiRowUi> = emptyList()
|
||||||
val rows: List<ApiRowUi> = emptyList(),
|
|
||||||
/** 尚未添加的服务(右上角 + 的可选列表)。 */
|
|
||||||
val addable: List<ServiceType> = emptyList()
|
|
||||||
)
|
)
|
||||||
|
|||||||
@ -39,8 +39,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import com.rainy.token.R
|
import com.rainy.token.R
|
||||||
import com.rainy.token.data.debug.DebugLog
|
import com.rainy.token.data.debug.DebugLog
|
||||||
import com.rainy.token.ui.theme.InkMuted
|
import com.rainy.token.ui.theme.InkMuted
|
||||||
import com.rainy.token.ui.theme.StatusOrange
|
|
||||||
import com.rainy.token.ui.theme.StatusRed
|
|
||||||
import com.rainy.token.ui.theme.StrawberryPink
|
import com.rainy.token.ui.theme.StrawberryPink
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@ -105,8 +103,8 @@ fun DebugLogScreen(onBack: () -> Unit) {
|
|||||||
items(entries, key = { it.timestamp.toString() + it.tag + it.message }) { entry ->
|
items(entries, key = { it.timestamp.toString() + it.tag + it.message }) { entry ->
|
||||||
val levelColor = when (entry.level) {
|
val levelColor = when (entry.level) {
|
||||||
DebugLog.Level.INFO -> InkMuted
|
DebugLog.Level.INFO -> InkMuted
|
||||||
DebugLog.Level.WARN -> StatusOrange
|
DebugLog.Level.WARN -> Color(0xFFFFA726)
|
||||||
DebugLog.Level.ERROR -> StatusRed
|
DebugLog.Level.ERROR -> Color(0xFFE91E63)
|
||||||
}
|
}
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
|||||||
@ -13,10 +13,10 @@ import com.rainy.token.ui.theme.DarkSurface
|
|||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 雨晴风格全局背景(精致粉调)。
|
* 雨晴风格全局背景。
|
||||||
*
|
*
|
||||||
* Light 模式:低饱和粉白渐变(#FFF9FB → #FDEFF4,从左上到右下)
|
* Light 模式:樱粉渐变(#FFF0F5 → #FFD1DC,从左上到右下)
|
||||||
* Dark 模式:暖深渐变(#1A1417 → #251C21)
|
* Dark 模式:暖深渐变(#1F1419 → #2A1F25)
|
||||||
*
|
*
|
||||||
* 放在 Scaffold 容器层,所有页面共享同一层渐变。
|
* 放在 Scaffold 容器层,所有页面共享同一层渐变。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -44,7 +44,7 @@ fun ServiceIcon(
|
|||||||
modifier = modifier
|
modifier = modifier
|
||||||
.size(size.dp)
|
.size(size.dp)
|
||||||
.clip(RoundedCornerShape(8.dp))
|
.clip(RoundedCornerShape(8.dp))
|
||||||
.background(if (isSystemInDarkTheme()) Color(0xFF32262C) else Color.White),
|
.background(if (isSystemInDarkTheme()) Color(0xFF352329) else Color.White),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Image(
|
Image(
|
||||||
@ -59,7 +59,7 @@ fun ServiceIcon(
|
|||||||
modifier = modifier
|
modifier = modifier
|
||||||
.size(size.dp)
|
.size(size.dp)
|
||||||
.clip(RoundedCornerShape(8.dp))
|
.clip(RoundedCornerShape(8.dp))
|
||||||
.background(if (isSystemInDarkTheme()) Color(0xFF32262C) else Color.White),
|
.background(if (isSystemInDarkTheme()) Color(0xFF352329) else Color.White),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Image(
|
Image(
|
||||||
@ -120,35 +120,21 @@ fun ServiceIcon(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ServiceType.TRAE -> {
|
ServiceType.TRAE -> {
|
||||||
Box(
|
LetterIcon(
|
||||||
modifier = modifier
|
letter = "T",
|
||||||
.size(size.dp)
|
background = Color(0xFF20242E),
|
||||||
.clip(RoundedCornerShape(8.dp))
|
modifier = modifier,
|
||||||
.background(if (isSystemInDarkTheme()) Color(0xFF20242E) else Color.White),
|
size = size
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Image(
|
|
||||||
painter = painterResource(id = R.drawable.ic_trae_logo),
|
|
||||||
contentDescription = "Trae",
|
|
||||||
modifier = Modifier.fillMaxSize()
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
ServiceType.WORKBUDDY -> {
|
ServiceType.WORKBUDDY -> {
|
||||||
Box(
|
LetterIcon(
|
||||||
modifier = modifier
|
letter = "W",
|
||||||
.size(size.dp)
|
background = Color(0xFF3339FF),
|
||||||
.clip(RoundedCornerShape(8.dp))
|
modifier = modifier,
|
||||||
.background(Color.White),
|
size = size
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Image(
|
|
||||||
painter = painterResource(id = R.drawable.ic_workbuddy_logo),
|
|
||||||
contentDescription = "WorkBuddy",
|
|
||||||
modifier = Modifier.fillMaxSize()
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
ServiceType.SUB2API -> {
|
ServiceType.SUB2API -> {
|
||||||
LetterIcon(
|
LetterIcon(
|
||||||
letter = "S",
|
letter = "S",
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
package com.rainy.token.ui.components
|
package com.rainy.token.ui.components
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
@ -45,14 +44,11 @@ fun StatusChip(
|
|||||||
style: StatusStyle,
|
style: StatusStyle,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
// 暗色模式下胶囊底色是状态色 12% alpha 叠加暗色卡片,
|
|
||||||
// 深色文字几乎不可读,故暗色使用同色系亮色文字。
|
|
||||||
val dark = isSystemInDarkTheme()
|
|
||||||
val (dotColor, textColor) = when (style.level) {
|
val (dotColor, textColor) = when (style.level) {
|
||||||
StatusLevel.OK -> StatusGreen to if (dark) Color(0xFF9CE8BC) else Color(0xFF1B5E20)
|
StatusLevel.OK -> StatusGreen to Color(0xFF1B5E20)
|
||||||
StatusLevel.WARNING -> StatusOrange to if (dark) Color(0xFFFFDBA6) else Color(0xFF8A4A00)
|
StatusLevel.WARNING -> StatusOrange to Color(0xFF8A4A00)
|
||||||
StatusLevel.ERROR -> StatusRed to if (dark) Color(0xFFFFB6CA) else Color(0xFF8A1F40)
|
StatusLevel.ERROR -> StatusRed to Color(0xFF8B0033)
|
||||||
StatusLevel.INFO -> StatusBlue to if (dark) Color(0xFFAED7FF) else Color(0xFF0D47A1)
|
StatusLevel.INFO -> StatusBlue to Color(0xFF0D47A1)
|
||||||
}
|
}
|
||||||
val bgColor = dotColor.copy(alpha = 0.12f)
|
val bgColor = dotColor.copy(alpha = 0.12f)
|
||||||
Row(
|
Row(
|
||||||
|
|||||||
@ -38,8 +38,6 @@ import androidx.compose.material.icons.filled.Refresh
|
|||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.material3.SwitchDefaults
|
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
@ -86,12 +84,10 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.rainy.token.data.repository.CredentialRepository
|
|
||||||
import com.rainy.token.BuildConfig
|
import com.rainy.token.BuildConfig
|
||||||
import com.rainy.token.R
|
import com.rainy.token.R
|
||||||
import com.rainy.token.domain.model.CredentialStatus
|
import com.rainy.token.domain.model.CredentialStatus
|
||||||
import com.rainy.token.domain.model.ServiceBalance
|
import com.rainy.token.domain.model.ServiceBalance
|
||||||
import com.rainy.token.domain.service.ServiceConfigProvider
|
|
||||||
import com.rainy.token.domain.service.ServiceType
|
import com.rainy.token.domain.service.ServiceType
|
||||||
import com.rainy.token.ui.components.ServiceIcon
|
import com.rainy.token.ui.components.ServiceIcon
|
||||||
import com.rainy.token.ui.components.SiteIcon
|
import com.rainy.token.ui.components.SiteIcon
|
||||||
@ -100,10 +96,7 @@ import com.rainy.token.ui.components.StatusLevel
|
|||||||
import com.rainy.token.ui.components.StatusStyle
|
import com.rainy.token.ui.components.StatusStyle
|
||||||
import com.rainy.token.ui.components.AppTips
|
import com.rainy.token.ui.components.AppTips
|
||||||
import com.rainy.token.ui.components.asString
|
import com.rainy.token.ui.components.asString
|
||||||
import com.rainy.token.ui.dashboard.autocheckin.AutoCheckinManager
|
|
||||||
import com.rainy.token.ui.theme.inkMuted
|
import com.rainy.token.ui.theme.inkMuted
|
||||||
import com.rainy.token.ui.theme.PureWhite
|
|
||||||
import com.rainy.token.ui.theme.inkWarm
|
|
||||||
import com.rainy.token.ui.theme.StrawberryPink
|
import com.rainy.token.ui.theme.StrawberryPink
|
||||||
import android.app.AppOpsManager
|
import android.app.AppOpsManager
|
||||||
import android.appwidget.AppWidgetManager
|
import android.appwidget.AppWidgetManager
|
||||||
@ -134,22 +127,19 @@ import java.util.Locale
|
|||||||
fun DashboardScreen(
|
fun DashboardScreen(
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
onOpenApiManagement: () -> Unit,
|
onOpenApiManagement: () -> Unit,
|
||||||
onOpenService: (ServiceType, String?) -> Unit,
|
onOpenUsageStat: () -> Unit,
|
||||||
|
onOpenService: (ServiceType) -> Unit,
|
||||||
onOpenUsageDetail: () -> Unit,
|
onOpenUsageDetail: () -> Unit,
|
||||||
onOpenCcgoUsageDetail: () -> Unit = {},
|
onOpenCcgoUsageDetail: () -> Unit = {},
|
||||||
onOpenHeatmap: () -> Unit = {},
|
onOpenHeatmap: () -> Unit = {},
|
||||||
onOpenRecharge: (ServiceType) -> Unit = {},
|
|
||||||
viewModel: DashboardViewModel = hiltViewModel()
|
viewModel: DashboardViewModel = hiltViewModel()
|
||||||
) {
|
) {
|
||||||
// 从设置页返回时重新读取本地凭据状态 + 缓存;若有已配置但无余额缓存的
|
// 从设置页返回时重新读取本地凭据状态 + 缓存(不自动发起网络请求)
|
||||||
// 新账号(刚登录/导入),ViewModel 会自动拉一次余额让卡片立刻有数据
|
|
||||||
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
|
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
|
||||||
viewModel.reloadLocalState()
|
viewModel.reloadLocalState()
|
||||||
}
|
}
|
||||||
|
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
// 自动签到汇报(每日首次打开 App 签到后弹一次)
|
|
||||||
val autoCheckinReport by viewModel.autoCheckinReport.collectAsStateWithLifecycle()
|
|
||||||
// Sub2API 面板管理弹窗数据源
|
// Sub2API 面板管理弹窗数据源
|
||||||
val panelKeysState by viewModel.panelKeysState.collectAsStateWithLifecycle()
|
val panelKeysState by viewModel.panelKeysState.collectAsStateWithLifecycle()
|
||||||
val panelGroups by viewModel.panelGroups.collectAsStateWithLifecycle()
|
val panelGroups by viewModel.panelGroups.collectAsStateWithLifecycle()
|
||||||
@ -174,14 +164,8 @@ fun DashboardScreen(
|
|||||||
?.split(',')
|
?.split(',')
|
||||||
?.filter { it.isNotBlank() }
|
?.filter { it.isNotBlank() }
|
||||||
.orEmpty()
|
.orEmpty()
|
||||||
// 兼容历史排序 id:
|
|
||||||
// - 最早的格式 "service:{storageKey}"(单账号服务)→ "{storageKey}:"
|
|
||||||
// - 迁移后旧格式 "{storageKey}:" / "{storageKey}:default" 以及任意 "{storageKey}:{账号id}"
|
|
||||||
// (多账号服务)→ 服务组 id "group:{storageKey}"(叠放卡组)
|
|
||||||
// - 其余(用量卡/热力图卡等)原样保留
|
|
||||||
val migrated = saved.mapNotNull { id -> migrateOrderId(id) }.distinct()
|
|
||||||
cardOrder.clear()
|
cardOrder.clear()
|
||||||
cardOrder.addAll(migrated)
|
cardOrder.addAll(saved)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 全局刷新触发器——每次 dashboard 刷新完成后 +1,UsageStatsCard 据此同步用量数据
|
// 全局刷新触发器——每次 dashboard 刷新完成后 +1,UsageStatsCard 据此同步用量数据
|
||||||
@ -218,45 +202,6 @@ fun DashboardScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 每日自动签到结果汇报:「xx 已签到,积分增加 x」/ 已签 / 失败原因
|
|
||||||
if (autoCheckinReport != null) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = viewModel::consumeAutoCheckinReport,
|
|
||||||
title = { Text(stringResource(R.string.auto_checkin_dialog_title)) },
|
|
||||||
text = {
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
|
||||||
autoCheckinReport!!.forEach { r ->
|
|
||||||
val serviceName = r.accountLabel?.let {
|
|
||||||
r.service.displayName + " · " + it
|
|
||||||
} ?: r.service.displayName
|
|
||||||
Text(
|
|
||||||
text = when {
|
|
||||||
r.failed && r.message.isNotBlank() ->
|
|
||||||
stringResource(R.string.auto_checkin_line_failed, serviceName, r.message)
|
|
||||||
r.failed ->
|
|
||||||
stringResource(R.string.auto_checkin_line_failed_generic, serviceName)
|
|
||||||
r.newlyCheckedIn && r.creditsGained > 0 ->
|
|
||||||
stringResource(R.string.auto_checkin_line_gained, serviceName, r.creditsGained)
|
|
||||||
// 今日确实已签(成功或服务端回报已签)→ 「今日已签到」
|
|
||||||
r.checkedInToday ->
|
|
||||||
stringResource(R.string.auto_checkin_line_already, serviceName)
|
|
||||||
// 其余(静默跳过等)不出现在汇报里,兜底走失败提示
|
|
||||||
else ->
|
|
||||||
stringResource(R.string.auto_checkin_line_failed_generic, serviceName)
|
|
||||||
},
|
|
||||||
style = MaterialTheme.typography.bodyMedium
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = viewModel::consumeAutoCheckinReport) {
|
|
||||||
Text(stringResource(R.string.action_confirm), color = StrawberryPink)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
containerColor = Color.Transparent,
|
containerColor = Color.Transparent,
|
||||||
topBar = {
|
topBar = {
|
||||||
@ -283,6 +228,13 @@ fun DashboardScreen(
|
|||||||
tint = StrawberryPink
|
tint = StrawberryPink
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
IconButton(onClick = onOpenUsageStat) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.DateRange,
|
||||||
|
contentDescription = stringResource(R.string.usage_stat_title),
|
||||||
|
tint = StrawberryPink
|
||||||
|
)
|
||||||
|
}
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = { viewModel.refresh() },
|
onClick = { viewModel.refresh() },
|
||||||
enabled = !uiState.refreshing
|
enabled = !uiState.refreshing
|
||||||
@ -346,49 +298,14 @@ fun DashboardScreen(
|
|||||||
.padding(contentPadding),
|
.padding(contentPadding),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
// 随机小技巧提示(每次启动随机一条)+ 最右侧自动签到开关
|
// 随机小技巧提示(每次启动随机一条)
|
||||||
val tipTextRes = remember { AppTips.randomHintRes() }
|
val tipTextRes = remember { AppTips.randomHintRes() }
|
||||||
val autoCheckinEnabled by viewModel.autoCheckinEnabled.collectAsStateWithLifecycle()
|
|
||||||
Card(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
shape = RoundedCornerShape(16.dp),
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surface
|
|
||||||
),
|
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
Text(
|
||||||
text = "💡 " + stringResource(tipTextRes),
|
text = "💡 " + stringResource(tipTextRes),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = inkMuted(),
|
color = inkMuted(),
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.padding(horizontal = 4.dp)
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.auto_checkin_switch_label),
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = inkWarm(),
|
|
||||||
fontWeight = FontWeight.SemiBold
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(2.dp))
|
|
||||||
Switch(
|
|
||||||
checked = autoCheckinEnabled,
|
|
||||||
onCheckedChange = viewModel::setAutoCheckinEnabled,
|
|
||||||
colors = SwitchDefaults.colors(
|
|
||||||
checkedThumbColor = PureWhite,
|
|
||||||
checkedTrackColor = StrawberryPink,
|
|
||||||
checkedBorderColor = StrawberryPink,
|
|
||||||
uncheckedThumbColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
uncheckedTrackColor = MaterialTheme.colorScheme.surfaceVariant,
|
|
||||||
uncheckedBorderColor = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val items = rememberDashboardItems(
|
val items = rememberDashboardItems(
|
||||||
cards = uiState.cards,
|
cards = uiState.cards,
|
||||||
order = cardOrder,
|
order = cardOrder,
|
||||||
@ -397,11 +314,7 @@ fun DashboardScreen(
|
|||||||
onOpenCcgoUsageDetail = onOpenCcgoUsageDetail,
|
onOpenCcgoUsageDetail = onOpenCcgoUsageDetail,
|
||||||
onOpenService = onOpenService,
|
onOpenService = onOpenService,
|
||||||
onOpenHeatmap = onOpenHeatmap,
|
onOpenHeatmap = onOpenHeatmap,
|
||||||
onOpenSub2PanelManagement = { accountId ->
|
onOpenSub2PanelManagement = { showSub2PanelManagement = true },
|
||||||
viewModel.openSub2Panel(accountId)
|
|
||||||
showSub2PanelManagement = true
|
|
||||||
},
|
|
||||||
onOpenRecharge = onOpenRecharge,
|
|
||||||
refreshTrigger = usageSyncTrigger
|
refreshTrigger = usageSyncTrigger
|
||||||
)
|
)
|
||||||
DraggableDashboardCards(
|
DraggableDashboardCards(
|
||||||
@ -469,12 +382,6 @@ fun DashboardScreen(
|
|||||||
if (showSub2PanelManagement) {
|
if (showSub2PanelManagement) {
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
val maxDialogHeight = (LocalConfiguration.current.screenHeightDp * 0.72f).dp
|
val maxDialogHeight = (LocalConfiguration.current.screenHeightDp * 0.72f).dp
|
||||||
// 面板弹窗按当前选中的账号取余额:默认账号 accountId=null,多账号为具体 ID;
|
|
||||||
// 找不到匹配卡(如刚新建账号尚无余额)时回退到第一张 Sub2API 卡,避免显示空。
|
|
||||||
val panelAccountId by viewModel.sub2PanelAccountId.collectAsStateWithLifecycle()
|
|
||||||
val panelBalanceCard = uiState.cards.firstOrNull {
|
|
||||||
it.service == ServiceType.SUB2API && it.accountId == panelAccountId
|
|
||||||
} ?: uiState.cards.firstOrNull { it.service == ServiceType.SUB2API }
|
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { showSub2PanelManagement = false },
|
onDismissRequest = { showSub2PanelManagement = false },
|
||||||
// 标题与刷新按钮同一行:刷新重新拉取 Key 列表与分组
|
// 标题与刷新按钮同一行:刷新重新拉取 Key 列表与分组
|
||||||
@ -501,7 +408,7 @@ fun DashboardScreen(
|
|||||||
.verticalScroll(scrollState)
|
.verticalScroll(scrollState)
|
||||||
) {
|
) {
|
||||||
com.rainy.token.ui.servicedetail.Sub2ApiPanelManagement(
|
com.rainy.token.ui.servicedetail.Sub2ApiPanelManagement(
|
||||||
balance = panelBalanceCard?.cachedBalance?.balance,
|
balance = uiState.cards.firstOrNull { it.service == ServiceType.SUB2API }?.cachedBalance?.balance,
|
||||||
keysState = panelKeysState,
|
keysState = panelKeysState,
|
||||||
groups = panelGroups,
|
groups = panelGroups,
|
||||||
actionMessage = panelActionMessage,
|
actionMessage = panelActionMessage,
|
||||||
@ -546,10 +453,9 @@ private fun rememberDashboardItems(
|
|||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
onOpenUsageDetail: () -> Unit,
|
onOpenUsageDetail: () -> Unit,
|
||||||
onOpenCcgoUsageDetail: () -> Unit,
|
onOpenCcgoUsageDetail: () -> Unit,
|
||||||
onOpenService: (ServiceType, String?) -> Unit,
|
onOpenService: (ServiceType) -> Unit,
|
||||||
onOpenHeatmap: () -> Unit,
|
onOpenHeatmap: () -> Unit,
|
||||||
onOpenSub2PanelManagement: (String?) -> Unit,
|
onOpenSub2PanelManagement: () -> Unit,
|
||||||
onOpenRecharge: (ServiceType) -> Unit,
|
|
||||||
refreshTrigger: Int
|
refreshTrigger: Int
|
||||||
): List<DashboardHomeItem> {
|
): List<DashboardHomeItem> {
|
||||||
val defaultItems = buildList {
|
val defaultItems = buildList {
|
||||||
@ -580,19 +486,11 @@ private fun rememberDashboardItems(
|
|||||||
HeatmapEntryCard(onOpenHeatmap = onOpenHeatmap)
|
HeatmapEntryCard(onOpenHeatmap = onOpenHeatmap)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// 服务卡片:每个账号一张普通卡(同服务多账号即多张连续卡片,不叠放)
|
|
||||||
cards.forEach { card ->
|
cards.forEach { card ->
|
||||||
// 多账号卡片用“账号 N”兜底显示名(同服务内默认账号在前,按展示顺序编号)
|
add(DashboardHomeItem("service:${card.service.storageKey}") {
|
||||||
add(DashboardHomeItem(card.key) {
|
|
||||||
DashboardCard(
|
DashboardCard(
|
||||||
card = card,
|
card = card,
|
||||||
accountLabel = if (card.accountId != null) {
|
onClick = { onOpenService(card.service) },
|
||||||
card.accountLabel?.takeIf { it.isNotBlank() }
|
|
||||||
?: stringResource(R.string.account_fallback_name, accountOrdinal(cards, card))
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
onClick = { onOpenService(card.service, card.accountId) },
|
|
||||||
onOpenUsageDetail = when (card.service) {
|
onOpenUsageDetail = when (card.service) {
|
||||||
ServiceType.OPENCODE_GO -> onOpenUsageDetail
|
ServiceType.OPENCODE_GO -> onOpenUsageDetail
|
||||||
ServiceType.COMMANDCODE_GO -> onOpenCcgoUsageDetail
|
ServiceType.COMMANDCODE_GO -> onOpenCcgoUsageDetail
|
||||||
@ -600,15 +498,9 @@ private fun rememberDashboardItems(
|
|||||||
},
|
},
|
||||||
// Token 活动热力图入口仅 OCGO 服务卡片显示(数据只统计 OCGO)
|
// Token 活动热力图入口仅 OCGO 服务卡片显示(数据只统计 OCGO)
|
||||||
onOpenHeatmap = if (card.service == ServiceType.OPENCODE_GO) onOpenHeatmap else null,
|
onOpenHeatmap = if (card.service == ServiceType.OPENCODE_GO) onOpenHeatmap else null,
|
||||||
// Sub2API 面板管理快捷入口(首页弹窗):绑定当前卡片所属账号
|
// Sub2API 面板管理快捷入口(首页弹窗)
|
||||||
onOpenPanelManagement = if (card.service == ServiceType.SUB2API) {
|
onOpenPanelManagement = if (card.service == ServiceType.SUB2API) {
|
||||||
{ accountId -> onOpenSub2PanelManagement(accountId ?: card.accountId) }
|
{ onOpenSub2PanelManagement() }
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
// 充值/订阅入口:有官方 externalUrl 的服务(DeepSeek/Trae/WorkBuddy)在卡片上直开
|
|
||||||
onOpenRecharge = if (ServiceConfigProvider.get(card.service).externalUrl != null) {
|
|
||||||
{ onOpenRecharge(card.service) }
|
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
@ -617,54 +509,10 @@ private fun rememberDashboardItems(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
val itemById = defaultItems.associateBy { it.id }
|
val itemById = defaultItems.associateBy { it.id }
|
||||||
val ordered = order.mapNotNull { id ->
|
val ordered = order.mapNotNull { itemById[it] }
|
||||||
// 回退兼容:叠放实验期间保存过 "group:{storageKey}" 排序 id 的用户,
|
|
||||||
// 旧 id 映射为当前该服务第一张账号卡 key,避免升级回退后卡片掉尾
|
|
||||||
if (id.startsWith("group:")) {
|
|
||||||
val serviceKey = id.removePrefix("group:")
|
|
||||||
defaultItems.firstOrNull { it.id.startsWith("$serviceKey:") && it.id != id }?.id ?: id
|
|
||||||
} else {
|
|
||||||
id
|
|
||||||
}
|
|
||||||
}.mapNotNull { itemById[it] }
|
|
||||||
return ordered + defaultItems.filterNot { item -> ordered.any { it.id == item.id } }
|
return ordered + defaultItems.filterNot { item -> ordered.any { it.id == item.id } }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
/** 同服务内账号序号(1 起,用于“账号 N”兜底显示)。 */
|
|
||||||
private fun accountOrdinal(cards: List<DashboardCardUi>, card: DashboardCardUi): Int {
|
|
||||||
var idx = 1
|
|
||||||
for (c in cards) {
|
|
||||||
if (c.key == card.key) break
|
|
||||||
if (c.service == card.service) idx++
|
|
||||||
}
|
|
||||||
return idx
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 历史排序 id → 当前 id。返回 null 表示该 id 无法对应任何卡片(忽略)。 */
|
|
||||||
private fun migrateOrderId(id: String): String? {
|
|
||||||
// 最早格式:"service:{storageKey}"
|
|
||||||
val servicePrefix = Regex("""^service:([^/:]+)$""").find(id)?.groupValues?.get(1)
|
|
||||||
if (servicePrefix != null) {
|
|
||||||
val svc = ServiceType.entries.firstOrNull { it.storageKey == servicePrefix } ?: return null
|
|
||||||
return if (ServiceType.isMultiAccount(svc)) {
|
|
||||||
"${svc.storageKey}:${CredentialRepository.LEGACY_DEFAULT_ACCOUNT_ID}"
|
|
||||||
} else {
|
|
||||||
"${svc.storageKey}:"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 旧默认账号 key:多账号服务 "{key}:" → "{key}:default"(其余账号 key 原样保留)
|
|
||||||
val sep = id.indexOf(':')
|
|
||||||
if (sep > 0) {
|
|
||||||
val key = id.substring(0, sep)
|
|
||||||
val svc = ServiceType.entries.firstOrNull { it.storageKey == key } ?: return id
|
|
||||||
if (ServiceType.isMultiAccount(svc) && id.endsWith(":")) {
|
|
||||||
return "$key:${CredentialRepository.LEGACY_DEFAULT_ACCOUNT_ID}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DraggableDashboardCards(
|
private fun DraggableDashboardCards(
|
||||||
items: List<DashboardHomeItem>,
|
items: List<DashboardHomeItem>,
|
||||||
@ -902,12 +750,10 @@ private fun DraggableDashboardCards(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun DashboardCard(
|
private fun DashboardCard(
|
||||||
card: DashboardCardUi,
|
card: DashboardCardUi,
|
||||||
accountLabel: String? = null,
|
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onOpenUsageDetail: (() -> Unit)? = null,
|
onOpenUsageDetail: (() -> Unit)? = null,
|
||||||
onOpenHeatmap: (() -> Unit)? = null,
|
onOpenHeatmap: (() -> Unit)? = null,
|
||||||
onOpenPanelManagement: ((String?) -> Unit)? = null,
|
onOpenPanelManagement: (() -> Unit)? = null,
|
||||||
onOpenRecharge: (() -> Unit)? = null,
|
|
||||||
) {
|
) {
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@ -928,60 +774,22 @@ private fun DashboardCard(
|
|||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Text(
|
Text(
|
||||||
text = card.service.displayName,
|
text = card.service.displayName,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.SemiBold
|
fontWeight = FontWeight.SemiBold
|
||||||
)
|
)
|
||||||
// 多账号:默认账号在卡片标题旁标「默认」
|
|
||||||
if (card.accountId != null && card.isDefaultAccount) {
|
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.account_default_badge),
|
// Sub2API 显示登录方式(邮箱登录 / API 登录),其余服务用固定描述
|
||||||
style = MaterialTheme.typography.labelSmall,
|
text = if (card.service == ServiceType.SUB2API) {
|
||||||
color = StrawberryPink,
|
sub2AuthModeText(card)
|
||||||
maxLines = 1,
|
|
||||||
softWrap = false,
|
|
||||||
modifier = Modifier
|
|
||||||
.clip(RoundedCornerShape(8.dp))
|
|
||||||
.background(StrawberryPink.copy(alpha = 0.12f))
|
|
||||||
.padding(horizontal = 6.dp, vertical = 1.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
// Sub2API 显示登录方式;Trae/WorkBuddy 显示「账号 N · 订阅状态」;
|
|
||||||
// 其余服务用固定描述
|
|
||||||
text = when (card.service) {
|
|
||||||
ServiceType.SUB2API -> sub2AuthModeText(card)
|
|
||||||
ServiceType.TRAE, ServiceType.WORKBUDDY -> {
|
|
||||||
val sub = subscriptionLine(card) ?: stringResource(secondaryLineRes(card))
|
|
||||||
if (card.accountId != null && accountLabel != null) {
|
|
||||||
"$accountLabel · $sub"
|
|
||||||
} else {
|
} else {
|
||||||
sub
|
stringResource(secondaryLineRes(card))
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> stringResource(secondaryLineRes(card))
|
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = inkMuted()
|
color = inkMuted()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// 今日已签到角标(Trae/WorkBuddy):让用户在首页即知签到状态,不用点进详情
|
|
||||||
if (card.checkedInToday) {
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.checkin_badge_today),
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = StrawberryPink,
|
|
||||||
modifier = Modifier
|
|
||||||
.clip(RoundedCornerShape(8.dp))
|
|
||||||
.background(StrawberryPink.copy(alpha = 0.12f))
|
|
||||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
StatusChip(style = card.statusBadgeStyle())
|
StatusChip(style = card.statusBadgeStyle())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1032,7 +840,7 @@ private fun DashboardCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 底部:左侧更新时间/错误信息,右侧「面板管理 / 充值订阅」入口(同一行、右对齐)───
|
// ─── 底部:左侧更新时间/错误信息,右侧「面板管理」入口(同一行、右对齐)───
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
Text(
|
||||||
@ -1056,7 +864,7 @@ private fun DashboardCard(
|
|||||||
// 注意:Material3 的最小尺寸挂在 TextButton 内层节点上,外层 modifier 传
|
// 注意:Material3 的最小尺寸挂在 TextButton 内层节点上,外层 modifier 传
|
||||||
// defaultMinSize(0) 压不下去(实测为 no-op),这里不再写无用约束。
|
// defaultMinSize(0) 压不下去(实测为 no-op),这里不再写无用约束。
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = { onOpenPanelManagement?.invoke(card.accountId) },
|
onClick = onOpenPanelManagement,
|
||||||
contentPadding = PaddingValues(0.dp)
|
contentPadding = PaddingValues(0.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
@ -1077,32 +885,6 @@ private fun DashboardCard(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 充值/订阅入口(DeepSeek/Trae/WorkBuddy 等有官方 externalUrl 的服务):
|
|
||||||
// 与「面板管理」同款式同位置,点开 App 内 WebView 到官方充值/订阅页
|
|
||||||
if (onOpenRecharge != null &&
|
|
||||||
card.credentialState != CredentialStatus.State.NOT_CONFIGURED
|
|
||||||
) {
|
|
||||||
TextButton(
|
|
||||||
onClick = onOpenRecharge,
|
|
||||||
contentPadding = PaddingValues(0.dp),
|
|
||||||
modifier = Modifier.padding(start = 8.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.action_subscribe_recharge),
|
|
||||||
color = StrawberryPink,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(2.dp))
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.KeyboardArrowRight,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = StrawberryPink,
|
|
||||||
modifier = Modifier.size(14.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,12 +13,10 @@ import com.rainy.token.domain.service.ServiceType
|
|||||||
import com.rainy.token.domain.usecase.RefreshBalanceUseCase
|
import com.rainy.token.domain.usecase.RefreshBalanceUseCase
|
||||||
import com.rainy.token.R
|
import com.rainy.token.R
|
||||||
import com.rainy.token.ui.components.UiText
|
import com.rainy.token.ui.components.UiText
|
||||||
import com.rainy.token.ui.dashboard.autocheckin.CHECKIN_SERVICES
|
|
||||||
import com.rainy.token.ui.widget.OpenCodeGoWidgetProvider
|
import com.rainy.token.ui.widget.OpenCodeGoWidgetProvider
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.CancellationException
|
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.awaitAll
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
@ -41,8 +39,6 @@ import kotlinx.coroutines.sync.Mutex
|
|||||||
class DashboardViewModel @Inject constructor(
|
class DashboardViewModel @Inject constructor(
|
||||||
private val credentialRepository: CredentialRepository,
|
private val credentialRepository: CredentialRepository,
|
||||||
private val refreshBalanceUseCase: RefreshBalanceUseCase,
|
private val refreshBalanceUseCase: RefreshBalanceUseCase,
|
||||||
private val checkinStatusStore: com.rainy.token.data.repository.CheckinStatusStore,
|
|
||||||
private val autoCheckinManager: com.rainy.token.ui.dashboard.autocheckin.AutoCheckinManager,
|
|
||||||
@param:ApplicationContext private val appContext: Context,
|
@param:ApplicationContext private val appContext: Context,
|
||||||
private val sub2ApiRepository: com.rainy.token.data.repository.Sub2ApiRepository
|
private val sub2ApiRepository: com.rainy.token.data.repository.Sub2ApiRepository
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
@ -55,84 +51,15 @@ class DashboardViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
// 1) 先从缓存快速填充 UI(不阻塞首帧,断网时也能看旧数据)
|
// 1) 先从缓存快速填充 UI(断网时也能看旧数据)
|
||||||
loadFromCache()
|
loadFromCache()
|
||||||
// 2) 每日自动签到:必须先于 refresh() 串行完成。两者并发会对同一服务取
|
// 2) 再发起网络刷新——此时 UI 已有缓存兜底,不会闪回旧值
|
||||||
// 相同凭据快照,后提交者因快照过期被丢弃(CredentialChanged),而签到
|
|
||||||
// 在服务端已成功、角标也已落库 → 弹窗报「凭据」错误但积分实际到账,
|
|
||||||
// 展示自相矛盾。串行后签到先落库,refresh 再拉到含新积分的余额。
|
|
||||||
// 用 try/catch 而非 runCatching:后者会吞掉 CancellationException,
|
|
||||||
// 导致 ViewModel 销毁取消后协程仍继续往下跑。
|
|
||||||
// 2.5) 打开 App 时把零点后台闹钟重排到下次零点(开机/升级/后台被杀后的兜底)
|
|
||||||
autoCheckinManager.refreshSchedule()
|
|
||||||
try {
|
|
||||||
autoCheckinManager.runDailyIfDue()
|
|
||||||
} catch (cancelled: CancellationException) {
|
|
||||||
throw cancelled
|
|
||||||
} catch (_: Exception) {
|
|
||||||
// 签到失败不阻塞余额刷新
|
|
||||||
}
|
|
||||||
// 3) 发起网络刷新——此时 UI 已有缓存兜底,不会闪回旧值
|
|
||||||
refresh()
|
refresh()
|
||||||
// 4) 同步签到状态到本地记录并重建角标(跨设备/旧版本签过的也能显示)
|
|
||||||
syncCheckinStatus()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 首页自己查询签到状态(不依赖进详情页触发):
|
* 重新读取本地凭据状态 + 余额缓存,不发起网络请求。
|
||||||
* 本地无当日记录的账号问一次服务端 status,已签的回写 [CheckinStatusStore] 后重建卡片角标。
|
|
||||||
*/
|
|
||||||
private suspend fun syncCheckinStatus() {
|
|
||||||
val accountStates = credentialRepository.readAccountStates()
|
|
||||||
val toQuery = accountStates.filter { entry ->
|
|
||||||
entry.service in CHECKIN_SERVICES &&
|
|
||||||
entry.credential != null &&
|
|
||||||
!checkinStatusStore.isCheckedToday(entry.service, entry.account?.id)
|
|
||||||
}
|
|
||||||
if (toQuery.isEmpty()) return
|
|
||||||
val newlyChecked = toQuery.filter { entry ->
|
|
||||||
runCatching {
|
|
||||||
when (entry.service) {
|
|
||||||
ServiceType.TRAE ->
|
|
||||||
refreshBalanceUseCase.fetchTraeCheckinStatus(entry.account?.id).getOrNull() == true
|
|
||||||
ServiceType.WORKBUDDY ->
|
|
||||||
refreshBalanceUseCase.fetchCheckinStatus(entry.account?.id).getOrNull()?.todayCheckedIn == true
|
|
||||||
else -> false
|
|
||||||
}
|
|
||||||
}.getOrDefault(false)
|
|
||||||
}
|
|
||||||
if (newlyChecked.isEmpty()) return
|
|
||||||
newlyChecked.forEach { checkinStatusStore.markChecked(it.service, it.account?.id) }
|
|
||||||
// 用最新记录更新卡片角标:按 store 真值重算(已有记录的账号不被误清)
|
|
||||||
_uiState.update { state ->
|
|
||||||
state.copy(cards = state.cards.map { card ->
|
|
||||||
card.copy(
|
|
||||||
checkedInToday = card.service in CHECKIN_SERVICES &&
|
|
||||||
checkinStatusStore.isCheckedToday(card.service, card.accountId)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 自动签到结果(非 null 时首页弹「xx 已签到」汇报弹窗;关闭后置 null)。 */
|
|
||||||
val autoCheckinReport: StateFlow<List<com.rainy.token.ui.dashboard.autocheckin.AutoCheckinManager.ServiceResult>?> =
|
|
||||||
autoCheckinManager.report
|
|
||||||
|
|
||||||
/** 自动签到开关状态(首页顶部提示行右侧 Switch)。 */
|
|
||||||
private val _autoCheckinEnabled = MutableStateFlow(autoCheckinManager.enabled)
|
|
||||||
val autoCheckinEnabled: StateFlow<Boolean> = _autoCheckinEnabled.asStateFlow()
|
|
||||||
|
|
||||||
fun setAutoCheckinEnabled(value: Boolean) {
|
|
||||||
autoCheckinManager.enabled = value
|
|
||||||
_autoCheckinEnabled.value = value
|
|
||||||
}
|
|
||||||
|
|
||||||
fun consumeAutoCheckinReport() = autoCheckinManager.consumeReport()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 重新读取本地凭据状态 + 余额缓存,不自动发起网络请求(除非发现已配置
|
|
||||||
* 但没有余额缓存的"新账号",此时拉一次余额让首页立刻有数据,不用等进详情页)。
|
|
||||||
*
|
*
|
||||||
* 卡片记录不含明文密钥的 SHA-256 指纹。更新时始终基于 _uiState 的最新卡片:
|
* 卡片记录不含明文密钥的 SHA-256 指纹。更新时始终基于 _uiState 的最新卡片:
|
||||||
* - 以当前已配置服务(configuredTypes)为准重建,新增服务即时出现、删除服务即时消失;
|
* - 以当前已配置服务(configuredTypes)为准重建,新增服务即时出现、删除服务即时消失;
|
||||||
@ -143,64 +70,92 @@ class DashboardViewModel @Inject constructor(
|
|||||||
*/
|
*/
|
||||||
fun reloadLocalState() {
|
fun reloadLocalState() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val accountStates = credentialRepository.readAccountStates()
|
val localStates = credentialRepository.readLocalStates()
|
||||||
|
val configured = configuredTypes(localStates).toSet()
|
||||||
_uiState.update { state ->
|
_uiState.update { state ->
|
||||||
state.copy(cards = rebuildCardsPreservingOrder(state, accountStates))
|
val previousByService = state.cards.associateBy { it.service }
|
||||||
|
// 保留用户既有顺序;新配置的服务追加到尾部(按枚举顺序)
|
||||||
|
val existingOrder = state.cards
|
||||||
|
.mapIndexed { index, card -> card.service to index }
|
||||||
|
.toMap()
|
||||||
|
val orderedTypes = configured.sortedWith(
|
||||||
|
compareBy({ existingOrder[it] ?: Int.MAX_VALUE }, { it.ordinal })
|
||||||
|
)
|
||||||
|
state.copy(
|
||||||
|
cards = orderedTypes.map { type ->
|
||||||
|
val local = localStates.getValue(type)
|
||||||
|
val card = previousByService[type]
|
||||||
|
if (card == null) {
|
||||||
|
buildCard(local, lastFetchError = null)
|
||||||
|
} else {
|
||||||
|
val credentialChanged = local.fingerprint != card.credentialFingerprint
|
||||||
|
val cacheAdvanced = !credentialChanged && isNewer(
|
||||||
|
candidate = local.cachedBalance,
|
||||||
|
current = card.cachedBalance
|
||||||
|
)
|
||||||
|
card.copy(
|
||||||
|
credentialState = local.status.state,
|
||||||
|
credentialFingerprint = local.fingerprint,
|
||||||
|
cachedBalance = if (credentialChanged) {
|
||||||
|
local.cachedBalance
|
||||||
|
} else {
|
||||||
|
newerOf(card.cachedBalance, local.cachedBalance)
|
||||||
|
},
|
||||||
|
// 凭据变化或出现更新的成功缓存时,旧错误已不再代表当前数据。
|
||||||
|
lastFetchError = if (credentialChanged || cacheAdvanced) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
card.lastFetchError
|
||||||
}
|
}
|
||||||
// 登录/导入后刚出现的账号还没有余额缓存:立即拉一次,首页即刻看到
|
)
|
||||||
// 余额与角标,不用等用户点进详情页才触发刷新(刷新内部有 Mutex 防并发)。
|
}
|
||||||
val hasFreshAccountWithoutCache = accountStates.any { entry ->
|
}
|
||||||
entry.status.state != CredentialStatus.State.NOT_CONFIGURED &&
|
)
|
||||||
entry.cachedBalance == null
|
|
||||||
}
|
}
|
||||||
if (hasFreshAccountWithoutCache) refresh()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 从本地缓存快速填充一次(不阻塞)。挂起函数,供调用方控制执行顺序。 */
|
/** 从本地缓存快速填充一次(不阻塞)。挂起函数,供调用方控制执行顺序。 */
|
||||||
private suspend fun loadFromCache() {
|
private suspend fun loadFromCache() {
|
||||||
val accountStates = credentialRepository.readAccountStates()
|
val localStates = credentialRepository.readLocalStates()
|
||||||
val entries = orderAccountEntries(accountStates)
|
// 首页仅显示已配置凭据的服务(有凭据就显示,未配置不占位)
|
||||||
val cards = entries.map { entry ->
|
val cards = configuredTypes(localStates).map { type ->
|
||||||
buildCard(entry, lastFetchError = null)
|
buildCard(localStates.getValue(type), lastFetchError = null)
|
||||||
}
|
}
|
||||||
_uiState.update { it.copy(loading = false, refreshing = false, cards = cards) }
|
_uiState.update { it.copy(loading = false, refreshing = false, cards = cards) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 拉取所有已配置账号的最新余额,更新缓存。失败保留旧数据并把错误信息带上。 */
|
/** 拉取所有服务最新余额,更新缓存。失败的服务保留旧数据并把错误信息带上。 */
|
||||||
fun refresh() {
|
fun refresh() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
// Mutex 防并发:如果已有 refresh 在跑,后来的直接跳过
|
// Mutex 防并发:如果已有 refresh 在跑,后来的直接跳过
|
||||||
if (!refreshMutex.tryLock()) return@launch
|
if (!refreshMutex.tryLock()) return@launch
|
||||||
try {
|
try {
|
||||||
_uiState.update { it.copy(refreshing = true) }
|
_uiState.update { it.copy(refreshing = true) }
|
||||||
val results: Map<AccountKey, Result<ServiceBalance>?> = coroutineScope {
|
val results: Map<ServiceType, Result<ServiceBalance>?> = coroutineScope {
|
||||||
val entries = orderAccountEntries(credentialRepository.readAccountStates())
|
ServiceType.entries.map { type ->
|
||||||
entries.map { entry ->
|
|
||||||
async {
|
async {
|
||||||
val key = AccountKey(entry.service, entry.account?.id)
|
val status = credentialRepository.statusFor(type)
|
||||||
val result: Result<ServiceBalance>? =
|
if (status.state == CredentialStatus.State.NOT_CONFIGURED) {
|
||||||
if (entry.status.state == CredentialStatus.State.NOT_CONFIGURED) {
|
type to null // 未配置的服务不拉
|
||||||
null // 未配置的账号不拉
|
|
||||||
} else {
|
} else {
|
||||||
refreshBalanceUseCase(entry.service, entry.account?.id)
|
type to refreshBalanceUseCase(type)
|
||||||
}
|
}
|
||||||
key to result
|
|
||||||
}
|
}
|
||||||
}.awaitAll().associateBy({ it.first }, { it.second })
|
}.awaitAll().toMap()
|
||||||
}
|
}
|
||||||
val accountStates = credentialRepository.readAccountStates()
|
val localStates = credentialRepository.readLocalStates()
|
||||||
val cards = orderAccountEntries(accountStates).map { entry ->
|
val cards = configuredTypes(localStates).map { type ->
|
||||||
val key = AccountKey(entry.service, entry.account?.id)
|
val result = results[type]
|
||||||
val error = results[key]?.exceptionOrNull()
|
val error = result?.exceptionOrNull()
|
||||||
val errorUi = error
|
val errorUi = error
|
||||||
?.takeUnless { it is RepositoryError.CredentialChanged }
|
?.takeUnless { it is RepositoryError.CredentialChanged }
|
||||||
?.let { errorToUiText(it) }
|
?.let { errorToUiText(it) }
|
||||||
buildCard(entry, lastFetchError = errorUi)
|
buildCard(localStates.getValue(type), lastFetchError = errorUi)
|
||||||
}
|
}
|
||||||
_uiState.update { it.copy(refreshing = false, cards = cards) }
|
_uiState.update { it.copy(refreshing = false, cards = cards) }
|
||||||
// 刷新成功后更新桌面小组件
|
// 刷新成功后更新桌面小组件
|
||||||
if (results[AccountKey(ServiceType.OPENCODE_GO, null)]?.isSuccess == true) {
|
if (results[ServiceType.OPENCODE_GO]?.isSuccess == true) {
|
||||||
OpenCodeGoWidgetProvider.notifyDataChanged(appContext)
|
OpenCodeGoWidgetProvider.notifyDataChanged(appContext)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@ -209,85 +164,23 @@ class DashboardViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 已配置凭据(state != NOT_CONFIGURED)的服务,首页只显示这些。 */
|
||||||
* 基于最新账号状态重建卡片列表,尽量保留旧顺序:
|
private fun configuredTypes(
|
||||||
* - 同一服务内默认账号在前,其余按账号创建顺序;
|
localStates: Map<ServiceType, CredentialRepository.LocalState>
|
||||||
* - 新增账号追加到同服务现有卡片之后;服务整体顺序沿用旧顺序,新服务按枚举追加。
|
): List<ServiceType> = ServiceType.entries.filter { type ->
|
||||||
*/
|
localStates.getValue(type).status.state != CredentialStatus.State.NOT_CONFIGURED
|
||||||
private fun rebuildCardsPreservingOrder(
|
|
||||||
state: DashboardUiState,
|
|
||||||
accountStates: List<CredentialRepository.AccountLocalState>
|
|
||||||
): List<DashboardCardUi> {
|
|
||||||
val entries = orderAccountEntries(accountStates)
|
|
||||||
val previousByKey = state.cards.associateBy { it.key }
|
|
||||||
return entries.mapIndexed { _, entry ->
|
|
||||||
val key = entry.service.storageKey + ":" + entry.account?.id.orEmpty()
|
|
||||||
val previous = previousByKey[key]
|
|
||||||
if (previous == null) {
|
|
||||||
buildCard(entry, lastFetchError = null)
|
|
||||||
} else {
|
|
||||||
val credentialChanged = entry.fingerprint != previous.credentialFingerprint
|
|
||||||
val cacheAdvanced = !credentialChanged && isNewer(
|
|
||||||
candidate = entry.cachedBalance,
|
|
||||||
current = previous.cachedBalance
|
|
||||||
)
|
|
||||||
previous.copy(
|
|
||||||
credentialState = entry.status.state,
|
|
||||||
credentialFingerprint = entry.fingerprint,
|
|
||||||
cachedBalance = if (credentialChanged) {
|
|
||||||
entry.cachedBalance
|
|
||||||
} else {
|
|
||||||
newerOf(previous.cachedBalance, entry.cachedBalance)
|
|
||||||
},
|
|
||||||
lastFetchError = if (credentialChanged || cacheAdvanced) {
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
previous.lastFetchError
|
|
||||||
},
|
|
||||||
// 账号默认状态在设置页改动后返回时也要同步
|
|
||||||
isDefaultAccount = entry.account?.isDefault == true,
|
|
||||||
// 签到角标每次 resume 重算:详情页手动签到后返回首页要立刻看到 ✓
|
|
||||||
checkedInToday = entry.service in CHECKIN_SERVICES &&
|
|
||||||
checkinStatusStore.isCheckedToday(entry.service, entry.account?.id)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 账号条目排序:服务按旧顺序/枚举,服务内默认账号优先、其余按存储顺序。 */
|
|
||||||
private fun orderAccountEntries(
|
|
||||||
accountStates: List<CredentialRepository.AccountLocalState>
|
|
||||||
): List<CredentialRepository.AccountLocalState> {
|
|
||||||
val existingOrder = _uiState.value.cards
|
|
||||||
.mapIndexed { index, card -> card.service to index }
|
|
||||||
.toMap()
|
|
||||||
return accountStates.sortedWith(
|
|
||||||
compareBy(
|
|
||||||
{ existingOrder[it.service] ?: Int.MAX_VALUE },
|
|
||||||
{ it.service.ordinal },
|
|
||||||
{ if (it.account?.isDefault == true) 0 else 1 },
|
|
||||||
{ it.account?.createdAt ?: 0L }
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildCard(
|
private fun buildCard(
|
||||||
local: CredentialRepository.AccountLocalState,
|
local: CredentialRepository.LocalState,
|
||||||
lastFetchError: UiText?
|
lastFetchError: UiText?
|
||||||
): DashboardCardUi = DashboardCardUi(
|
): DashboardCardUi = DashboardCardUi(
|
||||||
service = local.service,
|
service = local.status.service,
|
||||||
accountId = local.account?.id,
|
|
||||||
accountLabel = local.account?.label,
|
|
||||||
accountIndex = 0, // 展示兜底名称时由 UI 根据同服务卡片顺序计算
|
|
||||||
isDefaultAccount = local.account?.isDefault == true,
|
|
||||||
credentialState = local.status.state,
|
credentialState = local.status.state,
|
||||||
credentialFingerprint = local.fingerprint,
|
credentialFingerprint = local.fingerprint,
|
||||||
cachedBalance = local.cachedBalance,
|
cachedBalance = local.cachedBalance,
|
||||||
lastFetchError = lastFetchError,
|
lastFetchError = lastFetchError,
|
||||||
siteIcon = (local.credential as? Credential.Sub2ApiCredential)?.siteIcon,
|
siteIcon = (local.credential as? Credential.Sub2ApiCredential)?.siteIcon
|
||||||
// 可签到服务(Trae/WorkBuddy)各账号的今日签到角标;其它服务恒 false 不显示
|
|
||||||
checkedInToday = local.service in CHECKIN_SERVICES &&
|
|
||||||
checkinStatusStore.isCheckedToday(local.service, local.account?.id)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun newerOf(
|
private fun newerOf(
|
||||||
@ -317,26 +210,12 @@ class DashboardViewModel @Inject constructor(
|
|||||||
private val _panelGroups = MutableStateFlow<List<com.rainy.token.data.repository.Sub2ApiRepository.PanelGroup>>(emptyList())
|
private val _panelGroups = MutableStateFlow<List<com.rainy.token.data.repository.Sub2ApiRepository.PanelGroup>>(emptyList())
|
||||||
val panelGroups: StateFlow<List<com.rainy.token.data.repository.Sub2ApiRepository.PanelGroup>> = _panelGroups.asStateFlow()
|
val panelGroups: StateFlow<List<com.rainy.token.data.repository.Sub2ApiRepository.PanelGroup>> = _panelGroups.asStateFlow()
|
||||||
|
|
||||||
/** 一次性操作结果消息(新建/切组/兑换)。 */
|
/** 管理操作的一次性结果消息(新建/切组/兑换)。 */
|
||||||
private val _panelActionMessage = MutableStateFlow<String?>(null)
|
private val _panelActionMessage = MutableStateFlow<String?>(null)
|
||||||
val panelActionMessage: StateFlow<String?> = _panelActionMessage.asStateFlow()
|
val panelActionMessage: StateFlow<String?> = _panelActionMessage.asStateFlow()
|
||||||
|
|
||||||
fun consumePanelActionMessage() { _panelActionMessage.value = null }
|
fun consumePanelActionMessage() { _panelActionMessage.value = null }
|
||||||
|
|
||||||
/** 首页面板弹窗当前管理的 Sub2API 账号(null=默认账号)。 */
|
|
||||||
private val _sub2PanelAccountId = MutableStateFlow<String?>(null)
|
|
||||||
val sub2PanelAccountId: StateFlow<String?> = _sub2PanelAccountId.asStateFlow()
|
|
||||||
|
|
||||||
/** 打开首页面板弹窗并绑定目标账号,同时重置为未加载状态。 */
|
|
||||||
fun openSub2Panel(accountId: String?) {
|
|
||||||
_sub2PanelAccountId.value = accountId
|
|
||||||
_panelKeysState.value = com.rainy.token.ui.servicedetail.PanelKeysState.Idle
|
|
||||||
_panelGroups.value = emptyList()
|
|
||||||
_panelGroupsAccountId = null
|
|
||||||
_panelActionMessage.value = null
|
|
||||||
_recentlyCreatedKey.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 最近一次新建成功的明文 Key(弹窗立即复制用;查看后清除)。 */
|
/** 最近一次新建成功的明文 Key(弹窗立即复制用;查看后清除)。 */
|
||||||
private val _recentlyCreatedKey = MutableStateFlow<String?>(null)
|
private val _recentlyCreatedKey = MutableStateFlow<String?>(null)
|
||||||
val recentlyCreatedKey: StateFlow<String?> = _recentlyCreatedKey.asStateFlow()
|
val recentlyCreatedKey: StateFlow<String?> = _recentlyCreatedKey.asStateFlow()
|
||||||
@ -348,7 +227,7 @@ class DashboardViewModel @Inject constructor(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
if (_panelKeysState.value is com.rainy.token.ui.servicedetail.PanelKeysState.Loading) return@launch
|
if (_panelKeysState.value is com.rainy.token.ui.servicedetail.PanelKeysState.Loading) return@launch
|
||||||
_panelKeysState.value = com.rainy.token.ui.servicedetail.PanelKeysState.Loading
|
_panelKeysState.value = com.rainy.token.ui.servicedetail.PanelKeysState.Loading
|
||||||
sub2ApiRepository.fetchPanelKeys(_sub2PanelAccountId.value).fold(
|
sub2ApiRepository.fetchPanelKeys().fold(
|
||||||
onSuccess = { list ->
|
onSuccess = { list ->
|
||||||
_panelKeysState.value = com.rainy.token.ui.servicedetail.PanelKeysState.Success(list)
|
_panelKeysState.value = com.rainy.token.ui.servicedetail.PanelKeysState.Success(list)
|
||||||
},
|
},
|
||||||
@ -359,30 +238,18 @@ class DashboardViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 面板分组缓存所属账号(防止切换账号后仍显示上一个账号的分组)。 */
|
/** 加载可用分组;已非空则跳过(只要加载过一次即可复用)。 */
|
||||||
private var _panelGroupsAccountId: String? = null
|
|
||||||
|
|
||||||
/** 加载可用分组;同一账号加载过一次则跳过,切换账号后强制重拉。 */
|
|
||||||
fun loadPanelGroups() {
|
fun loadPanelGroups() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val accountId = _sub2PanelAccountId.value
|
if (_panelGroups.value.isNotEmpty()) return@launch
|
||||||
if (_panelGroupsAccountId == accountId && _panelGroups.value.isNotEmpty()) return@launch
|
sub2ApiRepository.fetchPanelGroups().onSuccess { _panelGroups.value = it }
|
||||||
sub2ApiRepository.fetchPanelGroups(accountId).fold(
|
|
||||||
onSuccess = {
|
|
||||||
_panelGroups.value = it
|
|
||||||
_panelGroupsAccountId = accountId
|
|
||||||
},
|
|
||||||
onFailure = {
|
|
||||||
// 分组加载失败不阻塞主流程(新建 Key 时可不选分组)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 新建 API Key:成功后展示掩码消息并刷新列表。 */
|
/** 新建 API Key:成功后展示掩码消息并刷新列表。 */
|
||||||
fun createPanelKey(name: String, groupId: Long?) {
|
fun createPanelKey(name: String, groupId: Long?) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
sub2ApiRepository.createPanelKey(name, groupId, _sub2PanelAccountId.value).fold(
|
sub2ApiRepository.createPanelKey(name, groupId).fold(
|
||||||
onSuccess = { key ->
|
onSuccess = { key ->
|
||||||
_recentlyCreatedKey.value = key
|
_recentlyCreatedKey.value = key
|
||||||
_panelActionMessage.value = "已创建 Key:" + maskPanelKey(key) + "(请在弹出的窗口复制完整 Key)"
|
_panelActionMessage.value = "已创建 Key:" + maskPanelKey(key) + "(请在弹出的窗口复制完整 Key)"
|
||||||
@ -396,7 +263,7 @@ class DashboardViewModel @Inject constructor(
|
|||||||
/** 切换 Key 所属分组:成功后刷新列表。 */
|
/** 切换 Key 所属分组:成功后刷新列表。 */
|
||||||
fun switchPanelGroup(keyId: Long, groupId: Long) {
|
fun switchPanelGroup(keyId: Long, groupId: Long) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
sub2ApiRepository.updatePanelKey(keyId, groupId, accountId = _sub2PanelAccountId.value).fold(
|
sub2ApiRepository.updatePanelKey(keyId, groupId).fold(
|
||||||
onSuccess = {
|
onSuccess = {
|
||||||
_panelActionMessage.value = "分组已切换"
|
_panelActionMessage.value = "分组已切换"
|
||||||
loadPanelKeys()
|
loadPanelKeys()
|
||||||
@ -409,7 +276,7 @@ class DashboardViewModel @Inject constructor(
|
|||||||
/** 删除 Key:成功后刷新列表。 */
|
/** 删除 Key:成功后刷新列表。 */
|
||||||
fun deletePanelKey(keyId: Long) {
|
fun deletePanelKey(keyId: Long) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
sub2ApiRepository.deletePanelKey(keyId, _sub2PanelAccountId.value).fold(
|
sub2ApiRepository.deletePanelKey(keyId).fold(
|
||||||
onSuccess = {
|
onSuccess = {
|
||||||
_panelActionMessage.value = "已删除 Key"
|
_panelActionMessage.value = "已删除 Key"
|
||||||
loadPanelKeys()
|
loadPanelKeys()
|
||||||
@ -422,10 +289,10 @@ class DashboardViewModel @Inject constructor(
|
|||||||
/** 兑换卡密:成功后刷新余额与 Key 列表。 */
|
/** 兑换卡密:成功后刷新余额与 Key 列表。 */
|
||||||
fun redeemPanelCode(code: String) {
|
fun redeemPanelCode(code: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
sub2ApiRepository.redeemPanelCode(code, _sub2PanelAccountId.value).fold(
|
sub2ApiRepository.redeemPanelCode(code).fold(
|
||||||
onSuccess = { msg ->
|
onSuccess = { msg ->
|
||||||
_panelActionMessage.value = msg
|
_panelActionMessage.value = msg
|
||||||
refreshBalanceUseCase(ServiceType.SUB2API, _sub2PanelAccountId.value)
|
refreshBalanceUseCase(ServiceType.SUB2API)
|
||||||
loadPanelKeys()
|
loadPanelKeys()
|
||||||
},
|
},
|
||||||
onFailure = { e -> _panelActionMessage.value = "兑换失败:" + (e.message ?: "") }
|
onFailure = { e -> _panelActionMessage.value = "兑换失败:" + (e.message ?: "") }
|
||||||
@ -448,40 +315,19 @@ data class DashboardUiState(
|
|||||||
|
|
||||||
data class DashboardCardUi(
|
data class DashboardCardUi(
|
||||||
val service: ServiceType,
|
val service: ServiceType,
|
||||||
/** 账号 ID(多账号服务);null=单账号服务。 */
|
|
||||||
val accountId: String? = null,
|
|
||||||
/** 账号自定义名称(可能为 null,UI 按“账号 N”兜底)。 */
|
|
||||||
val accountLabel: String? = null,
|
|
||||||
/** 同服务内的账号序号(0 起,用于兜底名称;由 UI 层计算后回写展示)。 */
|
|
||||||
val accountIndex: Int = 0,
|
|
||||||
/** 是否为默认账号(多账号服务)。 */
|
|
||||||
val isDefaultAccount: Boolean = false,
|
|
||||||
val credentialState: CredentialStatus.State,
|
val credentialState: CredentialStatus.State,
|
||||||
val credentialFingerprint: String?,
|
val credentialFingerprint: String?,
|
||||||
val cachedBalance: CachedBalance?,
|
val cachedBalance: CachedBalance?,
|
||||||
val lastFetchError: UiText?,
|
val lastFetchError: UiText?,
|
||||||
/** 站点 Logo URL(Sub2API 面板登录后抓取;非空时主页卡片用它替换内置图标)。 */
|
/** 站点 Logo URL(Sub2API 面板登录后抓取;非空时主页卡片用它替换内置图标)。 */
|
||||||
val siteIcon: String? = null,
|
val siteIcon: String? = null
|
||||||
/** 今日已签到角标(仅可签到服务有值,见 [CHECKIN_SERVICES])。 */
|
|
||||||
val checkedInToday: Boolean = false
|
|
||||||
) {
|
) {
|
||||||
/** 余额展示主数字。优先取缓存,错误时也展示(不隐藏,让用户看到旧值 + 红点提示)。 */
|
/** 余额展示主数字。优先取缓存,错误时也展示(不隐藏,让用户看到旧值 + 红点提示)。 */
|
||||||
val displayBalance: ServiceBalance? get() = cachedBalance?.balance
|
val displayBalance: ServiceBalance? get() = cachedBalance?.balance
|
||||||
|
|
||||||
/** 卡片唯一键:`{storageKey}:{accountId}`,单账号服务为 `{storageKey}:`。 */
|
|
||||||
val key: String get() = "${service.storageKey}:${accountId.orEmpty()}"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 内部请求/结果键:账号粒度(多账号服务逐账号拉取)。 */
|
|
||||||
private data class AccountKey(
|
|
||||||
val service: ServiceType,
|
|
||||||
val accountId: String? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
/** 把 Repository 错误映射为本地化文案(ParseError 按 reason 映射资源,detail 仅作日志,不流向 UI)。 */
|
/** 把 Repository 错误映射为本地化文案(ParseError 按 reason 映射资源,detail 仅作日志,不流向 UI)。 */
|
||||||
private fun errorToUiText(error: Throwable): UiText = when (error) {
|
private fun errorToUiText(error: Throwable): UiText = when (error) {
|
||||||
is RepositoryError.SessionExpired ->
|
|
||||||
UiText.Resource(R.string.error_credential_session_expired)
|
|
||||||
is RepositoryError.InvalidCredential ->
|
is RepositoryError.InvalidCredential ->
|
||||||
UiText.Resource(R.string.error_credential_invalid_reconfigure)
|
UiText.Resource(R.string.error_credential_invalid_reconfigure)
|
||||||
is RepositoryError.RateLimited -> UiText.Resource(
|
is RepositoryError.RateLimited -> UiText.Resource(
|
||||||
|
|||||||
@ -101,9 +101,6 @@ internal fun BalanceMainArea(card: DashboardCardUi) {
|
|||||||
card.service == ServiceType.SUB2API -> {
|
card.service == ServiceType.SUB2API -> {
|
||||||
Sub2ApiBalanceWithUsage(balance)
|
Sub2ApiBalanceWithUsage(balance)
|
||||||
}
|
}
|
||||||
card.service == ServiceType.TRAE -> {
|
|
||||||
TraeSplitMainBalance(balance)
|
|
||||||
}
|
|
||||||
else -> {
|
else -> {
|
||||||
Row(verticalAlignment = Alignment.Bottom) {
|
Row(verticalAlignment = Alignment.Bottom) {
|
||||||
Text(
|
Text(
|
||||||
@ -138,73 +135,6 @@ internal fun BalanceMainArea(card: DashboardCardUi) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Trae 首页卡片主区:不显示总积分,分开显示「通用积分」(TraeCode/TraeWork 均可用)
|
|
||||||
* 与「Work 专属积分」(仅 TraeWork),取 extras 中 general_credits/work_credits 小计
|
|
||||||
* (与详情页 TraePacksCard 同口径)。旧缓存缺小计键时回退总积分展示,刷新后恢复。
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun TraeSplitMainBalance(balance: ServiceBalance) {
|
|
||||||
val extras = balance.extras
|
|
||||||
val general = extras?.get("general_credits")?.toDoubleOrNull()
|
|
||||||
val work = extras?.get("work_credits")?.toDoubleOrNull()
|
|
||||||
if (general == null && work == null) {
|
|
||||||
// 旧缓存无小计键:回退原总积分展示
|
|
||||||
Row(verticalAlignment = Alignment.Bottom) {
|
|
||||||
Text(
|
|
||||||
text = formatAmount(balance.amount),
|
|
||||||
style = MaterialTheme.typography.displayMedium,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
|
||||||
Text(
|
|
||||||
text = balance.unit,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
color = inkMuted(),
|
|
||||||
modifier = Modifier.padding(bottom = 6.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val generalTitle = stringResource(R.string.trae_general_credits_title)
|
|
||||||
val workTitle = stringResource(R.string.trae_work_credits_title)
|
|
||||||
TraeSplitLine(generalTitle, general)
|
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
|
||||||
TraeSplitLine(workTitle, work)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 首页 Trae 分区一行:左侧分区名,右侧小计 + 单位。 */
|
|
||||||
@Composable
|
|
||||||
private fun TraeSplitLine(label: String, value: Double?) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.Bottom,
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
color = inkMuted()
|
|
||||||
)
|
|
||||||
Row(verticalAlignment = Alignment.Bottom) {
|
|
||||||
Text(
|
|
||||||
text = value?.let { formatAmount(it) } ?: "—",
|
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
|
||||||
Text(
|
|
||||||
text = "Credits",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
color = inkMuted(),
|
|
||||||
modifier = Modifier.padding(bottom = 2.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun OpenCodeGoMainBalance(balance: ServiceBalance) {
|
internal fun OpenCodeGoMainBalance(balance: ServiceBalance) {
|
||||||
Row(verticalAlignment = Alignment.Bottom) {
|
Row(verticalAlignment = Alignment.Bottom) {
|
||||||
@ -645,43 +575,6 @@ internal fun sub2AuthModeText(card: DashboardCardUi): String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Trae / WorkBuddy 卡片副标题:显示订阅状态(不再展示登录方式)。
|
|
||||||
*
|
|
||||||
* 取自 extras 的订阅字段(付费用户 / 订阅中),与详情页「账户信息」区同一数据源。
|
|
||||||
* 无缓存余额或字段缺失时返回 null,调用方回落到 [secondaryLineRes] 的通用描述。
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
internal fun subscriptionLine(card: DashboardCardUi): String? {
|
|
||||||
// 仅 Trae / WorkBuddy 使用本函数;其余服务回落通用描述
|
|
||||||
if (card.service != ServiceType.TRAE && card.service != ServiceType.WORKBUDDY) return null
|
|
||||||
val extras = card.displayBalance?.extras ?: return null
|
|
||||||
|
|
||||||
// WorkBuddy:订阅 / 付费状态
|
|
||||||
if ("is_paid_user" in extras || "subscription" in extras) {
|
|
||||||
val isPaid = extras["is_paid_user"] == "true"
|
|
||||||
val isSubscribed = extras["subscription"] == "true"
|
|
||||||
return when {
|
|
||||||
isSubscribed -> stringResource(R.string.extra_value_subscription_active)
|
|
||||||
isPaid -> stringResource(R.string.extra_value_paid_user)
|
|
||||||
else -> stringResource(R.string.extra_value_free_user)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trae:计费模式(积分计费 / 美元计费),无则不显示
|
|
||||||
val billing = when {
|
|
||||||
extras["is_credits_billing"] == "true" -> stringResource(R.string.extra_value_billing_credits)
|
|
||||||
extras["is_dollar_usage_billing"] == "true" -> stringResource(R.string.extra_value_billing_dollar)
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
if (billing != null) {
|
|
||||||
return stringResource(R.string.extra_label_billing_mode_desc, billing)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 字段完全缺失时返回 null,回落通用描述
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 服务副标题(资源 ID,由 UI 层解析)。 */
|
/** 服务副标题(资源 ID,由 UI 层解析)。 */
|
||||||
@StringRes
|
@StringRes
|
||||||
internal fun secondaryLineRes(card: DashboardCardUi): Int = when (card.service) {
|
internal fun secondaryLineRes(card: DashboardCardUi): Int = when (card.service) {
|
||||||
|
|||||||
@ -76,7 +76,7 @@ import java.util.Locale
|
|||||||
// 图表用色板(每个模型一个颜色,12 色 Material Design 500 色阶,足够区分 12 个模型)
|
// 图表用色板(每个模型一个颜色,12 色 Material Design 500 色阶,足够区分 12 个模型)
|
||||||
internal val modelColors = listOf(
|
internal val modelColors = listOf(
|
||||||
Color(0xFFF44336), // Red
|
Color(0xFFF44336), // Red
|
||||||
Color(0xFFC9436C), // Pink(玫瑰主色)
|
Color(0xFFE91E63), // Pink
|
||||||
Color(0xFF9C27B0), // Purple
|
Color(0xFF9C27B0), // Purple
|
||||||
Color(0xFF673AB7), // Deep Purple
|
Color(0xFF673AB7), // Deep Purple
|
||||||
Color(0xFF3F51B5), // Indigo
|
Color(0xFF3F51B5), // Indigo
|
||||||
@ -89,11 +89,11 @@ internal val modelColors = listOf(
|
|||||||
Color(0xFF607D8B) // Blue Grey
|
Color(0xFF607D8B) // Blue Grey
|
||||||
)
|
)
|
||||||
|
|
||||||
// Token 堆叠色板:命中缓存(浅樱粉·顶)、未命中输入(玫瑰粉·中)、输出(深玫瑰·底)
|
// Token 堆叠色板:命中缓存(浅粉·顶)、未命中输入(粉·中)、输出(深粉·底)
|
||||||
internal val tokenColors = listOf(
|
internal val tokenColors = listOf(
|
||||||
Color(0xFFFFE3EC), // cache hit — 浅樱粉(上面)
|
Color(0xFFFFD1DC), // cache hit — 浅粉(上面)
|
||||||
Color(0xFFFFA3BC), // cache miss input — 玫瑰粉(中间)
|
Color(0xFFFF85A2), // cache miss input — 粉色(中间)
|
||||||
Color(0xFFC9436C) // output — 深玫瑰(主色,下面)
|
Color(0xFFE91E63) // output — 深粉(下面)
|
||||||
)
|
)
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
|||||||
@ -0,0 +1,275 @@
|
|||||||
|
package com.rainy.token.ui.dashboard
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.rainy.token.R
|
||||||
|
import com.rainy.token.domain.model.CredentialStatus
|
||||||
|
import com.rainy.token.domain.model.ServiceBalance
|
||||||
|
import com.rainy.token.ui.components.ServiceIcon
|
||||||
|
import com.rainy.token.ui.components.formatAmount
|
||||||
|
import com.rainy.token.ui.theme.StrawberryPink
|
||||||
|
import com.rainy.token.ui.theme.StatusOrange
|
||||||
|
import com.rainy.token.ui.theme.inkMuted
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用量统计页:按已配置服务展示用量指标(余额、本月消费、总额度、下次重置、附加信息),
|
||||||
|
* 顶栏一键全部刷新。数据来自缓存 + 在线刷新,与 API 管理页(只管凭据)分离。
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun UsageStatScreen(
|
||||||
|
onBack: () -> Unit,
|
||||||
|
viewModel: UsageStatViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.usage_stat_title)) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.action_back)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
if (uiState.refreshing) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(end = 16.dp)
|
||||||
|
.size(20.dp),
|
||||||
|
color = StrawberryPink,
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
IconButton(onClick = { viewModel.refreshAll() }) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Refresh,
|
||||||
|
contentDescription = stringResource(R.string.action_refresh),
|
||||||
|
tint = StrawberryPink
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { innerPadding ->
|
||||||
|
when {
|
||||||
|
!uiState.initialized -> Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding)
|
||||||
|
)
|
||||||
|
uiState.rows.isEmpty() -> Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.usage_stat_empty),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = inkMuted()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
items(uiState.rows, key = { it.service.storageKey }) { row ->
|
||||||
|
UsageStatRowCard(row = row)
|
||||||
|
}
|
||||||
|
if (uiState.lastFailureCount > 0) {
|
||||||
|
item(key = "failure_note") {
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
R.string.usage_stat_failed,
|
||||||
|
uiState.lastFailureCount
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun UsageStatRowCard(row: UsageStatRowUi) {
|
||||||
|
val balance = row.balance
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(20.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
ServiceIcon(service = row.service, size = 44)
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
Text(
|
||||||
|
text = row.service.displayName,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
UsageStatDot(state = row.state)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
|
|
||||||
|
if (balance == null) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.usage_stat_never_fetched),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = inkMuted()
|
||||||
|
)
|
||||||
|
return@Column
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "${formatAmount(balance.amount)} ${balance.unit}",
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
|
||||||
|
val metrics = buildList {
|
||||||
|
balance.monthlySpent?.let {
|
||||||
|
add(stringResource(R.string.usage_stat_monthly_spent) to "${formatAmount(it)} ${balance.unit}")
|
||||||
|
}
|
||||||
|
balance.totalQuota?.let {
|
||||||
|
add(stringResource(R.string.usage_stat_total_quota) to "${formatAmount(it)} ${balance.unit}")
|
||||||
|
}
|
||||||
|
balance.nextResetAt?.let {
|
||||||
|
add(stringResource(R.string.usage_stat_next_reset) to formatTime(it))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
metrics.forEach { (label, value) ->
|
||||||
|
MetricLine(label = label, value = value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// extras 只透传对用户友好的键;过滤掉技术键与 JSON 模型数组,避免卡片被长串污染
|
||||||
|
val friendlyExtras = balance.extras.filterKeys { key ->
|
||||||
|
val k = key.lowercase()
|
||||||
|
!k.contains("models") &&
|
||||||
|
!k.contains("resetinsec") &&
|
||||||
|
!k.contains("resetat") &&
|
||||||
|
!k.contains("raw") &&
|
||||||
|
!k.endsWith(".unlimited")
|
||||||
|
}
|
||||||
|
if (friendlyExtras.isNotEmpty()) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
|
) {
|
||||||
|
friendlyExtras.entries.take(3).forEach { (key, value) ->
|
||||||
|
Text(
|
||||||
|
text = "$key: $value",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = inkMuted()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.fetchedAt > 0L) {
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.usage_stat_fetched_at, formatTime(row.fetchedAt)),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = inkMuted()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MetricLine(label: String, value: String) {
|
||||||
|
Row(modifier = Modifier.padding(vertical = 2.dp)) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = inkMuted(),
|
||||||
|
modifier = Modifier.width(96.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun UsageStatDot(state: CredentialStatus.State) {
|
||||||
|
val color = when (state) {
|
||||||
|
CredentialStatus.State.OK -> Color(0xFF3BB273)
|
||||||
|
CredentialStatus.State.WARNING -> StatusOrange
|
||||||
|
CredentialStatus.State.EXPIRED -> MaterialTheme.colorScheme.error
|
||||||
|
CredentialStatus.State.NOT_CONFIGURED -> inkMuted().copy(alpha = 0.5f)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(10.dp)
|
||||||
|
.clip(RoundedCornerShape(50))
|
||||||
|
.background(color = color)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatTime(ts: Long): String {
|
||||||
|
if (ts <= 0L) return "—"
|
||||||
|
return runCatching {
|
||||||
|
SimpleDateFormat("MM-dd HH:mm", Locale.getDefault()).format(Date(ts))
|
||||||
|
}.getOrDefault("—")
|
||||||
|
}
|
||||||
@ -0,0 +1,100 @@
|
|||||||
|
package com.rainy.token.ui.dashboard
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.rainy.token.data.cache.BalanceCache
|
||||||
|
import com.rainy.token.data.cache.CachedBalance
|
||||||
|
import com.rainy.token.data.repository.CredentialRepository
|
||||||
|
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 dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用量统计页 ViewModel。
|
||||||
|
*
|
||||||
|
* 按已配置的服务展示用量指标(余额、本月消费、总额度、下次重置、附加信息),
|
||||||
|
* 支持一键全部刷新。数据来自缓存(无网可见旧值)+ 可触发在线刷新。
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class UsageStatViewModel @Inject constructor(
|
||||||
|
private val credentialRepository: CredentialRepository,
|
||||||
|
private val balanceCache: BalanceCache,
|
||||||
|
private val refreshBalanceUseCase: RefreshBalanceUseCase
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(UsageStatUiState())
|
||||||
|
val uiState: StateFlow<UsageStatUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun load() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val rows = buildRows()
|
||||||
|
_uiState.update { it.copy(rows = rows, refreshing = false, initialized = true, lastFailureCount = 0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshAll() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val types = _uiState.value.rows.map { it.service }
|
||||||
|
if (types.isEmpty()) return@launch
|
||||||
|
_uiState.update { it.copy(refreshing = true, lastFailureCount = 0) }
|
||||||
|
val failures = coroutineScope {
|
||||||
|
types.map { type ->
|
||||||
|
async { type to refreshBalanceUseCase(type).isSuccess }
|
||||||
|
}.awaitAll()
|
||||||
|
}.count { !it.second }
|
||||||
|
val rows = buildRows()
|
||||||
|
// 先刷新行,再单独写失败计数:避免前者把失败数清零(load 会重置)
|
||||||
|
_uiState.update { it.copy(rows = rows, refreshing = false) }
|
||||||
|
_uiState.update { it.copy(lastFailureCount = failures) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取本地凭据 + 余额缓存,构建行数据(纯读,不发网络)。 */
|
||||||
|
private suspend fun buildRows(): List<UsageStatRowUi> {
|
||||||
|
val states = credentialRepository.readLocalStates()
|
||||||
|
val cached = balanceCache.getAll()
|
||||||
|
return ServiceType.entries
|
||||||
|
.filter { type ->
|
||||||
|
states.getValue(type).status.state != CredentialStatus.State.NOT_CONFIGURED
|
||||||
|
}
|
||||||
|
.map { type ->
|
||||||
|
val cachedBalance = cached[type]
|
||||||
|
UsageStatRowUi(
|
||||||
|
service = type,
|
||||||
|
balance = cachedBalance?.balance,
|
||||||
|
fetchedAt = cachedBalance?.fetchedAt ?: 0L,
|
||||||
|
state = states.getValue(type).status.state
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class UsageStatRowUi(
|
||||||
|
val service: ServiceType,
|
||||||
|
val balance: ServiceBalance?,
|
||||||
|
val fetchedAt: Long,
|
||||||
|
val state: CredentialStatus.State
|
||||||
|
)
|
||||||
|
|
||||||
|
data class UsageStatUiState(
|
||||||
|
val rows: List<UsageStatRowUi> = emptyList(),
|
||||||
|
val refreshing: Boolean = false,
|
||||||
|
val lastFailureCount: Int = 0,
|
||||||
|
/** 首帧 true 前不渲染空态,避免闪一下"暂无已配置服务" */
|
||||||
|
val initialized: Boolean = false
|
||||||
|
)
|
||||||
@ -289,8 +289,6 @@ private data class LoadResult(
|
|||||||
private fun syncErrorToUiText(error: Throwable): UiText = when (error) {
|
private fun syncErrorToUiText(error: Throwable): UiText = when (error) {
|
||||||
is SyncError.PartialSync ->
|
is SyncError.PartialSync ->
|
||||||
UiText.Resource(R.string.sync_partial, listOf(error.inserted, error.errors.size))
|
UiText.Resource(R.string.sync_partial, listOf(error.inserted, error.errors.size))
|
||||||
is RepositoryError.SessionExpired ->
|
|
||||||
UiText.Resource(R.string.error_credential_session_expired)
|
|
||||||
is RepositoryError.InvalidCredential ->
|
is RepositoryError.InvalidCredential ->
|
||||||
UiText.Resource(R.string.error_credential_invalid_reconfigure)
|
UiText.Resource(R.string.error_credential_invalid_reconfigure)
|
||||||
is RepositoryError.CredentialChanged ->
|
is RepositoryError.CredentialChanged ->
|
||||||
|
|||||||
@ -1,16 +0,0 @@
|
|||||||
package com.rainy.token.ui.dashboard.autocheckin
|
|
||||||
|
|
||||||
import dagger.hilt.EntryPoint
|
|
||||||
import dagger.hilt.InstallIn
|
|
||||||
import dagger.hilt.components.SingletonComponent
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 后台广播(自动签到闹钟 / 开机重排)获取 Hilt 单例的入口。
|
|
||||||
* 与 ui.widget.WidgetRefreshEntryPoint 同一模式:广播接收器不依赖
|
|
||||||
* @AndroidEntryPoint,通过 EntryPointAccessors 拿 SingletonComponent 里的依赖。
|
|
||||||
*/
|
|
||||||
@EntryPoint
|
|
||||||
@InstallIn(SingletonComponent::class)
|
|
||||||
interface AutoCheckinEntryPoint {
|
|
||||||
fun autoCheckinManager(): AutoCheckinManager
|
|
||||||
}
|
|
||||||
@ -1,296 +0,0 @@
|
|||||||
package com.rainy.token.ui.dashboard.autocheckin
|
|
||||||
|
|
||||||
import android.app.AlarmManager
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import com.rainy.token.data.repository.CheckinStatusStore
|
|
||||||
import com.rainy.token.data.repository.CredentialRepository
|
|
||||||
import com.rainy.token.data.repository.RepositoryError
|
|
||||||
import com.rainy.token.data.repository.TraeRepository
|
|
||||||
import com.rainy.token.data.repository.WorkBuddyRepository
|
|
||||||
import com.rainy.token.domain.model.CredentialStatus
|
|
||||||
import com.rainy.token.domain.model.ServiceAccountInfo
|
|
||||||
import com.rainy.token.domain.service.ServiceType
|
|
||||||
import com.rainy.token.domain.usecase.RefreshBalanceUseCase
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import java.time.ZonedDateTime
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.sync.Mutex
|
|
||||||
import kotlinx.coroutines.sync.withLock
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 每日自动签到编排器(小组件开关控制启用)。
|
|
||||||
*
|
|
||||||
* 行为契约(用户需求 2026-09-13 / 多账号版 2026-10 / 零点后台版 2026-11):
|
|
||||||
* - 开启后,每天零点后由 AlarmManager 自动触发([AutoCheckinReceiver]),
|
|
||||||
* 为所有「已配置凭据」的可签服务的【全部账号】各签一次;
|
|
||||||
* - 兜底:每天首次打开 App(DashboardViewModel)也会补签一次(错过闹钟/被杀进程场景);
|
|
||||||
* - 当日每账号只签一次:本地有「今日已成功签到」记录的账号直接跳过(手动签过也算);
|
|
||||||
* 失败/限流/设备拦截等非成功记录不占位,下次打开 App 会再次唤起签到流程;
|
|
||||||
* - 结果汇报:签完把每个账号的结果暴露为 [report],首页据此弹「xx 已签到,积分增加 xx」;
|
|
||||||
* - 开关持久化在 SharedPreferences "auto_checkin"(小组件与 App 内共用同一份)。
|
|
||||||
*
|
|
||||||
* 仅负责编排与记录,不做 UI:签到复用 RefreshBalanceUseCase(带凭据快照会话)。
|
|
||||||
*
|
|
||||||
* 调度说明:使用框架自带的 AlarmManager(非精确 setAndAllowWhileIdle)实现「每日零点后
|
|
||||||
* 后台签到」,零新增依赖;重启设备后闹钟由 BOOT_COMPLETED 广播重排。国产 ROM(如 vivo/
|
|
||||||
* iQOO)可能因电池优化延迟或抑制该闹钟,届时由「首次打开 App」兜底补签。
|
|
||||||
*/
|
|
||||||
@Singleton
|
|
||||||
class AutoCheckinManager @Inject constructor(
|
|
||||||
@ApplicationContext private val context: Context,
|
|
||||||
private val refreshBalanceUseCase: RefreshBalanceUseCase,
|
|
||||||
private val credentialRepository: CredentialRepository,
|
|
||||||
private val checkinStatusStore: CheckinStatusStore
|
|
||||||
) {
|
|
||||||
|
|
||||||
/** 单个账号的自动签到结果(UI 层本地化展示)。 */
|
|
||||||
data class ServiceResult(
|
|
||||||
val service: ServiceType,
|
|
||||||
/** 该账号的自定义名称;null=单账号服务/未命名的默认账号。 */
|
|
||||||
val accountLabel: String? = null,
|
|
||||||
/** true=本次签到成功(新签到账);false=已签/失败/跳过 */
|
|
||||||
val newlyCheckedIn: Boolean,
|
|
||||||
/** 今日已签(成功或本来已签),弹窗「已签到」行的判定依据 */
|
|
||||||
val checkedInToday: Boolean,
|
|
||||||
/** 本次新增积分(仅 SUCCESS 时有值;ALREADY/失败为 0) */
|
|
||||||
val creditsGained: Long,
|
|
||||||
/** 失败/提示原因(服务端原文,仅 failed 时非空) */
|
|
||||||
val message: String,
|
|
||||||
val failed: Boolean
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 自动签到汇总结果。null=没有可展示的内容(未开启/无候选账号/全部账号已签或静默跳过)。
|
|
||||||
* 弹窗消费后由 [consumeReport] 置 null。
|
|
||||||
*/
|
|
||||||
private val _report = MutableStateFlow<List<ServiceResult>?>(null)
|
|
||||||
val report: StateFlow<List<ServiceResult>?> = _report.asStateFlow()
|
|
||||||
|
|
||||||
/** 防同日并发重复签到:闹钟广播(IO 线程)与 Dashboard init(viewModelScope)可能同时进入。 */
|
|
||||||
private val runMutex = Mutex()
|
|
||||||
|
|
||||||
private fun prefs() =
|
|
||||||
context.getSharedPreferences("auto_checkin", Context.MODE_PRIVATE)
|
|
||||||
|
|
||||||
/** 自动签到开关(小组件 CheckBox 与 App 内共用)。开关变更时同步排/撤零点闹钟。 */
|
|
||||||
var enabled: Boolean
|
|
||||||
get() = prefs().getBoolean(KEY_ENABLED, false)
|
|
||||||
set(value) {
|
|
||||||
prefs().edit().putBoolean(KEY_ENABLED, value).apply()
|
|
||||||
refreshSchedule()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 每日签到执行入口(后台闹钟与首次打开 App 共用):
|
|
||||||
* 开启时 → 为已配置凭据的可签服务中【今日尚未成功签到】的账号各签一次。
|
|
||||||
* 返回 true 表示本次实际执行了签到(无论结果)。
|
|
||||||
*/
|
|
||||||
suspend fun runDailyIfDue(): Boolean = runMutex.withLock {
|
|
||||||
if (!enabled) return false
|
|
||||||
// 不再使用全局 lastRunDate 占位:只有真正「今日签到成功」的账号才跳过,
|
|
||||||
// 失败/限流/设备拦截等不会消费当天机会,重开 App 可再次唤起签到流程。
|
|
||||||
val results = mutableListOf<ServiceResult>()
|
|
||||||
for (service in CHECKIN_SERVICES) {
|
|
||||||
// 多账号服务遍历全部账号;无账号文档时 getDefaultAccount 同样返回 null,
|
|
||||||
// 候选为空 = 不签,与旧版「只签默认账号」的边界行为一致,不会误签旧凭据
|
|
||||||
val accounts = credentialRepository.accountsFor(service)
|
|
||||||
val candidates = accounts.ifEmpty {
|
|
||||||
listOfNotNull(credentialRepository.getDefaultAccount(service))
|
|
||||||
}
|
|
||||||
for (account in candidates) {
|
|
||||||
if (credentialRepository.statusFor(service, account.id).state ==
|
|
||||||
CredentialStatus.State.NOT_CONFIGURED
|
|
||||||
) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// 今日已有成功签到记录(手动签过也算)→ 跳过,不再发网络请求
|
|
||||||
if (checkinStatusStore.isCheckedToday(service, account.id)) continue
|
|
||||||
// runOne 返回 null = 无需汇报(如 INTL 不支持签到),不弹误导性条目
|
|
||||||
runOne(service, account)?.let { results += it }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (results.isNotEmpty()) {
|
|
||||||
_report.value = results
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 注册/取消每日零点后台闹钟(非精确 setAndAllowWhileIdle,零点后几分钟内触发)。
|
|
||||||
* 设备重启后闹钟丢失,由 BOOT_COMPLETED 广播再次调用本方法重排。
|
|
||||||
*/
|
|
||||||
fun refreshSchedule() {
|
|
||||||
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
|
||||||
val intent = Intent(context, AutoCheckinReceiver::class.java)
|
|
||||||
.setAction(ACTION_DAILY_CHECKIN)
|
|
||||||
val pendingIntent = PendingIntent.getBroadcast(
|
|
||||||
context,
|
|
||||||
0,
|
|
||||||
intent,
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
||||||
)
|
|
||||||
if (!enabled) {
|
|
||||||
alarmManager.cancel(pendingIntent)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
alarmManager.setAndAllowWhileIdle(
|
|
||||||
AlarmManager.RTC_WAKEUP,
|
|
||||||
nextRunTimeMillis(),
|
|
||||||
pendingIntent
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 下一次零点(00:05,本地时区)的运行时间戳。 */
|
|
||||||
private fun nextRunTimeMillis(): Long {
|
|
||||||
val now = ZonedDateTime.now()
|
|
||||||
var next = now.toLocalDate().atTime(CHECKIN_HOUR, CHECKIN_MINUTE).atZone(now.zone)
|
|
||||||
if (!next.isAfter(now)) next = next.plusDays(1)
|
|
||||||
return next.toInstant().toEpochMilli()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 返回 null 表示该账号无需出现在汇报弹窗里(如 INTL 不支持签到、静默跳过)。
|
|
||||||
*/
|
|
||||||
private suspend fun runOne(
|
|
||||||
service: ServiceType,
|
|
||||||
account: ServiceAccountInfo
|
|
||||||
): ServiceResult? {
|
|
||||||
val label = account.label?.takeIf { it.isNotBlank() }
|
|
||||||
return when (service) {
|
|
||||||
ServiceType.TRAE -> refreshBalanceUseCase.checkInTrae(account.id).fold(
|
|
||||||
onSuccess = { r ->
|
|
||||||
when (r.outcome) {
|
|
||||||
TraeRepository.CheckinOutcome.SUCCESS -> ServiceResult(
|
|
||||||
service = service,
|
|
||||||
accountLabel = label,
|
|
||||||
newlyCheckedIn = true,
|
|
||||||
checkedInToday = true,
|
|
||||||
creditsGained = r.points,
|
|
||||||
message = "",
|
|
||||||
failed = false
|
|
||||||
)
|
|
||||||
TraeRepository.CheckinOutcome.ALREADY ->
|
|
||||||
ServiceResult(service, label, false, true, 0, "", false)
|
|
||||||
// INTL 不支持签到:静默跳过,不进弹窗(避免误报「已签到」)
|
|
||||||
TraeRepository.CheckinOutcome.INTL_UNSUPPORTED -> null
|
|
||||||
TraeRepository.CheckinOutcome.DEVICE_BLOCKED -> {
|
|
||||||
recordFailure(service, account.id, CheckinStatusStore.STATUS_DEVICE_BLOCKED)
|
|
||||||
ServiceResult(service, label, false, false, 0, r.message, failed = true)
|
|
||||||
}
|
|
||||||
TraeRepository.CheckinOutcome.RETRYABLE -> {
|
|
||||||
recordFailure(service, account.id, CheckinStatusStore.STATUS_RETRYABLE)
|
|
||||||
ServiceResult(service, label, false, false, 0, r.message, failed = true)
|
|
||||||
}
|
|
||||||
TraeRepository.CheckinOutcome.FAILED -> {
|
|
||||||
recordFailure(service, account.id, CheckinStatusStore.STATUS_FAILED)
|
|
||||||
ServiceResult(service, label, false, false, 0, r.message, failed = true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onFailure = { e ->
|
|
||||||
recordFailure(service, account.id, CheckinStatusStore.STATUS_FAILED)
|
|
||||||
failureResult(service, label, account.id, e)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
ServiceType.WORKBUDDY -> refreshBalanceUseCase.checkInWorkBuddy(account.id).fold(
|
|
||||||
onSuccess = { s ->
|
|
||||||
when (s.outcome) {
|
|
||||||
WorkBuddyRepository.CheckinOutcome.SUCCESS -> ServiceResult(
|
|
||||||
service = service,
|
|
||||||
accountLabel = label,
|
|
||||||
newlyCheckedIn = true,
|
|
||||||
checkedInToday = true,
|
|
||||||
creditsGained = (s.todayCredit.takeIf { it > 0 } ?: s.dailyCredit),
|
|
||||||
message = "",
|
|
||||||
failed = false
|
|
||||||
)
|
|
||||||
WorkBuddyRepository.CheckinOutcome.ALREADY ->
|
|
||||||
ServiceResult(service, label, false, true, 0, "", false)
|
|
||||||
WorkBuddyRepository.CheckinOutcome.QUERY ->
|
|
||||||
ServiceResult(service, label, false, true, 0, "", false)
|
|
||||||
WorkBuddyRepository.CheckinOutcome.FAILED -> {
|
|
||||||
recordFailure(service, account.id, CheckinStatusStore.STATUS_FAILED)
|
|
||||||
ServiceResult(service, label, false, false, 0, s.message, failed = true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onFailure = { e ->
|
|
||||||
recordFailure(service, account.id, CheckinStatusStore.STATUS_FAILED)
|
|
||||||
failureResult(service, label, account.id, e)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
// CHECKIN_SERVICES 只含上述两者;穷尽兜底(未来加服务忘了实现时安全返回失败)
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 签到请求失败的汇报结果。
|
|
||||||
*
|
|
||||||
* 竞态兜底:签到请求在服务端已成功([RefreshBalanceUseCase.checkInTrae] 的 also 块
|
|
||||||
* 已写 CheckinStatusStore 成功记录),但与并发余额刷新争抢凭据快照导致结果被 [RepositoryError.CredentialChanged]
|
|
||||||
* 丢弃时——若本地记录确认今日已签,按「今日已签到」汇报,而不是报「凭据」错误
|
|
||||||
* (用户会看到积分实际到账却提示失败的矛盾)。
|
|
||||||
*/
|
|
||||||
private fun failureResult(
|
|
||||||
service: ServiceType,
|
|
||||||
accountLabel: String?,
|
|
||||||
accountId: String,
|
|
||||||
error: Throwable
|
|
||||||
): ServiceResult =
|
|
||||||
if (error is RepositoryError.CredentialChanged &&
|
|
||||||
checkinStatusStore.isCheckedToday(service, accountId)
|
|
||||||
) {
|
|
||||||
ServiceResult(service, accountLabel, false, true, 0, "", false)
|
|
||||||
} else {
|
|
||||||
ServiceResult(
|
|
||||||
service,
|
|
||||||
accountLabel,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
0,
|
|
||||||
error.message.orEmpty(),
|
|
||||||
failed = true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 落一条非成功签到记录(仅当今天还没有成功记录时写入)。
|
|
||||||
* 失败/限流/设备拦截不消费当天签到机会,重开 App 仍会重试;
|
|
||||||
* 但若在一次 CredentialChanged 竞态中服务端实际已成功,保留成功记录不被覆盖。
|
|
||||||
*/
|
|
||||||
private fun recordFailure(
|
|
||||||
service: ServiceType,
|
|
||||||
accountId: String,
|
|
||||||
status: String
|
|
||||||
) {
|
|
||||||
if (!checkinStatusStore.isCheckedToday(service, accountId)) {
|
|
||||||
checkinStatusStore.record(service, accountId, status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 弹窗关闭时调用,清除汇报状态(下次执行前不再重复弹)。 */
|
|
||||||
fun consumeReport() {
|
|
||||||
_report.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val KEY_ENABLED = "enabled"
|
|
||||||
|
|
||||||
/** 每日自动签到闹钟的广播 action(供 [AutoCheckinReceiver] 分发)。 */
|
|
||||||
const val ACTION_DAILY_CHECKIN = "com.rainy.token.action.DAILY_AUTO_CHECKIN"
|
|
||||||
|
|
||||||
/** 每日签到目标时间:零点后 5 分钟(避开日切边界),本地时区。 */
|
|
||||||
const val CHECKIN_HOUR = 0
|
|
||||||
const val CHECKIN_MINUTE = 5
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 支持每日签到的服务(DashboardViewModel 与自动签到共用的唯一来源,避免两处漂移)。 */
|
|
||||||
internal val CHECKIN_SERVICES = listOf(ServiceType.TRAE, ServiceType.WORKBUDDY)
|
|
||||||
@ -1,60 +0,0 @@
|
|||||||
package com.rainy.token.ui.dashboard.autocheckin
|
|
||||||
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import dagger.hilt.android.EntryPointAccessors
|
|
||||||
import kotlin.coroutines.cancellation.CancellationException
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 每日自动签到后台触发点。
|
|
||||||
*
|
|
||||||
* 两种入口共用同一个接收器:
|
|
||||||
* - [AutoCheckinManager.ACTION_DAILY_CHECKIN]:AlarmManager 每日零点后发出的闹钟广播,
|
|
||||||
* 到点就为该服务的全部已配置账号签到;
|
|
||||||
* - ACTION_BOOT_COMPLETED:设备重启后闹钟会被系统清空,这里只负责重新排程,不抢签。
|
|
||||||
*
|
|
||||||
* 通过 EntryPointAccessors 获取 Hilt 单例,不依赖 @AndroidEntryPoint。
|
|
||||||
*/
|
|
||||||
class AutoCheckinReceiver : BroadcastReceiver() {
|
|
||||||
|
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
|
||||||
val action = intent.action
|
|
||||||
val appContext = context.applicationContext
|
|
||||||
val pendingResult = goAsync()
|
|
||||||
|
|
||||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
|
||||||
try {
|
|
||||||
val manager = EntryPointAccessors.fromApplication(
|
|
||||||
appContext, AutoCheckinEntryPoint::class.java
|
|
||||||
).autoCheckinManager()
|
|
||||||
|
|
||||||
when (action) {
|
|
||||||
AutoCheckinManager.ACTION_DAILY_CHECKIN -> {
|
|
||||||
// 到点签到(关闭时 runDailyIfDue 自身会短路返回);
|
|
||||||
// 无论本次签到是否抛异常,都必须把一次性闹钟重排到明天
|
|
||||||
try {
|
|
||||||
manager.runDailyIfDue()
|
|
||||||
} finally {
|
|
||||||
manager.refreshSchedule()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Intent.ACTION_BOOT_COMPLETED -> {
|
|
||||||
// 重启后闹钟丢失,重新排程(今天是否签到由首次打开 App 兜底)
|
|
||||||
manager.refreshSchedule()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (cancelled: CancellationException) {
|
|
||||||
throw cancelled
|
|
||||||
} catch (_: Exception) {
|
|
||||||
// 后台静默:签到/排程失败不阻塞下次闹钟,等下次打开 App 再补救
|
|
||||||
} finally {
|
|
||||||
pendingResult.finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -66,24 +66,24 @@ import kotlinx.coroutines.flow.first
|
|||||||
|
|
||||||
// ── 热力图颜色 ──────────────────────────────────────────────
|
// ── 热力图颜色 ──────────────────────────────────────────────
|
||||||
|
|
||||||
/** 亮色模式 6 级玫瑰粉梯度(精致粉调,最高级与主色一致) */
|
/** 亮色模式 6 级粉色梯度 */
|
||||||
private val LightHeatmapColors = listOf(
|
private val LightHeatmapColors = listOf(
|
||||||
Color(0xFFF3E9EE), // Level 0: 极浅暖灰
|
Color(0xFFEFE0E5), // Level 0: 极浅暖灰
|
||||||
Color(0xFFFFE3EC), // Level 1: 浅樱粉
|
Color(0xFFFFD6E0), // Level 1: 浅樱粉
|
||||||
Color(0xFFFFC9D9), // Level 2: 浅玫瑰柔
|
Color(0xFFFFB3C6), // Level 2: 草莓粉柔
|
||||||
Color(0xFFFFA3BC), // Level 3: 玫瑰粉
|
Color(0xFFFF85A2), // Level 3: 草莓粉
|
||||||
Color(0xFFF27E9F), // Level 4: 玫瑰粉深
|
Color(0xFFFF6B8E), // Level 4: 草莓粉深
|
||||||
Color(0xFFC9436C), // Level 5: 深玫瑰(主色)
|
Color(0xFFE84973), // Level 5: 玫红
|
||||||
)
|
)
|
||||||
|
|
||||||
/** 暗色模式 6 级玫瑰粉梯度 */
|
/** 暗色模式 6 级粉色梯度 */
|
||||||
private val DarkHeatmapColors = listOf(
|
private val DarkHeatmapColors = listOf(
|
||||||
Color(0xFF3A2B33), // Level 0: 暖深灰
|
Color(0xFF3A2A30), // Level 0: 暖深灰
|
||||||
Color(0xFF4A3239), // Level 1
|
Color(0xFF4A2E3A), // Level 1
|
||||||
Color(0xFF643D4A), // Level 2
|
Color(0xFF6B3D4A), // Level 2
|
||||||
Color(0xFF855062), // Level 3
|
Color(0xFF8B4D5A), // Level 3
|
||||||
Color(0xFFA9627A), // Level 4
|
Color(0xFFB86880), // Level 4
|
||||||
Color(0xFFE07C9A), // Level 5
|
Color(0xFFD67F9A), // Level 5
|
||||||
)
|
)
|
||||||
|
|
||||||
/** 月份名称资源(按月索引 1~12,locale-aware) */
|
/** 月份名称资源(按月索引 1~12,locale-aware) */
|
||||||
|
|||||||
@ -17,7 +17,6 @@ import androidx.compose.foundation.rememberScrollState
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.foundation.horizontalScroll
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ArrowBack
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
@ -30,8 +29,6 @@ import androidx.compose.material3.DropdownMenuItem
|
|||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||||
import androidx.compose.material3.FilterChip
|
|
||||||
import androidx.compose.material3.FilterChipDefaults
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@ -57,7 +54,6 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
@ -66,7 +62,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import com.rainy.token.R
|
import com.rainy.token.R
|
||||||
import com.rainy.token.data.repository.ModelUsageRow
|
import com.rainy.token.data.repository.ModelUsageRow
|
||||||
import com.rainy.token.data.repository.OpenCodeGoRepository
|
import com.rainy.token.data.repository.OpenCodeGoRepository
|
||||||
import com.rainy.token.data.repository.WorkBuddyRepository
|
|
||||||
import com.rainy.token.data.repository.WindowModelUsage
|
import com.rainy.token.data.repository.WindowModelUsage
|
||||||
import com.rainy.token.domain.model.ServiceBalance
|
import com.rainy.token.domain.model.ServiceBalance
|
||||||
import com.rainy.token.domain.model.TriggerSummary
|
import com.rainy.token.domain.model.TriggerSummary
|
||||||
@ -85,11 +80,6 @@ import com.rainy.token.ui.components.isFiveHourLabel
|
|||||||
import com.rainy.token.ui.theme.inkMuted
|
import com.rainy.token.ui.theme.inkMuted
|
||||||
import com.rainy.token.ui.theme.StrawberryPink
|
import com.rainy.token.ui.theme.StrawberryPink
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonObject
|
|
||||||
import kotlinx.serialization.json.contentOrNull
|
|
||||||
import kotlinx.serialization.json.doubleOrNull
|
|
||||||
import kotlinx.serialization.json.jsonArray
|
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
@ -110,15 +100,13 @@ import java.util.Locale
|
|||||||
@Composable
|
@Composable
|
||||||
fun ServiceDetailScreen(
|
fun ServiceDetailScreen(
|
||||||
service: ServiceType,
|
service: ServiceType,
|
||||||
accountId: String? = null,
|
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onSwitchAccount: (ServiceType, String?) -> Unit = { _, _ -> },
|
|
||||||
onConfigureCredential: (ServiceType) -> Unit,
|
onConfigureCredential: (ServiceType) -> Unit,
|
||||||
onStartWebViewLogin: (ServiceType, String?) -> Unit,
|
onStartWebViewLogin: (ServiceType) -> Unit,
|
||||||
onOpenRecharge: (ServiceType) -> Unit = { },
|
onOpenRecharge: (ServiceType) -> Unit = { },
|
||||||
viewModel: ServiceDetailViewModel = hiltViewModel()
|
viewModel: ServiceDetailViewModel = hiltViewModel()
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(service, accountId) { viewModel.bind(service, accountId) }
|
LaunchedEffect(service) { viewModel.bind(service) }
|
||||||
// 从凭据编辑页返回时重新读取凭据状态 + 缓存
|
// 从凭据编辑页返回时重新读取凭据状态 + 缓存
|
||||||
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
|
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
|
||||||
viewModel.reloadCredentialState()
|
viewModel.reloadCredentialState()
|
||||||
@ -130,20 +118,6 @@ fun ServiceDetailScreen(
|
|||||||
val modelsLoading by viewModel.modelsLoading.collectAsStateWithLifecycle()
|
val modelsLoading by viewModel.modelsLoading.collectAsStateWithLifecycle()
|
||||||
val checkinState by viewModel.checkinState.collectAsStateWithLifecycle()
|
val checkinState by viewModel.checkinState.collectAsStateWithLifecycle()
|
||||||
val checkinInfo by viewModel.checkinInfo.collectAsStateWithLifecycle()
|
val checkinInfo by viewModel.checkinInfo.collectAsStateWithLifecycle()
|
||||||
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
|
|
||||||
|
|
||||||
// 解析当前账号(显式 accountId 优先;未指定则默认账号)
|
|
||||||
val currentAccount: com.rainy.token.domain.model.ServiceAccountInfo? =
|
|
||||||
accounts.firstOrNull { it.id == accountId }
|
|
||||||
?: if (accountId.isNullOrBlank()) {
|
|
||||||
accounts.firstOrNull { it.isDefault } ?: accounts.firstOrNull()
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
val accountLabel: String? = currentAccount?.let { account ->
|
|
||||||
account.label?.takeIf { it.isNotBlank() }
|
|
||||||
?: stringResource(R.string.account_fallback_name, accounts.indexOf(account) + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 面板管理(Key/分组/兑换)已移到首页 Sub2API 卡片的「面板管理」入口,
|
// 面板管理(Key/分组/兑换)已移到首页 Sub2API 卡片的「面板管理」入口,
|
||||||
// 详情页只保留用量明细所需的窗口统计状态。
|
// 详情页只保留用量明细所需的窗口统计状态。
|
||||||
@ -170,11 +144,7 @@ fun ServiceDetailScreen(
|
|||||||
ServiceIcon(service = service, size = 32)
|
ServiceIcon(service = service, size = 32)
|
||||||
Spacer(modifier = Modifier.width(10.dp))
|
Spacer(modifier = Modifier.width(10.dp))
|
||||||
Text(
|
Text(
|
||||||
text = if (accountLabel != null) {
|
text = service.displayName,
|
||||||
stringResource(R.string.service_detail_title_with_account, service.displayName, accountLabel)
|
|
||||||
} else {
|
|
||||||
service.displayName
|
|
||||||
},
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
fontWeight = FontWeight.SemiBold
|
fontWeight = FontWeight.SemiBold
|
||||||
)
|
)
|
||||||
@ -202,18 +172,6 @@ fun ServiceDetailScreen(
|
|||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding = PaddingValues(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
// 多账号服务:账号切换器(仅展示单个账号时不显示,保持旧版视觉)
|
|
||||||
if (ServiceType.isMultiAccount(service) && accounts.size > 1) {
|
|
||||||
item {
|
|
||||||
AccountSwitcherRow(
|
|
||||||
accounts = accounts,
|
|
||||||
selectedId = currentAccount?.id,
|
|
||||||
fallbackName = { index -> stringResource(R.string.account_fallback_name, index + 1) },
|
|
||||||
onSelected = { id -> onSwitchAccount(service, id) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 主余额卡
|
// 主余额卡
|
||||||
item { MainBalanceCard(state = uiState.state, service = service) }
|
item { MainBalanceCard(state = uiState.state, service = service) }
|
||||||
|
|
||||||
@ -234,42 +192,23 @@ fun ServiceDetailScreen(
|
|||||||
ServiceType.OLLAMA -> {
|
ServiceType.OLLAMA -> {
|
||||||
item { OllamaUsageCard(uiState.state) }
|
item { OllamaUsageCard(uiState.state) }
|
||||||
}
|
}
|
||||||
ServiceType.TRAE -> {
|
ServiceType.TRAE,
|
||||||
// 每日签到卡;未配置凭据时展示引导卡,避免暴露必然失败的签到按钮
|
|
||||||
item {
|
|
||||||
if (uiState.hasCredential) {
|
|
||||||
CheckinCard(
|
|
||||||
state = checkinState,
|
|
||||||
todayCheckedIn = checkinInfo?.todayCheckedIn == true,
|
|
||||||
streakDays = checkinInfo?.streakDays ?: 0L,
|
|
||||||
isTrae = true,
|
|
||||||
onCheckin = { viewModel.checkin() }
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
CheckinNeedCredentialCard()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 权益包积分明细(拆开显示每包总额/已用/剩余);无 entitlement.* 数据时该卡不渲染
|
|
||||||
item { TraePacksCard(uiState.state) }
|
|
||||||
// 账户信息(总积分/已消耗/计费方式等,本地化标签,不暴露英文原始键)
|
|
||||||
item { TraeAccountInfoCard(uiState.state) }
|
|
||||||
}
|
|
||||||
ServiceType.WORKBUDDY -> {
|
ServiceType.WORKBUDDY -> {
|
||||||
|
// 每日签到卡(WorkBuddy/Trae 共用);下方仍展示通用 extras
|
||||||
|
// 未配置凭据时展示引导卡,避免暴露必然失败的签到按钮
|
||||||
item {
|
item {
|
||||||
if (uiState.hasCredential) {
|
if (uiState.hasCredential) {
|
||||||
CheckinCard(
|
CheckinCard(
|
||||||
state = checkinState,
|
state = checkinState,
|
||||||
todayCheckedIn = checkinInfo?.todayCheckedIn == true,
|
todayCheckedIn = checkinInfo?.todayCheckedIn == true,
|
||||||
streakDays = checkinInfo?.streakDays ?: 0L,
|
streakDays = checkinInfo?.streakDays ?: 0L,
|
||||||
isTrae = false,
|
|
||||||
onCheckin = { viewModel.checkin() }
|
onCheckin = { viewModel.checkin() }
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
CheckinNeedCredentialCard()
|
CheckinNeedCredentialCard()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 资源明细(资源 ID 美化为「资源 #007」+ 余额;套餐/冻结/付费信息归账户信息)
|
item { GenericExtrasCard(uiState.state) }
|
||||||
item { WorkBuddyResourcesCard(uiState.state) }
|
|
||||||
}
|
}
|
||||||
ServiceType.SUB2API -> {
|
ServiceType.SUB2API -> {
|
||||||
// 新明细卡:模型筛选 + 时间窗口 + 柱状图;无结构化明细时内部回退 GenericExtrasCard
|
// 新明细卡:模型筛选 + 时间窗口 + 柱状图;无结构化明细时内部回退 GenericExtrasCard
|
||||||
@ -315,7 +254,7 @@ fun ServiceDetailScreen(
|
|||||||
modelsLoading = modelsLoading,
|
modelsLoading = modelsLoading,
|
||||||
onRefresh = { viewModel.refresh() },
|
onRefresh = { viewModel.refresh() },
|
||||||
onConfigureCredential = { onConfigureCredential(service) },
|
onConfigureCredential = { onConfigureCredential(service) },
|
||||||
onStartWebViewLogin = { onStartWebViewLogin(service, accountId) },
|
onStartWebViewLogin = { onStartWebViewLogin(service) },
|
||||||
onOpenRecharge = config.externalUrl?.let { { onOpenRecharge(service) } },
|
onOpenRecharge = config.externalUrl?.let { { onOpenRecharge(service) } },
|
||||||
onTriggerUsage = { viewModel.triggerUsage() },
|
onTriggerUsage = { viewModel.triggerUsage() },
|
||||||
onSelectModel = { viewModel.selectModel(it) },
|
onSelectModel = { viewModel.selectModel(it) },
|
||||||
@ -348,46 +287,6 @@ fun ServiceDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 多账号服务的账号切换器:横向可滚动的 FilterChip 行,默认账号带「默认」角标。
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun AccountSwitcherRow(
|
|
||||||
accounts: List<com.rainy.token.domain.model.ServiceAccountInfo>,
|
|
||||||
selectedId: String?,
|
|
||||||
fallbackName: @Composable (Int) -> String,
|
|
||||||
onSelected: (String) -> Unit
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.horizontalScroll(rememberScrollState()),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
accounts.forEachIndexed { index, account ->
|
|
||||||
val label = account.label?.takeIf { it.isNotBlank() } ?: fallbackName(index)
|
|
||||||
val selected = account.id == selectedId
|
|
||||||
FilterChip(
|
|
||||||
selected = selected,
|
|
||||||
onClick = { if (!selected) onSelected(account.id) },
|
|
||||||
label = {
|
|
||||||
Text(
|
|
||||||
text = if (account.isDefault) {
|
|
||||||
stringResource(R.string.account_chip_default, label)
|
|
||||||
} else {
|
|
||||||
label
|
|
||||||
},
|
|
||||||
maxLines = 1
|
|
||||||
)
|
|
||||||
},
|
|
||||||
colors = FilterChipDefaults.filterChipColors(
|
|
||||||
selectedContainerColor = StrawberryPink.copy(alpha = 0.14f)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CommandCode 专属:月度用量卡 + 窗口进度条。
|
* CommandCode 专属:月度用量卡 + 窗口进度条。
|
||||||
*
|
*
|
||||||
@ -1060,9 +959,9 @@ private fun MainBalanceCard(state: State, service: ServiceType) {
|
|||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) { CircularProgressIndicator(color = StrawberryPink) }
|
) { CircularProgressIndicator(color = StrawberryPink) }
|
||||||
}
|
}
|
||||||
is State.Fresh -> BalanceBigNumber(state.data, service)
|
is State.Fresh -> BalanceBigNumber(state.data)
|
||||||
is State.Stale -> {
|
is State.Stale -> {
|
||||||
BalanceBigNumber(state.data, service)
|
BalanceBigNumber(state.data)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.msg_stale_cache, formatTime(state.lastFetchedAt)),
|
text = stringResource(R.string.msg_stale_cache, formatTime(state.lastFetchedAt)),
|
||||||
@ -1071,7 +970,7 @@ private fun MainBalanceCard(state: State, service: ServiceType) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
is State.Error -> {
|
is State.Error -> {
|
||||||
BalanceBigNumber(state.cached, service)
|
BalanceBigNumber(state.cached)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.msg_showing_last_balance),
|
text = stringResource(R.string.msg_showing_last_balance),
|
||||||
@ -1092,7 +991,7 @@ private fun MainBalanceCard(state: State, service: ServiceType) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun BalanceBigNumber(balance: ServiceBalance?, service: ServiceType) {
|
private fun BalanceBigNumber(balance: ServiceBalance?) {
|
||||||
if (balance == null) {
|
if (balance == null) {
|
||||||
Text(
|
Text(
|
||||||
text = "—",
|
text = "—",
|
||||||
@ -1132,34 +1031,6 @@ private fun BalanceBigNumber(balance: ServiceBalance?, service: ServiceType) {
|
|||||||
color = inkMuted()
|
color = inkMuted()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// WorkBuddy:账户信息(冻结 / 付费 / 订阅)并入主余额卡,与「当前余额」同区展示
|
|
||||||
if (service == ServiceType.WORKBUDDY) {
|
|
||||||
val extras = balance.extras
|
|
||||||
val accountRows = buildList {
|
|
||||||
extras["frozen"]?.takeIf { it != "0" }?.let {
|
|
||||||
add(stringResource(R.string.extra_label_frozen) to "$it ${balance.unit}")
|
|
||||||
}
|
|
||||||
extras["is_paid_user"]?.let {
|
|
||||||
add(
|
|
||||||
stringResource(R.string.extra_label_is_paid_user) to
|
|
||||||
stringResource(if (it == "true") R.string.extra_value_yes else R.string.extra_value_no)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (extras["subscription"] == "true") {
|
|
||||||
add(stringResource(R.string.extra_label_subscription) to stringResource(R.string.extra_value_yes))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (accountRows.isNotEmpty()) {
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.detail_account_info_title),
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
fontWeight = FontWeight.SemiBold
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
accountRows.forEach { (label, value) -> InfoRow(label = label, value = value) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -1240,7 +1111,6 @@ private fun CheckinCard(
|
|||||||
state: CheckinState,
|
state: CheckinState,
|
||||||
todayCheckedIn: Boolean,
|
todayCheckedIn: Boolean,
|
||||||
streakDays: Long,
|
streakDays: Long,
|
||||||
isTrae: Boolean = false,
|
|
||||||
onCheckin: () -> Unit
|
onCheckin: () -> Unit
|
||||||
) {
|
) {
|
||||||
val actionDone = todayCheckedIn || state is CheckinState.Done
|
val actionDone = todayCheckedIn || state is CheckinState.Done
|
||||||
@ -1293,18 +1163,6 @@ private fun CheckinCard(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Trae 签到失败/提示时附设备 ID 获取路径(签到须用官方客户端绑定设备,
|
|
||||||
// 9074 风控根因;用户换设备后可按此路径自查新 did)。INTL 不支持与设备无关,不提示。
|
|
||||||
val hintRelevant = state is CheckinState.Notice ||
|
|
||||||
(state is CheckinState.Error && state.showDeviceHint)
|
|
||||||
if (isTrae && hintRelevant) {
|
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.checkin_trae_device_hint),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = inkMuted()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
Button(
|
Button(
|
||||||
onClick = onCheckin,
|
onClick = onCheckin,
|
||||||
@ -1351,349 +1209,9 @@ private fun CheckinNeedCredentialCard() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Trae 权益包积分明细卡:把 balance.extras 中固定前缀键 entitlement.$i.* 拆开逐包展示,
|
|
||||||
* 并按 available_endpoint 分成「通用积分」(TraeCode/TraeWork 均可用)与
|
|
||||||
* 「Work 专属积分」(仅 TraeWork)两个分区,各带小计,与官方用量页口径一致。
|
|
||||||
* 只在存在 entitlement.* 数据时渲染;其余键仍由 [GenericExtrasCard] 展示。
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
internal fun TraePacksCard(state: State) {
|
|
||||||
val balance = when (state) {
|
|
||||||
is State.Fresh -> state.data
|
|
||||||
is State.Stale -> state.data
|
|
||||||
is State.Error -> state.cached
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
val extras = balance?.extras ?: return
|
|
||||||
val packs = buildList<PackLine> {
|
|
||||||
var i = 0
|
|
||||||
while (true) {
|
|
||||||
val name = extras["entitlement.$i.name"] ?: break
|
|
||||||
val unlimited = extras["entitlement.$i.unlimited"] == "true"
|
|
||||||
val limit = extras["entitlement.$i.limit"]?.toLongOrNull()
|
|
||||||
val used = extras["entitlement.$i.used"]?.toDoubleOrNull()
|
|
||||||
val remaining = extras["entitlement.$i.remaining"]?.toDoubleOrNull()
|
|
||||||
val isWork = extras["entitlement.$i.endpoint"] == "1"
|
|
||||||
add(PackLine(name, limit, used, remaining, unlimited, isWork))
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (packs.isEmpty()) return
|
|
||||||
|
|
||||||
val generalTotal = extras["general_credits"]?.toDoubleOrNull()
|
|
||||||
val workTotal = extras["work_credits"]?.toDoubleOrNull()
|
|
||||||
val generalPacks = packs.filter { !it.isWork }
|
|
||||||
val workPacks = packs.filter { it.isWork }
|
|
||||||
|
|
||||||
val unlimitedLabel = stringResource(R.string.trae_pack_unlimited)
|
|
||||||
val remainingFmt = stringResource(R.string.trae_pack_remaining_fmt)
|
|
||||||
val generalTitle = stringResource(R.string.trae_general_credits_title)
|
|
||||||
val workTitle = stringResource(R.string.trae_work_credits_title)
|
|
||||||
|
|
||||||
Card(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
shape = RoundedCornerShape(20.dp),
|
|
||||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(5.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.trae_packs_title),
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
fontWeight = FontWeight.SemiBold
|
|
||||||
)
|
|
||||||
// 通用积分区(available_endpoint=0:TraeCode/TraeWork 均可用)
|
|
||||||
if (generalTotal != null || generalPacks.isNotEmpty()) {
|
|
||||||
TraeSectionRow(generalTitle, generalTotal)
|
|
||||||
generalPacks.forEach { p ->
|
|
||||||
Text(
|
|
||||||
text = traePackLine(p, unlimitedLabel, remainingFmt),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = inkMuted()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Work 专属积分区(available_endpoint=1:仅 TraeWork 可用)
|
|
||||||
if (workTotal != null || workPacks.isNotEmpty()) {
|
|
||||||
TraeSectionRow(workTitle, workTotal)
|
|
||||||
workPacks.forEach { p ->
|
|
||||||
Text(
|
|
||||||
text = traePackLine(p, unlimitedLabel, remainingFmt),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = inkMuted()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 分区标题行:左侧分区名,右侧小计(通用/Work 专属各一组)。 */
|
|
||||||
@Composable
|
|
||||||
private fun TraeSectionRow(title: String, total: Double?) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
if (total != null) {
|
|
||||||
Text(
|
|
||||||
text = formatAmount(total),
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 单个权益包的展示行文案(剩余/总额/已用,或无限额度)。 */
|
|
||||||
private fun traePackLine(p: PackLine, unlimitedLabel: String, remainingFmt: String): String =
|
|
||||||
if (p.unlimited) {
|
|
||||||
"${p.name}: $unlimitedLabel"
|
|
||||||
} else {
|
|
||||||
val limitStr = p.limit?.toString() ?: "?"
|
|
||||||
val usedStr = p.used?.let { formatAmount(it) } ?: "?"
|
|
||||||
val remStr = p.remaining?.let { formatAmount(it) } ?: "?"
|
|
||||||
"${p.name}: " + String.format(Locale.US, remainingFmt, remStr, limitStr, usedStr)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Trae 单个权益包展示行。 */
|
|
||||||
private data class PackLine(
|
|
||||||
val name: String,
|
|
||||||
val limit: Long?,
|
|
||||||
val used: Double?,
|
|
||||||
val remaining: Double?,
|
|
||||||
val unlimited: Boolean,
|
|
||||||
val isWork: Boolean
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Trae 账户信息卡:只展示对用户有意义的账户级指标(总积分/已消耗/消耗比例/权益包数),
|
|
||||||
* 原始布尔键(is_credits_billing / is_dollar_usage_billing)合并为一行「计费方式」,
|
|
||||||
* is_new_user 仅在为新用户时展示。数据缺失的行自动隐藏;无任何数据时整卡不渲染。
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
internal fun TraeAccountInfoCard(state: State) {
|
|
||||||
val balance = when (state) {
|
|
||||||
is State.Fresh -> state.data
|
|
||||||
is State.Stale -> state.data
|
|
||||||
is State.Error -> state.cached
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
val extras = balance?.extras ?: return
|
|
||||||
|
|
||||||
val unit = balance.unit
|
|
||||||
val rows = buildList {
|
|
||||||
extras["total_credits"]?.let {
|
|
||||||
add(stringResource(R.string.extra_label_total_credits) to "$it $unit")
|
|
||||||
}
|
|
||||||
extras["consumed_credits"]?.let {
|
|
||||||
add(stringResource(R.string.extra_label_consumed_credits) to "$it $unit")
|
|
||||||
}
|
|
||||||
extras["consumption_ratio"]?.toDoubleOrNull()?.let {
|
|
||||||
add(stringResource(R.string.extra_label_consumption_ratio) to "%.1f%%".format(it * 100))
|
|
||||||
}
|
|
||||||
extras["pack_count"]?.toIntOrNull()?.takeIf { it > 0 }?.let {
|
|
||||||
add(stringResource(R.string.extra_label_pack_count) to it.toString())
|
|
||||||
}
|
|
||||||
// 计费方式:两个英文布尔键合并为一行自然语言
|
|
||||||
val billingLabel = when {
|
|
||||||
extras["is_credits_billing"] == "true" -> stringResource(R.string.extra_value_billing_credits)
|
|
||||||
extras["is_dollar_usage_billing"] == "true" -> stringResource(R.string.extra_value_billing_dollar)
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
billingLabel?.let {
|
|
||||||
add(stringResource(R.string.extra_label_billing_mode) to it)
|
|
||||||
}
|
|
||||||
if (extras["is_new_user"] == "true") {
|
|
||||||
add(stringResource(R.string.extra_label_is_new_user) to stringResource(R.string.extra_value_yes))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (rows.isEmpty()) return
|
|
||||||
|
|
||||||
AccountInfoCard(title = stringResource(R.string.detail_account_info_title), rows = rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* WorkBuddy 资源卡:按官网个人中心语义分区展示结构化包明细(extras["wb_detail_raw"])。
|
|
||||||
*
|
|
||||||
* - 「套餐积分」区:订阅套餐包,展示 已用/总量 + 剩余 + 下次权益周期更新时间;
|
|
||||||
* - 「平台奖励积分」区:奖励类包,展示 可用数量 · 剩余合计,各行带到期时间;
|
|
||||||
* - 「账户信息」区:套餐数/冻结/付费用户/订阅状态。
|
|
||||||
*
|
|
||||||
* 旧缓存无 wb_detail_raw 时回退为「资源 #007」简化行。无任何数据时整卡不渲染。
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
internal fun WorkBuddyResourcesCard(state: State) {
|
|
||||||
val balance = when (state) {
|
|
||||||
is State.Fresh -> state.data
|
|
||||||
is State.Stale -> state.data
|
|
||||||
is State.Error -> state.cached
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
val extras = balance?.extras ?: return
|
|
||||||
val unit = balance.unit
|
|
||||||
val packs = remember(extras) { parseWorkBuddyPacks(extras[WorkBuddyRepository.KEY_DETAIL_RAW]) }
|
|
||||||
// 套餐包编码:优先用仓库算好的 KEY_PLAN_CODE;旧数据回退到 summary 的 subscription_code
|
|
||||||
val planCode = extras[WorkBuddyRepository.KEY_PLAN_CODE]
|
|
||||||
?: extras["subscription_code"].orEmpty()
|
|
||||||
|
|
||||||
// 分区:套餐积分 vs 平台奖励积分(依据 PackageCode 分组,与官网个人中心口径一致)
|
|
||||||
// 账户信息(frozen/付费/订阅)已并入主余额卡 MainBalanceCard
|
|
||||||
if (packs.isEmpty()) return
|
|
||||||
val planPacks = if (planCode.isNotBlank()) packs.filter { it.code == planCode } else emptyList()
|
|
||||||
val rewardPacks = packs - planPacks.toSet()
|
|
||||||
val planTotal = planPacks.sumOf { it.remain }
|
|
||||||
|
|
||||||
Card(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
shape = RoundedCornerShape(20.dp),
|
|
||||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(5.dp)
|
|
||||||
) {
|
|
||||||
// ── 套餐积分:已用/总量 + 剩余 + 周期结束时间 ──
|
|
||||||
if (planPacks.isNotEmpty()) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.wb_plan_title),
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
fontWeight = FontWeight.SemiBold
|
|
||||||
)
|
|
||||||
InfoRow(
|
|
||||||
label = stringResource(R.string.wb_plan_used_total),
|
|
||||||
value = "${formatAmount(planPacks.sumOf { it.used })} / ${formatAmount(planPacks.sumOf { it.total })} $unit"
|
|
||||||
)
|
|
||||||
InfoRow(
|
|
||||||
label = stringResource(R.string.extra_label_remaining),
|
|
||||||
value = "${formatAmount(planTotal)} $unit"
|
|
||||||
)
|
|
||||||
planPacks.firstNotNullOfOrNull { it.cycleEnd }?.let {
|
|
||||||
InfoRow(label = stringResource(R.string.wb_plan_next_reset), value = it)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 平台奖励积分:逐行列出到期时间 + 剩余 ──
|
|
||||||
if (rewardPacks.isNotEmpty()) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.wb_reward_title),
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
fontWeight = FontWeight.SemiBold
|
|
||||||
)
|
|
||||||
rewardPacks
|
|
||||||
.sortedWith(compareBy({ it.expireAt == null }, { it.expireAt ?: "" }))
|
|
||||||
.forEachIndexed { index, p ->
|
|
||||||
val label = p.expireAt?.let {
|
|
||||||
stringResource(R.string.wb_reward_expire, it)
|
|
||||||
} ?: stringResource(R.string.wb_reward_item, index + 1)
|
|
||||||
InfoRow(label = label, value = "${formatAmount(p.remain)} $unit")
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** WorkBuddy 单个资源包(结构化明细行)。 */
|
|
||||||
private data class WbPack(
|
|
||||||
val code: String,
|
|
||||||
val name: String,
|
|
||||||
val remain: Double,
|
|
||||||
val used: Double,
|
|
||||||
val total: Double,
|
|
||||||
/** 周期结束时间(CycleEndTime,形如 "2026-09-30 23:59:59") */
|
|
||||||
val cycleEnd: String?,
|
|
||||||
/** 到期时间(ExpiredTime;null=未到期) */
|
|
||||||
val expireAt: String?,
|
|
||||||
/** 0=生效中,3=已过期(服务端枚举,其余值按未知处理) */
|
|
||||||
val status: Int
|
|
||||||
) {
|
|
||||||
val cycleReset: String? get() = cycleEnd
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 解析 extras[KEY_DETAIL_RAW](WorkBuddyRepository 序列化的账户明细 JSON 数组)。 */
|
|
||||||
private fun parseWorkBuddyPacks(raw: String?): List<WbPack> {
|
|
||||||
if (raw.isNullOrBlank()) return emptyList()
|
|
||||||
return runCatching {
|
|
||||||
Json.parseToJsonElement(raw).jsonArray.mapNotNull { el ->
|
|
||||||
val o = el as? JsonObject ?: return@mapNotNull null
|
|
||||||
WbPack(
|
|
||||||
code = o["code"]?.jsonPrimitive?.contentOrNull.orEmpty(),
|
|
||||||
name = o["name"]?.jsonPrimitive?.contentOrNull.orEmpty(),
|
|
||||||
remain = o["remain"]?.jsonPrimitive?.doubleOrNull ?: return@mapNotNull null,
|
|
||||||
used = o["used"]?.jsonPrimitive?.doubleOrNull ?: 0.0,
|
|
||||||
total = o["total"]?.jsonPrimitive?.doubleOrNull ?: 0.0,
|
|
||||||
cycleEnd = o["cycleEnd"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() },
|
|
||||||
expireAt = o["expiredAt"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() },
|
|
||||||
status = o["status"]?.jsonPrimitive?.doubleOrNull?.toInt() ?: -1
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}.getOrDefault(emptyList())
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 通用「账户信息」卡:标题 + 左灰标签/右强调值 行列表。 */
|
|
||||||
@Composable
|
|
||||||
private fun AccountInfoCard(title: String, rows: List<Pair<String, String>>) {
|
|
||||||
Card(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
shape = RoundedCornerShape(20.dp),
|
|
||||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(5.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
fontWeight = FontWeight.SemiBold
|
|
||||||
)
|
|
||||||
rows.forEach { (label, value) -> InfoRow(label = label, value = value) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 明细行:左标签(灰)+ 右值(强调,右对齐),避免「key: value」裸奔样式。 */
|
|
||||||
@Composable
|
|
||||||
private fun InfoRow(label: String, value: String) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = inkMuted(),
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = value,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
textAlign = TextAlign.End
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通用明细卡:把 balance.extras 中的友好键逐行展示(Trae 权益包 / WorkBuddy 资源 /
|
* 通用明细卡:把 balance.extras 中的友好键逐行展示(Trae 权益包 / WorkBuddy 资源 /
|
||||||
* Sub2API 账户等)。技术键(models / reset* / raw 等)一律过滤。
|
* Sub2API 账户等)。技术键(models / reset* / raw 等)一律过滤。
|
||||||
*
|
|
||||||
* 已知技术键(consumed_credits / pack_count / is_paid_user 等)映射为本地化标签;
|
|
||||||
* 未知键(权益包名等,由服务端按用户 locale 返回)原样展示。
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
internal fun GenericExtrasCard(state: State) {
|
internal fun GenericExtrasCard(state: State) {
|
||||||
@ -1706,10 +1224,6 @@ internal fun GenericExtrasCard(state: State) {
|
|||||||
val extras = balance?.extras ?: return
|
val extras = balance?.extras ?: return
|
||||||
val friendly = extras.filterKeys { key ->
|
val friendly = extras.filterKeys { key ->
|
||||||
val k = key.lowercase()
|
val k = key.lowercase()
|
||||||
// entitlement.* 键由 TraePacksCard 专门拆开渲染,不得进入通用卡
|
|
||||||
!k.startsWith("entitlement.") &&
|
|
||||||
// 通用/Work 专属小计由 TraePacksCard 分区标题展示,避免重复
|
|
||||||
k != "general_credits" && k != "work_credits" &&
|
|
||||||
!k.contains("models") &&
|
!k.contains("models") &&
|
||||||
!k.contains("reset") &&
|
!k.contains("reset") &&
|
||||||
!k.contains("raw") &&
|
!k.contains("raw") &&
|
||||||
@ -1718,38 +1232,6 @@ internal fun GenericExtrasCard(state: State) {
|
|||||||
!k.endsWith(".limit")
|
!k.endsWith(".limit")
|
||||||
}
|
}
|
||||||
if (friendly.isEmpty()) return
|
if (friendly.isEmpty()) return
|
||||||
|
|
||||||
val yesLabel = stringResource(R.string.extra_value_yes)
|
|
||||||
val noLabel = stringResource(R.string.extra_value_no)
|
|
||||||
val labelMap = mapOf(
|
|
||||||
"total_credits" to stringResource(R.string.extra_label_total_credits),
|
|
||||||
"consumed_credits" to stringResource(R.string.extra_label_consumed_credits),
|
|
||||||
"consumption_ratio" to stringResource(R.string.extra_label_consumption_ratio),
|
|
||||||
"pack_count" to stringResource(R.string.extra_label_pack_count),
|
|
||||||
"is_credits_billing" to stringResource(R.string.extra_label_is_credits_billing),
|
|
||||||
"is_dollar_usage_billing" to stringResource(R.string.extra_label_is_dollar_usage_billing),
|
|
||||||
"is_new_user" to stringResource(R.string.extra_label_is_new_user),
|
|
||||||
"packages" to stringResource(R.string.extra_label_packages),
|
|
||||||
"frozen" to stringResource(R.string.extra_label_frozen),
|
|
||||||
"is_paid_user" to stringResource(R.string.extra_label_is_paid_user),
|
|
||||||
"subscription" to stringResource(R.string.extra_label_subscription)
|
|
||||||
)
|
|
||||||
val knownOrder = listOf(
|
|
||||||
"total_credits", "consumed_credits", "consumption_ratio", "pack_count",
|
|
||||||
"is_credits_billing", "is_dollar_usage_billing", "is_new_user",
|
|
||||||
"packages", "frozen", "is_paid_user", "subscription"
|
|
||||||
)
|
|
||||||
val sorted = friendly.entries.sortedWith { a, b ->
|
|
||||||
val ai = knownOrder.indexOf(a.key)
|
|
||||||
val bi = knownOrder.indexOf(b.key)
|
|
||||||
when {
|
|
||||||
ai >= 0 && bi < 0 -> -1
|
|
||||||
ai < 0 && bi >= 0 -> 1
|
|
||||||
ai >= 0 && bi >= 0 -> ai.compareTo(bi)
|
|
||||||
else -> a.key.compareTo(b.key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
shape = RoundedCornerShape(20.dp),
|
shape = RoundedCornerShape(20.dp),
|
||||||
@ -1765,11 +1247,9 @@ internal fun GenericExtrasCard(state: State) {
|
|||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
fontWeight = FontWeight.SemiBold
|
fontWeight = FontWeight.SemiBold
|
||||||
)
|
)
|
||||||
sorted.forEach { (key, value) ->
|
friendly.forEach { (key, value) ->
|
||||||
val label = labelMap[key] ?: key
|
|
||||||
val formatted = formatExtraValue(key, value, yesLabel, noLabel)
|
|
||||||
Text(
|
Text(
|
||||||
text = "$label: $formatted",
|
text = "$key: $value",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = inkMuted()
|
color = inkMuted()
|
||||||
)
|
)
|
||||||
@ -1778,21 +1258,6 @@ internal fun GenericExtrasCard(state: State) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 格式化 extras 值:布尔 → 是/否,比例 → 百分比,其余原样。 */
|
|
||||||
private fun formatExtraValue(
|
|
||||||
key: String,
|
|
||||||
value: String,
|
|
||||||
yes: String,
|
|
||||||
no: String
|
|
||||||
): String = when (key) {
|
|
||||||
"is_credits_billing", "is_dollar_usage_billing", "is_new_user",
|
|
||||||
"is_paid_user", "subscription" ->
|
|
||||||
if (value.equals("true", ignoreCase = true)) yes else no
|
|
||||||
"consumption_ratio" ->
|
|
||||||
value.toDoubleOrNull()?.let { "%.1f%%".format(it * 100) } ?: value
|
|
||||||
else -> value
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ErrorCard(message: String) {
|
private fun ErrorCard(message: String) {
|
||||||
Card(
|
Card(
|
||||||
|
|||||||
@ -35,20 +35,10 @@ class ServiceDetailViewModel @Inject constructor(
|
|||||||
private val credentialRepository: CredentialRepository,
|
private val credentialRepository: CredentialRepository,
|
||||||
private val balanceCache: BalanceCache,
|
private val balanceCache: BalanceCache,
|
||||||
private val refreshBalanceUseCase: RefreshBalanceUseCase,
|
private val refreshBalanceUseCase: RefreshBalanceUseCase,
|
||||||
private val checkinStatusStore: com.rainy.token.data.repository.CheckinStatusStore,
|
|
||||||
private val sub2ApiRepository: com.rainy.token.data.repository.Sub2ApiRepository
|
private val sub2ApiRepository: com.rainy.token.data.repository.Sub2ApiRepository
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _serviceType = MutableStateFlow<ServiceType?>(null)
|
private val _serviceType = MutableStateFlow<ServiceType?>(null)
|
||||||
val serviceType: StateFlow<ServiceType?> = _serviceType.asStateFlow()
|
|
||||||
|
|
||||||
/** 当前选中的账号 ID(多账号服务;null=默认账号,单账号服务恒为 null)。 */
|
|
||||||
private val _accountId = MutableStateFlow<String?>(null)
|
|
||||||
|
|
||||||
/** 多账号服务的账号列表(用于详情页账号切换)。 */
|
|
||||||
private val _accounts = MutableStateFlow<List<com.rainy.token.domain.model.ServiceAccountInfo>>(emptyList())
|
|
||||||
val accounts: StateFlow<List<com.rainy.token.domain.model.ServiceAccountInfo>> = _accounts.asStateFlow()
|
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(ServiceDetailUiState())
|
private val _uiState = MutableStateFlow(ServiceDetailUiState())
|
||||||
val uiState: StateFlow<ServiceDetailUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<ServiceDetailUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
@ -78,51 +68,32 @@ class ServiceDetailViewModel @Inject constructor(
|
|||||||
/** 今日签到信息摘要(仅字段已确认的才填)。 */
|
/** 今日签到信息摘要(仅字段已确认的才填)。 */
|
||||||
data class CheckinInfo(val todayCheckedIn: Boolean, val streakDays: Long)
|
data class CheckinInfo(val todayCheckedIn: Boolean, val streakDays: Long)
|
||||||
|
|
||||||
/** 进入详情页时预取「今日签到信息」:WORKBUDDY 走状态接口;TRAE 本地记录 + status 接口。 */
|
/** 进入详情页时预取签到状态:仅 WORKBUDDY 支持状态查询接口,TRAE 无该接口。 */
|
||||||
private fun preloadCheckinInfo(service: ServiceType, accountId: String?) {
|
private fun preloadCheckinInfo(service: ServiceType) {
|
||||||
_checkinInfo.value = null
|
_checkinInfo.value = null
|
||||||
|
if (service != ServiceType.WORKBUDDY) return
|
||||||
val generation = serviceGeneration
|
val generation = serviceGeneration
|
||||||
when (service) {
|
viewModelScope.launch {
|
||||||
ServiceType.WORKBUDDY -> viewModelScope.launch {
|
|
||||||
// 走 RefreshBalanceUseCase 会话:状态查询也会触发 OAuth 轮换,需按快照校验提交
|
// 走 RefreshBalanceUseCase 会话:状态查询也会触发 OAuth 轮换,需按快照校验提交
|
||||||
val status = refreshBalanceUseCase.fetchCheckinStatus(accountId).getOrNull() ?: return@launch
|
val status = refreshBalanceUseCase.fetchCheckinStatus().getOrNull() ?: return@launch
|
||||||
if (generation != serviceGeneration || _serviceType.value != service) return@launch
|
if (generation != serviceGeneration || _serviceType.value != service) return@launch
|
||||||
_checkinInfo.value = CheckinInfo(
|
_checkinInfo.value = CheckinInfo(
|
||||||
todayCheckedIn = status.todayCheckedIn,
|
todayCheckedIn = status.todayCheckedIn,
|
||||||
streakDays = status.streakDays
|
streakDays = status.streakDays
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
ServiceType.TRAE -> {
|
|
||||||
// 先看统一签到记录(零网络秒回),未命中再查服务端 status(跨设备已签也能识别)
|
|
||||||
if (checkinStatusStore.isCheckedToday(service, accountId)) {
|
|
||||||
_checkinInfo.value = CheckinInfo(todayCheckedIn = true, streakDays = 0)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
viewModelScope.launch {
|
|
||||||
val checked = refreshBalanceUseCase.fetchTraeCheckinStatus(accountId).getOrNull()
|
|
||||||
?: return@launch
|
|
||||||
if (generation != serviceGeneration || _serviceType.value != service) return@launch
|
|
||||||
if (checked) {
|
|
||||||
checkinStatusStore.markChecked(service, accountId)
|
|
||||||
_checkinInfo.value = CheckinInfo(todayCheckedIn = true, streakDays = 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> Unit
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 触发每日签到:仅 WORKBUDDY / TRAE 有效;结果按语义本地化(不直接显示 Repository 中文串)。 */
|
/** 触发每日签到:仅 WORKBUDDY / TRAE 有效;结果按语义本地化(不直接显示 Repository 中文串)。 */
|
||||||
fun checkin() {
|
fun checkin() {
|
||||||
val service = _serviceType.value ?: return
|
val service = _serviceType.value
|
||||||
val accountId = _accountId.value
|
|
||||||
if (service != ServiceType.WORKBUDDY && service != ServiceType.TRAE) return
|
if (service != ServiceType.WORKBUDDY && service != ServiceType.TRAE) return
|
||||||
if (_checkinState.value is CheckinState.Loading) return
|
if (_checkinState.value is CheckinState.Loading) return
|
||||||
_checkinState.value = CheckinState.Loading
|
_checkinState.value = CheckinState.Loading
|
||||||
val generation = serviceGeneration
|
val generation = serviceGeneration
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val result = when (service) {
|
val result = when (service) {
|
||||||
ServiceType.WORKBUDDY -> refreshBalanceUseCase.checkInWorkBuddy(accountId).fold(
|
ServiceType.WORKBUDDY -> refreshBalanceUseCase.checkInWorkBuddy().fold(
|
||||||
onSuccess = { s ->
|
onSuccess = { s ->
|
||||||
when (s.outcome) {
|
when (s.outcome) {
|
||||||
WorkBuddyRepository.CheckinOutcome.ALREADY ->
|
WorkBuddyRepository.CheckinOutcome.ALREADY ->
|
||||||
@ -158,7 +129,7 @@ class ServiceDetailViewModel @Inject constructor(
|
|||||||
// RepositoryError.message 是硬编码中文,必须走 errorMessage() 本地化映射
|
// RepositoryError.message 是硬编码中文,必须走 errorMessage() 本地化映射
|
||||||
onFailure = { e -> CheckinState.Error(errorMessage(e)) }
|
onFailure = { e -> CheckinState.Error(errorMessage(e)) }
|
||||||
)
|
)
|
||||||
ServiceType.TRAE -> refreshBalanceUseCase.checkInTrae(accountId).fold(
|
ServiceType.TRAE -> refreshBalanceUseCase.checkInTrae().fold(
|
||||||
onSuccess = { r ->
|
onSuccess = { r ->
|
||||||
when (r.outcome) {
|
when (r.outcome) {
|
||||||
TraeRepository.CheckinOutcome.SUCCESS ->
|
TraeRepository.CheckinOutcome.SUCCESS ->
|
||||||
@ -168,17 +139,8 @@ class ServiceDetailViewModel @Inject constructor(
|
|||||||
// 设备级拦截不是「账号已签」:用 Notice 展示,按钮保持可点(用户可换网/稍后重试)
|
// 设备级拦截不是「账号已签」:用 Notice 展示,按钮保持可点(用户可换网/稍后重试)
|
||||||
TraeRepository.CheckinOutcome.DEVICE_BLOCKED ->
|
TraeRepository.CheckinOutcome.DEVICE_BLOCKED ->
|
||||||
CheckinState.Notice(UiText.Resource(R.string.checkin_trae_device_blocked))
|
CheckinState.Notice(UiText.Resource(R.string.checkin_trae_device_blocked))
|
||||||
// 服务端临时限流(参与用户太多等):提示而非错误,稍后可重试
|
|
||||||
TraeRepository.CheckinOutcome.RETRYABLE ->
|
|
||||||
CheckinState.Notice(
|
|
||||||
if (r.message.isNotBlank()) UiText.Dynamic(r.message)
|
|
||||||
else UiText.Resource(R.string.checkin_trae_retry_later)
|
|
||||||
)
|
|
||||||
TraeRepository.CheckinOutcome.INTL_UNSUPPORTED ->
|
TraeRepository.CheckinOutcome.INTL_UNSUPPORTED ->
|
||||||
CheckinState.Error(
|
CheckinState.Error(UiText.Resource(R.string.checkin_trae_intl))
|
||||||
UiText.Resource(R.string.checkin_trae_intl),
|
|
||||||
showDeviceHint = false
|
|
||||||
)
|
|
||||||
TraeRepository.CheckinOutcome.FAILED -> CheckinState.Error(
|
TraeRepository.CheckinOutcome.FAILED -> CheckinState.Error(
|
||||||
// 服务端原始原因优先;缺省用带 code 的失败文案
|
// 服务端原始原因优先;缺省用带 code 的失败文案
|
||||||
if (r.message.isNotBlank()) {
|
if (r.message.isNotBlank()) {
|
||||||
@ -198,7 +160,7 @@ class ServiceDetailViewModel @Inject constructor(
|
|||||||
if (generation == serviceGeneration) {
|
if (generation == serviceGeneration) {
|
||||||
_checkinState.value = result
|
_checkinState.value = result
|
||||||
// 签到成功后刷新「今日已签到/连签」摘要,让按钮态即时更新
|
// 签到成功后刷新「今日已签到/连签」摘要,让按钮态即时更新
|
||||||
if (result is CheckinState.Done) preloadCheckinInfo(service, accountId)
|
if (result is CheckinState.Done) preloadCheckinInfo(service)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -230,7 +192,7 @@ class ServiceDetailViewModel @Inject constructor(
|
|||||||
val generation = ++panelWindowStatsGeneration
|
val generation = ++panelWindowStatsGeneration
|
||||||
_panelWindowStats.value = PanelWindowStatsState.Loading
|
_panelWindowStats.value = PanelWindowStatsState.Loading
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val result = sub2ApiRepository.fetchWindowModelStats(from, to, _accountId.value)
|
val result = sub2ApiRepository.fetchWindowModelStats(from, to)
|
||||||
// 过时结果(用户已切换到别的窗口)直接丢弃,不写状态
|
// 过时结果(用户已切换到别的窗口)直接丢弃,不写状态
|
||||||
if (generation != panelWindowStatsGeneration) return@launch
|
if (generation != panelWindowStatsGeneration) return@launch
|
||||||
result.fold(
|
result.fold(
|
||||||
@ -245,7 +207,7 @@ class ServiceDetailViewModel @Inject constructor(
|
|||||||
fun loadPanelGroups() {
|
fun loadPanelGroups() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
if (_panelGroups.value.isNotEmpty()) return@launch
|
if (_panelGroups.value.isNotEmpty()) return@launch
|
||||||
sub2ApiRepository.fetchPanelGroups(_accountId.value).onSuccess { _panelGroups.value = it }
|
sub2ApiRepository.fetchPanelGroups().onSuccess { _panelGroups.value = it }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -258,7 +220,7 @@ class ServiceDetailViewModel @Inject constructor(
|
|||||||
fun loadHourlyTrend(date: LocalDate) {
|
fun loadHourlyTrend(date: LocalDate) {
|
||||||
val generation = ++hourlyTrendGeneration
|
val generation = ++hourlyTrendGeneration
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val result = sub2ApiRepository.fetchHourlyTrend(date, _accountId.value)
|
val result = sub2ApiRepository.fetchHourlyTrend(date)
|
||||||
if (generation != hourlyTrendGeneration) return@launch
|
if (generation != hourlyTrendGeneration) return@launch
|
||||||
_hourlyTrend.value = result.getOrDefault(emptyList())
|
_hourlyTrend.value = result.getOrDefault(emptyList())
|
||||||
}
|
}
|
||||||
@ -275,7 +237,7 @@ class ServiceDetailViewModel @Inject constructor(
|
|||||||
val generation = ++panelKeyStatsGeneration
|
val generation = ++panelKeyStatsGeneration
|
||||||
_panelKeyStats.value = PanelKeyStatsState.Loading
|
_panelKeyStats.value = PanelKeyStatsState.Loading
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val result = sub2ApiRepository.fetchKeyUsageStats(from, to, _accountId.value)
|
val result = sub2ApiRepository.fetchKeyUsageStats(from, to)
|
||||||
if (generation != panelKeyStatsGeneration) return@launch
|
if (generation != panelKeyStatsGeneration) return@launch
|
||||||
result.fold(
|
result.fold(
|
||||||
onSuccess = { list ->
|
onSuccess = { list ->
|
||||||
@ -299,7 +261,7 @@ class ServiceDetailViewModel @Inject constructor(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
if (_panelKeysState.value is PanelKeysState.Loading) return@launch
|
if (_panelKeysState.value is PanelKeysState.Loading) return@launch
|
||||||
_panelKeysState.value = PanelKeysState.Loading
|
_panelKeysState.value = PanelKeysState.Loading
|
||||||
sub2ApiRepository.fetchPanelKeys(_accountId.value).fold(
|
sub2ApiRepository.fetchPanelKeys().fold(
|
||||||
onSuccess = { list -> _panelKeysState.value = PanelKeysState.Success(list) },
|
onSuccess = { list -> _panelKeysState.value = PanelKeysState.Success(list) },
|
||||||
onFailure = { e -> _panelKeysState.value = PanelKeysState.Error(e.message ?: "加载 Key 列表失败") }
|
onFailure = { e -> _panelKeysState.value = PanelKeysState.Error(e.message ?: "加载 Key 列表失败") }
|
||||||
)
|
)
|
||||||
@ -314,7 +276,7 @@ class ServiceDetailViewModel @Inject constructor(
|
|||||||
|
|
||||||
fun createPanelKey(name: String, groupId: Long?) {
|
fun createPanelKey(name: String, groupId: Long?) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
sub2ApiRepository.createPanelKey(name, groupId, _accountId.value).fold(
|
sub2ApiRepository.createPanelKey(name, groupId).fold(
|
||||||
onSuccess = { key ->
|
onSuccess = { key ->
|
||||||
_recentlyCreatedKey.value = key
|
_recentlyCreatedKey.value = key
|
||||||
_panelActionMessage.value = "已创建 Key:" + maskPanelKey(key) + "(请在弹出的窗口复制完整 Key)"
|
_panelActionMessage.value = "已创建 Key:" + maskPanelKey(key) + "(请在弹出的窗口复制完整 Key)"
|
||||||
@ -327,7 +289,7 @@ class ServiceDetailViewModel @Inject constructor(
|
|||||||
|
|
||||||
fun switchPanelGroup(keyId: Long, groupId: Long) {
|
fun switchPanelGroup(keyId: Long, groupId: Long) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
sub2ApiRepository.updatePanelKey(keyId, groupId, accountId = _accountId.value).fold(
|
sub2ApiRepository.updatePanelKey(keyId, groupId).fold(
|
||||||
onSuccess = {
|
onSuccess = {
|
||||||
_panelActionMessage.value = "分组已切换"
|
_panelActionMessage.value = "分组已切换"
|
||||||
loadPanelKeys()
|
loadPanelKeys()
|
||||||
@ -339,10 +301,10 @@ class ServiceDetailViewModel @Inject constructor(
|
|||||||
|
|
||||||
fun redeemPanelCode(code: String) {
|
fun redeemPanelCode(code: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
sub2ApiRepository.redeemPanelCode(code, _accountId.value).fold(
|
sub2ApiRepository.redeemPanelCode(code).fold(
|
||||||
onSuccess = { msg ->
|
onSuccess = { msg ->
|
||||||
_panelActionMessage.value = msg
|
_panelActionMessage.value = msg
|
||||||
refreshBalanceUseCase(ServiceType.SUB2API, _accountId.value)
|
refreshBalanceUseCase(ServiceType.SUB2API)
|
||||||
loadPanelKeys()
|
loadPanelKeys()
|
||||||
},
|
},
|
||||||
onFailure = { e -> _panelActionMessage.value = "兑换失败:" + (e.message ?: "") }
|
onFailure = { e -> _panelActionMessage.value = "兑换失败:" + (e.message ?: "") }
|
||||||
@ -399,55 +361,37 @@ class ServiceDetailViewModel @Inject constructor(
|
|||||||
else -> CODEX_PREFS
|
else -> CODEX_PREFS
|
||||||
}
|
}
|
||||||
|
|
||||||
fun bind(service: ServiceType, accountId: String? = null) {
|
fun bind(service: ServiceType) {
|
||||||
val effectiveAccountId = accountId?.takeIf { it.isNotBlank() }
|
if (_serviceType.value == service) return
|
||||||
if (_serviceType.value == service && _accountId.value == effectiveAccountId) return
|
|
||||||
|
|
||||||
serviceGeneration++
|
serviceGeneration++
|
||||||
refreshGeneration++
|
refreshGeneration++
|
||||||
_serviceType.value = service
|
_serviceType.value = service
|
||||||
_accountId.value = effectiveAccountId
|
|
||||||
_accounts.value = emptyList()
|
|
||||||
currentCredentialFingerprint = null
|
currentCredentialFingerprint = null
|
||||||
_models.value = emptyList()
|
_models.value = emptyList()
|
||||||
_selectedModel.value = null
|
_selectedModel.value = null
|
||||||
_modelsLoading.value = false
|
_modelsLoading.value = false
|
||||||
_triggerState.value = TriggerState.Idle
|
_triggerState.value = TriggerState.Idle
|
||||||
// 切换服务/账号时重置签到状态,避免上一服务/账号的 Done/Error 残留(按钮态/文案串台)
|
// 切换服务时重置签到状态,避免上一服务的 Done/Error 残留(按钮态/文案串台)
|
||||||
_checkinState.value = CheckinState.Idle
|
_checkinState.value = CheckinState.Idle
|
||||||
|
|
||||||
if (service == ServiceType.CODEX || service == ServiceType.OPENCODE_GO || service == ServiceType.OLLAMA) {
|
if (service == ServiceType.CODEX || service == ServiceType.OPENCODE_GO || service == ServiceType.OLLAMA) {
|
||||||
loadSelectedModel(service)?.let { _selectedModel.value = it }
|
loadSelectedModel(service)?.let { _selectedModel.value = it }
|
||||||
loadModelsCache(service).takeIf { it.isNotEmpty() }?.let { _models.value = it }
|
loadModelsCache(service).takeIf { it.isNotEmpty() }?.let { _models.value = it }
|
||||||
}
|
}
|
||||||
loadAccounts(service)
|
preloadCheckinInfo(service)
|
||||||
preloadCheckinInfo(service, effectiveAccountId)
|
|
||||||
loadFromCache()
|
loadFromCache()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 加载多账号服务的账号列表(账号管理页返回后可再次调用刷新)。 */
|
|
||||||
fun loadAccounts(service: ServiceType) {
|
|
||||||
val serviceGen = serviceGeneration
|
|
||||||
viewModelScope.launch {
|
|
||||||
val accounts = credentialRepository.accountsFor(service)
|
|
||||||
if (serviceGen == serviceGeneration && _serviceType.value == service) {
|
|
||||||
_accounts.value = accounts
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 返回页面时同步凭据和缓存。凭据未变时保留错误/Loading,但按 fetchedAt 采用
|
* 返回页面时同步凭据和缓存。凭据未变时保留错误/Loading,但按 fetchedAt 采用
|
||||||
* Widget 或其他入口写入的更新缓存;凭据新增或替换时废弃旧状态并重新刷新。
|
* Widget 或其他入口写入的更新缓存;凭据新增或替换时废弃旧状态并重新刷新。
|
||||||
*/
|
*/
|
||||||
fun reloadCredentialState() {
|
fun reloadCredentialState() {
|
||||||
val type = _serviceType.value ?: return
|
val type = _serviceType.value ?: return
|
||||||
val accountId = _accountId.value
|
|
||||||
val serviceGen = serviceGeneration
|
val serviceGen = serviceGeneration
|
||||||
// 账号列表可能因账号管理页新增/删除/设默认而变化,一并刷新
|
|
||||||
loadAccounts(type)
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val local = credentialRepository.readLocalState(type, accountId)
|
val local = credentialRepository.readLocalState(type)
|
||||||
val newFingerprint = local.fingerprint
|
val newFingerprint = local.fingerprint
|
||||||
val change = classifyCredentialChange(currentCredentialFingerprint, newFingerprint)
|
val change = classifyCredentialChange(currentCredentialFingerprint, newFingerprint)
|
||||||
val newHasCredential = local.status.state != CredentialStatus.State.NOT_CONFIGURED
|
val newHasCredential = local.status.state != CredentialStatus.State.NOT_CONFIGURED
|
||||||
@ -497,11 +441,10 @@ class ServiceDetailViewModel @Inject constructor(
|
|||||||
|
|
||||||
fun refresh() {
|
fun refresh() {
|
||||||
val type = _serviceType.value ?: return
|
val type = _serviceType.value ?: return
|
||||||
val accountId = _accountId.value
|
|
||||||
val serviceGen = serviceGeneration
|
val serviceGen = serviceGeneration
|
||||||
val gen = ++refreshGeneration
|
val gen = ++refreshGeneration
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val requestFingerprint = credentialRepository.readLocalState(type, accountId).fingerprint
|
val requestFingerprint = credentialRepository.readLocalState(type).fingerprint
|
||||||
if (isStaleRequest(type, serviceGen, gen)) return@launch
|
if (isStaleRequest(type, serviceGen, gen)) return@launch
|
||||||
_uiState.update { it.copy(state = State.Loading) }
|
_uiState.update { it.copy(state = State.Loading) }
|
||||||
|
|
||||||
@ -522,10 +465,10 @@ class ServiceDetailViewModel @Inject constructor(
|
|||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
val result = refreshBalanceUseCase(type, accountId)
|
val result = refreshBalanceUseCase(type)
|
||||||
if (isStaleRequest(type, serviceGen, gen)) return@launch
|
if (isStaleRequest(type, serviceGen, gen)) return@launch
|
||||||
|
|
||||||
val local = credentialRepository.readLocalState(type, accountId)
|
val local = credentialRepository.readLocalState(type)
|
||||||
if (isStaleRequest(type, serviceGen, gen)) return@launch
|
if (isStaleRequest(type, serviceGen, gen)) return@launch
|
||||||
val fingerprintChanged = local.fingerprint != requestFingerprint
|
val fingerprintChanged = local.fingerprint != requestFingerprint
|
||||||
|
|
||||||
@ -715,7 +658,6 @@ class ServiceDetailViewModel @Inject constructor(
|
|||||||
|
|
||||||
fun saveManualBalance(amount: Double) {
|
fun saveManualBalance(amount: Double) {
|
||||||
val type = _serviceType.value ?: return
|
val type = _serviceType.value ?: return
|
||||||
val accountId = _accountId.value
|
|
||||||
val serviceGen = serviceGeneration
|
val serviceGen = serviceGeneration
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val config = com.rainy.token.domain.service.ServiceConfigProvider.get(type)
|
val config = com.rainy.token.domain.service.ServiceConfigProvider.get(type)
|
||||||
@ -725,8 +667,8 @@ class ServiceDetailViewModel @Inject constructor(
|
|||||||
unit = config.displayUnit,
|
unit = config.displayUnit,
|
||||||
isAvailable = true
|
isAvailable = true
|
||||||
)
|
)
|
||||||
balanceCache.put(type, balance, accountId)
|
balanceCache.put(type, balance)
|
||||||
val cached = balanceCache.get(type, accountId)
|
val cached = balanceCache.get(type)
|
||||||
if (serviceGen == serviceGeneration && _serviceType.value == type) {
|
if (serviceGen == serviceGeneration && _serviceType.value == type) {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
@ -740,9 +682,8 @@ class ServiceDetailViewModel @Inject constructor(
|
|||||||
|
|
||||||
fun markVerified() {
|
fun markVerified() {
|
||||||
val type = _serviceType.value ?: return
|
val type = _serviceType.value ?: return
|
||||||
val accountId = _accountId.value
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val credential = credentialRepository.get(type, accountId) ?: return@launch
|
val credential = credentialRepository.get(type) ?: return@launch
|
||||||
val updated = when (credential) {
|
val updated = when (credential) {
|
||||||
is Credential.ApiKeyCredential ->
|
is Credential.ApiKeyCredential ->
|
||||||
credential.copy(lastVerifiedAt = System.currentTimeMillis())
|
credential.copy(lastVerifiedAt = System.currentTimeMillis())
|
||||||
@ -757,17 +698,16 @@ class ServiceDetailViewModel @Inject constructor(
|
|||||||
is Credential.Sub2ApiCredential ->
|
is Credential.Sub2ApiCredential ->
|
||||||
credential.copy(lastVerifiedAt = System.currentTimeMillis())
|
credential.copy(lastVerifiedAt = System.currentTimeMillis())
|
||||||
}
|
}
|
||||||
credentialRepository.save(updated, accountId)
|
credentialRepository.save(updated)
|
||||||
loadFromCache()
|
loadFromCache()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadFromCache() {
|
private fun loadFromCache() {
|
||||||
val type = _serviceType.value ?: return
|
val type = _serviceType.value ?: return
|
||||||
val accountId = _accountId.value
|
|
||||||
val serviceGen = serviceGeneration
|
val serviceGen = serviceGeneration
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val local = credentialRepository.readLocalState(type, accountId)
|
val local = credentialRepository.readLocalState(type)
|
||||||
val status = local.status
|
val status = local.status
|
||||||
val cached = local.cachedBalance
|
val cached = local.cachedBalance
|
||||||
val config = com.rainy.token.domain.service.ServiceConfigProvider.get(type)
|
val config = com.rainy.token.domain.service.ServiceConfigProvider.get(type)
|
||||||
@ -781,10 +721,7 @@ class ServiceDetailViewModel @Inject constructor(
|
|||||||
UiText.Resource(R.string.error_credential_not_configured),
|
UiText.Resource(R.string.error_credential_not_configured),
|
||||||
RepositoryError.InvalidCredential()
|
RepositoryError.InvalidCredential()
|
||||||
)
|
)
|
||||||
// OK 与 WARNING 都视为「凭据可用」:WARNING 仅是提示建议重新验证(如面板登录较久),
|
cached != null && status.state == CredentialStatus.State.OK ->
|
||||||
// 凭据仍有效,不应误报为「未配置或已过期」——否则能正常刷新余额却显示错误。
|
|
||||||
cached != null && (status.state == CredentialStatus.State.OK ||
|
|
||||||
status.state == CredentialStatus.State.WARNING) ->
|
|
||||||
// 缓存新鲜(≤5 分钟)时视为最新数据直接展示,避免"刚刷新完进详情却显示缓存"的困惑
|
// 缓存新鲜(≤5 分钟)时视为最新数据直接展示,避免"刚刷新完进详情却显示缓存"的困惑
|
||||||
if (isCacheFresh(cached.fetchedAt)) {
|
if (isCacheFresh(cached.fetchedAt)) {
|
||||||
State.Fresh(cached.balance)
|
State.Fresh(cached.balance)
|
||||||
@ -886,8 +823,6 @@ class ServiceDetailViewModel @Inject constructor(
|
|||||||
_serviceType.value != type
|
_serviceType.value != type
|
||||||
|
|
||||||
private fun errorMessage(error: Throwable): UiText = when (error) {
|
private fun errorMessage(error: Throwable): UiText = when (error) {
|
||||||
is RepositoryError.SessionExpired ->
|
|
||||||
UiText.Resource(R.string.error_credential_session_expired)
|
|
||||||
is RepositoryError.InvalidCredential ->
|
is RepositoryError.InvalidCredential ->
|
||||||
UiText.Resource(R.string.error_credential_invalid_reconfigure)
|
UiText.Resource(R.string.error_credential_invalid_reconfigure)
|
||||||
is RepositoryError.CredentialChanged ->
|
is RepositoryError.CredentialChanged ->
|
||||||
@ -947,11 +882,7 @@ sealed class CheckinState {
|
|||||||
data object Idle : CheckinState()
|
data object Idle : CheckinState()
|
||||||
data object Loading : CheckinState()
|
data object Loading : CheckinState()
|
||||||
data class Done(val message: UiText) : CheckinState()
|
data class Done(val message: UiText) : CheckinState()
|
||||||
|
data class Error(val message: UiText) : CheckinState()
|
||||||
/**
|
|
||||||
* @param showDeviceHint INTL 区接口不支持与设备无关,false 时不追加「设备 ID 获取路径」提示。
|
|
||||||
*/
|
|
||||||
data class Error(val message: UiText, val showDeviceHint: Boolean = true) : CheckinState()
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 提示态(非错误、也非完成):例如 Trae 设备级拦截「该设备今日签到次数已达上限」。
|
* 提示态(非错误、也非完成):例如 Trae 设备级拦截「该设备今日签到次数已达上限」。
|
||||||
|
|||||||
@ -1,342 +0,0 @@
|
|||||||
package com.rainy.token.ui.settings
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.ArrowBack
|
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.CardDefaults
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.DropdownMenu
|
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Snackbar
|
|
||||||
import androidx.compose.material3.SnackbarHost
|
|
||||||
import androidx.compose.material3.SnackbarHostState
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
|
||||||
import androidx.lifecycle.Lifecycle
|
|
||||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import com.rainy.token.R
|
|
||||||
import com.rainy.token.domain.model.ServiceAccountInfo
|
|
||||||
import com.rainy.token.domain.service.ServiceType
|
|
||||||
import com.rainy.token.ui.components.ServiceIcon
|
|
||||||
import com.rainy.token.ui.components.resolve
|
|
||||||
import com.rainy.token.ui.theme.InkMuted
|
|
||||||
import com.rainy.token.ui.theme.StrawberryPink
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 账号管理页(Trae / WorkBuddy 多账号)。
|
|
||||||
*
|
|
||||||
* - 列出该服务全部账号(默认账号带角标);
|
|
||||||
* - 行内菜单:设为默认 / 重命名 / 删除;
|
|
||||||
* - 「添加账号」导航到凭据编辑器(新建模式)。
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun AccountManagementScreen(
|
|
||||||
service: ServiceType,
|
|
||||||
onBack: () -> Unit,
|
|
||||||
onEditAccount: (ServiceType, String?) -> Unit,
|
|
||||||
viewModel: AccountManagementViewModel = hiltViewModel()
|
|
||||||
) {
|
|
||||||
// 重新进入页面(从凭据编辑页保存/删除后返回)时刷新账号列表
|
|
||||||
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
|
|
||||||
viewModel.refresh()
|
|
||||||
}
|
|
||||||
LaunchedEffect(service) { viewModel.bind(service) }
|
|
||||||
|
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
|
||||||
val context = LocalContext.current
|
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
|
||||||
|
|
||||||
// 行菜单:记录哪个账号的菜单展开
|
|
||||||
var menuAccountId by remember { mutableStateOf<String?>(null) }
|
|
||||||
// 重命名对话框
|
|
||||||
var renamingAccount by remember { mutableStateOf<ServiceAccountInfo?>(null) }
|
|
||||||
var renameInput by remember { mutableStateOf("") }
|
|
||||||
// 删除确认对话框
|
|
||||||
var deletingAccount by remember { mutableStateOf<ServiceAccountInfo?>(null) }
|
|
||||||
|
|
||||||
LaunchedEffect(uiState.message) {
|
|
||||||
uiState.message?.let { msg ->
|
|
||||||
snackbarHostState.showSnackbar(msg.resolve(context))
|
|
||||||
viewModel.consumeMessage()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Scaffold(
|
|
||||||
containerColor = Color.Transparent,
|
|
||||||
topBar = {
|
|
||||||
TopAppBar(
|
|
||||||
title = { Text(stringResource(R.string.account_manager_title, service.displayName)) },
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = onBack) {
|
|
||||||
Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) { data -> Snackbar(snackbarData = data) } }
|
|
||||||
) { innerPadding ->
|
|
||||||
if (uiState.loading && uiState.accounts.isEmpty()) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(innerPadding),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
CircularProgressIndicator(color = StrawberryPink)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(innerPadding),
|
|
||||||
contentPadding = PaddingValues(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
|
||||||
) {
|
|
||||||
if (uiState.accounts.isEmpty()) {
|
|
||||||
item {
|
|
||||||
Card(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
shape = RoundedCornerShape(20.dp),
|
|
||||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.account_empty_hint),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = InkMuted,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(20.dp),
|
|
||||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
items(uiState.accounts, key = { it.id }) { account ->
|
|
||||||
val ordinal = accountOrdinal(uiState.accounts, account)
|
|
||||||
AccountRow(
|
|
||||||
account = account,
|
|
||||||
displayName = account.label?.takeIf { it.isNotBlank() }
|
|
||||||
?: stringResource(R.string.account_fallback_name, ordinal),
|
|
||||||
menuExpanded = menuAccountId == account.id,
|
|
||||||
onMenuToggle = {
|
|
||||||
menuAccountId = if (menuAccountId == account.id) null else account.id
|
|
||||||
},
|
|
||||||
onMenuDismiss = { menuAccountId = null },
|
|
||||||
onEdit = { onEditAccount(service, account.id) },
|
|
||||||
onSetDefault = {
|
|
||||||
menuAccountId = null
|
|
||||||
viewModel.setDefault(account.id)
|
|
||||||
},
|
|
||||||
onRename = {
|
|
||||||
menuAccountId = null
|
|
||||||
renamingAccount = account
|
|
||||||
renameInput = account.label.orEmpty()
|
|
||||||
},
|
|
||||||
onDelete = {
|
|
||||||
menuAccountId = null
|
|
||||||
deletingAccount = account
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
item {
|
|
||||||
Button(
|
|
||||||
onClick = { onEditAccount(service, null) },
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(46.dp),
|
|
||||||
shape = RoundedCornerShape(14.dp),
|
|
||||||
colors = androidx.compose.material3.ButtonDefaults.buttonColors(
|
|
||||||
containerColor = StrawberryPink,
|
|
||||||
contentColor = Color.White
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.account_add))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重命名对话框
|
|
||||||
renamingAccount?.let { account ->
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { renamingAccount = null },
|
|
||||||
title = { Text(stringResource(R.string.account_rename_dialog_title)) },
|
|
||||||
text = {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = renameInput,
|
|
||||||
onValueChange = { renameInput = it },
|
|
||||||
label = { Text(stringResource(R.string.account_name_label)) },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
viewModel.rename(account.id, renameInput)
|
|
||||||
renamingAccount = null
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.action_confirm), color = StrawberryPink)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = { renamingAccount = null }) {
|
|
||||||
Text(stringResource(R.string.action_cancel))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除确认对话框
|
|
||||||
deletingAccount?.let { account ->
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { deletingAccount = null },
|
|
||||||
title = { Text(stringResource(R.string.account_delete_title)) },
|
|
||||||
text = { Text(stringResource(R.string.account_delete_confirm)) },
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
viewModel.delete(account.id)
|
|
||||||
deletingAccount = null
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.action_delete_confirmed), color = StrawberryPink)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = { deletingAccount = null }) {
|
|
||||||
Text(stringResource(R.string.action_cancel))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun AccountRow(
|
|
||||||
account: ServiceAccountInfo,
|
|
||||||
displayName: String,
|
|
||||||
menuExpanded: Boolean,
|
|
||||||
onMenuToggle: () -> Unit,
|
|
||||||
onMenuDismiss: () -> Unit,
|
|
||||||
onEdit: () -> Unit,
|
|
||||||
onSetDefault: () -> Unit,
|
|
||||||
onRename: () -> Unit,
|
|
||||||
onDelete: () -> Unit
|
|
||||||
) {
|
|
||||||
Card(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable { onEdit() },
|
|
||||||
shape = RoundedCornerShape(20.dp),
|
|
||||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 12.dp, end = 4.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Box(modifier = Modifier.weight(1f)) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Text(
|
|
||||||
text = displayName,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.SemiBold
|
|
||||||
)
|
|
||||||
if (account.isDefault) {
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.account_default_badge),
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = StrawberryPink,
|
|
||||||
maxLines = 1,
|
|
||||||
softWrap = false,
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
|
||||||
.background(
|
|
||||||
color = StrawberryPink.copy(alpha = 0.12f),
|
|
||||||
shape = RoundedCornerShape(8.dp)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Box {
|
|
||||||
IconButton(onClick = onMenuToggle) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.MoreVert,
|
|
||||||
contentDescription = stringResource(R.string.account_actions),
|
|
||||||
tint = InkMuted
|
|
||||||
)
|
|
||||||
}
|
|
||||||
DropdownMenu(expanded = menuExpanded, onDismissRequest = onMenuDismiss) {
|
|
||||||
if (!account.isDefault) {
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(stringResource(R.string.account_set_default)) },
|
|
||||||
onClick = onSetDefault
|
|
||||||
)
|
|
||||||
}
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(stringResource(R.string.account_rename)) },
|
|
||||||
onClick = onRename
|
|
||||||
)
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(stringResource(R.string.account_delete_title)) },
|
|
||||||
onClick = onDelete
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 同服务内账号序号(1 起,用于“账号 N”兜底显示)。 */
|
|
||||||
private fun accountOrdinal(accounts: List<ServiceAccountInfo>, account: ServiceAccountInfo): Int =
|
|
||||||
accounts.indexOf(account) + 1
|
|
||||||
@ -1,97 +0,0 @@
|
|||||||
package com.rainy.token.ui.settings
|
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import com.rainy.token.R
|
|
||||||
import com.rainy.token.data.repository.CredentialRepository
|
|
||||||
import com.rainy.token.domain.model.ServiceAccountInfo
|
|
||||||
import com.rainy.token.domain.service.ServiceType
|
|
||||||
import com.rainy.token.ui.components.UiText
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import javax.inject.Inject
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.update
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 账号管理页 ViewModel(Trae / WorkBuddy 多账号)。
|
|
||||||
*
|
|
||||||
* 「添加账号」由页面回调导航到凭据编辑器(新建模式),首个账号保存时自动设为默认;
|
|
||||||
* 本页负责列出现有账号、设默认、重命名、删除。
|
|
||||||
*/
|
|
||||||
@HiltViewModel
|
|
||||||
class AccountManagementViewModel @Inject constructor(
|
|
||||||
private val credentialRepository: CredentialRepository
|
|
||||||
) : ViewModel() {
|
|
||||||
|
|
||||||
private val _service = MutableStateFlow<ServiceType?>(null)
|
|
||||||
val service: StateFlow<ServiceType?> = _service.asStateFlow()
|
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(AccountManagementUiState())
|
|
||||||
val uiState: StateFlow<AccountManagementUiState> = _uiState.asStateFlow()
|
|
||||||
|
|
||||||
private var loadGeneration = 0
|
|
||||||
|
|
||||||
fun bind(service: ServiceType) {
|
|
||||||
if (_service.value == service) return
|
|
||||||
_service.value = service
|
|
||||||
refresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun refresh() {
|
|
||||||
val type = _service.value ?: return
|
|
||||||
val generation = ++loadGeneration
|
|
||||||
viewModelScope.launch {
|
|
||||||
_uiState.update { it.copy(loading = true) }
|
|
||||||
val accounts = credentialRepository.accountsFor(type)
|
|
||||||
if (generation != loadGeneration) return@launch
|
|
||||||
_uiState.update {
|
|
||||||
it.copy(
|
|
||||||
loading = false,
|
|
||||||
accounts = accounts,
|
|
||||||
// 账号列表可能为空(用户刚删光),保持消息残留会误导,这里清掉
|
|
||||||
message = if (accounts.isEmpty()) null else it.message
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setDefault(accountId: String) {
|
|
||||||
val type = _service.value ?: return
|
|
||||||
viewModelScope.launch {
|
|
||||||
credentialRepository.setDefaultAccount(type, accountId)
|
|
||||||
_uiState.update { it.copy(message = UiText.Resource(R.string.msg_account_default_set)) }
|
|
||||||
refresh()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun rename(accountId: String, label: String) {
|
|
||||||
val type = _service.value ?: return
|
|
||||||
viewModelScope.launch {
|
|
||||||
credentialRepository.renameAccount(type, accountId, label.trim())
|
|
||||||
_uiState.update { it.copy(message = UiText.Resource(R.string.msg_account_renamed)) }
|
|
||||||
refresh()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun delete(accountId: String) {
|
|
||||||
val type = _service.value ?: return
|
|
||||||
viewModelScope.launch {
|
|
||||||
credentialRepository.removeAccount(type, accountId)
|
|
||||||
_uiState.update { it.copy(message = UiText.Resource(R.string.msg_account_deleted)) }
|
|
||||||
refresh()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun consumeMessage() {
|
|
||||||
_uiState.update { it.copy(message = null) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class AccountManagementUiState(
|
|
||||||
val loading: Boolean = false,
|
|
||||||
val accounts: List<ServiceAccountInfo> = emptyList(),
|
|
||||||
val message: UiText? = null
|
|
||||||
)
|
|
||||||
@ -53,8 +53,6 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
import androidx.lifecycle.Lifecycle
|
|
||||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
@ -80,18 +78,13 @@ import com.rainy.token.domain.service.ServiceType
|
|||||||
@Composable
|
@Composable
|
||||||
fun CredentialEditScreen(
|
fun CredentialEditScreen(
|
||||||
service: ServiceType,
|
service: ServiceType,
|
||||||
accountId: String? = null,
|
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onStartWebViewLogin: (ServiceType, String?) -> Unit,
|
onStartWebViewLogin: (ServiceType) -> Unit,
|
||||||
onWebViewLoginSuccess: (ServiceType) -> Unit,
|
onWebViewLoginSuccess: (ServiceType) -> Unit,
|
||||||
onStartCodexOAuth: () -> Unit = {},
|
onStartCodexOAuth: () -> Unit = {},
|
||||||
viewModel: CredentialEditViewModel = hiltViewModel()
|
viewModel: CredentialEditViewModel = hiltViewModel()
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(service, accountId) { viewModel.bind(service, accountId) }
|
LaunchedEffect(service) { viewModel.bind(service) }
|
||||||
// 从 WebView 登录页返回时,登录流程可能已改写当前账号凭据,重载表单
|
|
||||||
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
|
|
||||||
viewModel.reload()
|
|
||||||
}
|
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@ -107,23 +100,10 @@ fun CredentialEditScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 多账号服务:标题带账号名(新建账号尚无 ID 时也提示是「新账号」)
|
|
||||||
val titleText = when {
|
|
||||||
ServiceType.isMultiAccount(service) && uiState.accountId != null ->
|
|
||||||
stringResource(
|
|
||||||
R.string.service_detail_title_with_account,
|
|
||||||
service.displayName,
|
|
||||||
uiState.accountLabel ?: stringResource(R.string.account_fallback_name, uiState.accountIndex)
|
|
||||||
)
|
|
||||||
ServiceType.isMultiAccount(service) && !uiState.hasExisting ->
|
|
||||||
stringResource(R.string.account_new_credential_title, service.displayName)
|
|
||||||
else -> service.displayName
|
|
||||||
}
|
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(titleText) },
|
title = { Text(service.displayName) },
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = onBack) {
|
||||||
Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
|
Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
|
||||||
@ -154,12 +134,10 @@ fun CredentialEditScreen(
|
|||||||
TraeCredentialForm(
|
TraeCredentialForm(
|
||||||
jwt = uiState.traeJwt,
|
jwt = uiState.traeJwt,
|
||||||
region = uiState.traeRegion,
|
region = uiState.traeRegion,
|
||||||
checkinDeviceId = uiState.traeCheckinDeviceId,
|
|
||||||
hasExisting = uiState.hasExisting,
|
hasExisting = uiState.hasExisting,
|
||||||
onJwtChange = viewModel::updateTraeJwt,
|
onJwtChange = viewModel::updateTraeJwt,
|
||||||
onRegionChange = viewModel::updateTraeRegion,
|
onRegionChange = viewModel::updateTraeRegion,
|
||||||
onCheckinDeviceIdChange = viewModel::updateTraeCheckinDeviceId,
|
onStartInAppLogin = { onStartWebViewLogin(ServiceType.TRAE) },
|
||||||
onStartInAppLogin = { onStartWebViewLogin(ServiceType.TRAE, accountId) },
|
|
||||||
onSave = viewModel::saveTraeCredential
|
onSave = viewModel::saveTraeCredential
|
||||||
)
|
)
|
||||||
} else if (service == ServiceType.WORKBUDDY) {
|
} else if (service == ServiceType.WORKBUDDY) {
|
||||||
@ -169,7 +147,7 @@ fun CredentialEditScreen(
|
|||||||
hasExisting = uiState.hasExisting,
|
hasExisting = uiState.hasExisting,
|
||||||
onAccessChange = viewModel::updateWorkBuddyAccess,
|
onAccessChange = viewModel::updateWorkBuddyAccess,
|
||||||
onRefreshChange = viewModel::updateWorkBuddyRefresh,
|
onRefreshChange = viewModel::updateWorkBuddyRefresh,
|
||||||
onStartInAppLogin = { onStartWebViewLogin(ServiceType.WORKBUDDY, accountId) },
|
onStartInAppLogin = { onStartWebViewLogin(ServiceType.WORKBUDDY) },
|
||||||
onSave = viewModel::saveWorkBuddyCredential
|
onSave = viewModel::saveWorkBuddyCredential
|
||||||
)
|
)
|
||||||
} else if (service == ServiceType.SUB2API) {
|
} else if (service == ServiceType.SUB2API) {
|
||||||
@ -267,7 +245,6 @@ fun CredentialEditScreen(
|
|||||||
onImportFromClipboard = { viewModel.importFromClipboard(context) },
|
onImportFromClipboard = { viewModel.importFromClipboard(context) },
|
||||||
onCopyLoginUrl = { copyToClipboard(context, uiState.loginUrl) },
|
onCopyLoginUrl = { copyToClipboard(context, uiState.loginUrl) },
|
||||||
onOpenLoginUrl = { openInBrowser(context, uiState.loginUrl) },
|
onOpenLoginUrl = { openInBrowser(context, uiState.loginUrl) },
|
||||||
onStartInAppLogin = { onStartWebViewLogin(ServiceType.OPENCODE_GO, accountId) },
|
|
||||||
onShowHelp = { showGoHelp = true },
|
onShowHelp = { showGoHelp = true },
|
||||||
onApiKeyChange = viewModel::updateTriggerApiKey,
|
onApiKeyChange = viewModel::updateTriggerApiKey,
|
||||||
onSaveApiKey = { viewModel.saveTriggerApiKey() }
|
onSaveApiKey = { viewModel.saveTriggerApiKey() }
|
||||||
@ -511,7 +488,6 @@ private fun OpenCodeGoForm(
|
|||||||
onImportFromClipboard: () -> Unit,
|
onImportFromClipboard: () -> Unit,
|
||||||
onCopyLoginUrl: () -> Unit,
|
onCopyLoginUrl: () -> Unit,
|
||||||
onOpenLoginUrl: () -> Unit,
|
onOpenLoginUrl: () -> Unit,
|
||||||
onStartInAppLogin: () -> Unit,
|
|
||||||
onShowHelp: () -> Unit,
|
onShowHelp: () -> Unit,
|
||||||
onApiKeyChange: (String) -> Unit,
|
onApiKeyChange: (String) -> Unit,
|
||||||
onSaveApiKey: () -> Unit
|
onSaveApiKey: () -> Unit
|
||||||
@ -522,10 +498,6 @@ private fun OpenCodeGoForm(
|
|||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.outline
|
color = MaterialTheme.colorScheme.outline
|
||||||
)
|
)
|
||||||
// 主推入口:应用内登录自动抓 auth cookie + workspaceId,免手动开 DevTools 手抄
|
|
||||||
OutlinedButton(onClick = onStartInAppLogin, modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Text(text = stringResource(R.string.action_login_in_app))
|
|
||||||
}
|
|
||||||
OutlinedButton(onClick = onImportFromClipboard, modifier = Modifier.fillMaxWidth()) {
|
OutlinedButton(onClick = onImportFromClipboard, modifier = Modifier.fillMaxWidth()) {
|
||||||
Text(text = stringResource(R.string.action_import_from_clipboard))
|
Text(text = stringResource(R.string.action_import_from_clipboard))
|
||||||
}
|
}
|
||||||
@ -778,11 +750,9 @@ private fun ManualCookieForm(
|
|||||||
private fun TraeCredentialForm(
|
private fun TraeCredentialForm(
|
||||||
jwt: String,
|
jwt: String,
|
||||||
region: String,
|
region: String,
|
||||||
checkinDeviceId: String,
|
|
||||||
hasExisting: Boolean,
|
hasExisting: Boolean,
|
||||||
onJwtChange: (String) -> Unit,
|
onJwtChange: (String) -> Unit,
|
||||||
onRegionChange: (String) -> Unit,
|
onRegionChange: (String) -> Unit,
|
||||||
onCheckinDeviceIdChange: (String) -> Unit,
|
|
||||||
onStartInAppLogin: () -> Unit,
|
onStartInAppLogin: () -> Unit,
|
||||||
onSave: () -> Unit
|
onSave: () -> Unit
|
||||||
) {
|
) {
|
||||||
@ -823,85 +793,11 @@ private fun TraeCredentialForm(
|
|||||||
onSelect = { onRegionChange("INTL") }
|
onSelect = { onRegionChange("INTL") }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
OutlinedTextField(
|
|
||||||
value = checkinDeviceId,
|
|
||||||
onValueChange = onCheckinDeviceIdChange,
|
|
||||||
label = { Text(stringResource(R.string.field_trae_checkin_device_id)) },
|
|
||||||
supportingText = { Text(stringResource(R.string.field_trae_checkin_device_hint)) },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
var showDeviceIdGuide by remember { mutableStateOf(false) }
|
|
||||||
TextButton(onClick = { showDeviceIdGuide = true }) {
|
|
||||||
Text(stringResource(R.string.field_trae_device_id_howto))
|
|
||||||
}
|
|
||||||
if (showDeviceIdGuide) {
|
|
||||||
TraeDeviceIdGuideDialog(onDismiss = { showDeviceIdGuide = false })
|
|
||||||
}
|
|
||||||
Button(onClick = onSave, enabled = jwt.isNotBlank(), modifier = Modifier.fillMaxWidth()) {
|
Button(onClick = onSave, enabled = jwt.isNotBlank(), modifier = Modifier.fillMaxWidth()) {
|
||||||
Text(if (hasExisting) stringResource(R.string.action_update) else stringResource(R.string.action_save))
|
Text(if (hasExisting) stringResource(R.string.action_update) else stringResource(R.string.action_save))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 设备 ID 获取分步指引弹窗:面向不熟悉电脑操作的用户,含一键复制日志目录路径。 */
|
|
||||||
@Composable
|
|
||||||
private fun TraeDeviceIdGuideDialog(onDismiss: () -> Unit) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
// 环境变量形式:Win+R 与资源管理器地址栏都能展开 %APPDATA%,相对路径在两处都打不开
|
|
||||||
val logPath = "%APPDATA%\\Trae CN\\logs"
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
title = { Text(stringResource(R.string.field_trae_device_id_howto_title)) },
|
|
||||||
text = {
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.field_trae_device_id_howto_step1),
|
|
||||||
style = MaterialTheme.typography.bodyMedium
|
|
||||||
)
|
|
||||||
// 路径 + 一键复制(小白直接粘到资源管理器地址栏)
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
logPath,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.weight(1f, fill = true)
|
|
||||||
)
|
|
||||||
TextButton(onClick = {
|
|
||||||
val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
|
||||||
cm.setPrimaryClip(ClipData.newPlainText("trae_log_path", logPath))
|
|
||||||
// 复制反馈:小白需要确认复制成功(R.string 引用须在 composable 内解析)
|
|
||||||
android.widget.Toast.makeText(
|
|
||||||
context,
|
|
||||||
context.getString(R.string.msg_path_copied),
|
|
||||||
android.widget.Toast.LENGTH_SHORT
|
|
||||||
).show()
|
|
||||||
}) {
|
|
||||||
Text(stringResource(R.string.action_copy))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.field_trae_device_id_howto_step2),
|
|
||||||
style = MaterialTheme.typography.bodyMedium
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.field_trae_device_id_howto_step3),
|
|
||||||
style = MaterialTheme.typography.bodyMedium
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.field_trae_device_id_howto_step4),
|
|
||||||
style = MaterialTheme.typography.bodyMedium
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_close)) }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun RegionButton(label: String, selected: Boolean, onSelect: () -> Unit) {
|
private fun RegionButton(label: String, selected: Boolean, onSelect: () -> Unit) {
|
||||||
OutlinedButton(onClick = onSelect) {
|
OutlinedButton(onClick = onSelect) {
|
||||||
|
|||||||
@ -40,8 +40,7 @@ import kotlinx.serialization.json.jsonPrimitive
|
|||||||
class CredentialEditViewModel @Inject constructor(
|
class CredentialEditViewModel @Inject constructor(
|
||||||
private val credentialRepository: CredentialRepository,
|
private val credentialRepository: CredentialRepository,
|
||||||
private val refreshBalanceUseCaseProvider: Provider<RefreshBalanceUseCase>,
|
private val refreshBalanceUseCaseProvider: Provider<RefreshBalanceUseCase>,
|
||||||
private val sub2ApiRepository: Sub2ApiRepository,
|
private val sub2ApiRepository: Sub2ApiRepository
|
||||||
private val savedStateHandle: androidx.lifecycle.SavedStateHandle
|
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
/** Sub2 站点信息探测(baseUrl 输入 debounce 后触发)。 */
|
/** Sub2 站点信息探测(baseUrl 输入 debounce 后触发)。 */
|
||||||
@ -53,45 +52,25 @@ class CredentialEditViewModel @Inject constructor(
|
|||||||
|
|
||||||
private var serviceType: ServiceType? = null
|
private var serviceType: ServiceType? = null
|
||||||
|
|
||||||
/** 当前编辑的账号 ID(多账号服务);null=新建账号/单账号服务。 */
|
fun bind(service: ServiceType) {
|
||||||
private var accountId: String? = null
|
if (serviceType == service) return
|
||||||
|
|
||||||
fun bind(service: ServiceType, accountId: String? = null) {
|
|
||||||
val effectiveAccountId = accountId?.takeIf { it.isNotBlank() }
|
|
||||||
if (serviceType == service && this.accountId == effectiveAccountId) return
|
|
||||||
serviceType = service
|
serviceType = service
|
||||||
this.accountId = effectiveAccountId
|
|
||||||
val config = ServiceConfigProvider.get(service)
|
val config = ServiceConfigProvider.get(service)
|
||||||
val isApiKey = config.method == FetchMethod.REST_API
|
val isApiKey = config.method == FetchMethod.REST_API
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
service = service,
|
service = service,
|
||||||
isApiKeyService = isApiKey,
|
isApiKeyService = isApiKey,
|
||||||
loginUrl = config.loginUrl,
|
loginUrl = config.loginUrl
|
||||||
accountId = effectiveAccountId,
|
|
||||||
accountLabel = null,
|
|
||||||
accountIndex = 0
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
load()
|
load()
|
||||||
refreshAccountInfo()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun load() {
|
private fun load() {
|
||||||
val type = serviceType ?: return
|
val type = serviceType ?: return
|
||||||
// WebView 登录成功回调会把实际写入的账号 ID 通过 savedStateHandle 回传:
|
|
||||||
// 新建账号模式(accountId=null)登录后要切换到“编辑该新账号”模式,避免再次保存时误建第二个账号
|
|
||||||
savedStateHandle.get<String>(KEY_LOGIN_ACCOUNT_ID)?.let { pendingId ->
|
|
||||||
savedStateHandle[KEY_LOGIN_ACCOUNT_ID] = null
|
|
||||||
if (accountId.isNullOrBlank() && !pendingId.isBlank()) {
|
|
||||||
accountId = pendingId
|
|
||||||
_uiState.update { it.copy(accountId = pendingId) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
// 多账号服务 + null accountId = “新建账号”模式:表单必须留空(不能预填默认账号凭据)
|
val existing = credentialRepository.get(type)
|
||||||
val isNewAccount = ServiceType.isMultiAccount(type) && accountId.isNullOrBlank()
|
|
||||||
val existing = if (isNewAccount) null else credentialRepository.get(type, accountId)
|
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
apiKey = when (existing) {
|
apiKey = when (existing) {
|
||||||
@ -123,7 +102,6 @@ class CredentialEditViewModel @Inject constructor(
|
|||||||
triggerApiKey = (existing as? Credential.SessionCredential)?.apiKey.orEmpty(),
|
triggerApiKey = (existing as? Credential.SessionCredential)?.apiKey.orEmpty(),
|
||||||
traeJwt = (existing as? Credential.TraeCredential)?.jwt.orEmpty(),
|
traeJwt = (existing as? Credential.TraeCredential)?.jwt.orEmpty(),
|
||||||
traeRegion = (existing as? Credential.TraeCredential)?.region ?: "CN",
|
traeRegion = (existing as? Credential.TraeCredential)?.region ?: "CN",
|
||||||
traeCheckinDeviceId = (existing as? Credential.TraeCredential)?.checkinDeviceId.orEmpty(),
|
|
||||||
workBuddyAccess = (existing as? Credential.WorkBuddyCredential)?.accessToken.orEmpty(),
|
workBuddyAccess = (existing as? Credential.WorkBuddyCredential)?.accessToken.orEmpty(),
|
||||||
workBuddyRefresh = (existing as? Credential.WorkBuddyCredential)?.refreshToken.orEmpty(),
|
workBuddyRefresh = (existing as? Credential.WorkBuddyCredential)?.refreshToken.orEmpty(),
|
||||||
sub2BaseUrl = (existing as? Credential.Sub2ApiCredential)?.baseUrl.orEmpty(),
|
sub2BaseUrl = (existing as? Credential.Sub2ApiCredential)?.baseUrl.orEmpty(),
|
||||||
@ -212,8 +190,6 @@ class CredentialEditViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
val err = result.exceptionOrNull()
|
val err = result.exceptionOrNull()
|
||||||
val reason: UiText = when (err) {
|
val reason: UiText = when (err) {
|
||||||
is RepositoryError.SessionExpired ->
|
|
||||||
UiText.Resource(R.string.error_credential_session_expired)
|
|
||||||
is RepositoryError.InvalidCredential ->
|
is RepositoryError.InvalidCredential ->
|
||||||
UiText.Resource(R.string.error_credential_rejected)
|
UiText.Resource(R.string.error_credential_rejected)
|
||||||
is RepositoryError.CredentialChanged ->
|
is RepositoryError.CredentialChanged ->
|
||||||
@ -303,10 +279,6 @@ class CredentialEditViewModel @Inject constructor(
|
|||||||
_uiState.update { it.copy(traeRegion = value) }
|
_uiState.update { it.copy(traeRegion = value) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateTraeCheckinDeviceId(value: String) {
|
|
||||||
_uiState.update { it.copy(traeCheckinDeviceId = value) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun saveTraeCredential() {
|
fun saveTraeCredential() {
|
||||||
val type = serviceType ?: return
|
val type = serviceType ?: return
|
||||||
val current = _uiState.value
|
val current = _uiState.value
|
||||||
@ -316,30 +288,17 @@ class CredentialEditViewModel @Inject constructor(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
// 新建账号模式(多账号服务 + null accountId)不继承默认账号的元数据(lastVerifiedAt 等)
|
val existing = credentialRepository.get(type) as? Credential.TraeCredential
|
||||||
val isNewAccount = ServiceType.isMultiAccount(type) && accountId.isNullOrBlank()
|
credentialRepository.save(
|
||||||
val existing = if (isNewAccount) {
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
credentialRepository.get(type, accountId) as? Credential.TraeCredential
|
|
||||||
}
|
|
||||||
val effectiveId = credentialRepository.save(
|
|
||||||
(existing ?: Credential.TraeCredential(service = type, jwt = jwt, region = "CN")).copy(
|
(existing ?: Credential.TraeCredential(service = type, jwt = jwt, region = "CN")).copy(
|
||||||
jwt = jwt,
|
jwt = jwt,
|
||||||
region = if (current.traeRegion.uppercase() == "INTL") "INTL" else "CN",
|
region = if (current.traeRegion.uppercase() == "INTL") "INTL" else "CN",
|
||||||
// 换账号(JWT 变化)时清空本地签到短路标记,避免新账号被误判「今日已签到」
|
|
||||||
lastCheckinDate = if (existing != null && existing.jwt == jwt) existing.lastCheckinDate else null,
|
|
||||||
// 签到设备 ID:留空 = 用内置默认;填写 = 优先(换设备后自查 did 更新)
|
|
||||||
checkinDeviceId = current.traeCheckinDeviceId.trim().ifBlank { null },
|
|
||||||
lastVerifiedAt = existing?.lastVerifiedAt ?: 0L
|
lastVerifiedAt = existing?.lastVerifiedAt ?: 0L
|
||||||
),
|
|
||||||
accountId
|
|
||||||
)
|
)
|
||||||
accountId = effectiveId.ifBlank { accountId }
|
)
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(hasExisting = true, message = UiText.Resource(R.string.msg_credentials_saved))
|
it.copy(hasExisting = true, message = UiText.Resource(R.string.msg_credentials_saved))
|
||||||
}
|
}
|
||||||
refreshAccountInfo()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -363,28 +322,19 @@ class CredentialEditViewModel @Inject constructor(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
// 新建账号模式(多账号服务 + null accountId)不继承默认账号的元数据
|
val existing = credentialRepository.get(type) as? Credential.WorkBuddyCredential
|
||||||
val isNewAccount = ServiceType.isMultiAccount(type) && accountId.isNullOrBlank()
|
credentialRepository.save(
|
||||||
val existing = if (isNewAccount) {
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
credentialRepository.get(type, accountId) as? Credential.WorkBuddyCredential
|
|
||||||
}
|
|
||||||
val effectiveId = credentialRepository.save(
|
|
||||||
(existing ?: Credential.WorkBuddyCredential(service = type, accessToken = access, refreshToken = refresh))
|
(existing ?: Credential.WorkBuddyCredential(service = type, accessToken = access, refreshToken = refresh))
|
||||||
.copy(
|
.copy(
|
||||||
accessToken = access,
|
accessToken = access,
|
||||||
refreshToken = refresh,
|
refreshToken = refresh,
|
||||||
expiresAt = existing?.expiresAt ?: 0L,
|
expiresAt = existing?.expiresAt ?: 0L,
|
||||||
lastVerifiedAt = existing?.lastVerifiedAt ?: 0L
|
lastVerifiedAt = existing?.lastVerifiedAt ?: 0L
|
||||||
),
|
|
||||||
accountId
|
|
||||||
)
|
)
|
||||||
accountId = effectiveId.ifBlank { accountId }
|
)
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(hasExisting = true, message = UiText.Resource(R.string.msg_credentials_saved))
|
it.copy(hasExisting = true, message = UiText.Resource(R.string.msg_credentials_saved))
|
||||||
}
|
}
|
||||||
refreshAccountInfo()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -469,13 +419,7 @@ class CredentialEditViewModel @Inject constructor(
|
|||||||
val email = current.sub2Email.trim()
|
val email = current.sub2Email.trim()
|
||||||
val password = current.sub2Password.trim()
|
val password = current.sub2Password.trim()
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
// 新建账号模式(多账号服务 + null accountId)不继承默认账号的元数据
|
val existing = credentialRepository.get(type) as? Credential.Sub2ApiCredential
|
||||||
val isNewAccount = ServiceType.isMultiAccount(type) && accountId.isNullOrBlank()
|
|
||||||
val existing = if (isNewAccount) {
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
credentialRepository.get(type, accountId) as? Credential.Sub2ApiCredential
|
|
||||||
}
|
|
||||||
// 面板 Token(本次 WebView 获取或既有)也算有效认证
|
// 面板 Token(本次 WebView 获取或既有)也算有效认证
|
||||||
val panelToken = current.sub2PanelToken ?: existing?.authToken
|
val panelToken = current.sub2PanelToken ?: existing?.authToken
|
||||||
// 实例地址必填;认证需 API Key /(邮箱+密码)/ 面板 Token 之一
|
// 实例地址必填;认证需 API Key /(邮箱+密码)/ 面板 Token 之一
|
||||||
@ -483,7 +427,7 @@ class CredentialEditViewModel @Inject constructor(
|
|||||||
_uiState.update { it.copy(message = UiText.Resource(R.string.error_sub2_required)) }
|
_uiState.update { it.copy(message = UiText.Resource(R.string.error_sub2_required)) }
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
val effectiveId = credentialRepository.save(
|
credentialRepository.save(
|
||||||
(existing ?: Credential.Sub2ApiCredential(service = type, baseUrl = base, email = email, password = password))
|
(existing ?: Credential.Sub2ApiCredential(service = type, baseUrl = base, email = email, password = password))
|
||||||
.copy(
|
.copy(
|
||||||
baseUrl = base,
|
baseUrl = base,
|
||||||
@ -493,10 +437,8 @@ class CredentialEditViewModel @Inject constructor(
|
|||||||
authToken = panelToken,
|
authToken = panelToken,
|
||||||
siteIcon = current.sub2SiteIcon ?: existing?.siteIcon,
|
siteIcon = current.sub2SiteIcon ?: existing?.siteIcon,
|
||||||
lastVerifiedAt = existing?.lastVerifiedAt ?: 0L
|
lastVerifiedAt = existing?.lastVerifiedAt ?: 0L
|
||||||
),
|
|
||||||
accountId
|
|
||||||
)
|
)
|
||||||
accountId = effectiveId.ifBlank { accountId }
|
)
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(hasExisting = true, sub2PanelToken = null, message = UiText.Resource(R.string.msg_credentials_saved))
|
it.copy(hasExisting = true, sub2PanelToken = null, message = UiText.Resource(R.string.msg_credentials_saved))
|
||||||
}
|
}
|
||||||
@ -824,19 +766,10 @@ class CredentialEditViewModel @Inject constructor(
|
|||||||
|
|
||||||
fun deleteCredential() {
|
fun deleteCredential() {
|
||||||
val type = serviceType ?: return
|
val type = serviceType ?: return
|
||||||
val targetAccountId = accountId
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
if (ServiceType.isMultiAccount(type) && !targetAccountId.isNullOrBlank()) {
|
|
||||||
credentialRepository.removeAccount(type, targetAccountId)
|
|
||||||
} else {
|
|
||||||
credentialRepository.remove(type)
|
credentialRepository.remove(type)
|
||||||
}
|
|
||||||
accountId = null
|
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
accountId = null,
|
|
||||||
accountLabel = null,
|
|
||||||
accountIndex = 0,
|
|
||||||
message = UiText.Resource(R.string.msg_credential_deleted),
|
message = UiText.Resource(R.string.msg_credential_deleted),
|
||||||
hasExisting = false,
|
hasExisting = false,
|
||||||
apiKey = "",
|
apiKey = "",
|
||||||
@ -849,7 +782,6 @@ class CredentialEditViewModel @Inject constructor(
|
|||||||
triggerApiKey = "",
|
triggerApiKey = "",
|
||||||
traeJwt = "",
|
traeJwt = "",
|
||||||
traeRegion = "CN",
|
traeRegion = "CN",
|
||||||
traeCheckinDeviceId = "",
|
|
||||||
workBuddyAccess = "",
|
workBuddyAccess = "",
|
||||||
workBuddyRefresh = "",
|
workBuddyRefresh = "",
|
||||||
sub2BaseUrl = "",
|
sub2BaseUrl = "",
|
||||||
@ -861,40 +793,10 @@ class CredentialEditViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 多账号服务:按当前账号 ID 刷新展示用的账号名与序号。 */
|
|
||||||
private fun refreshAccountInfo() {
|
|
||||||
val type = serviceType ?: return
|
|
||||||
val aid = accountId ?: return
|
|
||||||
viewModelScope.launch {
|
|
||||||
val accounts = credentialRepository.accountsFor(type)
|
|
||||||
val index = accounts.indexOfFirst { it.id == aid }
|
|
||||||
if (index < 0) return@launch
|
|
||||||
val account = accounts[index]
|
|
||||||
_uiState.update {
|
|
||||||
it.copy(
|
|
||||||
accountId = aid,
|
|
||||||
accountLabel = account.label?.takeIf { l -> l.isNotBlank() },
|
|
||||||
accountIndex = index + 1
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clearMessage() {
|
fun clearMessage() {
|
||||||
_uiState.update { it.copy(message = null) }
|
_uiState.update { it.copy(message = null) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 从 WebView 登录页返回后重载表单与账号信息(当前凭据可能已被登录流程改写)。 */
|
|
||||||
fun reload() {
|
|
||||||
load()
|
|
||||||
refreshAccountInfo()
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
/** WebView 登录页通过 previousBackStackEntry.savedStateHandle 回传实际写入的账号 ID。 */
|
|
||||||
const val KEY_LOGIN_ACCOUNT_ID = "login_account_id"
|
|
||||||
}
|
|
||||||
|
|
||||||
fun importFromClipboard(context: Context) {
|
fun importFromClipboard(context: Context) {
|
||||||
val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
|
val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
|
||||||
val text = cm.primaryClip?.getItemAt(0)?.text?.toString()
|
val text = cm.primaryClip?.getItemAt(0)?.text?.toString()
|
||||||
@ -951,12 +853,6 @@ class CredentialEditViewModel @Inject constructor(
|
|||||||
|
|
||||||
data class CredentialEditUiState(
|
data class CredentialEditUiState(
|
||||||
val service: ServiceType? = null,
|
val service: ServiceType? = null,
|
||||||
/** 当前编辑的账号 ID(多账号服务);null=新建账号/单账号服务。 */
|
|
||||||
val accountId: String? = null,
|
|
||||||
/** 账号自定义名称(可能为 null,UI 按“账号 N”兜底)。 */
|
|
||||||
val accountLabel: String? = null,
|
|
||||||
/** 同服务内账号序号(1 起,兜底名称用)。 */
|
|
||||||
val accountIndex: Int = 0,
|
|
||||||
val isApiKeyService: Boolean = false,
|
val isApiKeyService: Boolean = false,
|
||||||
val loginUrl: String = "",
|
val loginUrl: String = "",
|
||||||
val apiKey: String = "",
|
val apiKey: String = "",
|
||||||
@ -972,8 +868,6 @@ data class CredentialEditUiState(
|
|||||||
/** Trae:Cloud-IDE-JWT + 区域 */
|
/** Trae:Cloud-IDE-JWT + 区域 */
|
||||||
val traeJwt: String = "",
|
val traeJwt: String = "",
|
||||||
val traeRegion: String = "CN",
|
val traeRegion: String = "CN",
|
||||||
/** Trae 签到设备 ID(官方客户端绑定 did;留空用内置默认) */
|
|
||||||
val traeCheckinDeviceId: String = "",
|
|
||||||
/** WorkBuddy:OAuth tokens */
|
/** WorkBuddy:OAuth tokens */
|
||||||
val workBuddyAccess: String = "",
|
val workBuddyAccess: String = "",
|
||||||
val workBuddyRefresh: String = "",
|
val workBuddyRefresh: String = "",
|
||||||
|
|||||||
@ -54,11 +54,6 @@ import com.rainy.token.ui.components.ServiceIcon
|
|||||||
import com.rainy.token.ui.components.StatusChip
|
import com.rainy.token.ui.components.StatusChip
|
||||||
import com.rainy.token.ui.components.StatusLevel
|
import com.rainy.token.ui.components.StatusLevel
|
||||||
import com.rainy.token.ui.components.StatusStyle
|
import com.rainy.token.ui.components.StatusStyle
|
||||||
import android.content.Intent
|
|
||||||
import android.net.Uri
|
|
||||||
import android.provider.Settings
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
|
||||||
import com.rainy.token.R
|
import com.rainy.token.R
|
||||||
import com.rainy.token.ui.components.AppTips
|
import com.rainy.token.ui.components.AppTips
|
||||||
import com.rainy.token.ui.theme.InkMuted
|
import com.rainy.token.ui.theme.InkMuted
|
||||||
@ -78,7 +73,6 @@ import com.rainy.token.util.LocaleManager
|
|||||||
fun SettingsScreen(
|
fun SettingsScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onEditCredential: (ServiceType) -> Unit,
|
onEditCredential: (ServiceType) -> Unit,
|
||||||
onManageAccounts: (ServiceType) -> Unit = {},
|
|
||||||
onOpenTips: () -> Unit = {},
|
onOpenTips: () -> Unit = {},
|
||||||
onOpenDebugLog: () -> Unit = {},
|
onOpenDebugLog: () -> Unit = {},
|
||||||
viewModel: SettingsViewModel = hiltViewModel()
|
viewModel: SettingsViewModel = hiltViewModel()
|
||||||
@ -143,17 +137,10 @@ fun SettingsScreen(
|
|||||||
modifier = Modifier.padding(start = 4.dp, bottom = 4.dp)
|
modifier = Modifier.padding(start = 4.dp, bottom = 4.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
items(uiState.credentialStatuses, key = { it.status.service.name }) { line ->
|
items(uiState.credentialStatuses, key = { it.service.name }) { status ->
|
||||||
CredentialStatusCard(
|
CredentialStatusCard(
|
||||||
status = line.status,
|
status = status,
|
||||||
accountCount = line.accountCount,
|
onClick = { onEditCredential(status.service) }
|
||||||
onClick = {
|
|
||||||
if (ServiceType.isMultiAccount(line.status.service)) {
|
|
||||||
onManageAccounts(line.status.service)
|
|
||||||
} else {
|
|
||||||
onEditCredential(line.status.service)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
@ -165,9 +152,6 @@ fun SettingsScreen(
|
|||||||
item {
|
item {
|
||||||
DebugLogCard(onClick = { onOpenDebugLog() })
|
DebugLogCard(onClick = { onOpenDebugLog() })
|
||||||
}
|
}
|
||||||
item {
|
|
||||||
UpdateCard()
|
|
||||||
}
|
|
||||||
item {
|
item {
|
||||||
Spacer(modifier = Modifier.padding(top = 8.dp))
|
Spacer(modifier = Modifier.padding(top = 8.dp))
|
||||||
AboutCard()
|
AboutCard()
|
||||||
@ -277,11 +261,7 @@ private fun LanguageDialog(onDismiss: () -> Unit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun CredentialStatusCard(
|
private fun CredentialStatusCard(status: CredentialStatus, onClick: () -> Unit) {
|
||||||
status: CredentialStatus,
|
|
||||||
accountCount: Int = 0,
|
|
||||||
onClick: () -> Unit
|
|
||||||
) {
|
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@ -303,11 +283,7 @@ private fun CredentialStatusCard(
|
|||||||
fontWeight = FontWeight.SemiBold
|
fontWeight = FontWeight.SemiBold
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = if (accountCount > 0) {
|
text = stateLabel(status.state),
|
||||||
stringResource(R.string.account_count_line, stateLabel(status.state), accountCount)
|
|
||||||
} else {
|
|
||||||
stateLabel(status.state)
|
|
||||||
},
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = InkMuted,
|
color = InkMuted,
|
||||||
modifier = Modifier.padding(top = 2.dp)
|
modifier = Modifier.padding(top = 2.dp)
|
||||||
@ -318,142 +294,6 @@ private fun CredentialStatusCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun UpdateCard(
|
|
||||||
viewModel: UpdateViewModel = hiltViewModel()
|
|
||||||
) {
|
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
|
||||||
val context = LocalContext.current
|
|
||||||
Card(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
shape = RoundedCornerShape(20.dp),
|
|
||||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.padding(20.dp)) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.title_update),
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.SemiBold
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.padding(top = 8.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(
|
|
||||||
R.string.update_current_version,
|
|
||||||
state.currentVersionName,
|
|
||||||
state.currentVersionCode
|
|
||||||
),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = InkMuted
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.padding(top = 12.dp))
|
|
||||||
when (val phase = state.phase) {
|
|
||||||
is UpdateViewModel.Phase.Idle -> Unit
|
|
||||||
is UpdateViewModel.Phase.Checking -> {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.width(18.dp).height(18.dp),
|
|
||||||
color = StrawberryPink,
|
|
||||||
strokeWidth = 2.dp
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.update_checking),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = InkMuted
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is UpdateViewModel.Phase.Latest -> {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.update_latest),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = InkMuted
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.padding(top = 8.dp))
|
|
||||||
TextButton(onClick = { viewModel.checkForUpdate() }) {
|
|
||||||
Text(stringResource(R.string.update_check))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is UpdateViewModel.Phase.Available -> {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.update_available, phase.info.versionName),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = StrawberryPink,
|
|
||||||
fontWeight = FontWeight.SemiBold
|
|
||||||
)
|
|
||||||
if (phase.info.notes.isNotBlank()) {
|
|
||||||
Spacer(modifier = Modifier.padding(top = 4.dp))
|
|
||||||
Text(
|
|
||||||
text = phase.info.notes,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = InkMuted
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.padding(top = 12.dp))
|
|
||||||
Button(onClick = { viewModel.downloadAndInstall(phase.info) }) {
|
|
||||||
Text(stringResource(R.string.update_download))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is UpdateViewModel.Phase.Downloading -> {
|
|
||||||
LinearProgressIndicator(
|
|
||||||
progress = { phase.progress },
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.padding(top = 4.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(
|
|
||||||
R.string.update_downloading,
|
|
||||||
(phase.progress * 100).toInt()
|
|
||||||
),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = InkMuted
|
|
||||||
)
|
|
||||||
}
|
|
||||||
is UpdateViewModel.Phase.Ready -> {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.update_download_finished),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = InkMuted
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.padding(top = 8.dp))
|
|
||||||
Button(onClick = { viewModel.install(phase.file) }) {
|
|
||||||
Text(stringResource(R.string.update_install))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is UpdateViewModel.Phase.NeedInstallPermission -> {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.update_install_permission_tip),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.error
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.padding(top = 8.dp))
|
|
||||||
Button(onClick = {
|
|
||||||
val intent = Intent(
|
|
||||||
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
|
|
||||||
Uri.parse("package:" + context.packageName)
|
|
||||||
)
|
|
||||||
runCatching { context.startActivity(intent) }
|
|
||||||
}) {
|
|
||||||
Text(stringResource(R.string.update_enable_permission))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is UpdateViewModel.Phase.Error -> {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.update_failed, phase.message),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.error
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.padding(top = 8.dp))
|
|
||||||
TextButton(onClick = { viewModel.checkForUpdate() }) {
|
|
||||||
Text(stringResource(R.string.update_check))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AboutCard() {
|
private fun AboutCard() {
|
||||||
Card(
|
Card(
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.rainy.token.data.repository.CredentialRepository
|
import com.rainy.token.data.repository.CredentialRepository
|
||||||
import com.rainy.token.domain.model.CredentialStatus
|
import com.rainy.token.domain.model.CredentialStatus
|
||||||
import com.rainy.token.domain.service.ServiceType
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@ -33,16 +32,8 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.update { it.copy(loading = true) }
|
_uiState.update { it.copy(loading = true) }
|
||||||
val statuses = credentialRepository.statusForAll()
|
val statuses = credentialRepository.statusForAll()
|
||||||
val lines = statuses.map { status ->
|
|
||||||
val accountCount = if (ServiceType.isMultiAccount(status.service)) {
|
|
||||||
credentialRepository.accountsFor(status.service).size
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
CredentialStatusLine(status = status, accountCount = accountCount)
|
|
||||||
}
|
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(loading = false, credentialStatuses = lines)
|
it.copy(loading = false, credentialStatuses = statuses)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -50,11 +41,5 @@ class SettingsViewModel @Inject constructor(
|
|||||||
|
|
||||||
data class SettingsUiState(
|
data class SettingsUiState(
|
||||||
val loading: Boolean = false,
|
val loading: Boolean = false,
|
||||||
val credentialStatuses: List<CredentialStatusLine> = emptyList()
|
val credentialStatuses: List<CredentialStatus> = emptyList()
|
||||||
)
|
|
||||||
|
|
||||||
/** 凭据状态行:单账号服务 [accountCount]=0;多账号服务为账号数(0 表示尚未配置)。 */
|
|
||||||
data class CredentialStatusLine(
|
|
||||||
val status: CredentialStatus,
|
|
||||||
val accountCount: Int = 0
|
|
||||||
)
|
)
|
||||||
@ -1,98 +0,0 @@
|
|||||||
package com.rainy.token.ui.settings
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import androidx.core.content.FileProvider
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import com.rainy.token.BuildConfig
|
|
||||||
import com.rainy.token.data.repository.UpdateRepository
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import java.io.File
|
|
||||||
import javax.inject.Inject
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.update
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
/** 设置页更新模块:检查更新 → 下载 APK → 拉起系统安装器。 */
|
|
||||||
@HiltViewModel
|
|
||||||
class UpdateViewModel @Inject constructor(
|
|
||||||
private val updateRepository: UpdateRepository,
|
|
||||||
@ApplicationContext private val context: Context
|
|
||||||
) : ViewModel() {
|
|
||||||
|
|
||||||
sealed interface Phase {
|
|
||||||
object Idle : Phase
|
|
||||||
object Checking : Phase
|
|
||||||
object Latest : Phase
|
|
||||||
data class Available(val info: UpdateRepository.UpdateInfo) : Phase
|
|
||||||
data class Downloading(val progress: Float) : Phase
|
|
||||||
data class Ready(val file: File) : Phase
|
|
||||||
object NeedInstallPermission : Phase
|
|
||||||
data class Error(val message: String) : Phase
|
|
||||||
}
|
|
||||||
|
|
||||||
data class UiState(
|
|
||||||
val phase: Phase = Phase.Idle,
|
|
||||||
val currentVersionName: String = BuildConfig.VERSION_NAME,
|
|
||||||
val currentVersionCode: Int = BuildConfig.VERSION_CODE
|
|
||||||
)
|
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(UiState())
|
|
||||||
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
|
|
||||||
|
|
||||||
init {
|
|
||||||
checkForUpdate()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun checkForUpdate() {
|
|
||||||
_uiState.update { it.copy(phase = Phase.Checking) }
|
|
||||||
viewModelScope.launch {
|
|
||||||
updateRepository.checkForUpdate().fold(
|
|
||||||
onSuccess = { info ->
|
|
||||||
_uiState.update {
|
|
||||||
it.copy(phase = if (info == null) Phase.Latest else Phase.Available(info))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onFailure = { e ->
|
|
||||||
_uiState.update { it.copy(phase = Phase.Error(e.message ?: "unknown")) }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun downloadAndInstall(info: UpdateRepository.UpdateInfo) {
|
|
||||||
_uiState.update { it.copy(phase = Phase.Downloading(0f)) }
|
|
||||||
viewModelScope.launch {
|
|
||||||
updateRepository.downloadApk(info) { downloaded, total ->
|
|
||||||
val progress = if (total > 0) downloaded.toFloat() / total else 0f
|
|
||||||
_uiState.update { it.copy(phase = Phase.Downloading(progress)) }
|
|
||||||
}.fold(
|
|
||||||
onSuccess = { file -> install(file) },
|
|
||||||
onFailure = { e ->
|
|
||||||
_uiState.update { it.copy(phase = Phase.Error(e.message ?: "unknown")) }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun install(file: File) {
|
|
||||||
if (context.packageManager.canRequestPackageInstalls()) {
|
|
||||||
_uiState.update { it.copy(phase = Phase.Ready(file)) }
|
|
||||||
val authority = context.packageName + ".fileprovider"
|
|
||||||
val uri = FileProvider.getUriForFile(context, authority, file)
|
|
||||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
|
||||||
setDataAndType(uri, "application/vnd.android.package-archive")
|
|
||||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
||||||
}
|
|
||||||
runCatching { context.startActivity(intent) }.onFailure { e ->
|
|
||||||
_uiState.update { it.copy(phase = Phase.Error(e.message ?: "cannot start installer")) }
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
_uiState.update { it.copy(phase = Phase.NeedInstallPermission) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -3,41 +3,40 @@ package com.rainy.token.ui.theme
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 雨晴风格配色(RainyStyle Palette)— 精致粉调。
|
* 雨晴风格配色(RainyStyle Palette)。
|
||||||
*
|
*
|
||||||
* 视觉基调:低饱和樱粉白背景 + 纯白卡片 + 深玫瑰主色点缀,
|
* 视觉基调:元气樱粉 / 草莓粉主调,搭配纯白卡片与暖灰文本。
|
||||||
* 通过克制用色与留白呈现精致感,保留"雨晴粉"品牌识别度。
|
|
||||||
* 状态色:草绿(正常)、暖橙(注意)、玫红(异常)。
|
* 状态色:草绿(正常)、暖橙(注意)、玫红(异常)。
|
||||||
*
|
*
|
||||||
* 背景使用柔和粉白渐变(#FFF9FB → #FDEFF4),主元素采用深玫瑰
|
* 背景使用樱粉渐变(#FFF0F5 → #FFD1DC),主元素采用草莓粉
|
||||||
* (#C9436C / #B3305B),与白色形成优雅层次。
|
* (#FF85A2 / #FF6B8E),与白色形成层次。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// ─── 品牌主色 ───
|
// ─── 品牌主色 ───
|
||||||
val CherryPinkLight = Color(0xFFFFF9FB) // 粉白(背景起始)
|
val CherryPinkLight = Color(0xFFFFF0F5) // 樱粉浅(背景起始)
|
||||||
val CherryPinkDeep = Color(0xFFFDEFF4) // 浅樱粉(背景结束)
|
val CherryPinkDeep = Color(0xFFFFD1DC) // 樱粉深(背景结束)
|
||||||
val StrawberryPink = Color(0xFFC9436C) // 深玫瑰(主色 / 强调,白底对比度 ≥ 4.5:1)
|
val StrawberryPink = Color(0xFFFF85A2) // 草莓粉(主色 / 强调)
|
||||||
val StrawberryPinkDark = Color(0xFFB3305B)// 深玫瑰按下态(主按钮)
|
val StrawberryPinkDark = Color(0xFFFF6B8E)// 草莓粉深(按下态 / 主按钮)
|
||||||
val StrawberryPinkSoft = Color(0xFFFFC9D8)// 浅玫瑰柔(容器 / 选中背景)
|
val StrawberryPinkSoft = Color(0xFFFFB3C6)// 草莓粉柔(hover / 副按钮)
|
||||||
|
|
||||||
// ─── 中性色 ───
|
// ─── 中性色 ───
|
||||||
val PureWhite = Color(0xFFFFFFFF)
|
val PureWhite = Color(0xFFFFFFFF)
|
||||||
val SnowWhite = Color(0xFFFAF5F7) // 卡片次级背景
|
val SnowWhite = Color(0xFFFAF6F8) // 卡片次级背景
|
||||||
val InkWarm = Color(0xFF3A2B31) // 暖黑(主文本)
|
val InkWarm = Color(0xFF3D2C35) // 暖黑(主文本)
|
||||||
val InkMuted = Color(0xFF7A6870) // 暖灰(次要文本,白底 ≥ 4.5:1)
|
val InkMuted = Color(0xFF7A6B72) // 暖灰(次要文本)
|
||||||
val InkOutline = Color(0xFFE8DCE1) // 暖灰(分隔线 / 描边)
|
val InkOutline = Color(0xFFD9CFD3) // 暖灰(分隔线 / 描边)
|
||||||
|
|
||||||
// ─── 状态色 ───
|
// ─── 状态色 ───
|
||||||
val StatusGreen = Color(0xFF4CAF50) // 正常
|
val StatusGreen = Color(0xFF66BB6A) // 正常
|
||||||
val StatusOrange = Color(0xFFFFA726) // 注意 / 需重登
|
val StatusOrange = Color(0xFFFFA726) // 注意 / 需重登
|
||||||
val StatusRed = Color(0xFFD64A6E) // 错误 / 异常(玫红,与粉调统一)
|
val StatusRed = Color(0xFFE91E63) // 错误 / 异常
|
||||||
val StatusBlue = Color(0xFF64B5F6) // 信息(Stale 缓存)
|
val StatusBlue = Color(0xFF64B5F6) // 信息(Stale 缓存)
|
||||||
|
|
||||||
// ─── 暗色模式 ───
|
// ─── 暗色模式 ───
|
||||||
val DarkBackground = Color(0xFF1A1417) // 暖深背景
|
val DarkBackground = Color(0xFF1F1419) // 暖深背景
|
||||||
val DarkSurface = Color(0xFF251C21) // 卡片
|
val DarkSurface = Color(0xFF2A1F25) // 卡片
|
||||||
val DarkOnSurface = Color(0xFFF2E6EB) // 卡片文本
|
val DarkOnSurface = Color(0xFFEFE0E5) // 卡片文本
|
||||||
val DarkPrimary = Color(0xFFFFA9BF) // 暗色下主色更亮
|
val DarkPrimary = Color(0xFFFFB3C6) // 暗色下主色更亮
|
||||||
val DarkInkWarm = Color(0xFFF2E6EB) // 暗色下暖黑→浅粉白
|
val DarkInkWarm = Color(0xFFEFE0E5) // 暗色下暖黑→浅粉白
|
||||||
val DarkInkMuted = Color(0xFFCBB8C0) // 暗色下暖灰→浅灰粉
|
val DarkInkMuted = Color(0xFFC9B8BE) // 暗色下暖灰→浅灰粉
|
||||||
val DarkInkOutline = Color(0xFF4C3B44) // 暗色下描边
|
val DarkInkOutline = Color(0xFF4D3A42) // 暗色下描边
|
||||||
|
|||||||
@ -13,22 +13,21 @@ import androidx.compose.ui.platform.LocalView
|
|||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 雨晴风格主题(精致粉调)。
|
* 雨晴风格主题。
|
||||||
*
|
*
|
||||||
* - **不使用** dynamicColor(系统主题色),固定使用雨晴玫瑰粉品牌色。
|
* - **不使用** dynamicColor(系统主题色),固定使用雨晴樱粉品牌色。
|
||||||
* 理由:dynamicColor 会让 APP 视觉随系统变化,破坏品牌一致性;
|
* 理由:dynamicColor 会让 APP 视觉随系统变化,破坏品牌一致性;
|
||||||
* 玩家一眼看到 APP icon + 粉色调就知道是 RainyToken 系列。
|
* 玩家一眼看到 APP icon + 粉色调就知道是 RainyToken 系列。
|
||||||
* - 主色:深玫瑰 #C9436C;容器色:浅玫瑰粉;背景:低饱和粉白渐变。
|
* - 暗色模式:暖深背景 + 亮草莓粉强调,跟 Light 模式同色系不同明度。
|
||||||
* - 暗色模式:暖深背景 + 亮玫瑰粉强调,跟 Light 模式同色系不同明度。
|
|
||||||
*/
|
*/
|
||||||
private val LightColors = lightColorScheme(
|
private val LightColors = lightColorScheme(
|
||||||
primary = StrawberryPink,
|
primary = StrawberryPink,
|
||||||
onPrimary = PureWhite,
|
onPrimary = PureWhite,
|
||||||
primaryContainer = StrawberryPinkSoft,
|
primaryContainer = StrawberryPinkSoft,
|
||||||
onPrimaryContainer = Color(0xFF5C112C),
|
onPrimaryContainer = InkWarm,
|
||||||
secondary = Color(0xFFB7698B),
|
secondary = StrawberryPinkDark,
|
||||||
onSecondary = PureWhite,
|
onSecondary = PureWhite,
|
||||||
secondaryContainer = Color(0xFFFFE7EE),
|
secondaryContainer = Color(0xFFFFE4EC),
|
||||||
onSecondaryContainer = InkWarm,
|
onSecondaryContainer = InkWarm,
|
||||||
tertiary = StatusGreen,
|
tertiary = StatusGreen,
|
||||||
onTertiary = PureWhite,
|
onTertiary = PureWhite,
|
||||||
@ -39,32 +38,32 @@ private val LightColors = lightColorScheme(
|
|||||||
surfaceVariant = SnowWhite,
|
surfaceVariant = SnowWhite,
|
||||||
onSurfaceVariant = InkMuted,
|
onSurfaceVariant = InkMuted,
|
||||||
outline = InkOutline,
|
outline = InkOutline,
|
||||||
outlineVariant = Color(0xFFF2E7EB),
|
outlineVariant = Color(0xFFEFE0E5),
|
||||||
error = StatusRed,
|
error = StatusRed,
|
||||||
onError = PureWhite
|
onError = PureWhite
|
||||||
)
|
)
|
||||||
|
|
||||||
private val DarkColors = darkColorScheme(
|
private val DarkColors = darkColorScheme(
|
||||||
primary = DarkPrimary,
|
primary = DarkPrimary,
|
||||||
onPrimary = Color(0xFF4A1025),
|
onPrimary = DarkBackground,
|
||||||
primaryContainer = Color(0xFF6B2C42),
|
primaryContainer = StrawberryPinkDark,
|
||||||
onPrimaryContainer = DarkOnSurface,
|
onPrimaryContainer = PureWhite,
|
||||||
secondary = StrawberryPink,
|
secondary = StrawberryPink,
|
||||||
onSecondary = DarkBackground,
|
onSecondary = DarkBackground,
|
||||||
secondaryContainer = Color(0xFF4A2E3A),
|
secondaryContainer = Color(0xFF4A2E3A),
|
||||||
onSecondaryContainer = DarkOnSurface,
|
onSecondaryContainer = DarkOnSurface,
|
||||||
tertiary = Color(0xFF7BCF9E),
|
tertiary = StatusGreen,
|
||||||
onTertiary = Color(0xFF0D2B1C),
|
onTertiary = DarkBackground,
|
||||||
background = DarkBackground,
|
background = DarkBackground,
|
||||||
onBackground = DarkOnSurface,
|
onBackground = DarkOnSurface,
|
||||||
surface = DarkSurface,
|
surface = DarkSurface,
|
||||||
onSurface = DarkOnSurface,
|
onSurface = DarkOnSurface,
|
||||||
surfaceVariant = Color(0xFF32262C),
|
surfaceVariant = Color(0xFF352329),
|
||||||
onSurfaceVariant = Color(0xFFCBB8C0),
|
onSurfaceVariant = Color(0xFFC9B8BE),
|
||||||
outline = Color(0xFF4C3B44),
|
outline = Color(0xFF4D3A42),
|
||||||
outlineVariant = Color(0xFF3B2C33),
|
outlineVariant = Color(0xFF3A2A30),
|
||||||
error = Color(0xFFFF8194),
|
error = Color(0xFFFF6B8E),
|
||||||
onError = Color(0xFF4A1025)
|
onError = PureWhite
|
||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@ -122,7 +122,7 @@ fun CodexOAuthScreen(
|
|||||||
Text(
|
Text(
|
||||||
uiState.error!!.asString(),
|
uiState.error!!.asString(),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.error
|
color = androidx.compose.ui.graphics.Color(0xFFE91E63)
|
||||||
)
|
)
|
||||||
TextButton(onClick = { viewModel.start(CodexOAuthMode.WEBVIEW) }) {
|
TextButton(onClick = { viewModel.start(CodexOAuthMode.WEBVIEW) }) {
|
||||||
Text(stringResource(R.string.action_retry), color = StrawberryPink)
|
Text(stringResource(R.string.action_retry), color = StrawberryPink)
|
||||||
@ -286,7 +286,7 @@ private fun HeadlessOAuthContent(
|
|||||||
Text(
|
Text(
|
||||||
text = error.asString(),
|
text = error.asString(),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.error,
|
color = androidx.compose.ui.graphics.Color(0xFFE91E63),
|
||||||
modifier = Modifier.padding(top = 4.dp)
|
modifier = Modifier.padding(top = 4.dp)
|
||||||
)
|
)
|
||||||
TextButton(onClick = onRetry) {
|
TextButton(onClick = onRetry) {
|
||||||
|
|||||||
@ -19,8 +19,6 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.SnackbarHost
|
|
||||||
import androidx.compose.material3.SnackbarHostState
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
@ -29,7 +27,6 @@ import androidx.compose.material.icons.filled.ArrowBack
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@ -38,7 +35,6 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.rainy.token.R
|
import com.rainy.token.R
|
||||||
import com.rainy.token.data.debug.DebugLog
|
|
||||||
import com.rainy.token.domain.service.ServiceType
|
import com.rainy.token.domain.service.ServiceType
|
||||||
import com.rainy.token.ui.components.asString
|
import com.rainy.token.ui.components.asString
|
||||||
|
|
||||||
@ -53,34 +49,22 @@ import com.rainy.token.ui.components.asString
|
|||||||
@Composable
|
@Composable
|
||||||
fun WebViewLoginScreen(
|
fun WebViewLoginScreen(
|
||||||
service: ServiceType,
|
service: ServiceType,
|
||||||
accountId: String? = null,
|
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onLoginSucceeded: (ServiceType, String?) -> Unit,
|
onLoginSucceeded: (ServiceType) -> Unit,
|
||||||
/** 用户已保存的面板基址(Sub2API 的 loginUrl 为空,靠它提供登录入口与可信 host)。 */
|
/** 用户已保存的面板基址(Sub2API 的 loginUrl 为空,靠它提供登录入口与可信 host)。 */
|
||||||
configuredBaseUrl: String? = null,
|
configuredBaseUrl: String? = null,
|
||||||
viewModel: WebViewLoginViewModel = hiltViewModel()
|
viewModel: WebViewLoginViewModel = hiltViewModel()
|
||||||
) {
|
) {
|
||||||
// configuredBaseUrl 可选:不传时 ViewModel 自行从凭据仓库读取(Sub2API 自托管实例)
|
// configuredBaseUrl 可选:不传时 ViewModel 自行从凭据仓库读取(Sub2API 自托管实例)
|
||||||
LaunchedEffect(service, configuredBaseUrl, accountId) {
|
LaunchedEffect(service, configuredBaseUrl) {
|
||||||
viewModel.bind(service, configuredBaseUrl, accountId)
|
viewModel.bind(service, configuredBaseUrl)
|
||||||
}
|
}
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
|
||||||
|
|
||||||
// 登录成功自动回调(携带实际写入的账号 ID,供新建账号模式回填)
|
// 登录成功自动回调
|
||||||
LaunchedEffect(uiState.loginSucceeded) {
|
LaunchedEffect(uiState.loginSucceeded) {
|
||||||
if (uiState.loginSucceeded) {
|
if (uiState.loginSucceeded) {
|
||||||
onLoginSucceeded(service, uiState.loginAccountId)
|
onLoginSucceeded(service)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 自动识别服务(WorkBuddy 轮询超时、bindWorkBuddy 网络失败等)没有手动确认弹窗兜底,
|
|
||||||
// 错误通过 Snackbar 呈现,避免用户静默卡在登录页。
|
|
||||||
// key 不含 pendingManualConfirm:错误文本不变时不重复提示(弹窗关闭不回放),且保持互斥。
|
|
||||||
val errorText = uiState.error?.asString()
|
|
||||||
LaunchedEffect(errorText, uiState.loginSucceeded) {
|
|
||||||
if (errorText != null && !uiState.pendingManualConfirm && !uiState.loginSucceeded) {
|
|
||||||
snackbarHostState.showSnackbar(errorText)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -89,7 +73,6 @@ fun WebViewLoginScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(stringResource(R.string.title_webview_login, service.displayName)) },
|
title = { Text(stringResource(R.string.title_webview_login, service.displayName)) },
|
||||||
@ -134,44 +117,24 @@ fun WebViewLoginScreen(
|
|||||||
)
|
)
|
||||||
|
|
||||||
webViewClient = object : WebViewClient() {
|
webViewClient = object : WebViewClient() {
|
||||||
override fun shouldOverrideUrlLoading(
|
|
||||||
view: WebView?,
|
|
||||||
request: android.webkit.WebResourceRequest?
|
|
||||||
): Boolean {
|
|
||||||
val url = request?.url?.toString() ?: return false
|
|
||||||
return handleOverrideUrl(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Deprecated("deprecated in API 24, kept for older devices")
|
|
||||||
override fun shouldOverrideUrlLoading(
|
override fun shouldOverrideUrlLoading(
|
||||||
view: WebView?,
|
view: WebView?,
|
||||||
url: String?
|
url: String?
|
||||||
): Boolean {
|
): Boolean {
|
||||||
url ?: return false
|
url ?: return false
|
||||||
return handleOverrideUrl(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun handleOverrideUrl(url: String): Boolean {
|
|
||||||
// Trae OAuth 回调:拦截 127.0.0.1:18080/authorize,提取 userJwt
|
// Trae OAuth 回调:拦截 127.0.0.1:18080/authorize,提取 userJwt
|
||||||
if (url.startsWith("http://127.0.0.1:18080/authorize")) {
|
if (url.startsWith("http://127.0.0.1:18080/authorize")) {
|
||||||
DebugLog.i("TraeLogin", "回调命中: ${url.toSafeLogUrl()}")
|
|
||||||
viewModel.onTraeCallback(url)
|
viewModel.onTraeCallback(url)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPageStarted(view: WebView?, url: String?, favicon: android.graphics.Bitmap?) {
|
|
||||||
DebugLog.i("TraeLogin", "pageStarted: ${url?.toSafeLogUrl()}")
|
|
||||||
super.onPageStarted(view, url, favicon)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPageFinished(view: WebView?, url: String?) {
|
override fun onPageFinished(view: WebView?, url: String?) {
|
||||||
url ?: return
|
url ?: return
|
||||||
// 登录成功后面板会写入 localStorage.auth_token,抓到即自动保存
|
// 登录成功后面板会写入 localStorage.auth_token,抓到即自动保存
|
||||||
view?.evaluateJavascript(viewModel.probeScript, null)
|
view?.evaluateJavascript(viewModel.probeScript, null)
|
||||||
viewModel.onPageFinished(url)
|
viewModel.onPageFinished(url)
|
||||||
DebugLog.i("TraeLogin", "pageFinished: ${url.toSafeLogUrl()}")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
webChromeClient = WebChromeClient()
|
webChromeClient = WebChromeClient()
|
||||||
@ -238,19 +201,3 @@ private fun parseProbeJson(json: String): Map<String, String> = runCatching {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.getOrDefault(emptyMap())
|
}.getOrDefault(emptyMap())
|
||||||
|
|
||||||
/**
|
|
||||||
* 脱敏日志 URL:只保留 scheme://host/path,去掉 query/fragment(OAuth 回调的
|
|
||||||
* userJwt/userInfo 等敏感参数不落调试日志,与 ViewModel 侧“只记参数名”口径一致)。
|
|
||||||
* 解析失败时回退为截断原文。
|
|
||||||
*/
|
|
||||||
private fun String.toSafeLogUrl(maxLen: Int = 200): String {
|
|
||||||
val parsed = runCatching { android.net.Uri.parse(this) }.getOrNull()
|
|
||||||
val safe = parsed?.let { u ->
|
|
||||||
val host = u.host ?: ""
|
|
||||||
buildString {
|
|
||||||
append(u.scheme ?: "").append("://").append(host).append(u.path.orEmpty())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return if (safe.isNullOrBlank()) take(maxLen) else safe
|
|
||||||
}
|
|
||||||
@ -20,7 +20,6 @@ import kotlinx.serialization.json.jsonObject
|
|||||||
import kotlinx.serialization.json.jsonPrimitive
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
import kotlinx.serialization.json.longOrNull
|
import kotlinx.serialization.json.longOrNull
|
||||||
import com.rainy.token.R
|
import com.rainy.token.R
|
||||||
import com.rainy.token.data.debug.DebugLog
|
|
||||||
import com.rainy.token.data.repository.WebViewSessionSaver
|
import com.rainy.token.data.repository.WebViewSessionSaver
|
||||||
import com.rainy.token.data.repository.CredentialRepository
|
import com.rainy.token.data.repository.CredentialRepository
|
||||||
import com.rainy.token.domain.model.Credential
|
import com.rainy.token.domain.model.Credential
|
||||||
@ -58,13 +57,9 @@ class WebViewLoginViewModel @Inject constructor(
|
|||||||
/** WorkBuddy 轮询协程,bind 新服务时取消。 */
|
/** WorkBuddy 轮询协程,bind 新服务时取消。 */
|
||||||
private var workBuddyPollingJob: Job? = null
|
private var workBuddyPollingJob: Job? = null
|
||||||
|
|
||||||
/** 目标账号(多账号服务);null = 新建账号。 */
|
fun bind(service: ServiceType, configuredBaseUrl: String? = null) {
|
||||||
private var accountId: String? = null
|
|
||||||
|
|
||||||
fun bind(service: ServiceType, configuredBaseUrl: String? = null, accountId: String? = null) {
|
|
||||||
workBuddyPollingJob?.cancel()
|
workBuddyPollingJob?.cancel()
|
||||||
workBuddyPollingJob = null
|
workBuddyPollingJob = null
|
||||||
this.accountId = accountId?.takeIf { it.isNotBlank() }
|
|
||||||
|
|
||||||
val config = ServiceConfigProvider.get(service)
|
val config = ServiceConfigProvider.get(service)
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
@ -80,59 +75,16 @@ class WebViewLoginViewModel @Inject constructor(
|
|||||||
when (service) {
|
when (service) {
|
||||||
ServiceType.TRAE -> bindTrae()
|
ServiceType.TRAE -> bindTrae()
|
||||||
ServiceType.WORKBUDDY -> bindWorkBuddy()
|
ServiceType.WORKBUDDY -> bindWorkBuddy()
|
||||||
ServiceType.OPENCODE_GO -> bindOpenCodeGo()
|
|
||||||
else -> bindSub2Api(service, config, configuredBaseUrl)
|
else -> bindSub2Api(service, config, configuredBaseUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── OpenCode Go ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* OpenCode Go 应用内登录:WebView 打开 opencode.ai/auth(GitHub / Google OAuth)。
|
|
||||||
* 登录后用户会落到 dashboard(`https://opencode.ai/workspace/{workspaceId}/go`),
|
|
||||||
* 此时从 URL 提取 workspaceId、从 CookieManager 提取 `auth` cookie,
|
|
||||||
* 两者即 [OpenCodeGoRepository.fetchBalance] 所需的全部凭据,自动落库免手抄。
|
|
||||||
*/
|
|
||||||
private fun bindOpenCodeGo() {
|
|
||||||
_uiState.update {
|
|
||||||
it.copy(loginUrl = "https://opencode.ai/auth", trustedHosts = setOf("opencode.ai"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** dashboard URL → 提取 workspaceId 并连同 auth cookie 保存;成功返回 true。 */
|
|
||||||
private fun trySaveOpenCodeSession(url: String): Boolean {
|
|
||||||
val workspaceId = OPENCODE_WORKSPACE_REGEX.find(url)?.groupValues?.get(1) ?: return false
|
|
||||||
val cookieString = android.webkit.CookieManager.getInstance().getCookie(url) ?: return false
|
|
||||||
val authCookie = cookieString.split(";")
|
|
||||||
.mapNotNull { entry ->
|
|
||||||
val parts = entry.trim().split("=", limit = 2)
|
|
||||||
if (parts.size != 2 || parts[0] != "auth") null else parts[1].trim()
|
|
||||||
}
|
|
||||||
.firstOrNull { it.isNotBlank() } ?: return false
|
|
||||||
viewModelScope.launch {
|
|
||||||
val saved = sessionSaver.saveOpenCodeGoSession(
|
|
||||||
authCookie = authCookie,
|
|
||||||
workspaceId = workspaceId
|
|
||||||
)
|
|
||||||
if (saved != null) {
|
|
||||||
_uiState.update {
|
|
||||||
it.copy(loginSucceeded = true, savedSession = saved, pendingManualConfirm = false, error = null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Trae ──────────────────────────────────────────────────────────────
|
// ─── Trae ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private fun bindTrae() {
|
private fun bindTrae() {
|
||||||
val traceId = randomHex(16)
|
val traceId = randomHex(16)
|
||||||
val machineId = randomHex(32)
|
val machineId = randomHex(32)
|
||||||
// 设备 ID:claim 签到接口按 x-device-id 做设备级每日去重/风控,要求与 OAuth
|
val deviceId = randomDigits(19)
|
||||||
// 会话绑定的设备一致。这里持久化生成每机唯一 ID(首次生成后跨登录复用),
|
|
||||||
// 登录 URL 与签到 claim 用同一个值。不能每次随机(会话绑定不一致会被风控拒),
|
|
||||||
// 也不可用 userJwt.ClientID(固定客户端标识,所有用户相同,必被按设备限流)。
|
|
||||||
val deviceId = traeDeviceId()
|
|
||||||
val callbackUrl = "http://127.0.0.1:18080/authorize"
|
val callbackUrl = "http://127.0.0.1:18080/authorize"
|
||||||
val authUrl = buildString {
|
val authUrl = buildString {
|
||||||
append("https://www.trae.cn/authorization?")
|
append("https://www.trae.cn/authorization?")
|
||||||
@ -148,20 +100,9 @@ class WebViewLoginViewModel @Inject constructor(
|
|||||||
append("x_os_version=1.0&x_app_version=0.1.43&")
|
append("x_os_version=1.0&x_app_version=0.1.43&")
|
||||||
append("x_app_type=stable&plugin_version=2.3.62834")
|
append("x_app_type=stable&plugin_version=2.3.62834")
|
||||||
}
|
}
|
||||||
DebugLog.i("TraeLogin", "bindTrae: 授权URL已生成 len=${authUrl.length} deviceId持久化=${deviceId.take(6)}…")
|
|
||||||
_uiState.update { it.copy(loginUrl = authUrl) }
|
_uiState.update { it.copy(loginUrl = authUrl) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Trae 签到用的持久化设备 ID:首次生成 19 位数字并保存,此后跨登录复用。 */
|
|
||||||
private fun traeDeviceId(): String {
|
|
||||||
val prefs = com.rainy.token.RainyTokenApplication.appContext
|
|
||||||
.getSharedPreferences("trae_login", android.content.Context.MODE_PRIVATE)
|
|
||||||
prefs.getString("device_id", null)?.takeIf { it.isNotBlank() }?.let { return it }
|
|
||||||
val id = randomDigits(19)
|
|
||||||
prefs.edit().putString("device_id", id).apply()
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trae 授权回调拦截。URL 形如:
|
* Trae 授权回调拦截。URL 形如:
|
||||||
* `http://127.0.0.1:18080/authorize?userJwt=<JSON>&userInfo=<JSON>&...`
|
* `http://127.0.0.1:18080/authorize?userJwt=<JSON>&userInfo=<JSON>&...`
|
||||||
@ -174,63 +115,26 @@ class WebViewLoginViewModel @Inject constructor(
|
|||||||
if (service != ServiceType.TRAE) return
|
if (service != ServiceType.TRAE) return
|
||||||
if (_uiState.value.loginSucceeded) return
|
if (_uiState.value.loginSucceeded) return
|
||||||
|
|
||||||
val parsed = url.toHttpUrlOrNull() ?: run {
|
val parsed = url.toHttpUrlOrNull() ?: return
|
||||||
DebugLog.w("TraeLogin", "onTraeCallback: URL 解析失败")
|
val userJwtRaw = parsed.queryParameter("userJwt") ?: return
|
||||||
return
|
|
||||||
}
|
|
||||||
// 只记录参数名(不含 token 值,避免把 userJwt 明文落调试日志)
|
|
||||||
DebugLog.i("TraeLogin", "onTraeCallback: 收到回调,参数=${parsed.queryParameterNames}")
|
|
||||||
// Trae 回调中参数名可能为 userJwt 或 user_jwt,值是 JSON 字符串
|
|
||||||
val userJwtRaw = parsed.queryParameter("userJwt")
|
|
||||||
?: parsed.queryParameter("user_jwt")
|
|
||||||
?: run {
|
|
||||||
DebugLog.w("TraeLogin", "onTraeCallback: 无 userJwt/user_jwt 参数")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val userJwt = try {
|
val userJwt = try {
|
||||||
Json.parseToJsonElement(userJwtRaw).jsonObject
|
Json.parseToJsonElement(userJwtRaw).jsonObject
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
DebugLog.w("TraeLogin", "onTraeCallback: userJwt JSON 解析失败: ${e.message}")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val token = userJwt["Token"]?.jsonPrimitive?.contentOrNull
|
val token = userJwt["Token"]?.jsonPrimitive?.contentOrNull
|
||||||
if (token.isNullOrBlank()) {
|
if (token.isNullOrBlank()) return
|
||||||
DebugLog.w("TraeLogin", "onTraeCallback: 收到回调但 userJwt 无 Token(userJwtKeys=${userJwt.keys})")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
DebugLog.i("TraeLogin", "onTraeCallback: userJwt 解析成功 token非空=${token.isNotBlank()}")
|
|
||||||
// 设备绑定 ID:登录会话绑定的 App 持久化设备 ID(与授权 URL device_id 一致)。
|
|
||||||
// 注意不能用 userJwt.ClientID——那是固定客户端标识(en1oxy7wnw8j9n),所有
|
|
||||||
// 用户相同,作 x-device-id 会被服务端按设备风控拒绝(「参与用户太多」)。
|
|
||||||
val deviceId = traeDeviceId()
|
|
||||||
// 用户 ID:从回调 userInfo 参数提取,签到按 trae-check 口径拼 ${deviceId}-${userId}。
|
|
||||||
val userId = parsed.queryParameter("userInfo")?.let { raw ->
|
|
||||||
runCatching {
|
|
||||||
val u = Json.parseToJsonElement(raw).jsonObject
|
|
||||||
u["userId"]?.jsonPrimitive?.contentOrNull
|
|
||||||
?: u["user_id"]?.jsonPrimitive?.contentOrNull
|
|
||||||
?: u["id"]?.jsonPrimitive?.contentOrNull
|
|
||||||
?: u["accountId"]?.jsonPrimitive?.contentOrNull
|
|
||||||
}.getOrNull()
|
|
||||||
}
|
|
||||||
DebugLog.i("TraeLogin", "onTraeCallback: userId=${if (userId.isNullOrBlank()) "null" else "ok"}")
|
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
// 刚成功登录的凭据视为已验证:lastVerifiedAt 记为当前时间,
|
|
||||||
// 否则首页/列表会显示"未验证/未登录"直到进详情页拉余额后才正常
|
|
||||||
val credential = Credential.TraeCredential(
|
val credential = Credential.TraeCredential(
|
||||||
service = ServiceType.TRAE,
|
service = ServiceType.TRAE,
|
||||||
jwt = token,
|
jwt = token,
|
||||||
region = "CN",
|
region = "CN"
|
||||||
deviceId = deviceId,
|
|
||||||
userId = userId?.takeIf { it.isNotBlank() },
|
|
||||||
lastVerifiedAt = System.currentTimeMillis()
|
|
||||||
)
|
)
|
||||||
val effectiveId = credentialRepository.save(credential, accountId)
|
credentialRepository.save(credential)
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(loginSucceeded = true, pendingManualConfirm = false, error = null, loginAccountId = effectiveId.ifBlank { null })
|
it.copy(loginSucceeded = true, pendingManualConfirm = false, error = null)
|
||||||
}
|
}
|
||||||
DebugLog.i("TraeLogin", "onTraeCallback: 凭据已保存,登录完成")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -247,8 +151,7 @@ class WebViewLoginViewModel @Inject constructor(
|
|||||||
.build()
|
.build()
|
||||||
val (body, ok) = withContext(Dispatchers.IO) {
|
val (body, ok) = withContext(Dispatchers.IO) {
|
||||||
val resp = okHttpClient.newCall(request).execute()
|
val resp = okHttpClient.newCall(request).execute()
|
||||||
val s = resp.body?.string().orEmpty()
|
resp.body?.string().orEmpty() to resp.isSuccessful
|
||||||
s to resp.isSuccessful
|
|
||||||
}
|
}
|
||||||
if (!ok || body.isBlank()) {
|
if (!ok || body.isBlank()) {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
@ -259,8 +162,7 @@ class WebViewLoginViewModel @Inject constructor(
|
|||||||
val json = Json.parseToJsonElement(body).jsonObject
|
val json = Json.parseToJsonElement(body).jsonObject
|
||||||
val data = json["data"]?.jsonObject
|
val data = json["data"]?.jsonObject
|
||||||
val state = data?.get("state")?.jsonPrimitive?.contentOrNull
|
val state = data?.get("state")?.jsonPrimitive?.contentOrNull
|
||||||
val authURL = data?.get("authUrl")?.jsonPrimitive?.contentOrNull
|
val authURL = data?.get("authURL")?.jsonPrimitive?.contentOrNull
|
||||||
?: data?.get("authURL")?.jsonPrimitive?.contentOrNull
|
|
||||||
if (state.isNullOrBlank() || authURL.isNullOrBlank()) {
|
if (state.isNullOrBlank() || authURL.isNullOrBlank()) {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(error = UiText.Resource(R.string.error_network_generic))
|
it.copy(error = UiText.Resource(R.string.error_network_generic))
|
||||||
@ -287,7 +189,8 @@ class WebViewLoginViewModel @Inject constructor(
|
|||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
.url("https://copilot.tencent.com/v2/plugin/auth/token?state=$state")
|
.url("https://copilot.tencent.com/v2/plugin/auth/token?state=$state")
|
||||||
.addHeader("User-Agent", "CLI/2.63.2 CodeBuddy/2.63.2")
|
.addHeader("User-Agent", "CLI/2.63.2 CodeBuddy/2.63.2")
|
||||||
.get()
|
.addHeader("Content-Type", "application/json")
|
||||||
|
.post("{\"state\":\"$state\"}".toRequestBody("application/json".toMediaTypeOrNull()))
|
||||||
.build()
|
.build()
|
||||||
val body = withContext(Dispatchers.IO) {
|
val body = withContext(Dispatchers.IO) {
|
||||||
val resp = okHttpClient.newCall(request).execute()
|
val resp = okHttpClient.newCall(request).execute()
|
||||||
@ -299,36 +202,26 @@ class WebViewLoginViewModel @Inject constructor(
|
|||||||
val code = json["code"]?.jsonPrimitive?.intOrNull ?: -1
|
val code = json["code"]?.jsonPrimitive?.intOrNull ?: -1
|
||||||
if (code == 0) {
|
if (code == 0) {
|
||||||
val data = json["data"]?.jsonObject
|
val data = json["data"]?.jsonObject
|
||||||
val accessToken = data?.get("accessToken")?.jsonPrimitive?.contentOrNull
|
val accessToken = data?.get("access_token")?.jsonPrimitive?.contentOrNull
|
||||||
?: data?.get("access_token")?.jsonPrimitive?.contentOrNull
|
val refreshToken = data?.get("refresh_token")?.jsonPrimitive?.contentOrNull
|
||||||
val refreshToken = data?.get("refreshToken")?.jsonPrimitive?.contentOrNull
|
val expiresIn = data?.get("expires_in")?.jsonPrimitive?.longOrNull ?: 7200L
|
||||||
?: data?.get("refresh_token")?.jsonPrimitive?.contentOrNull
|
|
||||||
val expiresIn = data?.get("expiresIn")?.jsonPrimitive?.longOrNull
|
|
||||||
?: data?.get("expires_in")?.jsonPrimitive?.longOrNull ?: 7200L
|
|
||||||
if (!accessToken.isNullOrBlank()) {
|
if (!accessToken.isNullOrBlank()) {
|
||||||
val expiresAt = System.currentTimeMillis() + expiresIn * 1000
|
val expiresAt = System.currentTimeMillis() + expiresIn * 1000
|
||||||
// 刚成功登录的凭据视为已验证(见 Trae 同款注释)
|
|
||||||
val credential = Credential.WorkBuddyCredential(
|
val credential = Credential.WorkBuddyCredential(
|
||||||
service = ServiceType.WORKBUDDY,
|
service = ServiceType.WORKBUDDY,
|
||||||
accessToken = accessToken,
|
accessToken = accessToken,
|
||||||
refreshToken = refreshToken ?: "",
|
refreshToken = refreshToken ?: "",
|
||||||
expiresAt = expiresAt,
|
expiresAt = expiresAt
|
||||||
lastVerifiedAt = System.currentTimeMillis()
|
|
||||||
)
|
)
|
||||||
val effectiveId = credentialRepository.save(credential, accountId)
|
credentialRepository.save(credential)
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(loginSucceeded = true, pendingManualConfirm = false, error = null)
|
||||||
loginSucceeded = true,
|
|
||||||
pendingManualConfirm = false,
|
|
||||||
error = null,
|
|
||||||
loginAccountId = effectiveId.ifBlank { null }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
// 网络异常时继续轮询,直到超时
|
// 轮询网络错误忽略,下次重试
|
||||||
}
|
}
|
||||||
delay(2000)
|
delay(2000)
|
||||||
}
|
}
|
||||||
@ -366,25 +259,6 @@ class WebViewLoginViewModel @Inject constructor(
|
|||||||
fun onPageFinished(url: String) {
|
fun onPageFinished(url: String) {
|
||||||
val current = _uiState.value
|
val current = _uiState.value
|
||||||
if (current.loginSucceeded) return
|
if (current.loginSucceeded) return
|
||||||
// OpenCode:URL 到 dashboard(/workspace/{id}/go)即自动提取凭据,无 dashboard
|
|
||||||
// (如停在 /auth 登录页)则静默等待,不打扰用户操作。
|
|
||||||
if (_uiState.value.service == ServiceType.OPENCODE_GO) {
|
|
||||||
if (!trySaveOpenCodeSession(url) && !looksLikeLoggedInPage(url)) {
|
|
||||||
_uiState.update { it.copy(pendingManualConfirm = false) }
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Trae 的登录完成由 OAuth 回调(127.0.0.1:18080/authorize?userJwt=...,onTraeCallback)
|
|
||||||
// 自动判定,onPageFinished 的 URL 关键词启发式对 Trae 不适用:授权页 URL 含 /auth,
|
|
||||||
// 会在用户尚在页面操作时就误弹「登录完成?」提示;且 saveSession 的 cookie 抓取
|
|
||||||
// 对 JWT 凭据无效,反而可能覆盖已保存的凭据。Trae 流程静默等待回调即可,
|
|
||||||
// 想放弃登录由 BackHandler 返回。
|
|
||||||
if (_uiState.value.service == ServiceType.TRAE) return
|
|
||||||
// WorkBuddy 的登录完成由 token 轮询(pollWorkBuddyToken)自动判定:扫码页面 URL
|
|
||||||
// 含 /authorize,关键词启发式会误弹「登录完成?」;且确认后走 cookie 抓取会把
|
|
||||||
// 错误的 SessionCredential 保存到 WORKBUDDY 服务。静默等待轮询结果即可,
|
|
||||||
// 轮询成功自动落库并回调返回,超时由 pollWorkBuddyToken 置 error。
|
|
||||||
if (_uiState.value.service == ServiceType.WORKBUDDY) return
|
|
||||||
if (looksLikeLoggedInPage(url)) {
|
if (looksLikeLoggedInPage(url)) {
|
||||||
saveSession(url)
|
saveSession(url)
|
||||||
} else {
|
} else {
|
||||||
@ -393,17 +267,6 @@ class WebViewLoginViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun confirmLoginManually() {
|
fun confirmLoginManually() {
|
||||||
val service = _uiState.value.service ?: return
|
|
||||||
// 防御性守卫:Trae/WorkBuddy/OpenCode 均通过各自的自动识别(回调/轮询/URL 正则)
|
|
||||||
// 完成登录,不应用 cookie 抓取兜底覆盖凭据;未来若有新入口误将 pendingManualConfirm
|
|
||||||
// 置位,这里直接短路,避免把错误的 SessionCredential 写入这些服务。
|
|
||||||
if (service == ServiceType.TRAE ||
|
|
||||||
service == ServiceType.WORKBUDDY ||
|
|
||||||
service == ServiceType.OPENCODE_GO
|
|
||||||
) {
|
|
||||||
dismissManualPrompt()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val url = _uiState.value.loginUrl
|
val url = _uiState.value.loginUrl
|
||||||
saveSession(url)
|
saveSession(url)
|
||||||
}
|
}
|
||||||
@ -426,19 +289,11 @@ class WebViewLoginViewModel @Inject constructor(
|
|||||||
baseUrl = origin.ifBlank { _uiState.value.loginUrl },
|
baseUrl = origin.ifBlank { _uiState.value.loginUrl },
|
||||||
authToken = token,
|
authToken = token,
|
||||||
siteIcon = values[SUB2_ICON_KEY],
|
siteIcon = values[SUB2_ICON_KEY],
|
||||||
email = values[SUB2_EMAIL_KEY],
|
email = values[SUB2_EMAIL_KEY]
|
||||||
accountId = this@WebViewLoginViewModel.accountId
|
|
||||||
)
|
)
|
||||||
if (saved != null) {
|
if (saved != null) {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(loginSucceeded = true, pendingManualConfirm = false, error = null)
|
||||||
loginSucceeded = true,
|
|
||||||
pendingManualConfirm = false,
|
|
||||||
error = null,
|
|
||||||
// 回填实际写入的账号 ID:新建账号流程返回编辑页时保持编辑该账号,
|
|
||||||
// 避免用户再次点保存产生重复账号
|
|
||||||
loginAccountId = saved.accountId.ifBlank { null }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -512,9 +367,6 @@ private const val SUB2_ICON_KEY = "site_icon"
|
|||||||
private const val SUB2_EMAIL_KEY = "email"
|
private const val SUB2_EMAIL_KEY = "email"
|
||||||
private const val SUB2_NONCE_KEY = "nonce"
|
private const val SUB2_NONCE_KEY = "nonce"
|
||||||
|
|
||||||
/** OpenCode dashboard URL 中的 workspaceId:`/workspace/{id}/go`。 */
|
|
||||||
private val OPENCODE_WORKSPACE_REGEX = Regex("""/workspace/([^/?#]+)/go""")
|
|
||||||
|
|
||||||
internal fun buildProbeScript(nonce: String): String = """
|
internal fun buildProbeScript(nonce: String): String = """
|
||||||
(function () {
|
(function () {
|
||||||
try {
|
try {
|
||||||
@ -543,7 +395,5 @@ data class WebViewLoginUiState(
|
|||||||
val loginSucceeded: Boolean = false,
|
val loginSucceeded: Boolean = false,
|
||||||
val savedSession: Credential.SessionCredential? = null,
|
val savedSession: Credential.SessionCredential? = null,
|
||||||
val pendingManualConfirm: Boolean = false,
|
val pendingManualConfirm: Boolean = false,
|
||||||
val error: UiText? = null,
|
val error: UiText? = null
|
||||||
/** Trae/WorkBuddy 登录实际写入的账号 ID(新建账号模式时用于回填信用编辑页)。 */
|
|
||||||
val loginAccountId: String? = null
|
|
||||||
)
|
)
|
||||||
|
|||||||
@ -8,15 +8,11 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.res.ColorStateList
|
import android.content.res.ColorStateList
|
||||||
import android.util.TypedValue
|
import android.util.TypedValue
|
||||||
import android.view.View
|
|
||||||
import android.widget.RemoteViews
|
import android.widget.RemoteViews
|
||||||
import com.rainy.token.MainActivity
|
import com.rainy.token.MainActivity
|
||||||
import com.rainy.token.R
|
import com.rainy.token.R
|
||||||
import com.rainy.token.data.cache.BalanceCache
|
import com.rainy.token.data.cache.BalanceCache
|
||||||
import com.rainy.token.data.cache.balanceCacheDataStore
|
import com.rainy.token.data.cache.balanceCacheDataStore
|
||||||
import com.rainy.token.data.local.SecureStorage
|
|
||||||
import com.rainy.token.data.local.secureStorageDataStore
|
|
||||||
import com.rainy.token.data.repository.CredentialRepository
|
|
||||||
import com.rainy.token.domain.service.ServiceType
|
import com.rainy.token.domain.service.ServiceType
|
||||||
import com.rainy.token.ui.components.normalizeWindowLabel
|
import com.rainy.token.ui.components.normalizeWindowLabel
|
||||||
import com.rainy.token.util.LocaleManager
|
import com.rainy.token.util.LocaleManager
|
||||||
@ -61,15 +57,12 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
|
|||||||
appWidgetManager: AppWidgetManager,
|
appWidgetManager: AppWidgetManager,
|
||||||
appWidgetIds: IntArray
|
appWidgetIds: IntArray
|
||||||
) {
|
) {
|
||||||
|
var selectedHasCachedData = false
|
||||||
|
|
||||||
// 每次渲染都用最新语言偏好重新包装 context:Application 层的 wrap 在进程启动时固定,
|
// 每次渲染都用最新语言偏好重新包装 context:Application 层的 wrap 在进程启动时固定,
|
||||||
// 应用内切换语言后不会自动更新,必须在这里按当前偏好重建,否则 widget 文本停留在旧语言
|
// 应用内切换语言后不会自动更新,必须在这里按当前偏好重建,否则 widget 文本停留在旧语言
|
||||||
val localized = LocaleManager.wrapContext(context)
|
val localized = LocaleManager.wrapContext(context)
|
||||||
|
|
||||||
// 已配置服务只读一次(Keystore 解密有成本);未配置的服务不进轮换、DS 行整体隐藏
|
|
||||||
val configured = configuredServices(context)
|
|
||||||
val dsConfigured = isCredentialPresent(context, ServiceType.DEEPSEEK)
|
|
||||||
val selectedService = currentDisplayService(context, configured)
|
|
||||||
|
|
||||||
for (widgetId in appWidgetIds) {
|
for (widgetId in appWidgetIds) {
|
||||||
val views = RemoteViews(context.packageName, R.layout.widget_opencode_go)
|
val views = RemoteViews(context.packageName, R.layout.widget_opencode_go)
|
||||||
|
|
||||||
@ -111,34 +104,7 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
|
|||||||
try {
|
try {
|
||||||
val dataStore = context.applicationContext.balanceCacheDataStore
|
val dataStore = context.applicationContext.balanceCacheDataStore
|
||||||
val cache = BalanceCache(dataStore)
|
val cache = BalanceCache(dataStore)
|
||||||
views.setViewVisibility(
|
val selectedService = currentDisplayService(context)
|
||||||
R.id.widget_ds_row,
|
|
||||||
if (dsConfigured) View.VISIBLE else View.GONE
|
|
||||||
)
|
|
||||||
if (selectedService == null || configured.isEmpty()) {
|
|
||||||
// 轮换区没有任何已配置服务:
|
|
||||||
// - DeepSeek 已配置 → 标题回退「DeepSeek 余额」,只展示 DS 余额行
|
|
||||||
// - 全都没配置 → 展示引导空态,不展示任何服务数据
|
|
||||||
views.setTextViewText(R.id.widget_switch, "")
|
|
||||||
val emptyLogo = if (dsConfigured) ServiceType.DEEPSEEK else ServiceType.OPENCODE_GO
|
|
||||||
views.setImageViewResource(R.id.widget_logo, widgetLogo(emptyLogo))
|
|
||||||
// DeepSeek logo 是方形,XML 默认 22x12 会压扁,用正方形盒
|
|
||||||
if (emptyLogo == ServiceType.DEEPSEEK) {
|
|
||||||
views.setViewLayoutWidth(R.id.widget_logo, 14f, TypedValue.COMPLEX_UNIT_DIP)
|
|
||||||
views.setViewLayoutHeight(R.id.widget_logo, 14f, TypedValue.COMPLEX_UNIT_DIP)
|
|
||||||
} else {
|
|
||||||
views.setViewLayoutWidth(R.id.widget_logo, 22f, TypedValue.COMPLEX_UNIT_DIP)
|
|
||||||
views.setViewLayoutHeight(R.id.widget_logo, 12f, TypedValue.COMPLEX_UNIT_DIP)
|
|
||||||
}
|
|
||||||
views.setTextViewText(
|
|
||||||
R.id.widget_service_title,
|
|
||||||
localized.getString(
|
|
||||||
if (dsConfigured) R.string.widget_deepseek_balance
|
|
||||||
else R.string.widget_not_configured
|
|
||||||
)
|
|
||||||
)
|
|
||||||
setEmptyState(views, localized)
|
|
||||||
} else {
|
|
||||||
views.setTextViewText(R.id.widget_switch, shortName(selectedService))
|
views.setTextViewText(R.id.widget_switch, shortName(selectedService))
|
||||||
views.setTextViewText(
|
views.setTextViewText(
|
||||||
R.id.widget_service_title,
|
R.id.widget_service_title,
|
||||||
@ -155,6 +121,7 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
|
|||||||
}
|
}
|
||||||
val cached = cache.get(selectedService)
|
val cached = cache.get(selectedService)
|
||||||
if (cached != null) {
|
if (cached != null) {
|
||||||
|
selectedHasCachedData = true
|
||||||
populateServiceRows(views, localized, selectedService, cached.balance)
|
populateServiceRows(views, localized, selectedService, cached.balance)
|
||||||
|
|
||||||
val sdf = SimpleDateFormat("MM-dd HH:mm", Locale.getDefault())
|
val sdf = SimpleDateFormat("MM-dd HH:mm", Locale.getDefault())
|
||||||
@ -171,10 +138,8 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
|
|||||||
localized.getString(R.string.widget_service_quota, selectedService.displayName)
|
localized.getString(R.string.widget_service_quota, selectedService.displayName)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// DeepSeek 余额独立于轮换区填充,且必须放在所有 setEmptyState 之后:
|
// DeepSeek 余额
|
||||||
// setEmptyState 会把 widget_ds_amount 写成 "—",后填的真值才能覆盖它
|
|
||||||
if (dsConfigured) {
|
|
||||||
val dsCached = cache.get(ServiceType.DEEPSEEK)
|
val dsCached = cache.get(ServiceType.DEEPSEEK)
|
||||||
if (dsCached != null && dsCached.balance.amount > 0) {
|
if (dsCached != null && dsCached.balance.amount > 0) {
|
||||||
val dsBal = dsCached.balance
|
val dsBal = dsCached.balance
|
||||||
@ -183,7 +148,6 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
|
|||||||
} else {
|
} else {
|
||||||
views.setTextViewText(R.id.widget_ds_amount, "—")
|
views.setTextViewText(R.id.widget_ds_amount, "—")
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
setEmptyState(views, localized)
|
setEmptyState(views, localized)
|
||||||
}
|
}
|
||||||
@ -192,11 +156,8 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
|
|||||||
appWidgetManager.updateAppWidget(widgetId, views)
|
appWidgetManager.updateAppWidget(widgetId, views)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 自动刷新:有已配置服务且超过冷却时间 → 触发后台刷新。
|
// 自动刷新:缓存为空 或 超过冷却时间 → 触发后台刷新
|
||||||
// 缓存命中与否不参与决策——receiver 现在无条件重渲染,若以「缓存为空」为由
|
if (!selectedHasCachedData || shouldAutoRefresh(context)) {
|
||||||
// 绕过冷却,刷新失败时会形成 onUpdate → 广播 → 失败 → 重渲染 → 再广播的
|
|
||||||
// 无冷却重试环;冷却约束是唯一的节流阀。
|
|
||||||
if ((configured.isNotEmpty() || dsConfigured) && shouldAutoRefresh(context)) {
|
|
||||||
markAutoRefreshTime(context)
|
markAutoRefreshTime(context)
|
||||||
context.sendBroadcast(WidgetRefreshReceiver.createIntent(context))
|
context.sendBroadcast(WidgetRefreshReceiver.createIntent(context))
|
||||||
}
|
}
|
||||||
@ -339,9 +300,9 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
|
|||||||
/** 根据用量百分比动态改进度条颜色 */
|
/** 根据用量百分比动态改进度条颜色 */
|
||||||
private fun setProgressColor(views: RemoteViews, barViewId: Int, pct: Int) {
|
private fun setProgressColor(views: RemoteViews, barViewId: Int, pct: Int) {
|
||||||
val color = when {
|
val color = when {
|
||||||
pct >= 80 -> 0xFFD64A6E.toInt() // 玫红
|
pct >= 80 -> 0xFFE91E63.toInt() // 玫红
|
||||||
pct >= 50 -> 0xFFFFA726.toInt() // 暖橙
|
pct >= 50 -> 0xFFFFA726.toInt() // 暖橙
|
||||||
else -> 0xFFC9436C.toInt() // 深玫瑰(主色)
|
else -> 0xFFFF85A2.toInt() // 草莓粉
|
||||||
}
|
}
|
||||||
views.setColorStateList(barViewId, "setProgressTintList", ColorStateList.valueOf(color))
|
views.setColorStateList(barViewId, "setProgressTintList", ColorStateList.valueOf(color))
|
||||||
}
|
}
|
||||||
@ -384,57 +345,14 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
|
|||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
fun currentDisplayService(context: Context): ServiceType {
|
||||||
* 已配置凭据的服务(本地存在 credential 即视为已配置)。
|
val key = autoRefreshPrefs(context).getString(KEY_DISPLAY_SERVICE, ServiceType.OPENCODE_GO.storageKey)
|
||||||
* 小组件只在这些服务间轮换展示;未配置的不占位。
|
return DISPLAY_SERVICES.firstOrNull { it.storageKey == key } ?: ServiceType.OPENCODE_GO
|
||||||
* 注:只覆盖 [DISPLAY_SERVICES](轮换区),DeepSeek 单独用 [isCredentialPresent] 判断。
|
|
||||||
*/
|
|
||||||
fun configuredServices(context: Context): List<ServiceType> {
|
|
||||||
val repository = widgetCredentialRepository(context)
|
|
||||||
return runCatching {
|
|
||||||
runBlocking {
|
|
||||||
DISPLAY_SERVICES.filter { repository.get(it) != null }
|
|
||||||
}
|
|
||||||
}.getOrDefault(emptyList())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 指定服务本地是否已保存凭据(DeepSeek 余额行显隐用)。 */
|
|
||||||
fun isCredentialPresent(context: Context, service: ServiceType): Boolean {
|
|
||||||
val repository = widgetCredentialRepository(context)
|
|
||||||
return runCatching {
|
|
||||||
runBlocking { repository.get(service) != null }
|
|
||||||
}.getOrDefault(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun widgetCredentialRepository(context: Context): CredentialRepository {
|
|
||||||
val appContext = context.applicationContext
|
|
||||||
return CredentialRepository(
|
|
||||||
secureStorage = SecureStorage(appContext.secureStorageDataStore),
|
|
||||||
balanceCache = BalanceCache(appContext.balanceCacheDataStore)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 当前展示的服务:仅在已配置集合内取用户选择;选择失效(服务被删除)
|
|
||||||
* 时回退第一个已配置服务;一个都没配置返回 null(Widget 展示引导空态)。
|
|
||||||
*/
|
|
||||||
fun currentDisplayService(context: Context): ServiceType? =
|
|
||||||
currentDisplayService(context, configuredServices(context))
|
|
||||||
|
|
||||||
/** 同上,但复用已读好的 [configured],避免一次更新里多次 Keystore 解密。 */
|
|
||||||
fun currentDisplayService(context: Context, configured: List<ServiceType>): ServiceType? {
|
|
||||||
if (configured.isEmpty()) return null
|
|
||||||
val key = autoRefreshPrefs(context).getString(KEY_DISPLAY_SERVICE, null)
|
|
||||||
return configured.firstOrNull { it.storageKey == key } ?: configured.first()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 只在已配置服务间轮换。 */
|
|
||||||
private fun switchDisplayService(context: Context) {
|
private fun switchDisplayService(context: Context) {
|
||||||
val configured = configuredServices(context)
|
val current = currentDisplayService(context)
|
||||||
if (configured.size <= 1) return
|
val next = DISPLAY_SERVICES[(DISPLAY_SERVICES.indexOf(current).coerceAtLeast(0) + 1) % DISPLAY_SERVICES.size]
|
||||||
val current = currentDisplayService(context, configured)
|
|
||||||
val currentIndex = current?.let { configured.indexOf(it) } ?: -1
|
|
||||||
val next = configured[(currentIndex.coerceAtLeast(0) + 1) % configured.size]
|
|
||||||
autoRefreshPrefs(context).edit().putString(KEY_DISPLAY_SERVICE, next.storageKey).apply()
|
autoRefreshPrefs(context).edit().putString(KEY_DISPLAY_SERVICE, next.storageKey).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -23,30 +23,26 @@ class WidgetRefreshReceiver : BroadcastReceiver() {
|
|||||||
if (isRefreshing) return
|
if (isRefreshing) return
|
||||||
isRefreshing = true
|
isRefreshing = true
|
||||||
val pendingResult = goAsync()
|
val pendingResult = goAsync()
|
||||||
OpenCodeGoWidgetProvider.showRefreshing(appContext)
|
|
||||||
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
try {
|
|
||||||
// EntryPoint 获取也放进 try:任何异常都必须落到 finally,
|
|
||||||
// 否则 isRefreshing 卡死 true、pendingResult 泄漏
|
|
||||||
val entryPoint = EntryPointAccessors.fromApplication(
|
val entryPoint = EntryPointAccessors.fromApplication(
|
||||||
appContext, WidgetRefreshEntryPoint::class.java
|
appContext, WidgetRefreshEntryPoint::class.java
|
||||||
)
|
)
|
||||||
val useCase = entryPoint.refreshBalanceUseCase()
|
val useCase = entryPoint.refreshBalanceUseCase()
|
||||||
|
OpenCodeGoWidgetProvider.showRefreshing(appContext)
|
||||||
|
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
try {
|
||||||
val selectedService = OpenCodeGoWidgetProvider.currentDisplayService(appContext)
|
val selectedService = OpenCodeGoWidgetProvider.currentDisplayService(appContext)
|
||||||
val dsConfigured = OpenCodeGoWidgetProvider.isCredentialPresent(appContext, ServiceType.DEEPSEEK)
|
val refreshed = withTimeoutOrNull(25_000L) {
|
||||||
if (selectedService != null || dsConfigured) {
|
val selectedResult = useCase(selectedService)
|
||||||
withTimeoutOrNull(25_000L) {
|
val dsResult = useCase(ServiceType.DEEPSEEK)
|
||||||
// 只刷新「当前展示的服务 + 已配置的 DeepSeek」;未配置的不发请求
|
selectedResult.isSuccess || dsResult.isSuccess
|
||||||
selectedService?.let { useCase(it) }
|
} == true
|
||||||
if (dsConfigured) useCase(ServiceType.DEEPSEEK)
|
if (refreshed) {
|
||||||
}
|
OpenCodeGoWidgetProvider.notifyDataChanged(appContext)
|
||||||
}
|
}
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
// 静默,Widget 保留旧数据
|
// 静默,Widget 保留旧数据
|
||||||
} finally {
|
} finally {
|
||||||
// 无论成败都重渲染:清掉「刷新中」占位,零配置时也能立即恢复空态文案
|
|
||||||
OpenCodeGoWidgetProvider.notifyDataChanged(appContext)
|
|
||||||
isRefreshing = false
|
isRefreshing = false
|
||||||
pendingResult.finish()
|
pendingResult.finish()
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 17 KiB |
@ -3,8 +3,8 @@
|
|||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:shape="rectangle">
|
android:shape="rectangle">
|
||||||
<corners android:radius="16dp" />
|
<corners android:radius="16dp" />
|
||||||
<solid android:color="#1A1417" />
|
<solid android:color="#2D1825" />
|
||||||
<stroke
|
<stroke
|
||||||
android:width="1dp"
|
android:width="1dp"
|
||||||
android:color="#4C3B44" />
|
android:color="#4D2D3D" />
|
||||||
</shape>
|
</shape>
|
||||||
|
Before Width: | Height: | Size: 488 B |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 8.7 KiB |
@ -1,5 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- 雨晴Token 背景 · 精致粉调渐变 -->
|
<!-- 雨晴Token 背景 · 樱粉渐变 -->
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:aapt="http://schemas.android.com/aapt"
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
android:width="108dp"
|
android:width="108dp"
|
||||||
@ -12,9 +12,9 @@
|
|||||||
android:startX="0" android:startY="0"
|
android:startX="0" android:startY="0"
|
||||||
android:endX="108" android:endY="108"
|
android:endX="108" android:endY="108"
|
||||||
android:type="linear">
|
android:type="linear">
|
||||||
<item android:offset="0" android:color="#FFF9FB" />
|
<item android:offset="0" android:color="#FFF0F5" />
|
||||||
<item android:offset="0.5" android:color="#FDEFF4" />
|
<item android:offset="0.5" android:color="#FFD1DC" />
|
||||||
<item android:offset="1" android:color="#FBDCE8" />
|
<item android:offset="1" android:color="#FFB3C1" />
|
||||||
</gradient>
|
</gradient>
|
||||||
</aapt:attr>
|
</aapt:attr>
|
||||||
</path>
|
</path>
|
||||||
|
|||||||
@ -7,14 +7,14 @@
|
|||||||
android:viewportHeight="108">
|
android:viewportHeight="108">
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
配色统一雨晴品牌系(精致粉调):
|
配色统一雨晴品牌系:
|
||||||
· 水滴:深玫瑰 #C9436C(主品牌色)
|
· 水滴:草莓粉 #FF85A2(主品牌色)
|
||||||
· sparkle:浅玫瑰粉 #FFC9D8(浅层次)
|
· sparkle:樱粉 #FFD1DC(浅层次)
|
||||||
· 高光:白色
|
· 高光:白色
|
||||||
· 背景:粉白渐变 #FFF9FB→#FDEFF4→#FBDCE8
|
· 背景:樱粉渐变 #FFF0F5→#FFD1DC→#FFB3C1
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<!-- 大水滴 · 深玫瑰 -->
|
<!-- 大水滴 · 草莓粉 -->
|
||||||
<path
|
<path
|
||||||
android:pathData="
|
android:pathData="
|
||||||
M54,30
|
M54,30
|
||||||
@ -24,7 +24,7 @@
|
|||||||
Q64,76 68,68
|
Q64,76 68,68
|
||||||
Q72,62 64,50
|
Q72,62 64,50
|
||||||
Q54,30 54,30Z"
|
Q54,30 54,30Z"
|
||||||
android:fillColor="#C9436C" />
|
android:fillColor="#FF85A2" />
|
||||||
|
|
||||||
<!-- 水滴高光 · 白色椭圆 -->
|
<!-- 水滴高光 · 白色椭圆 -->
|
||||||
<path
|
<path
|
||||||
@ -32,24 +32,24 @@
|
|||||||
android:fillColor="#FFFFFF"
|
android:fillColor="#FFFFFF"
|
||||||
android:fillAlpha="0.75" />
|
android:fillAlpha="0.75" />
|
||||||
|
|
||||||
<!-- sparkle 小太阳 · 浅玫瑰粉 · 右上角 (72,32) -->
|
<!-- sparkle 小太阳 · 樱粉 · 右上角 (72,32) -->
|
||||||
<path android:pathData="M72,22L72,42"
|
<path android:pathData="M72,22L72,42"
|
||||||
android:strokeColor="#FFC9D8" android:strokeWidth="3"
|
android:strokeColor="#FFD1DC" android:strokeWidth="3"
|
||||||
android:strokeLineCap="round"
|
android:strokeLineCap="round"
|
||||||
android:fillColor="@android:color/transparent" />
|
android:fillColor="@android:color/transparent" />
|
||||||
<path android:pathData="M62,32L82,32"
|
<path android:pathData="M62,32L82,32"
|
||||||
android:strokeColor="#FFC9D8" android:strokeWidth="3"
|
android:strokeColor="#FFD1DC" android:strokeWidth="3"
|
||||||
android:strokeLineCap="round"
|
android:strokeLineCap="round"
|
||||||
android:fillColor="@android:color/transparent" />
|
android:fillColor="@android:color/transparent" />
|
||||||
<path android:pathData="M66,26L78,38"
|
<path android:pathData="M66,26L78,38"
|
||||||
android:strokeColor="#FFC9D8" android:strokeWidth="2"
|
android:strokeColor="#FFD1DC" android:strokeWidth="2"
|
||||||
android:strokeLineCap="round"
|
android:strokeLineCap="round"
|
||||||
android:fillColor="@android:color/transparent" />
|
android:fillColor="@android:color/transparent" />
|
||||||
<path android:pathData="M78,26L66,38"
|
<path android:pathData="M78,26L66,38"
|
||||||
android:strokeColor="#FFC9D8" android:strokeWidth="2"
|
android:strokeColor="#FFD1DC" android:strokeWidth="2"
|
||||||
android:strokeLineCap="round"
|
android:strokeLineCap="round"
|
||||||
android:fillColor="@android:color/transparent" />
|
android:fillColor="@android:color/transparent" />
|
||||||
<path android:pathData="M72,32m-2.5,0a2.5,2.5 0,1,1 5,0a2.5,2.5 0,1,1 -5,0"
|
<path android:pathData="M72,32m-2.5,0a2.5,2.5 0,1,1 5,0a2.5,2.5 0,1,1 -5,0"
|
||||||
android:fillColor="#FFC9D8" />
|
android:fillColor="#FFD1DC" />
|
||||||
|
|
||||||
</vector>
|
</vector>
|
||||||
@ -1,9 +1,9 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!--
|
<!--
|
||||||
RainyToken 几何字标 · 与 OpenCode Go logo 完全同构
|
RainyToken 几何字标 · 与 OpenCode Go logo 完全同构
|
||||||
同样的两个 24x30 S 形方块拼接,只是配色换成雨晴精致粉系:
|
同样的两个 24x30 S 形方块拼接,只是配色换成雨晴粉色系:
|
||||||
- Go 深色 #211E1E → 深玫瑰 #C9436C
|
- Go 深色 #211E1E → 粉色 #C2185B
|
||||||
- Go 浅色 #CFCECD → 浅玫瑰粉 #FFC9D8
|
- Go 浅色 #CFCECD → 浅粉 #FFD1DC
|
||||||
viewBox 和 path 与 ic_opencode_go_logo 完全一致
|
viewBox 和 path 与 ic_opencode_go_logo 完全一致
|
||||||
-->
|
-->
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
@ -11,20 +11,20 @@
|
|||||||
android:height="30dp"
|
android:height="30dp"
|
||||||
android:viewportWidth="54"
|
android:viewportWidth="54"
|
||||||
android:viewportHeight="30">
|
android:viewportHeight="30">
|
||||||
<!-- 左块(深玫瑰) -->
|
<!-- 左块(深粉) -->
|
||||||
<path
|
<path
|
||||||
android:fillColor="#C9436C"
|
android:fillColor="#C2185B"
|
||||||
android:pathData="M24 30H0V0H24V6H6V24H18V18H12V12H24V30Z"/>
|
android:pathData="M24 30H0V0H24V6H6V24H18V18H12V12H24V30Z"/>
|
||||||
<!-- 左块 S 内弯(浅粉) -->
|
<!-- 左块 S 内弯(浅粉) -->
|
||||||
<path
|
<path
|
||||||
android:fillColor="#FFC9D8"
|
android:fillColor="#FFD1DC"
|
||||||
android:pathData="M12 18H18V24H6V12H12V18Z"/>
|
android:pathData="M12 18H18V24H6V12H12V18Z"/>
|
||||||
<!-- 右块 S 内弯(浅粉) -->
|
<!-- 右块 S 内弯(浅粉) -->
|
||||||
<path
|
<path
|
||||||
android:fillColor="#FFC9D8"
|
android:fillColor="#FFD1DC"
|
||||||
android:pathData="M48 12V24H36V12H48Z"/>
|
android:pathData="M48 12V24H36V12H48Z"/>
|
||||||
<!-- 右块(深玫瑰) -->
|
<!-- 右块(深粉) -->
|
||||||
<path
|
<path
|
||||||
android:fillColor="#C9436C"
|
android:fillColor="#C2185B"
|
||||||
android:pathData="M54 30H30V0H54V30ZM36 24H48V6H36V24Z"/>
|
android:pathData="M54 30H30V0H54V30ZM36 24H48V6H36V24Z"/>
|
||||||
</vector>
|
</vector>
|
||||||
@ -1,30 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:aapt="http://schemas.android.com/aapt"
|
|
||||||
android:width="40dp"
|
|
||||||
android:height="40dp"
|
|
||||||
android:viewportWidth="40"
|
|
||||||
android:viewportHeight="40">
|
|
||||||
<!-- 圆角渐变底(源自官网 SVG paint0_linear:#0EC8A9 → #01C886) -->
|
|
||||||
<path android:pathData="M20,0C31.046,0 40,8.954 40,20C40,31.046 31.046,40 20,40C8.954,40 0,31.046 0,20C0,8.954 8.954,0 20,0Z">
|
|
||||||
<aapt:attr name="android:fillColor">
|
|
||||||
<gradient
|
|
||||||
android:startColor="#0EC8A9"
|
|
||||||
android:endColor="#01C886"
|
|
||||||
android:startX="20"
|
|
||||||
android:startY="0"
|
|
||||||
android:endX="20"
|
|
||||||
android:endY="40" />
|
|
||||||
</aapt:attr>
|
|
||||||
</path>
|
|
||||||
<!-- 主体图形(官网 SVG 白色路径,去除高斯模糊滤镜后保留轮廓) -->
|
|
||||||
<path
|
|
||||||
android:pathData="M28.5931,3.12762C28.9853,2.77585 29.0091,2.76226 29.2968,2.74499C29.7628,2.71096 30.1894,2.93488 30.916,3.59634C32.6132,5.13875 34.9769,8.30904 36.4462,11.0168L37.0138,12.0682L37.8156,12.4668C38.5896,12.8579 39.8593,13.6593 40.3898,14.0895C40.6297,14.2878 40.6638,14.2925 40.9133,14.1954C42.0388,13.7572 43.6506,14.3382 45.0727,15.7024C46.3529,16.9294 47.5794,19.026 48.0491,20.7757C48.1177,21.0574 48.2087,21.6628 48.2419,22.1134C48.349,23.6964 47.8414,24.9608 46.8637,25.5331C46.664,25.6484 46.6505,25.6795 46.6561,26.1774C46.7011,28.5481 46.0621,30.9144 44.7785,33.2221C43.3293,35.8133 40.7489,38.4945 37.2566,41.0199C35.3813,42.3846 30.9445,44.9701 28.9382,45.8778C24.1324,48.0414 20.2794,48.8709 16.9329,48.4609C14.9368,48.219 12.6769,47.44 11.34,46.5355C10.9885,46.2925 10.9325,46.2774 10.6637,46.3543C9.2327,46.7651 7.35867,45.9207 5.76659,44.1535C5.13165,43.4471 4.1065,41.7127 3.77404,40.7843C3.0054,38.6118 3.15852,36.6506 4.18236,35.4799C4.44671,35.1785 4.45511,35.1658 4.39735,34.6589C4.30195,33.8289 4.25837,32.6008 4.30192,31.808L4.33666,31.0674L3.22502,29.101C1.5033,26.0375 0.409325,23.4645 -0.012563,21.4992C-0.235219,20.4218 -0.221193,19.9436 0.0522186,19.5899C0.21871,19.3763 0.764928,19.1545 1.42322,19.0329C3.08041,18.742 6.69466,19.0056 10.7155,19.7155L11.1329,19.788L12.0511,18.976C13.5747,17.6264 14.587,16.8696 16.4531,15.706C18.3981,14.4891 20.5929,13.4877 23.0648,12.695L23.8577,12.4414L24.2939,11.2964C25.8547,7.17615 27.4533,4.13844 28.5931,3.12762ZM15.5182,24.243C13.7542,25.2615 12.8718,25.7706 12.2236,26.3413C9.59893,28.6526 8.61811,32.3134 9.73545,35.6274C10.0114,36.4457 10.5201,37.3283 11.5386,39.0923C12.5571,40.8564 13.0671,41.7383 13.6378,42.3864C15.9491,45.0112 19.6103,45.9929 22.9243,44.8755C23.7426,44.5995 24.6249,44.0899 26.3888,43.0714L36.5375,37.2121C38.3016,36.1936 39.184,35.6845 39.8321,35.1137C42.4568,32.8024 43.4373,29.1409 42.3198,25.8269C42.0438,25.0086 41.5342,24.1264 40.5158,22.3624C39.4973,20.5983 38.9882,19.7159 38.4175,19.0678C36.8263,17.2506 35.0162,16.0207 33.2109,15.4111C32.0906,15.0345 31.0169,14.8711 29.9888,14.9141C29.9121,14.9172 29.8379,14.9248 29.7661,14.9368C29.6944,14.9489 29.6241,14.963 29.5553,14.9785L29.5553,14.9785C28.5066,15.2051 27.5082,15.6179 26.5601,16.2168C25.612,16.8157 24.7347,17.5885 23.9283,18.5352C23.1219,19.4819 22.4032,20.5843 21.7722,21.8423C21.1412,23.1003 20.6135,24.5007 20.189,26.0436C19.7645,27.5865 19.4573,29.2539 19.2674,31.0458C19.0775,32.8377 19.0159,34.7359 19.0826,36.7404C19.1493,38.7449 19.3424,40.8284 19.6619,42.991C19.6698,42.9989 19.6777,43.0068 19.6856,43.0147C19.6935,43.0226 19.7014,43.0305 19.7093,43.0384L19.7093,43.0384C20.758,42.8118 21.7564,42.399 22.7045,41.8001C23.6526,41.2012 24.5299,40.4284 25.3363,39.4817C26.1427,38.535 26.8614,37.4326 27.4924,36.1746C28.1234,34.9166 28.6511,33.5162 29.0756,31.9733C29.5001,30.4304 29.8073,28.763 29.9972,26.9711C30.1871,25.1792 30.2487,23.281 30.182,21.2765C30.1153,19.272 29.9222,17.1885 29.6027,15.0259L29.5948,15.018C29.5869,15.0101 29.579,15.0022 29.5711,14.9943C28.5224,15.2209 27.524,15.6337 26.5759,16.2326C25.6278,16.8315 24.7505,17.6043 23.9441,18.551C23.1377,19.4977 22.419,20.6001 21.788,21.8581C21.157,23.1161 20.6293,24.5165 20.2048,26.0594C19.7803,27.6023 19.4731,29.2697 19.2832,31.0616C19.0933,32.8535 19.0317,34.7517 19.0984,36.7562C19.1651,38.7607 19.3582,40.8442 19.6777,43.0068L19.6777,43.0068Z"
|
|
||||||
android:fillColor="#FFFFFF" />
|
|
||||||
<!-- 白色小装饰条(官网 SVG 两个 rect) -->
|
|
||||||
<path
|
|
||||||
android:pathData="M18.5,32.9L16.5,31.3C15.9,32.4 16.2,33.8 17.3,34.4L19.3,36C19.9,34.9 19.6,33.5 18.5,32.9Z"
|
|
||||||
android:fillColor="#FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:pathData="M29.3,26.7L27.3,25.1C26.7,26.2 27,27.6 28.1,28.2L30.1,29.8C30.7,28.7 30.4,27.3 29.3,26.7Z"
|
|
||||||
android:fillColor="#FFFFFF" />
|
|
||||||
</vector>
|
|
||||||
@ -2,8 +2,8 @@
|
|||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:shape="rectangle">
|
android:shape="rectangle">
|
||||||
<corners android:radius="16dp" />
|
<corners android:radius="16dp" />
|
||||||
<solid android:color="#FFF9FB" />
|
<solid android:color="#FFF0F5" />
|
||||||
<stroke
|
<stroke
|
||||||
android:width="1dp"
|
android:width="1dp"
|
||||||
android:color="#F2DCE4" />
|
android:color="#FFD1DC" />
|
||||||
</shape>
|
</shape>
|
||||||
@ -1,5 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- MIUI Widget 深色模式布局 — 文字颜色适配暗色背景 #1A1417 -->
|
<!-- MIUI Widget 深色模式布局 — 文字颜色适配暗色背景 #2D1825 -->
|
||||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:id="@android:id/background"
|
android:id="@android:id/background"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
@ -35,7 +35,7 @@
|
|||||||
android:layout_width="10dp"
|
android:layout_width="10dp"
|
||||||
android:layout_height="24dp"
|
android:layout_height="24dp"
|
||||||
android:text=">"
|
android:text=">"
|
||||||
android:textColor="#CBB8C0"
|
android:textColor="#8A7A82"
|
||||||
android:textSize="14sp"
|
android:textSize="14sp"
|
||||||
android:gravity="center" />
|
android:gravity="center" />
|
||||||
|
|
||||||
@ -44,7 +44,7 @@
|
|||||||
android:layout_width="48dp"
|
android:layout_width="48dp"
|
||||||
android:layout_height="24dp"
|
android:layout_height="24dp"
|
||||||
android:text="OCGO"
|
android:text="OCGO"
|
||||||
android:textColor="#FFA9BF"
|
android:textColor="#FF85A2"
|
||||||
android:textSize="10sp"
|
android:textSize="10sp"
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
android:layout_marginStart="6dp"
|
android:layout_marginStart="6dp"
|
||||||
@ -75,7 +75,7 @@
|
|||||||
android:layout_width="40dp"
|
android:layout_width="40dp"
|
||||||
android:layout_height="24dp"
|
android:layout_height="24dp"
|
||||||
android:text="↻"
|
android:text="↻"
|
||||||
android:textColor="#FFA9BF"
|
android:textColor="#FF85A2"
|
||||||
android:textSize="20sp"
|
android:textSize="20sp"
|
||||||
android:layout_marginStart="6dp"
|
android:layout_marginStart="6dp"
|
||||||
android:gravity="center" />
|
android:gravity="center" />
|
||||||
@ -99,7 +99,7 @@
|
|||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="@string/widget_title_opencode"
|
android:text="@string/widget_title_opencode"
|
||||||
android:textColor="#CBB8C0"
|
android:textColor="#C4A0AB"
|
||||||
android:textSize="11sp" />
|
android:textSize="11sp" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
@ -113,10 +113,10 @@
|
|||||||
<TextView android:id="@+id/row1_label"
|
<TextView android:id="@+id/row1_label"
|
||||||
android:layout_width="44dp" android:layout_height="wrap_content"
|
android:layout_width="44dp" android:layout_height="wrap_content"
|
||||||
android:maxLines="1" android:ellipsize="end"
|
android:maxLines="1" android:ellipsize="end"
|
||||||
android:text="5h" android:textColor="#CBB8C0" android:textSize="11sp" />
|
android:text="5h" android:textColor="#C4A0AB" android:textSize="11sp" />
|
||||||
<TextView android:id="@+id/row1_pct"
|
<TextView android:id="@+id/row1_pct"
|
||||||
android:layout_width="38dp" android:layout_height="wrap_content"
|
android:layout_width="38dp" android:layout_height="wrap_content"
|
||||||
android:text="--" android:textColor="#FFC9D8" android:textSize="12sp"
|
android:text="--" android:textColor="#FFD1DC" android:textSize="12sp"
|
||||||
android:textStyle="bold" android:gravity="end" />
|
android:textStyle="bold" android:gravity="end" />
|
||||||
<ProgressBar android:id="@+id/row1_bar"
|
<ProgressBar android:id="@+id/row1_bar"
|
||||||
style="@android:style/Widget.ProgressBar.Horizontal"
|
style="@android:style/Widget.ProgressBar.Horizontal"
|
||||||
@ -126,7 +126,7 @@
|
|||||||
android:progressTint="@color/widget_bar_5h" />
|
android:progressTint="@color/widget_bar_5h" />
|
||||||
<TextView android:id="@+id/row1_reset"
|
<TextView android:id="@+id/row1_reset"
|
||||||
android:layout_width="48dp" android:layout_height="wrap_content"
|
android:layout_width="48dp" android:layout_height="wrap_content"
|
||||||
android:text="--" android:textColor="#CBB8C0" android:textSize="10sp" />
|
android:text="--" android:textColor="#8A7A82" android:textSize="10sp" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<!-- 本周 -->
|
<!-- 本周 -->
|
||||||
@ -139,10 +139,10 @@
|
|||||||
<TextView android:id="@+id/row2_label"
|
<TextView android:id="@+id/row2_label"
|
||||||
android:layout_width="44dp" android:layout_height="wrap_content"
|
android:layout_width="44dp" android:layout_height="wrap_content"
|
||||||
android:maxLines="1" android:ellipsize="end"
|
android:maxLines="1" android:ellipsize="end"
|
||||||
android:text="@string/window_weekly" android:textColor="#CBB8C0" android:textSize="11sp" />
|
android:text="@string/window_weekly" android:textColor="#C4A0AB" android:textSize="11sp" />
|
||||||
<TextView android:id="@+id/row2_pct"
|
<TextView android:id="@+id/row2_pct"
|
||||||
android:layout_width="38dp" android:layout_height="wrap_content"
|
android:layout_width="38dp" android:layout_height="wrap_content"
|
||||||
android:text="--" android:textColor="#FFC9D8" android:textSize="12sp"
|
android:text="--" android:textColor="#FFD1DC" android:textSize="12sp"
|
||||||
android:textStyle="bold" android:gravity="end" />
|
android:textStyle="bold" android:gravity="end" />
|
||||||
<ProgressBar android:id="@+id/row2_bar"
|
<ProgressBar android:id="@+id/row2_bar"
|
||||||
style="@android:style/Widget.ProgressBar.Horizontal"
|
style="@android:style/Widget.ProgressBar.Horizontal"
|
||||||
@ -152,7 +152,7 @@
|
|||||||
android:progressTint="@color/widget_bar_weekly" />
|
android:progressTint="@color/widget_bar_weekly" />
|
||||||
<TextView android:id="@+id/row2_reset"
|
<TextView android:id="@+id/row2_reset"
|
||||||
android:layout_width="48dp" android:layout_height="wrap_content"
|
android:layout_width="48dp" android:layout_height="wrap_content"
|
||||||
android:text="--" android:textColor="#CBB8C0" android:textSize="10sp" />
|
android:text="--" android:textColor="#8A7A82" android:textSize="10sp" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<!-- 本月 -->
|
<!-- 本月 -->
|
||||||
@ -165,10 +165,10 @@
|
|||||||
<TextView android:id="@+id/row3_label"
|
<TextView android:id="@+id/row3_label"
|
||||||
android:layout_width="44dp" android:layout_height="wrap_content"
|
android:layout_width="44dp" android:layout_height="wrap_content"
|
||||||
android:maxLines="1" android:ellipsize="end"
|
android:maxLines="1" android:ellipsize="end"
|
||||||
android:text="@string/window_monthly" android:textColor="#CBB8C0" android:textSize="11sp" />
|
android:text="@string/window_monthly" android:textColor="#C4A0AB" android:textSize="11sp" />
|
||||||
<TextView android:id="@+id/row3_pct"
|
<TextView android:id="@+id/row3_pct"
|
||||||
android:layout_width="38dp" android:layout_height="wrap_content"
|
android:layout_width="38dp" android:layout_height="wrap_content"
|
||||||
android:text="--" android:textColor="#FFC9D8" android:textSize="12sp"
|
android:text="--" android:textColor="#FFD1DC" android:textSize="12sp"
|
||||||
android:textStyle="bold" android:gravity="end" />
|
android:textStyle="bold" android:gravity="end" />
|
||||||
<ProgressBar android:id="@+id/row3_bar"
|
<ProgressBar android:id="@+id/row3_bar"
|
||||||
style="@android:style/Widget.ProgressBar.Horizontal"
|
style="@android:style/Widget.ProgressBar.Horizontal"
|
||||||
@ -178,12 +178,11 @@
|
|||||||
android:progressTint="@color/widget_bar_monthly" />
|
android:progressTint="@color/widget_bar_monthly" />
|
||||||
<TextView android:id="@+id/row3_reset"
|
<TextView android:id="@+id/row3_reset"
|
||||||
android:layout_width="48dp" android:layout_height="wrap_content"
|
android:layout_width="48dp" android:layout_height="wrap_content"
|
||||||
android:text="--" android:textColor="#CBB8C0" android:textSize="10sp" />
|
android:text="--" android:textColor="#8A7A82" android:textSize="10sp" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<!-- DeepSeek 余额(未配置 DeepSeek 时整行隐藏,id 供 RemoteViews setViewVisibility) -->
|
<!-- DeepSeek 余额 -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/widget_ds_row"
|
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="horizontal"
|
android:orientation="horizontal"
|
||||||
@ -200,14 +199,14 @@
|
|||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="@string/widget_deepseek_balance"
|
android:text="@string/widget_deepseek_balance"
|
||||||
android:textColor="#CBB8C0"
|
android:textColor="#C4A0AB"
|
||||||
android:textSize="11sp" />
|
android:textSize="11sp" />
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/widget_ds_amount"
|
android:id="@+id/widget_ds_amount"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="--"
|
android:text="--"
|
||||||
android:textColor="#FFC9D8"
|
android:textColor="#FFD1DC"
|
||||||
android:textSize="11sp"
|
android:textSize="11sp"
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
android:layout_marginStart="4dp" />
|
android:layout_marginStart="4dp" />
|
||||||
@ -233,7 +232,7 @@
|
|||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="@string/widget_brand"
|
android:text="@string/widget_brand"
|
||||||
android:textColor="#CBB8C0"
|
android:textColor="#8A7A82"
|
||||||
android:textSize="9sp" />
|
android:textSize="9sp" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
|||||||
@ -36,7 +36,7 @@
|
|||||||
android:layout_width="10dp"
|
android:layout_width="10dp"
|
||||||
android:layout_height="24dp"
|
android:layout_height="24dp"
|
||||||
android:text=">"
|
android:text=">"
|
||||||
android:textColor="#7A6870"
|
android:textColor="#C4A0AB"
|
||||||
android:textSize="14sp"
|
android:textSize="14sp"
|
||||||
android:gravity="center" />
|
android:gravity="center" />
|
||||||
|
|
||||||
@ -45,7 +45,7 @@
|
|||||||
android:layout_width="48dp"
|
android:layout_width="48dp"
|
||||||
android:layout_height="24dp"
|
android:layout_height="24dp"
|
||||||
android:text="OCGO"
|
android:text="OCGO"
|
||||||
android:textColor="#C9436C"
|
android:textColor="#FF85A2"
|
||||||
android:textSize="10sp"
|
android:textSize="10sp"
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
android:layout_marginStart="6dp"
|
android:layout_marginStart="6dp"
|
||||||
@ -69,7 +69,7 @@
|
|||||||
android:maxLines="1"
|
android:maxLines="1"
|
||||||
android:ellipsize="end"
|
android:ellipsize="end"
|
||||||
android:text="--"
|
android:text="--"
|
||||||
android:textColor="#7A6870"
|
android:textColor="#C4A0AB"
|
||||||
android:textSize="10sp" />
|
android:textSize="10sp" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
@ -77,7 +77,7 @@
|
|||||||
android:layout_width="40dp"
|
android:layout_width="40dp"
|
||||||
android:layout_height="24dp"
|
android:layout_height="24dp"
|
||||||
android:text="↻"
|
android:text="↻"
|
||||||
android:textColor="#C9436C"
|
android:textColor="#FF85A2"
|
||||||
android:textSize="20sp"
|
android:textSize="20sp"
|
||||||
android:layout_marginStart="6dp"
|
android:layout_marginStart="6dp"
|
||||||
android:gravity="center" />
|
android:gravity="center" />
|
||||||
@ -105,7 +105,7 @@
|
|||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="@string/widget_title_opencode"
|
android:text="@string/widget_title_opencode"
|
||||||
android:textColor="#7A6870"
|
android:textColor="#8A7A82"
|
||||||
android:textSize="11sp" />
|
android:textSize="11sp" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
@ -120,11 +120,11 @@
|
|||||||
<TextView android:id="@+id/row1_label"
|
<TextView android:id="@+id/row1_label"
|
||||||
android:layout_width="44dp" android:layout_height="wrap_content"
|
android:layout_width="44dp" android:layout_height="wrap_content"
|
||||||
android:maxLines="1" android:ellipsize="end"
|
android:maxLines="1" android:ellipsize="end"
|
||||||
android:text="5h" android:textColor="#7A6870" android:textSize="11sp" />
|
android:text="5h" android:textColor="#8A7A82" android:textSize="11sp" />
|
||||||
|
|
||||||
<TextView android:id="@+id/row1_pct"
|
<TextView android:id="@+id/row1_pct"
|
||||||
android:layout_width="38dp" android:layout_height="wrap_content"
|
android:layout_width="38dp" android:layout_height="wrap_content"
|
||||||
android:text="--" android:textColor="#3A2B31" android:textSize="12sp"
|
android:text="--" android:textColor="#3D2C35" android:textSize="12sp"
|
||||||
android:textStyle="bold" android:gravity="end" />
|
android:textStyle="bold" android:gravity="end" />
|
||||||
|
|
||||||
<ProgressBar android:id="@+id/row1_bar"
|
<ProgressBar android:id="@+id/row1_bar"
|
||||||
@ -136,7 +136,7 @@
|
|||||||
|
|
||||||
<TextView android:id="@+id/row1_reset"
|
<TextView android:id="@+id/row1_reset"
|
||||||
android:layout_width="48dp" android:layout_height="wrap_content"
|
android:layout_width="48dp" android:layout_height="wrap_content"
|
||||||
android:text="--" android:textColor="#7A6870" android:textSize="10sp" />
|
android:text="--" android:textColor="#B0A0A7" android:textSize="10sp" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<!-- 本周 -->
|
<!-- 本周 -->
|
||||||
@ -150,11 +150,11 @@
|
|||||||
<TextView android:id="@+id/row2_label"
|
<TextView android:id="@+id/row2_label"
|
||||||
android:layout_width="44dp" android:layout_height="wrap_content"
|
android:layout_width="44dp" android:layout_height="wrap_content"
|
||||||
android:maxLines="1" android:ellipsize="end"
|
android:maxLines="1" android:ellipsize="end"
|
||||||
android:text="@string/window_weekly" android:textColor="#7A6870" android:textSize="11sp" />
|
android:text="@string/window_weekly" android:textColor="#8A7A82" android:textSize="11sp" />
|
||||||
|
|
||||||
<TextView android:id="@+id/row2_pct"
|
<TextView android:id="@+id/row2_pct"
|
||||||
android:layout_width="38dp" android:layout_height="wrap_content"
|
android:layout_width="38dp" android:layout_height="wrap_content"
|
||||||
android:text="--" android:textColor="#3A2B31" android:textSize="12sp"
|
android:text="--" android:textColor="#3D2C35" android:textSize="12sp"
|
||||||
android:textStyle="bold" android:gravity="end" />
|
android:textStyle="bold" android:gravity="end" />
|
||||||
|
|
||||||
<ProgressBar android:id="@+id/row2_bar"
|
<ProgressBar android:id="@+id/row2_bar"
|
||||||
@ -166,7 +166,7 @@
|
|||||||
|
|
||||||
<TextView android:id="@+id/row2_reset"
|
<TextView android:id="@+id/row2_reset"
|
||||||
android:layout_width="48dp" android:layout_height="wrap_content"
|
android:layout_width="48dp" android:layout_height="wrap_content"
|
||||||
android:text="--" android:textColor="#7A6870" android:textSize="10sp" />
|
android:text="--" android:textColor="#B0A0A7" android:textSize="10sp" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<!-- 本月 -->
|
<!-- 本月 -->
|
||||||
@ -180,11 +180,11 @@
|
|||||||
<TextView android:id="@+id/row3_label"
|
<TextView android:id="@+id/row3_label"
|
||||||
android:layout_width="44dp" android:layout_height="wrap_content"
|
android:layout_width="44dp" android:layout_height="wrap_content"
|
||||||
android:maxLines="1" android:ellipsize="end"
|
android:maxLines="1" android:ellipsize="end"
|
||||||
android:text="@string/window_monthly" android:textColor="#7A6870" android:textSize="11sp" />
|
android:text="@string/window_monthly" android:textColor="#8A7A82" android:textSize="11sp" />
|
||||||
|
|
||||||
<TextView android:id="@+id/row3_pct"
|
<TextView android:id="@+id/row3_pct"
|
||||||
android:layout_width="38dp" android:layout_height="wrap_content"
|
android:layout_width="38dp" android:layout_height="wrap_content"
|
||||||
android:text="--" android:textColor="#3A2B31" android:textSize="12sp"
|
android:text="--" android:textColor="#3D2C35" android:textSize="12sp"
|
||||||
android:textStyle="bold" android:gravity="end" />
|
android:textStyle="bold" android:gravity="end" />
|
||||||
|
|
||||||
<ProgressBar android:id="@+id/row3_bar"
|
<ProgressBar android:id="@+id/row3_bar"
|
||||||
@ -196,12 +196,11 @@
|
|||||||
|
|
||||||
<TextView android:id="@+id/row3_reset"
|
<TextView android:id="@+id/row3_reset"
|
||||||
android:layout_width="48dp" android:layout_height="wrap_content"
|
android:layout_width="48dp" android:layout_height="wrap_content"
|
||||||
android:text="--" android:textColor="#7A6870" android:textSize="10sp" />
|
android:text="--" android:textColor="#B0A0A7" android:textSize="10sp" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<!-- DeepSeek 余额(未配置 DeepSeek 时整行隐藏,id 供 RemoteViews setViewVisibility) -->
|
<!-- DeepSeek 余额 -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/widget_ds_row"
|
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="horizontal"
|
android:orientation="horizontal"
|
||||||
@ -221,7 +220,7 @@
|
|||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="@string/widget_deepseek_balance"
|
android:text="@string/widget_deepseek_balance"
|
||||||
android:textColor="#7A6870"
|
android:textColor="#8A7A82"
|
||||||
android:textSize="11sp" />
|
android:textSize="11sp" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
@ -229,7 +228,7 @@
|
|||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="--"
|
android:text="--"
|
||||||
android:textColor="#3A2B31"
|
android:textColor="#3D2C35"
|
||||||
android:textSize="11sp"
|
android:textSize="11sp"
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
android:layout_marginStart="4dp" />
|
android:layout_marginStart="4dp" />
|
||||||
@ -258,7 +257,7 @@
|
|||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="@string/widget_brand"
|
android:text="@string/widget_brand"
|
||||||
android:textColor="#7A6870"
|
android:textColor="#D4C4CC"
|
||||||
android:textSize="9sp" />
|
android:textSize="9sp" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
|||||||
@ -42,7 +42,7 @@
|
|||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="@string/widget_auto_refresh"
|
android:text="@string/widget_auto_refresh"
|
||||||
android:textColor="#7A6870"
|
android:textColor="#C4A0AB"
|
||||||
android:textSize="10sp" />
|
android:textSize="10sp" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
@ -64,7 +64,7 @@
|
|||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="@string/widget_title_opencode"
|
android:text="@string/widget_title_opencode"
|
||||||
android:textColor="#7A6870"
|
android:textColor="#8A7A82"
|
||||||
android:textSize="11sp" />
|
android:textSize="11sp" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
@ -79,11 +79,11 @@
|
|||||||
<TextView
|
<TextView
|
||||||
android:layout_width="44dp" android:layout_height="wrap_content"
|
android:layout_width="44dp" android:layout_height="wrap_content"
|
||||||
android:maxLines="1" android:ellipsize="end"
|
android:maxLines="1" android:ellipsize="end"
|
||||||
android:text="5h" android:textColor="#7A6870" android:textSize="11sp" />
|
android:text="5h" android:textColor="#8A7A82" android:textSize="11sp" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="38dp" android:layout_height="wrap_content"
|
android:layout_width="38dp" android:layout_height="wrap_content"
|
||||||
android:text="42%" android:textColor="#3A2B31" android:textSize="12sp"
|
android:text="42%" android:textColor="#3D2C35" android:textSize="12sp"
|
||||||
android:textStyle="bold" android:gravity="end" />
|
android:textStyle="bold" android:gravity="end" />
|
||||||
|
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
@ -91,7 +91,7 @@
|
|||||||
android:layout_width="0dp" android:layout_height="6dp"
|
android:layout_width="0dp" android:layout_height="6dp"
|
||||||
android:layout_weight="1" android:layout_marginStart="8dp"
|
android:layout_weight="1" android:layout_marginStart="8dp"
|
||||||
android:layout_marginEnd="8dp" android:max="100" android:progress="42"
|
android:layout_marginEnd="8dp" android:max="100" android:progress="42"
|
||||||
android:progressTint="#C9436C" />
|
android:progressTint="#FF85A2" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<!-- 本周 -->
|
<!-- 本周 -->
|
||||||
@ -105,11 +105,11 @@
|
|||||||
<TextView
|
<TextView
|
||||||
android:layout_width="44dp" android:layout_height="wrap_content"
|
android:layout_width="44dp" android:layout_height="wrap_content"
|
||||||
android:maxLines="1" android:ellipsize="end"
|
android:maxLines="1" android:ellipsize="end"
|
||||||
android:text="@string/window_weekly" android:textColor="#7A6870" android:textSize="11sp" />
|
android:text="@string/window_weekly" android:textColor="#8A7A82" android:textSize="11sp" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="38dp" android:layout_height="wrap_content"
|
android:layout_width="38dp" android:layout_height="wrap_content"
|
||||||
android:text="68%" android:textColor="#3A2B31" android:textSize="12sp"
|
android:text="68%" android:textColor="#3D2C35" android:textSize="12sp"
|
||||||
android:textStyle="bold" android:gravity="end" />
|
android:textStyle="bold" android:gravity="end" />
|
||||||
|
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
@ -131,11 +131,11 @@
|
|||||||
<TextView
|
<TextView
|
||||||
android:layout_width="44dp" android:layout_height="wrap_content"
|
android:layout_width="44dp" android:layout_height="wrap_content"
|
||||||
android:maxLines="1" android:ellipsize="end"
|
android:maxLines="1" android:ellipsize="end"
|
||||||
android:text="@string/window_monthly" android:textColor="#7A6870" android:textSize="11sp" />
|
android:text="@string/window_monthly" android:textColor="#8A7A82" android:textSize="11sp" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="38dp" android:layout_height="wrap_content"
|
android:layout_width="38dp" android:layout_height="wrap_content"
|
||||||
android:text="35%" android:textColor="#3A2B31" android:textSize="12sp"
|
android:text="35%" android:textColor="#3D2C35" android:textSize="12sp"
|
||||||
android:textStyle="bold" android:gravity="end" />
|
android:textStyle="bold" android:gravity="end" />
|
||||||
|
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
@ -143,7 +143,7 @@
|
|||||||
android:layout_width="0dp" android:layout_height="6dp"
|
android:layout_width="0dp" android:layout_height="6dp"
|
||||||
android:layout_weight="1" android:layout_marginStart="8dp"
|
android:layout_weight="1" android:layout_marginStart="8dp"
|
||||||
android:layout_marginEnd="8dp" android:max="100" android:progress="35"
|
android:layout_marginEnd="8dp" android:max="100" android:progress="35"
|
||||||
android:progressTint="#C9436C" />
|
android:progressTint="#FF85A2" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<!-- DeepSeek 余额 -->
|
<!-- DeepSeek 余额 -->
|
||||||
@ -165,14 +165,14 @@
|
|||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="@string/widget_deepseek_balance"
|
android:text="@string/widget_deepseek_balance"
|
||||||
android:textColor="#7A6870"
|
android:textColor="#8A7A82"
|
||||||
android:textSize="11sp" />
|
android:textSize="11sp" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="¥18.00"
|
android:text="¥18.00"
|
||||||
android:textColor="#3A2B31"
|
android:textColor="#3D2C35"
|
||||||
android:textSize="11sp"
|
android:textSize="11sp"
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
android:layout_marginStart="4dp" />
|
android:layout_marginStart="4dp" />
|
||||||
|
|||||||
@ -46,18 +46,22 @@
|
|||||||
<string name="nav_empty_subtitle">点击左侧卡片即可</string>
|
<string name="nav_empty_subtitle">点击左侧卡片即可</string>
|
||||||
<string name="title_settings">设置</string>
|
<string name="title_settings">设置</string>
|
||||||
<string name="api_mgmt_title">API 管理</string>
|
<string name="api_mgmt_title">API 管理</string>
|
||||||
|
<string name="api_mgmt_configure">配置</string>
|
||||||
|
<string name="api_mgmt_edit">编辑</string>
|
||||||
<string name="api_mgmt_test">测试</string>
|
<string name="api_mgmt_test">测试</string>
|
||||||
<string name="api_mgmt_add">添加 API Key</string>
|
|
||||||
<string name="api_mgmt_add_picker_title">添加服务</string>
|
|
||||||
<string name="api_mgmt_all_added">所有服务都已添加</string>
|
|
||||||
<string name="api_mgmt_empty_title">还没有添加任何 API</string>
|
|
||||||
<string name="api_mgmt_empty_body">点击右上角「+」添加一个 API Key</string>
|
|
||||||
<string name="api_mgmt_delete_title">删除凭据?</string>
|
<string name="api_mgmt_delete_title">删除凭据?</string>
|
||||||
<string name="api_mgmt_delete_body">将删除 %1$s 已保存的凭据。</string>
|
<string name="api_mgmt_delete_body">将删除 %1$s 已保存的凭据。</string>
|
||||||
<string name="api_mgmt_unconfigured">未配置</string>
|
<string name="api_mgmt_unconfigured">未配置</string>
|
||||||
<string name="api_mgmt_accounts_subtitle">%1$d 个账号 · 点按管理</string>
|
|
||||||
<string name="api_mgmt_last_verified">最后验证:%1$s</string>
|
<string name="api_mgmt_last_verified">最后验证:%1$s</string>
|
||||||
<string name="api_mgmt_cached_balance">缓存余额:%1$s %2$s</string>
|
<string name="api_mgmt_cached_balance">缓存余额:%1$s %2$s</string>
|
||||||
|
<string name="usage_stat_title">用量统计</string>
|
||||||
|
<string name="usage_stat_empty">暂无已配置服务</string>
|
||||||
|
<string name="usage_stat_failed">%1$d 项刷新失败</string>
|
||||||
|
<string name="usage_stat_never_fetched">尚未获取</string>
|
||||||
|
<string name="usage_stat_fetched_at">更新于 %1$s</string>
|
||||||
|
<string name="usage_stat_monthly_spent">本月消费</string>
|
||||||
|
<string name="usage_stat_total_quota">总额度</string>
|
||||||
|
<string name="usage_stat_next_reset">下次重置</string>
|
||||||
<string name="api_mgmt_test_success">✅ %1$s 连接成功:%2$s %3$s</string>
|
<string name="api_mgmt_test_success">✅ %1$s 连接成功:%2$s %3$s</string>
|
||||||
<string name="api_mgmt_test_fail">❌ %1$s 连接失败:%2$s</string>
|
<string name="api_mgmt_test_fail">❌ %1$s 连接失败:%2$s</string>
|
||||||
<string name="api_mgmt_err_invalid">凭据无效</string>
|
<string name="api_mgmt_err_invalid">凭据无效</string>
|
||||||
@ -137,50 +141,10 @@
|
|||||||
<string name="service_desc_workbuddy">OAuth · CodeBuddy 同体系积分</string>
|
<string name="service_desc_workbuddy">OAuth · CodeBuddy 同体系积分</string>
|
||||||
<string name="service_desc_sub2api">自托管面板 · 用户级 sk- Key 余额</string>
|
<string name="service_desc_sub2api">自托管面板 · 用户级 sk- Key 余额</string>
|
||||||
<string name="detail_extras_title">明细</string>
|
<string name="detail_extras_title">明细</string>
|
||||||
<string name="detail_account_info_title">账户信息</string>
|
|
||||||
<string name="detail_resources_title">资源明细</string>
|
|
||||||
<string name="wb_resource_label">资源 #%1$s</string>
|
|
||||||
<string name="wb_plan_title">套餐积分</string>
|
|
||||||
<string name="wb_plan_used_total">已用/总量</string>
|
|
||||||
<string name="wb_plan_in_use">使用中</string>
|
|
||||||
<string name="wb_plan_next_reset">下次权益周期更新</string>
|
|
||||||
<string name="wb_reward_title">平台奖励积分</string>
|
|
||||||
<string name="wb_reward_expire">到期:%1$s</string>
|
|
||||||
<string name="wb_reward_item">奖励包 %1$d</string>
|
|
||||||
<string name="extra_label_remaining">剩余</string>
|
|
||||||
<string name="extra_label_billing_mode">计费方式</string>
|
|
||||||
<string name="extra_value_billing_credits">积分</string>
|
|
||||||
<string name="extra_value_billing_dollar">美元</string>
|
|
||||||
<string name="extra_label_billing_mode_desc">%1$s计费</string>
|
|
||||||
<string name="extra_value_subscription_active">订阅中</string>
|
|
||||||
<string name="extra_value_paid_user">付费用户</string>
|
|
||||||
<string name="extra_value_free_user">免费用户</string>
|
|
||||||
<string name="extra_label_consumed_credits">已消耗</string>
|
|
||||||
<string name="extra_label_consumption_ratio">消耗比例</string>
|
|
||||||
<string name="extra_label_total_credits">总积分</string>
|
|
||||||
<string name="extra_label_pack_count">权益包</string>
|
|
||||||
<string name="extra_label_packages">套餐数</string>
|
|
||||||
<string name="extra_label_frozen">冻结</string>
|
|
||||||
<string name="extra_label_is_credits_billing">积分计费</string>
|
|
||||||
<string name="extra_label_is_dollar_usage_billing">美元计费</string>
|
|
||||||
<string name="extra_label_is_new_user">新用户</string>
|
|
||||||
<string name="extra_label_is_paid_user">付费用户</string>
|
|
||||||
<string name="extra_label_subscription">订阅</string>
|
|
||||||
<string name="extra_value_yes">是</string>
|
|
||||||
<string name="extra_value_no">否</string>
|
|
||||||
<string name="credential_title_trae">Trae 凭据</string>
|
<string name="credential_title_trae">Trae 凭据</string>
|
||||||
<string name="credential_hint_trae">粘贴 Trae 登录态中的 Cloud-IDE-JWT(ey… 开头)。请求头按 Authorization: Cloud-IDE-JWT 发送。</string>
|
<string name="credential_hint_trae">粘贴 Trae 登录态中的 Cloud-IDE-JWT(ey… 开头)。请求头按 Authorization: Cloud-IDE-JWT 发送。</string>
|
||||||
<string name="field_trae_jwt">Cloud-IDE-JWT</string>
|
<string name="field_trae_jwt">Cloud-IDE-JWT</string>
|
||||||
<string name="field_trae_region">区域</string>
|
<string name="field_trae_region">区域</string>
|
||||||
<string name="field_trae_checkin_device_id">签到设备 ID(可选)</string>
|
|
||||||
<string name="field_trae_checkin_device_hint">签到须使用官方客户端绑定的设备 ID。若持续失败,请在电脑客户端日志(AppData\\Roaming\\Trae CN\\logs\\日期目录\\main.log)中搜索 did=,将最新的值粘贴到此处。留空则使用内置默认。</string>
|
|
||||||
<string name="field_trae_device_id_howto">设备 ID 怎么找?(分步指引)</string>
|
|
||||||
<string name="msg_path_copied">路径已复制,粘贴到 Win+R 或文件资源管理器即可</string>
|
|
||||||
<string name="field_trae_device_id_howto_title">查找设备 ID(4 步)</string>
|
|
||||||
<string name="field_trae_device_id_howto_step1">1. 在电脑上按 Win+R,粘贴下面的路径后回车(或复制后粘贴到文件资源管理器地址栏):</string>
|
|
||||||
<string name="field_trae_device_id_howto_step2">2. 打开文件夹名里日期最新的那个文件夹。</string>
|
|
||||||
<string name="field_trae_device_id_howto_step3">3. 用记事本打开 main.log。</string>
|
|
||||||
<string name="field_trae_device_id_howto_step4">4. 按 Ctrl+F 搜索 did=,后面的数字(如 did=895482260673817)就是设备 ID,复制并粘贴到上面的输入框。</string>
|
|
||||||
<string name="option_region_cn">CN</string>
|
<string name="option_region_cn">CN</string>
|
||||||
<string name="option_region_intl">INTL</string>
|
<string name="option_region_intl">INTL</string>
|
||||||
<string name="credential_title_workbuddy">WorkBuddy 凭据</string>
|
<string name="credential_title_workbuddy">WorkBuddy 凭据</string>
|
||||||
@ -233,15 +197,8 @@
|
|||||||
<string name="checkin_need_credential">请先添加凭据后再签到。</string>
|
<string name="checkin_need_credential">请先添加凭据后再签到。</string>
|
||||||
<string name="checkin_trae_intl">INTL 区签到接口暂未支持,请用 CN 区账号</string>
|
<string name="checkin_trae_intl">INTL 区签到接口暂未支持,请用 CN 区账号</string>
|
||||||
<string name="checkin_trae_device_blocked">该设备今日签到次数已达上限</string>
|
<string name="checkin_trae_device_blocked">该设备今日签到次数已达上限</string>
|
||||||
<string name="checkin_trae_retry_later">参与用户太多,请稍后重试</string>
|
|
||||||
<string name="checkin_trae_device_hint">签到须使用官方客户端绑定的设备 ID。若持续失败,请在电脑上打开 Trae 客户端日志目录(AppData\\Roaming\\Trae CN\\logs\\日期目录\\main.log),搜索 did= 获取当前绑定的设备 ID,然后粘贴到「设置 → Trae → 签到设备 ID」。</string>
|
|
||||||
<string name="checkin_trae_failed_code">签到失败(code=%1$d)</string>
|
<string name="checkin_trae_failed_code">签到失败(code=%1$d)</string>
|
||||||
<string name="checkin_wb_success_credits">签到成功 +%1$d 积分</string>
|
<string name="checkin_wb_success_credits">签到成功 +%1$d 积分</string>
|
||||||
<string name="trae_packs_title">积分权益包</string>
|
|
||||||
<string name="trae_pack_unlimited">无限额度</string>
|
|
||||||
<string name="trae_general_credits_title">通用积分</string>
|
|
||||||
<string name="trae_work_credits_title">Work 专属积分</string>
|
|
||||||
<string name="trae_pack_remaining_fmt">剩余 %1$s/%2$s 分 · 已用 %3$s</string>
|
|
||||||
|
|
||||||
<!-- ══════════ Sub2API 面板 WebView 登录(全账号统计) ══════════ -->
|
<!-- ══════════ Sub2API 面板 WebView 登录(全账号统计) ══════════ -->
|
||||||
<string name="sub2_panel_login_hint">在打开的页面中正常登录,App 自动读取登录态;也可继续手动填 API Key</string>
|
<string name="sub2_panel_login_hint">在打开的页面中正常登录,App 自动读取登录态;也可继续手动填 API Key</string>
|
||||||
@ -383,34 +340,6 @@
|
|||||||
<string name="error_credential_not_configured">凭据未配置</string>
|
<string name="error_credential_not_configured">凭据未配置</string>
|
||||||
<string name="error_credential_not_configured_expired">凭据未配置或已过期</string>
|
<string name="error_credential_not_configured_expired">凭据未配置或已过期</string>
|
||||||
<string name="error_credential_invalid_reconfigure">凭据无效,请在设置中重新配置</string>
|
<string name="error_credential_invalid_reconfigure">凭据无效,请在设置中重新配置</string>
|
||||||
<string name="error_credential_session_expired">登录已过期,请重新登录</string>
|
|
||||||
<string name="auto_checkin_switch_label">自动签到</string>
|
|
||||||
<string name="checkin_badge_today">今日已签 ✓</string>
|
|
||||||
<string name="auto_checkin_dialog_title">每日自动签到</string>
|
|
||||||
<string name="auto_checkin_line_gained">%1$s 已签到,积分增加 %2$d</string>
|
|
||||||
<string name="auto_checkin_line_already">%1$s 今日已签到</string>
|
|
||||||
<string name="auto_checkin_line_failed">%1$s 签到失败:%2$s</string>
|
|
||||||
<string name="auto_checkin_line_failed_generic">%1$s 签到失败,请手动签到</string>
|
|
||||||
<string name="account_manager_title">%1$s · 账号管理</string>
|
|
||||||
<string name="account_add">添加账号</string>
|
|
||||||
<string name="account_actions">账号操作</string>
|
|
||||||
<string name="account_default_badge">默认</string>
|
|
||||||
<string name="account_fallback_name">账号 %1$d</string>
|
|
||||||
<string name="account_set_default">设为默认</string>
|
|
||||||
<string name="account_rename">重命名</string>
|
|
||||||
<string name="account_rename_dialog_title">重命名账号</string>
|
|
||||||
<string name="account_name_label">账号名称</string>
|
|
||||||
<string name="account_delete_title">删除账号</string>
|
|
||||||
<string name="account_delete_confirm">确定删除该账号?该账号的凭据与余额缓存将一并清除。</string>
|
|
||||||
<string name="account_empty_hint">还没有账号,点下方按钮添加一个。</string>
|
|
||||||
<string name="account_count_line">%1$s · %2$d 个账号</string>
|
|
||||||
<string name="account_new_credential_title">%1$s · 新账号</string>
|
|
||||||
<string name="service_detail_title_with_account">%1$s · %2$s</string>
|
|
||||||
<string name="account_chip_default">%1$s(默认)</string>
|
|
||||||
<string name="msg_account_default_set">已设为默认账号</string>
|
|
||||||
<string name="msg_account_renamed">已重命名</string>
|
|
||||||
<string name="msg_account_deleted">账号已删除</string>
|
|
||||||
<string name="action_delete_confirmed">删除</string>
|
|
||||||
<string name="error_credential_invalid_relogin">凭据无效,请重新登录</string>
|
<string name="error_credential_invalid_relogin">凭据无效,请重新登录</string>
|
||||||
<string name="error_credential_changed_reload">凭据已变更,正在重新加载</string>
|
<string name="error_credential_changed_reload">凭据已变更,正在重新加载</string>
|
||||||
<string name="error_credential_changed_retry">凭据已变更,请重新操作</string>
|
<string name="error_credential_changed_retry">凭据已变更,请重新操作</string>
|
||||||
@ -462,7 +391,6 @@
|
|||||||
<string name="widget_quota_with_plan">%1$s · %2$s</string>
|
<string name="widget_quota_with_plan">%1$s · %2$s</string>
|
||||||
<string name="widget_updated_at">更新 %1$s</string>
|
<string name="widget_updated_at">更新 %1$s</string>
|
||||||
<string name="widget_no_data">暂无数据</string>
|
<string name="widget_no_data">暂无数据</string>
|
||||||
<string name="widget_not_configured">还没有配置任何服务</string>
|
|
||||||
<string name="widget_refreshing">刷新中…</string>
|
<string name="widget_refreshing">刷新中…</string>
|
||||||
|
|
||||||
<!-- ══════════ 小技巧(AppTips) ══════════ -->
|
<!-- ══════════ 小技巧(AppTips) ══════════ -->
|
||||||
@ -625,7 +553,6 @@
|
|||||||
<string name="action_relogin">重新登录</string>
|
<string name="action_relogin">重新登录</string>
|
||||||
<string name="action_configure_credential">配置凭据</string>
|
<string name="action_configure_credential">配置凭据</string>
|
||||||
<string name="action_recharge">充值</string>
|
<string name="action_recharge">充值</string>
|
||||||
<string name="action_subscribe_recharge">充值 / 订阅</string>
|
|
||||||
<string name="main_card_current_balance">当前余额</string>
|
<string name="main_card_current_balance">当前余额</string>
|
||||||
<string name="main_card_5h_usage">5h 实时用量</string>
|
<string name="main_card_5h_usage">5h 实时用量</string>
|
||||||
<string name="main_card_monthly_balance">月度余额</string>
|
<string name="main_card_monthly_balance">月度余额</string>
|
||||||
@ -771,17 +698,4 @@
|
|||||||
<string name="sub2_panel_delete_confirm_title">删除 API Key</string>
|
<string name="sub2_panel_delete_confirm_title">删除 API Key</string>
|
||||||
<string name="sub2_panel_delete_confirm_body">确定删除「%1$s」吗?删除后无法恢复,正在使用该 Key 的客户端会立即失效。</string>
|
<string name="sub2_panel_delete_confirm_body">确定删除「%1$s」吗?删除后无法恢复,正在使用该 Key 的客户端会立即失效。</string>
|
||||||
<string name="sub2_trend_account_scope">此图为账号整体趋势(无单模型维度)</string>
|
<string name="sub2_trend_account_scope">此图为账号整体趋势(无单模型维度)</string>
|
||||||
<string name="title_update">检查更新</string>
|
|
||||||
<string name="update_current_version">当前版本 %1$s(%2$d)</string>
|
|
||||||
<string name="update_check">检查更新</string>
|
|
||||||
<string name="update_checking">正在检查…</string>
|
|
||||||
<string name="update_latest">已是最新版本</string>
|
|
||||||
<string name="update_available">发现新版本 %1$s</string>
|
|
||||||
<string name="update_download">下载并安装</string>
|
|
||||||
<string name="update_downloading">正在下载 %1$d%%</string>
|
|
||||||
<string name="update_download_finished">下载完成</string>
|
|
||||||
<string name="update_install">安装</string>
|
|
||||||
<string name="update_install_permission_tip">需要开启「安装未知应用」权限</string>
|
|
||||||
<string name="update_enable_permission">去开启</string>
|
|
||||||
<string name="update_failed">更新失败:%1$s</string>
|
|
||||||
</resources>
|
</resources>
|
||||||
@ -46,18 +46,22 @@
|
|||||||
<string name="nav_empty_subtitle">點選左側卡片即可</string>
|
<string name="nav_empty_subtitle">點選左側卡片即可</string>
|
||||||
<string name="title_settings">設定</string>
|
<string name="title_settings">設定</string>
|
||||||
<string name="api_mgmt_title">API 管理</string>
|
<string name="api_mgmt_title">API 管理</string>
|
||||||
|
<string name="api_mgmt_configure">設定</string>
|
||||||
|
<string name="api_mgmt_edit">編輯</string>
|
||||||
<string name="api_mgmt_test">測試</string>
|
<string name="api_mgmt_test">測試</string>
|
||||||
<string name="api_mgmt_add">新增 API Key</string>
|
|
||||||
<string name="api_mgmt_add_picker_title">新增服務</string>
|
|
||||||
<string name="api_mgmt_all_added">所有服務都已新增</string>
|
|
||||||
<string name="api_mgmt_empty_title">還沒有新增任何 API</string>
|
|
||||||
<string name="api_mgmt_empty_body">點擊右上角「+」新增一個 API Key</string>
|
|
||||||
<string name="api_mgmt_delete_title">刪除憑證?</string>
|
<string name="api_mgmt_delete_title">刪除憑證?</string>
|
||||||
<string name="api_mgmt_delete_body">將刪除 %1$s 已儲存的憑證。</string>
|
<string name="api_mgmt_delete_body">將刪除 %1$s 已儲存的憑證。</string>
|
||||||
<string name="api_mgmt_unconfigured">未設定</string>
|
<string name="api_mgmt_unconfigured">未設定</string>
|
||||||
<string name="api_mgmt_accounts_subtitle">%1$d 個帳號 · 點按管理</string>
|
|
||||||
<string name="api_mgmt_last_verified">最後驗證:%1$s</string>
|
<string name="api_mgmt_last_verified">最後驗證:%1$s</string>
|
||||||
<string name="api_mgmt_cached_balance">快取餘額:%1$s %2$s</string>
|
<string name="api_mgmt_cached_balance">快取餘額:%1$s %2$s</string>
|
||||||
|
<string name="usage_stat_title">用量統計</string>
|
||||||
|
<string name="usage_stat_empty">尚無已設定服務</string>
|
||||||
|
<string name="usage_stat_failed">%1$d 項刷新失敗</string>
|
||||||
|
<string name="usage_stat_never_fetched">尚未取得</string>
|
||||||
|
<string name="usage_stat_fetched_at">更新於 %1$s</string>
|
||||||
|
<string name="usage_stat_monthly_spent">本月消費</string>
|
||||||
|
<string name="usage_stat_total_quota">總額度</string>
|
||||||
|
<string name="usage_stat_next_reset">下次重置</string>
|
||||||
<string name="api_mgmt_test_success">✅ %1$s 連線成功:%2$s %3$s</string>
|
<string name="api_mgmt_test_success">✅ %1$s 連線成功:%2$s %3$s</string>
|
||||||
<string name="api_mgmt_test_fail">❌ %1$s 連線失敗:%2$s</string>
|
<string name="api_mgmt_test_fail">❌ %1$s 連線失敗:%2$s</string>
|
||||||
<string name="api_mgmt_err_invalid">憑證無效</string>
|
<string name="api_mgmt_err_invalid">憑證無效</string>
|
||||||
@ -137,50 +141,10 @@
|
|||||||
<string name="service_desc_workbuddy">OAuth · CodeBuddy 同體系積分</string>
|
<string name="service_desc_workbuddy">OAuth · CodeBuddy 同體系積分</string>
|
||||||
<string name="service_desc_sub2api">自托管面板 · 用戶級 sk- Key 餘額</string>
|
<string name="service_desc_sub2api">自托管面板 · 用戶級 sk- Key 餘額</string>
|
||||||
<string name="detail_extras_title">明細</string>
|
<string name="detail_extras_title">明細</string>
|
||||||
<string name="detail_account_info_title">帳戶資訊</string>
|
|
||||||
<string name="detail_resources_title">資源明細</string>
|
|
||||||
<string name="wb_resource_label">資源 #%1$s</string>
|
|
||||||
<string name="wb_plan_title">套餐積分</string>
|
|
||||||
<string name="wb_plan_used_total">已用/總量</string>
|
|
||||||
<string name="wb_plan_in_use">使用中</string>
|
|
||||||
<string name="wb_plan_next_reset">下次權益週期更新</string>
|
|
||||||
<string name="wb_reward_title">平台獎勵積分</string>
|
|
||||||
<string name="wb_reward_expire">到期:%1$s</string>
|
|
||||||
<string name="wb_reward_item">獎勵包 %1$d</string>
|
|
||||||
<string name="extra_label_remaining">剩餘</string>
|
|
||||||
<string name="extra_label_billing_mode">計費方式</string>
|
|
||||||
<string name="extra_value_billing_credits">積分</string>
|
|
||||||
<string name="extra_value_billing_dollar">美元</string>
|
|
||||||
<string name="extra_label_billing_mode_desc">%1$s計費</string>
|
|
||||||
<string name="extra_value_subscription_active">訂閱中</string>
|
|
||||||
<string name="extra_value_paid_user">付費用戶</string>
|
|
||||||
<string name="extra_value_free_user">免費用戶</string>
|
|
||||||
<string name="extra_label_consumed_credits">已消耗</string>
|
|
||||||
<string name="extra_label_consumption_ratio">消耗比例</string>
|
|
||||||
<string name="extra_label_total_credits">總積分</string>
|
|
||||||
<string name="extra_label_pack_count">權益包</string>
|
|
||||||
<string name="extra_label_packages">套餐數</string>
|
|
||||||
<string name="extra_label_frozen">凍結</string>
|
|
||||||
<string name="extra_label_is_credits_billing">積分計費</string>
|
|
||||||
<string name="extra_label_is_dollar_usage_billing">美元計費</string>
|
|
||||||
<string name="extra_label_is_new_user">新用戶</string>
|
|
||||||
<string name="extra_label_is_paid_user">付費用戶</string>
|
|
||||||
<string name="extra_label_subscription">訂閱</string>
|
|
||||||
<string name="extra_value_yes">是</string>
|
|
||||||
<string name="extra_value_no">否</string>
|
|
||||||
<string name="credential_title_trae">Trae 憑證</string>
|
<string name="credential_title_trae">Trae 憑證</string>
|
||||||
<string name="credential_hint_trae">貼上 Trae 登入態中的 Cloud-IDE-JWT(ey… 開頭)。請求頭以 Authorization: Cloud-IDE-JWT 送出。</string>
|
<string name="credential_hint_trae">貼上 Trae 登入態中的 Cloud-IDE-JWT(ey… 開頭)。請求頭以 Authorization: Cloud-IDE-JWT 送出。</string>
|
||||||
<string name="field_trae_jwt">Cloud-IDE-JWT</string>
|
<string name="field_trae_jwt">Cloud-IDE-JWT</string>
|
||||||
<string name="field_trae_region">區域</string>
|
<string name="field_trae_region">區域</string>
|
||||||
<string name="field_trae_checkin_device_id">簽到裝置 ID(可選)</string>
|
|
||||||
<string name="field_trae_checkin_device_hint">簽到須使用官方用戶端綁定的裝置 ID。若持續失敗,請在電腦用戶端日誌(AppData\\Roaming\\Trae CN\\logs\\日期目錄\\main.log)中搜尋 did=,將最新的值貼到此處。留空則使用內建預設。</string>
|
|
||||||
<string name="field_trae_device_id_howto">裝置 ID 怎麼找?(分步指引)</string>
|
|
||||||
<string name="msg_path_copied">路徑已複製,貼到 Win+R 或檔案總管即可</string>
|
|
||||||
<string name="field_trae_device_id_howto_title">尋找裝置 ID(4 步)</string>
|
|
||||||
<string name="field_trae_device_id_howto_step1">1. 在電腦上按 Win+R,貼上下面的路徑後按 Enter(或複製後貼到檔案總管位址列):</string>
|
|
||||||
<string name="field_trae_device_id_howto_step2">2. 開啟資料夾名稱裡日期最新的那個資料夾。</string>
|
|
||||||
<string name="field_trae_device_id_howto_step3">3. 用記事本開啟 main.log。</string>
|
|
||||||
<string name="field_trae_device_id_howto_step4">4. 按 Ctrl+F 搜尋 did=,後面的數字(如 did=895482260673817)就是裝置 ID,複製並貼到上面的輸入框。</string>
|
|
||||||
<string name="option_region_cn">CN</string>
|
<string name="option_region_cn">CN</string>
|
||||||
<string name="option_region_intl">INTL</string>
|
<string name="option_region_intl">INTL</string>
|
||||||
<string name="credential_title_workbuddy">WorkBuddy 憑證</string>
|
<string name="credential_title_workbuddy">WorkBuddy 憑證</string>
|
||||||
@ -233,15 +197,8 @@
|
|||||||
<string name="checkin_need_credential">請先新增憑證後再簽到。</string>
|
<string name="checkin_need_credential">請先新增憑證後再簽到。</string>
|
||||||
<string name="checkin_trae_intl">INTL 區簽到介面暫未支援,請用 CN 區帳號</string>
|
<string name="checkin_trae_intl">INTL 區簽到介面暫未支援,請用 CN 區帳號</string>
|
||||||
<string name="checkin_trae_device_blocked">該裝置今日簽到次數已達上限</string>
|
<string name="checkin_trae_device_blocked">該裝置今日簽到次數已達上限</string>
|
||||||
<string name="checkin_trae_retry_later">參與用戶太多,請稍後重試</string>
|
|
||||||
<string name="checkin_trae_device_hint">簽到須使用官方用戶端綁定的裝置 ID。若持續失敗,請在電腦上打開 Trae 用戶端日誌目錄(AppData\\Roaming\\Trae CN\\logs\\日期目錄\\main.log),搜尋 did= 取得目前綁定的裝置 ID,然後貼到「設定 → Trae → 簽到裝置 ID」。</string>
|
|
||||||
<string name="checkin_trae_failed_code">簽到失敗(code=%1$d)</string>
|
<string name="checkin_trae_failed_code">簽到失敗(code=%1$d)</string>
|
||||||
<string name="checkin_wb_success_credits">簽到成功 +%1$d 積分</string>
|
<string name="checkin_wb_success_credits">簽到成功 +%1$d 積分</string>
|
||||||
<string name="trae_packs_title">積分權益包</string>
|
|
||||||
<string name="trae_pack_unlimited">無限額度</string>
|
|
||||||
<string name="trae_general_credits_title">通用積分</string>
|
|
||||||
<string name="trae_work_credits_title">Work 專屬積分</string>
|
|
||||||
<string name="trae_pack_remaining_fmt">剩餘 %1$s/%2$s 分 · 已用 %3$s</string>
|
|
||||||
|
|
||||||
<!-- ══════════ Sub2API 面板 WebView 登入(全帳號統計) ══════════ -->
|
<!-- ══════════ Sub2API 面板 WebView 登入(全帳號統計) ══════════ -->
|
||||||
<string name="sub2_panel_login_hint">在開啟的頁面中正常登入,App 自動讀取登入態;也可繼續手動填 API Key</string>
|
<string name="sub2_panel_login_hint">在開啟的頁面中正常登入,App 自動讀取登入態;也可繼續手動填 API Key</string>
|
||||||
@ -383,34 +340,6 @@
|
|||||||
<string name="error_credential_not_configured">憑據未配置</string>
|
<string name="error_credential_not_configured">憑據未配置</string>
|
||||||
<string name="error_credential_not_configured_expired">憑據未配置或已過期</string>
|
<string name="error_credential_not_configured_expired">憑據未配置或已過期</string>
|
||||||
<string name="error_credential_invalid_reconfigure">憑據無效,請在設定中重新配置</string>
|
<string name="error_credential_invalid_reconfigure">憑據無效,請在設定中重新配置</string>
|
||||||
<string name="error_credential_session_expired">登入已過期,請重新登入</string>
|
|
||||||
<string name="auto_checkin_switch_label">自動簽到</string>
|
|
||||||
<string name="checkin_badge_today">今日已簽 ✓</string>
|
|
||||||
<string name="auto_checkin_dialog_title">每日自動簽到</string>
|
|
||||||
<string name="auto_checkin_line_gained">%1$s 已簽到,積分增加 %2$d</string>
|
|
||||||
<string name="auto_checkin_line_already">%1$s 今日已簽到</string>
|
|
||||||
<string name="auto_checkin_line_failed">%1$s 簽到失敗:%2$s</string>
|
|
||||||
<string name="auto_checkin_line_failed_generic">%1$s 簽到失敗,請手動簽到</string>
|
|
||||||
<string name="account_manager_title">%1$s · 帳號管理</string>
|
|
||||||
<string name="account_add">新增帳號</string>
|
|
||||||
<string name="account_actions">帳號操作</string>
|
|
||||||
<string name="account_default_badge">預設</string>
|
|
||||||
<string name="account_fallback_name">帳號 %1$d</string>
|
|
||||||
<string name="account_set_default">設為預設</string>
|
|
||||||
<string name="account_rename">重新命名</string>
|
|
||||||
<string name="account_rename_dialog_title">重新命名帳號</string>
|
|
||||||
<string name="account_name_label">帳號名稱</string>
|
|
||||||
<string name="account_delete_title">刪除帳號</string>
|
|
||||||
<string name="account_delete_confirm">確定刪除該帳號?該帳號的憑據與餘額快取將一併清除。</string>
|
|
||||||
<string name="account_empty_hint">還沒有帳號,點下方按鈕新增一個。</string>
|
|
||||||
<string name="account_count_line">%1$s · %2$d 個帳號</string>
|
|
||||||
<string name="account_new_credential_title">%1$s · 新帳號</string>
|
|
||||||
<string name="service_detail_title_with_account">%1$s · %2$s</string>
|
|
||||||
<string name="account_chip_default">%1$s(預設)</string>
|
|
||||||
<string name="msg_account_default_set">已設為預設帳號</string>
|
|
||||||
<string name="msg_account_renamed">已重新命名</string>
|
|
||||||
<string name="msg_account_deleted">帳號已刪除</string>
|
|
||||||
<string name="action_delete_confirmed">刪除</string>
|
|
||||||
<string name="error_credential_invalid_relogin">憑據無效,請重新登入</string>
|
<string name="error_credential_invalid_relogin">憑據無效,請重新登入</string>
|
||||||
<string name="error_credential_changed_reload">憑據已變更,正在重新載入</string>
|
<string name="error_credential_changed_reload">憑據已變更,正在重新載入</string>
|
||||||
<string name="error_credential_changed_retry">憑據已變更,請重新操作</string>
|
<string name="error_credential_changed_retry">憑據已變更,請重新操作</string>
|
||||||
@ -462,7 +391,6 @@
|
|||||||
<string name="widget_quota_with_plan">%1$s · %2$s</string>
|
<string name="widget_quota_with_plan">%1$s · %2$s</string>
|
||||||
<string name="widget_updated_at">更新 %1$s</string>
|
<string name="widget_updated_at">更新 %1$s</string>
|
||||||
<string name="widget_no_data">暫無數據</string>
|
<string name="widget_no_data">暫無數據</string>
|
||||||
<string name="widget_not_configured">還沒有設定任何服務</string>
|
|
||||||
<string name="widget_refreshing">重新整理中…</string>
|
<string name="widget_refreshing">重新整理中…</string>
|
||||||
|
|
||||||
<!-- ══════════ 小技巧(AppTips) ══════════ -->
|
<!-- ══════════ 小技巧(AppTips) ══════════ -->
|
||||||
@ -625,7 +553,6 @@
|
|||||||
<string name="action_relogin">重新登入</string>
|
<string name="action_relogin">重新登入</string>
|
||||||
<string name="action_configure_credential">配置憑據</string>
|
<string name="action_configure_credential">配置憑據</string>
|
||||||
<string name="action_recharge">儲值</string>
|
<string name="action_recharge">儲值</string>
|
||||||
<string name="action_subscribe_recharge">儲值 / 訂閱</string>
|
|
||||||
<string name="main_card_current_balance">當前餘額</string>
|
<string name="main_card_current_balance">當前餘額</string>
|
||||||
<string name="main_card_5h_usage">5h 實時用量</string>
|
<string name="main_card_5h_usage">5h 實時用量</string>
|
||||||
<string name="main_card_monthly_balance">月度餘額</string>
|
<string name="main_card_monthly_balance">月度餘額</string>
|
||||||
@ -771,17 +698,4 @@
|
|||||||
<string name="sub2_panel_delete_confirm_title">刪除 API Key</string>
|
<string name="sub2_panel_delete_confirm_title">刪除 API Key</string>
|
||||||
<string name="sub2_panel_delete_confirm_body">確定刪除「%1$s」嗎?刪除後無法復原,正在使用該 Key 的用戶端會立即失效。</string>
|
<string name="sub2_panel_delete_confirm_body">確定刪除「%1$s」嗎?刪除後無法復原,正在使用該 Key 的用戶端會立即失效。</string>
|
||||||
<string name="sub2_trend_account_scope">此圖為帳號整體趨勢(無單模型維度)</string>
|
<string name="sub2_trend_account_scope">此圖為帳號整體趨勢(無單模型維度)</string>
|
||||||
<string name="title_update">檢查更新</string>
|
|
||||||
<string name="update_current_version">目前版本 %1$s(%2$d)</string>
|
|
||||||
<string name="update_check">檢查更新</string>
|
|
||||||
<string name="update_checking">正在檢查…</string>
|
|
||||||
<string name="update_latest">已是最新版本</string>
|
|
||||||
<string name="update_available">發現新版本 %1$s</string>
|
|
||||||
<string name="update_download">下載並安裝</string>
|
|
||||||
<string name="update_downloading">正在下載 %1$d%%</string>
|
|
||||||
<string name="update_download_finished">下載完成</string>
|
|
||||||
<string name="update_install">安裝</string>
|
|
||||||
<string name="update_install_permission_tip">需要開啟「安裝未知應用」權限</string>
|
|
||||||
<string name="update_enable_permission">前往開啟</string>
|
|
||||||
<string name="update_failed">更新失敗:%1$s</string>
|
|
||||||
</resources>
|
</resources>
|
||||||
@ -50,18 +50,22 @@
|
|||||||
<string name="nav_empty_subtitle">Tap a card on the left</string>
|
<string name="nav_empty_subtitle">Tap a card on the left</string>
|
||||||
<string name="title_settings">Settings</string>
|
<string name="title_settings">Settings</string>
|
||||||
<string name="api_mgmt_title">API Management</string>
|
<string name="api_mgmt_title">API Management</string>
|
||||||
|
<string name="api_mgmt_configure">Configure</string>
|
||||||
|
<string name="api_mgmt_edit">Edit</string>
|
||||||
<string name="api_mgmt_test">Test</string>
|
<string name="api_mgmt_test">Test</string>
|
||||||
<string name="api_mgmt_add">Add API Key</string>
|
|
||||||
<string name="api_mgmt_add_picker_title">Add a service</string>
|
|
||||||
<string name="api_mgmt_all_added">All services have been added</string>
|
|
||||||
<string name="api_mgmt_empty_title">No entries yet</string>
|
|
||||||
<string name="api_mgmt_empty_body">Tap the + in the top-right corner to add an API key</string>
|
|
||||||
<string name="api_mgmt_delete_title">Delete credential?</string>
|
<string name="api_mgmt_delete_title">Delete credential?</string>
|
||||||
<string name="api_mgmt_delete_body">This will remove the saved credential for %1$s.</string>
|
<string name="api_mgmt_delete_body">This will remove the saved credential for %1$s.</string>
|
||||||
<string name="api_mgmt_unconfigured">Not configured</string>
|
<string name="api_mgmt_unconfigured">Not configured</string>
|
||||||
<string name="api_mgmt_accounts_subtitle">%1$d accounts · tap to manage</string>
|
|
||||||
<string name="api_mgmt_last_verified">Last verified: %1$s</string>
|
<string name="api_mgmt_last_verified">Last verified: %1$s</string>
|
||||||
<string name="api_mgmt_cached_balance">Cached: %1$s %2$s</string>
|
<string name="api_mgmt_cached_balance">Cached: %1$s %2$s</string>
|
||||||
|
<string name="usage_stat_title">Usage Stats</string>
|
||||||
|
<string name="usage_stat_empty">No configured services yet</string>
|
||||||
|
<string name="usage_stat_failed">%1$d refresh failed</string>
|
||||||
|
<string name="usage_stat_never_fetched">Not fetched yet</string>
|
||||||
|
<string name="usage_stat_fetched_at">Updated %1$s</string>
|
||||||
|
<string name="usage_stat_monthly_spent">Monthly spent</string>
|
||||||
|
<string name="usage_stat_total_quota">Total quota</string>
|
||||||
|
<string name="usage_stat_next_reset">Next reset</string>
|
||||||
<string name="api_mgmt_test_success">✅ %1$s connected: %2$s %3$s</string>
|
<string name="api_mgmt_test_success">✅ %1$s connected: %2$s %3$s</string>
|
||||||
<string name="api_mgmt_test_fail">❌ %1$s failed: %2$s</string>
|
<string name="api_mgmt_test_fail">❌ %1$s failed: %2$s</string>
|
||||||
<string name="api_mgmt_err_invalid">Invalid credential</string>
|
<string name="api_mgmt_err_invalid">Invalid credential</string>
|
||||||
@ -140,50 +144,10 @@
|
|||||||
<string name="service_desc_workbuddy">OAuth · CodeBuddy 同体系积分</string>
|
<string name="service_desc_workbuddy">OAuth · CodeBuddy 同体系积分</string>
|
||||||
<string name="service_desc_sub2api">自托管面板 · 用户级 sk- Key 余额</string>
|
<string name="service_desc_sub2api">自托管面板 · 用户级 sk- Key 余额</string>
|
||||||
<string name="detail_extras_title">Breakdown</string>
|
<string name="detail_extras_title">Breakdown</string>
|
||||||
<string name="detail_account_info_title">Account info</string>
|
|
||||||
<string name="detail_resources_title">Resources</string>
|
|
||||||
<string name="wb_resource_label">#%1$s</string>
|
|
||||||
<string name="wb_plan_title">Plan credits</string>
|
|
||||||
<string name="wb_plan_used_total">Used / Total</string>
|
|
||||||
<string name="wb_plan_in_use">In use</string>
|
|
||||||
<string name="wb_plan_next_reset">Next cycle</string>
|
|
||||||
<string name="wb_reward_title">Platform rewards</string>
|
|
||||||
<string name="wb_reward_expire">Expires: %1$s</string>
|
|
||||||
<string name="wb_reward_item">Reward %1$d</string>
|
|
||||||
<string name="extra_label_remaining">Remaining</string>
|
|
||||||
<string name="extra_label_billing_mode">Billing mode</string>
|
|
||||||
<string name="extra_value_billing_credits">Credits</string>
|
|
||||||
<string name="extra_value_billing_dollar">USD</string>
|
|
||||||
<string name="extra_label_billing_mode_desc">%1$s billing</string>
|
|
||||||
<string name="extra_value_subscription_active">Subscribed</string>
|
|
||||||
<string name="extra_value_paid_user">Paid plan</string>
|
|
||||||
<string name="extra_value_free_user">Free plan</string>
|
|
||||||
<string name="extra_label_consumed_credits">Consumed</string>
|
|
||||||
<string name="extra_label_consumption_ratio">Consumption ratio</string>
|
|
||||||
<string name="extra_label_total_credits">Total credits</string>
|
|
||||||
<string name="extra_label_pack_count">Entitlement packs</string>
|
|
||||||
<string name="extra_label_packages">Packages</string>
|
|
||||||
<string name="extra_label_frozen">Frozen</string>
|
|
||||||
<string name="extra_label_is_credits_billing">Credits billing</string>
|
|
||||||
<string name="extra_label_is_dollar_usage_billing">USD billing</string>
|
|
||||||
<string name="extra_label_is_new_user">New user</string>
|
|
||||||
<string name="extra_label_is_paid_user">Paid user</string>
|
|
||||||
<string name="extra_label_subscription">Subscription</string>
|
|
||||||
<string name="extra_value_yes">Yes</string>
|
|
||||||
<string name="extra_value_no">No</string>
|
|
||||||
<string name="credential_title_trae">Trae credentials</string>
|
<string name="credential_title_trae">Trae credentials</string>
|
||||||
<string name="credential_hint_trae">Paste the Cloud-IDE-JWT from your Trae account session (starts with ey…). Used as Authorization: Cloud-IDE-JWT header.</string>
|
<string name="credential_hint_trae">Paste the Cloud-IDE-JWT from your Trae account session (starts with ey…). Used as Authorization: Cloud-IDE-JWT header.</string>
|
||||||
<string name="field_trae_jwt">Cloud-IDE-JWT</string>
|
<string name="field_trae_jwt">Cloud-IDE-JWT</string>
|
||||||
<string name="field_trae_region">Region</string>
|
<string name="field_trae_region">Region</string>
|
||||||
<string name="field_trae_checkin_device_id">Check-in device ID (optional)</string>
|
|
||||||
<string name="field_trae_checkin_device_hint">Check-in requires the device ID bound to the official desktop client. If it keeps failing, find the latest did= value in the client log (AppData\\Roaming\\Trae CN\\logs\\<date>\\main.log) and paste it here. Leave blank to use the built-in default.</string>
|
|
||||||
<string name="field_trae_device_id_howto">How to find the device ID? (step-by-step guide)</string>
|
|
||||||
<string name="msg_path_copied">Path copied, paste it in Win+R or File Explorer</string>
|
|
||||||
<string name="field_trae_device_id_howto_title">Find device ID (4 steps)</string>
|
|
||||||
<string name="field_trae_device_id_howto_step1">1. On your PC, press Win+R, paste the path below and press Enter (or copy it and paste into the File Explorer address bar):</string>
|
|
||||||
<string name="field_trae_device_id_howto_step2">2. Open the folder with the latest date in its name.</string>
|
|
||||||
<string name="field_trae_device_id_howto_step3">3. Open main.log with Notepad.</string>
|
|
||||||
<string name="field_trae_device_id_howto_step4">4. Press Ctrl+F and search did= — the number after it (like did=895482260673817) is your device ID. Copy it and paste into the field above.</string>
|
|
||||||
<string name="option_region_cn">CN</string>
|
<string name="option_region_cn">CN</string>
|
||||||
<string name="option_region_intl">INTL</string>
|
<string name="option_region_intl">INTL</string>
|
||||||
<string name="credential_title_workbuddy">WorkBuddy credentials</string>
|
<string name="credential_title_workbuddy">WorkBuddy credentials</string>
|
||||||
@ -236,15 +200,8 @@
|
|||||||
<string name="checkin_need_credential">Add your credentials first to check in.</string>
|
<string name="checkin_need_credential">Add your credentials first to check in.</string>
|
||||||
<string name="checkin_trae_intl">INTL check-in not yet supported, please use a CN account</string>
|
<string name="checkin_trae_intl">INTL check-in not yet supported, please use a CN account</string>
|
||||||
<string name="checkin_trae_device_blocked">Device check-in limit reached today</string>
|
<string name="checkin_trae_device_blocked">Device check-in limit reached today</string>
|
||||||
<string name="checkin_trae_retry_later">Too many participants, please try again later</string>
|
|
||||||
<string name="checkin_trae_device_hint">Sign-in requires the device ID bound to the official desktop client. If it keeps failing, open the Trae client log folder (AppData\\Roaming\\Trae CN\\logs\\<date>\\main.log) on your PC and search for did= to get the current bound device ID, then paste it into Settings → Trae → Check-in device ID.</string>
|
|
||||||
<string name="checkin_trae_failed_code">Check-in failed (code=%1$d)</string>
|
<string name="checkin_trae_failed_code">Check-in failed (code=%1$d)</string>
|
||||||
<string name="checkin_wb_success_credits">Check-in successful +%1$d credits</string>
|
<string name="checkin_wb_success_credits">Check-in successful +%1$d credits</string>
|
||||||
<string name="trae_packs_title">Entitlement credits</string>
|
|
||||||
<string name="trae_pack_unlimited">Unlimited</string>
|
|
||||||
<string name="trae_pack_remaining_fmt">Remaining %1$s/%2$s pts · used %3$s</string>
|
|
||||||
<string name="trae_general_credits_title">General credits</string>
|
|
||||||
<string name="trae_work_credits_title">Work credits</string>
|
|
||||||
|
|
||||||
<!-- ══════════ Sub2API 面板 WebView 登录(全账号统计) ══════════ -->
|
<!-- ══════════ Sub2API 面板 WebView 登录(全账号统计) ══════════ -->
|
||||||
<string name="sub2_panel_login_hint">Sign in normally on the opened page; the app reads the session automatically. Manual API Key still works.</string>
|
<string name="sub2_panel_login_hint">Sign in normally on the opened page; the app reads the session automatically. Manual API Key still works.</string>
|
||||||
@ -386,34 +343,6 @@
|
|||||||
<string name="error_credential_not_configured">Credential not configured</string>
|
<string name="error_credential_not_configured">Credential not configured</string>
|
||||||
<string name="error_credential_not_configured_expired">Credential missing or expired</string>
|
<string name="error_credential_not_configured_expired">Credential missing or expired</string>
|
||||||
<string name="error_credential_invalid_reconfigure">Credential invalid, please reconfigure in Settings</string>
|
<string name="error_credential_invalid_reconfigure">Credential invalid, please reconfigure in Settings</string>
|
||||||
<string name="error_credential_session_expired">Login expired, please sign in again</string>
|
|
||||||
<string name="auto_checkin_switch_label">Auto check-in</string>
|
|
||||||
<string name="checkin_badge_today">Checked in ✓</string>
|
|
||||||
<string name="auto_checkin_dialog_title">Daily auto check-in</string>
|
|
||||||
<string name="auto_checkin_line_gained">%1$s checked in, +%2$d credits</string>
|
|
||||||
<string name="auto_checkin_line_already">%1$s already checked in today</string>
|
|
||||||
<string name="auto_checkin_line_failed">%1$s check-in failed: %2$s</string>
|
|
||||||
<string name="auto_checkin_line_failed_generic">%1$s check-in failed, please check in manually</string>
|
|
||||||
<string name="account_manager_title">%1$s · Accounts</string>
|
|
||||||
<string name="account_add">Add Account</string>
|
|
||||||
<string name="account_actions">Account actions</string>
|
|
||||||
<string name="account_default_badge">Default</string>
|
|
||||||
<string name="account_fallback_name">Account %1$d</string>
|
|
||||||
<string name="account_set_default">Set as Default</string>
|
|
||||||
<string name="account_rename">Rename</string>
|
|
||||||
<string name="account_rename_dialog_title">Rename Account</string>
|
|
||||||
<string name="account_name_label">Account name</string>
|
|
||||||
<string name="account_delete_title">Delete Account</string>
|
|
||||||
<string name="account_delete_confirm">Delete this account? Its credential and balance cache will also be removed.</string>
|
|
||||||
<string name="account_empty_hint">No accounts yet. Tap below to add one.</string>
|
|
||||||
<string name="account_count_line">%1$s · %2$d accounts</string>
|
|
||||||
<string name="account_new_credential_title">%1$s · New Account</string>
|
|
||||||
<string name="service_detail_title_with_account">%1$s · %2$s</string>
|
|
||||||
<string name="account_chip_default">%1$s (Default)</string>
|
|
||||||
<string name="msg_account_default_set">Set as default account</string>
|
|
||||||
<string name="msg_account_renamed">Renamed</string>
|
|
||||||
<string name="msg_account_deleted">Account deleted</string>
|
|
||||||
<string name="action_delete_confirmed">Delete</string>
|
|
||||||
<string name="error_credential_invalid_relogin">Credential invalid, please log in again</string>
|
<string name="error_credential_invalid_relogin">Credential invalid, please log in again</string>
|
||||||
<string name="error_credential_changed_reload">Credentials changed, reloading</string>
|
<string name="error_credential_changed_reload">Credentials changed, reloading</string>
|
||||||
<string name="error_credential_changed_retry">Credentials changed, please retry</string>
|
<string name="error_credential_changed_retry">Credentials changed, please retry</string>
|
||||||
@ -465,7 +394,6 @@
|
|||||||
<string name="widget_quota_with_plan">%1$s · %2$s</string>
|
<string name="widget_quota_with_plan">%1$s · %2$s</string>
|
||||||
<string name="widget_updated_at">Updated %1$s</string>
|
<string name="widget_updated_at">Updated %1$s</string>
|
||||||
<string name="widget_no_data">No data</string>
|
<string name="widget_no_data">No data</string>
|
||||||
<string name="widget_not_configured">No services configured yet</string>
|
|
||||||
<string name="widget_refreshing">Refreshing…</string>
|
<string name="widget_refreshing">Refreshing…</string>
|
||||||
|
|
||||||
<!-- ══════════ 小技巧(AppTips) ══════════ -->
|
<!-- ══════════ 小技巧(AppTips) ══════════ -->
|
||||||
@ -628,7 +556,6 @@
|
|||||||
<string name="action_relogin">Log in again</string>
|
<string name="action_relogin">Log in again</string>
|
||||||
<string name="action_configure_credential">Configure credential</string>
|
<string name="action_configure_credential">Configure credential</string>
|
||||||
<string name="action_recharge">Top up / Recharge</string>
|
<string name="action_recharge">Top up / Recharge</string>
|
||||||
<string name="action_subscribe_recharge">Top up / Subscribe</string>
|
|
||||||
<string name="main_card_current_balance">Current balance</string>
|
<string name="main_card_current_balance">Current balance</string>
|
||||||
<string name="main_card_5h_usage">5h live usage</string>
|
<string name="main_card_5h_usage">5h live usage</string>
|
||||||
<string name="main_card_monthly_balance">Monthly balance</string>
|
<string name="main_card_monthly_balance">Monthly balance</string>
|
||||||
@ -776,17 +703,4 @@
|
|||||||
<string name="sub2_panel_delete_confirm_title">Delete API key</string>
|
<string name="sub2_panel_delete_confirm_title">Delete API key</string>
|
||||||
<string name="sub2_panel_delete_confirm_body">Delete "%1$s"? This cannot be undone and any client using this key will stop working.</string>
|
<string name="sub2_panel_delete_confirm_body">Delete "%1$s"? This cannot be undone and any client using this key will stop working.</string>
|
||||||
<string name="sub2_trend_account_scope">Chart shows the whole account (no per-model breakdown)</string>
|
<string name="sub2_trend_account_scope">Chart shows the whole account (no per-model breakdown)</string>
|
||||||
<string name="title_update">App update</string>
|
|
||||||
<string name="update_current_version">Current version %1$s (%2$d)</string>
|
|
||||||
<string name="update_check">Check for updates</string>
|
|
||||||
<string name="update_checking">Checking…</string>
|
|
||||||
<string name="update_latest">You are on the latest version</string>
|
|
||||||
<string name="update_available">New version %1$s available</string>
|
|
||||||
<string name="update_download">Download & install</string>
|
|
||||||
<string name="update_downloading">Downloading %1$d%%</string>
|
|
||||||
<string name="update_download_finished">Download finished</string>
|
|
||||||
<string name="update_install">Install</string>
|
|
||||||
<string name="update_install_permission_tip">Please allow installing apps from unknown sources</string>
|
|
||||||
<string name="update_enable_permission">Open settings</string>
|
|
||||||
<string name="update_failed">Update failed: %1$s</string>
|
|
||||||
</resources>
|
</resources>
|
||||||
@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<!-- 进度条颜色:<50% 玫瑰 / 50-80% 橙色 / >80% 玫红(与 App 内状态色一致) -->
|
<!-- 进度条颜色:<50% 粉色 / 50-80% 橙色 / >80% 玫红 -->
|
||||||
<color name="widget_bar_5h">#C9436C</color>
|
<color name="widget_bar_5h">#FF85A2</color>
|
||||||
<color name="widget_bar_weekly">#FFA726</color>
|
<color name="widget_bar_weekly">#FFA726</color>
|
||||||
<color name="widget_bar_monthly">#D64A6E</color>
|
<color name="widget_bar_monthly">#E91E63</color>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@ -1,4 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<cache-path name="apk_downloads" path="apk_downloads/" />
|
|
||||||
</paths>
|
|
||||||
@ -5,7 +5,4 @@
|
|||||||
<certificates src="system" />
|
<certificates src="system" />
|
||||||
</trust-anchors>
|
</trust-anchors>
|
||||||
</base-config>
|
</base-config>
|
||||||
<domain-config cleartextTrafficPermitted="true">
|
|
||||||
<domain includeSubdomains="false">124.222.97.212</domain>
|
|
||||||
</domain-config>
|
|
||||||
</network-security-config>
|
</network-security-config>
|
||||||
|
|||||||
@ -47,15 +47,6 @@ class TraeRepositoryTest {
|
|||||||
assertEquals(0L, r.points)
|
assertEquals(0L, r.points)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun code10001_authoritative_marksAlreadyChecked() {
|
|
||||||
// 权威口径幂等兜底码是 10001(trae-checkin-helper 逆向),不能误判为 FAILED
|
|
||||||
val r = parse("""{"code":10001,"message":"Already claimed"}""").getOrThrow()
|
|
||||||
|
|
||||||
assertTrue(r.alreadyChecked)
|
|
||||||
assertEquals(TraeRepository.CheckinOutcome.ALREADY, r.outcome)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun alreadyKeywordWithoutCode_marksAlreadyChecked() {
|
fun alreadyKeywordWithoutCode_marksAlreadyChecked() {
|
||||||
// 服务端换 code 但文案不变时仍识别为「账号级已签」
|
// 服务端换 code 但文案不变时仍识别为「账号级已签」
|
||||||
@ -90,24 +81,6 @@ class TraeRepositoryTest {
|
|||||||
assertEquals(TraeRepository.CheckinOutcome.DEVICE_BLOCKED, r.outcome)
|
assertEquals(TraeRepository.CheckinOutcome.DEVICE_BLOCKED, r.outcome)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun tooManyParticipants_isRetryableNotice() {
|
|
||||||
// 服务端活动/账号级临时限流:提示可稍后重试,而不是硬错误或误判已签
|
|
||||||
val body = """{"code":9001,"message":"当前参与用户太多,请稍后重试"}"""
|
|
||||||
val r = parse(body).getOrThrow()
|
|
||||||
|
|
||||||
assertFalse(r.success)
|
|
||||||
assertFalse(r.alreadyChecked)
|
|
||||||
assertEquals(TraeRepository.CheckinOutcome.RETRYABLE, r.outcome)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun retryKeyword_isRetryableNotice() {
|
|
||||||
val r = parse("""{"code":9002,"message":"请求过于频繁,please retry later"}""").getOrThrow()
|
|
||||||
|
|
||||||
assertEquals(TraeRepository.CheckinOutcome.RETRYABLE, r.outcome)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun unknownCode_isFailedAndKeepsCode() {
|
fun unknownCode_isFailedAndKeepsCode() {
|
||||||
val r = parse("""{"code":500,"message":"服务异常"}""").getOrThrow()
|
val r = parse("""{"code":500,"message":"服务异常"}""").getOrThrow()
|
||||||
@ -134,55 +107,4 @@ class TraeRepositoryTest {
|
|||||||
|
|
||||||
assertTrue(result.isFailure)
|
assertTrue(result.isFailure)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── parseStatus(status 接口:enable/checked_in 协议校验) ───────────────
|
|
||||||
|
|
||||||
private fun parseStatus(body: String) = TraeRepository.parseStatus(body)
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun status_enabledNotChecked_parsesFields() {
|
|
||||||
val s = parseStatus("""{"enable":true,"checked_in":false,"credits":100}""").getOrThrow()
|
|
||||||
|
|
||||||
assertTrue(s.enable == true)
|
|
||||||
assertTrue(s.checkedIn == false)
|
|
||||||
assertEquals(100.0, s.credits!!, 0.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun status_disabled_parsesEnableFalse() {
|
|
||||||
// 签到活动未开放(enable=false):提示稍后再试,而非错误
|
|
||||||
val s = parseStatus("""{"code":0,"enable":false,"checked_in":false}""").getOrThrow()
|
|
||||||
|
|
||||||
assertEquals(false, s.enable)
|
|
||||||
assertEquals(false, s.checkedIn)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun status_dataWrapped_parses() {
|
|
||||||
val s = parseStatus("""{"data":{"enable":true,"checked_in":true,"credits":200}}""").getOrThrow()
|
|
||||||
|
|
||||||
assertTrue(s.enable == true)
|
|
||||||
assertTrue(s.checkedIn == true)
|
|
||||||
assertEquals(200.0, s.credits!!, 0.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun status_nonZeroCode_isFailure() {
|
|
||||||
assertTrue(parseStatus("""{"code":5001,"enable":false,"checked_in":false}""").isFailure)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun status_missingBooleans_isFailure() {
|
|
||||||
// enable/checked_in 非布尔:协议异常(调用方应继续走 claim 兜底)
|
|
||||||
assertTrue(parseStatus("""{"code":0,"enable":1,"checked_in":0}""").isFailure)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun claim_creditFieldParsesPoints() {
|
|
||||||
// 社区实测(trae-checkin-helper):claim 成功响应可能用 credit 字段而非 points
|
|
||||||
val r = parse("""{"code":0,"data":{"credit":150}}""").getOrThrow()
|
|
||||||
|
|
||||||
assertTrue(r.success)
|
|
||||||
assertEquals(150L, r.points)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
136
docs/签到核心请求.md
@ -1,136 +0,0 @@
|
|||||||
# WB(WorkBuddy)与 Trae 每日签到核心请求
|
|
||||||
|
|
||||||
> 本文档整理自仓库现有实现(源码为准):
|
|
||||||
> - `app/src/main/java/com/rainy/token/data/repository/TraeRepository.kt`
|
|
||||||
> - `app/src/main/java/com/rainy/token/data/repository/WorkBuddyRepository.kt`
|
|
||||||
>
|
|
||||||
> 其中 **WB = 腾讯 WorkBuddy**(非微博)。所有请求均为 `POST`,body 默认为 `{}`。
|
|
||||||
|
|
||||||
## 一、端点汇总
|
|
||||||
|
|
||||||
| 服务 | 用途 | URL | Body |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| Trae(CN) | 查询签到状态 | `https://api.trae.cn/trae/api/v2/ug/checkin_credits/status` | `{}` |
|
|
||||||
| Trae(CN) | 执行签到 | `https://api.trae.cn/trae/api/v2/ug/checkin_credits/claim` | `{}`(9074 时换 `{"req_source":2}` → `{"req_source":1}`) |
|
|
||||||
| WorkBuddy | 查询签到状态(优先) | `https://www.codebuddy.cn/v2/billing/meter/checkin-activity-status` | `{}` |
|
|
||||||
| WorkBuddy | 查询签到状态(兜底) | `https://www.codebuddy.cn/v2/billing/meter/checkin-status` | `{}` |
|
|
||||||
| WorkBuddy | 执行签到 | `https://www.codebuddy.cn/v2/billing/meter/daily-checkin` | `{}` |
|
|
||||||
| WorkBuddy | accessToken 刷新 | `https://copilot.tencent.com/v2/plugin/auth/token/refresh` | 头 `X-Refresh-Token` |
|
|
||||||
|
|
||||||
## 二、Trae 核心请求源码
|
|
||||||
|
|
||||||
认证头:`Authorization: Cloud-IDE-JWT <jwt>`,`x-device-id` 必须是账号绑定的官方客户端设备 ID。
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
// 官方签到接口路径
|
|
||||||
const val CHECKIN_STATUS_PATH = "/trae/api/v2/ug/checkin_credits/status"
|
|
||||||
const val CHECKIN_CLAIM_PATH = "/trae/api/v2/ug/checkin_credits/claim"
|
|
||||||
|
|
||||||
private fun checkinRequest(
|
|
||||||
jwt: String,
|
|
||||||
deviceId: String,
|
|
||||||
deviceSrc: String,
|
|
||||||
path: String,
|
|
||||||
bodyJson: String = "{}"
|
|
||||||
): Result<String> {
|
|
||||||
val requestId = java.util.UUID.randomUUID().toString()
|
|
||||||
val request = Request.Builder()
|
|
||||||
.url("https://api.trae.cn" + path)
|
|
||||||
.addHeader("Content-Type", "application/json")
|
|
||||||
.addHeader("Accept", "*/*")
|
|
||||||
.addHeader("User-Agent", "VSCode 1.107.1 (TRAE SOLO CN)")
|
|
||||||
.addHeader("Authorization", "Cloud-IDE-JWT " + jwt)
|
|
||||||
.addHeader("X-User-Region", "CN")
|
|
||||||
.addHeader("Accept-Language", "zh-CN")
|
|
||||||
.addHeader("Package-Type", "stable_cn")
|
|
||||||
.addHeader("X-Lgw-Req-Sdk-Type", "3")
|
|
||||||
.addHeader("X-Market-Client-Id", "VSCode 1.107.1")
|
|
||||||
.addHeader("X-Device-Brand", "83DG")
|
|
||||||
.addHeader("X-Device-Type", "windows")
|
|
||||||
.addHeader("X-OS-Version", "Windows 11 Pro")
|
|
||||||
.addHeader("App-Version", "0.1.61")
|
|
||||||
.addHeader("X-Request-Id", requestId)
|
|
||||||
.addHeader("X-TT-Trace-Id", ttTraceId(requestId))
|
|
||||||
.addHeader("X-Device-Id", deviceId)
|
|
||||||
.addHeader("Sec-Fetch-Dest", "empty")
|
|
||||||
.addHeader("Sec-Fetch-Mode", "no-cors")
|
|
||||||
.addHeader("Sec-Fetch-Site", "none")
|
|
||||||
.post(bodyJson.toRequestBody("application/json".toMediaType()))
|
|
||||||
.build()
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Trae 签到结果判定
|
|
||||||
|
|
||||||
- `code = 0 / 200` → **SUCCESS**(成功)
|
|
||||||
- `code = 1001 / 10001`,或文案含 `已签到 / 已经签到 / 明日再来 / 今日已完成 / 已领取 / already / checked / claimed` → **ALREADY**(今日已签)
|
|
||||||
- 文案含 `设备 / device / machine` → **DEVICE_BLOCKED**(设备级拦截)
|
|
||||||
- 文案含 `参与 / 稍后重试 / 火爆 / 繁忙 / 限流 / 排队 / too many / retry / busy / popular / later` → **RETRYABLE**(临时限流)
|
|
||||||
- 其余 → **FAILED**
|
|
||||||
|
|
||||||
### Trae 注意事项
|
|
||||||
|
|
||||||
- UA 必须是 `VSCode 1.107.1 (TRAE SOLO CN)`,这是签到走的 VSCode 插件进程身份;与 IDE 主进程 / Web 混用会触发风控。
|
|
||||||
- `X-Device-Id` 不可用固定 ClientID `en1oxy7wnw8j9n`(所有用户相同),会按设备风控拒绝。
|
|
||||||
- 仓库内置默认设备 ID:`ACCOUNT_BOUND_DEVICE_ID = "895482260673817"`,账号级,换设备需更新。
|
|
||||||
- INTL 区签到接口未实测,仓库实现直接不支持。
|
|
||||||
|
|
||||||
## 三、WorkBuddy(WB)核心请求源码
|
|
||||||
|
|
||||||
认证头:`Authorization: Bearer <accessToken>`。签到域名是 `www.codebuddy.cn`(余额主查询才是 `www.workbuddy.cn`)。
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
// 查询今日签到状态(两条路径依次 fallback,共享 token 轮换状态)
|
|
||||||
suspend fun fetchCheckinStatus(accountId: String? = null): Result<CheckinStatus> {
|
|
||||||
val body = authedPostFirstSuccessful(
|
|
||||||
credential,
|
|
||||||
accountId,
|
|
||||||
"/v2/billing/meter/checkin-activity-status",
|
|
||||||
"/v2/billing/meter/checkin-status"
|
|
||||||
) ?: return Result.failure(RepositoryError.Unknown())
|
|
||||||
return runCatching { parseCheckin(body) }...
|
|
||||||
}
|
|
||||||
|
|
||||||
// 执行签到
|
|
||||||
suspend fun checkIn(accountId: String? = null): Result<CheckinStatus> {
|
|
||||||
val body = when (val r = authedPost(credential, "/v2/billing/meter/daily-checkin")) {
|
|
||||||
is AuthedPostResult.Ok -> r.body
|
|
||||||
is AuthedPostResult.Failed -> r.body
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
// code=0 成功;code=10001 或文案含「已签/今日/already」→ 已签
|
|
||||||
}
|
|
||||||
|
|
||||||
// 带自动 refresh 一次的 POST 核心
|
|
||||||
private fun authedPost(credential: Credential.WorkBuddyCredential, path: String): AuthedPostResult {
|
|
||||||
var access = credential.accessToken
|
|
||||||
var refresh = credential.refreshToken
|
|
||||||
repeat(2) { attempt ->
|
|
||||||
val request = Request.Builder()
|
|
||||||
.url("https://www.codebuddy.cn" + path)
|
|
||||||
.addHeader("Authorization", "Bearer " + access)
|
|
||||||
.addHeader("Accept", "application/json")
|
|
||||||
.addHeader("Content-Type", "application/json")
|
|
||||||
.post("{}".toRequestBody(jsonMedia))
|
|
||||||
.build()
|
|
||||||
// 2xx → Ok
|
|
||||||
// 401/403 且第一次尝试 → tryRefesh(refresh) 换新 token 后重试
|
|
||||||
// refresh 请求头携带 X-Refresh-Token <refreshToken>
|
|
||||||
// 轮换出的新凭据必须先落库再用,否则旧 refreshToken 单次消费后失效
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### WorkBuddy 签到结果判定
|
|
||||||
|
|
||||||
- `code = 0` → **SUCCESS**
|
|
||||||
- `code = 10001`,或文案含 `已签 / 已经签到 / 今日 / already / checked / claimed` → **ALREADY**(今日已签)
|
|
||||||
- 其余 → **FAILED**(HTTP 400 body 也可能携带签到业务语义,需透传解析,不能直接当网络错误丢弃)
|
|
||||||
|
|
||||||
## 四、通用约定
|
|
||||||
|
|
||||||
1. Trae 用 `Cloud-IDE-JWT`,WorkBuddy 用 `Bearer`,两者凭据类型不同,不能混用。
|
|
||||||
2. 两者签到都幂等:今日已签时服务端返回已签语义,客户端本地也会记录 `lastCheckinDate` 短路,避免重复请求。
|
|
||||||
3. 401/403:Trae 直接视为凭据失效;WorkBuddy 会尝试用 refreshToken 轮换一次。
|
|
||||||
4. 不要随机生成设备 ID(Trae)或篡改客户端头,否则会被风控(Trae 9074「参与用户太多」)。
|
|
||||||
@ -29,4 +29,3 @@ android.aapt2.process.daemon=false
|
|||||||
# KSP 2.x 在多文件 @Inject 跨文件依赖时偶发 "could not be resolved" 误报
|
# KSP 2.x 在多文件 @Inject 跨文件依赖时偶发 "could not be resolved" 误报
|
||||||
# 关闭 incremental 模式解决
|
# 关闭 incremental 模式解决
|
||||||
ksp.incremental=false
|
ksp.incremental=false
|
||||||
android.overridePathCheck=true
|
|
||||||
|
|||||||
@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"versionCode": 42,
|
|
||||||
"versionName": "1.7.22",
|
|
||||||
"apkUrl": "http://124.222.97.212:8443/attachments/03955cf3-112f-4453-bac7-91bba405e40b",
|
|
||||||
"notes": "Trae/WorkBuddy INTL 手机浏览器登录并自动回填;WorkBuddy CN 软件内登录;WorkBuddy INTL 余额走 workbuddy.ai;国际版无独立签到"
|
|
||||||
}
|
|
||||||