feat(sub2api): 面板管理支持删除 Key;用量明细修柱状图与按小时趋势

This commit is contained in:
Liuxinyu176 2026-09-13 00:46:04 +08:00
parent bc862df226
commit 100b698f38
6 changed files with 214 additions and 33 deletions

View File

@ -350,6 +350,37 @@ class Sub2ApiRepository constructor(
}
/** 面板 models 分布 → Sub2ModelStat(actual_cost 优先,与网页「按实际消费」排序一致)。 */
/**
* 当天趋势(按小时):GET /api/v1/usage/dashboard/snapshot-v2?granularity=hour。
*
* 用于「今天」窗口——按天只有一根柱子没有信息量,改为按小时展示。
* 若面板版本不支持 hour 粒度(返回空/报错),返回空列表,由调用方回退到按天。
*/
suspend fun fetchHourlyTrend(date: LocalDate): Result<List<Sub2DailyUsage>> = withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.SUB2API) as? Credential.Sub2ApiCredential
?: return@withContext Result.success(emptyList())
val base = normalizeBase(credential.baseUrl)
if (base.isBlank()) return@withContext Result.success(emptyList())
val panelToken = credential.authToken?.trim().takeUnless { it.isNullOrEmpty() }
val auth = if (panelToken != null) {
AuthHeader("Authorization", "Bearer " + panelToken)
} else {
resolveAuth(base, credential) ?: return@withContext Result.success(emptyList())
}
val day = date.toString()
val resp = get(
base,
"/api/v1/usage/dashboard/snapshot-v2?start_date=$day&end_date=$day" +
"&granularity=hour&include_trend=true&include_model_stats=false" +
"&include_group_stats=false&timezone=" + tzParam(),
auth
)
// 不支持时静默回退:这里不把失败抛给 UI,让「今天」仍能显示按天柱状图
if (resp == null || resp.code !in 200..299) return@withContext Result.success(emptyList())
val list = runCatching { parsePanelTrend(resp.body) }.getOrDefault(emptyList())
Result.success(list)
}
private fun parsePanelModels(body: String): List<Sub2ModelStat> {
val root = json.parseToJsonElement(body).jsonObject
val data = root["data"]?.jsonObject ?: root
@ -368,15 +399,21 @@ class Sub2ApiRepository constructor(
}.sortedByDescending { it.cost }
}
/** 面板 snapshot-v2.trend → Sub2DailyUsage(actual_cost 优先,cost 兜底)。 */
/**
* 面板 snapshot-v2.trend → Sub2DailyUsage(actual_cost 优先,cost 兜底)。
*
* granularity=hour 时面板可能返回 "2026-09-13T14:00:00" 这类带时间的串,
* 这里统一归一化为 "HH:00",让按小时与按天两种趋势共用同一渲染路径。
*/
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
val raw = o.scalar("date") ?: o.scalar("time", "bucket", "timestamp") ?: return@mapNotNull null
Sub2DailyUsage(
date = o.scalar("date") ?: return@mapNotNull null,
date = normalizeTrendBucket(raw),
requests = o.longOr("requests") ?: 0,
totalTokens = o.longOr("total_tokens") ?: 0,
cost = o.numberOr("actual_cost") ?: o.numberOr("cost") ?: 0.0
@ -384,6 +421,16 @@ class Sub2ApiRepository constructor(
}.sortedBy { it.date }
}
/** "2026-09-13T14:00:00" / "2026-09-13 14:00" → "14:00";纯日期原样返回。 */
private fun normalizeTrendBucket(raw: String): String {
val s = raw.trim()
val hasTime = s.length > 10 && (s[10] == 'T' || s[10] == ' ')
if (!hasTime) return s
val time = s.substring(11)
val hh = time.substringBefore(':').take(2)
return if (hh.length == 2 && hh.all { it.isDigit() }) hh + ":00" else s
}
// ---------- 面板一键登录 / 站点信息 ----------
/**
@ -682,6 +729,36 @@ class Sub2ApiRepository constructor(
}.getOrElse { e -> Result.failure(RepositoryError.Unknown(IllegalStateException("修改 Key 失败:" + e.message))) }
}
/**
* 删除 Key:DELETE /api/v1/keys/{id}。
*
* 面板返回形态不统一({code:0} / {success:true} / 204 空体),因此判定顺序为:
* HTTP 2xx 且没有业务错误码即视为成功;解析不出 JSON 也不当作失败(部分版本返回空体)。
*/
suspend fun deletePanelKey(id: Long): Result<Boolean> = withContext(Dispatchers.IO) {
val (auth, base) = panelAuthOrNull()
?: return@withContext Result.failure(RepositoryError.InvalidCredential("请先登录面板"))
val resp = delete(base, "/api/v1/keys/" + id, auth)
?: return@withContext Result.failure(RepositoryError.Unknown(IllegalStateException("删除 Key 失败(网络异常)")))
if (resp.code !in 200..299) {
return@withContext Result.failure(mapSubHttpError(resp.code))
}
// 2xx 但体里带业务错误码(部分面板用 200 + code!=0 表达失败)
val bizError = runCatching {
val obj = json.parseToJsonElement(resp.body).jsonObject
val codeNum = obj.numberOr("code", "status")
val successFlag = obj["success"]?.jsonPrimitive?.contentOrNull?.toBooleanStrictOrNull()
if (codeNum != null && codeNum != 0.0) obj.scalar("message", "error") ?: "删除 Key 失败"
else if (successFlag == false) obj.scalar("message", "error") ?: "删除 Key 失败"
else null
}.getOrNull()
if (bizError != null) {
Result.failure(RepositoryError.Unknown(IllegalStateException(bizError)))
} else {
Result.success(true)
}
}
/** 可用分组列表(切换分组选择器)。 */
suspend fun fetchPanelGroups(): Result<List<PanelGroup>> = withContext(Dispatchers.IO) {
val (auth, base) = panelAuthOrNull() ?: return@withContext Result.failure(RepositoryError.InvalidCredential("请先登录面板"))
@ -801,6 +878,19 @@ class Sub2ApiRepository constructor(
resp.use { SubApiResponse(it.code, it.body?.string().orEmpty()) }
} catch (_: Throwable) { null }
}
private suspend fun delete(base: String, path: String, auth: AuthHeader): SubApiResponse? {
return try {
val resp = okHttpClient.newCall(
Request.Builder()
.url(base + path)
.addHeader(auth.name, auth.value)
.delete()
.build()
).execute()
resp.use { SubApiResponse(it.code, it.body?.string().orEmpty()) }
} catch (_: Throwable) { null }
}
// ---------- 解析 ----------
private data class UsageStats(

View File

@ -417,6 +417,7 @@ fun DashboardScreen(
onLoadGroups = { viewModel.loadPanelGroups() },
onCreateKey = viewModel::createPanelKey,
onSwitchGroup = viewModel::switchPanelGroup,
onDeleteKey = viewModel::deletePanelKey,
onRedeem = viewModel::redeemPanelCode,
onConsumeMessage = viewModel::consumePanelActionMessage,
onConsumeCreatedKey = viewModel::consumeRecentlyCreatedKey,

View File

@ -273,6 +273,19 @@ class DashboardViewModel @Inject constructor(
}
}
/** 删除 Key:成功后刷新列表。 */
fun deletePanelKey(keyId: Long) {
viewModelScope.launch {
sub2ApiRepository.deletePanelKey(keyId).fold(
onSuccess = {
_panelActionMessage.value = "已删除 Key"
loadPanelKeys()
},
onFailure = { e -> _panelActionMessage.value = "删除失败:" + (e.message ?: "") }
)
}
}
/** 兑换卡密:成功后刷新余额与 Key 列表。 */
fun redeemPanelCode(code: String) {
viewModelScope.launch {

View File

@ -715,17 +715,17 @@ private fun Sub2ApiUsageTiles(balance: ServiceBalance) {
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalArrangement = Arrangement.Center
) {
// 两行:第一行「周期 · 请求次数」,第二行 Token 数(避免占三行显得松散)
// 第一行:周期名 + 消耗金额
Row(verticalAlignment = Alignment.Bottom) {
Text(
text = w.label,
text = stringResource(w.labelRes),
style = MaterialTheme.typography.labelSmall,
color = inkMuted(),
maxLines = 1
)
Spacer(modifier = Modifier.weight(1f))
Text(
text = stringResource(R.string.sub2_home_requests, w.requests),
text = stringResource(R.string.sub2_home_spend, w.cost),
style = MaterialTheme.typography.labelSmall,
color = inkMuted(),
maxLines = 1,
@ -762,8 +762,8 @@ private fun buildSub2SummaryWindows(detail: Sub2UsageDetail?): List<Sub2WindowSu
return Triple(req, tok, cost)
}
// 首页仅显示 日/周 两个周期(近30天/本月 明细在详情页时间窗口里查看)
val windows = listOf("今日" to today, "近7天" to today.minusDays(6))
return windows.map { (label, from) ->
val windows = listOf(R.string.sub2_window_today to today, R.string.sub2_window_7d to today.minusDays(6))
return windows.map { (labelRes, from) ->
val (req, tok, cost) = sum(from)
val tokens = when {
tok >= 1_000_000_000 -> (tok / 1_000_000_000.0).formatLocale("%.1fB")
@ -771,14 +771,15 @@ private fun buildSub2SummaryWindows(detail: Sub2UsageDetail?): List<Sub2WindowSu
tok >= 1_000 -> (tok / 1_000.0).formatLocale("%.1fK")
else -> tok.toString()
}
Sub2WindowSummary(label = label, requests = req, tokens = tokens)
Sub2WindowSummary(labelRes = labelRes, requests = req, cost = cost, tokens = tokens)
}
}
/** 首页周期汇总(今日 / 近7天)。 */
private data class Sub2WindowSummary(
val label: String,
@androidx.annotation.StringRes val labelRes: Int,
val requests: Long,
val cost: Double,
val tokens: String
)

View File

@ -17,9 +17,11 @@ 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.Delete
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
@ -73,6 +75,7 @@ internal fun Sub2ApiPanelManagement(
onLoadGroups: () -> Unit,
onCreateKey: (String, Long?) -> Unit,
onSwitchGroup: (Long, Long) -> Unit,
onDeleteKey: (Long) -> Unit,
onRedeem: (String) -> Unit,
onConsumeMessage: () -> Unit,
onConsumeCreatedKey: () -> Unit = {},
@ -83,6 +86,8 @@ internal fun Sub2ApiPanelManagement(
var showCreate by remember { mutableStateOf(false) }
var showRedeem by remember { mutableStateOf(false) }
var switchForKeyId by remember { mutableStateOf<Long?>(null) }
// 待删除的 Key:先弹确认框(删除不可逆,避免列表里误触)
var deleteForKeyId by remember { mutableStateOf<Long?>(null) }
val groupMap = remember(groups) { groups.associateBy { it.id } }
val copiedToast = stringResource(R.string.sub2_panel_copied)
@ -245,6 +250,15 @@ internal fun Sub2ApiPanelManagement(
Text(stringResource(R.string.sub2_panel_group_column), style = MaterialTheme.typography.labelSmall)
}
}
// 删除:用图标而非文字,避免一行挤下三个按钮在窄屏折行
IconButton(onClick = { deleteForKeyId = key.id }) {
Icon(
imageVector = Icons.Filled.Delete,
contentDescription = stringResource(R.string.action_delete),
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(18.dp)
)
}
}
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f))
}
@ -255,6 +269,39 @@ internal fun Sub2ApiPanelManagement(
}
}
// 删除确认弹窗:删除不可逆,二次确认后才发请求
deleteForKeyId?.let { keyId ->
val targetName = (keysState as? PanelKeysState.Success)
?.keys?.firstOrNull { it.id == keyId }?.name
AlertDialog(
onDismissRequest = { deleteForKeyId = null },
title = { Text(stringResource(R.string.sub2_panel_delete_confirm_title), fontWeight = FontWeight.Bold) },
text = {
Text(
stringResource(
R.string.sub2_panel_delete_confirm_body,
targetName ?: ("#" + keyId)
)
)
},
confirmButton = {
Button(
onClick = {
onDeleteKey(keyId)
deleteForKeyId = null
},
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError
)
) { Text(stringResource(R.string.action_delete)) }
},
dismissButton = {
TextButton(onClick = { deleteForKeyId = null }) { Text(stringResource(R.string.action_cancel)) }
}
)
}
// 新建 Key 弹窗
if (showCreate) {
var name by remember { mutableStateOf("") }

View File

@ -62,7 +62,10 @@ import java.util.Locale
internal fun Sub2ApiUsageCard(
state: State,
windowStatsState: PanelWindowStatsState = PanelWindowStatsState.Idle,
onLoadWindowModelStats: ((from: LocalDate, to: LocalDate) -> Unit)? = null
onLoadWindowModelStats: ((from: LocalDate, to: LocalDate) -> Unit)? = null,
/** 「今天」窗口的按小时趋势;非空时用小时柱替代按天柱(按天只有一根柱没有信息量)。 */
hourlyTrend: List<Sub2DailyUsage> = emptyList(),
onLoadHourlyTrend: (() -> Unit)? = null
) {
val balance = when (state) {
is State.Fresh -> state.data
@ -120,6 +123,16 @@ internal fun Sub2ApiUsageCard(
val days = remember(detail, window) { window.slice(detail.dailyUsage) }
val rangeLabel = remember(days) { formatRange(days) }
// 「今天」改为按小时展示:只有一根按天柱子看不出分布,故拉当天小时趋势
LaunchedEffect(window) {
if (window == Sub2Window.TODAY) onLoadHourlyTrend?.invoke()
}
// 小时数据只在「今天」窗口且拿到 ≥2 个小时桶时才用。
// 实测面板可能把整天聚合成单个 "00:00" 桶(时区/活动量原因),
// 此时按小时展示两端刻度都是 00:00,反而不如按天清楚,故回退按天。
val useHourly = window == Sub2Window.TODAY && hourlyTrend.size >= 2
val chartSeries = if (useHourly) hourlyTrend else days
// 非「全部」窗口:按所选时间周期向网关拉取分模型统计(model_stats 受 start_date/end_date 控制)
LaunchedEffect(window) {
if (onLoadWindowModelStats != null && window != Sub2Window.ALL) {
@ -193,9 +206,13 @@ internal fun Sub2ApiUsageCard(
}
}
// ── 每日区域:仅「全部模型」时展示(daily_usage 是账户整体,无模型维度)──
if (selectedModel == null) {
if (days.isEmpty()) {
// ── 趋势区域:按天(或「今天」按小时)展示 ──
// 说明:daily_usage 是账户整体、无模型维度,因此选中单个模型时这张图仍然是
// 「账户整体趋势」——加一行标注说明口径,而不是像以前那样整块隐藏
//(隐藏会让用户以为柱状图坏了)。
run {
val isHourly = useHourly
if (chartSeries.isEmpty()) {
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(R.string.sub2_no_daily),
@ -203,27 +220,36 @@ internal fun Sub2ApiUsageCard(
color = inkMuted()
)
} else {
// 每日花费柱状图 + 首尾日期刻度
Spacer(modifier = Modifier.height(14.dp))
DailyCostBars(days)
if (selectedModel != null) {
// 选中模型时明确标注:趋势图仍是账户整体口径,避免误读为该模型趋势
Text(
text = stringResource(R.string.sub2_trend_account_scope),
style = MaterialTheme.typography.labelSmall,
color = inkMuted()
)
Spacer(modifier = Modifier.height(6.dp))
}
DailyCostBars(chartSeries)
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = shortDate(days.first().date),
text = shortDate(chartSeries.first().date),
style = MaterialTheme.typography.labelSmall,
color = inkMuted()
)
Text(
text = shortDate(days.last().date),
text = shortDate(chartSeries.last().date),
style = MaterialTheme.typography.labelSmall,
color = inkMuted()
)
}
// 每日明细
// 每日明细只在按天且有数据时列出(按小时时逐小时列会过长)
if (!isHourly) {
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(R.string.sub2_daily_header),
@ -242,6 +268,7 @@ internal fun Sub2ApiUsageCard(
}
}
}
}
// ── 按模型统计(整卡始终展示,数据随所选时间周期变化)──
val statsLoading = window != Sub2Window.ALL &&
@ -523,8 +550,10 @@ private fun ModelRow(stat: Sub2ModelStat, highlighted: Boolean = false) {
}
}
/** "MM-dd" 短日期;解析失败原样返回。 */
/** "MM-dd" 短日期;"HH:00" 小时刻度原样返回;解析失败原样返回。 */
private fun shortDate(iso: String): String {
// 小时桶("14:00")不是日期,直接展示,别走 LocalDate.parse
if (iso.length == 5 && iso[2] == ':') return iso
return runCatching {
LocalDate.parse(iso).format(DateTimeFormatter.ofPattern("MM-dd", Locale.US))
}.getOrDefault(iso)