diff --git a/app/src/main/java/com/rainy/token/data/cache/BalanceCache.kt b/app/src/main/java/com/rainy/token/data/cache/BalanceCache.kt index 52247c1..986ee23 100644 --- a/app/src/main/java/com/rainy/token/data/cache/BalanceCache.kt +++ b/app/src/main/java/com/rainy/token/data/cache/BalanceCache.kt @@ -21,7 +21,11 @@ import kotlinx.serialization.json.Json * * 计划 7.1:断网时 UI 仍可展示 stale 数据。DataStore 文件名 `balance_cache`。 * - * 存储结构:JSON 序列化的 `Map`。 + * 存储结构: + * - `balance_cache_v3`:JSON 序列化的 `Map`,供单账号服务 + * (以及多账号服务的默认账号镜像,兼容旧读取路径)。 + * - `balance_cache_accounts_v1`:JSON 序列化的 `Map`,复合键 + * `"{service.storageKey}:{accountId}"`,存放多账号服务每个账号的独立缓存。 */ class BalanceCache( private val dataStore: DataStore, @@ -31,6 +35,9 @@ class BalanceCache( // v3: 2026-07 统一 Codex amount 为 usedPct(之前是 remainingPct),旧缓存失效 private val cacheKey = stringPreferencesKey("balance_cache_v3") + // v1: 2026-10 多账号服务按账号独立缓存 + private val accountsCacheKey = stringPreferencesKey("balance_cache_accounts_v1") + suspend fun getAll(): Map { val raw = dataStore.data.map { it[cacheKey] }.first() ?: return emptyMap() return decode(raw) @@ -38,18 +45,46 @@ class BalanceCache( suspend fun get(service: ServiceType): CachedBalance? = getAll()[service] - suspend fun put(service: ServiceType, balance: ServiceBalance) { + /** 读取缓存:无 [accountId] 时读旧按服务缓存;指定账号时读账号分片缓存。 */ + 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 { + 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 -> session.stageBalance(service, balance) return } - putCached( - service = service, - cachedBalance = CachedBalance( - balance = balance, - fetchedAt = System.currentTimeMillis() - ) - ) + val cached = CachedBalance(balance = balance, fetchedAt = System.currentTimeMillis()) + if (accountId.isNullOrBlank()) { + putCached(service, cached) + } else { + putAccountCached(service, accountId, cached) + } } /** 恢复一份已有缓存并保留原 fetchedAt;仅供凭据测试安全回滚使用。 */ @@ -61,8 +96,58 @@ class BalanceCache( } } - /** 删除单个服务缓存。凭据新增、替换或删除时由 CredentialRepository 调用。 */ + /** 以账号复合键写入缓存(保留原 fetchedAt)。 */ + 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) { + 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 -> val current = decode(prefs[cacheKey]) if (service !in current) return@edit @@ -76,10 +161,32 @@ class BalanceCache( } } - suspend fun clear() { - dataStore.edit { it.remove(cacheKey) } + /** 删除单个账号的缓存(默认账号的旧结构镜像由调用方另行清理)。 */ + 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() { + dataStore.edit { + it.remove(cacheKey) + it.remove(accountsCacheKey) + } + } + + private fun accountKey(service: ServiceType, accountId: String) = + "${service.storageKey}:$accountId" + private fun decode(raw: String?): Map { if (raw == null) return emptyMap() return runCatching { @@ -96,6 +203,22 @@ class BalanceCache( value ) + private fun decodeAccounts(raw: String?): Map { + if (raw == null) return emptyMap() + return runCatching { + json.decodeFromString( + MapSerializer(String.serializer(), CachedBalance.serializer()), + raw + ) + }.getOrDefault(emptyMap()) + } + + private fun encodeAccounts(value: Map): String = + json.encodeToString( + MapSerializer(String.serializer(), CachedBalance.serializer()), + value + ) + companion object { val DefaultJson = Json { ignoreUnknownKeys = true @@ -113,4 +236,4 @@ data class CachedBalance( /** 顶层 DataStore 委托。文件名对应计划 7.1。 */ val Context.balanceCacheDataStore: DataStore by preferencesDataStore( name = "balance_cache" -) +) \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/data/repository/CheckinStatusStore.kt b/app/src/main/java/com/rainy/token/data/repository/CheckinStatusStore.kt new file mode 100644 index 0000000..adaf2f5 --- /dev/null +++ b/app/src/main/java/com/rainy/token/data/repository/CheckinStatusStore.kt @@ -0,0 +1,61 @@ +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 + +/** + * 每日签到状态的统一本地记录(SharedPreferences "checkin_status")。 + * + * 手动签到与自动签到成功/已签后都会落一条 `service → 签到日期` 记录,供三处消费: + * - 服务详情页:进入时直接显示「今日已签到」,不再盲目展示可点按钮; + * - 首页卡片:Trae / WorkBuddy 各账号卡角标「今日已签到 ✓」; + * - 自动签到:当日已有记录的(默认)账号跳过,保证每日只签一次。 + * + * 多账号服务按账号分别记录(键 `last_checkin_{storageKey}:{accountId}`); + * 单账号服务沿用旧键 `last_checkin_{storageKey}`([accountId] 传 null)。 + * + * 与 TraeCredential.lastCheckinDate 的关系:后者是 Trae Repository 层的网络去重 + * 短路字段(仅 Trae 有);本存储是 UI/编排层的展示与调度记录,覆盖所有可签服务。 + */ +@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 = + lastCheckinDate(service, accountId) == LocalDate.now().toString() + + /** 最近一次签到日期(yyyy-MM-dd),无记录返回 null。 */ + fun lastCheckinDate(service: ServiceType, accountId: String? = null): String? { + val p = prefs() + val scoped = p.getString(key(service, accountId), null) + if (scoped != null) return scoped + // 仅旧版迁移来的 “default” 账号回退读旧版单账号键(其他账号没有旧记录,不能共用), + // 避免升级当日重复签到请求 + return if (accountId == CredentialRepository.LEGACY_DEFAULT_ACCOUNT_ID) { + p.getString("last_checkin_${service.storageKey}", null) + } else { + null + } + } + + /** 记录该服务(账号)今日已签到(SUCCESS / ALREADY 均调用)。 */ + fun markChecked(service: ServiceType, accountId: String? = null) { + prefs().edit().putString(key(service, accountId), LocalDate.now().toString()).apply() + } + + private fun key(service: ServiceType, accountId: String?): String = + if (accountId.isNullOrBlank()) { + "last_checkin_${service.storageKey}" + } else { + "last_checkin_${service.storageKey}:$accountId" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/data/repository/CredentialRepository.kt b/app/src/main/java/com/rainy/token/data/repository/CredentialRepository.kt index 8cff148..3fd4923 100644 --- a/app/src/main/java/com/rainy/token/data/repository/CredentialRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/CredentialRepository.kt @@ -3,11 +3,14 @@ package com.rainy.token.data.repository import com.rainy.token.data.cache.BalanceCache import com.rainy.token.data.cache.CachedBalance 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.CredentialStatus +import com.rainy.token.domain.model.ServiceAccountInfo import com.rainy.token.domain.service.ServiceConfigProvider import com.rainy.token.domain.service.ServiceType import java.security.MessageDigest +import java.util.UUID import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.currentCoroutineContext @@ -19,6 +22,12 @@ import kotlinx.coroutines.sync.withLock * * 所有凭据变更都经过 [mutationMutex] 串行化,并维护进程内 revision。网络刷新先取得 * [CredentialSnapshot];请求结束后只有快照仍为当前版本时,暂存的凭据与余额才会提交。 + * + * 多账号服务(Trae / WorkBuddy): + * - 账号元数据([ServiceAccountInfo])存 `accounts_{storageKey}`(加密 DataStore); + * - 每个账号的凭据独立存 `credential_{storageKey}_{accountId}`; + * - 旧版本单凭据存 `credential_{storageKey}`,首次读取时自动迁移为默认账号 + * (固定 id = [LEGACY_DEFAULT_ACCOUNT_ID]),旧键保留作为兼容回退。 */ @Singleton class CredentialRepository @Inject constructor( @@ -29,7 +38,9 @@ class CredentialRepository @Inject constructor( internal data class CredentialSnapshot( val credential: Credential, val revision: Long, - val fingerprint: String + val fingerprint: String, + /** 快照所属账号;单账号服务为 null。 */ + val accountId: String? = null ) { val service: ServiceType get() = credential.service } @@ -42,6 +53,16 @@ class CredentialRepository @Inject constructor( 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( val testedFingerprint: String, val previousIdentity: String?, @@ -49,69 +70,232 @@ class CredentialRepository @Inject constructor( ) private val mutationMutex = Mutex() - private val revisions = mutableMapOf() - private val cacheRollbackEntries = mutableMapOf() + private val revisions = mutableMapOf() + private val cacheRollbackEntries = mutableMapOf() - private fun keyFor(service: ServiceType): String = "credential_${service.storageKey}" + private fun accountDocKeyFor(service: ServiceType): String = "accounts_${service.storageKey}" - suspend fun save(credential: Credential) { + /** 凭据存储键:单账号(或兼容回退)为 `credential_{storageKey}`。 */ + 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 = + 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 -> session.stageCredential(credential) - return + return session.snapshot.accountId ?: "" } mutationMutex.withLock { val service = credential.service - val current = getUnlocked(service) + val effectiveId = if (ServiceType.isMultiAccount(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 newIdentity = cacheIdentityFingerprint(credential) val cacheIdentityChanged = currentIdentity != newIdentity - bumpRevision(service) + bumpRevision(service, effectiveId) if (cacheIdentityChanged) { - cacheRollbackEntries[service] = CacheRollbackEntry( + cacheRollbackEntries[key] = CacheRollbackEntry( testedFingerprint = credentialFingerprint(credential)!!, previousIdentity = currentIdentity, - cachedBalance = if (current != null) balanceCache.get(service) else null + cachedBalance = if (current != null) { + accountCacheFor(service, effectiveId) + } else { + null + } ) - balanceCache.remove(service) + removeCacheFor(service, effectiveId) } else { - cacheRollbackEntries.remove(service) + cacheRollbackEntries.remove(key) } - putUnlocked(credential) + putUnlocked(credential, effectiveId) + return effectiveId ?: "" } } - suspend fun get(service: ServiceType): Credential? { + suspend fun get(service: ServiceType, accountId: String? = null): Credential? { currentCoroutineContext()[RefreshWriteSession]?.let { session -> if (session.snapshot.service == service) { - return session.credentialForRead() + val matches = if (ServiceType.isMultiAccount(service)) { + val requested = accountId?.takeIf { it.isNotBlank() } + requested == null || + session.snapshot.accountId == null || + session.snapshot.accountId == requested + } else { + true + } + if (matches) return session.credentialForRead() } } - return mutationMutex.withLock { getUnlocked(service) } + return mutationMutex.withLock { getUnlocked(service, accountId) } } - internal suspend fun readLocalState(service: ServiceType): LocalState = + internal suspend fun readLocalState( + service: ServiceType, + accountId: String? = null + ): LocalState = mutationMutex.withLock { - val credential = getUnlocked(service) - localStateOf(service, credential, balanceCache.get(service)) + val credential = getUnlocked(service, accountId) + localStateOf(service, credential, accountCacheFor(service, accountId)) } + /** 全部服务的按账号本地状态(保留单账号服务旧语义,多账号展开一条/账号)。 */ + suspend fun readAccountStates(): List = + mutationMutex.withLock { readAccountStatesUnlocked() } + internal suspend fun readLocalStates(): Map = mutationMutex.withLock { - val cached = balanceCache.getAll() + val accountStates = readAccountStatesUnlocked() + val legacyCached = balanceCache.getAll() ServiceType.entries.associateWith { service -> - val credential = getUnlocked(service) - localStateOf(service, credential, cached[service]) + // 默认账号优先(多账号服务中自动签到/小组件使用的账号) + val entry = accountStates + .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) { mutationMutex.withLock { - bumpRevision(service) - cacheRollbackEntries.remove(service) + val accounts = if (ServiceType.isMultiAccount(service)) { + readAccountsUnlocked(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) - secureStorage.remove(keyFor(service)) } } @@ -124,12 +308,13 @@ class CredentialRepository @Inject constructor( previous: Credential? ): Boolean = mutationMutex.withLock { val service = testedSnapshot.service - val current = getUnlocked(service) ?: return@withLock false + val accountId = testedSnapshot.accountId + val current = getUnlocked(service, accountId) ?: return@withLock false if ( !snapshotMatches( snapshotRevision = testedSnapshot.revision, snapshotFingerprint = testedSnapshot.fingerprint, - currentRevision = revisionFor(service), + currentRevision = revisionFor(service, accountId), currentFingerprint = credentialFingerprint(current) ) ) return@withLock false @@ -137,35 +322,53 @@ class CredentialRepository @Inject constructor( val currentIdentity = cacheIdentityFingerprint(current) val previousIdentity = cacheIdentityFingerprint(previous) val identityChanged = currentIdentity != previousIdentity - val rollbackEntry = cacheRollbackEntries[service]?.takeIf { + val rollbackEntry = cacheRollbackEntries[revisionKey(service, accountId)]?.takeIf { it.testedFingerprint == testedSnapshot.fingerprint && it.previousIdentity == previousIdentity } - bumpRevision(service) + bumpRevision(service, accountId) if (identityChanged) { - balanceCache.remove(service) + removeCacheFor(service, accountId) } if (previous == null) { - secureStorage.remove(keyFor(service)) + secureStorage.remove(keyFor(service, accountId)) } else { require(previous.service == service) { "回滚凭据服务不匹配" } - putUnlocked(previous) + putUnlocked(previous, accountId) } if (identityChanged) { - rollbackEntry?.cachedBalance?.let { balanceCache.putCached(service, it) } + rollbackEntry?.cachedBalance?.let { + 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 } - internal suspend fun snapshot(service: ServiceType): CredentialSnapshot? = + internal suspend fun snapshot( + service: ServiceType, + accountId: String? = null + ): CredentialSnapshot? = mutationMutex.withLock { - val credential = getUnlocked(service) ?: return@withLock null + val resolvedId = if (ServiceType.isMultiAccount(service)) { + resolveAccountUnlocked(service, accountId)?.id + } else { + null + } + val credential = getUnlocked(service, resolvedId) ?: return@withLock null CredentialSnapshot( credential = credential, - revision = revisionFor(service), - fingerprint = credentialFingerprint(credential)!! + revision = revisionFor(service, resolvedId), + fingerprint = credentialFingerprint(credential)!!, + accountId = resolvedId ) } @@ -185,11 +388,11 @@ class CredentialRepository @Inject constructor( val hasPendingWrites = pendingCredential != null || pendingBalance != null val snapshot = session.snapshot - val current = getUnlocked(snapshot.service) ?: return@withLock false + val service = snapshot.service + val accountId = snapshot.accountId + val current = getUnlocked(service, accountId) ?: return@withLock false val currentFingerprint = credentialFingerprint(current) - // 一键激活等只读会话不应被同凭据刷新造成的 revision 变化误伤;但凭据真正 - // 替换/删除后,旧账户的成功响应和失败信息都必须丢弃。 if (!hasPendingWrites) { return@withLock currentFingerprint == snapshot.fingerprint } @@ -197,7 +400,7 @@ class CredentialRepository @Inject constructor( val snapshotStillCurrent = snapshotMatches( snapshotRevision = snapshot.revision, snapshotFingerprint = snapshot.fingerprint, - currentRevision = revisionFor(snapshot.service), + currentRevision = revisionFor(service, accountId), currentFingerprint = currentFingerprint ) val canMergeRotatedCredential = pendingCredential != null && @@ -213,22 +416,33 @@ class CredentialRepository @Inject constructor( cacheIdentityFingerprint(current) != cacheIdentityFingerprint(finalCredential) && !sameLineage - bumpRevision(snapshot.service) + bumpRevision(service, accountId) if (cacheIdentityChanged) { - balanceCache.remove(snapshot.service) + removeCacheFor(service, accountId) } if (pendingCredential != null) { - putUnlocked(pendingCredential) + putUnlocked(pendingCredential, accountId) } if (pendingBalance != null) { - balanceCache.put(snapshot.service, pendingBalance) - cacheRollbackEntries.remove(snapshot.service) + val cached = CachedBalance(balance = pendingBalance, fetchedAt = System.currentTimeMillis()) + if (accountId.isNullOrBlank()) { + 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 } - suspend fun statusFor(service: ServiceType): CredentialStatus { - val credential = get(service) ?: return CredentialStatus( + suspend fun statusFor( + service: ServiceType, + accountId: String? = null + ): CredentialStatus { + val credential = get(service, accountId) ?: return CredentialStatus( service = service, state = CredentialStatus.State.NOT_CONFIGURED, lastVerifiedAt = 0L @@ -246,6 +460,74 @@ class CredentialRepository @Inject constructor( suspend fun statusForAll(): List = ServiceConfigProvider.all().map { statusFor(it.type) } + // ───────────────────────────────────────────── + // 实现细节 + // ───────────────────────────────────────────── + + private suspend fun readAccountStatesUnlocked(): List { + 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( service: ServiceType, credential: Credential?, @@ -275,27 +557,162 @@ class CredentialRepository @Inject constructor( ) } - private suspend fun putUnlocked(credential: Credential) { + /** 读取账号元数据;多账号服务且文档不存在时尝试从旧单凭据迁移。 */ + private suspend fun readAccountsUnlocked(service: ServiceType): List { + 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 { + 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 + ) { secureStorage.put( - key = keyFor(credential.service), + key = accountDocKeyFor(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, serializer = Credential.serializer() ) } - private suspend fun getUnlocked(service: ServiceType): Credential? = - secureStorage.get( - key = keyFor(service), - serializer = Credential.serializer() - ) + /** 按账号读缓存:优先账号分片,默认账号回退旧结构镜像。 */ + private suspend fun accountCacheFor( + service: ServiceType, + accountId: String? + ): 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 bumpRevision(service: ServiceType) { - revisions[service] = revisionFor(service) + 1L + private fun clearRollbacksFor(service: ServiceType) { + val prefix = service.storageKey + ":" + 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 { + /** 旧单凭据迁移为默认账号时使用的固定账号 ID。 */ + const val LEGACY_DEFAULT_ACCOUNT_ID = "default" + internal fun determineCredentialState( lastVerifiedAt: Long, now: Long @@ -439,6 +856,10 @@ class CredentialRepository @Inject constructor( return sha256(material) } + /** 账号存储键(与 BalanceCache 的复合键格式一致,供测试断言)。 */ + internal fun accountCacheKeyFor(service: ServiceType, accountId: String): String = + "${service.storageKey}:$accountId" + private fun List.sortedForFingerprint() = sortedWith( compareBy( @@ -475,4 +896,4 @@ class CredentialRepository @Inject constructor( return String(chars) } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/data/repository/RefreshWriteSession.kt b/app/src/main/java/com/rainy/token/data/repository/RefreshWriteSession.kt index 8d51f18..0992060 100644 --- a/app/src/main/java/com/rainy/token/data/repository/RefreshWriteSession.kt +++ b/app/src/main/java/com/rainy/token/data/repository/RefreshWriteSession.kt @@ -19,6 +19,9 @@ internal class RefreshWriteSession( companion object Key : CoroutineContext.Key + /** 快照对应的账号 ID;单账号服务为 null。 */ + val accountId: String? get() = snapshot.accountId + private val stateLock = Any() private var pendingCredential: Credential? = null private var pendingBalance: ServiceBalance? = null diff --git a/app/src/main/java/com/rainy/token/data/repository/RepositoryError.kt b/app/src/main/java/com/rainy/token/data/repository/RepositoryError.kt index 08395d4..fa8419d 100644 --- a/app/src/main/java/com/rainy/token/data/repository/RepositoryError.kt +++ b/app/src/main/java/com/rainy/token/data/repository/RepositoryError.kt @@ -9,6 +9,9 @@ sealed class RepositoryError(message: String, cause: Throwable? = null) : Except class InvalidCredential(detail: String? = null, cause: Throwable? = null) : RepositoryError("凭据无效" + (detail?.let { ": $it" } ?: ""), cause) + /** 服务端 CheckLogin 确认登录态已失效(未登录或 JWT 过期),需重新登录 */ + class SessionExpired : RepositoryError("登录已过期,请重新登录") + /** 请求期间凭据被保存、替换或删除;旧结果已安全丢弃。 */ class CredentialChanged : RepositoryError("凭据已变更,本次请求结果已丢弃") diff --git a/app/src/main/java/com/rainy/token/data/repository/Sub2ApiRepository.kt b/app/src/main/java/com/rainy/token/data/repository/Sub2ApiRepository.kt index 22febfd..3746712 100644 --- a/app/src/main/java/com/rainy/token/data/repository/Sub2ApiRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/Sub2ApiRepository.kt @@ -64,8 +64,8 @@ class Sub2ApiRepository constructor( private val json = Json { ignoreUnknownKeys = true } private val jsonMedia = "application/json".toMediaType() - suspend fun fetchBalance(): Result = withContext(Dispatchers.IO) { - val credential = credentialRepository.get(ServiceType.SUB2API) + suspend fun fetchBalance(accountId: String? = null): Result = withContext(Dispatchers.IO) { + val credential = credentialRepository.get(ServiceType.SUB2API, accountId) ?: return@withContext Result.failure(RepositoryError.InvalidCredential()) if (credential !is Credential.Sub2ApiCredential) { return@withContext Result.failure(RepositoryError.InvalidCredential()) @@ -73,10 +73,14 @@ class Sub2ApiRepository constructor( val base = normalizeBase(credential.baseUrl) 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() } if (panelToken != null) { - return@withContext fetchPanelBalance(base, panelToken) + return@withContext withPanelAutoRefresh(accountId) { b, cred, token -> + fetchPanelBalance(b, token, cred, accountId) + } } val auth = resolveAuth(base, credential) ?: return@withContext Result.failure( @@ -125,8 +129,8 @@ class Sub2ApiRepository constructor( extras = stats.extras, sub2UsageDetail = parseUsageDetail(resp.body).takeIf { it.isNotEmpty } ) - balanceCache.put(ServiceType.SUB2API, balance) - credentialRepository.save(credential.copy(lastVerifiedAt = System.currentTimeMillis())) + balanceCache.put(ServiceType.SUB2API, balance, accountId) + credentialRepository.save(credential.copy(lastVerifiedAt = System.currentTimeMillis()), accountId) Result.success(balance) } @@ -258,7 +262,12 @@ class Sub2ApiRepository constructor( * - GET /api/v1/usage/dashboard/models?… → 全账号模型分布 * - GET /api/v1/usage/dashboard/snapshot-v2?granularity=day → 每日趋势(近30天) */ - private suspend fun fetchPanelBalance(base: String, token: String): Result { + private suspend fun fetchPanelBalance( + base: String, + token: String, + credential: Credential.Sub2ApiCredential, + accountId: String? = null + ): Result { val tz = java.net.URLEncoder.encode(TimeZone.getDefault().id, "UTF-8") val today = java.time.LocalDate.now() val monthStart = today.withDayOfMonth(1).toString() @@ -348,7 +357,15 @@ class Sub2ApiRepository constructor( ) // 面板模式提前 return 时也要写余额缓存,否则主页列表读不到(显示「待获取」)。 // 通过 RefreshWriteSession 机制暂存,随本次成功刷新一起提交。 - balanceCache.put(ServiceType.SUB2API, panelBalance) + balanceCache.put(ServiceType.SUB2API, panelBalance, accountId) + // 面板请求成功即证明凭据验证通过:同步更新 lastVerifiedAt(对齐非面板 fetchBalance 路径), + // 否则面板登录超过 7 天(token 仍有效)后详情页会把凭据误标为「未配置或已过期」。 + // 注意这里必须回写本次请求实际使用的 [token]:自动重登后 [token] 已是新值, + // 若沿用旧的 credential 会覆盖掉刚刷新的 token,导致下一个请求再次 401。 + credentialRepository.save( + credential.copy(authToken = token, lastVerifiedAt = System.currentTimeMillis()), + accountId + ) return Result.success(panelBalance) } @@ -359,17 +376,38 @@ class Sub2ApiRepository constructor( * 用于「今天」窗口——按天只有一根柱子没有信息量,改为按小时展示。 * 若面板版本不支持 hour 粒度(返回空/报错),返回空列表,由调用方回退到按天。 */ - suspend fun fetchHourlyTrend(date: LocalDate): Result> = withContext(Dispatchers.IO) { - val credential = credentialRepository.get(ServiceType.SUB2API) as? Credential.Sub2ApiCredential + suspend fun fetchHourlyTrend(date: LocalDate, accountId: String? = null): Result> = withContext(Dispatchers.IO) { + val credential = credentialRepository.get(ServiceType.SUB2API, accountId) as? Credential.Sub2ApiCredential ?: return@withContext Result.success(emptyList()) val base = normalizeBase(credential.baseUrl) if (base.isBlank()) return@withContext Result.success(emptyList()) val panelToken = credential.authToken?.trim().takeUnless { it.isNullOrEmpty() } - val auth = if (panelToken != null) { - AuthHeader("Authorization", "Bearer " + panelToken) - } else { - resolveAuth(base, credential) ?: return@withContext Result.success(emptyList()) + if (panelToken != null) { + // 面板模式:token 过期自动重登后重试;自动重登失败仍静默回退(小时趋势非主链路) + return@withContext withPanelAutoRefresh(accountId) panelTrendBlock@ { b, _, token -> + val auth = AuthHeader("Authorization", "Bearer " + token) + 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 resp = get( base, @@ -550,34 +588,35 @@ class Sub2ApiRepository constructor( data class PanelGroup(val id: Long, val name: String, val rateMultiplier: Double, val description: String = "") /** Key 列表。 */ - suspend fun fetchPanelKeys(): Result> = withContext(Dispatchers.IO) { - val (auth, base) = panelAuthOrNull() ?: return@withContext Result.failure(RepositoryError.InvalidCredential("请先登录面板(账号密码或网页登录)")) - val resp = get(base, "/api/v1/keys?page=1&page_size=100&sort_by=created_at&sort_order=desc&timezone=" + tzParam(), auth) - ?: return@withContext Result.failure(RepositoryError.Unknown(IllegalStateException("获取 Key 列表失败"))) - panelFailure(resp)?.let { return@withContext Result.failure(it) } - val body = resp.body - runCatching { - val root = json.parseToJsonElement(body).jsonObject - val data = root["data"]?.jsonObject ?: root - val items = data["items"] as? JsonArray ?: JsonArray(emptyList()) - items.mapNotNull { el -> - val o = el.jsonObject - PanelApiKey( - id = o.longOr("id") ?: 0L, - name = o.scalar("name") ?: "", - key = o.scalar("key") ?: "", - groupId = o["group_id"]?.jsonPrimitive?.contentOrNull?.toLongOrNull(), - status = o.scalar("status") ?: "", - lastUsedAt = o.scalar("last_used_at"), - createdAt = o.scalar("created_at"), - quota = o.numberOr("quota") ?: 0.0 - ) - } - }.fold( - onSuccess = { Result.success(it) }, - onFailure = { Result.failure(RepositoryError.ParseError(RepositoryError.ParseErrorReason.MALFORMED_RESPONSE, "面板响应解析失败")) } - ) - } + suspend fun fetchPanelKeys(accountId: String? = null): Result> = + withPanelAutoRefresh(accountId) panelKeysBlock@ { base, _, token -> + 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) + ?: return@panelKeysBlock Result.failure(RepositoryError.Unknown(IllegalStateException("获取 Key 列表失败"))) + panelFailure(resp)?.let { return@panelKeysBlock Result.failure(it) } + val body = resp.body + runCatching { + val root = json.parseToJsonElement(body).jsonObject + val data = root["data"]?.jsonObject ?: root + val items = data["items"] as? JsonArray ?: JsonArray(emptyList()) + items.mapNotNull { el -> + val o = el.jsonObject + PanelApiKey( + id = o.longOr("id") ?: 0L, + name = o.scalar("name") ?: "", + key = o.scalar("key") ?: "", + groupId = o["group_id"]?.jsonPrimitive?.contentOrNull?.toLongOrNull(), + status = o.scalar("status") ?: "", + lastUsedAt = o.scalar("last_used_at"), + createdAt = o.scalar("created_at"), + quota = o.numberOr("quota") ?: 0.0 + ) + } + }.fold( + onSuccess = { Result.success(it) }, + onFailure = { Result.failure(RepositoryError.ParseError(RepositoryError.ParseErrorReason.MALFORMED_RESPONSE, "面板响应解析失败")) } + ) + } /** * 按面板 Token / 用户 API Key 两种认证方式,拉取指定日期区间内的分模型统计 @@ -585,9 +624,9 @@ class Sub2ApiRepository constructor( * 面板模式:GET /api/v1/usage/dashboard/models?start_date=&end_date=… * Key 模式:GET /v1/usage?days=&start_date=&end_date=…(404/400 自动兜底 /api/v1/usage)。 */ - suspend fun fetchWindowModelStats(startDate: LocalDate, endDate: LocalDate): Result = + suspend fun fetchWindowModelStats(startDate: LocalDate, endDate: LocalDate, accountId: String? = null): Result = withContext(Dispatchers.IO) { - val credential = credentialRepository.get(ServiceType.SUB2API) as? Credential.Sub2ApiCredential + val credential = credentialRepository.get(ServiceType.SUB2API, accountId) as? Credential.Sub2ApiCredential ?: return@withContext Result.failure(RepositoryError.InvalidCredential("请先配置 Sub2API 凭据")) val base = normalizeBase(credential.baseUrl) if (base.isBlank()) return@withContext Result.failure(RepositoryError.InvalidCredential()) @@ -596,27 +635,32 @@ class Sub2ApiRepository constructor( val tz = tzParam() val panelToken = credential.authToken?.trim().takeUnless { it.isNullOrEmpty() } - val models: WindowModelStats = if (panelToken != null) { - val auth = AuthHeader("Authorization", "Bearer " + panelToken) - val resp = get(base, "/api/v1/usage/dashboard/models?start_date=$start&end_date=$end&model_source=requested&timezone=$tz", auth) - ?: return@withContext Result.failure(RepositoryError.Unknown(IllegalStateException("获取窗口模型统计失败"))) - panelFailure(resp)?.let { return@withContext Result.failure(it) } - val list = runCatching { parsePanelModels(resp.body) }.getOrElse { e -> - return@withContext Result.failure( - RepositoryError.ParseError(RepositoryError.ParseErrorReason.MALFORMED_RESPONSE, "面板响应解析失败: " + e.message) - ) + if (panelToken != null) { + // 面板模式:token 过期(401/403)自动重登换新 token 后重试一次 + return@withContext withPanelAutoRefresh(accountId) block@ { b, _, token -> + val auth = AuthHeader("Authorization", "Bearer " + token) + val resp = get(b, "/api/v1/usage/dashboard/models?start_date=$start&end_date=$end&model_source=requested&timezone=$tz", auth) + ?: return@block Result.failure(RepositoryError.Unknown(IllegalStateException("获取窗口模型统计失败"))) + panelFailure(resp)?.let { return@block Result.failure(it) } + val list = runCatching { parsePanelModels(resp.body) }.getOrElse { e -> + return@block Result.failure( + 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 { - val auth = resolveAuth(base, credential) - ?: return@withContext Result.failure(RepositoryError.InvalidCredential("Sub2API 认证失败,请检查 API Key")) - // 实测(xxcsn.site v0.2.1):下游 /v1/usage 的 model_stats 是「历史累计」口径, - // days / start_date / end_date 只影响 daily_usage,不影响 model_stats—— - // 所以直接用它无法回答「本周期用了哪些模型」。 - // 先用当前 sk-key 反查它在面板里的 id,再走带日期窗口的 dashboard/models - //(该端点支持 api_key_id 且窗口生效,实测 key 维度 today=0 / 7d=8 条)。 - val scoped = fetchKeyScopedWindowModelStats(base, panelToken, credential, start, end, tz) - if (scoped != null) return@withContext scoped + } + + // Key 模式:resolveAuth 每次请求都用邮箱+密码(或 apiKey)现取 token,不存在长期过期问题 + val auth = resolveAuth(base, credential) + ?: return@withContext Result.failure(RepositoryError.InvalidCredential("Sub2API 认证失败,请检查 API Key")) + // 实测(xxcsn.site v0.2.1):下游 /v1/usage 的 model_stats 是「历史累计」口径, + // days / start_date / end_date 只影响 daily_usage,不影响 model_stats—— + // 所以直接用它无法回答「本周期用了哪些模型」。 + // 先用当前 sk-key 反查它在面板里的 id,再走带日期窗口的 dashboard/models + //(该端点支持 api_key_id 且窗口生效,实测 key 维度 today=0 / 7d=8 条)。 + val scoped = fetchKeyScopedWindowModelStats(base, panelToken, credential, start, end, tz) + if (scoped != null) return@withContext scoped // 兜底:只能拿到历史累计口径,仍返回结果,但由调用方按「累计」展示,避免误导成周期数据。 val days = (ChronoUnit.DAYS.between(startDate, endDate) + 1).coerceIn(1L, 90L) @@ -635,10 +679,8 @@ class Sub2ApiRepository constructor( ) } // allTime=true:这是历史累计口径,UI 必须标注「累计」,不能冒充所选周期 - WindowModelStats(list.sortedByDescending { it.cost }, allTime = true) - } - Result.success(models) - } + Result.success(WindowModelStats(list.sortedByDescending { it.cost }, allTime = true)) + } /** * Key 模式下按日期窗口取分模型统计(实测 xxcsn.site v0.2.1)。 @@ -695,18 +737,22 @@ class Sub2ApiRepository constructor( * /api/v1/keys 上的 usage_1d/5h/7d 实测恒为 0),所以必须逐个查询, * 故用 coroutineScope + async 并行,避免 Key 多时串行等待过久。 */ - suspend fun fetchKeyUsageStats(startDate: LocalDate, endDate: LocalDate): Result> = - withContext(Dispatchers.IO) { - val (auth, base) = panelAuthOrNull() - ?: return@withContext Result.failure(RepositoryError.InvalidCredential("请先登录面板")) - val keys = fetchPanelKeys().getOrElse { return@withContext Result.failure(it) } - if (keys.isEmpty()) return@withContext Result.success(emptyList()) + suspend fun fetchKeyUsageStats(startDate: LocalDate, endDate: LocalDate, accountId: String? = null): Result> = + withPanelAutoRefresh(accountId) keyStatsBlock@ { base, _, token -> + // Key 列表由 fetchPanelKeys 获取(其内部同样带自动刷新,可能已换新 token) + val keys = fetchPanelKeys(accountId).getOrElse { return@keyStatsBlock Result.failure(it) } + if (keys.isEmpty()) return@keyStatsBlock Result.success(emptyList()) + // fetchPanelKeys 刷新成功后会落库新 token,这里重读一次最新凭据; + // 未刷新则沿用传入 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 end = endDate.toString() val tz = tzParam() // 分组名映射(展示「Key · 分组」),拉取失败不影响主流程 - val groups = runCatching { fetchPanelGroups().getOrNull().orEmpty() } + val groups = runCatching { fetchPanelGroups(accountId).getOrNull().orEmpty() } .getOrDefault(emptyList()) .associateBy { it.id } @@ -759,54 +805,59 @@ class Sub2ApiRepository constructor( * 兼容多种响应形态:data 为对象(key / api_key / sk_key / token 字段)、data 为字符串, * 并把网关错误信息(message / error)透传给 UI,避免「响应异常」掩盖真实原因。 */ - suspend fun createPanelKey(name: String, groupId: Long? = null): Result = withContext(Dispatchers.IO) { - val (auth, base) = panelAuthOrNull() ?: return@withContext Result.failure(RepositoryError.InvalidCredential("请先登录面板")) - val body = buildJsonObject { - put("name", name) - if (groupId != null) put("group_id", groupId) - }.toString() - val resp = post(base, "/api/v1/keys", auth, body) - ?: return@withContext Result.failure(RepositoryError.Unknown(IllegalStateException("创建 Key 失败(网络异常)"))) - runCatching { - val obj = json.parseToJsonElement(resp.body).jsonObject - // 业务码字段并集:code / status 任一存在即按其判定;再叠加 success 布尔 - val codeNum = obj.numberOr("code", "status") ?: 0.0 - val successFlag = obj["success"]?.jsonPrimitive?.contentOrNull?.toBooleanStrictOrNull() - if (resp.code !in 200..299 || codeNum != 0.0 || successFlag == false) { - val msg = obj.scalar("message", "error") ?: "HTTP " + resp.code - return@runCatching Result.failure(RepositoryError.Unknown(IllegalStateException(msg))) + suspend fun createPanelKey(name: String, groupId: Long? = null, accountId: String? = null): Result = + withPanelAutoRefresh(accountId) block@ { base, _, token -> + val auth = AuthHeader("Authorization", "Bearer " + token) + val body = buildJsonObject { + put("name", name) + if (groupId != null) put("group_id", groupId) + }.toString() + val resp = post(base, "/api/v1/keys", auth, body) + ?: return@block Result.failure(RepositoryError.Unknown(IllegalStateException("创建 Key 失败(网络异常)"))) + // HTTP 401/403 表示面板登录失效(自动重登后重试);其余按业务/网络错误透传 + if (resp.code == 401 || resp.code == 403) { + return@block Result.failure(RepositoryError.InvalidCredential("Sub2API 面板登录已失效,请重新网页登录")) } - val data = obj["data"] - val key = when (data) { - is JsonObject -> data.scalar("key", "api_key", "sk_key", "token") - is JsonPrimitive -> data.contentOrNull?.takeIf { it.isNotBlank() } - null -> obj.scalar("key", "api_key", "sk_key", "token") - else -> null + runCatching { + val obj = json.parseToJsonElement(resp.body).jsonObject + // 业务码字段并集:code / status 任一存在即按其判定;再叠加 success 布尔 + val codeNum = obj.numberOr("code", "status") ?: 0.0 + val successFlag = obj["success"]?.jsonPrimitive?.contentOrNull?.toBooleanStrictOrNull() + if (resp.code !in 200..299 || codeNum != 0.0 || successFlag == false) { + val msg = obj.scalar("message", "error") ?: "HTTP " + resp.code + return@runCatching Result.failure(RepositoryError.Unknown(IllegalStateException(msg))) + } + val data = obj["data"] + val key = when (data) { + is JsonObject -> data.scalar("key", "api_key", "sk_key", "token") + is JsonPrimitive -> data.contentOrNull?.takeIf { it.isNotBlank() } + null -> obj.scalar("key", "api_key", "sk_key", "token") + else -> null + } + if (key != null) Result.success(key) + else { + // 不丢信息:把响应片段带给用户/日志,方便排查面板响应形态 + Result.failure( + RepositoryError.Unknown(IllegalStateException("创建成功但未在响应中找到 Key:" + resp.body.take(120))) + ) + } + }.getOrElse { e -> + Result.failure(RepositoryError.ParseError(RepositoryError.ParseErrorReason.MALFORMED_RESPONSE, "创建 Key 响应解析失败: " + e.message)) } - if (key != null) Result.success(key) - else { - // 不丢信息:把响应片段带给用户/日志,方便排查面板响应形态 - Result.failure( - RepositoryError.Unknown(IllegalStateException("创建成功但未在响应中找到 Key:" + resp.body.take(120))) - ) - } - }.getOrElse { e -> - Result.failure(RepositoryError.ParseError(RepositoryError.ParseErrorReason.MALFORMED_RESPONSE, "创建 Key 响应解析失败: " + e.message)) } - } /** 修改 Key(切换分组 / 增删黑白名单 / 额度等):PUT /api/v1/keys/{id}。 */ - suspend fun updatePanelKey(id: Long, groupId: Long? = null, patch: Map = emptyMap()): Result = - withContext(Dispatchers.IO) { - val (auth, base) = panelAuthOrNull() ?: return@withContext Result.failure(RepositoryError.InvalidCredential("请先登录面板")) + suspend fun updatePanelKey(id: Long, groupId: Long? = null, patch: Map = emptyMap(), accountId: String? = null): Result = + withPanelAutoRefresh(accountId) block@ { base, _, token -> + val auth = AuthHeader("Authorization", "Bearer " + token) val body = buildJsonObject { if (groupId != null) put("group_id", groupId) patch.forEach { (k, v) -> put(k, v) } }.toString() val resp = put(base, "/api/v1/keys/" + id, auth, body) - ?: return@withContext Result.failure(RepositoryError.Unknown(IllegalStateException("修改 Key 失败"))) - panelFailure(resp)?.let { return@withContext Result.failure(it) } - return@withContext runCatching { + ?: return@block Result.failure(RepositoryError.Unknown(IllegalStateException("修改 Key 失败"))) + panelFailure(resp)?.let { return@block Result.failure(it) } + return@block runCatching { val obj = json.parseToJsonElement(resp.body).jsonObject // 兼容 {code:0} 与 {success:true} 两种面板响应形态 val ok = obj.numberOr("code", "status")?.let { it == 0.0 } @@ -826,119 +877,174 @@ class Sub2ApiRepository constructor( * 面板返回形态不统一({code:0} / {success:true} / 204 空体),因此判定顺序为: * HTTP 2xx 且没有业务错误码即视为成功;解析不出 JSON 也不当作失败(部分版本返回空体)。 */ - suspend fun deletePanelKey(id: Long): Result = withContext(Dispatchers.IO) { - val (auth, base) = panelAuthOrNull() - ?: return@withContext Result.failure(RepositoryError.InvalidCredential("请先登录面板")) - val resp = delete(base, "/api/v1/keys/" + id, auth) - ?: return@withContext Result.failure(RepositoryError.Unknown(IllegalStateException("删除 Key 失败(网络异常)"))) - if (resp.code !in 200..299) { - return@withContext Result.failure(mapSubHttpError(resp.code)) + suspend fun deletePanelKey(id: Long, accountId: String? = null): Result = + withPanelAutoRefresh(accountId) block@ { base, _, token -> + val auth = AuthHeader("Authorization", "Bearer " + token) + val resp = delete(base, "/api/v1/keys/" + id, auth) + ?: return@block Result.failure(RepositoryError.Unknown(IllegalStateException("删除 Key 失败(网络异常)"))) + if (resp.code !in 200..299) { + return@block Result.failure(mapSubHttpError(resp.code)) + } + // 2xx 但体里带业务错误码(部分面板用 200 + code!=0 表达失败) + val bizError = runCatching { + val obj = json.parseToJsonElement(resp.body).jsonObject + val codeNum = obj.numberOr("code", "status") + val successFlag = obj["success"]?.jsonPrimitive?.contentOrNull?.toBooleanStrictOrNull() + if (codeNum != null && codeNum != 0.0) obj.scalar("message", "error") ?: "删除 Key 失败" + else if (successFlag == false) obj.scalar("message", "error") ?: "删除 Key 失败" + else null + }.getOrNull() + if (bizError != null) { + Result.failure(RepositoryError.Unknown(IllegalStateException(bizError))) + } else { + Result.success(true) + } } - // 2xx 但体里带业务错误码(部分面板用 200 + code!=0 表达失败) - val bizError = runCatching { - val obj = json.parseToJsonElement(resp.body).jsonObject - val codeNum = obj.numberOr("code", "status") - val successFlag = obj["success"]?.jsonPrimitive?.contentOrNull?.toBooleanStrictOrNull() - if (codeNum != null && codeNum != 0.0) obj.scalar("message", "error") ?: "删除 Key 失败" - else if (successFlag == false) obj.scalar("message", "error") ?: "删除 Key 失败" - else null - }.getOrNull() - if (bizError != null) { - Result.failure(RepositoryError.Unknown(IllegalStateException(bizError))) - } else { - Result.success(true) - } - } /** 可用分组列表(切换分组选择器)。 */ - suspend fun fetchPanelGroups(): Result> = withContext(Dispatchers.IO) { - val (auth, base) = panelAuthOrNull() ?: return@withContext Result.failure(RepositoryError.InvalidCredential("请先登录面板")) - val resp = get(base, "/api/v1/groups/available", auth) - ?: return@withContext Result.failure(RepositoryError.Unknown(IllegalStateException("获取分组失败"))) - panelFailure(resp)?.let { return@withContext Result.failure(it) } - val body = resp.body - runCatching { - val root = json.parseToJsonElement(body).jsonObject - (root["data"] as? JsonArray ?: JsonArray(emptyList())).mapNotNull { el -> - val o = el.jsonObject - PanelGroup( - id = o.longOr("id") ?: 0L, - name = o.scalar("name") ?: "", - rateMultiplier = o.numberOr("rate_multiplier") ?: 1.0, - description = o.scalar("description") ?: "" - ) - } - }.fold( - onSuccess = { Result.success(it) }, - onFailure = { Result.failure(RepositoryError.ParseError(RepositoryError.ParseErrorReason.MALFORMED_RESPONSE, "面板响应解析失败")) } - ) - } + suspend fun fetchPanelGroups(accountId: String? = null): Result> = + withPanelAutoRefresh(accountId) panelGroupsBlock@ { base, _, token -> + val auth = AuthHeader("Authorization", "Bearer " + token) + val resp = get(base, "/api/v1/groups/available", auth) + ?: return@panelGroupsBlock Result.failure(RepositoryError.Unknown(IllegalStateException("获取分组失败"))) + panelFailure(resp)?.let { return@panelGroupsBlock Result.failure(it) } + val body = resp.body + runCatching { + val root = json.parseToJsonElement(body).jsonObject + (root["data"] as? JsonArray ?: JsonArray(emptyList())).mapNotNull { el -> + val o = el.jsonObject + PanelGroup( + id = o.longOr("id") ?: 0L, + name = o.scalar("name") ?: "", + rateMultiplier = o.numberOr("rate_multiplier") ?: 1.0, + description = o.scalar("description") ?: "" + ) + } + }.fold( + onSuccess = { Result.success(it) }, + onFailure = { Result.failure(RepositoryError.ParseError(RepositoryError.ParseErrorReason.MALFORMED_RESPONSE, "面板响应解析失败")) } + ) + } /** 兑换卡密:POST /api/v1/redeem {code} → code=0 或业务错误(REDEEM_*)。 */ - suspend fun redeemPanelCode(code: String): Result = withContext(Dispatchers.IO) { - val (auth, base) = panelAuthOrNull() ?: return@withContext Result.failure(RepositoryError.InvalidCredential("请先登录面板")) - val body = buildJsonObject { put("code", code.trim()) }.toString() - val resp = post(base, "/api/v1/redeem", auth, body) - ?: return@withContext Result.failure(RepositoryError.Unknown(IllegalStateException("兑换失败"))) - // 兑换失败时面板用 HTTP 404/400 + {code,message,reason} 表达业务错误 - // (实测:code=404, message="redeem code not found", reason=REDEEM_CODE_NOT_FOUND)。 - // 因此这里必须先解析 body 再判定状态,否则会把「兑换码不存在」等具体原因吞成通用 HTTP 错误。 - val parsed = runCatching { json.parseToJsonElement(resp.body).jsonObject }.getOrNull() - ?: return@withContext Result.failure( - RepositoryError.ParseError(RepositoryError.ParseErrorReason.MALFORMED_RESPONSE, "兑换响应解析失败(HTTP " + resp.code + ")") - ) - val codeNum = parsed.numberOr("code") - val successFlag = parsed["success"]?.jsonPrimitive?.contentOrNull?.toBooleanStrictOrNull() - val msg = parsed.scalar("message", "error", "reason") ?: "" - val ok = resp.code in 200..299 && when { - codeNum != null -> codeNum == 0.0 - successFlag != null -> successFlag - // 既无 code 也无 success:响应里能解析出 data 即视为成功 - else -> parsed["data"] != null - } - if (!ok) { - // 面板用英文 message + 机器可读 reason 表达业务错误(实测: - // 404 REDEEM_CODE_NOT_FOUND / 409 REDEEM_CODE_USED)。 - // 中文用户看不懂英文原文,这里把已确认的 reason 译成中文,未知 reason 原样透传便于排查。 - val reason = parsed.scalar("reason") - val localized = when (reason) { - "REDEEM_CODE_NOT_FOUND" -> "兑换码不存在,请检查是否输入正确" - "REDEEM_CODE_USED" -> "该兑换码已被使用" - "REDEEM_CODE_EXPIRED" -> "该兑换码已过期" - "REDEEM_CODE_DISABLED" -> "该兑换码已被禁用" - else -> null + suspend fun redeemPanelCode(code: String, accountId: String? = null): Result = + withPanelAutoRefresh(accountId) block@ { base, _, token -> + val auth = AuthHeader("Authorization", "Bearer " + token) + val body = buildJsonObject { put("code", code.trim()) }.toString() + val resp = post(base, "/api/v1/redeem", auth, body) + ?: return@block Result.failure(RepositoryError.Unknown(IllegalStateException("兑换失败"))) + // 兑换失败时面板用 HTTP 404/400 + {code,message,reason} 表达业务错误 + // (实测:code=404, message="redeem code not found", reason=REDEEM_CODE_NOT_FOUND)。 + // 因此这里必须先解析 body 再判定状态,否则会把「兑换码不存在」等具体原因吞成通用 HTTP 错误。 + val parsed = runCatching { json.parseToJsonElement(resp.body).jsonObject }.getOrNull() + ?: return@block Result.failure( + RepositoryError.ParseError(RepositoryError.ParseErrorReason.MALFORMED_RESPONSE, "兑换响应解析失败(HTTP " + resp.code + ")") + ) + val codeNum = parsed.numberOr("code") + val successFlag = parsed["success"]?.jsonPrimitive?.contentOrNull?.toBooleanStrictOrNull() + val msg = parsed.scalar("message", "error", "reason") ?: "" + val ok = resp.code in 200..299 && when { + codeNum != null -> codeNum == 0.0 + successFlag != null -> successFlag + // 既无 code 也无 success:响应里能解析出 data 即视为成功 + else -> parsed["data"] != null } - // 业务错误优先用面板文案;凭据失效仍按 401/403 归类,便于 UI 区分「去登录」 - val text = localized ?: msg.ifBlank { "兑换失败(HTTP " + resp.code + ")" } - val business = RepositoryError.Unknown(IllegalStateException(text)) - val failure = when (resp.code) { - 401, 403 -> RepositoryError.InvalidCredential("Sub2API 面板登录已失效,请重新网页登录") - else -> business + if (!ok) { + // 面板用英文 message + 机器可读 reason 表达业务错误(实测: + // 404 REDEEM_CODE_NOT_FOUND / 409 REDEEM_CODE_USED)。 + // 中文用户看不懂英文原文,这里把已确认的 reason 译成中文,未知 reason 原样透传便于排查。 + val reason = parsed.scalar("reason") + val localized = when (reason) { + "REDEEM_CODE_NOT_FOUND" -> "兑换码不存在,请检查是否输入正确" + "REDEEM_CODE_USED" -> "该兑换码已被使用" + "REDEEM_CODE_EXPIRED" -> "该兑换码已过期" + "REDEEM_CODE_DISABLED" -> "该兑换码已被禁用" + else -> null + } + // 业务错误优先用面板文案;凭据失效仍按 401/403 归类,便于 UI 区分「去登录」 + val text = localized ?: msg.ifBlank { "兑换失败(HTTP " + resp.code + ")" } + val business = RepositoryError.Unknown(IllegalStateException(text)) + val failure = when (resp.code) { + 401, 403 -> RepositoryError.InvalidCredential("Sub2API 面板登录已失效,请重新网页登录") + else -> business + } + return@block Result.failure(failure) } - return@withContext Result.failure(failure) + // 实测成功响应:{"code":0,"data":{"id":57,"type":"balance","value":50,"status":"used",...}} + // 注意 value 是数字(50)而非字符串,必须走 numberOr,否则金额显示不出来。 + // data 期望是对象;若某实例返回字符串/数字,jsonObject 会抛异常中断协程,这里降级为「无金额」文案 + val data = parsed["data"] as? JsonObject + val amount = data?.numberOr("value") + val typeSuffix = when (data?.scalar("type")) { + "balance" -> " 余额" + "subscription" -> " 订阅" + else -> "" + } + val amountText = amount?.let { "(+" + trimNum(it) + typeSuffix + ")" } ?: "" + Result.success("兑换成功" + amountText) } - // 实测成功响应:{"code":0,"data":{"id":57,"type":"balance","value":50,"status":"used",...}} - // 注意 value 是数字(50)而非字符串,必须走 numberOr,否则金额显示不出来。 - // data 期望是对象;若某实例返回字符串/数字,jsonObject 会抛异常中断协程,这里降级为「无金额」文案 - val data = parsed["data"] as? JsonObject - val amount = data?.numberOr("value") - val typeSuffix = when (data?.scalar("type")) { - "balance" -> " 余额" - "subscription" -> " 订阅" - else -> "" + + /** + * 面板操作统一包装:为指定账号读取凭据,用 authToken 执行 [block]; + * + * 自动刷新语义: + * - 首次请求返回 [RepositoryError.InvalidCredential](HTTP 401/403 = token 过期/登录失效)时, + * 用账号的邮箱+密码自动重新登录换取新 token,保存到该账号后【重试一次】; + * - 自动重登失败(邮箱/密码已变、面板关闭账号密码登录、网络异常)→ 返回原错误, + * 由 UI 引导用户真正去手动登录; + * - 重试后仍失败 → 返回第二次请求的错误。 + * + * 多账号注意:accountId=null 语义是「默认账号」(不是新建)。回写刷新后的 token 前 + * 必须先解析出真实账号 ID,否则 [CredentialRepository.save] 对多账号服务会把 + * null accountId 当成「新建账号」,造成每次过期重登都新建一个重复账号。 + */ + private suspend fun withPanelAutoRefresh( + accountId: String?, + block: suspend (base: String, credential: Credential.Sub2ApiCredential, token: String) -> Result + ): Result = withContext(Dispatchers.IO) { + val targetAccountId = if (accountId.isNullOrBlank()) { + credentialRepository.getDefaultAccount(ServiceType.SUB2API)?.id + } else { + accountId } - val amountText = amount?.let { "(+" + trimNum(it) + typeSuffix + ")" } ?: "" - Result.success("兑换成功" + amountText) + val credential = credentialRepository.get(ServiceType.SUB2API, targetAccountId) as? Credential.Sub2ApiCredential + ?: return@withContext Result.failure(RepositoryError.InvalidCredential("请先配置 Sub2API 凭据")) + val base = normalizeBase(credential.baseUrl) + if (base.isBlank()) return@withContext Result.failure(RepositoryError.InvalidCredential()) + val initialToken = credential.authToken?.trim()?.takeIf { it.isNotEmpty() } + ?: 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 自动登录失败,请手动重新登录")) } - /** 面板凭证获取(token 非空时);返回 (auth, base) 或 null。 */ - private suspend fun panelAuthOrNull(): Pair? { - val credential = credentialRepository.get(ServiceType.SUB2API) as? Credential.Sub2ApiCredential ?: return null - val token = credential.authToken?.trim().takeUnless { it.isNullOrEmpty() } ?: return null - val base = normalizeBase(credential.baseUrl) - if (base.isBlank()) return null - return AuthHeader("Authorization", "Bearer " + token) to base - } + /** 按账号的面板刷新互斥锁(避免并发 401 时多次 loginPanel 互相作废 token)。 */ + private val panelRefreshLocks = java.util.concurrent.ConcurrentHashMap() private fun tzParam(): String = java.net.URLEncoder.encode(TimeZone.getDefault().id, "UTF-8") diff --git a/app/src/main/java/com/rainy/token/data/repository/TraeRepository.kt b/app/src/main/java/com/rainy/token/data/repository/TraeRepository.kt index 973ef7e..99c9187 100644 --- a/app/src/main/java/com/rainy/token/data/repository/TraeRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/TraeRepository.kt @@ -1,6 +1,7 @@ package com.rainy.token.data.repository import com.rainy.token.data.cache.BalanceCache +import com.rainy.token.data.debug.DebugLog import com.rainy.token.domain.model.Credential import com.rainy.token.domain.model.ServiceBalance import com.rainy.token.domain.service.ServiceType @@ -12,11 +13,13 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.doubleOrNull import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request @@ -55,8 +58,8 @@ class TraeRepository constructor( private val json = Json { ignoreUnknownKeys = true } - suspend fun fetchBalance(): Result = withContext(Dispatchers.IO) { - val credential = credentialRepository.get(ServiceType.TRAE) + suspend fun fetchBalance(accountId: String? = null): Result = 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()) @@ -77,8 +80,15 @@ class TraeRepository constructor( val body = try { okHttpClient.newCall(request).execute().use { resp -> val text = resp.body?.string().orEmpty() + DebugLog.i(TAG, "fetchBalance: http=${resp.code} body=${text.take(300)}") if (!resp.isSuccessful) { - return@withContext Result.failure(mapHttpError(resp.code)) + return@withContext Result.failure( + if (resp.code == 401 || resp.code == 403) { + authErrorFor(credential.jwt.trim()) + } else { + mapHttpError(resp.code) + } + ) } text } @@ -102,8 +112,8 @@ class TraeRepository constructor( totalQuota = parsed.totalQuota, extras = parsed.extras ) - balanceCache.put(ServiceType.TRAE, balance) - credentialRepository.save(credential.copy(lastVerifiedAt = System.currentTimeMillis())) + balanceCache.put(ServiceType.TRAE, balance, accountId) + credentialRepository.save(credential.copy(lastVerifiedAt = System.currentTimeMillis()), accountId) Result.success(balance) } @@ -127,7 +137,12 @@ class TraeRepository constructor( } // 2) 权益包明细(前若干条 + 计数),同时累计兜底求和 + // 用固定前缀键 entitlement.$i.* 输出,供 TraePacksCard 拆开渲染(每包的 + // 总额/已用/剩余)。旧「中文名.limit/.used」键形式存在歧义且被 + // GenericExtrasCard 的过滤规则吞掉,导致权益明细从未展示(2026-10 用户反馈)。 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 packCount = 0 val packs = root["user_entitlement_pack_list"]?.jsonArray @@ -135,26 +150,47 @@ class TraeRepository constructor( val pack = packEl.jsonObject val base = pack["entitlement_base_info"]?.jsonObject val quota = base?.get("quota")?.jsonObject - val name = pack.getString("display_desc") ?: base?.getString("name") ?: "pack[$index]" + val name = pack.getString("display_desc") + ?: 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 used = pack.getNumber("usage", "credits_amount") ?: 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++ when { limit == null -> { /* 结构变化:跳过 */ } - limit < 0 -> extras["$name.unlimited"] = "true" + limit < 0 -> { + extras["entitlement.$index.name"] = name + extras["entitlement.$index.unlimited"] = "true" + extras["entitlement.$index.endpoint"] = endpointKey + } else -> { val remaining = (limit - (used ?: 0.0)).coerceAtLeast(0.0) fallbackSum += remaining + if (isWork) workSum += remaining else generalSum += remaining if (packShown < 8) { - extras["$name.limit"] = limit.toString() - extras["$name.used"] = used?.let { trimNum(it) } ?: "?" + extras["entitlement.$index.name"] = name + extras["entitlement.$index.limit"] = limit.toString() + extras["entitlement.$index.used"] = used?.let { trimNum(it) } ?: "0" + extras["entitlement.$index.remaining"] = trimNum(remaining) + extras["entitlement.$index.endpoint"] = endpointKey packShown++ } } } } - if (packCount > 0) extras["pack_count"] = packCount.toString() + if (packCount > 0) { + extras["pack_count"] = packCount.toString() + extras["general_credits"] = trimNum(generalSum) + extras["work_credits"] = trimNum(workSum) + } // 3) 金额:汇总优先,缺省回退包级 val amount = if (total != null && consumed != null) { @@ -195,6 +231,39 @@ class TraeRepository constructor( // ---------- 每日签到(checkin_credits)---------- + /** + * 轻量查询「今日是否已签到」(只读,不发起 claim)。 + * + * 供详情页进入时预取状态用:先查本地记录(credential.lastCheckinDate / + * [CheckinStatusStore]),未命中再调 status 接口(服务端权威,跨设备已签也能识别)。 + * 返回 true=今日已签;false=未签/活动未开放;失败=网络/协议异常(调用方按未知处理)。 + */ + suspend fun fetchCheckinStatus(accountId: String? = null): Result = 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 中文串)。 */ enum class CheckinOutcome { /** 签到成功 */ @@ -203,6 +272,8 @@ class TraeRepository constructor( ALREADY, /** 设备级拦截(提示而非报错) */ DEVICE_BLOCKED, + /** 服务端临时限流(如「参与用户太多,请稍后重试」):提示而非报错,可稍后重试 */ + RETRYABLE, /** INTL 区签到接口未实测,暂不支持 */ INTL_UNSUPPORTED, /** 其他失败(服务端给了原因/错误码) */ @@ -224,16 +295,19 @@ class TraeRepository constructor( ) /** - * 执行 Trae 每日签到(CN 区)。 - * 接口:POST https://api.trae.cn/trae/api/v2/ug/checkin_credits/claim body {}。 - * 头:Cloud-IDE-JWT + x-device-id(claim 按设备做每日去重——用 JWT 派生的稳定 - * 伪 deviceId,同账号稳定、不同账号不同,避免共享 device-id 被拦)。 - * 语义:code 0/200 成功;code 1001 或消息含「已签到/已领取/already」=账号已签; - * 消息含「设备/device」=设备级拦截(提示而非报错)。 + * 执行 Trae 每日签到(CN 区),流程对齐社区实测工具(trae-daily-checkin / + * trae-checkin-helper 逆向口径): + * 1. POST /trae/api/v2/ug/checkin_credits/status(body {})查状态: + * - enable=false → 签到功能暂未开放(中性提示,非错误) + * - checked_in=true → 今日已签(幂等短路,不重复 claim) + * 2. POST /trae/api/v2/ug/checkin_credits/claim(body {})执行签到: + * code 0/200 成功;code 10001 或文案含「已签到/already」=已签。 + * 头:Cloud-IDE-JWT + x-device-id(登录会话绑定的 App 持久化设备 ID; + * 不可用 userJwt.ClientID——固定客户端标识,所有用户相同,会被按设备风控拒绝)。 * INTL 区签到接口未实测,暂不支持(返回明确提示)。 */ - suspend fun checkIn(): Result = withContext(Dispatchers.IO) { - val credential = credentialRepository.get(ServiceType.TRAE) + suspend fun checkIn(accountId: String? = null): Result = 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()) @@ -250,49 +324,197 @@ class TraeRepository constructor( ) ) } - val request = Request.Builder() - .url("https://api.trae.cn/trae/api/v2/ug/checkin_credits/claim") - .addHeader("Authorization", "Cloud-IDE-JWT " + credential.jwt.trim()) - .addHeader("Content-Type", "application/json") - .addHeader("x-device-id", stableDeviceId(credential.jwt.trim())) - .post("{}".toRequestBody("application/json".toMediaType())) - .build() - - val body = try { - okHttpClient.newCall(request).execute().use { resp -> - val text = resp.body?.string().orEmpty() - if (resp.code == 401 || resp.code == 403) { - return@withContext Result.failure(RepositoryError.InvalidCredential()) - } - if (!resp.isSuccessful && resp.code != 400) { - return@withContext Result.failure(mapHttpError(resp.code)) - } - // 400 也可能是业务失败(已签/设备拦截),交给 body 判断 - text - } - } catch (e: IOException) { - return@withContext Result.failure(RepositoryError.Network(e)) - } catch (e: Throwable) { - return@withContext Result.failure(RepositoryError.Unknown(e)) + val jwt = credential.jwt.trim() + // 本地签到状态短路:今天已成功签到则不再发起任何网络请求(防重复签到/重复 claim) + val today = java.time.LocalDate.now().toString() + if (credential.lastCheckinDate == today) { + DebugLog.i(TAG, "checkin: 本地已记录今日签到,短路跳过网络请求") + return@withContext Result.success( + CheckinResult( + 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 判定仍权威 - // 仅「凭据被服务端接受」时才算已验证:SUCCESS/ALREADY/DEVICE_BLOCKED 都说明 - // JWT 有效(401/403 已在上面提前返回);FAILED(含 HTTP 400 业务失败)不标记, - // 避免把无效凭据误标为已验证。 - val parsed = parseCheckin(body) + // Step 2:执行签到。claim body 按官方契约探测(对齐 cpa-multi-plugins v0.12.41/43: + // 09-04 上游收紧活动校验后,body 必须与 token 谱系匹配;官方契约空 {} 实测到账, + // SOLO/TraeWork 官方同款 req_source=2,TraeCode 用 req_source=1;9074 时顺次换 body)。 + val claimBodies = listOf("{}", """{"req_source":2}""", """{"req_source":1}""") + var claimResult: Result? = null + 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()) + claimResult = parsed + // 9074 已解析为 RETRYABLE;换 body 再试,其它语义(成功/已签/设备拦截/硬错误)直接定稿 + if (parsed.getOrNull()?.outcome != CheckinOutcome.RETRYABLE) break + } + val parsed = claimResult + ?: return@withContext Result.failure(RepositoryError.Unknown()) + // 仅「凭据被服务端接受」时才算已验证:SUCCESS/ALREADY/DEVICE_BLOCKED/RETRYABLE 都说明 + // JWT 有效(401/403 已在 checkinRequest 提前失败);FAILED(含 HTTP 400 业务失败)不标记, + // 避免把无效凭据误标为已验证。SUCCESS/ALREADY 同时落本地签到日期(防当日重复签到)。 val outcome = parsed.getOrNull()?.outcome if (outcome != null && outcome != CheckinOutcome.FAILED) { - credentialRepository.save(credential.copy(lastVerifiedAt = System.currentTimeMillis())) + val checkedToday = + outcome == CheckinOutcome.SUCCESS || outcome == CheckinOutcome.ALREADY + credentialRepository.save( + credential.copy( + lastVerifiedAt = System.currentTimeMillis(), + lastCheckinDate = if (checkedToday) today else credential.lastCheckinDate + ), + accountId + ) } parsed } - /** 由 JWT 派生稳定伪 deviceId:同账号恒定、不同账号不同(防 claim 设备级去重误伤)。 */ - private fun stableDeviceId(seed: String): String = runCatching { - val digest = java.security.MessageDigest.getInstance("SHA-256") - .digest(("rainytoken:" + seed).toByteArray()) - digest.take(12).joinToString("") { "%02x".format(it) } - }.getOrDefault("rainytoken-device") + /** + * 签到系 POST(status/claim),请求头逐头对齐 cpa-multi-plugins v0.12.45 + * 抓包指纹(2026-09-03 成功签到请求复刻,用户账号实测:旧头多日 9074, + * 新头当日 claim code=0): + * - UA 必须是 VSCode 插件进程身份 "VSCode 1.107.1 (TRAE SOLO CN)"——签到走 + * VSCode 插件进程,与 IDE 主进程 Trae/0.1.x / web 均不同,混用身份 = 风控画像对不上; + * - X-User-Region / X-Request-Id / X-TT-Trace-Id / Package-Type / App-Version + * 等均随真实客户端;Authorization: Cloud-IDE-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 { + 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) { 401, 403 -> RepositoryError.InvalidCredential() @@ -301,6 +523,44 @@ class TraeRepository constructor( 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 与小数点)。 * 注意不能用 v.toString().trimEnd('0','.'):trimEnd 会连续剥离集合内任意字符, @@ -316,6 +576,20 @@ class TraeRepository constructor( ) 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 可静态调用/单测)。 */ private val parseJson = Json { ignoreUnknownKeys = true } @@ -331,19 +605,43 @@ class TraeRepository constructor( */ internal fun parseCheckin(body: String): Result = runCatching { val root = parseJson.parseToJsonElement(body).jsonObject - val data = root["data"]?.jsonObject ?: root + // data 可能为 null/非对象(如 {"code":0}),安全转换后退回根层 + val data = root["data"] as? JsonObject ?: root val code = data.numberOr("code")?.toLong() ?: root.numberOr("code")?.toLong() ?: -1L val msg = data.scalarOr("message", "msg") ?: root.scalarOr("message", "msg") ?: "" - val points = (data.numberOr("points") ?: root.numberOr("points"))?.toLong() ?: 0L + // points 与 credit 双候选(社区实测:claim 成功响应可能用 credit 字段) + val points = (data.numberOr("points", "credit") + ?: root.numberOr("points", "credit"))?.toLong() ?: 0L val succeeded = code == 0L || code == 200L val lowerMsg = msg.lowercase() val deviceBlock = msg.contains("设备") || lowerMsg.contains("device") || lowerMsg.contains("machine") - val already = code == 1001L || (!deviceBlock && ( + // 权威口径幂等兜底码是 10001(trae-checkin-helper 逆向);历史实现用 1001, + // 两者都认,避免把已签误判为 FAILED 硬错误。 + val already = (code == 1001L || code == 10001L) || (!deviceBlock && ( msg.contains("已签到") || msg.contains("已经签到") || msg.contains("明日再来") || msg.contains("今日已完成") || msg.contains("已领取") || 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 { succeeded -> Result.success( @@ -361,12 +659,64 @@ class TraeRepository constructor( deviceBlock -> Result.success( CheckinResult(false, false, 0L, msg, CheckinOutcome.DEVICE_BLOCKED) ) + retryable -> Result.success( + CheckinResult(false, false, 0L, msg, CheckinOutcome.RETRYABLE) + ) else -> Result.success( CheckinResult(false, false, 0L, msg, CheckinOutcome.FAILED, code) ) } }.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 = 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? { for (key in keys) { this[key]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }?.let { return it } diff --git a/app/src/main/java/com/rainy/token/data/repository/WebViewSessionSaver.kt b/app/src/main/java/com/rainy/token/data/repository/WebViewSessionSaver.kt index efcdc90..ef5e475 100644 --- a/app/src/main/java/com/rainy/token/data/repository/WebViewSessionSaver.kt +++ b/app/src/main/java/com/rainy/token/data/repository/WebViewSessionSaver.kt @@ -22,6 +22,12 @@ class WebViewSessionSaver @Inject constructor( private val credentialRepository: CredentialRepository ) { + /** Sub2API 面板登录保存结果:凭据 + 实际写入的账号 ID(新建账号时返回新 ID)。 */ + data class SavedSub2PanelSession( + val credential: Credential.Sub2ApiCredential, + val accountId: String + ) + /** * 提取并保存 Cookie 列表。返回保存后的凭据。 */ @@ -57,17 +63,19 @@ class WebViewSessionSaver @Inject constructor( * @param authToken localStorage.auth_token 的值;为空表示没抓到,返回 null 不覆盖旧凭据 * @param siteIcon 站点 Logo(可选,登录页 favicon/og:image) * @param email 登录邮箱(可选,仅用于展示与回退登录) + * @param accountId 目标账号(多账号服务;null=默认账号/新建账号) */ suspend fun saveSub2ApiPanelSession( baseUrl: String, authToken: String, siteIcon: String? = null, - email: String? = null - ): Credential.Sub2ApiCredential? { + email: String? = null, + accountId: String? = null + ): SavedSub2PanelSession? { val token = authToken.trim() if (token.isEmpty()) return null val base = normalizeBaseUrl(baseUrl) ?: return null - val existing = credentialRepository.get(ServiceType.SUB2API) as? Credential.Sub2ApiCredential + val existing = credentialRepository.get(ServiceType.SUB2API, accountId) as? Credential.Sub2ApiCredential val credential = Credential.Sub2ApiCredential( service = ServiceType.SUB2API, baseUrl = base, @@ -78,8 +86,37 @@ class WebViewSessionSaver @Inject constructor( siteIcon = siteIcon?.takeIf { it.isNotBlank() } ?: existing?.siteIcon, lastVerifiedAt = System.currentTimeMillis() ) - credentialRepository.save(credential) - return credential + // 返回实际写入的账号 ID:新建账号 / 默认账号统一回填,避免 UI 停留在「新建」空表单 + val effectiveId = credentialRepository.save(credential, accountId) + 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 } /** diff --git a/app/src/main/java/com/rainy/token/data/repository/WorkBuddyRepository.kt b/app/src/main/java/com/rainy/token/data/repository/WorkBuddyRepository.kt index f425ebb..7d3c344 100644 --- a/app/src/main/java/com/rainy/token/data/repository/WorkBuddyRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/WorkBuddyRepository.kt @@ -1,6 +1,7 @@ package com.rainy.token.data.repository import com.rainy.token.data.cache.BalanceCache +import com.rainy.token.data.debug.DebugLog import com.rainy.token.domain.model.Credential import com.rainy.token.domain.model.ServiceBalance import com.rainy.token.domain.service.ServiceType @@ -63,8 +64,8 @@ class WorkBuddyRepository constructor( put("QueryResourceId", buildJsonArray { }) }.toString() - suspend fun fetchBalance(): Result = withContext(Dispatchers.IO) { - val credential = credentialRepository.get(ServiceType.WORKBUDDY) + suspend fun fetchBalance(accountId: String? = null): Result = withContext(Dispatchers.IO) { + val credential = credentialRepository.get(ServiceType.WORKBUDDY, accountId) ?: return@withContext Result.failure(RepositoryError.InvalidCredential()) if (credential !is Credential.WorkBuddyCredential) { return@withContext Result.failure(RepositoryError.InvalidCredential()) @@ -113,7 +114,8 @@ class WorkBuddyRepository constructor( } else { credential.expiresAt.coerceAtLeast(System.currentTimeMillis()) } - ) + ), + accountId ) access = refreshed.accessToken refresh = refreshed.refreshToken @@ -137,6 +139,15 @@ 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( service = ServiceType.WORKBUDDY, amount = parsed.amount, @@ -144,13 +155,37 @@ class WorkBuddyRepository constructor( monthlySpent = parsed.used.takeIf { it > 0 }, totalQuota = parsed.size.takeIf { it > 0 }, isAvailable = parsed.amount > 0 || parsed.size > 0, - extras = parsed.extras + extras = mergedExtras ) - balanceCache.put(ServiceType.WORKBUDDY, balance) - credentialRepository.save(credential.copy(lastVerifiedAt = System.currentTimeMillis())) + balanceCache.put(ServiceType.WORKBUDDY, balance, accountId) + credentialRepository.save(credential.copy(lastVerifiedAt = System.currentTimeMillis()), accountId) Result.success(balance) } + /** + * 拉取细粒度账户明细并转成 extras 增量([KEY_DETAIL_RAW] / [KEY_PLAN_CODE])。 + * 请求失败或解析失败返回 null(调用方降级到 summary 的简化 extras)。 + */ + private fun fetchResourceDetailExtras(accessToken: String): Map? { + 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)。 */ private fun summaryRequest(accessToken: String): Request = Request.Builder() @@ -205,7 +240,71 @@ class WorkBuddyRepository constructor( 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? { + 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() + 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 { val root = json.parseToJsonElement(body).jsonObject val data = root["data"]?.jsonObject ?: root @@ -221,26 +320,54 @@ class WorkBuddyRepository constructor( var frozen = 0.0 var unitRaw: String? = null val extras = linkedMapOf() - for (item in list.take(20)) { - val remain = item.number("CycleRemainCapacity", "CycleCapacityRemain", "Remain", "balance") ?: 0.0 - val u = item.number("CycleUsedCapacity", "CycleCapacityUsed", "Used") ?: 0.0 - val s = item.number("CycleTotalCapacity", "CycleCapacitySize", "Size", "Total") ?: 0.0 - val f = item.number("CycleFrozenCapacity", "Frozen") ?: 0.0 - amount += remain - used += u - size += s - frozen += f - unitRaw = unitRaw ?: item.scalar("CapacityUnit", "unit", "currency", "Unit") - val name = item.scalar("ProductName", "PackageCode", "ResourceName", "name", "ResourceId") - if (!name.isNullOrBlank() && extras.size < 3) { - extras[name] = "${trimNum(remain)} ${normalizeUnit(unitRaw) ?: "Credits"}" + // 结构化包明细(名称/编码/已用/总量/剩余/到期/周期重置),详情页按官方语义分组展示。 + // 键名带 raw 以复用通用卡的「技术键过滤」,不被用量页/通用明细卡当普通键展示。 + val detail = buildJsonArray { + for (item in list.take(20)) { + val remain = item.number("CycleRemainCapacity", "CycleCapacityRemain", "Remain", "balance") ?: 0.0 + val u = item.number("CycleUsedCapacity", "CycleCapacityUsed", "Used") ?: 0.0 + val s = item.number("CycleTotalCapacity", "CycleCapacitySize", "Size", "Total") ?: 0.0 + val f = item.number("CycleFrozenCapacity", "Frozen") ?: 0.0 + amount += remain + used += u + size += s + frozen += f + unitRaw = unitRaw ?: item.scalar("CapacityUnit", "unit", "currency", "Unit") + val name = item.scalar("ProductName", "PackageCode", "ResourceName", "name", "ResourceId") + if (!name.isNullOrBlank() && extras.size < 3) { + 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 (frozen > 0) extras["frozen"] = trimNum(frozen) + extras["wb_detail_raw"] = detail.toString() data["IsPaidUser"]?.jsonPrimitive?.contentOrNull?.let { extras["is_paid_user"] = it } data["SubscriptionPackageCode"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() } - ?.let { extras["subscription"] = "true" } + ?.let { + extras["subscription"] = "true" + // 订阅套餐的包编码:详情页据此把该包归入「套餐积分」区,其余归「平台奖励积分」 + extras["subscription_code"] = it + } return ParsedWorkBuddy( amount = amount, @@ -280,6 +407,27 @@ class WorkBuddyRepository constructor( 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)---------- /** 签到动作语义结果(UI 层据此本地化,不直接展示 Repository 中文串)。 */ @@ -312,8 +460,8 @@ class WorkBuddyRepository constructor( ) /** 查询今日签到状态。uid 未知时省略 X-User-Id 头(源码确认该头可选)。 */ - suspend fun fetchCheckinStatus(): Result = withContext(Dispatchers.IO) { - val credential = credentialRepository.get(ServiceType.WORKBUDDY) + suspend fun fetchCheckinStatus(accountId: String? = null): Result = withContext(Dispatchers.IO) { + val credential = credentialRepository.get(ServiceType.WORKBUDDY, accountId) ?: return@withContext Result.failure(RepositoryError.InvalidCredential()) if (credential !is Credential.WorkBuddyCredential) { return@withContext Result.failure(RepositoryError.InvalidCredential()) @@ -322,6 +470,7 @@ class WorkBuddyRepository constructor( // 共享轮换状态:前一条路径轮换出的新 token 会用于下一条,且失败也先落库。 val body = authedPostFirstSuccessful( credential, + accountId, "/v2/billing/meter/checkin-activity-status", "/v2/billing/meter/checkin-status" ) ?: return@withContext Result.failure(RepositoryError.Unknown()) @@ -342,49 +491,67 @@ class WorkBuddyRepository constructor( * 执行今日签到(幂等:code=0 成功 / code=10001 已签,两种都算成功;源码确认 HTTP 400 也可能是已签)。 * 成功/已签后刷新一次状态并附带 message 提示。 */ - suspend fun checkIn(): Result = withContext(Dispatchers.IO) { - val credential = credentialRepository.get(ServiceType.WORKBUDDY) + suspend fun checkIn(accountId: String? = null): Result = withContext(Dispatchers.IO) { + val credential = credentialRepository.get(ServiceType.WORKBUDDY, accountId) ?: return@withContext Result.failure(RepositoryError.InvalidCredential()) if (credential !is Credential.WorkBuddyCredential) { return@withContext Result.failure(RepositoryError.InvalidCredential()) } val body = when (val r = authedPost(credential, "/v2/billing/meter/daily-checkin")) { is AuthedPostResult.Ok -> { - persistCheckinOutcome(credential, r.rotatedCredential) + persistCheckinOutcome(credential, accountId, r.rotatedCredential) r.body } is AuthedPostResult.Failed -> { // 即便签到请求失败,也必须先落库轮换出的新 token,否则账号卡在死 token 上; // 但未发生轮换(纯网络失败)时不写入——避免把失败请求误标为「刚验证通过」。 if (r.rotatedCredential != null) { - persistCheckinOutcome(credential, r.rotatedCredential) + persistCheckinOutcome(credential, accountId, r.rotatedCredential) } - return@withContext Result.failure(RepositoryError.Unknown()) + // HTTP 400 也可能是业务失败且 body 携带签到语义(如已签),透传交给下方解析。 + if (r.body.isBlank()) { + 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 root = json.parseToJsonElement(body).jsonObject - val obj = root["data"]?.jsonObject ?: root - obj.scalar("code", "Code")?.toLongOrNull() ?: -1L - }.getOrDefault(-1L) - val status = fetchCheckinStatus().getOrNull() + val r = json.parseToJsonElement(body).jsonObject + r.scalar("code", "Code")?.toLongOrNull() + ?: (r["data"] as? JsonObject)?.scalar("code", "Code")?.toLongOrNull() + }.getOrNull() ?: -1L + val msg = runCatching { + 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 { code == 0L -> Result.success( (status ?: CheckinStatus(true, true, 1, 0, 0, "")).copy( outcome = CheckinOutcome.SUCCESS ) ) - code == 10001L -> Result.success( + already -> Result.success( (status ?: CheckinStatus(true, true, 0, 0, 0, "")).copy( outcome = CheckinOutcome.ALREADY ) ) 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) { Result.success(status.copy(message = msg, outcome = CheckinOutcome.FAILED)) } else { @@ -410,9 +577,11 @@ class WorkBuddyRepository constructor( override val rotatedCredential: Credential.WorkBuddyCredential? ) : AuthedPostResult - /** 请求失败,但过程中发生过 token 轮换(新凭据必须落库)。 */ + /** 请求失败,但过程中发生过 token 轮换(新凭据必须落库)。[body] 携带业务失败响应体 + * (如 HTTP 400 也返回签到语义),可能为空。 */ data class Failed( - override val rotatedCredential: Credential.WorkBuddyCredential? + override val rotatedCredential: Credential.WorkBuddyCredential?, + val body: String = "" ) : AuthedPostResult } @@ -434,12 +603,15 @@ class WorkBuddyRepository constructor( .build() val resp = try { okHttpClient.newCall(request).execute() - } catch (_: Throwable) { + } catch (e: Throwable) { + DebugLog.w(TAG, "authedPost 异常: path=$path err=${e::class.simpleName}: ${e.message}") return AuthedPostResult.Failed(rotated) } val code = resp.code val body = runCatching { resp.body?.string().orEmpty() }.getOrDefault("") resp.close() + // 本地调试日志:记录请求路径/HTTP 码/响应摘要,不记录 Authorization(敏感)。 + DebugLog.i(TAG, "authedPost: path=$path attempt=$attempt http=$code body=${body.take(200)}") when { code in 200..299 -> return AuthedPostResult.Ok(body, rotated) (code == 401 || code == 403) && attempt == 0 -> { @@ -457,7 +629,7 @@ class WorkBuddyRepository constructor( access = refreshed.accessToken refresh = refreshed.refreshToken } - else -> return AuthedPostResult.Failed(rotated) + else -> return AuthedPostResult.Failed(rotated, body) } } return AuthedPostResult.Failed(rotated) @@ -470,19 +642,20 @@ class WorkBuddyRepository constructor( */ private suspend fun authedPostFirstSuccessful( credential: Credential.WorkBuddyCredential, + accountId: String?, vararg paths: String ): String? { var current = credential for (path in paths) { when (val r = authedPost(current, path)) { is AuthedPostResult.Ok -> { - persistCheckinOutcome(credential, r.rotatedCredential) + persistCheckinOutcome(credential, accountId, r.rotatedCredential) return r.body } is AuthedPostResult.Failed -> { // 即便本路径失败,也要先把轮换出的新 token 落库并继续用新 token 试下一条 if (r.rotatedCredential != null) { - persistCheckinOutcome(credential, r.rotatedCredential) + persistCheckinOutcome(credential, accountId, r.rotatedCredential) current = r.rotatedCredential } } @@ -497,10 +670,11 @@ class WorkBuddyRepository constructor( */ private suspend fun persistCheckinOutcome( credential: Credential.WorkBuddyCredential, + accountId: String?, rotated: Credential.WorkBuddyCredential? ) { val base = rotated ?: credential - credentialRepository.save(base.copy(lastVerifiedAt = System.currentTimeMillis())) + credentialRepository.save(base.copy(lastVerifiedAt = System.currentTimeMillis()), accountId) } private fun mapHttpError(code: Int, body: String): RepositoryError = when (code) { @@ -526,6 +700,14 @@ class WorkBuddyRepository constructor( ) 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 可静态调用/单测)。 */ private val parseJson = Json { ignoreUnknownKeys = true } diff --git a/app/src/main/java/com/rainy/token/domain/model/Credential.kt b/app/src/main/java/com/rainy/token/domain/model/Credential.kt index 4f1a488..760b281 100644 --- a/app/src/main/java/com/rainy/token/domain/model/Credential.kt +++ b/app/src/main/java/com/rainy/token/domain/model/Credential.kt @@ -66,6 +66,13 @@ sealed class Credential { /** * 用于 Trae(CN/Intl):官方 IDE 扩展使用的 Cloud-IDE-JWT。 * 单个 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 data class TraeCredential( @@ -74,6 +81,19 @@ sealed class Credential { val jwt: String, /** 区域:"CN"(api.trae.cn)/ "INTL"(grow-normal.trae.ai) */ 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 ) : Credential() diff --git a/app/src/main/java/com/rainy/token/domain/model/ServiceAccount.kt b/app/src/main/java/com/rainy/token/domain/model/ServiceAccount.kt new file mode 100644 index 0000000..fe43be9 --- /dev/null +++ b/app/src/main/java/com/rainy/token/domain/model/ServiceAccount.kt @@ -0,0 +1,32 @@ +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 = emptyList() +) \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/domain/service/ServiceConfigProvider.kt b/app/src/main/java/com/rainy/token/domain/service/ServiceConfigProvider.kt index 2dc191c..fd16a14 100644 --- a/app/src/main/java/com/rainy/token/domain/service/ServiceConfigProvider.kt +++ b/app/src/main/java/com/rainy/token/domain/service/ServiceConfigProvider.kt @@ -78,13 +78,17 @@ object ServiceConfigProvider { type = ServiceType.TRAE, method = FetchMethod.REST_API, loginUrl = "", - displayUnit = "Credits" + displayUnit = "Credits", + // 官网定价页(docs.trae.cn 订阅管理:购买会员套餐/增购积分入口) + externalUrl = "https://www.trae.cn/pricing" ), ServiceType.WORKBUDDY to ServiceConfig( type = ServiceType.WORKBUDDY, method = FetchMethod.REST_API, loginUrl = "", - displayUnit = "Credits" + displayUnit = "Credits", + // 官网订阅定价页 + externalUrl = "https://www.workbuddy.cn/pricing/" ), ServiceType.SUB2API to ServiceConfig( type = ServiceType.SUB2API, diff --git a/app/src/main/java/com/rainy/token/domain/service/ServiceType.kt b/app/src/main/java/com/rainy/token/domain/service/ServiceType.kt index 91f4a42..efb7e28 100644 --- a/app/src/main/java/com/rainy/token/domain/service/ServiceType.kt +++ b/app/src/main/java/com/rainy/token/domain/service/ServiceType.kt @@ -26,5 +26,16 @@ enum class ServiceType( companion object { fun fromStorageKey(key: String): ServiceType? = entries.firstOrNull { it.storageKey == key } + + /** 支持多账号的服务(Trae / WorkBuddy / Sub2API 已启用,见需求范围)。 */ + val MULTI_ACCOUNT_SERVICES: Set = setOf( + ServiceType.TRAE, + ServiceType.WORKBUDDY, + ServiceType.SUB2API + ) + + /** 该服务是否启用多账号。 */ + fun isMultiAccount(service: ServiceType): Boolean = + service in MULTI_ACCOUNT_SERVICES } } \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/domain/usecase/RefreshBalanceUseCase.kt b/app/src/main/java/com/rainy/token/domain/usecase/RefreshBalanceUseCase.kt index 6aa5810..7f70ecf 100644 --- a/app/src/main/java/com/rainy/token/domain/usecase/RefreshBalanceUseCase.kt +++ b/app/src/main/java/com/rainy/token/domain/usecase/RefreshBalanceUseCase.kt @@ -29,6 +29,7 @@ import kotlinx.coroutines.withContext */ class RefreshBalanceUseCase @Inject constructor( private val credentialRepository: CredentialRepository, + private val checkinStatusStore: com.rainy.token.data.repository.CheckinStatusStore, private val deepSeekRepositoryProvider: Provider, private val openCodeGoRepositoryProvider: Provider, private val commandCodeGoRepositoryProvider: Provider, @@ -38,8 +39,11 @@ class RefreshBalanceUseCase @Inject constructor( private val workBuddyRepositoryProvider: Provider, private val sub2ApiRepositoryProvider: Provider ) { - suspend operator fun invoke(service: ServiceType): Result = - withCredentialSession(service) { + suspend operator fun invoke( + service: ServiceType, + accountId: String? = null + ): Result = + withCredentialSession(service, accountId) { when (service) { ServiceType.DEEPSEEK -> retryOnTransientError { deepSeekRepositoryProvider.get().fetchBalance() @@ -57,13 +61,13 @@ class RefreshBalanceUseCase @Inject constructor( ollamaRepositoryProvider.get().fetchBalance() } ServiceType.TRAE -> retryOnTransientError { - traeRepositoryProvider.get().fetchBalance() + traeRepositoryProvider.get().fetchBalance(accountId) } ServiceType.WORKBUDDY -> retryOnTransientError { - workBuddyRepositoryProvider.get().fetchBalance() + workBuddyRepositoryProvider.get().fetchBalance(accountId) } ServiceType.SUB2API -> retryOnTransientError { - sub2ApiRepositoryProvider.get().fetchBalance() + sub2ApiRepositoryProvider.get().fetchBalance(accountId) } } } @@ -99,27 +103,50 @@ class RefreshBalanceUseCase @Inject constructor( * access/refresh),轮换结果需按凭据快照校验后提交,避免并发改凭据被旧快照覆盖; * 同时会话内重试才能读到已轮换的新 token(否则旧 refreshToken 单次消费后直接失效)。 */ - suspend fun checkInWorkBuddy(): Result = - withCredentialSession(ServiceType.WORKBUDDY) { - workBuddyRepositoryProvider.get().checkIn() + suspend fun checkInWorkBuddy(accountId: String? = null): Result = + withCredentialSession(ServiceType.WORKBUDDY, accountId) { + workBuddyRepositoryProvider.get().checkIn(accountId).also { result -> + result.getOrNull()?.let { s -> + if (s.outcome == WorkBuddyRepository.CheckinOutcome.SUCCESS || + s.outcome == WorkBuddyRepository.CheckinOutcome.ALREADY + ) { + checkinStatusStore.markChecked(ServiceType.WORKBUDDY, accountId) + } + } + } } - suspend fun checkInTrae(): Result = - withCredentialSession(ServiceType.TRAE) { - traeRepositoryProvider.get().checkIn() + suspend fun checkInTrae(accountId: String? = null): Result = + withCredentialSession(ServiceType.TRAE, accountId) { + traeRepositoryProvider.get().checkIn(accountId).also { result -> + result.getOrNull()?.let { r -> + if (r.outcome == TraeRepository.CheckinOutcome.SUCCESS || + r.outcome == TraeRepository.CheckinOutcome.ALREADY + ) { + checkinStatusStore.markChecked(ServiceType.TRAE, accountId) + } + } + } } /** 查询今日签到状态(仅 WORKBUDDY 有该接口)。走会话以保证状态查询的凭据写入不覆盖并发编辑。 */ - suspend fun fetchCheckinStatus(): Result = - withCredentialSession(ServiceType.WORKBUDDY) { - workBuddyRepositoryProvider.get().fetchCheckinStatus() + suspend fun fetchCheckinStatus(accountId: String? = null): Result = + withCredentialSession(ServiceType.WORKBUDDY, accountId) { + workBuddyRepositoryProvider.get().fetchCheckinStatus(accountId) + } + + /** TRAE 今日签到状态(只读 status 接口,供详情页进入时预取「已签到」展示)。 */ + suspend fun fetchTraeCheckinStatus(accountId: String? = null): Result = + withCredentialSession(ServiceType.TRAE, accountId) { + traeRepositoryProvider.get().fetchCheckinStatus(accountId) } private suspend fun withCredentialSession( service: ServiceType, + accountId: String? = null, block: suspend () -> Result ): Result { - val snapshot = credentialRepository.snapshot(service) + val snapshot = credentialRepository.snapshot(service, accountId) ?: return Result.failure(RepositoryError.InvalidCredential()) val session = RefreshWriteSession(snapshot) diff --git a/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt b/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt index 3b576b0..b5a42a8 100644 --- a/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt +++ b/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt @@ -50,12 +50,13 @@ import com.rainy.token.ui.dashboard.UsageDataScreen import com.rainy.token.ui.dashboard.UsageDataViewModel import com.rainy.token.ui.dashboard.UsageDetailScreen 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.heatmap.HeatmapScreen import com.rainy.token.ui.account.ApiManagementScreen 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.CredentialEditViewModel import com.rainy.token.ui.settings.SettingsScreen import com.rainy.token.ui.theme.inkMuted import com.rainy.token.ui.theme.StrawberryPink @@ -77,19 +78,35 @@ object Routes { const val DASHBOARD = "dashboard" const val SETTINGS = "settings" const val API_MANAGEMENT = "api_management" - const val USAGE_STAT = "usage_stat" const val TIPS = "tips" const val DEBUG_LOG = "debug_log" const val CODEX_OAUTH = "codex_oauth" - const val CREDENTIAL_EDIT = "credential_edit/{type}" - fun credentialEdit(type: ServiceType) = "credential_edit/${type.name}" - const val WEBVIEW_LOGIN = "webview_login/{type}" - fun webviewLogin(type: ServiceType) = "webview_login/${type.name}" + const val CREDENTIAL_EDIT = "credential_edit/{type}?account={account}" + fun credentialEdit(type: ServiceType, accountId: String? = null): String = + if (accountId.isNullOrBlank()) { + "credential_edit/${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}" fun webviewPage(url: String, title: String) = "webview_page?url=${Uri.encode(url)}&title=${Uri.encode(title)}" - const val SERVICE_DETAIL = "service/{type}" - fun serviceDetail(type: ServiceType) = "service/${type.name}" + const val SERVICE_DETAIL = "service/{type}?account={account}" + fun serviceDetail(type: ServiceType, accountId: String? = null): String = + if (accountId.isNullOrBlank()) { + "service/${type.name}" + } else { + "service/${type.name}?account=${Uri.encode(accountId)}" + } const val USAGE_DETAIL = "usage_detail" const val USAGE_OVERVIEW = "usage_overview" const val USAGE_DATA = "usage_data" @@ -104,15 +121,20 @@ private fun parseServiceType(typeName: String?): ServiceType = ServiceType.fromStorageKey(it) ?: runCatching { ServiceType.valueOf(it) }.getOrNull() } ?: ServiceType.DEEPSEEK +/** 凭据入口:多账号服务先进账号管理页,其余直达凭据编辑页。 */ +private fun credentialEntry(type: ServiceType): String = + if (ServiceType.isMultiAccount(type)) Routes.accounts(type) else Routes.credentialEdit(type) + private sealed class DetailPane { object Empty : DetailPane() - data class ServiceDetail(val type: ServiceType) : DetailPane() + data class ServiceDetail(val type: ServiceType, val accountId: String? = null) : DetailPane() object OCGOUsage : DetailPane() object CCGOUsage : DetailPane() object Settings : DetailPane() object ApiManagement : DetailPane() - object UsageStat : DetailPane() object Heatmap : DetailPane() + /** 通用外部页面(充值/订阅页),App 内 WebView 打开。 */ + data class WebPage(val url: String, val title: String) : DetailPane() } @Composable @@ -204,30 +226,34 @@ private fun CompactNavHost() { } ) { composable(Routes.DASHBOARD) { + val rechargeLabel = stringResource(R.string.action_recharge) DashboardScreen( onOpenSettings = { navController.navigate(Routes.SETTINGS) }, onOpenApiManagement = { navController.navigate(Routes.API_MANAGEMENT) }, - onOpenUsageStat = { navController.navigate(Routes.USAGE_STAT) }, - onOpenService = { type -> navController.navigate(Routes.serviceDetail(type)) }, + onOpenService = { type, accountId -> + navController.navigate(Routes.serviceDetail(type, accountId)) + }, onOpenUsageDetail = { navController.navigate(Routes.USAGE_DETAIL) }, onOpenCcgoUsageDetail = { navController.navigate(Routes.CCGO_USAGE_DETAIL) }, - onOpenHeatmap = { navController.navigate(Routes.HEATMAP) } - ) - } - composable(Routes.API_MANAGEMENT) { - val rechargeLabel = stringResource(R.string.action_recharge) - ApiManagementScreen( - onBack = guardedPop, - onConfigure = { type -> navController.navigate(Routes.credentialEdit(type)) }, - onRecharge = { type -> + onOpenHeatmap = { navController.navigate(Routes.HEATMAP) }, + onOpenRecharge = { type -> ServiceConfigProvider.get(type).externalUrl?.let { url -> navController.navigate(Routes.webviewPage(url, rechargeLabel)) } } ) } - composable(Routes.USAGE_STAT) { - UsageStatScreen(onBack = guardedPop) + composable(Routes.API_MANAGEMENT) { + val rechargeLabel = stringResource(R.string.action_recharge) + ApiManagementScreen( + onBack = guardedPop, + onConfigure = { type -> navController.navigate(credentialEntry(type)) }, + onRecharge = { type -> + ServiceConfigProvider.get(type).externalUrl?.let { url -> + navController.navigate(Routes.webviewPage(url, rechargeLabel)) + } + } + ) } composable(Routes.USAGE_DETAIL) { UsageDetailScreen( @@ -285,11 +311,22 @@ private fun CompactNavHost() { composable(Routes.SETTINGS) { SettingsScreen( onBack = guardedPop, - onEditCredential = { type -> navController.navigate(Routes.credentialEdit(type)) }, + onEditCredential = { type -> navController.navigate(credentialEntry(type)) }, + onManageAccounts = { type -> navController.navigate(Routes.accounts(type)) }, onOpenTips = { navController.navigate(Routes.TIPS) }, 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) { TipsScreen(onBack = guardedPop) } @@ -304,27 +341,51 @@ private fun CompactNavHost() { } composable( route = Routes.CREDENTIAL_EDIT, - arguments = listOf(navArgument("type") { type = NavType.StringType }) + arguments = listOf( + navArgument("type") { type = NavType.StringType }, + navArgument("account") { + type = NavType.StringType + nullable = true + defaultValue = null + } + ) ) { backStackEntry -> val type = parseServiceType(backStackEntry.arguments?.getString("type")) + val accountId = backStackEntry.arguments?.getString("account") CredentialEditScreen( service = type, + accountId = accountId, onBack = guardedPop, - onStartWebViewLogin = { service -> navController.navigate(Routes.webviewLogin(service)) }, + onStartWebViewLogin = { svc, acc -> navController.navigate(Routes.webviewLogin(svc, acc)) }, onWebViewLoginSuccess = { }, onStartCodexOAuth = { navController.navigate(Routes.CODEX_OAUTH) } ) } composable( route = Routes.WEBVIEW_LOGIN, - arguments = listOf(navArgument("type") { type = NavType.StringType }) + arguments = listOf( + navArgument("type") { type = NavType.StringType }, + navArgument("account") { + type = NavType.StringType + nullable = true + defaultValue = null + } + ) ) { backStackEntry -> val type = parseServiceType(backStackEntry.arguments?.getString("type")) + val accountId = backStackEntry.arguments?.getString("account") WebViewLoginScreen( service = type, + accountId = accountId, onBack = guardedPop, - onLoginSucceeded = { - // 登录成功后直接返回详情页(跳过凭据编辑页),让详情页 ON_RESUME 刷新余额 + onLoginSucceeded = { _, effectiveId -> + // 把实际写入的账号 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() } @@ -333,15 +394,30 @@ private fun CompactNavHost() { } composable( route = Routes.SERVICE_DETAIL, - arguments = listOf(navArgument("type") { type = NavType.StringType }) + arguments = listOf( + navArgument("type") { type = NavType.StringType }, + navArgument("account") { + type = NavType.StringType + nullable = true + defaultValue = null + } + ) ) { backStackEntry -> val type = parseServiceType(backStackEntry.arguments?.getString("type")) + val accountId = backStackEntry.arguments?.getString("account") val rechargeLabel = stringResource(R.string.action_recharge) ServiceDetailScreen( service = type, + accountId = accountId, onBack = guardedPop, - onConfigureCredential = { svc -> navController.navigate(Routes.credentialEdit(svc)) }, - onStartWebViewLogin = { svc -> navController.navigate(Routes.webviewLogin(svc)) }, + onSwitchAccount = { svc, acc -> + // 切换账号时替换当前详情页(避免同路由栈堆积) + 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 -> ServiceConfigProvider.get(svc).externalUrl?.let { url -> navController.navigate(Routes.webviewPage(url, rechargeLabel)) @@ -385,6 +461,8 @@ private fun ExpandedLayout() { prevPane = detailPane } + val rechargeLabel = stringResource(R.string.action_recharge) + Row(modifier = Modifier.fillMaxSize()) { Box( modifier = Modifier @@ -394,11 +472,17 @@ private fun ExpandedLayout() { DashboardScreen( onOpenSettings = { detailPane = DetailPane.Settings }, onOpenApiManagement = { detailPane = DetailPane.ApiManagement }, - onOpenUsageStat = { detailPane = DetailPane.UsageStat }, - onOpenService = { type -> detailPane = DetailPane.ServiceDetail(type) }, + onOpenService = { type, accountId -> + detailPane = DetailPane.ServiceDetail(type, accountId) + }, onOpenUsageDetail = { detailPane = DetailPane.OCGOUsage }, onOpenCcgoUsageDetail = { detailPane = DetailPane.CCGOUsage }, onOpenHeatmap = { detailPane = DetailPane.Heatmap }, + onOpenRecharge = { type -> + ServiceConfigProvider.get(type).externalUrl?.let { url -> + detailPane = DetailPane.WebPage(url = url, title = rechargeLabel) + } + }, viewModel = dashboardVm ) } @@ -417,6 +501,9 @@ private fun ExpandedLayout() { ExpandedDetailPane( pane = detailPane, onClose = { detailPane = DetailPane.Empty }, + onSwitchAccount = { svc, acc -> + detailPane = DetailPane.ServiceDetail(svc, acc) + }, onCredentialEditClosed = { dashboardVm.reloadLocalState() } ) } @@ -427,6 +514,7 @@ private fun ExpandedLayout() { private fun ExpandedDetailPane( pane: DetailPane, onClose: () -> Unit, + onSwitchAccount: (ServiceType, String?) -> Unit = { _, _ -> }, onCredentialEditClosed: () -> Unit = {} ) { when (pane) { @@ -439,8 +527,8 @@ private fun ExpandedDetailPane( } } is DetailPane.ServiceDetail -> { - // key(pane.type):切换服务时重建嵌套导航栈,避免停留在上一个服务的充值页 - key(pane.type) { + // key(pane.type + accountId):切换服务/账号时重建嵌套导航栈,避免停留在上一个页面 + key("${pane.type.name}:${pane.accountId.orEmpty()}") { val navController = rememberNavController() NavHost( navController = navController, @@ -451,9 +539,11 @@ private fun ExpandedDetailPane( val rechargeLabel = LocalContext.current.getString(R.string.action_recharge) ServiceDetailScreen( service = pane.type, + accountId = pane.accountId, onBack = onClose, + onSwitchAccount = onSwitchAccount, onConfigureCredential = { onClose() }, - onStartWebViewLogin = { }, + onStartWebViewLogin = { _, _ -> }, onOpenRecharge = { svc -> ServiceConfigProvider.get(svc).externalUrl?.let { url -> navController.navigate(Routes.webviewPage(url, rechargeLabel)) @@ -539,8 +629,12 @@ private fun ExpandedDetailPane( is DetailPane.Heatmap -> { HeatmapScreen(onBack = onClose) } - is DetailPane.UsageStat -> { - UsageStatScreen(onBack = onClose) + is DetailPane.WebPage -> { + WebViewPageScreen( + url = pane.url, + title = pane.title, + onBack = onClose + ) } is DetailPane.ApiManagement -> { val apiNavController = rememberNavController() @@ -563,7 +657,7 @@ private fun ExpandedDetailPane( val rechargeLabel = LocalContext.current.getString(R.string.action_recharge) ApiManagementScreen( onBack = onClose, - onConfigure = { type -> apiNavController.navigate(Routes.credentialEdit(type)) }, + onConfigure = { type -> apiNavController.navigate(credentialEntry(type)) }, onRecharge = { type -> ServiceConfigProvider.get(type).externalUrl?.let { url -> apiNavController.navigate(Routes.webviewPage(url, rechargeLabel)) @@ -571,16 +665,35 @@ private fun ExpandedDetailPane( } ) } - composable( - route = Routes.CREDENTIAL_EDIT, - arguments = listOf(navArgument("type") { type = NavType.StringType }) - ) { entry -> + composable(Routes.ACCOUNTS) { entry -> val type = parseServiceType(entry.arguments?.getString("type")) - CredentialEditScreen( + AccountManagementScreen( service = type, onBack = { apiNavController.popBackStack() }, - onStartWebViewLogin = { svc -> - apiNavController.navigate(Routes.webviewLogin(svc)) + onEditAccount = { svc, accountId -> + apiNavController.navigate(Routes.credentialEdit(svc, accountId)) + } + ) + } + composable( + route = Routes.CREDENTIAL_EDIT, + arguments = listOf( + navArgument("type") { type = NavType.StringType }, + navArgument("account") { + type = NavType.StringType + nullable = true + defaultValue = null + } + ) + ) { entry -> + val type = parseServiceType(entry.arguments?.getString("type")) + val accountId = entry.arguments?.getString("account") + CredentialEditScreen( + service = type, + accountId = accountId, + onBack = { apiNavController.popBackStack() }, + onStartWebViewLogin = { svc, acc -> + apiNavController.navigate(Routes.webviewLogin(svc, acc)) }, onWebViewLoginSuccess = { }, onStartCodexOAuth = { apiNavController.navigate(Routes.CODEX_OAUTH) } @@ -588,13 +701,29 @@ private fun ExpandedDetailPane( } composable( route = Routes.WEBVIEW_LOGIN, - arguments = listOf(navArgument("type") { type = NavType.StringType }) + arguments = listOf( + navArgument("type") { type = NavType.StringType }, + navArgument("account") { + type = NavType.StringType + nullable = true + defaultValue = null + } + ) ) { entry -> val type = parseServiceType(entry.arguments?.getString("type")) + val accountId = entry.arguments?.getString("account") WebViewLoginScreen( service = type, + accountId = accountId, onBack = { apiNavController.popBackStack() }, - onLoginSucceeded = { apiNavController.popBackStack() } + onLoginSucceeded = { _, effectiveId -> + if (!effectiveId.isNullOrBlank()) { + apiNavController.previousBackStackEntry?.savedStateHandle?.set( + CredentialEditViewModel.KEY_LOGIN_ACCOUNT_ID, effectiveId + ) + } + apiNavController.popBackStack() + } ) } composable(Routes.CODEX_OAUTH) { @@ -640,12 +769,25 @@ private fun ExpandedDetailPane( SettingsScreen( onBack = onClose, onEditCredential = { type -> - settingsNavController.navigate(Routes.credentialEdit(type)) + settingsNavController.navigate(credentialEntry(type)) + }, + onManageAccounts = { type -> + settingsNavController.navigate(Routes.accounts(type)) }, onOpenTips = { settingsNavController.navigate("tips") }, 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") { TipsScreen(onBack = { settingsNavController.popBackStack() }) } @@ -660,14 +802,23 @@ private fun ExpandedDetailPane( } composable( route = Routes.CREDENTIAL_EDIT, - arguments = listOf(navArgument("type") { type = NavType.StringType }) + arguments = listOf( + navArgument("type") { type = NavType.StringType }, + navArgument("account") { + type = NavType.StringType + nullable = true + defaultValue = null + } + ) ) { entry -> val type = parseServiceType(entry.arguments?.getString("type")) + val accountId = entry.arguments?.getString("account") CredentialEditScreen( service = type, + accountId = accountId, onBack = { settingsNavController.popBackStack() }, - onStartWebViewLogin = { svc -> - settingsNavController.navigate(Routes.webviewLogin(svc)) + onStartWebViewLogin = { svc, acc -> + settingsNavController.navigate(Routes.webviewLogin(svc, acc)) }, onWebViewLoginSuccess = { }, onStartCodexOAuth = { settingsNavController.navigate(Routes.CODEX_OAUTH) } @@ -675,13 +826,29 @@ private fun ExpandedDetailPane( } composable( route = Routes.WEBVIEW_LOGIN, - arguments = listOf(navArgument("type") { type = NavType.StringType }) + arguments = listOf( + navArgument("type") { type = NavType.StringType }, + navArgument("account") { + type = NavType.StringType + nullable = true + defaultValue = null + } + ) ) { entry -> val type = parseServiceType(entry.arguments?.getString("type")) + val accountId = entry.arguments?.getString("account") WebViewLoginScreen( service = type, + accountId = accountId, onBack = { settingsNavController.popBackStack() }, - onLoginSucceeded = { settingsNavController.popBackStack() } + onLoginSucceeded = { _, effectiveId -> + if (!effectiveId.isNullOrBlank()) { + settingsNavController.previousBackStackEntry?.savedStateHandle?.set( + CredentialEditViewModel.KEY_LOGIN_ACCOUNT_ID, effectiveId + ) + } + settingsNavController.popBackStack() + } ) } } diff --git a/app/src/main/java/com/rainy/token/ui/account/ApiManagementScreen.kt b/app/src/main/java/com/rainy/token/ui/account/ApiManagementScreen.kt index 44a6bfd..277c7ad 100644 --- a/app/src/main/java/com/rainy/token/ui/account/ApiManagementScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/account/ApiManagementScreen.kt @@ -1,6 +1,7 @@ package com.rainy.token.ui.account 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 @@ -10,6 +11,7 @@ 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.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -17,6 +19,7 @@ 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.Add import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.AlertDialog @@ -29,7 +32,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Snackbar import androidx.compose.material3.SnackbarHost @@ -50,6 +52,7 @@ 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.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.Lifecycle @@ -57,6 +60,7 @@ import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.rainy.token.R 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.ui.components.ServiceIcon import com.rainy.token.ui.components.formatAmount @@ -69,10 +73,11 @@ import java.util.Date import java.util.Locale /** - * API 管理页:按供应商集中展示/管理已保存的凭据。 + * API 管理页(记事本式)。 * - * 每个供应商一张卡:状态、Key 掩码、最后验证、缓存余额,以及 - * 配置/编辑、测试连接、删除(需确认)操作;DeepSeek 额外提供充值入口。 + * 心智模型像记事本:正文只有「已添加」的服务条目,右上角 **+** 从剩余服务里选一个 + * 添加(进入对应配置流程);未配置的服务不占卡片。条目点击即编辑,行内提供 + * 测试连接 / 删除(需确认),有官方充值/订阅页的服务额外给入口。 */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -88,6 +93,7 @@ fun ApiManagementScreen( val snackbarHostState = remember { SnackbarHostState() } val context = LocalContext.current var pendingDelete by remember { mutableStateOf(null) } + var showAddPicker by remember { mutableStateOf(false) } LaunchedEffect(message) { message?.let { @@ -112,6 +118,15 @@ fun ApiManagementScreen( 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 + ) + } } ) }, @@ -124,11 +139,14 @@ fun ApiManagementScreen( contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { + if (uiState.rows.isEmpty()) { + item { EmptyNotesHint(onAdd = { showAddPicker = true }) } + } items(uiState.rows, key = { it.service.storageKey }) { row -> ApiRowCard( row = row, testing = row.service in testing, - onConfigure = { onConfigure(row.service) }, + onEdit = { onConfigure(row.service) }, onTest = { viewModel.test(row.service) }, onDelete = { pendingDelete = row.service }, onRecharge = { onRecharge(row.service) } @@ -137,6 +155,17 @@ fun ApiManagementScreen( } } + if (showAddPicker) { + AddServicePickerDialog( + addable = uiState.addable, + onPick = { service -> + showAddPicker = false + onConfigure(service) + }, + onDismiss = { showAddPicker = false } + ) + } + pendingDelete?.let { service -> AlertDialog( onDismissRequest = { pendingDelete = null }, @@ -163,17 +192,137 @@ 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, + 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 private fun ApiRowCard( row: ApiRowUi, testing: Boolean, - onConfigure: () -> Unit, + onEdit: () -> Unit, onTest: () -> Unit, onDelete: () -> Unit, onRecharge: () -> Unit ) { + // 有官方充值/订阅页的服务(ServiceConfig.externalUrl)才展示充值入口 + val rechargeUrl = remember(row.service) { + runCatching { ServiceConfigProvider.get(row.service).externalUrl }.getOrNull() + } Card( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .clickable { onEdit() }, shape = RoundedCornerShape(20.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) @@ -189,7 +338,11 @@ private fun ApiRowCard( fontWeight = FontWeight.SemiBold ) Text( - text = row.maskedCredential ?: stringResource(R.string.api_mgmt_unconfigured), + text = if (row.isMultiAccountRow) { + stringResource(R.string.api_mgmt_accounts_subtitle, row.accountCount) + } else { + row.maskedCredential ?: stringResource(R.string.api_mgmt_unconfigured) + }, style = MaterialTheme.typography.bodySmall, color = inkMuted() ) @@ -225,52 +378,38 @@ private fun ApiRowCard( } } Spacer(modifier = Modifier.width(8.dp)) - // 测试连接(进行中转圈)+ 删除 - if (testing) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - color = StrawberryPink, - strokeWidth = 2.dp - ) - } else { - TextButton(onClick = onTest, enabled = row.isConfigured) { - Text(stringResource(R.string.api_mgmt_test)) + // 测试连接(进行中转圈)+ 删除;多账号服务行内不提供(避免只对默认账号操作 / 误删全部账号) + if (!row.isMultiAccountRow) { + if (testing) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = StrawberryPink, + strokeWidth = 2.dp + ) + } else { + TextButton(onClick = onTest) { + Text( + stringResource(R.string.api_mgmt_test), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + IconButton(onClick = onDelete) { + Icon( + imageVector = Icons.Filled.Delete, + contentDescription = stringResource(R.string.action_delete), + tint = MaterialTheme.colorScheme.error + ) } } - IconButton(onClick = onDelete, enabled = row.isConfigured) { - Icon( - imageVector = Icons.Filled.Delete, - contentDescription = stringResource(R.string.action_delete), - tint = if (row.isConfigured) MaterialTheme.colorScheme.error else inkMuted().copy(alpha = 0.4f) - ) - } - } - - 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( - if (row.isConfigured) { - stringResource(R.string.api_mgmt_edit) - } 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)) + if (rechargeUrl != null) { + TextButton(onClick = onRecharge) { + Text( + stringResource(R.string.action_recharge), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) } } } diff --git a/app/src/main/java/com/rainy/token/ui/account/ApiManagementViewModel.kt b/app/src/main/java/com/rainy/token/ui/account/ApiManagementViewModel.kt index 88d2bc4..abe5c80 100644 --- a/app/src/main/java/com/rainy/token/ui/account/ApiManagementViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/account/ApiManagementViewModel.kt @@ -21,10 +21,11 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch /** - * API 管理页 ViewModel。 + * API 管理页 ViewModel(记事本式)。 * - * 按供应商集中展示已保存凭据的状态与摘要(Key 掩码、最后验证、缓存余额), - * 提供测试连接 / 删除操作。编辑走既有 credential_edit 页面。 + * 列表只展示「已添加」的凭据条目(Key 掩码、最后验证、缓存余额), + * 未配置的服务不占位;右上角 + 从 [addable] 里选服务新增。 + * 提供测试连接 / 删除操作,编辑走既有 credential_edit 页面。 */ @HiltViewModel class ApiManagementViewModel @Inject constructor( @@ -49,7 +50,7 @@ class ApiManagementViewModel @Inject constructor( fun load() { viewModelScope.launch { val states = credentialRepository.readLocalStates() - val rows = ServiceType.entries.map { type -> + val all = ServiceType.entries.map { type -> val local = states.getValue(type) val cred = credentialRepository.get(type) ApiRowUi( @@ -57,10 +58,21 @@ class ApiManagementViewModel @Inject constructor( state = local.status.state, lastVerifiedAt = local.status.lastVerifiedAt, 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) } } } @@ -128,6 +140,7 @@ class ApiManagementViewModel @Inject constructor( } 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.CredentialChanged -> UiText.Resource(R.string.api_mgmt_err_changed) is RepositoryError.RateLimited -> UiText.Resource(R.string.api_mgmt_err_ratelimited) @@ -143,11 +156,19 @@ data class ApiRowUi( val state: CredentialStatus.State, val lastVerifiedAt: Long, val cachedBalance: CachedBalance?, - val maskedCredential: String? + val maskedCredential: String?, + /** 多账号服务的账号个数;单账号服务为 0。 */ + val accountCount: Int = 0 ) { val isConfigured: Boolean get() = state != CredentialStatus.State.NOT_CONFIGURED + + /** 多账号服务且已有账号:行内不提供单服务的测试/删除,引导去账号管理页。 */ + val isMultiAccountRow: Boolean get() = ServiceType.isMultiAccount(service) && accountCount > 0 } data class ApiManagementUiState( - val rows: List = emptyList() + /** 已添加的凭据条目(记事本正文)。 */ + val rows: List = emptyList(), + /** 尚未添加的服务(右上角 + 的可选列表)。 */ + val addable: List = emptyList() ) diff --git a/app/src/main/java/com/rainy/token/ui/components/ServiceIcon.kt b/app/src/main/java/com/rainy/token/ui/components/ServiceIcon.kt index e046271..0072828 100644 --- a/app/src/main/java/com/rainy/token/ui/components/ServiceIcon.kt +++ b/app/src/main/java/com/rainy/token/ui/components/ServiceIcon.kt @@ -120,20 +120,34 @@ fun ServiceIcon( } } ServiceType.TRAE -> { - LetterIcon( - letter = "T", - background = Color(0xFF20242E), - modifier = modifier, - size = size - ) + Box( + modifier = modifier + .size(size.dp) + .clip(RoundedCornerShape(8.dp)) + .background(if (isSystemInDarkTheme()) Color(0xFF20242E) else Color.White), + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(id = R.drawable.ic_trae_logo), + contentDescription = "Trae", + modifier = Modifier.fillMaxSize() + ) + } } ServiceType.WORKBUDDY -> { - LetterIcon( - letter = "W", - background = Color(0xFF3339FF), - modifier = modifier, - size = size - ) + Box( + modifier = modifier + .size(size.dp) + .clip(RoundedCornerShape(8.dp)) + .background(Color.White), + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(id = R.drawable.ic_workbuddy_logo), + contentDescription = "WorkBuddy", + modifier = Modifier.fillMaxSize() + ) + } } ServiceType.SUB2API -> { LetterIcon( diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt index 93a24f0..8112dfa 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt @@ -38,6 +38,8 @@ import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.AlertDialog import androidx.compose.material3.TextButton +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator @@ -84,10 +86,12 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.rainy.token.data.repository.CredentialRepository import com.rainy.token.BuildConfig import com.rainy.token.R import com.rainy.token.domain.model.CredentialStatus 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.ui.components.ServiceIcon import com.rainy.token.ui.components.SiteIcon @@ -96,6 +100,7 @@ import com.rainy.token.ui.components.StatusLevel import com.rainy.token.ui.components.StatusStyle import com.rainy.token.ui.components.AppTips 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.StrawberryPink import android.app.AppOpsManager @@ -127,19 +132,22 @@ import java.util.Locale fun DashboardScreen( onOpenSettings: () -> Unit, onOpenApiManagement: () -> Unit, - onOpenUsageStat: () -> Unit, - onOpenService: (ServiceType) -> Unit, + onOpenService: (ServiceType, String?) -> Unit, onOpenUsageDetail: () -> Unit, onOpenCcgoUsageDetail: () -> Unit = {}, onOpenHeatmap: () -> Unit = {}, + onOpenRecharge: (ServiceType) -> Unit = {}, viewModel: DashboardViewModel = hiltViewModel() ) { - // 从设置页返回时重新读取本地凭据状态 + 缓存(不自动发起网络请求) + // 从设置页返回时重新读取本地凭据状态 + 缓存;若有已配置但无余额缓存的 + // 新账号(刚登录/导入),ViewModel 会自动拉一次余额让卡片立刻有数据 LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { viewModel.reloadLocalState() } val uiState by viewModel.uiState.collectAsStateWithLifecycle() + // 自动签到汇报(每日首次打开 App 签到后弹一次) + val autoCheckinReport by viewModel.autoCheckinReport.collectAsStateWithLifecycle() // Sub2API 面板管理弹窗数据源 val panelKeysState by viewModel.panelKeysState.collectAsStateWithLifecycle() val panelGroups by viewModel.panelGroups.collectAsStateWithLifecycle() @@ -164,8 +172,14 @@ fun DashboardScreen( ?.split(',') ?.filter { it.isNotBlank() } .orEmpty() + // 兼容历史排序 id: + // - 最早的格式 "service:{storageKey}"(单账号服务)→ "{storageKey}:" + // - 迁移后旧格式 "{storageKey}:" / "{storageKey}:default" 以及任意 "{storageKey}:{账号id}" + // (多账号服务)→ 服务组 id "group:{storageKey}"(叠放卡组) + // - 其余(用量卡/热力图卡等)原样保留 + val migrated = saved.mapNotNull { id -> migrateOrderId(id) }.distinct() cardOrder.clear() - cardOrder.addAll(saved) + cardOrder.addAll(migrated) } // 全局刷新触发器——每次 dashboard 刷新完成后 +1,UsageStatsCard 据此同步用量数据 @@ -202,6 +216,45 @@ 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( containerColor = Color.Transparent, topBar = { @@ -228,13 +281,6 @@ fun DashboardScreen( tint = StrawberryPink ) } - IconButton(onClick = onOpenUsageStat) { - Icon( - imageVector = Icons.Filled.DateRange, - contentDescription = stringResource(R.string.usage_stat_title), - tint = StrawberryPink - ) - } IconButton( onClick = { viewModel.refresh() }, enabled = !uiState.refreshing @@ -298,14 +344,32 @@ fun DashboardScreen( .padding(contentPadding), verticalArrangement = Arrangement.spacedBy(12.dp) ) { - // 随机小技巧提示(每次启动随机一条) + // 随机小技巧提示(每次启动随机一条)+ 最右侧自动签到开关 val tipTextRes = remember { AppTips.randomHintRes() } - Text( - text = "💡 " + stringResource(tipTextRes), - style = MaterialTheme.typography.bodySmall, - color = inkMuted(), + val autoCheckinEnabled by viewModel.autoCheckinEnabled.collectAsStateWithLifecycle() + Row( + verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 4.dp) - ) + ) { + Text( + text = "💡 " + stringResource(tipTextRes), + style = MaterialTheme.typography.bodySmall, + color = inkMuted(), + modifier = Modifier.weight(1f) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.auto_checkin_switch_label), + style = MaterialTheme.typography.labelSmall, + color = inkMuted() + ) + Spacer(modifier = Modifier.width(2.dp)) + Switch( + checked = autoCheckinEnabled, + onCheckedChange = viewModel::setAutoCheckinEnabled, + colors = SwitchDefaults.colors(checkedThumbColor = StrawberryPink) + ) + } val items = rememberDashboardItems( cards = uiState.cards, order = cardOrder, @@ -314,7 +378,11 @@ fun DashboardScreen( onOpenCcgoUsageDetail = onOpenCcgoUsageDetail, onOpenService = onOpenService, onOpenHeatmap = onOpenHeatmap, - onOpenSub2PanelManagement = { showSub2PanelManagement = true }, + onOpenSub2PanelManagement = { accountId -> + viewModel.openSub2Panel(accountId) + showSub2PanelManagement = true + }, + onOpenRecharge = onOpenRecharge, refreshTrigger = usageSyncTrigger ) DraggableDashboardCards( @@ -382,6 +450,12 @@ fun DashboardScreen( if (showSub2PanelManagement) { val scrollState = rememberScrollState() 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( onDismissRequest = { showSub2PanelManagement = false }, // 标题与刷新按钮同一行:刷新重新拉取 Key 列表与分组 @@ -408,7 +482,7 @@ fun DashboardScreen( .verticalScroll(scrollState) ) { com.rainy.token.ui.servicedetail.Sub2ApiPanelManagement( - balance = uiState.cards.firstOrNull { it.service == ServiceType.SUB2API }?.cachedBalance?.balance, + balance = panelBalanceCard?.cachedBalance?.balance, keysState = panelKeysState, groups = panelGroups, actionMessage = panelActionMessage, @@ -453,9 +527,10 @@ private fun rememberDashboardItems( onOpenSettings: () -> Unit, onOpenUsageDetail: () -> Unit, onOpenCcgoUsageDetail: () -> Unit, - onOpenService: (ServiceType) -> Unit, + onOpenService: (ServiceType, String?) -> Unit, onOpenHeatmap: () -> Unit, - onOpenSub2PanelManagement: () -> Unit, + onOpenSub2PanelManagement: (String?) -> Unit, + onOpenRecharge: (ServiceType) -> Unit, refreshTrigger: Int ): List { val defaultItems = buildList { @@ -486,11 +561,19 @@ private fun rememberDashboardItems( HeatmapEntryCard(onOpenHeatmap = onOpenHeatmap) }) } + // 服务卡片:每个账号一张普通卡(同服务多账号即多张连续卡片,不叠放) cards.forEach { card -> - add(DashboardHomeItem("service:${card.service.storageKey}") { + // 多账号卡片用“账号 N”兜底显示名(同服务内默认账号在前,按展示顺序编号) + add(DashboardHomeItem(card.key) { DashboardCard( card = card, - onClick = { onOpenService(card.service) }, + accountLabel = if (card.accountId != null) { + 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) { ServiceType.OPENCODE_GO -> onOpenUsageDetail ServiceType.COMMANDCODE_GO -> onOpenCcgoUsageDetail @@ -498,9 +581,15 @@ private fun rememberDashboardItems( }, // Token 活动热力图入口仅 OCGO 服务卡片显示(数据只统计 OCGO) onOpenHeatmap = if (card.service == ServiceType.OPENCODE_GO) onOpenHeatmap else null, - // Sub2API 面板管理快捷入口(首页弹窗) + // Sub2API 面板管理快捷入口(首页弹窗):绑定当前卡片所属账号 onOpenPanelManagement = if (card.service == ServiceType.SUB2API) { - { onOpenSub2PanelManagement() } + { accountId -> onOpenSub2PanelManagement(accountId ?: card.accountId) } + } else { + null + }, + // 充值/订阅入口:有官方 externalUrl 的服务(DeepSeek/Trae/WorkBuddy)在卡片上直开 + onOpenRecharge = if (ServiceConfigProvider.get(card.service).externalUrl != null) { + { onOpenRecharge(card.service) } } else { null }, @@ -509,10 +598,54 @@ private fun rememberDashboardItems( } } val itemById = defaultItems.associateBy { it.id } - val ordered = order.mapNotNull { itemById[it] } + val ordered = order.mapNotNull { id -> + // 回退兼容:叠放实验期间保存过 "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 } } } +@Composable +/** 同服务内账号序号(1 起,用于“账号 N”兜底显示)。 */ +private fun accountOrdinal(cards: List, 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 private fun DraggableDashboardCards( items: List, @@ -750,10 +883,12 @@ private fun DraggableDashboardCards( @Composable private fun DashboardCard( card: DashboardCardUi, + accountLabel: String? = null, onClick: () -> Unit, onOpenUsageDetail: (() -> Unit)? = null, onOpenHeatmap: (() -> Unit)? = null, - onOpenPanelManagement: (() -> Unit)? = null, + onOpenPanelManagement: ((String?) -> Unit)? = null, + onOpenRecharge: (() -> Unit)? = null, ) { Card( modifier = Modifier @@ -774,22 +909,58 @@ private fun DashboardCard( } Spacer(modifier = Modifier.width(12.dp)) Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = card.service.displayName, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + // 多账号:默认账号在卡片标题旁标「默认」 + if (card.accountId != null && card.isDefaultAccount) { + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = stringResource(R.string.account_default_badge), + style = MaterialTheme.typography.labelSmall, + color = StrawberryPink, + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(StrawberryPink.copy(alpha = 0.12f)) + .padding(horizontal = 6.dp, vertical = 1.dp) + ) + } + } Text( - text = card.service.displayName, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold - ) - Text( - // Sub2API 显示登录方式(邮箱登录 / API 登录),其余服务用固定描述 - text = if (card.service == ServiceType.SUB2API) { - sub2AuthModeText(card) - } else { - stringResource(secondaryLineRes(card)) + // 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 { + sub + } + } + else -> stringResource(secondaryLineRes(card)) }, style = MaterialTheme.typography.bodySmall, 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()) } @@ -840,7 +1011,7 @@ private fun DashboardCard( } } - // ─── 底部:左侧更新时间/错误信息,右侧「面板管理」入口(同一行、右对齐)─── + // ─── 底部:左侧更新时间/错误信息,右侧「面板管理 / 充值订阅」入口(同一行、右对齐)─── Spacer(modifier = Modifier.height(12.dp)) Row(verticalAlignment = Alignment.CenterVertically) { Text( @@ -864,7 +1035,7 @@ private fun DashboardCard( // 注意:Material3 的最小尺寸挂在 TextButton 内层节点上,外层 modifier 传 // defaultMinSize(0) 压不下去(实测为 no-op),这里不再写无用约束。 TextButton( - onClick = onOpenPanelManagement, + onClick = { onOpenPanelManagement?.invoke(card.accountId) }, contentPadding = PaddingValues(0.dp) ) { Text( @@ -885,6 +1056,32 @@ 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) + ) + } + } } } } diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt index e78db10..fa035fe 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt @@ -17,6 +17,7 @@ import com.rainy.token.ui.widget.OpenCodeGoWidgetProvider import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope @@ -39,6 +40,8 @@ import kotlinx.coroutines.sync.Mutex class DashboardViewModel @Inject constructor( private val credentialRepository: CredentialRepository, 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, private val sub2ApiRepository: com.rainy.token.data.repository.Sub2ApiRepository ) : ViewModel() { @@ -51,15 +54,82 @@ class DashboardViewModel @Inject constructor( init { viewModelScope.launch { - // 1) 先从缓存快速填充 UI(断网时也能看旧数据) + // 1) 先从缓存快速填充 UI(不阻塞首帧,断网时也能看旧数据) loadFromCache() - // 2) 再发起网络刷新——此时 UI 已有缓存兜底,不会闪回旧值 + // 2) 每日自动签到:必须先于 refresh() 串行完成。两者并发会对同一服务取 + // 相同凭据快照,后提交者因快照过期被丢弃(CredentialChanged),而签到 + // 在服务端已成功、角标也已落库 → 弹窗报「凭据」错误但积分实际到账, + // 展示自相矛盾。串行后签到先落库,refresh 再拉到含新积分的余额。 + // 用 try/catch 而非 runCatching:后者会吞掉 CancellationException, + // 导致 ViewModel 销毁取消后协程仍继续往下跑。 + try { + autoCheckinManager.runDailyIfDue() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + // 签到失败不阻塞余额刷新 + } + // 3) 发起网络刷新——此时 UI 已有缓存兜底,不会闪回旧值 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?> = + autoCheckinManager.report + + /** 自动签到开关状态(首页顶部提示行右侧 Switch)。 */ + private val _autoCheckinEnabled = MutableStateFlow(autoCheckinManager.enabled) + val autoCheckinEnabled: StateFlow = _autoCheckinEnabled.asStateFlow() + + fun setAutoCheckinEnabled(value: Boolean) { + autoCheckinManager.enabled = value + _autoCheckinEnabled.value = value + } + + fun consumeAutoCheckinReport() = autoCheckinManager.consumeReport() + + /** + * 重新读取本地凭据状态 + 余额缓存,不自动发起网络请求(除非发现已配置 + * 但没有余额缓存的"新账号",此时拉一次余额让首页立刻有数据,不用等进详情页)。 * * 卡片记录不含明文密钥的 SHA-256 指纹。更新时始终基于 _uiState 的最新卡片: * - 以当前已配置服务(configuredTypes)为准重建,新增服务即时出现、删除服务即时消失; @@ -70,92 +140,64 @@ class DashboardViewModel @Inject constructor( */ fun reloadLocalState() { viewModelScope.launch { - val localStates = credentialRepository.readLocalStates() - val configured = configuredTypes(localStates).toSet() + val accountStates = credentialRepository.readAccountStates() _uiState.update { state -> - 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 - } - ) - } - } - ) + state.copy(cards = rebuildCardsPreservingOrder(state, accountStates)) } + // 登录/导入后刚出现的账号还没有余额缓存:立即拉一次,首页即刻看到 + // 余额与角标,不用等用户点进详情页才触发刷新(刷新内部有 Mutex 防并发)。 + val hasFreshAccountWithoutCache = accountStates.any { entry -> + entry.status.state != CredentialStatus.State.NOT_CONFIGURED && + entry.cachedBalance == null + } + if (hasFreshAccountWithoutCache) refresh() } } /** 从本地缓存快速填充一次(不阻塞)。挂起函数,供调用方控制执行顺序。 */ private suspend fun loadFromCache() { - val localStates = credentialRepository.readLocalStates() - // 首页仅显示已配置凭据的服务(有凭据就显示,未配置不占位) - val cards = configuredTypes(localStates).map { type -> - buildCard(localStates.getValue(type), lastFetchError = null) + val accountStates = credentialRepository.readAccountStates() + val entries = orderAccountEntries(accountStates) + val cards = entries.map { entry -> + buildCard(entry, lastFetchError = null) } _uiState.update { it.copy(loading = false, refreshing = false, cards = cards) } } - /** 拉取所有服务最新余额,更新缓存。失败的服务保留旧数据并把错误信息带上。 */ + /** 拉取所有已配置账号的最新余额,更新缓存。失败保留旧数据并把错误信息带上。 */ fun refresh() { viewModelScope.launch { // Mutex 防并发:如果已有 refresh 在跑,后来的直接跳过 if (!refreshMutex.tryLock()) return@launch try { _uiState.update { it.copy(refreshing = true) } - val results: Map?> = coroutineScope { - ServiceType.entries.map { type -> + val results: Map?> = coroutineScope { + val entries = orderAccountEntries(credentialRepository.readAccountStates()) + entries.map { entry -> async { - val status = credentialRepository.statusFor(type) - if (status.state == CredentialStatus.State.NOT_CONFIGURED) { - type to null // 未配置的服务不拉 - } else { - type to refreshBalanceUseCase(type) - } + val key = AccountKey(entry.service, entry.account?.id) + val result: Result? = + if (entry.status.state == CredentialStatus.State.NOT_CONFIGURED) { + null // 未配置的账号不拉 + } else { + refreshBalanceUseCase(entry.service, entry.account?.id) + } + key to result } - }.awaitAll().toMap() + }.awaitAll().associateBy({ it.first }, { it.second }) } - val localStates = credentialRepository.readLocalStates() - val cards = configuredTypes(localStates).map { type -> - val result = results[type] - val error = result?.exceptionOrNull() + val accountStates = credentialRepository.readAccountStates() + val cards = orderAccountEntries(accountStates).map { entry -> + val key = AccountKey(entry.service, entry.account?.id) + val error = results[key]?.exceptionOrNull() val errorUi = error ?.takeUnless { it is RepositoryError.CredentialChanged } ?.let { errorToUiText(it) } - buildCard(localStates.getValue(type), lastFetchError = errorUi) + buildCard(entry, lastFetchError = errorUi) } _uiState.update { it.copy(refreshing = false, cards = cards) } // 刷新成功后更新桌面小组件 - if (results[ServiceType.OPENCODE_GO]?.isSuccess == true) { + if (results[AccountKey(ServiceType.OPENCODE_GO, null)]?.isSuccess == true) { OpenCodeGoWidgetProvider.notifyDataChanged(appContext) } } finally { @@ -164,23 +206,85 @@ class DashboardViewModel @Inject constructor( } } - /** 已配置凭据(state != NOT_CONFIGURED)的服务,首页只显示这些。 */ - private fun configuredTypes( - localStates: Map - ): List = ServiceType.entries.filter { type -> - localStates.getValue(type).status.state != CredentialStatus.State.NOT_CONFIGURED + /** + * 基于最新账号状态重建卡片列表,尽量保留旧顺序: + * - 同一服务内默认账号在前,其余按账号创建顺序; + * - 新增账号追加到同服务现有卡片之后;服务整体顺序沿用旧顺序,新服务按枚举追加。 + */ + private fun rebuildCardsPreservingOrder( + state: DashboardUiState, + accountStates: List + ): List { + 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 + ): List { + 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( - local: CredentialRepository.LocalState, + local: CredentialRepository.AccountLocalState, lastFetchError: UiText? ): DashboardCardUi = DashboardCardUi( - service = local.status.service, + service = local.service, + accountId = local.account?.id, + accountLabel = local.account?.label, + accountIndex = 0, // 展示兜底名称时由 UI 根据同服务卡片顺序计算 + isDefaultAccount = local.account?.isDefault == true, credentialState = local.status.state, credentialFingerprint = local.fingerprint, cachedBalance = local.cachedBalance, 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( @@ -210,12 +314,26 @@ class DashboardViewModel @Inject constructor( private val _panelGroups = MutableStateFlow>(emptyList()) val panelGroups: StateFlow> = _panelGroups.asStateFlow() - /** 管理操作的一次性结果消息(新建/切组/兑换)。 */ + /** 一次性操作结果消息(新建/切组/兑换)。 */ private val _panelActionMessage = MutableStateFlow(null) val panelActionMessage: StateFlow = _panelActionMessage.asStateFlow() fun consumePanelActionMessage() { _panelActionMessage.value = null } + /** 首页面板弹窗当前管理的 Sub2API 账号(null=默认账号)。 */ + private val _sub2PanelAccountId = MutableStateFlow(null) + val sub2PanelAccountId: StateFlow = _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(弹窗立即复制用;查看后清除)。 */ private val _recentlyCreatedKey = MutableStateFlow(null) val recentlyCreatedKey: StateFlow = _recentlyCreatedKey.asStateFlow() @@ -227,7 +345,7 @@ class DashboardViewModel @Inject constructor( viewModelScope.launch { if (_panelKeysState.value is com.rainy.token.ui.servicedetail.PanelKeysState.Loading) return@launch _panelKeysState.value = com.rainy.token.ui.servicedetail.PanelKeysState.Loading - sub2ApiRepository.fetchPanelKeys().fold( + sub2ApiRepository.fetchPanelKeys(_sub2PanelAccountId.value).fold( onSuccess = { list -> _panelKeysState.value = com.rainy.token.ui.servicedetail.PanelKeysState.Success(list) }, @@ -238,18 +356,30 @@ class DashboardViewModel @Inject constructor( } } - /** 加载可用分组;已非空则跳过(只要加载过一次即可复用)。 */ + /** 面板分组缓存所属账号(防止切换账号后仍显示上一个账号的分组)。 */ + private var _panelGroupsAccountId: String? = null + + /** 加载可用分组;同一账号加载过一次则跳过,切换账号后强制重拉。 */ fun loadPanelGroups() { viewModelScope.launch { - if (_panelGroups.value.isNotEmpty()) return@launch - sub2ApiRepository.fetchPanelGroups().onSuccess { _panelGroups.value = it } + val accountId = _sub2PanelAccountId.value + if (_panelGroupsAccountId == accountId && _panelGroups.value.isNotEmpty()) return@launch + sub2ApiRepository.fetchPanelGroups(accountId).fold( + onSuccess = { + _panelGroups.value = it + _panelGroupsAccountId = accountId + }, + onFailure = { + // 分组加载失败不阻塞主流程(新建 Key 时可不选分组) + } + ) } } /** 新建 API Key:成功后展示掩码消息并刷新列表。 */ fun createPanelKey(name: String, groupId: Long?) { viewModelScope.launch { - sub2ApiRepository.createPanelKey(name, groupId).fold( + sub2ApiRepository.createPanelKey(name, groupId, _sub2PanelAccountId.value).fold( onSuccess = { key -> _recentlyCreatedKey.value = key _panelActionMessage.value = "已创建 Key:" + maskPanelKey(key) + "(请在弹出的窗口复制完整 Key)" @@ -263,7 +393,7 @@ class DashboardViewModel @Inject constructor( /** 切换 Key 所属分组:成功后刷新列表。 */ fun switchPanelGroup(keyId: Long, groupId: Long) { viewModelScope.launch { - sub2ApiRepository.updatePanelKey(keyId, groupId).fold( + sub2ApiRepository.updatePanelKey(keyId, groupId, accountId = _sub2PanelAccountId.value).fold( onSuccess = { _panelActionMessage.value = "分组已切换" loadPanelKeys() @@ -276,7 +406,7 @@ class DashboardViewModel @Inject constructor( /** 删除 Key:成功后刷新列表。 */ fun deletePanelKey(keyId: Long) { viewModelScope.launch { - sub2ApiRepository.deletePanelKey(keyId).fold( + sub2ApiRepository.deletePanelKey(keyId, _sub2PanelAccountId.value).fold( onSuccess = { _panelActionMessage.value = "已删除 Key" loadPanelKeys() @@ -289,10 +419,10 @@ class DashboardViewModel @Inject constructor( /** 兑换卡密:成功后刷新余额与 Key 列表。 */ fun redeemPanelCode(code: String) { viewModelScope.launch { - sub2ApiRepository.redeemPanelCode(code).fold( + sub2ApiRepository.redeemPanelCode(code, _sub2PanelAccountId.value).fold( onSuccess = { msg -> _panelActionMessage.value = msg - refreshBalanceUseCase(ServiceType.SUB2API) + refreshBalanceUseCase(ServiceType.SUB2API, _sub2PanelAccountId.value) loadPanelKeys() }, onFailure = { e -> _panelActionMessage.value = "兑换失败:" + (e.message ?: "") } @@ -315,19 +445,43 @@ data class DashboardUiState( data class DashboardCardUi( 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 credentialFingerprint: String?, val cachedBalance: CachedBalance?, val lastFetchError: UiText?, /** 站点 Logo URL(Sub2API 面板登录后抓取;非空时主页卡片用它替换内置图标)。 */ - val siteIcon: String? = null + val siteIcon: String? = null, + /** 今日已签到角标(仅可签到服务有值,见 [CHECKIN_SERVICES])。 */ + val checkedInToday: Boolean = false ) { /** 余额展示主数字。优先取缓存,错误时也展示(不隐藏,让用户看到旧值 + 红点提示)。 */ 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 +) + +/** 支持每日签到的服务(首页角标与自动签到的统一口径)。 */ +internal val CHECKIN_SERVICES = listOf(ServiceType.TRAE, ServiceType.WORKBUDDY) + /** 把 Repository 错误映射为本地化文案(ParseError 按 reason 映射资源,detail 仅作日志,不流向 UI)。 */ private fun errorToUiText(error: Throwable): UiText = when (error) { + is RepositoryError.SessionExpired -> + UiText.Resource(R.string.error_credential_session_expired) is RepositoryError.InvalidCredential -> UiText.Resource(R.string.error_credential_invalid_reconfigure) is RepositoryError.RateLimited -> UiText.Resource( diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt b/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt index 030cfdc..37f912e 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt @@ -101,6 +101,9 @@ internal fun BalanceMainArea(card: DashboardCardUi) { card.service == ServiceType.SUB2API -> { Sub2ApiBalanceWithUsage(balance) } + card.service == ServiceType.TRAE -> { + TraeSplitMainBalance(balance) + } else -> { Row(verticalAlignment = Alignment.Bottom) { Text( @@ -135,6 +138,73 @@ 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 internal fun OpenCodeGoMainBalance(balance: ServiceBalance) { Row(verticalAlignment = Alignment.Bottom) { @@ -575,6 +645,43 @@ 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 层解析)。 */ @StringRes internal fun secondaryLineRes(card: DashboardCardUi): Int = when (card.service) { diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/UsageStatScreen.kt b/app/src/main/java/com/rainy/token/ui/dashboard/UsageStatScreen.kt deleted file mode 100644 index e492096..0000000 --- a/app/src/main/java/com/rainy/token/ui/dashboard/UsageStatScreen.kt +++ /dev/null @@ -1,275 +0,0 @@ -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("—") -} diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/UsageStatViewModel.kt b/app/src/main/java/com/rainy/token/ui/dashboard/UsageStatViewModel.kt deleted file mode 100644 index 5529b68..0000000 --- a/app/src/main/java/com/rainy/token/ui/dashboard/UsageStatViewModel.kt +++ /dev/null @@ -1,100 +0,0 @@ -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 = _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 { - 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 = emptyList(), - val refreshing: Boolean = false, - val lastFailureCount: Int = 0, - /** 首帧 true 前不渲染空态,避免闪一下"暂无已配置服务" */ - val initialized: Boolean = false -) diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/UsageViewModel.kt b/app/src/main/java/com/rainy/token/ui/dashboard/UsageViewModel.kt index 1ff3534..3176681 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/UsageViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/UsageViewModel.kt @@ -289,6 +289,8 @@ private data class LoadResult( private fun syncErrorToUiText(error: Throwable): UiText = when (error) { is SyncError.PartialSync -> 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 -> UiText.Resource(R.string.error_credential_invalid_reconfigure) is RepositoryError.CredentialChanged -> diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/autocheckin/AutoCheckinManager.kt b/app/src/main/java/com/rainy/token/ui/dashboard/autocheckin/AutoCheckinManager.kt new file mode 100644 index 0000000..8ca1919 --- /dev/null +++ b/app/src/main/java/com/rainy/token/ui/dashboard/autocheckin/AutoCheckinManager.kt @@ -0,0 +1,214 @@ +package com.rainy.token.ui.dashboard.autocheckin + +import android.content.Context +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.LocalDate +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * 每日自动签到编排器(小组件开关控制启用)。 + * + * 行为契约(用户需求 2026-09-13 / 多账号版 2026-10): + * - 开启后,每天首次打开 App 自动为所有「已配置凭据」的可签服务的【默认账号】各签一次; + * - 当日只签一次:[CheckinStatusStore] 有当日记录的账号直接跳过(手动签过也算); + * - 结果汇报:签完把每个账号的结果暴露为 [report],首页据此弹「xx 已签到,积分增加 xx」; + * - 开关持久化在 SharedPreferences "auto_checkin"(小组件与 App 内共用同一份)。 + * + * 仅负责编排与记录,不做 UI:签到复用 RefreshBalanceUseCase(带凭据快照会话)。 + */ +@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?>(null) + val report: StateFlow?> = _report.asStateFlow() + + 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() + } + + /** 已执行过「今日自动签到」的日期(防止同日多次开 App 重复跑)。 */ + private var lastRunDate: String? + get() = prefs().getString(KEY_LAST_RUN, null) + set(value) { + prefs().edit().putString(KEY_LAST_RUN, value).apply() + } + + /** + * 每日首次打开 App 时调用:开启且今日未跑过 → 为已配置凭据的可签服务默认账号各签一次。 + * 返回 true 表示本次实际执行了签到(无论结果)。 + */ + suspend fun runDailyIfDue(): Boolean { + if (!enabled) return false + val today = LocalDate.now().toString() + if (lastRunDate == today) return false + // 先占位再执行:即使执行中崩溃/杀进程,同日再开也不会重跑(宁可少签不重复打扰) + lastRunDate = today + + val results = mutableListOf() + for (service in CHECKIN_SERVICES) { + val defaultAccount = credentialRepository.getDefaultAccount(service) ?: continue + if (credentialRepository.statusFor(service, defaultAccount.id).state == + CredentialStatus.State.NOT_CONFIGURED + ) { + continue + } + // 当日已有签到记录(手动签过)→ 跳过,不再发网络请求 + if (checkinStatusStore.isCheckedToday(service, defaultAccount.id)) continue + // runOne 返回 null = 无需汇报(如 INTL 不支持签到),不弹误导性条目 + runOne(service, defaultAccount)?.let { results += it } + } + if (results.isNotEmpty()) { + _report.value = results + } + return true + } + + /** + * 返回 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 -> + ServiceResult(service, label, false, false, 0, r.message, failed = true) + TraeRepository.CheckinOutcome.RETRYABLE -> + ServiceResult(service, label, false, false, 0, r.message, failed = true) + TraeRepository.CheckinOutcome.FAILED -> + ServiceResult(service, label, false, false, 0, r.message, failed = true) + } + }, + onFailure = { e -> 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 -> + ServiceResult(service, label, false, false, 0, s.message, failed = true) + } + }, + onFailure = { e -> failureResult(service, label, account.id, e) } + ) + // CHECKIN_SERVICES 只含上述两者;穷尽兜底(未来加服务忘了实现时安全返回失败) + else -> null + } + } + + /** + * 签到请求失败的汇报结果。 + * + * 竞态兜底:签到请求在服务端已成功([RefreshBalanceUseCase.checkInTrae] 的 also 块 + * 已 markChecked),但与并发余额刷新争抢凭据快照导致结果被 [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 + ) + } + + /** 弹窗关闭时调用,清除汇报状态(下次执行前不再重复弹)。 */ + fun consumeReport() { + _report.value = null + } + + private companion object { + const val KEY_ENABLED = "enabled" + const val KEY_LAST_RUN = "last_run_date" + } +} + +/** 支持每日签到的服务(与 Dashboard 的 CHECKIN_SERVICES 同一口径,避免两处漂移)。 */ +internal val CHECKIN_SERVICES = listOf(ServiceType.TRAE, ServiceType.WORKBUDDY) \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt index fdf0bf9..1a1d1d5 100644 --- a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.horizontalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material3.AlertDialog @@ -29,6 +30,8 @@ import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -54,6 +57,7 @@ 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.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.Lifecycle @@ -62,6 +66,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.rainy.token.R import com.rainy.token.data.repository.ModelUsageRow 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.domain.model.ServiceBalance import com.rainy.token.domain.model.TriggerSummary @@ -80,6 +85,11 @@ import com.rainy.token.ui.components.isFiveHourLabel import com.rainy.token.ui.theme.inkMuted import com.rainy.token.ui.theme.StrawberryPink 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.util.Date import java.util.Locale @@ -100,13 +110,15 @@ import java.util.Locale @Composable fun ServiceDetailScreen( service: ServiceType, + accountId: String? = null, onBack: () -> Unit, + onSwitchAccount: (ServiceType, String?) -> Unit = { _, _ -> }, onConfigureCredential: (ServiceType) -> Unit, - onStartWebViewLogin: (ServiceType) -> Unit, + onStartWebViewLogin: (ServiceType, String?) -> Unit, onOpenRecharge: (ServiceType) -> Unit = { }, viewModel: ServiceDetailViewModel = hiltViewModel() ) { - LaunchedEffect(service) { viewModel.bind(service) } + LaunchedEffect(service, accountId) { viewModel.bind(service, accountId) } // 从凭据编辑页返回时重新读取凭据状态 + 缓存 LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { viewModel.reloadCredentialState() @@ -118,6 +130,20 @@ fun ServiceDetailScreen( val modelsLoading by viewModel.modelsLoading.collectAsStateWithLifecycle() val checkinState by viewModel.checkinState.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 卡片的「面板管理」入口, // 详情页只保留用量明细所需的窗口统计状态。 @@ -144,7 +170,11 @@ fun ServiceDetailScreen( ServiceIcon(service = service, size = 32) Spacer(modifier = Modifier.width(10.dp)) Text( - text = service.displayName, + text = if (accountLabel != null) { + stringResource(R.string.service_detail_title_with_account, service.displayName, accountLabel) + } else { + service.displayName + }, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold ) @@ -172,6 +202,18 @@ fun ServiceDetailScreen( contentPadding = PaddingValues(16.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) } @@ -192,23 +234,42 @@ fun ServiceDetailScreen( ServiceType.OLLAMA -> { item { OllamaUsageCard(uiState.state) } } - ServiceType.TRAE, - ServiceType.WORKBUDDY -> { - // 每日签到卡(WorkBuddy/Trae 共用);下方仍展示通用 extras - // 未配置凭据时展示引导卡,避免暴露必然失败的签到按钮 + ServiceType.TRAE -> { + // 每日签到卡;未配置凭据时展示引导卡,避免暴露必然失败的签到按钮 item { if (uiState.hasCredential) { CheckinCard( state = checkinState, todayCheckedIn = checkinInfo?.todayCheckedIn == true, streakDays = checkinInfo?.streakDays ?: 0L, + isTrae = true, onCheckin = { viewModel.checkin() } ) } else { CheckinNeedCredentialCard() } } - item { GenericExtrasCard(uiState.state) } + // 权益包积分明细(拆开显示每包总额/已用/剩余);无 entitlement.* 数据时该卡不渲染 + item { TraePacksCard(uiState.state) } + // 账户信息(总积分/已消耗/计费方式等,本地化标签,不暴露英文原始键) + item { TraeAccountInfoCard(uiState.state) } + } + ServiceType.WORKBUDDY -> { + item { + if (uiState.hasCredential) { + CheckinCard( + state = checkinState, + todayCheckedIn = checkinInfo?.todayCheckedIn == true, + streakDays = checkinInfo?.streakDays ?: 0L, + isTrae = false, + onCheckin = { viewModel.checkin() } + ) + } else { + CheckinNeedCredentialCard() + } + } + // 资源明细(资源 ID 美化为「资源 #007」+ 余额;套餐/冻结/付费信息归账户信息) + item { WorkBuddyResourcesCard(uiState.state) } } ServiceType.SUB2API -> { // 新明细卡:模型筛选 + 时间窗口 + 柱状图;无结构化明细时内部回退 GenericExtrasCard @@ -254,7 +315,7 @@ fun ServiceDetailScreen( modelsLoading = modelsLoading, onRefresh = { viewModel.refresh() }, onConfigureCredential = { onConfigureCredential(service) }, - onStartWebViewLogin = { onStartWebViewLogin(service) }, + onStartWebViewLogin = { onStartWebViewLogin(service, accountId) }, onOpenRecharge = config.externalUrl?.let { { onOpenRecharge(service) } }, onTriggerUsage = { viewModel.triggerUsage() }, onSelectModel = { viewModel.selectModel(it) }, @@ -287,6 +348,46 @@ fun ServiceDetailScreen( } } +/** + * 多账号服务的账号切换器:横向可滚动的 FilterChip 行,默认账号带「默认」角标。 + */ +@Composable +private fun AccountSwitcherRow( + accounts: List, + 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 专属:月度用量卡 + 窗口进度条。 * @@ -959,9 +1060,9 @@ private fun MainBalanceCard(state: State, service: ServiceType) { contentAlignment = Alignment.Center ) { CircularProgressIndicator(color = StrawberryPink) } } - is State.Fresh -> BalanceBigNumber(state.data) + is State.Fresh -> BalanceBigNumber(state.data, service) is State.Stale -> { - BalanceBigNumber(state.data) + BalanceBigNumber(state.data, service) Spacer(modifier = Modifier.height(8.dp)) Text( text = stringResource(R.string.msg_stale_cache, formatTime(state.lastFetchedAt)), @@ -970,7 +1071,7 @@ private fun MainBalanceCard(state: State, service: ServiceType) { ) } is State.Error -> { - BalanceBigNumber(state.cached) + BalanceBigNumber(state.cached, service) Spacer(modifier = Modifier.height(8.dp)) Text( text = stringResource(R.string.msg_showing_last_balance), @@ -991,7 +1092,7 @@ private fun MainBalanceCard(state: State, service: ServiceType) { } @Composable -private fun BalanceBigNumber(balance: ServiceBalance?) { +private fun BalanceBigNumber(balance: ServiceBalance?, service: ServiceType) { if (balance == null) { Text( text = "—", @@ -1031,6 +1132,34 @@ private fun BalanceBigNumber(balance: ServiceBalance?) { 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) } + } + } } /** @@ -1111,6 +1240,7 @@ private fun CheckinCard( state: CheckinState, todayCheckedIn: Boolean, streakDays: Long, + isTrae: Boolean = false, onCheckin: () -> Unit ) { val actionDone = todayCheckedIn || state is CheckinState.Done @@ -1163,6 +1293,18 @@ 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)) Button( onClick = onCheckin, @@ -1209,6 +1351,343 @@ 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 { + 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 { + 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>) { + 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 资源 / * Sub2API 账户等)。技术键(models / reset* / raw 等)一律过滤。 @@ -1227,7 +1706,11 @@ internal fun GenericExtrasCard(state: State) { val extras = balance?.extras ?: return val friendly = extras.filterKeys { key -> val k = key.lowercase() - !k.contains("models") && + // entitlement.* 键由 TraePacksCard 专门拆开渲染,不得进入通用卡 + !k.startsWith("entitlement.") && + // 通用/Work 专属小计由 TraePacksCard 分区标题展示,避免重复 + k != "general_credits" && k != "work_credits" && + !k.contains("models") && !k.contains("reset") && !k.contains("raw") && !k.endsWith(".unlimited") && diff --git a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModel.kt b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModel.kt index 4f56805..31fd4f4 100644 --- a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModel.kt @@ -35,10 +35,20 @@ class ServiceDetailViewModel @Inject constructor( private val credentialRepository: CredentialRepository, private val balanceCache: BalanceCache, private val refreshBalanceUseCase: RefreshBalanceUseCase, + private val checkinStatusStore: com.rainy.token.data.repository.CheckinStatusStore, private val sub2ApiRepository: com.rainy.token.data.repository.Sub2ApiRepository ) : ViewModel() { private val _serviceType = MutableStateFlow(null) + val serviceType: StateFlow = _serviceType.asStateFlow() + + /** 当前选中的账号 ID(多账号服务;null=默认账号,单账号服务恒为 null)。 */ + private val _accountId = MutableStateFlow(null) + + /** 多账号服务的账号列表(用于详情页账号切换)。 */ + private val _accounts = MutableStateFlow>(emptyList()) + val accounts: StateFlow> = _accounts.asStateFlow() + private val _uiState = MutableStateFlow(ServiceDetailUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -68,32 +78,51 @@ class ServiceDetailViewModel @Inject constructor( /** 今日签到信息摘要(仅字段已确认的才填)。 */ data class CheckinInfo(val todayCheckedIn: Boolean, val streakDays: Long) - /** 进入详情页时预取签到状态:仅 WORKBUDDY 支持状态查询接口,TRAE 无该接口。 */ - private fun preloadCheckinInfo(service: ServiceType) { + /** 进入详情页时预取「今日签到信息」:WORKBUDDY 走状态接口;TRAE 本地记录 + status 接口。 */ + private fun preloadCheckinInfo(service: ServiceType, accountId: String?) { _checkinInfo.value = null - if (service != ServiceType.WORKBUDDY) return val generation = serviceGeneration - viewModelScope.launch { - // 走 RefreshBalanceUseCase 会话:状态查询也会触发 OAuth 轮换,需按快照校验提交 - val status = refreshBalanceUseCase.fetchCheckinStatus().getOrNull() ?: return@launch - if (generation != serviceGeneration || _serviceType.value != service) return@launch - _checkinInfo.value = CheckinInfo( - todayCheckedIn = status.todayCheckedIn, - streakDays = status.streakDays - ) + when (service) { + ServiceType.WORKBUDDY -> viewModelScope.launch { + // 走 RefreshBalanceUseCase 会话:状态查询也会触发 OAuth 轮换,需按快照校验提交 + val status = refreshBalanceUseCase.fetchCheckinStatus(accountId).getOrNull() ?: return@launch + if (generation != serviceGeneration || _serviceType.value != service) return@launch + _checkinInfo.value = CheckinInfo( + todayCheckedIn = status.todayCheckedIn, + 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 中文串)。 */ fun checkin() { - val service = _serviceType.value + val service = _serviceType.value ?: return + val accountId = _accountId.value if (service != ServiceType.WORKBUDDY && service != ServiceType.TRAE) return if (_checkinState.value is CheckinState.Loading) return _checkinState.value = CheckinState.Loading val generation = serviceGeneration viewModelScope.launch { val result = when (service) { - ServiceType.WORKBUDDY -> refreshBalanceUseCase.checkInWorkBuddy().fold( + ServiceType.WORKBUDDY -> refreshBalanceUseCase.checkInWorkBuddy(accountId).fold( onSuccess = { s -> when (s.outcome) { WorkBuddyRepository.CheckinOutcome.ALREADY -> @@ -129,7 +158,7 @@ class ServiceDetailViewModel @Inject constructor( // RepositoryError.message 是硬编码中文,必须走 errorMessage() 本地化映射 onFailure = { e -> CheckinState.Error(errorMessage(e)) } ) - ServiceType.TRAE -> refreshBalanceUseCase.checkInTrae().fold( + ServiceType.TRAE -> refreshBalanceUseCase.checkInTrae(accountId).fold( onSuccess = { r -> when (r.outcome) { TraeRepository.CheckinOutcome.SUCCESS -> @@ -139,8 +168,17 @@ class ServiceDetailViewModel @Inject constructor( // 设备级拦截不是「账号已签」:用 Notice 展示,按钮保持可点(用户可换网/稍后重试) TraeRepository.CheckinOutcome.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 -> - CheckinState.Error(UiText.Resource(R.string.checkin_trae_intl)) + CheckinState.Error( + UiText.Resource(R.string.checkin_trae_intl), + showDeviceHint = false + ) TraeRepository.CheckinOutcome.FAILED -> CheckinState.Error( // 服务端原始原因优先;缺省用带 code 的失败文案 if (r.message.isNotBlank()) { @@ -160,7 +198,7 @@ class ServiceDetailViewModel @Inject constructor( if (generation == serviceGeneration) { _checkinState.value = result // 签到成功后刷新「今日已签到/连签」摘要,让按钮态即时更新 - if (result is CheckinState.Done) preloadCheckinInfo(service) + if (result is CheckinState.Done) preloadCheckinInfo(service, accountId) } } } @@ -192,7 +230,7 @@ class ServiceDetailViewModel @Inject constructor( val generation = ++panelWindowStatsGeneration _panelWindowStats.value = PanelWindowStatsState.Loading viewModelScope.launch { - val result = sub2ApiRepository.fetchWindowModelStats(from, to) + val result = sub2ApiRepository.fetchWindowModelStats(from, to, _accountId.value) // 过时结果(用户已切换到别的窗口)直接丢弃,不写状态 if (generation != panelWindowStatsGeneration) return@launch result.fold( @@ -207,7 +245,7 @@ class ServiceDetailViewModel @Inject constructor( fun loadPanelGroups() { viewModelScope.launch { if (_panelGroups.value.isNotEmpty()) return@launch - sub2ApiRepository.fetchPanelGroups().onSuccess { _panelGroups.value = it } + sub2ApiRepository.fetchPanelGroups(_accountId.value).onSuccess { _panelGroups.value = it } } } @@ -220,7 +258,7 @@ class ServiceDetailViewModel @Inject constructor( fun loadHourlyTrend(date: LocalDate) { val generation = ++hourlyTrendGeneration viewModelScope.launch { - val result = sub2ApiRepository.fetchHourlyTrend(date) + val result = sub2ApiRepository.fetchHourlyTrend(date, _accountId.value) if (generation != hourlyTrendGeneration) return@launch _hourlyTrend.value = result.getOrDefault(emptyList()) } @@ -237,7 +275,7 @@ class ServiceDetailViewModel @Inject constructor( val generation = ++panelKeyStatsGeneration _panelKeyStats.value = PanelKeyStatsState.Loading viewModelScope.launch { - val result = sub2ApiRepository.fetchKeyUsageStats(from, to) + val result = sub2ApiRepository.fetchKeyUsageStats(from, to, _accountId.value) if (generation != panelKeyStatsGeneration) return@launch result.fold( onSuccess = { list -> @@ -261,7 +299,7 @@ class ServiceDetailViewModel @Inject constructor( viewModelScope.launch { if (_panelKeysState.value is PanelKeysState.Loading) return@launch _panelKeysState.value = PanelKeysState.Loading - sub2ApiRepository.fetchPanelKeys().fold( + sub2ApiRepository.fetchPanelKeys(_accountId.value).fold( onSuccess = { list -> _panelKeysState.value = PanelKeysState.Success(list) }, onFailure = { e -> _panelKeysState.value = PanelKeysState.Error(e.message ?: "加载 Key 列表失败") } ) @@ -276,7 +314,7 @@ class ServiceDetailViewModel @Inject constructor( fun createPanelKey(name: String, groupId: Long?) { viewModelScope.launch { - sub2ApiRepository.createPanelKey(name, groupId).fold( + sub2ApiRepository.createPanelKey(name, groupId, _accountId.value).fold( onSuccess = { key -> _recentlyCreatedKey.value = key _panelActionMessage.value = "已创建 Key:" + maskPanelKey(key) + "(请在弹出的窗口复制完整 Key)" @@ -289,7 +327,7 @@ class ServiceDetailViewModel @Inject constructor( fun switchPanelGroup(keyId: Long, groupId: Long) { viewModelScope.launch { - sub2ApiRepository.updatePanelKey(keyId, groupId).fold( + sub2ApiRepository.updatePanelKey(keyId, groupId, accountId = _accountId.value).fold( onSuccess = { _panelActionMessage.value = "分组已切换" loadPanelKeys() @@ -301,10 +339,10 @@ class ServiceDetailViewModel @Inject constructor( fun redeemPanelCode(code: String) { viewModelScope.launch { - sub2ApiRepository.redeemPanelCode(code).fold( + sub2ApiRepository.redeemPanelCode(code, _accountId.value).fold( onSuccess = { msg -> _panelActionMessage.value = msg - refreshBalanceUseCase(ServiceType.SUB2API) + refreshBalanceUseCase(ServiceType.SUB2API, _accountId.value) loadPanelKeys() }, onFailure = { e -> _panelActionMessage.value = "兑换失败:" + (e.message ?: "") } @@ -361,37 +399,55 @@ class ServiceDetailViewModel @Inject constructor( else -> CODEX_PREFS } - fun bind(service: ServiceType) { - if (_serviceType.value == service) return + fun bind(service: ServiceType, accountId: String? = null) { + val effectiveAccountId = accountId?.takeIf { it.isNotBlank() } + if (_serviceType.value == service && _accountId.value == effectiveAccountId) return serviceGeneration++ refreshGeneration++ _serviceType.value = service + _accountId.value = effectiveAccountId + _accounts.value = emptyList() currentCredentialFingerprint = null _models.value = emptyList() _selectedModel.value = null _modelsLoading.value = false _triggerState.value = TriggerState.Idle - // 切换服务时重置签到状态,避免上一服务的 Done/Error 残留(按钮态/文案串台) + // 切换服务/账号时重置签到状态,避免上一服务/账号的 Done/Error 残留(按钮态/文案串台) _checkinState.value = CheckinState.Idle if (service == ServiceType.CODEX || service == ServiceType.OPENCODE_GO || service == ServiceType.OLLAMA) { loadSelectedModel(service)?.let { _selectedModel.value = it } loadModelsCache(service).takeIf { it.isNotEmpty() }?.let { _models.value = it } } - preloadCheckinInfo(service) + loadAccounts(service) + preloadCheckinInfo(service, effectiveAccountId) 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 采用 * Widget 或其他入口写入的更新缓存;凭据新增或替换时废弃旧状态并重新刷新。 */ fun reloadCredentialState() { val type = _serviceType.value ?: return + val accountId = _accountId.value val serviceGen = serviceGeneration + // 账号列表可能因账号管理页新增/删除/设默认而变化,一并刷新 + loadAccounts(type) viewModelScope.launch { - val local = credentialRepository.readLocalState(type) + val local = credentialRepository.readLocalState(type, accountId) val newFingerprint = local.fingerprint val change = classifyCredentialChange(currentCredentialFingerprint, newFingerprint) val newHasCredential = local.status.state != CredentialStatus.State.NOT_CONFIGURED @@ -441,10 +497,11 @@ class ServiceDetailViewModel @Inject constructor( fun refresh() { val type = _serviceType.value ?: return + val accountId = _accountId.value val serviceGen = serviceGeneration val gen = ++refreshGeneration viewModelScope.launch { - val requestFingerprint = credentialRepository.readLocalState(type).fingerprint + val requestFingerprint = credentialRepository.readLocalState(type, accountId).fingerprint if (isStaleRequest(type, serviceGen, gen)) return@launch _uiState.update { it.copy(state = State.Loading) } @@ -465,10 +522,10 @@ class ServiceDetailViewModel @Inject constructor( return@launch } - val result = refreshBalanceUseCase(type) + val result = refreshBalanceUseCase(type, accountId) if (isStaleRequest(type, serviceGen, gen)) return@launch - val local = credentialRepository.readLocalState(type) + val local = credentialRepository.readLocalState(type, accountId) if (isStaleRequest(type, serviceGen, gen)) return@launch val fingerprintChanged = local.fingerprint != requestFingerprint @@ -658,6 +715,7 @@ class ServiceDetailViewModel @Inject constructor( fun saveManualBalance(amount: Double) { val type = _serviceType.value ?: return + val accountId = _accountId.value val serviceGen = serviceGeneration viewModelScope.launch { val config = com.rainy.token.domain.service.ServiceConfigProvider.get(type) @@ -667,8 +725,8 @@ class ServiceDetailViewModel @Inject constructor( unit = config.displayUnit, isAvailable = true ) - balanceCache.put(type, balance) - val cached = balanceCache.get(type) + balanceCache.put(type, balance, accountId) + val cached = balanceCache.get(type, accountId) if (serviceGen == serviceGeneration && _serviceType.value == type) { _uiState.update { it.copy( @@ -682,8 +740,9 @@ class ServiceDetailViewModel @Inject constructor( fun markVerified() { val type = _serviceType.value ?: return + val accountId = _accountId.value viewModelScope.launch { - val credential = credentialRepository.get(type) ?: return@launch + val credential = credentialRepository.get(type, accountId) ?: return@launch val updated = when (credential) { is Credential.ApiKeyCredential -> credential.copy(lastVerifiedAt = System.currentTimeMillis()) @@ -698,16 +757,17 @@ class ServiceDetailViewModel @Inject constructor( is Credential.Sub2ApiCredential -> credential.copy(lastVerifiedAt = System.currentTimeMillis()) } - credentialRepository.save(updated) + credentialRepository.save(updated, accountId) loadFromCache() } } private fun loadFromCache() { val type = _serviceType.value ?: return + val accountId = _accountId.value val serviceGen = serviceGeneration viewModelScope.launch { - val local = credentialRepository.readLocalState(type) + val local = credentialRepository.readLocalState(type, accountId) val status = local.status val cached = local.cachedBalance val config = com.rainy.token.domain.service.ServiceConfigProvider.get(type) @@ -721,7 +781,10 @@ class ServiceDetailViewModel @Inject constructor( UiText.Resource(R.string.error_credential_not_configured), RepositoryError.InvalidCredential() ) - cached != null && status.state == CredentialStatus.State.OK -> + // OK 与 WARNING 都视为「凭据可用」:WARNING 仅是提示建议重新验证(如面板登录较久), + // 凭据仍有效,不应误报为「未配置或已过期」——否则能正常刷新余额却显示错误。 + cached != null && (status.state == CredentialStatus.State.OK || + status.state == CredentialStatus.State.WARNING) -> // 缓存新鲜(≤5 分钟)时视为最新数据直接展示,避免"刚刷新完进详情却显示缓存"的困惑 if (isCacheFresh(cached.fetchedAt)) { State.Fresh(cached.balance) @@ -823,6 +886,8 @@ class ServiceDetailViewModel @Inject constructor( _serviceType.value != type private fun errorMessage(error: Throwable): UiText = when (error) { + is RepositoryError.SessionExpired -> + UiText.Resource(R.string.error_credential_session_expired) is RepositoryError.InvalidCredential -> UiText.Resource(R.string.error_credential_invalid_reconfigure) is RepositoryError.CredentialChanged -> @@ -882,7 +947,11 @@ sealed class CheckinState { data object Idle : CheckinState() data object Loading : 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 设备级拦截「该设备今日签到次数已达上限」。 diff --git a/app/src/main/java/com/rainy/token/ui/settings/AccountManagementScreen.kt b/app/src/main/java/com/rainy/token/ui/settings/AccountManagementScreen.kt new file mode 100644 index 0000000..5f483b1 --- /dev/null +++ b/app/src/main/java/com/rainy/token/ui/settings/AccountManagementScreen.kt @@ -0,0 +1,340 @@ +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(null) } + // 重命名对话框 + var renamingAccount by remember { mutableStateOf(null) } + var renameInput by remember { mutableStateOf("") } + // 删除确认对话框 + var deletingAccount by remember { mutableStateOf(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, + 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, account: ServiceAccountInfo): Int = + accounts.indexOf(account) + 1 \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/ui/settings/AccountManagementViewModel.kt b/app/src/main/java/com/rainy/token/ui/settings/AccountManagementViewModel.kt new file mode 100644 index 0000000..c1ec7a7 --- /dev/null +++ b/app/src/main/java/com/rainy/token/ui/settings/AccountManagementViewModel.kt @@ -0,0 +1,97 @@ +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(null) + val service: StateFlow = _service.asStateFlow() + + private val _uiState = MutableStateFlow(AccountManagementUiState()) + val uiState: StateFlow = _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 = emptyList(), + val message: UiText? = null +) \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt index 07f4267..86aab52 100644 --- a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt @@ -53,6 +53,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect import kotlinx.coroutines.delay import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -78,13 +80,18 @@ import com.rainy.token.domain.service.ServiceType @Composable fun CredentialEditScreen( service: ServiceType, + accountId: String? = null, onBack: () -> Unit, - onStartWebViewLogin: (ServiceType) -> Unit, + onStartWebViewLogin: (ServiceType, String?) -> Unit, onWebViewLoginSuccess: (ServiceType) -> Unit, onStartCodexOAuth: () -> Unit = {}, viewModel: CredentialEditViewModel = hiltViewModel() ) { - LaunchedEffect(service) { viewModel.bind(service) } + LaunchedEffect(service, accountId) { viewModel.bind(service, accountId) } + // 从 WebView 登录页返回时,登录流程可能已改写当前账号凭据,重载表单 + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { + viewModel.reload() + } val uiState by viewModel.uiState.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } val context = LocalContext.current @@ -100,10 +107,23 @@ 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( topBar = { TopAppBar( - title = { Text(service.displayName) }, + title = { Text(titleText) }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back)) @@ -134,10 +154,12 @@ fun CredentialEditScreen( TraeCredentialForm( jwt = uiState.traeJwt, region = uiState.traeRegion, + checkinDeviceId = uiState.traeCheckinDeviceId, hasExisting = uiState.hasExisting, onJwtChange = viewModel::updateTraeJwt, onRegionChange = viewModel::updateTraeRegion, - onStartInAppLogin = { onStartWebViewLogin(ServiceType.TRAE) }, + onCheckinDeviceIdChange = viewModel::updateTraeCheckinDeviceId, + onStartInAppLogin = { onStartWebViewLogin(ServiceType.TRAE, accountId) }, onSave = viewModel::saveTraeCredential ) } else if (service == ServiceType.WORKBUDDY) { @@ -147,7 +169,7 @@ fun CredentialEditScreen( hasExisting = uiState.hasExisting, onAccessChange = viewModel::updateWorkBuddyAccess, onRefreshChange = viewModel::updateWorkBuddyRefresh, - onStartInAppLogin = { onStartWebViewLogin(ServiceType.WORKBUDDY) }, + onStartInAppLogin = { onStartWebViewLogin(ServiceType.WORKBUDDY, accountId) }, onSave = viewModel::saveWorkBuddyCredential ) } else if (service == ServiceType.SUB2API) { @@ -245,6 +267,7 @@ fun CredentialEditScreen( onImportFromClipboard = { viewModel.importFromClipboard(context) }, onCopyLoginUrl = { copyToClipboard(context, uiState.loginUrl) }, onOpenLoginUrl = { openInBrowser(context, uiState.loginUrl) }, + onStartInAppLogin = { onStartWebViewLogin(ServiceType.OPENCODE_GO, accountId) }, onShowHelp = { showGoHelp = true }, onApiKeyChange = viewModel::updateTriggerApiKey, onSaveApiKey = { viewModel.saveTriggerApiKey() } @@ -488,6 +511,7 @@ private fun OpenCodeGoForm( onImportFromClipboard: () -> Unit, onCopyLoginUrl: () -> Unit, onOpenLoginUrl: () -> Unit, + onStartInAppLogin: () -> Unit, onShowHelp: () -> Unit, onApiKeyChange: (String) -> Unit, onSaveApiKey: () -> Unit @@ -498,6 +522,10 @@ private fun OpenCodeGoForm( style = MaterialTheme.typography.bodySmall, 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()) { Text(text = stringResource(R.string.action_import_from_clipboard)) } @@ -750,9 +778,11 @@ private fun ManualCookieForm( private fun TraeCredentialForm( jwt: String, region: String, + checkinDeviceId: String, hasExisting: Boolean, onJwtChange: (String) -> Unit, onRegionChange: (String) -> Unit, + onCheckinDeviceIdChange: (String) -> Unit, onStartInAppLogin: () -> Unit, onSave: () -> Unit ) { @@ -793,11 +823,85 @@ private fun TraeCredentialForm( 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()) { 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 private fun RegionButton(label: String, selected: Boolean, onSelect: () -> Unit) { OutlinedButton(onClick = onSelect) { diff --git a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt index 1016249..82f0ce2 100644 --- a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt @@ -40,7 +40,8 @@ import kotlinx.serialization.json.jsonPrimitive class CredentialEditViewModel @Inject constructor( private val credentialRepository: CredentialRepository, private val refreshBalanceUseCaseProvider: Provider, - private val sub2ApiRepository: Sub2ApiRepository + private val sub2ApiRepository: Sub2ApiRepository, + private val savedStateHandle: androidx.lifecycle.SavedStateHandle ) : ViewModel() { /** Sub2 站点信息探测(baseUrl 输入 debounce 后触发)。 */ @@ -52,25 +53,45 @@ class CredentialEditViewModel @Inject constructor( private var serviceType: ServiceType? = null - fun bind(service: ServiceType) { - if (serviceType == service) return + /** 当前编辑的账号 ID(多账号服务);null=新建账号/单账号服务。 */ + private var accountId: String? = null + + fun bind(service: ServiceType, accountId: String? = null) { + val effectiveAccountId = accountId?.takeIf { it.isNotBlank() } + if (serviceType == service && this.accountId == effectiveAccountId) return serviceType = service + this.accountId = effectiveAccountId val config = ServiceConfigProvider.get(service) val isApiKey = config.method == FetchMethod.REST_API _uiState.update { it.copy( service = service, isApiKeyService = isApiKey, - loginUrl = config.loginUrl + loginUrl = config.loginUrl, + accountId = effectiveAccountId, + accountLabel = null, + accountIndex = 0 ) } load() + refreshAccountInfo() } private fun load() { val type = serviceType ?: return + // WebView 登录成功回调会把实际写入的账号 ID 通过 savedStateHandle 回传: + // 新建账号模式(accountId=null)登录后要切换到“编辑该新账号”模式,避免再次保存时误建第二个账号 + savedStateHandle.get(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 { - val existing = credentialRepository.get(type) + // 多账号服务 + null accountId = “新建账号”模式:表单必须留空(不能预填默认账号凭据) + val isNewAccount = ServiceType.isMultiAccount(type) && accountId.isNullOrBlank() + val existing = if (isNewAccount) null else credentialRepository.get(type, accountId) _uiState.update { it.copy( apiKey = when (existing) { @@ -102,6 +123,7 @@ class CredentialEditViewModel @Inject constructor( triggerApiKey = (existing as? Credential.SessionCredential)?.apiKey.orEmpty(), traeJwt = (existing as? Credential.TraeCredential)?.jwt.orEmpty(), traeRegion = (existing as? Credential.TraeCredential)?.region ?: "CN", + traeCheckinDeviceId = (existing as? Credential.TraeCredential)?.checkinDeviceId.orEmpty(), workBuddyAccess = (existing as? Credential.WorkBuddyCredential)?.accessToken.orEmpty(), workBuddyRefresh = (existing as? Credential.WorkBuddyCredential)?.refreshToken.orEmpty(), sub2BaseUrl = (existing as? Credential.Sub2ApiCredential)?.baseUrl.orEmpty(), @@ -190,6 +212,8 @@ class CredentialEditViewModel @Inject constructor( } val err = result.exceptionOrNull() val reason: UiText = when (err) { + is RepositoryError.SessionExpired -> + UiText.Resource(R.string.error_credential_session_expired) is RepositoryError.InvalidCredential -> UiText.Resource(R.string.error_credential_rejected) is RepositoryError.CredentialChanged -> @@ -279,6 +303,10 @@ class CredentialEditViewModel @Inject constructor( _uiState.update { it.copy(traeRegion = value) } } + fun updateTraeCheckinDeviceId(value: String) { + _uiState.update { it.copy(traeCheckinDeviceId = value) } + } + fun saveTraeCredential() { val type = serviceType ?: return val current = _uiState.value @@ -288,17 +316,30 @@ class CredentialEditViewModel @Inject constructor( return } viewModelScope.launch { - val existing = credentialRepository.get(type) as? Credential.TraeCredential - credentialRepository.save( + // 新建账号模式(多账号服务 + null accountId)不继承默认账号的元数据(lastVerifiedAt 等) + val isNewAccount = ServiceType.isMultiAccount(type) && accountId.isNullOrBlank() + 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( jwt = jwt, 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 - ) + ), + accountId ) + accountId = effectiveId.ifBlank { accountId } _uiState.update { it.copy(hasExisting = true, message = UiText.Resource(R.string.msg_credentials_saved)) } + refreshAccountInfo() } } @@ -322,19 +363,28 @@ class CredentialEditViewModel @Inject constructor( return } viewModelScope.launch { - val existing = credentialRepository.get(type) as? Credential.WorkBuddyCredential - credentialRepository.save( + // 新建账号模式(多账号服务 + null accountId)不继承默认账号的元数据 + val isNewAccount = ServiceType.isMultiAccount(type) && accountId.isNullOrBlank() + 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)) .copy( accessToken = access, refreshToken = refresh, expiresAt = existing?.expiresAt ?: 0L, lastVerifiedAt = existing?.lastVerifiedAt ?: 0L - ) + ), + accountId ) + accountId = effectiveId.ifBlank { accountId } _uiState.update { it.copy(hasExisting = true, message = UiText.Resource(R.string.msg_credentials_saved)) } + refreshAccountInfo() } } @@ -419,7 +469,13 @@ class CredentialEditViewModel @Inject constructor( val email = current.sub2Email.trim() val password = current.sub2Password.trim() viewModelScope.launch { - val existing = credentialRepository.get(type) as? Credential.Sub2ApiCredential + // 新建账号模式(多账号服务 + null accountId)不继承默认账号的元数据 + val isNewAccount = ServiceType.isMultiAccount(type) && accountId.isNullOrBlank() + val existing = if (isNewAccount) { + null + } else { + credentialRepository.get(type, accountId) as? Credential.Sub2ApiCredential + } // 面板 Token(本次 WebView 获取或既有)也算有效认证 val panelToken = current.sub2PanelToken ?: existing?.authToken // 实例地址必填;认证需 API Key /(邮箱+密码)/ 面板 Token 之一 @@ -427,7 +483,7 @@ class CredentialEditViewModel @Inject constructor( _uiState.update { it.copy(message = UiText.Resource(R.string.error_sub2_required)) } return@launch } - credentialRepository.save( + val effectiveId = credentialRepository.save( (existing ?: Credential.Sub2ApiCredential(service = type, baseUrl = base, email = email, password = password)) .copy( baseUrl = base, @@ -437,8 +493,10 @@ class CredentialEditViewModel @Inject constructor( authToken = panelToken, siteIcon = current.sub2SiteIcon ?: existing?.siteIcon, lastVerifiedAt = existing?.lastVerifiedAt ?: 0L - ) + ), + accountId ) + accountId = effectiveId.ifBlank { accountId } _uiState.update { it.copy(hasExisting = true, sub2PanelToken = null, message = UiText.Resource(R.string.msg_credentials_saved)) } @@ -766,10 +824,19 @@ class CredentialEditViewModel @Inject constructor( fun deleteCredential() { val type = serviceType ?: return + val targetAccountId = accountId viewModelScope.launch { - credentialRepository.remove(type) + if (ServiceType.isMultiAccount(type) && !targetAccountId.isNullOrBlank()) { + credentialRepository.removeAccount(type, targetAccountId) + } else { + credentialRepository.remove(type) + } + accountId = null _uiState.update { it.copy( + accountId = null, + accountLabel = null, + accountIndex = 0, message = UiText.Resource(R.string.msg_credential_deleted), hasExisting = false, apiKey = "", @@ -782,6 +849,7 @@ class CredentialEditViewModel @Inject constructor( triggerApiKey = "", traeJwt = "", traeRegion = "CN", + traeCheckinDeviceId = "", workBuddyAccess = "", workBuddyRefresh = "", sub2BaseUrl = "", @@ -793,10 +861,40 @@ 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() { _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) { val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return val text = cm.primaryClip?.getItemAt(0)?.text?.toString() @@ -853,6 +951,12 @@ class CredentialEditViewModel @Inject constructor( data class CredentialEditUiState( 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 loginUrl: String = "", val apiKey: String = "", @@ -868,6 +972,8 @@ data class CredentialEditUiState( /** Trae:Cloud-IDE-JWT + 区域 */ val traeJwt: String = "", val traeRegion: String = "CN", + /** Trae 签到设备 ID(官方客户端绑定 did;留空用内置默认) */ + val traeCheckinDeviceId: String = "", /** WorkBuddy:OAuth tokens */ val workBuddyAccess: String = "", val workBuddyRefresh: String = "", diff --git a/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt b/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt index da5d181..1b6c615 100644 --- a/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt @@ -73,6 +73,7 @@ import com.rainy.token.util.LocaleManager fun SettingsScreen( onBack: () -> Unit, onEditCredential: (ServiceType) -> Unit, + onManageAccounts: (ServiceType) -> Unit = {}, onOpenTips: () -> Unit = {}, onOpenDebugLog: () -> Unit = {}, viewModel: SettingsViewModel = hiltViewModel() @@ -137,10 +138,17 @@ fun SettingsScreen( modifier = Modifier.padding(start = 4.dp, bottom = 4.dp) ) } - items(uiState.credentialStatuses, key = { it.service.name }) { status -> + items(uiState.credentialStatuses, key = { it.status.service.name }) { line -> CredentialStatusCard( - status = status, - onClick = { onEditCredential(status.service) } + status = line.status, + accountCount = line.accountCount, + onClick = { + if (ServiceType.isMultiAccount(line.status.service)) { + onManageAccounts(line.status.service) + } else { + onEditCredential(line.status.service) + } + } ) } item { @@ -261,7 +269,11 @@ private fun LanguageDialog(onDismiss: () -> Unit) { } @Composable -private fun CredentialStatusCard(status: CredentialStatus, onClick: () -> Unit) { +private fun CredentialStatusCard( + status: CredentialStatus, + accountCount: Int = 0, + onClick: () -> Unit +) { Card( modifier = Modifier .fillMaxWidth() @@ -283,7 +295,11 @@ private fun CredentialStatusCard(status: CredentialStatus, onClick: () -> Unit) fontWeight = FontWeight.SemiBold ) Text( - text = stateLabel(status.state), + text = if (accountCount > 0) { + stringResource(R.string.account_count_line, stateLabel(status.state), accountCount) + } else { + stateLabel(status.state) + }, style = MaterialTheme.typography.bodySmall, color = InkMuted, modifier = Modifier.padding(top = 2.dp) diff --git a/app/src/main/java/com/rainy/token/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/rainy/token/ui/settings/SettingsViewModel.kt index d2e4313..a742351 100644 --- a/app/src/main/java/com/rainy/token/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/settings/SettingsViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.rainy.token.data.repository.CredentialRepository import com.rainy.token.domain.model.CredentialStatus +import com.rainy.token.domain.service.ServiceType import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -32,8 +33,16 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { _uiState.update { it.copy(loading = true) } 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 { - it.copy(loading = false, credentialStatuses = statuses) + it.copy(loading = false, credentialStatuses = lines) } } } @@ -41,5 +50,11 @@ class SettingsViewModel @Inject constructor( data class SettingsUiState( val loading: Boolean = false, - val credentialStatuses: List = emptyList() + val credentialStatuses: List = emptyList() +) + +/** 凭据状态行:单账号服务 [accountCount]=0;多账号服务为账号数(0 表示尚未配置)。 */ +data class CredentialStatusLine( + val status: CredentialStatus, + val accountCount: Int = 0 ) \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginScreen.kt b/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginScreen.kt index e828469..52cd313 100644 --- a/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginScreen.kt @@ -19,6 +19,8 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +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 @@ -27,6 +29,7 @@ import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -35,6 +38,7 @@ 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.data.debug.DebugLog import com.rainy.token.domain.service.ServiceType import com.rainy.token.ui.components.asString @@ -49,22 +53,34 @@ import com.rainy.token.ui.components.asString @Composable fun WebViewLoginScreen( service: ServiceType, + accountId: String? = null, onBack: () -> Unit, - onLoginSucceeded: (ServiceType) -> Unit, + onLoginSucceeded: (ServiceType, String?) -> Unit, /** 用户已保存的面板基址(Sub2API 的 loginUrl 为空,靠它提供登录入口与可信 host)。 */ configuredBaseUrl: String? = null, viewModel: WebViewLoginViewModel = hiltViewModel() ) { // configuredBaseUrl 可选:不传时 ViewModel 自行从凭据仓库读取(Sub2API 自托管实例) - LaunchedEffect(service, configuredBaseUrl) { - viewModel.bind(service, configuredBaseUrl) + LaunchedEffect(service, configuredBaseUrl, accountId) { + viewModel.bind(service, configuredBaseUrl, accountId) } val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } - // 登录成功自动回调 + // 登录成功自动回调(携带实际写入的账号 ID,供新建账号模式回填) LaunchedEffect(uiState.loginSucceeded) { if (uiState.loginSucceeded) { - onLoginSucceeded(service) + onLoginSucceeded(service, uiState.loginAccountId) + } + } + + // 自动识别服务(WorkBuddy 轮询超时、bindWorkBuddy 网络失败等)没有手动确认弹窗兜底, + // 错误通过 Snackbar 呈现,避免用户静默卡在登录页。 + // key 不含 pendingManualConfirm:错误文本不变时不重复提示(弹窗关闭不回放),且保持互斥。 + val errorText = uiState.error?.asString() + LaunchedEffect(errorText, uiState.loginSucceeded) { + if (errorText != null && !uiState.pendingManualConfirm && !uiState.loginSucceeded) { + snackbarHostState.showSnackbar(errorText) } } @@ -73,6 +89,7 @@ fun WebViewLoginScreen( } Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { TopAppBar( title = { Text(stringResource(R.string.title_webview_login, service.displayName)) }, @@ -136,19 +153,25 @@ fun WebViewLoginScreen( fun handleOverrideUrl(url: String): Boolean { // Trae OAuth 回调:拦截 127.0.0.1:18080/authorize,提取 userJwt - if (url.startsWith("http://127.0.0.1:18080/authorize") || - 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) return true } 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?) { url ?: return // 登录成功后面板会写入 localStorage.auth_token,抓到即自动保存 view?.evaluateJavascript(viewModel.probeScript, null) viewModel.onPageFinished(url) + DebugLog.i("TraeLogin", "pageFinished: ${url.toSafeLogUrl()}") } } webChromeClient = WebChromeClient() @@ -214,4 +237,20 @@ private fun parseProbeJson(json: String): Map = runCatching { if (v.isNotEmpty()) put(k, v) } } -}.getOrDefault(emptyMap()) \ No newline at end of file +}.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 +} \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginViewModel.kt b/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginViewModel.kt index ff858c8..80534f0 100644 --- a/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginViewModel.kt @@ -20,6 +20,7 @@ import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.longOrNull 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.CredentialRepository import com.rainy.token.domain.model.Credential @@ -57,9 +58,13 @@ class WebViewLoginViewModel @Inject constructor( /** WorkBuddy 轮询协程,bind 新服务时取消。 */ private var workBuddyPollingJob: Job? = null - fun bind(service: ServiceType, configuredBaseUrl: String? = null) { + /** 目标账号(多账号服务);null = 新建账号。 */ + private var accountId: String? = null + + fun bind(service: ServiceType, configuredBaseUrl: String? = null, accountId: String? = null) { workBuddyPollingJob?.cancel() workBuddyPollingJob = null + this.accountId = accountId?.takeIf { it.isNotBlank() } val config = ServiceConfigProvider.get(service) _uiState.update { @@ -75,16 +80,59 @@ class WebViewLoginViewModel @Inject constructor( when (service) { ServiceType.TRAE -> bindTrae() ServiceType.WORKBUDDY -> bindWorkBuddy() + ServiceType.OPENCODE_GO -> bindOpenCodeGo() 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 ────────────────────────────────────────────────────────────── private fun bindTrae() { val traceId = randomHex(16) val machineId = randomHex(32) - val deviceId = randomDigits(19) + // 设备 ID:claim 签到接口按 x-device-id 做设备级每日去重/风控,要求与 OAuth + // 会话绑定的设备一致。这里持久化生成每机唯一 ID(首次生成后跨登录复用), + // 登录 URL 与签到 claim 用同一个值。不能每次随机(会话绑定不一致会被风控拒), + // 也不可用 userJwt.ClientID(固定客户端标识,所有用户相同,必被按设备限流)。 + val deviceId = traeDeviceId() val callbackUrl = "http://127.0.0.1:18080/authorize" val authUrl = buildString { append("https://www.trae.cn/authorization?") @@ -100,9 +148,20 @@ class WebViewLoginViewModel @Inject constructor( append("x_os_version=1.0&x_app_version=0.1.43&") 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) } } + /** 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 形如: * `http://127.0.0.1:18080/authorize?userJwt=&userInfo=&...` @@ -115,29 +174,63 @@ class WebViewLoginViewModel @Inject constructor( if (service != ServiceType.TRAE) return if (_uiState.value.loginSucceeded) return - val parsed = url.toHttpUrlOrNull() ?: return + val parsed = url.toHttpUrlOrNull() ?: run { + DebugLog.w("TraeLogin", "onTraeCallback: URL 解析失败") + return + } + // 只记录参数名(不含 token 值,避免把 userJwt 明文落调试日志) + DebugLog.i("TraeLogin", "onTraeCallback: 收到回调,参数=${parsed.queryParameterNames}") // Trae 回调中参数名可能为 userJwt 或 user_jwt,值是 JSON 字符串 val userJwtRaw = parsed.queryParameter("userJwt") ?: parsed.queryParameter("user_jwt") - ?: return + ?: run { + DebugLog.w("TraeLogin", "onTraeCallback: 无 userJwt/user_jwt 参数") + return + } val userJwt = try { Json.parseToJsonElement(userJwtRaw).jsonObject - } catch (_: Exception) { + } catch (e: Exception) { + DebugLog.w("TraeLogin", "onTraeCallback: userJwt JSON 解析失败: ${e.message}") return } val token = userJwt["Token"]?.jsonPrimitive?.contentOrNull - if (token.isNullOrBlank()) return + if (token.isNullOrBlank()) { + 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 { + // 刚成功登录的凭据视为已验证:lastVerifiedAt 记为当前时间, + // 否则首页/列表会显示"未验证/未登录"直到进详情页拉余额后才正常 val credential = Credential.TraeCredential( service = ServiceType.TRAE, jwt = token, - region = "CN" + region = "CN", + deviceId = deviceId, + userId = userId?.takeIf { it.isNotBlank() }, + lastVerifiedAt = System.currentTimeMillis() ) - credentialRepository.save(credential) + val effectiveId = credentialRepository.save(credential, accountId) _uiState.update { - it.copy(loginSucceeded = true, pendingManualConfirm = false, error = null) + it.copy(loginSucceeded = true, pendingManualConfirm = false, error = null, loginAccountId = effectiveId.ifBlank { null }) } + DebugLog.i("TraeLogin", "onTraeCallback: 凭据已保存,登录完成") } } @@ -214,15 +307,22 @@ class WebViewLoginViewModel @Inject constructor( ?: data?.get("expires_in")?.jsonPrimitive?.longOrNull ?: 7200L if (!accessToken.isNullOrBlank()) { val expiresAt = System.currentTimeMillis() + expiresIn * 1000 + // 刚成功登录的凭据视为已验证(见 Trae 同款注释) val credential = Credential.WorkBuddyCredential( service = ServiceType.WORKBUDDY, accessToken = accessToken, refreshToken = refreshToken ?: "", - expiresAt = expiresAt + expiresAt = expiresAt, + lastVerifiedAt = System.currentTimeMillis() ) - credentialRepository.save(credential) + val effectiveId = credentialRepository.save(credential, accountId) _uiState.update { - it.copy(loginSucceeded = true, pendingManualConfirm = false, error = null) + it.copy( + loginSucceeded = true, + pendingManualConfirm = false, + error = null, + loginAccountId = effectiveId.ifBlank { null } + ) } return } @@ -266,6 +366,25 @@ class WebViewLoginViewModel @Inject constructor( fun onPageFinished(url: String) { val current = _uiState.value 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)) { saveSession(url) } else { @@ -274,6 +393,17 @@ class WebViewLoginViewModel @Inject constructor( } 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 saveSession(url) } @@ -296,11 +426,19 @@ class WebViewLoginViewModel @Inject constructor( baseUrl = origin.ifBlank { _uiState.value.loginUrl }, authToken = token, siteIcon = values[SUB2_ICON_KEY], - email = values[SUB2_EMAIL_KEY] + email = values[SUB2_EMAIL_KEY], + accountId = this@WebViewLoginViewModel.accountId ) if (saved != null) { _uiState.update { - it.copy(loginSucceeded = true, pendingManualConfirm = false, error = null) + it.copy( + loginSucceeded = true, + pendingManualConfirm = false, + error = null, + // 回填实际写入的账号 ID:新建账号流程返回编辑页时保持编辑该账号, + // 避免用户再次点保存产生重复账号 + loginAccountId = saved.accountId.ifBlank { null } + ) } } } @@ -374,6 +512,9 @@ private const val SUB2_ICON_KEY = "site_icon" private const val SUB2_EMAIL_KEY = "email" 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 = """ (function () { try { @@ -402,5 +543,7 @@ data class WebViewLoginUiState( val loginSucceeded: Boolean = false, val savedSession: Credential.SessionCredential? = null, val pendingManualConfirm: Boolean = false, - val error: UiText? = null + val error: UiText? = null, + /** Trae/WorkBuddy 登录实际写入的账号 ID(新建账号模式时用于回填信用编辑页)。 */ + val loginAccountId: String? = null ) diff --git a/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt b/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt index 06c94f2..56e54d1 100644 --- a/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt +++ b/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt @@ -8,11 +8,15 @@ import android.content.Context import android.content.Intent import android.content.res.ColorStateList import android.util.TypedValue +import android.view.View import android.widget.RemoteViews import com.rainy.token.MainActivity import com.rainy.token.R import com.rainy.token.data.cache.BalanceCache 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.ui.components.normalizeWindowLabel import com.rainy.token.util.LocaleManager @@ -57,12 +61,15 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() { appWidgetManager: AppWidgetManager, appWidgetIds: IntArray ) { - var selectedHasCachedData = false - // 每次渲染都用最新语言偏好重新包装 context:Application 层的 wrap 在进程启动时固定, // 应用内切换语言后不会自动更新,必须在这里按当前偏好重建,否则 widget 文本停留在旧语言 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) { val views = RemoteViews(context.packageName, R.layout.widget_opencode_go) @@ -104,49 +111,78 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() { try { val dataStore = context.applicationContext.balanceCacheDataStore val cache = BalanceCache(dataStore) - val selectedService = currentDisplayService(context) - views.setTextViewText(R.id.widget_switch, shortName(selectedService)) - views.setTextViewText( - R.id.widget_service_title, - localized.getString(R.string.widget_service_quota, selectedService.displayName) + views.setViewVisibility( + R.id.widget_ds_row, + if (dsConfigured) View.VISIBLE else View.GONE ) - views.setImageViewResource(R.id.widget_logo, widgetLogo(selectedService)) - // Ollama logo 是正方形,XML 默认 22x12 是给宽扁 logo 的 - if (selectedService == ServiceType.OLLAMA) { - 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) - } - val cached = cache.get(selectedService) - if (cached != null) { - selectedHasCachedData = true - populateServiceRows(views, localized, selectedService, cached.balance) - - val sdf = SimpleDateFormat("MM-dd HH:mm", Locale.getDefault()) - val timeText = sdf.format(Date(cached.fetchedAt)) + 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_updated, - localized.getString(R.string.widget_updated_at, timeText) + R.id.widget_service_title, + localized.getString( + if (dsConfigured) R.string.widget_deepseek_balance + else R.string.widget_not_configured + ) ) - } else { setEmptyState(views, localized) + } else { views.setTextViewText(R.id.widget_switch, shortName(selectedService)) views.setTextViewText( R.id.widget_service_title, localized.getString(R.string.widget_service_quota, selectedService.displayName) ) - } + views.setImageViewResource(R.id.widget_logo, widgetLogo(selectedService)) + // Ollama logo 是正方形,XML 默认 22x12 是给宽扁 logo 的 + if (selectedService == ServiceType.OLLAMA) { + 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) + } + val cached = cache.get(selectedService) + if (cached != null) { + populateServiceRows(views, localized, selectedService, cached.balance) - // DeepSeek 余额 - val dsCached = cache.get(ServiceType.DEEPSEEK) - if (dsCached != null && dsCached.balance.amount > 0) { - val dsBal = dsCached.balance - val dsText = dsBal.unit + String.format("%.2f", dsBal.amount) - views.setTextViewText(R.id.widget_ds_amount, dsText) - } else { - views.setTextViewText(R.id.widget_ds_amount, "—") + val sdf = SimpleDateFormat("MM-dd HH:mm", Locale.getDefault()) + val timeText = sdf.format(Date(cached.fetchedAt)) + views.setTextViewText( + R.id.widget_updated, + localized.getString(R.string.widget_updated_at, timeText) + ) + } else { + setEmptyState(views, localized) + views.setTextViewText(R.id.widget_switch, shortName(selectedService)) + views.setTextViewText( + R.id.widget_service_title, + localized.getString(R.string.widget_service_quota, selectedService.displayName) + ) + } + } + // DeepSeek 余额独立于轮换区填充,且必须放在所有 setEmptyState 之后: + // setEmptyState 会把 widget_ds_amount 写成 "—",后填的真值才能覆盖它 + if (dsConfigured) { + val dsCached = cache.get(ServiceType.DEEPSEEK) + if (dsCached != null && dsCached.balance.amount > 0) { + val dsBal = dsCached.balance + val dsText = dsBal.unit + String.format("%.2f", dsBal.amount) + views.setTextViewText(R.id.widget_ds_amount, dsText) + } else { + views.setTextViewText(R.id.widget_ds_amount, "—") + } } } catch (_: Exception) { setEmptyState(views, localized) @@ -156,8 +192,11 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() { appWidgetManager.updateAppWidget(widgetId, views) } - // 自动刷新:缓存为空 或 超过冷却时间 → 触发后台刷新 - if (!selectedHasCachedData || shouldAutoRefresh(context)) { + // 自动刷新:有已配置服务且超过冷却时间 → 触发后台刷新。 + // 缓存命中与否不参与决策——receiver 现在无条件重渲染,若以「缓存为空」为由 + // 绕过冷却,刷新失败时会形成 onUpdate → 广播 → 失败 → 重渲染 → 再广播的 + // 无冷却重试环;冷却约束是唯一的节流阀。 + if ((configured.isNotEmpty() || dsConfigured) && shouldAutoRefresh(context)) { markAutoRefreshTime(context) context.sendBroadcast(WidgetRefreshReceiver.createIntent(context)) } @@ -345,14 +384,57 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() { .apply() } - fun currentDisplayService(context: Context): ServiceType { - val key = autoRefreshPrefs(context).getString(KEY_DISPLAY_SERVICE, ServiceType.OPENCODE_GO.storageKey) - return DISPLAY_SERVICES.firstOrNull { it.storageKey == key } ?: ServiceType.OPENCODE_GO + /** + * 已配置凭据的服务(本地存在 credential 即视为已配置)。 + * 小组件只在这些服务间轮换展示;未配置的不占位。 + * 注:只覆盖 [DISPLAY_SERVICES](轮换区),DeepSeek 单独用 [isCredentialPresent] 判断。 + */ + fun configuredServices(context: Context): List { + 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? { + 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) { - val current = currentDisplayService(context) - val next = DISPLAY_SERVICES[(DISPLAY_SERVICES.indexOf(current).coerceAtLeast(0) + 1) % DISPLAY_SERVICES.size] + val configured = configuredServices(context) + if (configured.size <= 1) return + 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() } diff --git a/app/src/main/java/com/rainy/token/ui/widget/WidgetRefreshReceiver.kt b/app/src/main/java/com/rainy/token/ui/widget/WidgetRefreshReceiver.kt index 4d40023..0fb5017 100644 --- a/app/src/main/java/com/rainy/token/ui/widget/WidgetRefreshReceiver.kt +++ b/app/src/main/java/com/rainy/token/ui/widget/WidgetRefreshReceiver.kt @@ -23,26 +23,30 @@ class WidgetRefreshReceiver : BroadcastReceiver() { if (isRefreshing) return isRefreshing = true val pendingResult = goAsync() - val entryPoint = EntryPointAccessors.fromApplication( - appContext, WidgetRefreshEntryPoint::class.java - ) - val useCase = entryPoint.refreshBalanceUseCase() OpenCodeGoWidgetProvider.showRefreshing(appContext) CoroutineScope(Dispatchers.IO).launch { try { + // EntryPoint 获取也放进 try:任何异常都必须落到 finally, + // 否则 isRefreshing 卡死 true、pendingResult 泄漏 + val entryPoint = EntryPointAccessors.fromApplication( + appContext, WidgetRefreshEntryPoint::class.java + ) + val useCase = entryPoint.refreshBalanceUseCase() val selectedService = OpenCodeGoWidgetProvider.currentDisplayService(appContext) - val refreshed = withTimeoutOrNull(25_000L) { - val selectedResult = useCase(selectedService) - val dsResult = useCase(ServiceType.DEEPSEEK) - selectedResult.isSuccess || dsResult.isSuccess - } == true - if (refreshed) { - OpenCodeGoWidgetProvider.notifyDataChanged(appContext) + val dsConfigured = OpenCodeGoWidgetProvider.isCredentialPresent(appContext, ServiceType.DEEPSEEK) + if (selectedService != null || dsConfigured) { + withTimeoutOrNull(25_000L) { + // 只刷新「当前展示的服务 + 已配置的 DeepSeek」;未配置的不发请求 + selectedService?.let { useCase(it) } + if (dsConfigured) useCase(ServiceType.DEEPSEEK) + } } } catch (_: Exception) { // 静默,Widget 保留旧数据 } finally { + // 无论成败都重渲染:清掉「刷新中」占位,零配置时也能立即恢复空态文案 + OpenCodeGoWidgetProvider.notifyDataChanged(appContext) isRefreshing = false pendingResult.finish() } diff --git a/app/src/main/res/drawable-nodpi/ic_trae_logo.png b/app/src/main/res/drawable-nodpi/ic_trae_logo.png new file mode 100644 index 0000000..e46c382 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_trae_logo.png differ diff --git a/app/src/main/res/drawable/ic_workbuddy_logo.xml b/app/src/main/res/drawable/ic_workbuddy_logo.xml new file mode 100644 index 0000000..b5bab31 --- /dev/null +++ b/app/src/main/res/drawable/ic_workbuddy_logo.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/layout-night/widget_opencode_go.xml b/app/src/main/res/layout-night/widget_opencode_go.xml index a686d54..06e632d 100644 --- a/app/src/main/res/layout-night/widget_opencode_go.xml +++ b/app/src/main/res/layout-night/widget_opencode_go.xml @@ -181,8 +181,9 @@ android:text="--" android:textColor="#8A7A82" android:textSize="10sp" /> - + - + 点击左侧卡片即可 设置 API 管理 - 配置 - 编辑 测试 + 添加 API Key + 添加服务 + 所有服务都已添加 + 还没有添加任何 API + 点击右上角「+」添加一个 API Key 删除凭据? 将删除 %1$s 已保存的凭据。 未配置 + %1$d 个账号 · 点按管理 最后验证:%1$s 缓存余额:%1$s %2$s - 用量统计 - 暂无已配置服务 - %1$d 项刷新失败 - 尚未获取 - 更新于 %1$s - 本月消费 - 总额度 - 下次重置 ✅ %1$s 连接成功:%2$s %3$s ❌ %1$s 连接失败:%2$s 凭据无效 @@ -141,6 +137,24 @@ OAuth · CodeBuddy 同体系积分 自托管面板 · 用户级 sk- Key 余额 明细 + 账户信息 + 资源明细 + 资源 #%1$s + 套餐积分 + 已用/总量 + 使用中 + 下次权益周期更新 + 平台奖励积分 + 到期:%1$s + 奖励包 %1$d + 剩余 + 计费方式 + 积分 + 美元 + %1$s计费 + 订阅中 + 付费用户 + 免费用户 已消耗 消耗比例 总积分 @@ -158,6 +172,15 @@ 粘贴 Trae 登录态中的 Cloud-IDE-JWT(ey… 开头)。请求头按 Authorization: Cloud-IDE-JWT 发送。 Cloud-IDE-JWT 区域 + 签到设备 ID(可选) + 签到须使用官方客户端绑定的设备 ID。若持续失败,请在电脑客户端日志(AppData\\Roaming\\Trae CN\\logs\\日期目录\\main.log)中搜索 did=,将最新的值粘贴到此处。留空则使用内置默认。 + 设备 ID 怎么找?(分步指引) + 路径已复制,粘贴到 Win+R 或文件资源管理器即可 + 查找设备 ID(4 步) + 1. 在电脑上按 Win+R,粘贴下面的路径后回车(或复制后粘贴到文件资源管理器地址栏): + 2. 打开文件夹名里日期最新的那个文件夹。 + 3. 用记事本打开 main.log。 + 4. 按 Ctrl+F 搜索 did=,后面的数字(如 did=895482260673817)就是设备 ID,复制并粘贴到上面的输入框。 CN INTL WorkBuddy 凭据 @@ -210,8 +233,15 @@ 请先添加凭据后再签到。 INTL 区签到接口暂未支持,请用 CN 区账号 该设备今日签到次数已达上限 + 参与用户太多,请稍后重试 + 签到须使用官方客户端绑定的设备 ID。若持续失败,请在电脑上打开 Trae 客户端日志目录(AppData\\Roaming\\Trae CN\\logs\\日期目录\\main.log),搜索 did= 获取当前绑定的设备 ID,然后粘贴到「设置 → Trae → 签到设备 ID」。 签到失败(code=%1$d) 签到成功 +%1$d 积分 + 积分权益包 + 无限额度 + 通用积分 + Work 专属积分 + 剩余 %1$s/%2$s 分 · 已用 %3$s 在打开的页面中正常登录,App 自动读取登录态;也可继续手动填 API Key @@ -353,6 +383,34 @@ 凭据未配置 凭据未配置或已过期 凭据无效,请在设置中重新配置 + 登录已过期,请重新登录 + 自动签到 + 今日已签 ✓ + 每日自动签到 + %1$s 已签到,积分增加 %2$d + %1$s 今日已签到 + %1$s 签到失败:%2$s + %1$s 签到失败,请手动签到 + %1$s · 账号管理 + 添加账号 + 账号操作 + 默认 + 账号 %1$d + 设为默认 + 重命名 + 重命名账号 + 账号名称 + 删除账号 + 确定删除该账号?该账号的凭据与余额缓存将一并清除。 + 还没有账号,点下方按钮添加一个。 + %1$s · %2$d 个账号 + %1$s · 新账号 + %1$s · %2$s + %1$s(默认) + 已设为默认账号 + 已重命名 + 账号已删除 + 删除 凭据无效,请重新登录 凭据已变更,正在重新加载 凭据已变更,请重新操作 @@ -404,6 +462,7 @@ %1$s · %2$s 更新 %1$s 暂无数据 + 还没有配置任何服务 刷新中… @@ -566,6 +625,7 @@ 重新登录 配置凭据 充值 + 充值 / 订阅 当前余额 5h 实时用量 月度余额 diff --git a/app/src/main/res/values-b+zh+Hant/strings.xml b/app/src/main/res/values-b+zh+Hant/strings.xml index 8c5f0ba..4f2ea3b 100644 --- a/app/src/main/res/values-b+zh+Hant/strings.xml +++ b/app/src/main/res/values-b+zh+Hant/strings.xml @@ -46,22 +46,18 @@ 點選左側卡片即可 設定 API 管理 - 設定 - 編輯 測試 + 新增 API Key + 新增服務 + 所有服務都已新增 + 還沒有新增任何 API + 點擊右上角「+」新增一個 API Key 刪除憑證? 將刪除 %1$s 已儲存的憑證。 未設定 + %1$d 個帳號 · 點按管理 最後驗證:%1$s 快取餘額:%1$s %2$s - 用量統計 - 尚無已設定服務 - %1$d 項刷新失敗 - 尚未取得 - 更新於 %1$s - 本月消費 - 總額度 - 下次重置 ✅ %1$s 連線成功:%2$s %3$s ❌ %1$s 連線失敗:%2$s 憑證無效 @@ -141,6 +137,24 @@ OAuth · CodeBuddy 同體系積分 自托管面板 · 用戶級 sk- Key 餘額 明細 + 帳戶資訊 + 資源明細 + 資源 #%1$s + 套餐積分 + 已用/總量 + 使用中 + 下次權益週期更新 + 平台獎勵積分 + 到期:%1$s + 獎勵包 %1$d + 剩餘 + 計費方式 + 積分 + 美元 + %1$s計費 + 訂閱中 + 付費用戶 + 免費用戶 已消耗 消耗比例 總積分 @@ -158,6 +172,15 @@ 貼上 Trae 登入態中的 Cloud-IDE-JWT(ey… 開頭)。請求頭以 Authorization: Cloud-IDE-JWT 送出。 Cloud-IDE-JWT 區域 + 簽到裝置 ID(可選) + 簽到須使用官方用戶端綁定的裝置 ID。若持續失敗,請在電腦用戶端日誌(AppData\\Roaming\\Trae CN\\logs\\日期目錄\\main.log)中搜尋 did=,將最新的值貼到此處。留空則使用內建預設。 + 裝置 ID 怎麼找?(分步指引) + 路徑已複製,貼到 Win+R 或檔案總管即可 + 尋找裝置 ID(4 步) + 1. 在電腦上按 Win+R,貼上下面的路徑後按 Enter(或複製後貼到檔案總管位址列): + 2. 開啟資料夾名稱裡日期最新的那個資料夾。 + 3. 用記事本開啟 main.log。 + 4. 按 Ctrl+F 搜尋 did=,後面的數字(如 did=895482260673817)就是裝置 ID,複製並貼到上面的輸入框。 CN INTL WorkBuddy 憑證 @@ -210,8 +233,15 @@ 請先新增憑證後再簽到。 INTL 區簽到介面暫未支援,請用 CN 區帳號 該裝置今日簽到次數已達上限 + 參與用戶太多,請稍後重試 + 簽到須使用官方用戶端綁定的裝置 ID。若持續失敗,請在電腦上打開 Trae 用戶端日誌目錄(AppData\\Roaming\\Trae CN\\logs\\日期目錄\\main.log),搜尋 did= 取得目前綁定的裝置 ID,然後貼到「設定 → Trae → 簽到裝置 ID」。 簽到失敗(code=%1$d) 簽到成功 +%1$d 積分 + 積分權益包 + 無限額度 + 通用積分 + Work 專屬積分 + 剩餘 %1$s/%2$s 分 · 已用 %3$s 在開啟的頁面中正常登入,App 自動讀取登入態;也可繼續手動填 API Key @@ -353,6 +383,34 @@ 憑據未配置 憑據未配置或已過期 憑據無效,請在設定中重新配置 + 登入已過期,請重新登入 + 自動簽到 + 今日已簽 ✓ + 每日自動簽到 + %1$s 已簽到,積分增加 %2$d + %1$s 今日已簽到 + %1$s 簽到失敗:%2$s + %1$s 簽到失敗,請手動簽到 + %1$s · 帳號管理 + 新增帳號 + 帳號操作 + 預設 + 帳號 %1$d + 設為預設 + 重新命名 + 重新命名帳號 + 帳號名稱 + 刪除帳號 + 確定刪除該帳號?該帳號的憑據與餘額快取將一併清除。 + 還沒有帳號,點下方按鈕新增一個。 + %1$s · %2$d 個帳號 + %1$s · 新帳號 + %1$s · %2$s + %1$s(預設) + 已設為預設帳號 + 已重新命名 + 帳號已刪除 + 刪除 憑據無效,請重新登入 憑據已變更,正在重新載入 憑據已變更,請重新操作 @@ -404,6 +462,7 @@ %1$s · %2$s 更新 %1$s 暫無數據 + 還沒有設定任何服務 重新整理中… @@ -566,6 +625,7 @@ 重新登入 配置憑據 儲值 + 儲值 / 訂閱 當前餘額 5h 實時用量 月度餘額 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 016ba21..0b6076e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -50,22 +50,18 @@ Tap a card on the left Settings API Management - Configure - Edit Test + Add API Key + Add a service + All services have been added + No entries yet + Tap the + in the top-right corner to add an API key Delete credential? This will remove the saved credential for %1$s. Not configured + %1$d accounts · tap to manage Last verified: %1$s Cached: %1$s %2$s - Usage Stats - No configured services yet - %1$d refresh failed - Not fetched yet - Updated %1$s - Monthly spent - Total quota - Next reset ✅ %1$s connected: %2$s %3$s ❌ %1$s failed: %2$s Invalid credential @@ -144,6 +140,24 @@ OAuth · CodeBuddy 同体系积分 自托管面板 · 用户级 sk- Key 余额 Breakdown + Account info + Resources + #%1$s + Plan credits + Used / Total + In use + Next cycle + Platform rewards + Expires: %1$s + Reward %1$d + Remaining + Billing mode + Credits + USD + %1$s billing + Subscribed + Paid plan + Free plan Consumed Consumption ratio Total credits @@ -161,6 +175,15 @@ Paste the Cloud-IDE-JWT from your Trae account session (starts with ey…). Used as Authorization: Cloud-IDE-JWT header. Cloud-IDE-JWT Region + Check-in device ID (optional) + 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. + How to find the device ID? (step-by-step guide) + Path copied, paste it in Win+R or File Explorer + Find device ID (4 steps) + 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): + 2. Open the folder with the latest date in its name. + 3. Open main.log with Notepad. + 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. CN INTL WorkBuddy credentials @@ -213,8 +236,15 @@ Add your credentials first to check in. INTL check-in not yet supported, please use a CN account Device check-in limit reached today + Too many participants, please try again later + 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. Check-in failed (code=%1$d) Check-in successful +%1$d credits + Entitlement credits + Unlimited + Remaining %1$s/%2$s pts · used %3$s + General credits + Work credits Sign in normally on the opened page; the app reads the session automatically. Manual API Key still works. @@ -356,6 +386,34 @@ Credential not configured Credential missing or expired Credential invalid, please reconfigure in Settings + Login expired, please sign in again + Auto check-in + Checked in ✓ + Daily auto check-in + %1$s checked in, +%2$d credits + %1$s already checked in today + %1$s check-in failed: %2$s + %1$s check-in failed, please check in manually + %1$s · Accounts + Add Account + Account actions + Default + Account %1$d + Set as Default + Rename + Rename Account + Account name + Delete Account + Delete this account? Its credential and balance cache will also be removed. + No accounts yet. Tap below to add one. + %1$s · %2$d accounts + %1$s · New Account + %1$s · %2$s + %1$s (Default) + Set as default account + Renamed + Account deleted + Delete Credential invalid, please log in again Credentials changed, reloading Credentials changed, please retry @@ -407,6 +465,7 @@ %1$s · %2$s Updated %1$s No data + No services configured yet Refreshing… @@ -569,6 +628,7 @@ Log in again Configure credential Top up / Recharge + Top up / Subscribe Current balance 5h live usage Monthly balance diff --git a/app/src/test/java/com/rainy/token/data/repository/TraeRepositoryTest.kt b/app/src/test/java/com/rainy/token/data/repository/TraeRepositoryTest.kt index 31551ce..10b94d9 100644 --- a/app/src/test/java/com/rainy/token/data/repository/TraeRepositoryTest.kt +++ b/app/src/test/java/com/rainy/token/data/repository/TraeRepositoryTest.kt @@ -47,6 +47,15 @@ class TraeRepositoryTest { 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 fun alreadyKeywordWithoutCode_marksAlreadyChecked() { // 服务端换 code 但文案不变时仍识别为「账号级已签」 @@ -81,6 +90,24 @@ class TraeRepositoryTest { 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 fun unknownCode_isFailedAndKeepsCode() { val r = parse("""{"code":500,"message":"服务异常"}""").getOrThrow() @@ -107,4 +134,55 @@ class TraeRepositoryTest { 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) + } } diff --git a/gradle.properties b/gradle.properties index 79951c5..9ee64af 100644 --- a/gradle.properties +++ b/gradle.properties @@ -28,4 +28,5 @@ android.aapt2.process.daemon=false # KSP 2.x 在多文件 @Inject 跨文件依赖时偶发 "could not be resolved" 误报 # 关闭 incremental 模式解决 -ksp.incremental=false \ No newline at end of file +ksp.incremental=false +android.overridePathCheck=true