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
|
// Core
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
|
implementation(libs.androidsvg)
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||||
|
|||||||
@ -37,7 +37,9 @@ class CredentialRepository @Inject constructor(
|
|||||||
internal data class LocalState(
|
internal data class LocalState(
|
||||||
val status: CredentialStatus,
|
val status: CredentialStatus,
|
||||||
val fingerprint: String?,
|
val fingerprint: String?,
|
||||||
val cachedBalance: CachedBalance?
|
val cachedBalance: CachedBalance?,
|
||||||
|
/** 当前凭据对象(首页卡片可能需要读取如 Sub2API 站点 Logo 等附加字段)。 */
|
||||||
|
val credential: Credential? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class CacheRollbackEntry(
|
private data class CacheRollbackEntry(
|
||||||
@ -268,7 +270,8 @@ class CredentialRepository @Inject constructor(
|
|||||||
return LocalState(
|
return LocalState(
|
||||||
status = status,
|
status = status,
|
||||||
fingerprint = credentialFingerprint(credential),
|
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.Sub2ModelStat
|
||||||
import com.rainy.token.domain.model.Sub2UsageDetail
|
import com.rainy.token.domain.model.Sub2UsageDetail
|
||||||
import com.rainy.token.domain.service.ServiceType
|
import com.rainy.token.domain.service.ServiceType
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.temporal.ChronoUnit
|
||||||
import java.util.TimeZone
|
import java.util.TimeZone
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
import kotlinx.serialization.json.buildJsonObject
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
@ -67,14 +70,27 @@ class Sub2ApiRepository constructor(
|
|||||||
val base = normalizeBase(credential.baseUrl)
|
val base = normalizeBase(credential.baseUrl)
|
||||||
if (base.isBlank()) return@withContext Result.failure(RepositoryError.InvalidCredential())
|
if (base.isBlank()) return@withContext Result.failure(RepositoryError.InvalidCredential())
|
||||||
|
|
||||||
|
// 面板模式:WebView 登录获取的 authToken 走用户级接口(全账号数据,与网页一致)
|
||||||
|
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(
|
val auth = resolveAuth(base, credential) ?: return@withContext Result.failure(
|
||||||
RepositoryError.InvalidCredential("Sub2API 认证失败:请确认 API Key 有效,或用面板账号邮箱+密码")
|
RepositoryError.InvalidCredential("Sub2API 认证失败:请确认 API Key 有效,或用面板账号邮箱+密码")
|
||||||
)
|
)
|
||||||
|
|
||||||
// 主端点 /v1/usage;404 时兜底 /api/v1/usage。
|
// 主端点 /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 按用户所在时区聚合。
|
// 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)
|
val resp = get(base, "/v1/usage?$usageQuery", auth)
|
||||||
?.takeIf { it.code != 404 && it.code != 400 }
|
?.takeIf { it.code != 404 && it.code != 400 }
|
||||||
?: get(base, "/api/v1/usage?$usageQuery", auth)
|
?: get(base, "/api/v1/usage?$usageQuery", auth)
|
||||||
@ -87,6 +103,9 @@ class Sub2ApiRepository constructor(
|
|||||||
if (resp.code !in 200..299) {
|
if (resp.code !in 200..299) {
|
||||||
return@withContext Result.failure(mapSubHttpError(resp.code))
|
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 ->
|
val stats = runCatching { parseUsageStats(resp.body) }.getOrElse { e ->
|
||||||
return@withContext Result.failure(
|
return@withContext Result.failure(
|
||||||
RepositoryError.ParseError(RepositoryError.ParseErrorReason.MALFORMED_RESPONSE, "sub2api /v1/usage 解析失败: ${e.message}")
|
RepositoryError.ParseError(RepositoryError.ParseErrorReason.MALFORMED_RESPONSE, "sub2api /v1/usage 解析失败: ${e.message}")
|
||||||
@ -111,13 +130,14 @@ class Sub2ApiRepository constructor(
|
|||||||
// ---------- 认证 ----------
|
// ---------- 认证 ----------
|
||||||
|
|
||||||
/** 返回可用认证头;失败返回 null。有 API Key 走 Bearer(普通用户推荐),否则登录换 token。 */
|
/** 返回可用认证头;失败返回 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()) {
|
if (!credential.apiKey.isNullOrBlank()) {
|
||||||
return AuthHeader("Authorization", "Bearer ${credential.apiKey.trim()}")
|
return AuthHeader("Authorization", "Bearer ${credential.apiKey.trim()}")
|
||||||
}
|
}
|
||||||
val body = buildJsonObject {
|
val body = buildJsonObject {
|
||||||
put("email", credential.email)
|
put("email", credential.email)
|
||||||
put("password", credential.password)
|
put("password", credential.password)
|
||||||
|
if (totp.isNotBlank()) put("totp_code", totp)
|
||||||
}.toString()
|
}.toString()
|
||||||
val loginUrl = "$base/api/v1/auth/login"
|
val loginUrl = "$base/api/v1/auth/login"
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
@ -206,6 +226,18 @@ class Sub2ApiRepository constructor(
|
|||||||
}
|
}
|
||||||
}.getOrNull()
|
}.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) {
|
private fun mapSubHttpError(code: Int): RepositoryError = when (code) {
|
||||||
401, 403 -> RepositoryError.InvalidCredential()
|
401, 403 -> RepositoryError.InvalidCredential()
|
||||||
423 -> RepositoryError.ServerError(423)
|
423 -> RepositoryError.ServerError(423)
|
||||||
@ -214,6 +246,464 @@ class Sub2ApiRepository constructor(
|
|||||||
else -> RepositoryError.Unknown()
|
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(
|
private data class UsageStats(
|
||||||
@ -329,8 +819,10 @@ class Sub2ApiRepository constructor(
|
|||||||
requests = o.longOr("requests", "request_count") ?: 0,
|
requests = o.longOr("requests", "request_count") ?: 0,
|
||||||
inputTokens = input,
|
inputTokens = input,
|
||||||
outputTokens = output,
|
outputTokens = output,
|
||||||
totalTokens = o.longOr("total_tokens") ?: (input + output),
|
// total_tokens 优先;老版网关只有 tokens 时回退 tokens,再回退 input+output
|
||||||
cost = o.numberOr("cost", "actual_cost", "amount") ?: 0.0
|
totalTokens = o.longOr("total_tokens") ?: o.longOr("tokens") ?: (input + output),
|
||||||
|
// actual_cost 优先(实际扣费),cost 是标准计费(标价),优先取标价会高估高价低倍率模型
|
||||||
|
cost = o.numberOr("actual_cost", "cost", "amount") ?: 0.0
|
||||||
)
|
)
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
}
|
}
|
||||||
@ -347,7 +839,7 @@ class Sub2ApiRepository constructor(
|
|||||||
date = date,
|
date = date,
|
||||||
requests = o.longOr("requests") ?: 0,
|
requests = o.longOr("requests") ?: 0,
|
||||||
totalTokens = o.longOr("total_tokens", "tokens") ?: (input + output),
|
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()
|
}.getOrNull()
|
||||||
}.sortedBy { it.date }
|
}.sortedBy { it.date }
|
||||||
|
|||||||
@ -193,6 +193,108 @@ class TraeRepository constructor(
|
|||||||
return null
|
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) {
|
private fun mapHttpError(code: Int): RepositoryError = when (code) {
|
||||||
401, 403 -> RepositoryError.InvalidCredential()
|
401, 403 -> RepositoryError.InvalidCredential()
|
||||||
429 -> RepositoryError.RateLimited()
|
429 -> RepositoryError.RateLimited()
|
||||||
|
|||||||
@ -274,6 +274,138 @@ class WorkBuddyRepository constructor(
|
|||||||
return null
|
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) {
|
private fun mapHttpError(code: Int, body: String): RepositoryError = when (code) {
|
||||||
401, 403 -> RepositoryError.InvalidCredential()
|
401, 403 -> RepositoryError.InvalidCredential()
|
||||||
429 -> RepositoryError.RateLimited()
|
429 -> RepositoryError.RateLimited()
|
||||||
|
|||||||
@ -109,6 +109,14 @@ sealed class Credential {
|
|||||||
val password: String,
|
val password: String,
|
||||||
/** 用户级 API Key(sk-…),存在时优先于 email/password 认证 */
|
/** 用户级 API Key(sk-…),存在时优先于 email/password 认证 */
|
||||||
val apiKey: String? = null,
|
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
|
override val lastVerifiedAt: Long = 0L
|
||||||
) : Credential()
|
) : 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.model.ServiceBalance
|
||||||
import com.rainy.token.domain.service.ServiceType
|
import com.rainy.token.domain.service.ServiceType
|
||||||
import com.rainy.token.ui.components.ServiceIcon
|
import com.rainy.token.ui.components.ServiceIcon
|
||||||
|
import com.rainy.token.ui.components.SiteIcon
|
||||||
import com.rainy.token.ui.components.StatusChip
|
import com.rainy.token.ui.components.StatusChip
|
||||||
import com.rainy.token.ui.components.StatusLevel
|
import com.rainy.token.ui.components.StatusLevel
|
||||||
import com.rainy.token.ui.components.StatusStyle
|
import com.rainy.token.ui.components.StatusStyle
|
||||||
@ -137,8 +138,15 @@ fun DashboardScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
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
|
val context = LocalContext.current
|
||||||
var showAddWidgetConfirm by remember { mutableStateOf(false) }
|
var showAddWidgetConfirm by remember { mutableStateOf(false) }
|
||||||
|
// 首页 Sub2API 卡片「面板管理」弹窗开关(内嵌与详情页同一个面板管理组件)
|
||||||
|
var showSub2PanelManagement by remember { mutableStateOf(false) }
|
||||||
// 一次性"长按拖拽排序"提示
|
// 一次性"长按拖拽排序"提示
|
||||||
var showDragHint by remember { mutableStateOf(false) }
|
var showDragHint by remember { mutableStateOf(false) }
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
@ -304,6 +312,7 @@ fun DashboardScreen(
|
|||||||
onOpenCcgoUsageDetail = onOpenCcgoUsageDetail,
|
onOpenCcgoUsageDetail = onOpenCcgoUsageDetail,
|
||||||
onOpenService = onOpenService,
|
onOpenService = onOpenService,
|
||||||
onOpenHeatmap = onOpenHeatmap,
|
onOpenHeatmap = onOpenHeatmap,
|
||||||
|
onOpenSub2PanelManagement = { showSub2PanelManagement = true },
|
||||||
refreshTrigger = usageSyncTrigger
|
refreshTrigger = usageSyncTrigger
|
||||||
)
|
)
|
||||||
DraggableDashboardCards(
|
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"
|
private const val DASHBOARD_ORDER_PREFS = "dashboard_card_order"
|
||||||
@ -386,6 +424,7 @@ private fun rememberDashboardItems(
|
|||||||
onOpenCcgoUsageDetail: () -> Unit,
|
onOpenCcgoUsageDetail: () -> Unit,
|
||||||
onOpenService: (ServiceType) -> Unit,
|
onOpenService: (ServiceType) -> Unit,
|
||||||
onOpenHeatmap: () -> Unit,
|
onOpenHeatmap: () -> Unit,
|
||||||
|
onOpenSub2PanelManagement: () -> Unit,
|
||||||
refreshTrigger: Int
|
refreshTrigger: Int
|
||||||
): List<DashboardHomeItem> {
|
): List<DashboardHomeItem> {
|
||||||
val defaultItems = buildList {
|
val defaultItems = buildList {
|
||||||
@ -416,6 +455,12 @@ private fun rememberDashboardItems(
|
|||||||
},
|
},
|
||||||
// Token 活动热力图入口仅 OCGO 服务卡片显示(数据只统计 OCGO)
|
// Token 活动热力图入口仅 OCGO 服务卡片显示(数据只统计 OCGO)
|
||||||
onOpenHeatmap = if (card.service == ServiceType.OPENCODE_GO) onOpenHeatmap else null,
|
onOpenHeatmap = if (card.service == ServiceType.OPENCODE_GO) onOpenHeatmap else null,
|
||||||
|
// Sub2API 面板管理快捷入口(首页弹窗)
|
||||||
|
onOpenPanelManagement = if (card.service == ServiceType.SUB2API) {
|
||||||
|
{ onOpenSub2PanelManagement() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -665,6 +710,7 @@ private fun DashboardCard(
|
|||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onOpenUsageDetail: (() -> Unit)? = null,
|
onOpenUsageDetail: (() -> Unit)? = null,
|
||||||
onOpenHeatmap: (() -> Unit)? = null,
|
onOpenHeatmap: (() -> Unit)? = null,
|
||||||
|
onOpenPanelManagement: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@ -677,7 +723,12 @@ private fun DashboardCard(
|
|||||||
Column(modifier = Modifier.padding(16.dp)) {
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
// ─── 顶部:图标 + 名称 + 状态 chip ───
|
// ─── 顶部:图标 + 名称 + 状态 chip ───
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
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)
|
ServiceIcon(service = card.service, size = 44)
|
||||||
|
}
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
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))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
Text(
|
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.CredentialRepository
|
||||||
import com.rainy.token.data.repository.RepositoryError
|
import com.rainy.token.data.repository.RepositoryError
|
||||||
import com.rainy.token.domain.model.CredentialStatus
|
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.model.ServiceBalance
|
||||||
import com.rainy.token.domain.service.ServiceType
|
import com.rainy.token.domain.service.ServiceType
|
||||||
import com.rainy.token.domain.usecase.RefreshBalanceUseCase
|
import com.rainy.token.domain.usecase.RefreshBalanceUseCase
|
||||||
@ -38,7 +39,8 @@ import kotlinx.coroutines.sync.Mutex
|
|||||||
class DashboardViewModel @Inject constructor(
|
class DashboardViewModel @Inject constructor(
|
||||||
private val credentialRepository: CredentialRepository,
|
private val credentialRepository: CredentialRepository,
|
||||||
private val refreshBalanceUseCase: RefreshBalanceUseCase,
|
private val refreshBalanceUseCase: RefreshBalanceUseCase,
|
||||||
@param:ApplicationContext private val appContext: Context
|
@param:ApplicationContext private val appContext: Context,
|
||||||
|
private val sub2ApiRepository: com.rainy.token.data.repository.Sub2ApiRepository
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(DashboardUiState(loading = true))
|
private val _uiState = MutableStateFlow(DashboardUiState(loading = true))
|
||||||
@ -177,7 +179,8 @@ class DashboardViewModel @Inject constructor(
|
|||||||
credentialState = local.status.state,
|
credentialState = local.status.state,
|
||||||
credentialFingerprint = local.fingerprint,
|
credentialFingerprint = local.fingerprint,
|
||||||
cachedBalance = local.cachedBalance,
|
cachedBalance = local.cachedBalance,
|
||||||
lastFetchError = lastFetchError
|
lastFetchError = lastFetchError,
|
||||||
|
siteIcon = (local.credential as? Credential.Sub2ApiCredential)?.siteIcon
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun newerOf(
|
private fun newerOf(
|
||||||
@ -194,6 +197,101 @@ class DashboardViewModel @Inject constructor(
|
|||||||
candidate: CachedBalance?,
|
candidate: CachedBalance?,
|
||||||
current: CachedBalance?
|
current: CachedBalance?
|
||||||
): Boolean = candidate != null && (current == null || candidate.fetchedAt > current.fetchedAt)
|
): 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(
|
data class DashboardUiState(
|
||||||
@ -207,7 +305,9 @@ data class DashboardCardUi(
|
|||||||
val credentialState: CredentialStatus.State,
|
val credentialState: CredentialStatus.State,
|
||||||
val credentialFingerprint: String?,
|
val credentialFingerprint: String?,
|
||||||
val cachedBalance: CachedBalance?,
|
val cachedBalance: CachedBalance?,
|
||||||
val lastFetchError: UiText?
|
val lastFetchError: UiText?,
|
||||||
|
/** 站点 Logo URL(Sub2API 面板登录后抓取;非空时主页卡片用它替换内置图标)。 */
|
||||||
|
val siteIcon: String? = null
|
||||||
) {
|
) {
|
||||||
/** 余额展示主数字。优先取缓存,错误时也展示(不隐藏,让用户看到旧值 + 红点提示)。 */
|
/** 余额展示主数字。优先取缓存,错误时也展示(不隐藏,让用户看到旧值 + 红点提示)。 */
|
||||||
val displayBalance: ServiceBalance? get() = cachedBalance?.balance
|
val displayBalance: ServiceBalance? get() = cachedBalance?.balance
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import androidx.compose.material3.LinearProgressIndicator
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@ -24,6 +25,9 @@ import androidx.annotation.StringRes
|
|||||||
import com.rainy.token.R
|
import com.rainy.token.R
|
||||||
import com.rainy.token.domain.model.CredentialStatus
|
import com.rainy.token.domain.model.CredentialStatus
|
||||||
import com.rainy.token.domain.model.ServiceBalance
|
import com.rainy.token.domain.model.ServiceBalance
|
||||||
|
import com.rainy.token.domain.model.Sub2DailyUsage
|
||||||
|
import com.rainy.token.domain.model.Sub2UsageDetail
|
||||||
|
import java.time.LocalDate
|
||||||
import com.rainy.token.domain.service.ServiceType
|
import com.rainy.token.domain.service.ServiceType
|
||||||
import com.rainy.token.ui.components.StatusStyle
|
import com.rainy.token.ui.components.StatusStyle
|
||||||
import com.rainy.token.ui.components.StatusLevel
|
import com.rainy.token.ui.components.StatusLevel
|
||||||
@ -91,6 +95,11 @@ internal fun BalanceMainArea(card: DashboardCardUi) {
|
|||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
OllamaUsageWindows(balance)
|
OllamaUsageWindows(balance)
|
||||||
}
|
}
|
||||||
|
card.service == ServiceType.SUB2API -> {
|
||||||
|
Sub2ApiMainBalance(balance)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
Sub2ApiUsageSummary(balance)
|
||||||
|
}
|
||||||
else -> {
|
else -> {
|
||||||
Row(verticalAlignment = Alignment.Bottom) {
|
Row(verticalAlignment = Alignment.Bottom) {
|
||||||
Text(
|
Text(
|
||||||
@ -607,3 +616,88 @@ internal fun usageUpdatedAtText(updatedAt: Long): String {
|
|||||||
else -> stringResource(R.string.footer_updated_at, sdf.format(Date(updatedAt)))
|
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 models by viewModel.models.collectAsStateWithLifecycle()
|
||||||
val selectedModel by viewModel.selectedModel.collectAsStateWithLifecycle()
|
val selectedModel by viewModel.selectedModel.collectAsStateWithLifecycle()
|
||||||
val modelsLoading by viewModel.modelsLoading.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 config = ServiceConfigProvider.get(service)
|
||||||
val isManualMode = config.method == FetchMethod.MANUAL
|
val isManualMode = config.method == FetchMethod.MANUAL
|
||||||
|
|
||||||
@ -186,11 +192,42 @@ fun ServiceDetailScreen(
|
|||||||
}
|
}
|
||||||
ServiceType.TRAE,
|
ServiceType.TRAE,
|
||||||
ServiceType.WORKBUDDY -> {
|
ServiceType.WORKBUDDY -> {
|
||||||
|
// 每日签到卡(WorkBuddy/Trae 共用);下方仍展示通用 extras
|
||||||
|
item {
|
||||||
|
CheckinCard(
|
||||||
|
state = checkinState,
|
||||||
|
onCheckin = { viewModel.checkin() }
|
||||||
|
)
|
||||||
|
}
|
||||||
item { GenericExtrasCard(uiState.state) }
|
item { GenericExtrasCard(uiState.state) }
|
||||||
}
|
}
|
||||||
ServiceType.SUB2API -> {
|
ServiceType.SUB2API -> {
|
||||||
// 新明细卡:模型筛选 + 时间窗口 + 柱状图;无结构化明细时内部回退 GenericExtrasCard
|
// 新明细卡:模型筛选 + 时间窗口 + 柱状图;无结构化明细时内部回退 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 资源 /
|
* 通用明细卡:把 balance.extras 中的友好键逐行展示(Trae 权益包 / WorkBuddy 资源 /
|
||||||
* Sub2API 账户等)。技术键(models / reset* / raw 等)一律过滤。
|
* 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.domain.usecase.RefreshBalanceUseCase
|
||||||
import com.rainy.token.ui.components.UiText
|
import com.rainy.token.ui.components.UiText
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import java.time.LocalDate
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@ -30,7 +31,10 @@ import kotlinx.coroutines.launch
|
|||||||
class ServiceDetailViewModel @Inject constructor(
|
class ServiceDetailViewModel @Inject constructor(
|
||||||
private val credentialRepository: CredentialRepository,
|
private val credentialRepository: CredentialRepository,
|
||||||
private val balanceCache: BalanceCache,
|
private val balanceCache: BalanceCache,
|
||||||
private val refreshBalanceUseCase: RefreshBalanceUseCase
|
private val refreshBalanceUseCase: RefreshBalanceUseCase,
|
||||||
|
private val 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() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _serviceType = MutableStateFlow<ServiceType?>(null)
|
private val _serviceType = MutableStateFlow<ServiceType?>(null)
|
||||||
@ -49,6 +53,147 @@ class ServiceDetailViewModel @Inject constructor(
|
|||||||
private val _modelsLoading = MutableStateFlow(false)
|
private val _modelsLoading = MutableStateFlow(false)
|
||||||
val modelsLoading: StateFlow<Boolean> = _modelsLoading.asStateFlow()
|
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 指纹。 */
|
/** 当前凭据的不可逆 SHA-256 指纹。 */
|
||||||
private var currentCredentialFingerprint: String? = null
|
private var currentCredentialFingerprint: String? = null
|
||||||
|
|
||||||
@ -604,6 +749,30 @@ class ServiceDetailViewModel @Inject constructor(
|
|||||||
?: emptyList()
|
?: 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 {
|
sealed class State {
|
||||||
data object Loading : State()
|
data object Loading : State()
|
||||||
data class Fresh(val data: ServiceBalance) : 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.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@ -48,14 +49,21 @@ import java.util.Locale
|
|||||||
* 数据来自 [Sub2UsageDetail](网关 /v1/usage 的 model_stats + daily_usage)。
|
* 数据来自 [Sub2UsageDetail](网关 /v1/usage 的 model_stats + daily_usage)。
|
||||||
* 提供:
|
* 提供:
|
||||||
* 1. 时间窗口筛选(今天 / 近7天 / 近30天 / 近90天 / 全部)——按日期对 daily_usage 切片;
|
* 1. 时间窗口筛选(今天 / 近7天 / 近30天 / 近90天 / 全部)——按日期对 daily_usage 切片;
|
||||||
* 2. 模型筛选(全部 / 指定模型)——选中模型时,汇总区切换为该模型全时间累计
|
* 2. 模型筛选(全部 / 指定模型)——选中模型或切换时间窗口时,分模型统计按所选周期
|
||||||
* (网关的 model_stats 无时间维度,故每日图/列表此时隐藏,避免误导);
|
* 重新向网关拉取(model_stats 受 start_date/end_date 控制),不再只显示全时间累计;
|
||||||
* 3. 窗口内汇总(请求数 / Tokens / 花费)+ 每日花费柱状图 + 每日明细列表。
|
* 3. 窗口内汇总(请求数 / Tokens / 花费)+ 每日花费柱状图(高度封顶)+ 每日明细列表。
|
||||||
|
*
|
||||||
|
* @param windowStatsState 非「全部」窗口下的分模型统计异步状态(由 ViewModel 拉取)。
|
||||||
|
* @param onLoadWindowModelStats 按窗口日期范围拉取分模型统计的回调;null 时跳过(旧用法)。
|
||||||
*
|
*
|
||||||
* 兼容:detail 为空(旧缓存/网关未返回明细)时回退到 [GenericExtrasCard]。
|
* 兼容:detail 为空(旧缓存/网关未返回明细)时回退到 [GenericExtrasCard]。
|
||||||
*/
|
*/
|
||||||
@Composable
|
@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) {
|
val balance = when (state) {
|
||||||
is State.Fresh -> state.data
|
is State.Fresh -> state.data
|
||||||
is State.Stale -> state.data
|
is State.Stale -> state.data
|
||||||
@ -89,7 +97,8 @@ internal fun Sub2ApiUsageCard(state: State) {
|
|||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
var window by remember { mutableStateOf(Sub2Window.DAYS_30) }
|
// 默认选中「今天」(用户反馈:进入详情页应直接看当天用量)
|
||||||
|
var window by remember { mutableStateOf(Sub2Window.TODAY) }
|
||||||
// key=detail:刷新后模型列表变化时重置选中态,避免「每日区隐藏+无模型说明」的悬空状态
|
// key=detail:刷新后模型列表变化时重置选中态,避免「每日区隐藏+无模型说明」的悬空状态
|
||||||
var selectedModel by remember(detail) { mutableStateOf<String?>(null) }
|
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 days = remember(detail, window) { window.slice(detail.dailyUsage) }
|
||||||
val rangeLabel = remember(days) { formatRange(days) }
|
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) {
|
if (rangeLabel != null) {
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
@ -119,9 +136,23 @@ internal fun Sub2ApiUsageCard(state: State) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 选中模型 → 汇总切换为该模型全时间累计;每日块隐藏(无模型维度,避免误导)
|
// 窗口分模型统计:「全部」用网关返回的全量 model_stats,其余按当前周期拉取结果
|
||||||
val selectedStat = remember(detail, selectedModel) {
|
val windowModelStats = if (window == Sub2Window.ALL) detail.modelStats
|
||||||
detail.modelStats.firstOrNull { it.model == selectedModel }
|
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) {
|
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))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
|
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
|
||||||
Spacer(modifier = Modifier.height(14.dp))
|
Spacer(modifier = Modifier.height(14.dp))
|
||||||
@ -220,7 +255,7 @@ internal fun Sub2ApiUsageCard(state: State) {
|
|||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
// 模型筛选 chips
|
// 模型筛选 chips(按当前周期内有数据的模型;「全部」时用网关全量)
|
||||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
item {
|
item {
|
||||||
FilterChip(
|
FilterChip(
|
||||||
@ -233,7 +268,7 @@ internal fun Sub2ApiUsageCard(state: State) {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
items(detail.modelStats) { stat ->
|
items(windowModelStats) { stat ->
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = selectedModel == stat.model,
|
selected = selectedModel == stat.model,
|
||||||
onClick = { selectedModel = stat.model },
|
onClick = { selectedModel = stat.model },
|
||||||
@ -243,7 +278,24 @@ internal fun Sub2ApiUsageCard(state: State) {
|
|||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
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 ->
|
shown.forEachIndexed { index, stat ->
|
||||||
ModelRow(stat, highlighted = selectedModel == stat.model)
|
ModelRow(stat, highlighted = selectedModel == stat.model)
|
||||||
if (index != shown.lastIndex) {
|
if (index != shown.lastIndex) {
|
||||||
@ -256,6 +308,8 @@ internal fun Sub2ApiUsageCard(state: State) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 时间窗口:按日期切片(近 N 天 = 今天及之前 N-1 天),不再按行数。 */
|
/** 时间窗口:按日期切片(近 N 天 = 今天及之前 N-1 天),不再按行数。 */
|
||||||
@ -267,14 +321,36 @@ private enum class Sub2Window(val labelRes: Int) {
|
|||||||
ALL(R.string.sub2_window_all);
|
ALL(R.string.sub2_window_all);
|
||||||
|
|
||||||
fun slice(all: List<Sub2DailyUsage>): List<Sub2DailyUsage> {
|
fun slice(all: List<Sub2DailyUsage>): List<Sub2DailyUsage> {
|
||||||
val today = LocalDate.now().toString()
|
val today = LocalDate.now()
|
||||||
fun from(daysBack: Long) = LocalDate.now().minusDays(daysBack).toString()
|
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) {
|
return when (this) {
|
||||||
TODAY -> all.filter { it.date == today }
|
TODAY -> today to today
|
||||||
DAYS_7 -> all.filter { it.date >= from(6) }
|
DAYS_7 -> today.minusDays(6) to today
|
||||||
DAYS_30 -> all.filter { it.date >= from(29) }
|
DAYS_30 -> today.minusDays(29) to today
|
||||||
DAYS_90 -> all.filter { it.date >= from(89) }
|
DAYS_90 -> today.minusDays(89) to today
|
||||||
ALL -> all
|
ALL -> today.minusDays(89) to today
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -324,19 +400,26 @@ private fun DailyCostBars(days: List<Sub2DailyUsage>) {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 长周期(如近90天)柱数多:收窄柱间距避免 3dp×N 间距溢出屏幕导致柱子消失/错乱
|
||||||
|
val barSpacing = when (days.size) {
|
||||||
|
in 0..15 -> 3.dp
|
||||||
|
in 16..44 -> 2.dp
|
||||||
|
else -> 1.dp
|
||||||
|
}
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(88.dp)
|
.height(88.dp)
|
||||||
.clip(RoundedCornerShape(12.dp))
|
.clip(RoundedCornerShape(12.dp))
|
||||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f))
|
.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,
|
verticalAlignment = Alignment.Bottom,
|
||||||
horizontalArrangement = Arrangement.spacedBy(3.dp)
|
horizontalArrangement = Arrangement.spacedBy(barSpacing)
|
||||||
) {
|
) {
|
||||||
days.forEach { day ->
|
days.forEach { day ->
|
||||||
// 零消耗日也占位(0.05f 最小可见高度),保证日期刻度对齐
|
// 每日用量栏高度限制(0.95 最高),避免个别峰值日把柱子顶满、图形失真;
|
||||||
val fraction = ((day.cost / maxCost).toFloat()).coerceIn(0.05f, 1f)
|
// 零消耗日给 0.02f 最小占位,保证日期刻度对齐
|
||||||
|
val fraction = ((day.cost / maxCost).toFloat()).coerceIn(0.02f, 0.95f)
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
|
|||||||
@ -5,18 +5,22 @@ import android.content.ClipboardManager
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ArrowBack
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
@ -27,6 +31,9 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
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.Snackbar
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
@ -37,8 +44,16 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
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.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@ -134,18 +149,40 @@ fun CredentialEditScreen(
|
|||||||
onSave = viewModel::saveWorkBuddyCredential
|
onSave = viewModel::saveWorkBuddyCredential
|
||||||
)
|
)
|
||||||
} else if (service == ServiceType.SUB2API) {
|
} else if (service == ServiceType.SUB2API) {
|
||||||
|
var showPanelLogin by remember { mutableStateOf(false) }
|
||||||
Sub2ApiCredentialForm(
|
Sub2ApiCredentialForm(
|
||||||
baseUrl = uiState.sub2BaseUrl,
|
baseUrl = uiState.sub2BaseUrl,
|
||||||
email = uiState.sub2Email,
|
email = uiState.sub2Email,
|
||||||
password = uiState.sub2Password,
|
password = uiState.sub2Password,
|
||||||
apiKey = uiState.sub2ApiKey,
|
apiKey = uiState.sub2ApiKey,
|
||||||
hasExisting = uiState.hasExisting,
|
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,
|
onBaseUrlChange = viewModel::updateSub2BaseUrl,
|
||||||
onEmailChange = viewModel::updateSub2Email,
|
onEmailChange = viewModel::updateSub2Email,
|
||||||
onPasswordChange = viewModel::updateSub2Password,
|
onPasswordChange = viewModel::updateSub2Password,
|
||||||
onApiKeyChange = viewModel::updateSub2ApiKey,
|
onApiKeyChange = viewModel::updateSub2ApiKey,
|
||||||
|
onLoginModeChange = viewModel::updateSub2LoginMode,
|
||||||
|
onTotpChange = viewModel::updateSub2Totp,
|
||||||
|
onPanelLogin = { showPanelLogin = true },
|
||||||
|
onLoginPanel = viewModel::loginPanelOneClick,
|
||||||
onSave = viewModel::saveSub2ApiCredential
|
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) {
|
} else if (uiState.isApiKeyService) {
|
||||||
if (service == ServiceType.COMMANDCODE_GO) {
|
if (service == ServiceType.COMMANDCODE_GO) {
|
||||||
CommandCodeGoForm(
|
CommandCodeGoForm(
|
||||||
@ -805,18 +842,24 @@ private fun Sub2ApiCredentialForm(
|
|||||||
password: String,
|
password: String,
|
||||||
apiKey: String,
|
apiKey: String,
|
||||||
hasExisting: Boolean,
|
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,
|
onBaseUrlChange: (String) -> Unit,
|
||||||
onEmailChange: (String) -> Unit,
|
onEmailChange: (String) -> Unit,
|
||||||
onPasswordChange: (String) -> Unit,
|
onPasswordChange: (String) -> Unit,
|
||||||
onApiKeyChange: (String) -> Unit,
|
onApiKeyChange: (String) -> Unit,
|
||||||
|
onLoginModeChange: (String) -> Unit = {},
|
||||||
|
onTotpChange: (String) -> Unit = {},
|
||||||
|
onPanelLogin: () -> Unit = {},
|
||||||
|
onLoginPanel: () -> Unit = {},
|
||||||
onSave: () -> Unit
|
onSave: () -> Unit
|
||||||
) {
|
) {
|
||||||
Text(text = stringResource(R.string.credential_title_sub2api), style = MaterialTheme.typography.titleMedium)
|
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(
|
OutlinedTextField(
|
||||||
value = baseUrl,
|
value = baseUrl,
|
||||||
onValueChange = onBaseUrlChange,
|
onValueChange = onBaseUrlChange,
|
||||||
@ -825,24 +868,41 @@ private fun Sub2ApiCredentialForm(
|
|||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth()
|
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(
|
||||||
text = stringResource(R.string.sub2_auth_header),
|
text = siteName ?: stringResource(R.string.sub2_site_probing),
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.outline
|
|
||||||
)
|
|
||||||
OutlinedTextField(
|
|
||||||
value = apiKey,
|
|
||||||
onValueChange = onApiKeyChange,
|
|
||||||
label = { Text(stringResource(R.string.field_sub2_api_key)) },
|
|
||||||
singleLine = true,
|
|
||||||
visualTransformation = PasswordVisualTransformation(),
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.sub2_or_account),
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.outline
|
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(
|
OutlinedTextField(
|
||||||
value = email,
|
value = email,
|
||||||
onValueChange = onEmailChange,
|
onValueChange = onEmailChange,
|
||||||
@ -858,15 +918,125 @@ private fun Sub2ApiCredentialForm(
|
|||||||
visualTransformation = PasswordVisualTransformation(),
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
modifier = Modifier.fillMaxWidth()
|
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(
|
Button(
|
||||||
onClick = onSave,
|
onClick = onSave,
|
||||||
enabled = baseUrl.isNotBlank() && (apiKey.isNotBlank() || (email.isNotBlank() && password.isNotBlank())),
|
enabled = baseUrl.isNotBlank() &&
|
||||||
|
(apiKey.isNotBlank() || (email.isNotBlank() && password.isNotBlank()) || hasPanelToken),
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
Text(if (hasExisting) stringResource(R.string.action_update) else stringResource(R.string.action_save))
|
Text(if (hasExisting) stringResource(R.string.action_update) else stringResource(R.string.action_save))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
private fun copyToClipboard(context: Context, text: String) {
|
||||||
val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
cm.setPrimaryClip(ClipData.newPlainText("login_url", text))
|
cm.setPrimaryClip(ClipData.newPlainText("login_url", text))
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import com.rainy.token.R
|
import com.rainy.token.R
|
||||||
import com.rainy.token.data.repository.CredentialRepository
|
import com.rainy.token.data.repository.CredentialRepository
|
||||||
import com.rainy.token.data.repository.RepositoryError
|
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.CookieEntry
|
||||||
import com.rainy.token.domain.model.Credential
|
import com.rainy.token.domain.model.Credential
|
||||||
import com.rainy.token.domain.service.FetchMethod
|
import com.rainy.token.domain.service.FetchMethod
|
||||||
@ -20,6 +21,8 @@ import javax.inject.Provider
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
@ -36,9 +39,14 @@ import kotlinx.serialization.json.jsonPrimitive
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class CredentialEditViewModel @Inject constructor(
|
class CredentialEditViewModel @Inject constructor(
|
||||||
private val credentialRepository: CredentialRepository,
|
private val credentialRepository: CredentialRepository,
|
||||||
private val refreshBalanceUseCaseProvider: Provider<RefreshBalanceUseCase>
|
private val refreshBalanceUseCaseProvider: Provider<RefreshBalanceUseCase>,
|
||||||
|
private val sub2ApiRepository: Sub2ApiRepository
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
/** Sub2 站点信息探测(baseUrl 输入 debounce 后触发)。 */
|
||||||
|
private var sub2SiteJob: Job? = null
|
||||||
|
private var sub2SiteGeneration: Int = 0
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(CredentialEditUiState())
|
private val _uiState = MutableStateFlow(CredentialEditUiState())
|
||||||
val uiState: StateFlow<CredentialEditUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<CredentialEditUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
@ -97,12 +105,25 @@ class CredentialEditViewModel @Inject constructor(
|
|||||||
workBuddyAccess = (existing as? Credential.WorkBuddyCredential)?.accessToken.orEmpty(),
|
workBuddyAccess = (existing as? Credential.WorkBuddyCredential)?.accessToken.orEmpty(),
|
||||||
workBuddyRefresh = (existing as? Credential.WorkBuddyCredential)?.refreshToken.orEmpty(),
|
workBuddyRefresh = (existing as? Credential.WorkBuddyCredential)?.refreshToken.orEmpty(),
|
||||||
sub2BaseUrl = (existing as? Credential.Sub2ApiCredential)?.baseUrl.orEmpty(),
|
sub2BaseUrl = (existing as? Credential.Sub2ApiCredential)?.baseUrl.orEmpty(),
|
||||||
|
sub2SiteIcon = (existing as? Credential.Sub2ApiCredential)?.siteIcon,
|
||||||
sub2Email = (existing as? Credential.Sub2ApiCredential)?.email.orEmpty(),
|
sub2Email = (existing as? Credential.Sub2ApiCredential)?.email.orEmpty(),
|
||||||
sub2Password = (existing as? Credential.Sub2ApiCredential)?.password.orEmpty(),
|
sub2Password = (existing as? Credential.Sub2ApiCredential)?.password.orEmpty(),
|
||||||
sub2ApiKey = (existing as? Credential.Sub2ApiCredential)?.apiKey.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
|
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 ----------
|
// ---------- Sub2API ----------
|
||||||
|
|
||||||
fun updateSub2BaseUrl(value: String) {
|
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) {
|
fun updateSub2Email(value: String) {
|
||||||
@ -335,6 +398,19 @@ class CredentialEditViewModel @Inject constructor(
|
|||||||
_uiState.update { it.copy(sub2ApiKey = value) }
|
_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() {
|
fun saveSub2ApiCredential() {
|
||||||
val type = serviceType ?: return
|
val type = serviceType ?: return
|
||||||
val current = _uiState.value
|
val current = _uiState.value
|
||||||
@ -342,13 +418,15 @@ class CredentialEditViewModel @Inject constructor(
|
|||||||
val apiKey = current.sub2ApiKey.trim()
|
val apiKey = current.sub2ApiKey.trim()
|
||||||
val email = current.sub2Email.trim()
|
val email = current.sub2Email.trim()
|
||||||
val password = current.sub2Password.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 {
|
viewModelScope.launch {
|
||||||
val existing = credentialRepository.get(type) as? Credential.Sub2ApiCredential
|
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(
|
credentialRepository.save(
|
||||||
(existing ?: Credential.Sub2ApiCredential(service = type, baseUrl = base, email = email, password = password))
|
(existing ?: Credential.Sub2ApiCredential(service = type, baseUrl = base, email = email, password = password))
|
||||||
.copy(
|
.copy(
|
||||||
@ -356,11 +434,13 @@ class CredentialEditViewModel @Inject constructor(
|
|||||||
email = email,
|
email = email,
|
||||||
password = password,
|
password = password,
|
||||||
apiKey = apiKey,
|
apiKey = apiKey,
|
||||||
|
authToken = panelToken,
|
||||||
|
siteIcon = current.sub2SiteIcon ?: existing?.siteIcon,
|
||||||
lastVerifiedAt = existing?.lastVerifiedAt ?: 0L
|
lastVerifiedAt = existing?.lastVerifiedAt ?: 0L
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
_uiState.update {
|
_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 sub2Email: String = "",
|
||||||
val sub2Password: String = "",
|
val sub2Password: String = "",
|
||||||
val sub2ApiKey: 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
|
val message: UiText? = null
|
||||||
)
|
)
|
||||||
|
|||||||
@ -179,7 +179,19 @@
|
|||||||
<string name="sub2_model_meta">%1$s 次请求 · %2$s tokens</string>
|
<string name="sub2_model_meta">%1$s 次请求 · %2$s tokens</string>
|
||||||
<string name="sub2_no_daily">该时间范围内暂无每日用量数据</string>
|
<string name="sub2_no_daily">该时间范围内暂无每日用量数据</string>
|
||||||
<string name="sub2_no_cost">该时间范围内无花费</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 注入用) ══════════ -->
|
<!-- ══════════ 时长标签(DurationText 注入用) ══════════ -->
|
||||||
<string name="format_day">天</string>
|
<string name="format_day">天</string>
|
||||||
@ -628,4 +640,35 @@
|
|||||||
<string name="month_12">十二月</string>
|
<string name="month_12">十二月</string>
|
||||||
<string name="field_api_key">API Key</string>
|
<string name="field_api_key">API Key</string>
|
||||||
<string name="field_workspace_id">Workspace ID</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>
|
</resources>
|
||||||
@ -179,7 +179,19 @@
|
|||||||
<string name="sub2_model_meta">%1$s 次請求 · %2$s tokens</string>
|
<string name="sub2_model_meta">%1$s 次請求 · %2$s tokens</string>
|
||||||
<string name="sub2_no_daily">該時間範圍內暫無每日用量數據</string>
|
<string name="sub2_no_daily">該時間範圍內暫無每日用量數據</string>
|
||||||
<string name="sub2_no_cost">該時間範圍內無花費</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 注入用) ══════════ -->
|
<!-- ══════════ 时长标签(DurationText 注入用) ══════════ -->
|
||||||
<string name="format_day">天</string>
|
<string name="format_day">天</string>
|
||||||
@ -628,4 +640,35 @@
|
|||||||
<string name="month_12">十二月</string>
|
<string name="month_12">十二月</string>
|
||||||
<string name="field_api_key">API Key</string>
|
<string name="field_api_key">API Key</string>
|
||||||
<string name="field_workspace_id">Workspace ID</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>
|
</resources>
|
||||||
@ -182,7 +182,19 @@
|
|||||||
<string name="sub2_model_meta">%1$s requests · %2$s tokens</string>
|
<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_daily">No daily usage data in this range</string>
|
||||||
<string name="sub2_no_cost">No spend 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 注入用) ══════════ -->
|
<!-- ══════════ 时长标签(DurationText 注入用) ══════════ -->
|
||||||
<string name="format_day">d</string>
|
<string name="format_day">d</string>
|
||||||
@ -633,4 +645,35 @@
|
|||||||
<string name="month_12">Dec</string>
|
<string name="month_12">Dec</string>
|
||||||
<string name="field_api_key">API Key</string>
|
<string name="field_api_key">API Key</string>
|
||||||
<string name="field_workspace_id">Workspace ID</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>
|
</resources>
|
||||||
@ -39,6 +39,7 @@ room = "2.7.1"
|
|||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
junitVersion = "1.2.1"
|
junitVersion = "1.2.1"
|
||||||
espressoCore = "3.6.1"
|
espressoCore = "3.6.1"
|
||||||
|
androidsvg = "1.4"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
# Core
|
# 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-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
|
||||||
room-compiler = { group = "androidx.room", name = "room-compiler", 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
|
# Test
|
||||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user