diff --git a/app/src/main/java/com/rainy/token/data/repository/Sub2ApiRepository.kt b/app/src/main/java/com/rainy/token/data/repository/Sub2ApiRepository.kt index 5bfb597..76b3b2f 100644 --- a/app/src/main/java/com/rainy/token/data/repository/Sub2ApiRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/Sub2ApiRepository.kt @@ -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> = 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 { 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 { 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 = 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> = 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( diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt index 8f3d82b..93a24f0 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt @@ -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, diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt index 722b203..e78db10 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt @@ -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 { diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt b/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt index 0283b98..030cfdc 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt @@ -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 + 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= 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 ) diff --git a/app/src/main/java/com/rainy/token/ui/servicedetail/Sub2ApiPanelManagement.kt b/app/src/main/java/com/rainy/token/ui/servicedetail/Sub2ApiPanelManagement.kt index 8600ece..b06908d 100644 --- a/app/src/main/java/com/rainy/token/ui/servicedetail/Sub2ApiPanelManagement.kt +++ b/app/src/main/java/com/rainy/token/ui/servicedetail/Sub2ApiPanelManagement.kt @@ -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(null) } + // 待删除的 Key:先弹确认框(删除不可逆,避免列表里误触) + var deleteForKeyId by remember { mutableStateOf(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("") } diff --git a/app/src/main/java/com/rainy/token/ui/servicedetail/Sub2ApiUsageCard.kt b/app/src/main/java/com/rainy/token/ui/servicedetail/Sub2ApiUsageCard.kt index 05718f9..3e69f25 100644 --- a/app/src/main/java/com/rainy/token/ui/servicedetail/Sub2ApiUsageCard.kt +++ b/app/src/main/java/com/rainy/token/ui/servicedetail/Sub2ApiUsageCard.kt @@ -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 = 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,41 +220,51 @@ 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() ) } - // 每日明细 - Spacer(modifier = Modifier.height(12.dp)) - Text( - text = stringResource(R.string.sub2_daily_header), - style = MaterialTheme.typography.labelLarge, - color = inkMuted() - ) - Spacer(modifier = Modifier.height(6.dp)) - days.reversed().forEachIndexed { index, day -> - DailyRow(day) - if (index != days.lastIndex) { - HorizontalDivider( - modifier = Modifier.padding(vertical = 2.dp), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f) - ) + // 每日明细只在按天且有数据时列出(按小时时逐小时列会过长) + if (!isHourly) { + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringResource(R.string.sub2_daily_header), + style = MaterialTheme.typography.labelLarge, + color = inkMuted() + ) + Spacer(modifier = Modifier.height(6.dp)) + days.reversed().forEachIndexed { index, day -> + DailyRow(day) + if (index != days.lastIndex) { + HorizontalDivider( + modifier = Modifier.padding(vertical = 2.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f) + ) + } } } } @@ -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)