diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8895293..077c966 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -190,6 +190,7 @@ dependencies { // Core implementation(libs.androidx.core.ktx) + implementation(libs.androidsvg) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.viewmodel.compose) 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 09efac4..8cff148 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 @@ -37,7 +37,9 @@ class CredentialRepository @Inject constructor( internal data class LocalState( val status: CredentialStatus, val fingerprint: String?, - val cachedBalance: CachedBalance? + val cachedBalance: CachedBalance?, + /** 当前凭据对象(首页卡片可能需要读取如 Sub2API 站点 Logo 等附加字段)。 */ + val credential: Credential? = null ) private data class CacheRollbackEntry( @@ -268,7 +270,8 @@ class CredentialRepository @Inject constructor( return LocalState( status = status, fingerprint = credentialFingerprint(credential), - cachedBalance = cached + cachedBalance = cached, + credential = credential ) } 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 51b0b68..d10892a 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 @@ -8,11 +8,14 @@ import com.rainy.token.domain.model.Sub2DailyUsage import com.rainy.token.domain.model.Sub2ModelStat import com.rainy.token.domain.model.Sub2UsageDetail import com.rainy.token.domain.service.ServiceType +import java.time.LocalDate +import java.time.temporal.ChronoUnit import java.util.TimeZone import javax.inject.Singleton import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject @@ -67,14 +70,27 @@ class Sub2ApiRepository constructor( val base = normalizeBase(credential.baseUrl) if (base.isBlank()) return@withContext Result.failure(RepositoryError.InvalidCredential()) + // 面板模式:WebView 登录获取的 authToken 走用户级接口(全账号数据,与网页一致) + val panelToken = credential.authToken?.trim().takeUnless { it.isNullOrEmpty() } + if (panelToken != null) { + return@withContext fetchPanelBalance(base, panelToken) + } + val auth = resolveAuth(base, credential) ?: return@withContext Result.failure( RepositoryError.InvalidCredential("Sub2API 认证失败:请确认 API Key 有效,或用面板账号邮箱+密码") ) // 主端点 /v1/usage;404 时兜底 /api/v1/usage。 - // 带 days=90 多取每日明细(官方前端同款参数,网关不支持时忽略参数仍 200), + // days 只控制 daily_usage 窗口(1-90);model_stats 走 start_date/end_date(默认仅近30天)—— + // 两者默认不同步会导致「时间窗口统计未生效」,显式对齐为近 90 天。 // timezone 保证 daily_usage 的 date 按用户所在时区聚合。 - val usageQuery = "days=90&timezone=" + java.net.URLEncoder.encode(TimeZone.getDefault().id, "UTF-8") + val usageQuery = run { + val today = java.time.LocalDate.now() + val start = today.minusDays(89).toString() + val end = today.toString() + "days=90&start_date=" + start + "&end_date=" + end + + "&timezone=" + java.net.URLEncoder.encode(TimeZone.getDefault().id, "UTF-8") + } val resp = get(base, "/v1/usage?$usageQuery", auth) ?.takeIf { it.code != 404 && it.code != 400 } ?: get(base, "/api/v1/usage?$usageQuery", auth) @@ -87,6 +103,9 @@ class Sub2ApiRepository constructor( if (resp.code !in 200..299) { return@withContext Result.failure(mapSubHttpError(resp.code)) } + if (com.rainy.token.BuildConfig.DEBUG) { + android.util.Log.d("Sub2Api", "resp(" + resp.code + ", " + resp.body.length + " chars): " + resp.body.take(3000)) + } val stats = runCatching { parseUsageStats(resp.body) }.getOrElse { e -> return@withContext Result.failure( RepositoryError.ParseError(RepositoryError.ParseErrorReason.MALFORMED_RESPONSE, "sub2api /v1/usage 解析失败: ${e.message}") @@ -111,13 +130,14 @@ class Sub2ApiRepository constructor( // ---------- 认证 ---------- /** 返回可用认证头;失败返回 null。有 API Key 走 Bearer(普通用户推荐),否则登录换 token。 */ - private fun resolveAuth(base: String, credential: Credential.Sub2ApiCredential): AuthHeader? { + private fun resolveAuth(base: String, credential: Credential.Sub2ApiCredential, totp: String = ""): AuthHeader? { if (!credential.apiKey.isNullOrBlank()) { return AuthHeader("Authorization", "Bearer ${credential.apiKey.trim()}") } val body = buildJsonObject { put("email", credential.email) put("password", credential.password) + if (totp.isNotBlank()) put("totp_code", totp) }.toString() val loginUrl = "$base/api/v1/auth/login" val request = Request.Builder() @@ -206,6 +226,18 @@ class Sub2ApiRepository constructor( } }.getOrNull() + /** + * 面板接口统一错误判定:非 2xx 直接映射为 RepositoryError(401/403 = 面板登录失效)。 + * 返回 null 表示响应可用。避免把「登录失效」静默当成余额 $0.00 / 空模型列表。 + */ + private fun panelFailure(resp: SubApiResponse): RepositoryError? { + if (resp.code in 200..299) return null + return when (resp.code) { + 401, 403 -> RepositoryError.InvalidCredential("Sub2API 面板登录已失效,请重新网页登录") + else -> mapSubHttpError(resp.code) + } + } + private fun mapSubHttpError(code: Int): RepositoryError = when (code) { 401, 403 -> RepositoryError.InvalidCredential() 423 -> RepositoryError.ServerError(423) @@ -214,6 +246,464 @@ class Sub2ApiRepository constructor( else -> RepositoryError.Unknown() } + // ---------- 面板模式(authToken → 全账号数据)---------- + + /** + * 面板模式余额(v0.2.10 实测接口,与网页「使用记录」页一致,actual_cost 口径): + * - GET /api/v1/auth/me → data.balance(余额) + * - GET /api/v1/usage/stats?start_date=本月首日&end_date=今天 → total_actual_cost(本月实际扣费) + * - 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 { + val tz = java.net.URLEncoder.encode(TimeZone.getDefault().id, "UTF-8") + val today = java.time.LocalDate.now() + val monthStart = today.withDayOfMonth(1).toString() + val end = today.toString() + // daily 明细拉满 90 天:7/30/90 天窗口都能取到完整真数据(网关前 60 天无一刀切限制,实测支持) + val dayStart = today.minusDays(89).toString() + val auth = AuthHeader("Authorization", "Bearer " + token) + + val meResp = get(base, "/api/v1/auth/me?timezone=" + tz, auth) + ?: return Result.failure(RepositoryError.InvalidCredential("Sub2API 面板登录已失效,请重新网页登录")) + panelFailure(meResp)?.let { return Result.failure(it) } + val meBody = meResp.body + val balance = runCatching { + val root = json.parseToJsonElement(meBody).jsonObject + val data = root["data"]?.jsonObject ?: root + data.numberOr("balance") ?: error("auth/me 响应缺少 balance 字段") + }.getOrElse { + return Result.failure( + RepositoryError.ParseError( + RepositoryError.ParseErrorReason.MALFORMED_RESPONSE, + "Sub2API 面板响应解析失败(auth/me): " + it.message + ) + ) + } + + val statsResp = get(base, "/api/v1/usage/stats?start_date=" + monthStart + "&end_date=" + end + "&timezone=" + tz, auth) + panelFailure(statsResp ?: SubApiResponse(0, ""))?.let { return Result.failure(it) } + val statsBody = statsResp?.body + val statsObj = statsBody?.let { runCatching { json.parseToJsonElement(it).jsonObject["data"]?.jsonObject }.getOrNull() } + val monthlySpent = statsObj?.numberOr("total_actual_cost") ?: 0.0 + val totalRequests = statsObj?.numberOr("total_requests")?.toLong() ?: 0L + val totalTokens = statsObj?.numberOr("total_tokens")?.toLong() ?: 0L + + // 与 daily 明细同窗口(近 90 天):两者口径不一致时「全部」窗口的模型分布与日柱会错位 + val modelsResp = get( + base, + "/api/v1/usage/dashboard/models?start_date=" + dayStart + "&end_date=" + end + + "&model_source=requested&timezone=" + tz, + auth + ) + panelFailure(modelsResp ?: SubApiResponse(0, ""))?.let { return Result.failure(it) } + val modelsBody = modelsResp?.body + val modelStats = modelsBody?.let { runCatching { parsePanelModels(it) }.getOrNull() } ?: emptyList() + + val snapResp = get( + base, + "/api/v1/usage/dashboard/snapshot-v2?start_date=" + dayStart + "&end_date=" + end + + "&granularity=day&include_trend=true&include_model_stats=false&include_group_stats=false&timezone=" + tz, + auth + ) + panelFailure(snapResp ?: SubApiResponse(0, ""))?.let { return Result.failure(it) } + val snapBody = snapResp?.body + val dailyUsage = snapBody?.let { runCatching { parsePanelTrend(it) }.getOrNull() } ?: emptyList() + + if (com.rainy.token.BuildConfig.DEBUG) { + android.util.Log.d( + "Sub2Api", + "panel: balance=" + balance + " monthSpent=" + monthlySpent + + " models=" + modelStats.size + " daily=" + dailyUsage.size + ) + } + + val extras = linkedMapOf() + extras["scope"] = "panel account (all keys)" + extras["month_start"] = monthStart + if (totalRequests > 0) extras["total_requests"] = totalRequests.toString() + if (totalTokens > 0) extras["total_tokens"] = totalTokens.toString() + modelStats.take(8).forEach { stat -> + val label = buildString { + if (stat.totalTokens > 0) append(stat.totalTokens).append(" tok") + if (stat.totalTokens > 0 && stat.cost > 0) append(" · ") + if (stat.cost > 0) append("$").append(trimNum(stat.cost)) + } + if (label.isNotBlank()) extras["model:" + stat.model] = label + } + + val panelBalance = ServiceBalance( + service = ServiceType.SUB2API, + amount = balance, + unit = "USD", + isAvailable = true, + monthlySpent = monthlySpent, + extras = extras, + sub2UsageDetail = Sub2UsageDetail(modelStats = modelStats, dailyUsage = dailyUsage) + ) + // 面板模式提前 return 时也要写余额缓存,否则主页列表读不到(显示「待获取」)。 + // 通过 RefreshWriteSession 机制暂存,随本次成功刷新一起提交。 + balanceCache.put(ServiceType.SUB2API, panelBalance) + return Result.success(panelBalance) + } + + /** 面板 models 分布 → Sub2ModelStat(actual_cost 优先,与网页「按实际消费」排序一致)。 */ + private fun parsePanelModels(body: String): List { + val root = json.parseToJsonElement(body).jsonObject + val data = root["data"]?.jsonObject ?: root + val arr = data["models"] as? JsonArray ?: return emptyList() + return arr.mapNotNull { el -> + val o = el as? JsonObject ?: return@mapNotNull null + Sub2ModelStat( + model = o.scalar("model") ?: return@mapNotNull null, + requests = o.longOr("requests") ?: 0, + inputTokens = o.longOr("input_tokens") ?: 0, + outputTokens = o.longOr("output_tokens") ?: 0, + totalTokens = o.longOr("total_tokens") + ?: ((o.longOr("input_tokens") ?: 0) + (o.longOr("output_tokens") ?: 0)), + cost = o.numberOr("actual_cost") ?: o.numberOr("cost") ?: 0.0 + ) + }.sortedByDescending { it.cost } + } + + /** 面板 snapshot-v2.trend → Sub2DailyUsage(actual_cost 优先,cost 兜底)。 */ + private fun parsePanelTrend(body: String): List { + val root = json.parseToJsonElement(body).jsonObject + val data = root["data"]?.jsonObject ?: root + val arr = data["trend"] as? JsonArray ?: return emptyList() + return arr.mapNotNull { el -> + val o = el as? JsonObject ?: return@mapNotNull null + Sub2DailyUsage( + date = o.scalar("date") ?: return@mapNotNull null, + requests = o.longOr("requests") ?: 0, + totalTokens = o.longOr("total_tokens") ?: 0, + cost = o.numberOr("actual_cost") ?: o.numberOr("cost") ?: 0.0 + ) + }.sortedBy { it.date } + } + + // ---------- 面板一键登录 / 站点信息 ---------- + + /** + * 面板账号一键登录:POST /api/v1/auth/login(含 423 合规重试)→ 面板 authToken。 + * 成功后调用方把 token 存入 Sub2ApiCredential.authToken,fetchBalance 自动走面板模式(全账号统计)。 + */ + suspend fun loginPanel(base: String, email: String, password: String, totp: String = ""): Result = withContext(Dispatchers.IO) { + val normalized = normalizeBase(base) + if (normalized.isBlank() || email.isBlank() || password.isBlank()) { + return@withContext Result.failure(RepositoryError.InvalidCredential("请先填写实例地址、邮箱与密码")) + } + val tmp = Credential.Sub2ApiCredential( + service = ServiceType.SUB2API, + baseUrl = normalized, + email = email, + password = password + ) + val auth = resolveAuth(normalized, tmp, totp) + ?: return@withContext Result.failure( + RepositoryError.InvalidCredential("登录失败:请检查邮箱/密码;若开启了二次验证请改用网页登录") + ) + val token = auth.value.removePrefix("Bearer ").trim() + if (token.isBlank()) { + return@withContext Result.failure(RepositoryError.InvalidCredential("登录响应中未找到 Token")) + } + Result.success(token) + } + + /** 面板站点信息(凭据页预览用:确认地址填对了)。 */ + data class PanelSiteInfo(val name: String, val iconUrl: String?) + + /** + * 抓取面板站点名称/图标:优先 /api/v1/public/settings(系统设置公开字段), + * 回退抓首页 HTML 的 与 favicon。失败返回 null(UI 隐藏预览行,静默降级)。 + */ + suspend fun fetchSiteInfo(base: String): PanelSiteInfo? = withContext(Dispatchers.IO) { + val normalized = normalizeBase(base) + if (normalized.isBlank()) return@withContext null + // 1) 公开设置接口 + try { + val fromSettings = okHttpClient.newCall( + Request.Builder().url(normalized + "/api/v1/public/settings").get().build() + ).execute().use { resp -> + if (!resp.isSuccessful) return@use null + val text = resp.body?.string().orEmpty() + val root = json.parseToJsonElement(text).jsonObject + val data = root["data"]?.jsonObject ?: root + val name = data.scalar("system_name", "site_name", "logo_text", "title") + val icon = data.scalar("system_logo", "logo", "favicon") + ?.let { u -> if (u.startsWith("http")) u else normalized + u } + if (name.isNullOrBlank()) null else PanelSiteInfo(name, icon) + } + if (fromSettings != null) return@withContext fromSettings + } catch (_: Throwable) { + // 面板可能没有该接口或返回非 JSON,回退抓首页 + } + // 2) 回退:首页 HTML title + favicon + try { + okHttpClient.newCall(Request.Builder().url(normalized).get().build()) + .execute().use { resp -> + val text = resp.body?.string().orEmpty().take(200_000) + val title = Regex("<title[^>]*>([^<]+)").find(text)?.groupValues?.get(1)?.trim() + val icon = Regex("rel=\"(?:icon|shortcut icon)\"[^>]*href=\"([^\"]+)\"").find(text)?.groupValues?.get(1) + ?: Regex("href=\"([^\"]+)\"[^>]*rel=\"(?:icon|shortcut icon)\"").find(text)?.groupValues?.get(1) + if (title.isNullOrBlank()) null + else PanelSiteInfo(title, icon?.let { u -> if (u.startsWith("http")) u else normalized + u }) + } + } catch (_: Throwable) { + null + } + } + + // ---------- 面板管理(Key 列表/新建/分组/兑换,v0.2.10 实测) ---------- + + /** 面板 API Key 条目(列表/新建/分组共用字段)。 */ + data class PanelApiKey( + val id: Long, + val name: String, + val key: String, + val groupId: Long?, + val status: String, + val lastUsedAt: String?, + val createdAt: String?, + val quota: Double + ) + + /** 可用分组(切换分组选择器数据)。 */ + 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, "面板响应解析失败")) } + ) + } + + /** + * 按面板 Token / 用户 API Key 两种认证方式,拉取指定日期区间内的分模型统计 + * (model_stats 受 start_date/end_date 控制,与主页拉取逻辑对齐)。 + * 面板模式:GET /api/v1/usage/dashboard/models?start_date=&end_date=… + * Key 模式:GET /v1/usage?days=&start_date=&end_date=…(404/400 自动兜底 /api/v1/usage)。 + */ + suspend fun fetchWindowModelStats(startDate: LocalDate, endDate: LocalDate): Result> = + withContext(Dispatchers.IO) { + val credential = credentialRepository.get(ServiceType.SUB2API) 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 start = startDate.toString() + val end = endDate.toString() + val tz = tzParam() + val panelToken = credential.authToken?.trim().takeUnless { it.isNullOrEmpty() } + + val models = 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) } + runCatching { parsePanelModels(resp.body) }.getOrElse { e -> + return@withContext Result.failure( + RepositoryError.ParseError(RepositoryError.ParseErrorReason.MALFORMED_RESPONSE, "面板响应解析失败: " + e.message) + ) + } + } else { + val auth = resolveAuth(base, credential) + ?: return@withContext Result.failure(RepositoryError.InvalidCredential("Sub2API 认证失败,请检查 API Key")) + val days = (ChronoUnit.DAYS.between(startDate, endDate) + 1).coerceIn(1L, 90L) + val query = "days=$days&start_date=$start&end_date=$end&timezone=$tz" + val resp = get(base, "/v1/usage?$query", auth)?.takeIf { it.code != 404 && it.code != 400 } + ?: get(base, "/api/v1/usage?$query", auth)?.takeIf { it.code != 404 && it.code != 400 } + ?: get(base, "/v1/usage", auth)?.takeIf { it.code != 404 } + ?: get(base, "/api/v1/usage", auth) + ?: return@withContext Result.failure(RepositoryError.Unknown()) + if (resp.code !in 200..299) { + return@withContext Result.failure(mapSubHttpError(resp.code)) + } + runCatching { parseUsageDetail(resp.body).modelStats }.getOrElse { e -> + return@withContext Result.failure( + RepositoryError.ParseError(RepositoryError.ParseErrorReason.MALFORMED_RESPONSE, "sub2api 窗口模型统计解析失败: " + e.message) + ) + } + } + Result.success(models.sortedByDescending { it.cost }) + } + + /** + * 新建 Key:POST /api/v1/keys {name, group_id?} → 返回明文 sk-key。 + * 兼容多种响应形态: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))) + } + 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)) + } + } + + /** 修改 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("请先登录面板")) + 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 { + val obj = json.parseToJsonElement(resp.body).jsonObject + // 兼容 {code:0} 与 {success:true} 两种面板响应形态 + val ok = obj.numberOr("code", "status")?.let { it == 0.0 } + ?: obj["success"]?.jsonPrimitive?.contentOrNull?.toBooleanStrictOrNull() + // 既无业务码也无 success 时,用 data/message 是否存在做兜底判断 + ?: (obj["data"] != null || obj["message"] == null) + if (ok) Result.success(true) + else Result.failure( + RepositoryError.Unknown(IllegalStateException(obj.scalar("message", "error") ?: "修改 Key 失败")) + ) + }.getOrElse { e -> Result.failure(RepositoryError.Unknown(IllegalStateException("修改 Key 失败:" + e.message))) } + } + + /** 可用分组列表(切换分组选择器)。 */ + 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, "面板响应解析失败")) } + ) + } + + /** 兑换卡密: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("兑换失败"))) + panelFailure(resp)?.let { return@withContext Result.failure(it) } + runCatching { + val obj = json.parseToJsonElement(resp.body).jsonObject + val codeNum = obj.numberOr("code") + val successFlag = obj["success"]?.jsonPrimitive?.contentOrNull?.toBooleanStrictOrNull() + val msg = obj.scalar("message") ?: "" + val ok = when { + codeNum != null -> codeNum == 0.0 + successFlag != null -> successFlag + // 既无 code 也无 success:响应里能解析出 data 即视为成功 + else -> obj["data"] != null + } + if (ok) { + Result.success("兑换成功" + (obj["data"]?.jsonObject?.scalar("value")?.let { "(+{$it})" } ?: "")) + } else { + Result.failure(RepositoryError.Unknown(IllegalStateException(msg.ifBlank { "兑换失败" }))) + } + }.getOrElse { Result.failure(RepositoryError.ParseError(RepositoryError.ParseErrorReason.MALFORMED_RESPONSE, "兑换响应解析失败")) } + } + + /** 面板凭证获取(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 + } + + private fun tzParam(): String = java.net.URLEncoder.encode(TimeZone.getDefault().id, "UTF-8") + + private suspend fun post(base: String, path: String, auth: AuthHeader, body: String): SubApiResponse? { + return try { + val resp = okHttpClient.newCall( + Request.Builder() + .url(base + path) + .addHeader(auth.name, auth.value) + .addHeader("Content-Type", "application/json") + .post(body.toRequestBody(jsonMedia)) + .build() + ).execute() + resp.use { SubApiResponse(it.code, it.body?.string().orEmpty()) } + } catch (_: Throwable) { null } + } + + private suspend fun put(base: String, path: String, auth: AuthHeader, body: String): SubApiResponse? { + return try { + val resp = okHttpClient.newCall( + Request.Builder() + .url(base + path) + .addHeader(auth.name, auth.value) + .addHeader("Content-Type", "application/json") + .put(body.toRequestBody(jsonMedia)) + .build() + ).execute() + resp.use { SubApiResponse(it.code, it.body?.string().orEmpty()) } + } catch (_: Throwable) { null } + } // ---------- 解析 ---------- private data class UsageStats( @@ -329,8 +819,10 @@ class Sub2ApiRepository constructor( requests = o.longOr("requests", "request_count") ?: 0, inputTokens = input, outputTokens = output, - totalTokens = o.longOr("total_tokens") ?: (input + output), - cost = o.numberOr("cost", "actual_cost", "amount") ?: 0.0 + // total_tokens 优先;老版网关只有 tokens 时回退 tokens,再回退 input+output + totalTokens = o.longOr("total_tokens") ?: o.longOr("tokens") ?: (input + output), + // actual_cost 优先(实际扣费),cost 是标准计费(标价),优先取标价会高估高价低倍率模型 + cost = o.numberOr("actual_cost", "cost", "amount") ?: 0.0 ) }.getOrNull() } @@ -347,7 +839,7 @@ class Sub2ApiRepository constructor( date = date, requests = o.longOr("requests") ?: 0, totalTokens = o.longOr("total_tokens", "tokens") ?: (input + output), - cost = o.numberOr("cost", "actual_cost") ?: 0.0 + cost = o.numberOr("actual_cost", "cost") ?: 0.0 ) }.getOrNull() }.sortedBy { it.date } 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 6dddc7e..ca61967 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 @@ -193,6 +193,108 @@ class TraeRepository constructor( return null } + // ---------- 每日签到(checkin_credits)---------- + + /** 签到结果(来源:trae-check 实测字段与语义)。 */ + data class CheckinResult( + val success: Boolean, + /** 今日已签(账号级,非设备拦截) */ + val alreadyChecked: Boolean, + val points: Long, + val message: String + ) + + /** + * 执行 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」=设备级拦截(提示而非报错)。 + * INTL 区签到接口未实测,暂不支持(返回明确提示)。 + */ + suspend fun checkIn(): Result = withContext(Dispatchers.IO) { + val credential = credentialRepository.get(ServiceType.TRAE) + ?: return@withContext Result.failure(RepositoryError.InvalidCredential()) + if (credential !is Credential.TraeCredential || credential.jwt.isBlank()) { + return@withContext Result.failure(RepositoryError.InvalidCredential()) + } + val regionKey = credential.region.uppercase().takeIf { it == "INTL" } ?: "CN" + if (regionKey == "INTL") { + return@withContext Result.success( + CheckinResult(false, false, 0, "INTL 区签到接口暂未支持,请用 CN 区账号") + ) + } + 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)) + } + + parseCheckin(body) + } + + /** 解析 claim 响应(code/message/points 多候选;语义判断见 checkIn 注释)。 */ + private fun parseCheckin(body: String): Result = runCatching { + val root = json.parseToJsonElement(body).jsonObject + val data = root["data"]?.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 + + 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 && ( + msg.contains("已签到") || msg.contains("已经签到") || msg.contains("明日再来") || + msg.contains("今日已完成") || msg.contains("已领取") || + lowerMsg.contains("already") || lowerMsg.contains("checked") || lowerMsg.contains("claimed") + )) + + when { + succeeded -> Result.success( + CheckinResult(true, false, points.takeIf { it > 0 } ?: 200L, msg.ifBlank { "签到成功" }) + ) + already -> Result.success(CheckinResult(true, true, 0L, "今日已签到")) + deviceBlock -> Result.success(CheckinResult(false, false, 0L, msg.ifBlank { "该设备今日签到次数已达上限" })) + else -> Result.success(CheckinResult(false, false, 0L, msg.ifBlank { "签到失败(code=" + code + ")" })) + } + }.getOrElse { Result.failure(RepositoryError.Unknown(it)) } + + /** 由 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") + + private fun JsonObject.scalarOr(vararg keys: String): String? { + for (key in keys) { + this[key]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }?.let { return it } + } + return null + } + private fun mapHttpError(code: Int): RepositoryError = when (code) { 401, 403 -> RepositoryError.InvalidCredential() 429 -> RepositoryError.RateLimited() 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 c238c58..8539279 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 @@ -274,6 +274,138 @@ class WorkBuddyRepository constructor( return null } + // ---------- 每日签到(Buddy 加油站 / codebuddy.cn)---------- + + /** + * 签到状态摘要(来源:cpa-multi-plugins workbuddy 插件实测字段)。 + * active=活动进行中;todayCheckedIn=今日已签;streakDays=连签;daily/todayCredit=每日/今日积分。 + */ + data class CheckinStatus( + val active: Boolean, + val todayCheckedIn: Boolean, + val streakDays: Long, + val dailyCredit: Long, + val todayCredit: Long, + val activityName: String, + /** 签到动作后的提示(签到成功/今日已签);查询模式为空串 */ + val message: String = "" + ) + + /** 查询今日签到状态。uid 未知时省略 X-User-Id 头(源码确认该头可选)。 */ + suspend fun fetchCheckinStatus(): Result = withContext(Dispatchers.IO) { + val credential = credentialRepository.get(ServiceType.WORKBUDDY) + ?: return@withContext Result.failure(RepositoryError.InvalidCredential()) + if (credential !is Credential.WorkBuddyCredential) { + return@withContext Result.failure(RepositoryError.InvalidCredential()) + } + // 两条候选路径 fallback(cpa-plugin 实测:activity-status 优先,旧路径 checkin-status 兜底) + val body = authedPost(credential, "/v2/billing/meter/checkin-activity-status") + ?: authedPost(credential, "/v2/billing/meter/checkin-status") + ?: return@withContext Result.failure(RepositoryError.Unknown()) + runCatching { parseCheckin(body) }.fold( + onSuccess = { Result.success(it) }, + onFailure = { + Result.failure( + RepositoryError.ParseError( + RepositoryError.ParseErrorReason.MALFORMED_RESPONSE, + "workbuddy checkin status 解析失败: " + it.message + ) + ) + } + ) + } + + /** + * 执行今日签到(幂等:code=0 成功 / code=10001 已签,两种都算成功;源码确认 HTTP 400 也可能是已签)。 + * 成功/已签后刷新一次状态并附带 message 提示。 + */ + suspend fun checkIn(): Result = withContext(Dispatchers.IO) { + val credential = credentialRepository.get(ServiceType.WORKBUDDY) + ?: return@withContext Result.failure(RepositoryError.InvalidCredential()) + if (credential !is Credential.WorkBuddyCredential) { + return@withContext Result.failure(RepositoryError.InvalidCredential()) + } + val body = authedPost(credential, "/v2/billing/meter/daily-checkin") + ?: return@withContext Result.failure(RepositoryError.Unknown()) + 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() + when { + code == 0L -> Result.success( + status?.copy(message = "签到成功 +" + (status.todayCredit.takeIf { it > 0 } ?: status.dailyCredit) + " 积分") + ?: CheckinStatus(true, true, 1, 0, 0, "", "签到成功") + ) + code == 10001L -> Result.success(status?.copy(message = "今日已签到") ?: CheckinStatus(true, true, 0, 0, 0, "", "今日已签到")) + 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 = if (msg.isBlank()) "签到失败" else msg)) + else Result.failure(RepositoryError.Unknown()) + } + } + } + + /** 带自动 refresh 一次的 POST(Bearer);返回响应体,失败 null。 */ + private fun authedPost(credential: Credential.WorkBuddyCredential, path: String): String? { + var access = credential.accessToken + var refresh = credential.refreshToken + repeat(2) { attempt -> + val request = Request.Builder() + .url("https://www.codebuddy.cn" + path) + .addHeader("Authorization", "Bearer " + access) + .addHeader("Accept", "application/json") + .addHeader("Content-Type", "application/json") + .post("{}".toRequestBody(jsonMedia)) + .build() + val resp = try { + okHttpClient.newCall(request).execute() + } catch (_: Throwable) { + return null + } + val code = resp.code + val body = runCatching { resp.body?.string().orEmpty() }.getOrDefault("") + resp.close() + when { + code in 200..299 -> return body + (code == 401 || code == 403) && attempt == 0 -> { + val refreshed = tryRefesh(refresh) ?: return null + // 轮换后的凭据由调用方主流程 save(此处仅内存内继续),避免半途改库 + access = refreshed.accessToken + refresh = refreshed.refreshToken + } + else -> return null + } + } + return null + } + + /** 解析 checkin-activity-status 响应(data 平铺或 data 包裹,防御式多候选)。 */ + private fun parseCheckin(body: String): CheckinStatus { + val root = json.parseToJsonElement(body).jsonObject + val obj = root["data"]?.jsonObject ?: root + return CheckinStatus( + active = obj.scalar("active", "Active")?.toBoolean() ?: true, + todayCheckedIn = obj.scalar("today_checked_in", "todayCheckedIn")?.toBoolean() ?: false, + streakDays = obj.longOr("streak_days", "streakDays") ?: 0, + dailyCredit = obj.longOr("daily_credit", "dailyCredit") ?: 0, + todayCredit = obj.longOr("today_credit", "todayCredit") ?: 0, + activityName = obj.scalar("activity_name", "activityName", "theme_name") ?: "" + ) + } + + private fun JsonObject.longOr(vararg keys: String): Long? { + for (key in keys) { + this[key]?.jsonPrimitive?.contentOrNull?.toLongOrNull()?.let { return it } + } + return null + } + private fun mapHttpError(code: Int, body: String): RepositoryError = when (code) { 401, 403 -> RepositoryError.InvalidCredential() 429 -> RepositoryError.RateLimited() 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 b062dfc..4f1a488 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 @@ -109,6 +109,14 @@ sealed class Credential { val password: String, /** 用户级 API Key(sk-…),存在时优先于 email/password 认证 */ val apiKey: String? = null, + /** + * 面板登录 Token(App 内 WebView 登录后自动从 localStorage.auth_token 提取)。 + * 存在时走【面板模式】:全账号 usage/stats + dashboard/models + snapshot-v2 + * (数据与网页「使用记录」页一致,actual_cost 口径)。 + */ + val authToken: String? = null, + /** 站点 Logo URL(登录/探测时抓取;主页卡片显示,替换内置图标)。 */ + val siteIcon: String? = null, override val lastVerifiedAt: Long = 0L ) : Credential() } diff --git a/app/src/main/java/com/rainy/token/ui/components/SiteIcon.kt b/app/src/main/java/com/rainy/token/ui/components/SiteIcon.kt new file mode 100644 index 0000000..2ee93f5 --- /dev/null +++ b/app/src/main/java/com/rainy/token/ui/components/SiteIcon.kt @@ -0,0 +1,65 @@ +package com.rainy.token.ui.components + +import android.graphics.BitmapFactory +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.unit.Dp +import com.caverock.androidsvg.SVG +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request + +/** + * 轻量站点图标加载(OkHttp 拉取,无三方图片库)。 + * 支持 PNG/JPEG/WebP(BitmapFactory)与 SVG(androidsvg 渲染);失败静默不渲染。 + * 用于 Sub2API 凭据页站点预览与主页卡片 Logo。 + */ +@Composable +fun SiteIcon(url: String, size: Dp) { + val icon by produceState(initialValue = null, url) { + // 网络 + SVG 解码放到 IO 线程:produceState 默认在 UI 调度器执行, + // 直接在生产者里阻塞请求会造成主页卡片掉帧 / StrictMode 告警。 + value = withContext(Dispatchers.IO) { + runCatching { + if (!url.startsWith("http")) return@runCatching null + val resp = OkHttpClient.Builder() + .build() + .newCall(Request.Builder().url(url).build()) + .execute() + resp.use { + val bytes = it.body?.bytes() ?: return@runCatching null + decodeIcon(bytes) + } + }.getOrNull() + } + } + val bitmap = icon ?: return + Image( + painter = BitmapPainter(bitmap), + contentDescription = null, + modifier = Modifier.size(size) + ) +} + +/** 位图解码:先按 PNG/JPEG 解,失败再按 SVG 渲染(BitmapFactory 不支持 SVG)。 */ +private fun decodeIcon(bytes: ByteArray): ImageBitmap? { + val raster = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + if (raster != null) return raster.asImageBitmap() + return runCatching { + val svg = SVG.getFromString(String(bytes, Charsets.UTF_8)) + val w = svg.documentWidth.toInt().coerceIn(16, 1024) + val h = svg.documentHeight.toInt().coerceIn(16, 1024) + val bmp = android.graphics.Bitmap.createBitmap(w, h, android.graphics.Bitmap.Config.ARGB_8888) + val c = android.graphics.Canvas(bmp) + svg.renderToCanvas(c) + bmp.asImageBitmap() + }.getOrNull() +} 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 29e15b5..dec5ae2 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 @@ -88,6 +88,7 @@ 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.ui.components.ServiceIcon +import com.rainy.token.ui.components.SiteIcon import com.rainy.token.ui.components.StatusChip import com.rainy.token.ui.components.StatusLevel import com.rainy.token.ui.components.StatusStyle @@ -137,8 +138,15 @@ fun DashboardScreen( } val uiState by viewModel.uiState.collectAsStateWithLifecycle() + // Sub2API 面板管理弹窗数据源 + val panelKeysState by viewModel.panelKeysState.collectAsStateWithLifecycle() + val panelGroups by viewModel.panelGroups.collectAsStateWithLifecycle() + val panelActionMessage by viewModel.panelActionMessage.collectAsStateWithLifecycle() + val recentlyCreatedKey by viewModel.recentlyCreatedKey.collectAsStateWithLifecycle() val context = LocalContext.current var showAddWidgetConfirm by remember { mutableStateOf(false) } + // 首页 Sub2API 卡片「面板管理」弹窗开关(内嵌与详情页同一个面板管理组件) + var showSub2PanelManagement by remember { mutableStateOf(false) } // 一次性"长按拖拽排序"提示 var showDragHint by remember { mutableStateOf(false) } LaunchedEffect(Unit) { @@ -304,6 +312,7 @@ fun DashboardScreen( onOpenCcgoUsageDetail = onOpenCcgoUsageDetail, onOpenService = onOpenService, onOpenHeatmap = onOpenHeatmap, + onOpenSub2PanelManagement = { showSub2PanelManagement = true }, refreshTrigger = usageSyncTrigger ) DraggableDashboardCards( @@ -363,6 +372,35 @@ fun DashboardScreen( } } } + + // Sub2API 面板管理弹窗:与详情页共用同一个面板管理组件 + if (showSub2PanelManagement) { + AlertDialog( + onDismissRequest = { showSub2PanelManagement = false }, + title = { Text(stringResource(R.string.sub2_panel_manage_title), fontWeight = FontWeight.Bold) }, + text = { + com.rainy.token.ui.servicedetail.Sub2ApiPanelManagement( + balance = uiState.cards.firstOrNull { it.service == ServiceType.SUB2API }?.cachedBalance?.balance, + keysState = panelKeysState, + groups = panelGroups, + actionMessage = panelActionMessage, + createdKey = recentlyCreatedKey, + onLoadKeys = { viewModel.loadPanelKeys() }, + onLoadGroups = { viewModel.loadPanelGroups() }, + onCreateKey = viewModel::createPanelKey, + onSwitchGroup = viewModel::switchPanelGroup, + onRedeem = viewModel::redeemPanelCode, + onConsumeMessage = viewModel::consumePanelActionMessage, + onConsumeCreatedKey = viewModel::consumeRecentlyCreatedKey + ) + }, + confirmButton = { + TextButton(onClick = { showSub2PanelManagement = false }) { + Text(stringResource(R.string.action_close)) + } + } + ) + } } private const val DASHBOARD_ORDER_PREFS = "dashboard_card_order" @@ -386,6 +424,7 @@ private fun rememberDashboardItems( onOpenCcgoUsageDetail: () -> Unit, onOpenService: (ServiceType) -> Unit, onOpenHeatmap: () -> Unit, + onOpenSub2PanelManagement: () -> Unit, refreshTrigger: Int ): List { val defaultItems = buildList { @@ -416,6 +455,12 @@ private fun rememberDashboardItems( }, // Token 活动热力图入口仅 OCGO 服务卡片显示(数据只统计 OCGO) onOpenHeatmap = if (card.service == ServiceType.OPENCODE_GO) onOpenHeatmap else null, + // Sub2API 面板管理快捷入口(首页弹窗) + onOpenPanelManagement = if (card.service == ServiceType.SUB2API) { + { onOpenSub2PanelManagement() } + } else { + null + }, ) }) } @@ -665,6 +710,7 @@ private fun DashboardCard( onClick: () -> Unit, onOpenUsageDetail: (() -> Unit)? = null, onOpenHeatmap: (() -> Unit)? = null, + onOpenPanelManagement: (() -> Unit)? = null, ) { Card( modifier = Modifier @@ -677,7 +723,12 @@ private fun DashboardCard( Column(modifier = Modifier.padding(16.dp)) { // ─── 顶部:图标 + 名称 + 状态 chip ─── Row(verticalAlignment = Alignment.CenterVertically) { - ServiceIcon(service = card.service, size = 44) + if (card.service == ServiceType.SUB2API && card.siteIcon != null) { + // Sub2API 面板登录后用站点真实 Logo 替换内置图标 + SiteIcon(url = card.siteIcon, size = 44.dp) + } else { + ServiceIcon(service = card.service, size = 44) + } Spacer(modifier = Modifier.width(12.dp)) Column(modifier = Modifier.weight(1f)) { Text( @@ -741,6 +792,29 @@ private fun DashboardCard( } } + // Sub2API 面板管理快捷入口(仅已配置凭据时显示;按钮点击不会触发整卡点击) + if (card.service == ServiceType.SUB2API && + card.credentialState != CredentialStatus.State.NOT_CONFIGURED && + onOpenPanelManagement != null + ) { + Spacer(modifier = Modifier.height(2.dp)) + TextButton(onClick = onOpenPanelManagement) { + Text( + stringResource(R.string.sub2_panel_manage_title), + color = StrawberryPink, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.width(2.dp)) + Icon( + imageVector = Icons.Filled.KeyboardArrowRight, + contentDescription = null, + tint = StrawberryPink, + modifier = Modifier.padding(top = 1.dp) + ) + } + } + // ─── 底部:更新时间 / 错误信息 ─── Spacer(modifier = Modifier.height(12.dp)) Text( 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 1aaf49f..722b203 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 @@ -7,6 +7,7 @@ import com.rainy.token.data.cache.CachedBalance import com.rainy.token.data.repository.CredentialRepository import com.rainy.token.data.repository.RepositoryError import com.rainy.token.domain.model.CredentialStatus +import com.rainy.token.domain.model.Credential import com.rainy.token.domain.model.ServiceBalance import com.rainy.token.domain.service.ServiceType import com.rainy.token.domain.usecase.RefreshBalanceUseCase @@ -38,7 +39,8 @@ import kotlinx.coroutines.sync.Mutex class DashboardViewModel @Inject constructor( private val credentialRepository: CredentialRepository, private val refreshBalanceUseCase: RefreshBalanceUseCase, - @param:ApplicationContext private val appContext: Context + @param:ApplicationContext private val appContext: Context, + private val sub2ApiRepository: com.rainy.token.data.repository.Sub2ApiRepository ) : ViewModel() { private val _uiState = MutableStateFlow(DashboardUiState(loading = true)) @@ -177,7 +179,8 @@ class DashboardViewModel @Inject constructor( credentialState = local.status.state, credentialFingerprint = local.fingerprint, cachedBalance = local.cachedBalance, - lastFetchError = lastFetchError + lastFetchError = lastFetchError, + siteIcon = (local.credential as? Credential.Sub2ApiCredential)?.siteIcon ) private fun newerOf( @@ -194,6 +197,101 @@ class DashboardViewModel @Inject constructor( candidate: CachedBalance?, current: CachedBalance? ): Boolean = candidate != null && (current == null || candidate.fetchedAt > current.fetchedAt) + + // ---------- Sub2API 面板管理(Key 列表/新建/分组/兑换) ---------- + + /** Key 列表状态。 */ + private val _panelKeysState = MutableStateFlow( + com.rainy.token.ui.servicedetail.PanelKeysState.Idle + ) + val panelKeysState: StateFlow = _panelKeysState.asStateFlow() + + /** 可用分组(新建 Key / 切分组下拉)。 */ + 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 } + + /** 最近一次新建成功的明文 Key(弹窗立即复制用;查看后清除)。 */ + private val _recentlyCreatedKey = MutableStateFlow(null) + val recentlyCreatedKey: StateFlow = _recentlyCreatedKey.asStateFlow() + + fun consumeRecentlyCreatedKey() { _recentlyCreatedKey.value = null } + + /** 刷新面板 Key 列表(仅 SUB2API 且有面板 Token)。 */ + fun loadPanelKeys() { + 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( + onSuccess = { list -> + _panelKeysState.value = com.rainy.token.ui.servicedetail.PanelKeysState.Success(list) + }, + onFailure = { e -> + _panelKeysState.value = com.rainy.token.ui.servicedetail.PanelKeysState.Error(e.message ?: "加载 Key 列表失败") + } + ) + } + } + + /** 加载可用分组;已非空则跳过(只要加载过一次即可复用)。 */ + fun loadPanelGroups() { + viewModelScope.launch { + if (_panelGroups.value.isNotEmpty()) return@launch + sub2ApiRepository.fetchPanelGroups().onSuccess { _panelGroups.value = it } + } + } + + /** 新建 API Key:成功后展示掩码消息并刷新列表。 */ + fun createPanelKey(name: String, groupId: Long?) { + viewModelScope.launch { + sub2ApiRepository.createPanelKey(name, groupId).fold( + onSuccess = { key -> + _recentlyCreatedKey.value = key + _panelActionMessage.value = "已创建 Key:" + maskPanelKey(key) + "(请在弹出的窗口复制完整 Key)" + loadPanelKeys() + }, + onFailure = { e -> _panelActionMessage.value = "创建失败:" + (e.message ?: "") } + ) + } + } + + /** 切换 Key 所属分组:成功后刷新列表。 */ + fun switchPanelGroup(keyId: Long, groupId: Long) { + viewModelScope.launch { + sub2ApiRepository.updatePanelKey(keyId, groupId).fold( + onSuccess = { + _panelActionMessage.value = "分组已切换" + loadPanelKeys() + }, + onFailure = { e -> _panelActionMessage.value = "切换失败:" + (e.message ?: "") } + ) + } + } + + /** 兑换卡密:成功后刷新余额与 Key 列表。 */ + fun redeemPanelCode(code: String) { + viewModelScope.launch { + sub2ApiRepository.redeemPanelCode(code).fold( + onSuccess = { msg -> + _panelActionMessage.value = msg + refreshBalanceUseCase(ServiceType.SUB2API) + loadPanelKeys() + }, + onFailure = { e -> _panelActionMessage.value = "兑换失败:" + (e.message ?: "") } + ) + } + } + + /** 面板 API Key 展示用掩码:只留前 8 + 后 4 位,避免页面显示过长。 */ + private fun maskPanelKey(key: String): String = when { + key.length <= 16 -> key + else -> key.take(8) + "…" + key.takeLast(4) + } } data class DashboardUiState( @@ -207,7 +305,9 @@ data class DashboardCardUi( val credentialState: CredentialStatus.State, val credentialFingerprint: String?, val cachedBalance: CachedBalance?, - val lastFetchError: UiText? + val lastFetchError: UiText?, + /** 站点 Logo URL(Sub2API 面板登录后抓取;非空时主页卡片用它替换内置图标)。 */ + val siteIcon: String? = null ) { /** 余额展示主数字。优先取缓存,错误时也展示(不隐藏,让用户看到旧值 + 红点提示)。 */ val displayBalance: ServiceBalance? get() = cachedBalance?.balance 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 ffbd2a7..9bdfad3 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 @@ -13,6 +13,7 @@ import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -24,6 +25,9 @@ import androidx.annotation.StringRes import com.rainy.token.R import com.rainy.token.domain.model.CredentialStatus import com.rainy.token.domain.model.ServiceBalance +import com.rainy.token.domain.model.Sub2DailyUsage +import com.rainy.token.domain.model.Sub2UsageDetail +import java.time.LocalDate import com.rainy.token.domain.service.ServiceType import com.rainy.token.ui.components.StatusStyle import com.rainy.token.ui.components.StatusLevel @@ -91,6 +95,11 @@ internal fun BalanceMainArea(card: DashboardCardUi) { Spacer(modifier = Modifier.height(12.dp)) OllamaUsageWindows(balance) } + card.service == ServiceType.SUB2API -> { + Sub2ApiMainBalance(balance) + Spacer(modifier = Modifier.height(12.dp)) + Sub2ApiUsageSummary(balance) + } else -> { Row(verticalAlignment = Alignment.Bottom) { Text( @@ -606,4 +615,89 @@ internal fun usageUpdatedAtText(updatedAt: Long): String { diffMin < 1440 -> stringResource(R.string.footer_hours_ago, diffMin / 60) else -> stringResource(R.string.footer_updated_at, sdf.format(Date(updatedAt))) } -} \ No newline at end of file +} +// ── Sub2API 首页卡片:余额 + 日/周/月/本月 用量汇总 ── + +@Composable +internal fun Sub2ApiMainBalance(balance: ServiceBalance) { + 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) + ) + } + balance.monthlySpent?.let { spent -> + Text( + text = stringResource(R.string.service_monthly_used, formatAmount(spent), balance.unit), + style = MaterialTheme.typography.bodySmall, + color = inkMuted() + ) + } +} + +/** 首页 Sub2API 用量汇总:今日 / 近7天 / 近30天 / 本月 的请求数、Token 数与花费。 */ +@Composable +internal fun Sub2ApiUsageSummary(balance: ServiceBalance) { + val detail = balance.sub2UsageDetail + val rows = remember(detail) { buildSub2SummaryRows(detail) } + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + if (rows.isEmpty()) { + Text( + text = stringResource(R.string.sub2_home_no_usage), + style = MaterialTheme.typography.bodySmall, + color = inkMuted() + ) + } else { + rows.forEach { row -> + Text( + text = row, + style = MaterialTheme.typography.bodySmall, + color = inkMuted() + ) + } + } + } +} + +/** 聚合 dailyUsage 计算各窗口汇总行文案;无数据返回空列表。 */ +private fun buildSub2SummaryRows(detail: Sub2UsageDetail?): List { + val days = detail?.dailyUsage.orEmpty() + if (days.isEmpty()) return emptyList() + val today = LocalDate.now() + fun sum(from: LocalDate): Triple { + var req = 0L; var tok = 0L; var cost = 0.0 + for (d in days) { + val date = runCatching { LocalDate.parse(d.date) }.getOrNull() ?: continue + if (!date.isBefore(from) && !date.isAfter(today)) { + req += d.requests; tok += d.totalTokens; cost += d.cost + } + } + return Triple(req, tok, cost) + } + data class W(val label: String, val from: LocalDate) + // 首页仅显示 日/周 两个周期(近30天/本月 明细在详情页时间窗口里查看) + val windows = listOf( + W("今日", today), + W("近7天", today.minusDays(6)) + ) + return windows.map { w -> + val (req, tok, cost) = sum(w.from) + val tokens = if (tok >= 1_000_000_000) (tok / 1_000_000_000.0).formatLocale("%.1fB") + else if (tok >= 1_000_000) (tok / 1_000_000.0).formatLocale("%.1fM") + else if (tok >= 1_000) (tok / 1_000.0).formatLocale("%.1fK") + else tok.toString() + "${w.label} · ${req} 次 · $tokens tokens · $${cost.formatLocale("%.2f")}" + } +} + +private fun Double.formatLocale(pattern: String): String = + String.format(java.util.Locale.getDefault(), pattern, this) 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 ad2633d..a7ee86c 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 @@ -116,6 +116,12 @@ fun ServiceDetailScreen( val models by viewModel.models.collectAsStateWithLifecycle() val selectedModel by viewModel.selectedModel.collectAsStateWithLifecycle() val modelsLoading by viewModel.modelsLoading.collectAsStateWithLifecycle() + val checkinState by viewModel.checkinState.collectAsStateWithLifecycle() + val panelKeysState by viewModel.panelKeysState.collectAsStateWithLifecycle() + val panelActionMessage by viewModel.panelActionMessage.collectAsStateWithLifecycle() + val panelGroups by viewModel.panelGroups.collectAsStateWithLifecycle() + val recentlyCreatedKey by viewModel.recentlyCreatedKey.collectAsStateWithLifecycle() + val panelWindowStats by viewModel.panelWindowStats.collectAsStateWithLifecycle() val config = ServiceConfigProvider.get(service) val isManualMode = config.method == FetchMethod.MANUAL @@ -186,11 +192,42 @@ fun ServiceDetailScreen( } ServiceType.TRAE, ServiceType.WORKBUDDY -> { + // 每日签到卡(WorkBuddy/Trae 共用);下方仍展示通用 extras + item { + CheckinCard( + state = checkinState, + onCheckin = { viewModel.checkin() } + ) + } item { GenericExtrasCard(uiState.state) } } ServiceType.SUB2API -> { // 新明细卡:模型筛选 + 时间窗口 + 柱状图;无结构化明细时内部回退 GenericExtrasCard - item { Sub2ApiUsageCard(uiState.state) } + item { + Sub2ApiUsageCard( + state = uiState.state, + windowStatsState = panelWindowStats, + onLoadWindowModelStats = { from, to -> viewModel.loadPanelWindowModelStats(from, to) } + ) + } + // 面板管理:Key 列表/新建/分组/兑换/低余额提醒(有面板 Token 才加载) + item { + Sub2ApiPanelManagement( + balance = (uiState.state as? State.Fresh)?.data + ?: (uiState.state as? State.Stale)?.data, + keysState = panelKeysState, + groups = panelGroups, + actionMessage = panelActionMessage, + createdKey = recentlyCreatedKey, + onLoadKeys = { viewModel.loadPanelKeys() }, + onLoadGroups = { viewModel.loadPanelGroups() }, + onCreateKey = viewModel::createPanelKey, + onSwitchGroup = viewModel::switchPanelGroup, + onRedeem = viewModel::redeemPanelCode, + onConsumeMessage = viewModel::consumePanelActionMessage, + onConsumeCreatedKey = viewModel::consumeRecentlyCreatedKey + ) + } } } @@ -1066,6 +1103,63 @@ private fun BreakdownRow(label: String, value: Double, unit: String) { } } +/** + * 每日签到卡(WorkBuddy / Trae 共用)。 + * WorkBuddy:codebuddy.cn Buddy 加油站(每日积分 + 连签奖励); + * Trae:api.trae.cn checkin_credits(每日积分)。 + * 结果消息直接采用官方响应语义(签到成功/今日已签到/设备拦截/失败原因)。 + */ +@Composable +private fun CheckinCard(state: CheckinState, onCheckin: () -> 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.padding(20.dp)) { + Text( + text = stringResource(R.string.checkin_title), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.checkin_subtitle), + style = MaterialTheme.typography.bodySmall, + color = inkMuted() + ) + Spacer(modifier = Modifier.height(12.dp)) + when (state) { + is CheckinState.Done -> Text( + text = state.message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary + ) + is CheckinState.Error -> Text( + text = state.message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + else -> Unit + } + Spacer(modifier = Modifier.height(12.dp)) + Button( + onClick = onCheckin, + enabled = state !is CheckinState.Loading, + modifier = Modifier.fillMaxWidth().height(44.dp), + shape = RoundedCornerShape(12.dp) + ) { + Text( + text = stringResource( + if (state is CheckinState.Loading) R.string.checkin_loading else R.string.checkin_action + ) + ) + } + } + } +} + /** * 通用明细卡:把 balance.extras 中的友好键逐行展示(Trae 权益包 / WorkBuddy 资源 / * Sub2API 账户等)。技术键(models / reset* / raw 等)一律过滤。 @@ -1485,4 +1579,4 @@ private fun ResponseDialog( } } ) -} \ No newline at end of file +} 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 f4620e2..23e085d 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 @@ -18,6 +18,7 @@ import com.rainy.token.domain.service.ServiceType import com.rainy.token.domain.usecase.RefreshBalanceUseCase import com.rainy.token.ui.components.UiText import dagger.hilt.android.lifecycle.HiltViewModel +import java.time.LocalDate import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -30,7 +31,10 @@ import kotlinx.coroutines.launch class ServiceDetailViewModel @Inject constructor( private val credentialRepository: CredentialRepository, private val balanceCache: BalanceCache, - private val refreshBalanceUseCase: RefreshBalanceUseCase + private val refreshBalanceUseCase: RefreshBalanceUseCase, + private val workBuddyRepository: com.rainy.token.data.repository.WorkBuddyRepository, + private val traeRepository: com.rainy.token.data.repository.TraeRepository, + private val sub2ApiRepository: com.rainy.token.data.repository.Sub2ApiRepository ) : ViewModel() { private val _serviceType = MutableStateFlow(null) @@ -49,6 +53,147 @@ class ServiceDetailViewModel @Inject constructor( private val _modelsLoading = MutableStateFlow(false) val modelsLoading: StateFlow = _modelsLoading.asStateFlow() + /** 每日签到状态(WorkBuddy / Trae 详情页用)。 */ + private val _checkinState = MutableStateFlow(CheckinState.Idle) + val checkinState: StateFlow = _checkinState.asStateFlow() + + /** 触发每日签到:仅 WORKBUDDY / TRAE 有效;结果消息来自官方响应语义。 */ + fun checkin() { + val service = _serviceType.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 -> workBuddyRepository.checkIn().fold( + onSuccess = { s -> + CheckinState.Done( + s.message.ifBlank { + if (s.todayCheckedIn) "今日已签到(连签 " + s.streakDays + " 天)" else "签到成功" + } + ) + }, + onFailure = { e -> CheckinState.Error(e.message ?: "签到失败,请检查凭据") } + ) + ServiceType.TRAE -> traeRepository.checkIn().fold( + onSuccess = { r -> CheckinState.Done(r.message) }, + onFailure = { e -> CheckinState.Error(e.message ?: "签到失败,请检查凭据") } + ) + else -> CheckinState.Error("该服务不支持签到") + } + if (generation == serviceGeneration) _checkinState.value = result + } + } + + // ---------- Sub2API 面板管理(Key 列表/新建/分组/兑换/低余额) ---------- + + /** Key 列表状态。 */ + private val _panelKeysState = MutableStateFlow(PanelKeysState.Idle) + val panelKeysState: StateFlow = _panelKeysState.asStateFlow() + + /** 管理操作的一次性结果消息(新建/切组/兑换/复制)。 */ + private val _panelActionMessage = MutableStateFlow(null) + val panelActionMessage: StateFlow = _panelActionMessage.asStateFlow() + + fun consumePanelActionMessage() { _panelActionMessage.value = null } + + /** 可用分组(新建 Key / 切分组下拉)。 */ + private val _panelGroups = MutableStateFlow>(emptyList()) + val panelGroups: StateFlow> = _panelGroups.asStateFlow() + + /** 按所选时间窗口拉取的分模型统计(「全部」窗口直接用明细里的全量 model_stats)。 */ + private val _panelWindowStats = MutableStateFlow(PanelWindowStatsState.Idle) + val panelWindowStats: StateFlow = _panelWindowStats.asStateFlow() + + /** 窗口统计请求代次:快速切换窗口时只接受最后一次请求的结果,避免旧窗口数据覆盖新窗口。 */ + private var panelWindowStatsGeneration = 0 + + fun loadPanelWindowModelStats(from: LocalDate, to: LocalDate) { + val generation = ++panelWindowStatsGeneration + _panelWindowStats.value = PanelWindowStatsState.Loading + viewModelScope.launch { + val result = sub2ApiRepository.fetchWindowModelStats(from, to) + // 过时结果(用户已切换到别的窗口)直接丢弃,不写状态 + if (generation != panelWindowStatsGeneration) return@launch + result.fold( + onSuccess = { list -> _panelWindowStats.value = PanelWindowStatsState.Success(list) }, + onFailure = { e -> + _panelWindowStats.value = PanelWindowStatsState.Error(e.message ?: "加载模型统计失败") + } + ) + } + } + + fun loadPanelGroups() { + viewModelScope.launch { + if (_panelGroups.value.isNotEmpty()) return@launch + sub2ApiRepository.fetchPanelGroups().onSuccess { _panelGroups.value = it } + } + } + + /** 刷新面板 Key 列表(仅 SUB2API 且有面板 Token)。 */ + fun loadPanelKeys() { + viewModelScope.launch { + if (_panelKeysState.value is PanelKeysState.Loading) return@launch + _panelKeysState.value = PanelKeysState.Loading + sub2ApiRepository.fetchPanelKeys().fold( + onSuccess = { list -> _panelKeysState.value = PanelKeysState.Success(list) }, + onFailure = { e -> _panelKeysState.value = PanelKeysState.Error(e.message ?: "加载 Key 列表失败") } + ) + } + } + + /** 最近一次新建成功的明文 Key(弹窗立即复制用;查看后清除)。 */ + private val _recentlyCreatedKey = MutableStateFlow(null) + val recentlyCreatedKey: StateFlow = _recentlyCreatedKey.asStateFlow() + + fun consumeRecentlyCreatedKey() { _recentlyCreatedKey.value = null } + + fun createPanelKey(name: String, groupId: Long?) { + viewModelScope.launch { + sub2ApiRepository.createPanelKey(name, groupId).fold( + onSuccess = { key -> + _recentlyCreatedKey.value = key + _panelActionMessage.value = "已创建 Key:" + maskPanelKey(key) + "(请在弹出的窗口复制完整 Key)" + loadPanelKeys() + }, + onFailure = { e -> _panelActionMessage.value = "创建失败:" + (e.message ?: "") } + ) + } + } + + fun switchPanelGroup(keyId: Long, groupId: Long) { + viewModelScope.launch { + sub2ApiRepository.updatePanelKey(keyId, groupId).fold( + onSuccess = { + _panelActionMessage.value = "分组已切换" + loadPanelKeys() + }, + onFailure = { e -> _panelActionMessage.value = "切换失败:" + (e.message ?: "") } + ) + } + } + + fun redeemPanelCode(code: String) { + viewModelScope.launch { + sub2ApiRepository.redeemPanelCode(code).fold( + onSuccess = { msg -> + _panelActionMessage.value = msg + refreshBalanceUseCase(ServiceType.SUB2API) + loadPanelKeys() + }, + onFailure = { e -> _panelActionMessage.value = "兑换失败:" + (e.message ?: "") } + ) + } + } + + /** 面板 API Key 展示用掩码:只留前 8 + 后 4 位,避免页面显示过长。 */ + private fun maskPanelKey(key: String): String = when { + key.length <= 16 -> key + else -> key.take(8) + "…" + key.takeLast(4) + } + /** 当前凭据的不可逆 SHA-256 指纹。 */ private var currentCredentialFingerprint: String? = null @@ -604,6 +749,30 @@ class ServiceDetailViewModel @Inject constructor( ?: emptyList() } +/** 每日签到 UI 状态。 */ +sealed class CheckinState { + data object Idle : CheckinState() + data object Loading : CheckinState() + data class Done(val message: String) : CheckinState() + data class Error(val message: String) : CheckinState() +} + +/** Sub2API 面板 Key 列表状态。 */ +sealed class PanelKeysState { + data object Idle : PanelKeysState() + data object Loading : PanelKeysState() + data class Success(val keys: List) : PanelKeysState() + data class Error(val message: String) : PanelKeysState() +} + +/** Sub2API 按所选时间窗口拉取的分模型统计状态。 */ +sealed class PanelWindowStatsState { + data object Idle : PanelWindowStatsState() + data object Loading : PanelWindowStatsState() + data class Success(val stats: List) : PanelWindowStatsState() + data class Error(val message: String) : PanelWindowStatsState() +} + sealed class State { data object Loading : State() data class Fresh(val data: ServiceBalance) : State() diff --git a/app/src/main/java/com/rainy/token/ui/servicedetail/Sub2ApiPanelManagement.kt b/app/src/main/java/com/rainy/token/ui/servicedetail/Sub2ApiPanelManagement.kt new file mode 100644 index 0000000..80be7d0 --- /dev/null +++ b/app/src/main/java/com/rainy/token/ui/servicedetail/Sub2ApiPanelManagement.kt @@ -0,0 +1,411 @@ +package com.rainy.token.ui.servicedetail + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Refresh +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.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.draw.clip +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 com.rainy.token.R +import com.rainy.token.data.repository.Sub2ApiRepository +import com.rainy.token.domain.model.ServiceBalance +import com.rainy.token.ui.theme.inkMuted +import java.util.Locale + +/** + * Sub2API 面板管理卡:Key 列表 / 新建 / 分组 / 兑换 / 低余额提醒。 + * + * 首页 Sub2API 卡片与 Sub2API 详情页共用(有面板 Token 时才能加载; + * API Key 模式只读余额,进入后提示先登录面板)。 + * + * 交互细节: + * - Key 列表只展示掩码(sk-…后缀),完整 Key 通过「复制」按钮复制并 Toast 反馈; + * - 新建 Key 成功后弹窗展示完整 Key,避免把明文直接铺在长消息里; + * - 兑换/切换分组等操作结果以 Toast + 行内文案双重反馈。 + */ +@Composable +internal fun Sub2ApiPanelManagement( + balance: ServiceBalance?, + keysState: PanelKeysState, + groups: List, + actionMessage: String?, + createdKey: String? = null, + onLoadKeys: () -> Unit, + onLoadGroups: () -> Unit, + onCreateKey: (String, Long?) -> Unit, + onSwitchGroup: (Long, Long) -> Unit, + onRedeem: (String) -> Unit, + onConsumeMessage: () -> Unit, + onConsumeCreatedKey: () -> Unit = {} +) { + val context = LocalContext.current + var showCreate by remember { mutableStateOf(false) } + var showRedeem by remember { mutableStateOf(false) } + var switchForKeyId by remember { mutableStateOf(null) } + + val groupMap = remember(groups) { groups.associateBy { it.id } } + val copiedToast = stringResource(R.string.sub2_panel_copied) + + // 进入卡片即自动拉取一次 Key 列表与分组(Idle 态),省掉一次手动点击 + LaunchedEffect(Unit) { + if (keysState is PanelKeysState.Idle) { + onLoadKeys() + onLoadGroups() + } + } + + // 最近一次操作结果:用于行内保留展示(Toast 消费后消息会被置空,这里留一份快照) + var lastResult by remember { mutableStateOf(null) } + + // 操作结果(新建/兑换/切组)用 Toast 提醒:避免结果消息藏在长页面里看不见。 + // 消费后再置空,保证下一次「同样的结果」(如连续两次切换分组成功)仍能再次提示。 + LaunchedEffect(actionMessage) { + val msg = actionMessage ?: return@LaunchedEffect + lastResult = msg + Toast.makeText(context, msg, Toast.LENGTH_LONG).show() + onConsumeMessage() + } + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)) + ) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(R.string.sub2_panel_manage_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.weight(1f)) + IconButton(onClick = { onLoadKeys(); onLoadGroups() }) { + Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.action_refresh)) + } + } + + (actionMessage ?: lastResult)?.let { msg -> + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = msg.take(160) + if (msg.length > 160) "…" else "", + style = MaterialTheme.typography.bodySmall, + color = if (msg.startsWith("已创建") || msg.startsWith("分组已") || msg.startsWith("兑换成功")) + MaterialTheme.colorScheme.primary + else + MaterialTheme.colorScheme.error + ) + } + + val amount = balance?.amount ?: 0.0 + if (amount > 0.0 && amount < 5.0) { + Spacer(modifier = Modifier.height(10.dp)) + Text( + text = stringResource( + R.string.sub2_panel_low_balance, + String.format(Locale.getDefault(), "%.2f", amount) + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.35f)) + .padding(horizontal = 10.dp, vertical = 8.dp) + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + when (keysState) { + is PanelKeysState.Idle -> { + OutlinedButton(onClick = onLoadKeys, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.sub2_panel_load_keys)) + } + } + is PanelKeysState.Loading -> { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 8.dp)) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.common_loading), style = MaterialTheme.typography.bodySmall, color = inkMuted()) + } + } + is PanelKeysState.Error -> { + Column { + Text(keysState.message, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) + TextButton(onClick = onLoadKeys) { Text(stringResource(R.string.action_retry)) } + } + } + is PanelKeysState.Success -> { + if (keysState.keys.isEmpty()) { + Text(stringResource(R.string.sub2_panel_no_keys), style = MaterialTheme.typography.bodySmall, color = inkMuted()) + } else { + keysState.keys.forEach { key -> + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.sub2_panel_key_id, key.name, key.id), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + // API Key 只显示掩码,避免页面展示过长明文 + Text( + text = buildString { + append(maskKey(key.key)) + append(" · ") + append( + key.groupId?.let { groupMap[it]?.name } + ?: stringResource(R.string.sub2_panel_default_group) + ) + append(" · ") + append(key.status) + append(" · ") + append( + key.lastUsedAt?.let { stringResource(R.string.sub2_panel_last_used, it.take(10)) } + ?: stringResource(R.string.sub2_panel_never_used) + ) + }, + style = MaterialTheme.typography.labelSmall, + color = inkMuted(), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + TextButton(onClick = { + copyToClipboard(context, key.key, copiedToast) + }) { Text(stringResource(R.string.action_copy), style = MaterialTheme.typography.labelSmall) } + if (groups.isNotEmpty()) { + TextButton(onClick = { switchForKeyId = key.id }) { + Text(stringResource(R.string.sub2_panel_group_column), style = MaterialTheme.typography.labelSmall) + } + } + } + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)) + } + } + } + } + + Spacer(modifier = Modifier.height(10.dp)) + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = { onLoadGroups(); showCreate = true }, modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.sub2_panel_create)) + } + OutlinedButton(onClick = { showRedeem = true }, modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.sub2_panel_redeem)) + } + } + } + } + + // 新建 Key 弹窗 + if (showCreate) { + var name by remember { mutableStateOf("") } + var groupId by remember { mutableStateOf(null) } + AlertDialog( + onDismissRequest = { showCreate = false }, + title = { Text(stringResource(R.string.sub2_panel_create_title), fontWeight = FontWeight.Bold) }, + text = { + Column { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text(stringResource(R.string.sub2_panel_key_name)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(10.dp)) + Text( + text = stringResource(R.string.sub2_panel_group_optional), + style = MaterialTheme.typography.labelMedium, + color = inkMuted() + ) + Spacer(modifier = Modifier.height(2.dp)) + if (groups.isEmpty()) { + Text( + text = stringResource(R.string.sub2_panel_no_groups), + style = MaterialTheme.typography.bodySmall, + color = inkMuted() + ) + } else { + groups.forEach { g -> + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Text( + text = g.name + (if (g.rateMultiplier != 1.0) "(x" + String.format(Locale.getDefault(), "%.2f", g.rateMultiplier) + ")" else ""), + style = MaterialTheme.typography.bodySmall, + color = if (groupId == g.id) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f) + ) + TextButton(onClick = { groupId = if (groupId == g.id) null else g.id }) { + Text( + text = if (groupId == g.id) stringResource(R.string.action_cancel) else stringResource(R.string.sub2_panel_group_column), + style = MaterialTheme.typography.labelSmall + ) + } + } + } + } + } + }, + confirmButton = { + Button( + onClick = { onCreateKey(name.trim(), groupId); showCreate = false }, + enabled = name.isNotBlank() + ) { Text(stringResource(R.string.sub2_panel_create)) } + }, + dismissButton = { + TextButton(onClick = { showCreate = false }) { Text(stringResource(R.string.action_cancel)) } + } + ) + } + + // 新建成功:弹窗展示完整 Key,方便一次性复制(避免明文铺在长消息里) + createdKey?.let { key -> + AlertDialog( + onDismissRequest = onConsumeCreatedKey, + title = { Text(stringResource(R.string.sub2_panel_create_success_title), fontWeight = FontWeight.Bold) }, + text = { + Column { + Text( + text = stringResource(R.string.sub2_panel_create_success_hint), + style = MaterialTheme.typography.bodySmall, + color = inkMuted() + ) + Spacer(modifier = Modifier.height(8.dp)) + SelectionContainer { + Text( + text = key, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + .padding(10.dp) + ) + } + } + }, + confirmButton = { + Button(onClick = { copyToClipboard(context, key, copiedToast); onConsumeCreatedKey() }) { + Text(stringResource(R.string.sub2_panel_copy_and_close)) + } + }, + dismissButton = { + TextButton(onClick = onConsumeCreatedKey) { Text(stringResource(R.string.action_close)) } + } + ) + } + + // 兑换弹窗 + if (showRedeem) { + var code by remember { mutableStateOf("") } + AlertDialog( + onDismissRequest = { showRedeem = false }, + title = { Text(stringResource(R.string.sub2_panel_redeem), fontWeight = FontWeight.Bold) }, + text = { + OutlinedTextField( + value = code, + onValueChange = { code = it }, + label = { Text(stringResource(R.string.sub2_panel_redeem_code_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + }, + confirmButton = { + Button( + onClick = { onRedeem(code.trim()); showRedeem = false }, + enabled = code.isNotBlank() && code.length >= 6 + ) { Text(stringResource(R.string.sub2_panel_redeem)) } + }, + dismissButton = { + TextButton(onClick = { showRedeem = false }) { Text(stringResource(R.string.action_cancel)) } + } + ) + } + + // 切换分组弹窗 + switchForKeyId?.let { keyId -> + AlertDialog( + onDismissRequest = { switchForKeyId = null }, + title = { Text(stringResource(R.string.sub2_panel_switch_group), fontWeight = FontWeight.Bold) }, + text = { + Column { + if (groups.isEmpty()) { + Text( + text = stringResource(R.string.sub2_panel_no_groups), + style = MaterialTheme.typography.bodySmall, + color = inkMuted() + ) + } else { + groups.forEach { g -> + TextButton(onClick = { onSwitchGroup(keyId, g.id); switchForKeyId = null }) { + Text( + text = g.name + (if (g.rateMultiplier != 1.0) "(x" + String.format(Locale.getDefault(), "%.2f", g.rateMultiplier) + ")" else ""), + color = MaterialTheme.colorScheme.onSurface + ) + } + } + } + } + }, + confirmButton = {}, + dismissButton = { + TextButton(onClick = { switchForKeyId = null }) { Text(stringResource(R.string.action_cancel)) } + } + ) + } +} + +/** API Key 展示掩码:只留前 8 + 后 4 位。 */ +private fun maskKey(key: String): String = when { + key.length <= 12 -> key + else -> key.take(8) + "…" + key.takeLast(4) +} + +/** 复制到剪贴板并 Toast 反馈(用户感知「复制按钮」有效)。 */ +private fun copyToClipboard(context: Context, text: String, toast: String) { + val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + cm.setPrimaryClip(ClipData.newPlainText("API Key", text)) + Toast.makeText(context, toast, Toast.LENGTH_SHORT).show() +} diff --git a/app/src/main/java/com/rainy/token/ui/servicedetail/Sub2ApiUsageCard.kt b/app/src/main/java/com/rainy/token/ui/servicedetail/Sub2ApiUsageCard.kt index bc63589..080c87b 100644 --- a/app/src/main/java/com/rainy/token/ui/servicedetail/Sub2ApiUsageCard.kt +++ b/app/src/main/java/com/rainy/token/ui/servicedetail/Sub2ApiUsageCard.kt @@ -21,6 +21,7 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text 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 @@ -48,14 +49,21 @@ import java.util.Locale * 数据来自 [Sub2UsageDetail](网关 /v1/usage 的 model_stats + daily_usage)。 * 提供: * 1. 时间窗口筛选(今天 / 近7天 / 近30天 / 近90天 / 全部)——按日期对 daily_usage 切片; - * 2. 模型筛选(全部 / 指定模型)——选中模型时,汇总区切换为该模型全时间累计 - * (网关的 model_stats 无时间维度,故每日图/列表此时隐藏,避免误导); - * 3. 窗口内汇总(请求数 / Tokens / 花费)+ 每日花费柱状图 + 每日明细列表。 + * 2. 模型筛选(全部 / 指定模型)——选中模型或切换时间窗口时,分模型统计按所选周期 + * 重新向网关拉取(model_stats 受 start_date/end_date 控制),不再只显示全时间累计; + * 3. 窗口内汇总(请求数 / Tokens / 花费)+ 每日花费柱状图(高度封顶)+ 每日明细列表。 + * + * @param windowStatsState 非「全部」窗口下的分模型统计异步状态(由 ViewModel 拉取)。 + * @param onLoadWindowModelStats 按窗口日期范围拉取分模型统计的回调;null 时跳过(旧用法)。 * * 兼容:detail 为空(旧缓存/网关未返回明细)时回退到 [GenericExtrasCard]。 */ @Composable -internal fun Sub2ApiUsageCard(state: State) { +internal fun Sub2ApiUsageCard( + state: State, + windowStatsState: PanelWindowStatsState = PanelWindowStatsState.Idle, + onLoadWindowModelStats: ((from: LocalDate, to: LocalDate) -> Unit)? = null +) { val balance = when (state) { is State.Fresh -> state.data is State.Stale -> state.data @@ -89,7 +97,8 @@ internal fun Sub2ApiUsageCard(state: State) { ) Spacer(modifier = Modifier.height(12.dp)) - var window by remember { mutableStateOf(Sub2Window.DAYS_30) } + // 默认选中「今天」(用户反馈:进入详情页应直接看当天用量) + var window by remember { mutableStateOf(Sub2Window.TODAY) } // key=detail:刷新后模型列表变化时重置选中态,避免「每日区隐藏+无模型说明」的悬空状态 var selectedModel by remember(detail) { mutableStateOf(null) } @@ -110,6 +119,14 @@ internal fun Sub2ApiUsageCard(state: State) { val days = remember(detail, window) { window.slice(detail.dailyUsage) } val rangeLabel = remember(days) { formatRange(days) } + + // 非「全部」窗口:按所选时间周期向网关拉取分模型统计(model_stats 受 start_date/end_date 控制) + LaunchedEffect(window) { + if (onLoadWindowModelStats != null && window != Sub2Window.ALL) { + val (from, to) = window.range() + onLoadWindowModelStats(from, to) + } + } if (rangeLabel != null) { Spacer(modifier = Modifier.height(8.dp)) Text( @@ -119,9 +136,23 @@ internal fun Sub2ApiUsageCard(state: State) { ) } - // 选中模型 → 汇总切换为该模型全时间累计;每日块隐藏(无模型维度,避免误导) - val selectedStat = remember(detail, selectedModel) { - detail.modelStats.firstOrNull { it.model == selectedModel } + // 窗口分模型统计:「全部」用网关返回的全量 model_stats,其余按当前周期拉取结果 + val windowModelStats = if (window == Sub2Window.ALL) detail.modelStats + else (windowStatsState as? PanelWindowStatsState.Success)?.stats.orEmpty() + + // 切换窗口后若原选中模型在当前周期没有数据,自动取消选中,避免「无模型说明 + 日图隐藏」的悬空态 + LaunchedEffect(window, windowModelStats) { + val model = selectedModel + if (model != null && windowModelStats.isNotEmpty() && + windowModelStats.none { it.model == model } + ) { + selectedModel = null + } + } + + // 选中模型 → 汇总/列表均切换为该模型在当前周期内的累计;每日块隐藏(无模型维度,避免误导) + val selectedStat = remember(detail, selectedModel, windowModelStats) { + windowModelStats.firstOrNull { it.model == selectedModel } } if (selectedStat != null) { @@ -208,8 +239,12 @@ internal fun Sub2ApiUsageCard(state: State) { } } - // ── 按模型统计(整卡始终展示,选中模型时高亮)── - if (detail.modelStats.isNotEmpty()) { + // ── 按模型统计(整卡始终展示,数据随所选时间周期变化)── + val statsLoading = window != Sub2Window.ALL && + (windowStatsState is PanelWindowStatsState.Idle || windowStatsState is PanelWindowStatsState.Loading) + val statsError = (windowStatsState as? PanelWindowStatsState.Error)?.message + + if (detail.modelStats.isNotEmpty() || window != Sub2Window.ALL) { Spacer(modifier = Modifier.height(16.dp)) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) Spacer(modifier = Modifier.height(14.dp)) @@ -220,7 +255,7 @@ internal fun Sub2ApiUsageCard(state: State) { ) Spacer(modifier = Modifier.height(8.dp)) - // 模型筛选 chips + // 模型筛选 chips(按当前周期内有数据的模型;「全部」时用网关全量) LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { item { FilterChip( @@ -233,7 +268,7 @@ internal fun Sub2ApiUsageCard(state: State) { ) ) } - items(detail.modelStats) { stat -> + items(windowModelStats) { stat -> FilterChip( selected = selectedModel == stat.model, onClick = { selectedModel = stat.model }, @@ -243,14 +278,33 @@ internal fun Sub2ApiUsageCard(state: State) { } Spacer(modifier = Modifier.height(8.dp)) - val shown = detail.modelStats.filter { selectedModel == null || it.model == selectedModel } - shown.forEachIndexed { index, stat -> - ModelRow(stat, highlighted = selectedModel == stat.model) - if (index != shown.lastIndex) { - HorizontalDivider( - modifier = Modifier.padding(vertical = 2.dp), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f) - ) + when { + statsLoading -> Text( + text = stringResource(R.string.common_loading), + style = MaterialTheme.typography.bodySmall, + color = inkMuted() + ) + statsError != null -> Text( + text = "✗ " + statsError, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + windowModelStats.isEmpty() -> Text( + text = stringResource(R.string.sub2_no_model_data), + style = MaterialTheme.typography.bodySmall, + color = inkMuted() + ) + else -> { + val shown = windowModelStats.filter { selectedModel == null || it.model == selectedModel } + shown.forEachIndexed { index, stat -> + ModelRow(stat, highlighted = selectedModel == stat.model) + if (index != shown.lastIndex) { + HorizontalDivider( + modifier = Modifier.padding(vertical = 2.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f) + ) + } + } } } } @@ -267,14 +321,36 @@ private enum class Sub2Window(val labelRes: Int) { ALL(R.string.sub2_window_all); fun slice(all: List): List { - val today = LocalDate.now().toString() - fun from(daysBack: Long) = LocalDate.now().minusDays(daysBack).toString() + val today = LocalDate.now() + val startDay = when (this) { + TODAY -> today + DAYS_7 -> today.minusDays(6) + DAYS_30 -> today.minusDays(29) + DAYS_90 -> today.minusDays(89) + ALL -> all.firstOrNull()?.let { runCatching { LocalDate.parse(it.date) }.getOrNull() } ?: today + } + // 按日期补零:网关只返回有用量的天(实测确认),filter 会日期跳空, + // 逐日补齐 cost=0 空行保证柱状图/列表连续。ALL 从最早数据日补到今天。 + val byDate = all.associateBy { it.date } + val out = mutableListOf() + var d = startDay + while (!d.isAfter(today)) { + val key = d.toString() + out.add(byDate[key] ?: Sub2DailyUsage(date = key, requests = 0, totalTokens = 0, cost = 0.0)) + d = d.plusDays(1) + } + return out + } + + /** 窗口对应日期范围 [start, end](end 恒为今天;「全部」与主页一致取近 90 天)。 */ + fun range(): Pair { + val today = LocalDate.now() return when (this) { - TODAY -> all.filter { it.date == today } - DAYS_7 -> all.filter { it.date >= from(6) } - DAYS_30 -> all.filter { it.date >= from(29) } - DAYS_90 -> all.filter { it.date >= from(89) } - ALL -> all + TODAY -> today to today + DAYS_7 -> today.minusDays(6) to today + DAYS_30 -> today.minusDays(29) to today + DAYS_90 -> today.minusDays(89) to today + ALL -> today.minusDays(89) to today } } } @@ -324,19 +400,26 @@ private fun DailyCostBars(days: List) { } return } + // 长周期(如近90天)柱数多:收窄柱间距避免 3dp×N 间距溢出屏幕导致柱子消失/错乱 + val barSpacing = when (days.size) { + in 0..15 -> 3.dp + in 16..44 -> 2.dp + else -> 1.dp + } Row( modifier = Modifier .fillMaxWidth() .height(88.dp) .clip(RoundedCornerShape(12.dp)) .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)) - .padding(horizontal = 8.dp, vertical = 10.dp), + .padding(horizontal = 6.dp, vertical = 10.dp), verticalAlignment = Alignment.Bottom, - horizontalArrangement = Arrangement.spacedBy(3.dp) + horizontalArrangement = Arrangement.spacedBy(barSpacing) ) { days.forEach { day -> - // 零消耗日也占位(0.05f 最小可见高度),保证日期刻度对齐 - val fraction = ((day.cost / maxCost).toFloat()).coerceIn(0.05f, 1f) + // 每日用量栏高度限制(0.95 最高),避免个别峰值日把柱子顶满、图形失真; + // 零消耗日给 0.02f 最小占位,保证日期刻度对齐 + val fraction = ((day.cost / maxCost).toFloat()).coerceIn(0.02f, 0.95f) Column( modifier = Modifier .weight(1f) 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 48e6b0c..6a0b9ab 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 @@ -5,18 +5,22 @@ import android.content.ClipboardManager import android.content.Context import android.content.Intent import android.net.Uri +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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button @@ -27,6 +31,9 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Snackbar import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState @@ -37,8 +44,16 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.foundation.layout.statusBarsPadding +import com.rainy.token.ui.components.SiteIcon +import androidx.compose.ui.Alignment +import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import kotlinx.coroutines.delay import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -134,18 +149,40 @@ fun CredentialEditScreen( onSave = viewModel::saveWorkBuddyCredential ) } else if (service == ServiceType.SUB2API) { + var showPanelLogin by remember { mutableStateOf(false) } Sub2ApiCredentialForm( baseUrl = uiState.sub2BaseUrl, email = uiState.sub2Email, password = uiState.sub2Password, apiKey = uiState.sub2ApiKey, hasExisting = uiState.hasExisting, + hasPanelToken = uiState.sub2PanelToken != null, + siteName = uiState.sub2SiteName, + siteIcon = uiState.sub2SiteIcon, + siteLoading = uiState.sub2SiteLoading, + loggingIn = uiState.sub2LoggingIn, + loginMode = uiState.sub2LoginMode, + totp = uiState.sub2Totp, onBaseUrlChange = viewModel::updateSub2BaseUrl, onEmailChange = viewModel::updateSub2Email, onPasswordChange = viewModel::updateSub2Password, onApiKeyChange = viewModel::updateSub2ApiKey, + onLoginModeChange = viewModel::updateSub2LoginMode, + onTotpChange = viewModel::updateSub2Totp, + onPanelLogin = { showPanelLogin = true }, + onLoginPanel = viewModel::loginPanelOneClick, onSave = viewModel::saveSub2ApiCredential ) + if (showPanelLogin && uiState.sub2BaseUrl.isNotBlank()) { + PanelLoginDialog( + baseUrl = uiState.sub2BaseUrl.trim(), + onToken = { token -> + viewModel.onPanelTokenAcquired(token) + showPanelLogin = false + }, + onDismiss = { showPanelLogin = false } + ) + } } else if (uiState.isApiKeyService) { if (service == ServiceType.COMMANDCODE_GO) { CommandCodeGoForm( @@ -805,18 +842,24 @@ private fun Sub2ApiCredentialForm( password: String, apiKey: String, hasExisting: Boolean, + hasPanelToken: Boolean = false, + siteName: String? = null, + siteIcon: String? = null, + siteLoading: Boolean = false, + loggingIn: Boolean = false, + loginMode: String = "account", + totp: String = "", onBaseUrlChange: (String) -> Unit, onEmailChange: (String) -> Unit, onPasswordChange: (String) -> Unit, onApiKeyChange: (String) -> Unit, + onLoginModeChange: (String) -> Unit = {}, + onTotpChange: (String) -> Unit = {}, + onPanelLogin: () -> Unit = {}, + onLoginPanel: () -> Unit = {}, onSave: () -> Unit ) { Text(text = stringResource(R.string.credential_title_sub2api), style = MaterialTheme.typography.titleMedium) - Text( - text = stringResource(R.string.credential_hint_sub2api), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.outline - ) OutlinedTextField( value = baseUrl, onValueChange = onBaseUrlChange, @@ -825,48 +868,175 @@ private fun Sub2ApiCredentialForm( singleLine = true, modifier = Modifier.fillMaxWidth() ) - Text( - text = stringResource(R.string.sub2_auth_header), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.outline - ) - OutlinedTextField( - value = apiKey, - onValueChange = onApiKeyChange, - label = { Text(stringResource(R.string.field_sub2_api_key)) }, - singleLine = true, - visualTransformation = PasswordVisualTransformation(), - modifier = Modifier.fillMaxWidth() - ) - Text( - text = stringResource(R.string.sub2_or_account), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.outline - ) - OutlinedTextField( - value = email, - onValueChange = onEmailChange, - label = { Text(stringResource(R.string.field_sub2_email)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - OutlinedTextField( - value = password, - onValueChange = onPasswordChange, - label = { Text(stringResource(R.string.field_sub2_password)) }, - singleLine = true, - visualTransformation = PasswordVisualTransformation(), - modifier = Modifier.fillMaxWidth() - ) + // 站点识别预览:Logo + 名称(确认地址填对;探测中/失败静默隐藏) + if (siteLoading || siteName != null) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(top = 4.dp) + ) { + if (siteIcon != null) { + SiteIcon(url = siteIcon, size = 16.dp) + Spacer(modifier = Modifier.size(6.dp)) + } + Text( + text = siteName ?: stringResource(R.string.sub2_site_probing), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline + ) + } + } + // 登录方式:账号密码(优先)| API Key + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) { + SegmentedButton( + selected = loginMode == "account", + onClick = { onLoginModeChange("account") }, + shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2) + ) { + Text(stringResource(R.string.sub2_mode_account)) + } + SegmentedButton( + selected = loginMode == "apikey", + onClick = { onLoginModeChange("apikey") }, + shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2) + ) { + Text(stringResource(R.string.sub2_mode_apikey)) + } + } + if (loginMode == "account") { + OutlinedTextField( + value = email, + onValueChange = onEmailChange, + label = { Text(stringResource(R.string.field_sub2_email)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = password, + onValueChange = onPasswordChange, + label = { Text(stringResource(R.string.field_sub2_password)) }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = totp, + onValueChange = onTotpChange, + label = { Text(stringResource(R.string.sub2_field_totp)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + OutlinedButton( + onClick = onLoginPanel, + enabled = baseUrl.isNotBlank() && email.isNotBlank() && password.isNotBlank() && !loggingIn, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = if (loggingIn) stringResource(R.string.sub2_panel_logining) + else if (hasPanelToken) stringResource(R.string.sub2_panel_login_done_short) + else stringResource(R.string.sub2_panel_oneclick) + ) + } + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth() + ) { + TextButton(onClick = onPanelLogin, enabled = baseUrl.isNotBlank()) { + Text(stringResource(R.string.sub2_panel_login)) + } + } + } else { + OutlinedTextField( + value = apiKey, + onValueChange = onApiKeyChange, + label = { Text(stringResource(R.string.field_sub2_api_key)) }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth() + ) + Text( + text = stringResource(R.string.sub2_mode_api_key_desc), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline + ) + } Button( onClick = onSave, - enabled = baseUrl.isNotBlank() && (apiKey.isNotBlank() || (email.isNotBlank() && password.isNotBlank())), + enabled = baseUrl.isNotBlank() && + (apiKey.isNotBlank() || (email.isNotBlank() && password.isNotBlank()) || hasPanelToken), modifier = Modifier.fillMaxWidth() ) { Text(if (hasExisting) stringResource(R.string.action_update) else stringResource(R.string.action_save)) } } +/** + * Sub2 面板 WebView 登录:打开实例登录页,用户正常登录后自动读取 localStorage.auth_token + * (evaluateJavascript 轮询),回填凭据。Token 全程不出设备;也可不登录直接关闭继续手动填写。 + */ +@Composable +private fun PanelLoginDialog( + baseUrl: String, + onToken: (String) -> Unit, + onDismiss: () -> Unit +) { + var webViewRef by remember { mutableStateOf(null) } + var detected by remember { mutableStateOf(false) } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false) + ) { + Box(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { + AndroidView( + factory = { ctx -> + android.webkit.WebView(ctx).apply { + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + webViewClient = android.webkit.WebViewClient() + webViewRef = this + loadUrl(if (baseUrl.contains("://")) baseUrl else "https://" + baseUrl) + } + }, + modifier = Modifier.fillMaxSize() + ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .statusBarsPadding() + .background(MaterialTheme.colorScheme.surface) + .padding(horizontal = 12.dp, vertical = 6.dp) + ) { + Text( + text = stringResource(R.string.sub2_panel_login), + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.weight(1f) + ) + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, contentDescription = null) + } + } + } + } + + // 轮询 localStorage.auth_token(登录成功后自动捕获并回调关闭) + LaunchedEffect(detected) { + while (!detected) { + kotlinx.coroutines.delay(1500) + val web = webViewRef ?: continue + web.evaluateJavascript( + "(function(){try{var t=window.localStorage.getItem('auth_token');return t?t:''}catch(e){return ''}})()" + ) { value -> + val token = value?.trim('"') ?: "" + if (token.length > 20 && !detected) { + detected = true + onToken(token) + } + } + } + } +} + private fun copyToClipboard(context: Context, text: String) { val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager cm.setPrimaryClip(ClipData.newPlainText("login_url", text)) 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 0251c8c..1016249 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 @@ -7,6 +7,7 @@ import androidx.lifecycle.viewModelScope import com.rainy.token.R import com.rainy.token.data.repository.CredentialRepository import com.rainy.token.data.repository.RepositoryError +import com.rainy.token.data.repository.Sub2ApiRepository import com.rainy.token.domain.model.CookieEntry import com.rainy.token.domain.model.Credential import com.rainy.token.domain.service.FetchMethod @@ -20,6 +21,8 @@ import javax.inject.Provider import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.serialization.json.Json @@ -36,9 +39,14 @@ import kotlinx.serialization.json.jsonPrimitive @HiltViewModel class CredentialEditViewModel @Inject constructor( private val credentialRepository: CredentialRepository, - private val refreshBalanceUseCaseProvider: Provider + private val refreshBalanceUseCaseProvider: Provider, + private val sub2ApiRepository: Sub2ApiRepository ) : ViewModel() { + /** Sub2 站点信息探测(baseUrl 输入 debounce 后触发)。 */ + private var sub2SiteJob: Job? = null + private var sub2SiteGeneration: Int = 0 + private val _uiState = MutableStateFlow(CredentialEditUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -97,12 +105,25 @@ class CredentialEditViewModel @Inject constructor( workBuddyAccess = (existing as? Credential.WorkBuddyCredential)?.accessToken.orEmpty(), workBuddyRefresh = (existing as? Credential.WorkBuddyCredential)?.refreshToken.orEmpty(), sub2BaseUrl = (existing as? Credential.Sub2ApiCredential)?.baseUrl.orEmpty(), + sub2SiteIcon = (existing as? Credential.Sub2ApiCredential)?.siteIcon, sub2Email = (existing as? Credential.Sub2ApiCredential)?.email.orEmpty(), sub2Password = (existing as? Credential.Sub2ApiCredential)?.password.orEmpty(), sub2ApiKey = (existing as? Credential.Sub2ApiCredential)?.apiKey.orEmpty(), + sub2LoginMode = when { + // 有面板 Token:账号密码模式优先(面板登录是主力) + !((existing as? Credential.Sub2ApiCredential)?.authToken).isNullOrBlank() -> "account" + // 仅存过 API Key:保持 API Key 模式 + !((existing as? Credential.Sub2ApiCredential)?.apiKey).isNullOrBlank() -> "apikey" + else -> "account" + }, hasExisting = existing != null ) } + // 已有 baseUrl:补跑一次站点识别(load 不经过输入框,探测不会自动触发) + val base = (existing as? Credential.Sub2ApiCredential)?.baseUrl + if (!base.isNullOrBlank()) { + updateSub2BaseUrl(base) + } } } @@ -320,7 +341,49 @@ class CredentialEditViewModel @Inject constructor( // ---------- Sub2API ---------- fun updateSub2BaseUrl(value: String) { - _uiState.update { it.copy(sub2BaseUrl = value) } + _uiState.update { it.copy(sub2BaseUrl = value, sub2SiteName = null, sub2SiteIcon = null) } + sub2SiteJob?.cancel() + val generation = ++sub2SiteGeneration + sub2SiteJob = viewModelScope.launch { + delay(600) + val base = value.trim() + if (base.isBlank() || generation != sub2SiteGeneration) return@launch + _uiState.update { it.copy(sub2SiteLoading = true) } + val info = sub2ApiRepository.fetchSiteInfo(base) + if (generation != sub2SiteGeneration) return@launch + _uiState.update { + it.copy( + sub2SiteLoading = false, + sub2SiteName = info?.name?.takeIf { n -> n.isNotBlank() }, + sub2SiteIcon = info?.iconUrl + ) + } + } + } + + /** 面板账号一键登录:邮箱+密码 → POST /api/v1/auth/login → 面板 Token(存为 authToken 走面板模式)。 */ + fun loginPanelOneClick() { + val current = _uiState.value + val base = current.sub2BaseUrl.trim() + val email = current.sub2Email.trim() + val password = current.sub2Password.trim() + if (base.isBlank() || email.isBlank() || password.isBlank()) { + _uiState.update { it.copy(message = UiText.Resource(R.string.error_sub2_required)) } + return + } + if (current.sub2LoggingIn) return + _uiState.update { it.copy(sub2LoggingIn = true) } + viewModelScope.launch { + sub2ApiRepository.loginPanel(base, email, password, current.sub2Totp.trim()).fold( + onSuccess = { token -> onPanelTokenAcquired(token) }, + onFailure = { e -> + _uiState.update { + it.copy(message = UiText.Dynamic(e.message ?: "登录失败,请检查邮箱/密码")) + } + } + ) + _uiState.update { it.copy(sub2LoggingIn = false) } + } } fun updateSub2Email(value: String) { @@ -335,6 +398,19 @@ class CredentialEditViewModel @Inject constructor( _uiState.update { it.copy(sub2ApiKey = value) } } + fun updateSub2LoginMode(mode: String) { + _uiState.update { it.copy(sub2LoginMode = mode) } + } + + fun updateSub2Totp(value: String) { + _uiState.update { it.copy(sub2Totp = value) } + } + + /** WebView 网页登录成功:写入本次待保存的面板 Token(保存时并入 authToken)。 */ + fun onPanelTokenAcquired(token: String) { + _uiState.update { it.copy(sub2PanelToken = token, message = UiText.Resource(R.string.sub2_panel_login_done)) } + } + fun saveSub2ApiCredential() { val type = serviceType ?: return val current = _uiState.value @@ -342,13 +418,15 @@ class CredentialEditViewModel @Inject constructor( val apiKey = current.sub2ApiKey.trim() val email = current.sub2Email.trim() val password = current.sub2Password.trim() - // 实例地址必填;认证需 API Key,或(邮箱+密码)二选一路径 - if (base.isBlank() || (apiKey.isBlank() && (email.isBlank() || password.isBlank()))) { - _uiState.update { it.copy(message = UiText.Resource(R.string.error_sub2_required)) } - return - } viewModelScope.launch { val existing = credentialRepository.get(type) as? Credential.Sub2ApiCredential + // 面板 Token(本次 WebView 获取或既有)也算有效认证 + val panelToken = current.sub2PanelToken ?: existing?.authToken + // 实例地址必填;认证需 API Key /(邮箱+密码)/ 面板 Token 之一 + if (base.isBlank() || (apiKey.isBlank() && (email.isBlank() || password.isBlank()) && panelToken == null)) { + _uiState.update { it.copy(message = UiText.Resource(R.string.error_sub2_required)) } + return@launch + } credentialRepository.save( (existing ?: Credential.Sub2ApiCredential(service = type, baseUrl = base, email = email, password = password)) .copy( @@ -356,11 +434,13 @@ class CredentialEditViewModel @Inject constructor( email = email, password = password, apiKey = apiKey, + authToken = panelToken, + siteIcon = current.sub2SiteIcon ?: existing?.siteIcon, lastVerifiedAt = existing?.lastVerifiedAt ?: 0L ) ) _uiState.update { - it.copy(hasExisting = true, message = UiText.Resource(R.string.msg_credentials_saved)) + it.copy(hasExisting = true, sub2PanelToken = null, message = UiText.Resource(R.string.msg_credentials_saved)) } } } @@ -796,5 +876,17 @@ data class CredentialEditUiState( val sub2Email: String = "", val sub2Password: String = "", val sub2ApiKey: String = "", + /** Sub2API:WebView/一键登录新获取的面板 Token(保存时并入 authToken) */ + val sub2PanelToken: String? = null, + /** Sub2API:baseUrl 输入后自动探测的站点名称/图标(确认地址填对) */ + val sub2SiteName: String? = null, + val sub2SiteIcon: String? = null, + val sub2SiteLoading: Boolean = false, + /** Sub2API:一键登录进行中 */ + val sub2LoggingIn: Boolean = false, + /** Sub2API:登录方式(默认账号密码优先;仅存 apiKey 时切 API Key) */ + val sub2LoginMode: String = "account", + /** Sub2API:二次验证码(选填) */ + val sub2Totp: String = "", val message: UiText? = null ) diff --git a/app/src/main/res/values-b+zh+Hans/strings.xml b/app/src/main/res/values-b+zh+Hans/strings.xml index 1827ece..4e1244a 100644 --- a/app/src/main/res/values-b+zh+Hans/strings.xml +++ b/app/src/main/res/values-b+zh+Hans/strings.xml @@ -179,7 +179,19 @@ %1$s 次请求 · %2$s tokens 该时间范围内暂无每日用量数据 该时间范围内无花费 - 模型:%1$s · 全时间累计 + 模型:%1$s · 当前周期累计 + 该周期暂无模型数据 + + + 每日签到 + 每天签到领积分,连签有奖励 + 签到 + 签到中… + + + 在打开的页面中正常登录,App 自动读取登录态;也可继续手动填 API Key + 已获取面板 Token,点击保存生效 + ✓ 已获取面板 Token 天 @@ -628,4 +640,35 @@ 十二月 API Key Workspace ID + 正在识别站点… + API Key:仅统计该 Key 的用量 + 面板登录:全账号消费统计,并解锁 Key 管理/分组/兑换 + 账号一键登录(推荐) + 正在登录… + 网页登录(备用,如开了二次验证) + 账号密码 + API Key + 二次验证码(选填) + 暂无用量数据,下拉刷新 + 面板管理 + 加载 API Key 列表 + 暂无 API Key + 新建 Key + 兑换卡密 + 新建 API Key + Key 名称 + 分组(可选,不选为默认) + 暂无可用分组 + ✓ 新建 Key 成功 + 以下 Key 仅显示这一次,请立即复制保存: + 复制并关闭 + 兑换码 + 切换分组 + 分组 + 默认分组 + 未使用 + 最近 %1$s + %1$s(ID %2$d) + API Key 已复制 + ⚠ 余额不足:剩余 $%1$s,请及时充值或兑换卡密 \ No newline at end of file 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 ae65939..5956153 100644 --- a/app/src/main/res/values-b+zh+Hant/strings.xml +++ b/app/src/main/res/values-b+zh+Hant/strings.xml @@ -179,7 +179,19 @@ %1$s 次請求 · %2$s tokens 該時間範圍內暫無每日用量數據 該時間範圍內無花費 - 模型:%1$s · 全時間累計 + 模型:%1$s · 當前週期累計 + 該週期暫無模型數據 + + + 每日簽到 + 每天簽到領積分,連簽有獎勵 + 簽到 + 簽到中… + + + 在開啟的頁面中正常登入,App 自動讀取登入態;也可繼續手動填 API Key + 已擷取面板 Token,點擊儲存生效 + ✓ 已擷取面板 Token 天 @@ -628,4 +640,35 @@ 十二月 API Key Workspace ID + 正在識別站點… + API Key:僅統計該 Key 的用量 + 面板登錄:全帳號消費統計,並解鎖 Key 管理/分組/兌換 + 帳號一鍵登錄(推薦) + 正在登錄… + 網頁登錄(備用,如開了二次驗證) + 帳號密碼 + API Key + 二次驗證碼(選填) + 暫無用量數據,下拉刷新 + 面板管理 + 載入 API Key 列表 + 暫無 API Key + 新建 Key + 兌換卡密 + 新建 API Key + Key 名稱 + 分組(可選,不選為預設) + 暫無可用分組 + ✓ 新建 Key 成功 + 以下 Key 僅顯示這一次,請立即複製保存: + 複製並關閉 + 兌換碼 + 切換分組 + 分組 + 預設分組 + 未使用 + 最近 %1$s + %1$s(ID %2$d) + API Key 已複製 + ⚠ 餘額不足:剩餘 $%1$s,請及時儲值或兌換卡密 \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d20333d..4629cc9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -182,7 +182,19 @@ %1$s requests · %2$s tokens No daily usage data in this range No spend in this range - Model: %1$s · all-time total + Model: %1$s · in current period + No model data in this range + + + Daily check-in + Check in every day for credits; streaks earn bonuses + Check in + Checking… + + + Sign in normally on the opened page; the app reads the session automatically. Manual API Key still works. + Panel token captured — tap Save to apply + ✓ Panel token captured d @@ -633,4 +645,35 @@ Dec API Key Workspace ID - + 正在识别站点… + API Key:仅统计该 Key 的用量 + 面板登录:全账号消费统计,并解锁 Key 管理/分组/兑换 + 账号一键登录(推荐) + 正在登录… + 网页登录(备用,如开了二次验证) + 账号密码 + API Key + 二次验证码(选填) + No usage data yet — pull to refresh + Panel management + Load API Key list + No API Key yet + New Key + Redeem code + New API Key + Key name + Group (optional, default if unselected) + No group available + ✓ API Key created + This Key is shown only once — copy it now: + Copy & close + Redeem code + Switch group + Group + Default group + never used + last %1$s + %1$s (ID %2$d) + API Key copied + ⚠ Low balance: $%1$s left — top up or redeem a code + \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 215044c..90a634c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -39,6 +39,7 @@ room = "2.7.1" junit = "4.13.2" junitVersion = "1.2.1" espressoCore = "3.6.1" +androidsvg = "1.4" [libraries] # Core @@ -88,6 +89,9 @@ room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = " room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } +# SVG(站点 Logo 渲染) +androidsvg = { group = "com.caverock", name = "androidsvg", version.ref = "androidsvg" } + # Test junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }