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 优先,与网页「按实际消费」排序一致)。 */ /** 面板 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> { private fun parsePanelModels(body: String): List<Sub2ModelStat> {
val root = json.parseToJsonElement(body).jsonObject val root = json.parseToJsonElement(body).jsonObject
val data = root["data"]?.jsonObject ?: root val data = root["data"]?.jsonObject ?: root
@ -368,15 +399,21 @@ class Sub2ApiRepository constructor(
}.sortedByDescending { it.cost } }.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> { private fun parsePanelTrend(body: String): List<Sub2DailyUsage> {
val root = json.parseToJsonElement(body).jsonObject val root = json.parseToJsonElement(body).jsonObject
val data = root["data"]?.jsonObject ?: root val data = root["data"]?.jsonObject ?: root
val arr = data["trend"] as? JsonArray ?: return emptyList() val arr = data["trend"] as? JsonArray ?: return emptyList()
return arr.mapNotNull { el -> return arr.mapNotNull { el ->
val o = el as? JsonObject ?: return@mapNotNull null val o = el as? JsonObject ?: return@mapNotNull null
val raw = o.scalar("date") ?: o.scalar("time", "bucket", "timestamp") ?: return@mapNotNull null
Sub2DailyUsage( Sub2DailyUsage(
date = o.scalar("date") ?: return@mapNotNull null, date = normalizeTrendBucket(raw),
requests = o.longOr("requests") ?: 0, requests = o.longOr("requests") ?: 0,
totalTokens = o.longOr("total_tokens") ?: 0, totalTokens = o.longOr("total_tokens") ?: 0,
cost = o.numberOr("actual_cost") ?: o.numberOr("cost") ?: 0.0 cost = o.numberOr("actual_cost") ?: o.numberOr("cost") ?: 0.0
@ -384,6 +421,16 @@ class Sub2ApiRepository constructor(
}.sortedBy { it.date } }.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))) } }.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) { suspend fun fetchPanelGroups(): Result<List<PanelGroup>> = withContext(Dispatchers.IO) {
val (auth, base) = panelAuthOrNull() ?: return@withContext Result.failure(RepositoryError.InvalidCredential("请先登录面板")) 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()) } resp.use { SubApiResponse(it.code, it.body?.string().orEmpty()) }
} catch (_: Throwable) { null } } 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( private data class UsageStats(

View File

@ -417,6 +417,7 @@ fun DashboardScreen(
onLoadGroups = { viewModel.loadPanelGroups() }, onLoadGroups = { viewModel.loadPanelGroups() },
onCreateKey = viewModel::createPanelKey, onCreateKey = viewModel::createPanelKey,
onSwitchGroup = viewModel::switchPanelGroup, onSwitchGroup = viewModel::switchPanelGroup,
onDeleteKey = viewModel::deletePanelKey,
onRedeem = viewModel::redeemPanelCode, onRedeem = viewModel::redeemPanelCode,
onConsumeMessage = viewModel::consumePanelActionMessage, onConsumeMessage = viewModel::consumePanelActionMessage,
onConsumeCreatedKey = viewModel::consumeRecentlyCreatedKey, 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 列表。 */ /** 兑换卡密:成功后刷新余额与 Key 列表。 */
fun redeemPanelCode(code: String) { fun redeemPanelCode(code: String) {
viewModelScope.launch { viewModelScope.launch {

View File

@ -715,17 +715,17 @@ private fun Sub2ApiUsageTiles(balance: ServiceBalance) {
.padding(horizontal = 12.dp, vertical = 10.dp), .padding(horizontal = 12.dp, vertical = 10.dp),
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
// 两行:第一行「周期 · 请求次数」,第二行 Token 数(避免占三行显得松散) // 第一行:周期名 + 消耗金额
Row(verticalAlignment = Alignment.Bottom) { Row(verticalAlignment = Alignment.Bottom) {
Text( Text(
text = w.label, text = stringResource(w.labelRes),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = inkMuted(), color = inkMuted(),
maxLines = 1 maxLines = 1
) )
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
Text( Text(
text = stringResource(R.string.sub2_home_requests, w.requests), text = stringResource(R.string.sub2_home_spend, w.cost),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = inkMuted(), color = inkMuted(),
maxLines = 1, maxLines = 1,
@ -762,8 +762,8 @@ private fun buildSub2SummaryWindows(detail: Sub2UsageDetail?): List<Sub2WindowSu
return Triple(req, tok, cost) return Triple(req, tok, cost)
} }
// 首页仅显示 日/周 两个周期(近30天/本月 明细在详情页时间窗口里查看) // 首页仅显示 日/周 两个周期(近30天/本月 明细在详情页时间窗口里查看)
val windows = listOf("今日" to today, "近7天" to today.minusDays(6)) val windows = listOf(R.string.sub2_window_today to today, R.string.sub2_window_7d to today.minusDays(6))
return windows.map { (label, from) -> return windows.map { (labelRes, from) ->
val (req, tok, cost) = sum(from) val (req, tok, cost) = sum(from)
val tokens = when { val tokens = when {
tok >= 1_000_000_000 -> (tok / 1_000_000_000.0).formatLocale("%.1fB") 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") tok >= 1_000 -> (tok / 1_000.0).formatLocale("%.1fK")
else -> tok.toString() else -> tok.toString()
} }
Sub2WindowSummary(label = label, requests = req, tokens = tokens) Sub2WindowSummary(labelRes = labelRes, requests = req, cost = cost, tokens = tokens)
} }
} }
/** 首页周期汇总(今日 / 近7天)。 */ /** 首页周期汇总(今日 / 近7天)。 */
private data class Sub2WindowSummary( private data class Sub2WindowSummary(
val label: String, @androidx.annotation.StringRes val labelRes: Int,
val requests: Long, val requests: Long,
val cost: Double,
val tokens: String 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.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
@ -73,6 +75,7 @@ internal fun Sub2ApiPanelManagement(
onLoadGroups: () -> Unit, onLoadGroups: () -> Unit,
onCreateKey: (String, Long?) -> Unit, onCreateKey: (String, Long?) -> Unit,
onSwitchGroup: (Long, Long) -> Unit, onSwitchGroup: (Long, Long) -> Unit,
onDeleteKey: (Long) -> Unit,
onRedeem: (String) -> Unit, onRedeem: (String) -> Unit,
onConsumeMessage: () -> Unit, onConsumeMessage: () -> Unit,
onConsumeCreatedKey: () -> Unit = {}, onConsumeCreatedKey: () -> Unit = {},
@ -83,6 +86,8 @@ internal fun Sub2ApiPanelManagement(
var showCreate by remember { mutableStateOf(false) } var showCreate by remember { mutableStateOf(false) }
var showRedeem by remember { mutableStateOf(false) } var showRedeem by remember { mutableStateOf(false) }
var switchForKeyId by remember { mutableStateOf<Long?>(null) } var switchForKeyId by remember { mutableStateOf<Long?>(null) }
// 待删除的 Key:先弹确认框(删除不可逆,避免列表里误触)
var deleteForKeyId by remember { mutableStateOf<Long?>(null) }
val groupMap = remember(groups) { groups.associateBy { it.id } } val groupMap = remember(groups) { groups.associateBy { it.id } }
val copiedToast = stringResource(R.string.sub2_panel_copied) 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) 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)) 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 弹窗 // 新建 Key 弹窗
if (showCreate) { if (showCreate) {
var name by remember { mutableStateOf("") } var name by remember { mutableStateOf("") }

View File

@ -62,7 +62,10 @@ import java.util.Locale
internal fun Sub2ApiUsageCard( internal fun Sub2ApiUsageCard(
state: State, state: State,
windowStatsState: PanelWindowStatsState = PanelWindowStatsState.Idle, 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) { val balance = when (state) {
is State.Fresh -> state.data is State.Fresh -> state.data
@ -120,6 +123,16 @@ internal fun Sub2ApiUsageCard(
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) }
// 「今天」改为按小时展示:只有一根按天柱子看不出分布,故拉当天小时趋势
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 控制) // 非「全部」窗口:按所选时间周期向网关拉取分模型统计(model_stats 受 start_date/end_date 控制)
LaunchedEffect(window) { LaunchedEffect(window) {
if (onLoadWindowModelStats != null && window != Sub2Window.ALL) { if (onLoadWindowModelStats != null && window != Sub2Window.ALL) {
@ -193,9 +206,13 @@ internal fun Sub2ApiUsageCard(
} }
} }
// ── 每日区域:仅「全部模型」时展示(daily_usage 是账户整体,无模型维度)── // ── 趋势区域:按天(或「今天」按小时)展示 ──
if (selectedModel == null) { // 说明:daily_usage 是账户整体、无模型维度,因此选中单个模型时这张图仍然是
if (days.isEmpty()) { // 「账户整体趋势」——加一行标注说明口径,而不是像以前那样整块隐藏
//(隐藏会让用户以为柱状图坏了)。
run {
val isHourly = useHourly
if (chartSeries.isEmpty()) {
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
Text( Text(
text = stringResource(R.string.sub2_no_daily), text = stringResource(R.string.sub2_no_daily),
@ -203,27 +220,36 @@ internal fun Sub2ApiUsageCard(
color = inkMuted() color = inkMuted()
) )
} else { } else {
// 每日花费柱状图 + 首尾日期刻度
Spacer(modifier = Modifier.height(14.dp)) 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)) Spacer(modifier = Modifier.height(4.dp))
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
) { ) {
Text( Text(
text = shortDate(days.first().date), text = shortDate(chartSeries.first().date),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = inkMuted() color = inkMuted()
) )
Text( Text(
text = shortDate(days.last().date), text = shortDate(chartSeries.last().date),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = inkMuted() color = inkMuted()
) )
} }
// 每日明细 // 每日明细只在按天且有数据时列出(按小时时逐小时列会过长)
if (!isHourly) {
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
Text( Text(
text = stringResource(R.string.sub2_daily_header), text = stringResource(R.string.sub2_daily_header),
@ -242,6 +268,7 @@ internal fun Sub2ApiUsageCard(
} }
} }
} }
}
// ── 按模型统计(整卡始终展示,数据随所选时间周期变化)── // ── 按模型统计(整卡始终展示,数据随所选时间周期变化)──
val statsLoading = window != Sub2Window.ALL && 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 { private fun shortDate(iso: String): String {
// 小时桶("14:00")不是日期,直接展示,别走 LocalDate.parse
if (iso.length == 5 && iso[2] == ':') return iso
return runCatching { return runCatching {
LocalDate.parse(iso).format(DateTimeFormatter.ofPattern("MM-dd", Locale.US)) LocalDate.parse(iso).format(DateTimeFormatter.ofPattern("MM-dd", Locale.US))
}.getOrDefault(iso) }.getOrDefault(iso)