feat(sub2api): 面板管理接入首页卡片;用量明细周期化与按模型筛选修复

首页与用量明细(用户反馈 1-3):
- 首页 Sub2API 卡片汇总只保留「今日 / 近7天」两个周期,去掉近30天/本月
- Sub2API 用量明细卡默认窗口改为「今天」,进入即看当天数据
- 每日用量柱状图:柱间距按天数自适应(>44 天 1dp),避免近90天时 3dp×N 间距溢出屏幕导致柱子挤没;柱高封顶 0.95、零消耗日 0.02 占位
- 「按模型」真正按时间周期筛选:新增 Sub2ApiRepository.fetchWindowModelStats(),按窗口 start_date/end_date 拉取 model_stats(面板模式走 dashboard/models,Key 模式走 /v1/usage),并新增加载中/错误/空态;切换窗口后自动取消悬空模型选中

面板管理(用户反馈 4):
- 面板管理 UI 从 ServiceDetailScreen 抽为公共组件 ui/servicedetail/Sub2ApiPanelManagement.kt
- 首页 Sub2API 卡片新增「面板管理」入口,弹窗内嵌同一组件(DashboardViewModel 新增面板状态与动作)
- API Key 不再展示过长明文:列表只显示掩码(前8+后4),新建成功弹窗一次性展示完整 Key 并可复制
- 复制按钮补 Toast 反馈;新建 Key 响应解析兼容 data 对象/字符串/顶层字段,并透传面板 error/message
- 兑换结果不再静默:Toast + 行内快照双反馈;消息消费后置空,连续相同结果也能再次提示
- 面板接口统一校验 HTTP 状态(401/403 明确提示面板登录已失效),不再把登录失效显示成余额 $0.00/空列表
- 面板模式分模型统计窗口与每日明细对齐到近 90 天

其它:SiteIcon 站点图标网络与 SVG 解码移到 Dispatchers.IO;新增 sub2_panel_* 三语文案,修正 sub2_home_no_usage 默认英文资源误写中文。
This commit is contained in:
Liuxinyu176 2026-09-12 03:11:31 +08:00
parent 80552af204
commit 2aaa225139
20 changed files with 2321 additions and 98 deletions

View File

@ -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)

View File

@ -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
)
}

View File

@ -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<ServiceBalance> {
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<String, String>()
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<Sub2ModelStat> {
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<Sub2DailyUsage> {
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<String> = 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 的 <title> 与 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[^>]*>([^<]+)</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<List<PanelApiKey>> = 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<List<Sub2ModelStat>> =
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<String> = 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<String, String> = emptyMap()): Result<Boolean> =
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<List<PanelGroup>> = 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<String> = 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<AuthHeader, String>? {
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 }

View File

@ -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 <jwt> + x-device-id(claim 按设备做每日去重——用 JWT 派生的稳定
* 伪 deviceId,同账号稳定、不同账号不同,避免共享 device-id 被拦)。
* 语义:code 0/200 成功;code 1001 或消息含「已签到/已领取/already」=账号已签;
* 消息含「设备/device」=设备级拦截(提示而非报错)。
* INTL 区签到接口未实测,暂不支持(返回明确提示)。
*/
suspend fun checkIn(): Result<CheckinResult> = 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<CheckinResult> = 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()

View File

@ -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<CheckinStatus> = 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<CheckinStatus> = 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()

View File

@ -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()
}

View File

@ -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<ImageBitmap?>(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()
}

View File

@ -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<DashboardHomeItem> {
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(

View File

@ -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>(
com.rainy.token.ui.servicedetail.PanelKeysState.Idle
)
val panelKeysState: StateFlow<com.rainy.token.ui.servicedetail.PanelKeysState> = _panelKeysState.asStateFlow()
/** 可用分组(新建 Key / 切分组下拉)。 */
private val _panelGroups = MutableStateFlow<List<com.rainy.token.data.repository.Sub2ApiRepository.PanelGroup>>(emptyList())
val panelGroups: StateFlow<List<com.rainy.token.data.repository.Sub2ApiRepository.PanelGroup>> = _panelGroups.asStateFlow()
/** 管理操作的一次性结果消息(新建/切组/兑换)。 */
private val _panelActionMessage = MutableStateFlow<String?>(null)
val panelActionMessage: StateFlow<String?> = _panelActionMessage.asStateFlow()
fun consumePanelActionMessage() { _panelActionMessage.value = null }
/** 最近一次新建成功的明文 Key(弹窗立即复制用;查看后清除)。 */
private val _recentlyCreatedKey = MutableStateFlow<String?>(null)
val recentlyCreatedKey: StateFlow<String?> = _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

View File

@ -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)))
}
}
}
// ── 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<String> {
val days = detail?.dailyUsage.orEmpty()
if (days.isEmpty()) return emptyList()
val today = LocalDate.now()
fun sum(from: LocalDate): Triple<Long, Long, Double> {
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)

View File

@ -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(
}
}
)
}
}

View File

@ -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<ServiceType?>(null)
@ -49,6 +53,147 @@ class ServiceDetailViewModel @Inject constructor(
private val _modelsLoading = MutableStateFlow(false)
val modelsLoading: StateFlow<Boolean> = _modelsLoading.asStateFlow()
/** 每日签到状态(WorkBuddy / Trae 详情页用)。 */
private val _checkinState = MutableStateFlow<CheckinState>(CheckinState.Idle)
val checkinState: StateFlow<CheckinState> = _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>(PanelKeysState.Idle)
val panelKeysState: StateFlow<PanelKeysState> = _panelKeysState.asStateFlow()
/** 管理操作的一次性结果消息(新建/切组/兑换/复制)。 */
private val _panelActionMessage = MutableStateFlow<String?>(null)
val panelActionMessage: StateFlow<String?> = _panelActionMessage.asStateFlow()
fun consumePanelActionMessage() { _panelActionMessage.value = null }
/** 可用分组(新建 Key / 切分组下拉)。 */
private val _panelGroups = MutableStateFlow<List<com.rainy.token.data.repository.Sub2ApiRepository.PanelGroup>>(emptyList())
val panelGroups: StateFlow<List<com.rainy.token.data.repository.Sub2ApiRepository.PanelGroup>> = _panelGroups.asStateFlow()
/** 按所选时间窗口拉取的分模型统计(「全部」窗口直接用明细里的全量 model_stats)。 */
private val _panelWindowStats = MutableStateFlow<PanelWindowStatsState>(PanelWindowStatsState.Idle)
val panelWindowStats: StateFlow<PanelWindowStatsState> = _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<String?>(null)
val recentlyCreatedKey: StateFlow<String?> = _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<com.rainy.token.data.repository.Sub2ApiRepository.PanelApiKey>) : 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<com.rainy.token.domain.model.Sub2ModelStat>) : PanelWindowStatsState()
data class Error(val message: String) : PanelWindowStatsState()
}
sealed class State {
data object Loading : State()
data class Fresh(val data: ServiceBalance) : State()

View File

@ -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<Sub2ApiRepository.PanelGroup>,
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<Long?>(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<String?>(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<Long?>(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()
}

View File

@ -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<String?>(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<Sub2DailyUsage>): List<Sub2DailyUsage> {
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<Sub2DailyUsage>()
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<LocalDate, LocalDate> {
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<Sub2DailyUsage>) {
}
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)

View File

@ -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<android.webkit.WebView?>(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))

View File

@ -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<RefreshBalanceUseCase>
private val refreshBalanceUseCaseProvider: Provider<RefreshBalanceUseCase>,
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<CredentialEditUiState> = _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
)

View File

@ -179,7 +179,19 @@
<string name="sub2_model_meta">%1$s 次请求 · %2$s tokens</string>
<string name="sub2_no_daily">该时间范围内暂无每日用量数据</string>
<string name="sub2_no_cost">该时间范围内无花费</string>
<string name="sub2_model_sel_summary">模型:%1$s · 全时间累计</string>
<string name="sub2_model_sel_summary">模型:%1$s · 当前周期累计</string>
<string name="sub2_no_model_data">该周期暂无模型数据</string>
<!-- ══════════ 每日签到(WorkBuddy / Trae) ══════════ -->
<string name="checkin_title">每日签到</string>
<string name="checkin_subtitle">每天签到领积分,连签有奖励</string>
<string name="checkin_action">签到</string>
<string name="checkin_loading">签到中…</string>
<!-- ══════════ Sub2API 面板 WebView 登录(全账号统计) ══════════ -->
<string name="sub2_panel_login_hint">在打开的页面中正常登录,App 自动读取登录态;也可继续手动填 API Key</string>
<string name="sub2_panel_login_done">已获取面板 Token,点击保存生效</string>
<string name="sub2_panel_login_done_short">✓ 已获取面板 Token</string>
<!-- ══════════ 时长标签(DurationText 注入用) ══════════ -->
<string name="format_day">天</string>
@ -628,4 +640,35 @@
<string name="month_12">十二月</string>
<string name="field_api_key">API Key</string>
<string name="field_workspace_id">Workspace ID</string>
<string name="sub2_site_probing">正在识别站点…</string>
<string name="sub2_mode_api_key_desc">API Key:仅统计该 Key 的用量</string>
<string name="sub2_mode_panel_desc">面板登录:全账号消费统计,并解锁 Key 管理/分组/兑换</string>
<string name="sub2_panel_oneclick">账号一键登录(推荐)</string>
<string name="sub2_panel_logining">正在登录…</string>
<string name="sub2_panel_login">网页登录(备用,如开了二次验证)</string>
<string name="sub2_mode_account">账号密码</string>
<string name="sub2_mode_apikey">API Key</string>
<string name="sub2_field_totp">二次验证码(选填)</string>
<string name="sub2_home_no_usage">暂无用量数据,下拉刷新</string>
<string name="sub2_panel_manage_title">面板管理</string>
<string name="sub2_panel_load_keys">加载 API Key 列表</string>
<string name="sub2_panel_no_keys">暂无 API Key</string>
<string name="sub2_panel_create">新建 Key</string>
<string name="sub2_panel_redeem">兑换卡密</string>
<string name="sub2_panel_create_title">新建 API Key</string>
<string name="sub2_panel_key_name">Key 名称</string>
<string name="sub2_panel_group_optional">分组(可选,不选为默认)</string>
<string name="sub2_panel_no_groups">暂无可用分组</string>
<string name="sub2_panel_create_success_title">✓ 新建 Key 成功</string>
<string name="sub2_panel_create_success_hint">以下 Key 仅显示这一次,请立即复制保存:</string>
<string name="sub2_panel_copy_and_close">复制并关闭</string>
<string name="sub2_panel_redeem_code_label">兑换码</string>
<string name="sub2_panel_switch_group">切换分组</string>
<string name="sub2_panel_group_column">分组</string>
<string name="sub2_panel_default_group">默认分组</string>
<string name="sub2_panel_never_used">未使用</string>
<string name="sub2_panel_last_used">最近 %1$s</string>
<string name="sub2_panel_key_id">%1$s(ID %2$d)</string>
<string name="sub2_panel_copied">API Key 已复制</string>
<string name="sub2_panel_low_balance">⚠ 余额不足:剩余 $%1$s,请及时充值或兑换卡密</string>
</resources>

View File

@ -179,7 +179,19 @@
<string name="sub2_model_meta">%1$s 次請求 · %2$s tokens</string>
<string name="sub2_no_daily">該時間範圍內暫無每日用量數據</string>
<string name="sub2_no_cost">該時間範圍內無花費</string>
<string name="sub2_model_sel_summary">模型:%1$s · 全時間累計</string>
<string name="sub2_model_sel_summary">模型:%1$s · 當前週期累計</string>
<string name="sub2_no_model_data">該週期暫無模型數據</string>
<!-- ══════════ 每日簽到(WorkBuddy / Trae) ══════════ -->
<string name="checkin_title">每日簽到</string>
<string name="checkin_subtitle">每天簽到領積分,連簽有獎勵</string>
<string name="checkin_action">簽到</string>
<string name="checkin_loading">簽到中…</string>
<!-- ══════════ Sub2API 面板 WebView 登入(全帳號統計) ══════════ -->
<string name="sub2_panel_login_hint">在開啟的頁面中正常登入,App 自動讀取登入態;也可繼續手動填 API Key</string>
<string name="sub2_panel_login_done">已擷取面板 Token,點擊儲存生效</string>
<string name="sub2_panel_login_done_short">✓ 已擷取面板 Token</string>
<!-- ══════════ 时长标签(DurationText 注入用) ══════════ -->
<string name="format_day">天</string>
@ -628,4 +640,35 @@
<string name="month_12">十二月</string>
<string name="field_api_key">API Key</string>
<string name="field_workspace_id">Workspace ID</string>
<string name="sub2_site_probing">正在識別站點…</string>
<string name="sub2_mode_api_key_desc">API Key:僅統計該 Key 的用量</string>
<string name="sub2_mode_panel_desc">面板登錄:全帳號消費統計,並解鎖 Key 管理/分組/兌換</string>
<string name="sub2_panel_oneclick">帳號一鍵登錄(推薦)</string>
<string name="sub2_panel_logining">正在登錄…</string>
<string name="sub2_panel_login">網頁登錄(備用,如開了二次驗證)</string>
<string name="sub2_mode_account">帳號密碼</string>
<string name="sub2_mode_apikey">API Key</string>
<string name="sub2_field_totp">二次驗證碼(選填)</string>
<string name="sub2_home_no_usage">暫無用量數據,下拉刷新</string>
<string name="sub2_panel_manage_title">面板管理</string>
<string name="sub2_panel_load_keys">載入 API Key 列表</string>
<string name="sub2_panel_no_keys">暫無 API Key</string>
<string name="sub2_panel_create">新建 Key</string>
<string name="sub2_panel_redeem">兌換卡密</string>
<string name="sub2_panel_create_title">新建 API Key</string>
<string name="sub2_panel_key_name">Key 名稱</string>
<string name="sub2_panel_group_optional">分組(可選,不選為預設)</string>
<string name="sub2_panel_no_groups">暫無可用分組</string>
<string name="sub2_panel_create_success_title">✓ 新建 Key 成功</string>
<string name="sub2_panel_create_success_hint">以下 Key 僅顯示這一次,請立即複製保存:</string>
<string name="sub2_panel_copy_and_close">複製並關閉</string>
<string name="sub2_panel_redeem_code_label">兌換碼</string>
<string name="sub2_panel_switch_group">切換分組</string>
<string name="sub2_panel_group_column">分組</string>
<string name="sub2_panel_default_group">預設分組</string>
<string name="sub2_panel_never_used">未使用</string>
<string name="sub2_panel_last_used">最近 %1$s</string>
<string name="sub2_panel_key_id">%1$s(ID %2$d)</string>
<string name="sub2_panel_copied">API Key 已複製</string>
<string name="sub2_panel_low_balance">⚠ 餘額不足:剩餘 $%1$s,請及時儲值或兌換卡密</string>
</resources>

View File

@ -182,7 +182,19 @@
<string name="sub2_model_meta">%1$s requests · %2$s tokens</string>
<string name="sub2_no_daily">No daily usage data in this range</string>
<string name="sub2_no_cost">No spend in this range</string>
<string name="sub2_model_sel_summary">Model: %1$s · all-time total</string>
<string name="sub2_model_sel_summary">Model: %1$s · in current period</string>
<string name="sub2_no_model_data">No model data in this range</string>
<!-- ══════════ 每日签到(WorkBuddy / Trae) ══════════ -->
<string name="checkin_title">Daily check-in</string>
<string name="checkin_subtitle">Check in every day for credits; streaks earn bonuses</string>
<string name="checkin_action">Check in</string>
<string name="checkin_loading">Checking…</string>
<!-- ══════════ Sub2API 面板 WebView 登录(全账号统计) ══════════ -->
<string name="sub2_panel_login_hint">Sign in normally on the opened page; the app reads the session automatically. Manual API Key still works.</string>
<string name="sub2_panel_login_done">Panel token captured — tap Save to apply</string>
<string name="sub2_panel_login_done_short">✓ Panel token captured</string>
<!-- ══════════ 时长标签(DurationText 注入用) ══════════ -->
<string name="format_day">d</string>
@ -633,4 +645,35 @@
<string name="month_12">Dec</string>
<string name="field_api_key">API Key</string>
<string name="field_workspace_id">Workspace ID</string>
</resources>
<string name="sub2_site_probing">正在识别站点…</string>
<string name="sub2_mode_api_key_desc">API Key:仅统计该 Key 的用量</string>
<string name="sub2_mode_panel_desc">面板登录:全账号消费统计,并解锁 Key 管理/分组/兑换</string>
<string name="sub2_panel_oneclick">账号一键登录(推荐)</string>
<string name="sub2_panel_logining">正在登录…</string>
<string name="sub2_panel_login">网页登录(备用,如开了二次验证)</string>
<string name="sub2_mode_account">账号密码</string>
<string name="sub2_mode_apikey">API Key</string>
<string name="sub2_field_totp">二次验证码(选填)</string>
<string name="sub2_home_no_usage">No usage data yet — pull to refresh</string>
<string name="sub2_panel_manage_title">Panel management</string>
<string name="sub2_panel_load_keys">Load API Key list</string>
<string name="sub2_panel_no_keys">No API Key yet</string>
<string name="sub2_panel_create">New Key</string>
<string name="sub2_panel_redeem">Redeem code</string>
<string name="sub2_panel_create_title">New API Key</string>
<string name="sub2_panel_key_name">Key name</string>
<string name="sub2_panel_group_optional">Group (optional, default if unselected)</string>
<string name="sub2_panel_no_groups">No group available</string>
<string name="sub2_panel_create_success_title">✓ API Key created</string>
<string name="sub2_panel_create_success_hint">This Key is shown only once — copy it now:</string>
<string name="sub2_panel_copy_and_close">Copy &amp; close</string>
<string name="sub2_panel_redeem_code_label">Redeem code</string>
<string name="sub2_panel_switch_group">Switch group</string>
<string name="sub2_panel_group_column">Group</string>
<string name="sub2_panel_default_group">Default group</string>
<string name="sub2_panel_never_used">never used</string>
<string name="sub2_panel_last_used">last %1$s</string>
<string name="sub2_panel_key_id">%1$s (ID %2$d)</string>
<string name="sub2_panel_copied">API Key copied</string>
<string name="sub2_panel_low_balance">⚠ Low balance: $%1$s left — top up or redeem a code</string>
</resources>

View File

@ -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" }