feat(sub2api): 面板管理支持删除 Key;用量明细修柱状图与按小时趋势
This commit is contained in:
parent
bc862df226
commit
100b698f38
@ -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(
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -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("") }
|
||||||
|
|||||||
@ -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,41 +220,51 @@ 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()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 每日明细
|
// 每日明细只在按天且有数据时列出(按小时时逐小时列会过长)
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
if (!isHourly) {
|
||||||
Text(
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
text = stringResource(R.string.sub2_daily_header),
|
Text(
|
||||||
style = MaterialTheme.typography.labelLarge,
|
text = stringResource(R.string.sub2_daily_header),
|
||||||
color = inkMuted()
|
style = MaterialTheme.typography.labelLarge,
|
||||||
)
|
color = inkMuted()
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
)
|
||||||
days.reversed().forEachIndexed { index, day ->
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
DailyRow(day)
|
days.reversed().forEachIndexed { index, day ->
|
||||||
if (index != days.lastIndex) {
|
DailyRow(day)
|
||||||
HorizontalDivider(
|
if (index != days.lastIndex) {
|
||||||
modifier = Modifier.padding(vertical = 2.dp),
|
HorizontalDivider(
|
||||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)
|
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 {
|
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)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user