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:
parent
80552af204
commit
2aaa225139
@ -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)
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
65
app/src/main/java/com/rainy/token/ui/components/SiteIcon.kt
Normal file
65
app/src/main/java/com/rainy/token/ui/components/SiteIcon.kt
Normal 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()
|
||||
}
|
||||
@ -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) {
|
||||
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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
@ -607,3 +616,88 @@ internal fun usageUpdatedAtText(updatedAt: Long): String {
|
||||
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)
|
||||
|
||||
@ -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 等)一律过滤。
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
}
|
||||
@ -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,7 +278,24 @@ internal fun Sub2ApiUsageCard(state: State) {
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
val shown = detail.modelStats.filter { selectedModel == null || it.model == selectedModel }
|
||||
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) {
|
||||
@ -257,6 +309,8 @@ internal fun Sub2ApiUsageCard(state: State) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 时间窗口:按日期切片(近 N 天 = 今天及之前 N-1 天),不再按行数。 */
|
||||
private enum class Sub2Window(val labelRes: Int) {
|
||||
@ -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)
|
||||
|
||||
@ -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,24 +868,41 @@ private fun Sub2ApiCredentialForm(
|
||||
singleLine = true,
|
||||
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 = 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,
|
||||
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,
|
||||
@ -858,15 +918,125 @@ private fun Sub2ApiCredentialForm(
|
||||
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))
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
<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 & 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>
|
||||
@ -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" }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user