feat(sub2api): 详情页新增「按 Key」tab;面板管理按钮间距收紧

详情页「按模型」分区改为 tab 切换:按模型 / 按 Key。
按 Key tab 调 GET /api/v1/usage/stats?api_key_id=N 逐个查询,并行拉取;
未使用的 Key(404)显示为 0 而非报错。

面板管理「复制/分组/删除」三个操作从 TextButton 改为可点击 Text,
去掉 58dp 最小宽度带来的过大间隔(用户反馈「间隔有点宽」)。

补齐三语言缺失字符串(sub2_home_spend / sub2_panel_delete_confirm_*
/ sub2_trend_account_scope / sub2_tab_by_* / sub2_no_key_data /
sub2_key_unused)。
This commit is contained in:
Liuxinyu176 2026-09-13 11:44:00 +08:00
parent 100b698f38
commit dc3027417e
8 changed files with 2883 additions and 2572 deletions

View File

@ -13,6 +13,9 @@ import java.time.temporal.ChronoUnit
import java.util.TimeZone
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
@ -526,6 +529,23 @@ class Sub2ApiRepository constructor(
*/
data class WindowModelStats(val stats: List<Sub2ModelStat>, val allTime: Boolean = false)
/**
* 单个 Key 在所选窗口内的用量汇总(来源:面板「使用记录」页的
* GET /api/v1/usage/stats?start_date=&end_date=&api_key_id=N,实测 v0.2.10)。
*
* 未使用过的 Key 该端点返回 404 API_KEY_NOT_FOUND,故用 [unused] 表示
* 「面板无该 Key 的记录」,UI 显示为 0 而不是报错。
*/
data class KeyUsageStat(
val keyId: Long,
val name: String,
val groupName: String?,
val requests: Long,
val totalTokens: Long,
val cost: Double,
val unused: Boolean = false
)
/** 可用分组(切换分组选择器数据)。 */
data class PanelGroup(val id: Long, val name: String, val rateMultiplier: Double, val description: String = "")
@ -663,6 +683,77 @@ class Sub2ApiRepository constructor(
}.getOrNull()
}
/**
* 按 Key 统计(面板「使用记录」页口径,v0.2.10 实测)。
*
* 先取 Key 列表,再对每个 Key 调
* GET /api/v1/usage/stats?start_date=&end_date=&api_key_id=N。
* 未使用过的 Key 返回 404 API_KEY_NOT_FOUND —— 属「无记录」而非错误,
* 标记为 [KeyUsageStat.unused] 并计 0,不中断整个列表。
*
* 说明:面板没有「一次拿到所有 Key 用量」的端点(dashboard 下 keys 相关路由均 404,
* /api/v1/keys 上的 usage_1d/5h/7d 实测恒为 0),所以必须逐个查询,
* 故用 coroutineScope + async 并行,避免 Key 多时串行等待过久。
*/
suspend fun fetchKeyUsageStats(startDate: LocalDate, endDate: LocalDate): Result<List<KeyUsageStat>> =
withContext(Dispatchers.IO) {
val (auth, base) = panelAuthOrNull()
?: return@withContext Result.failure(RepositoryError.InvalidCredential("请先登录面板"))
val keys = fetchPanelKeys().getOrElse { return@withContext Result.failure(it) }
if (keys.isEmpty()) return@withContext Result.success(emptyList())
val start = startDate.toString()
val end = endDate.toString()
val tz = tzParam()
// 分组名映射(展示「Key · 分组」),拉取失败不影响主流程
val groups = runCatching { fetchPanelGroups().getOrNull().orEmpty() }
.getOrDefault(emptyList())
.associateBy { it.id }
val stats = coroutineScope {
keys.map { k ->
async(Dispatchers.IO) {
val resp = get(
base,
"/api/v1/usage/stats?start_date=$start&end_date=$end" +
"&api_key_id=${k.id}&timezone=$tz",
auth
)
val unused = resp == null || resp.code == 404
val usage = if (!unused && resp!!.code in 200..299) {
runCatching { parseKeyUsage(resp.body) }.getOrNull()
} else null
KeyUsageStat(
keyId = k.id,
name = k.name,
groupName = k.groupId?.let { groups[it]?.name },
requests = usage?.first ?: 0L,
totalTokens = usage?.second ?: 0L,
cost = usage?.third ?: 0.0,
unused = unused
)
}
}.awaitAll()
}
Result.success(
stats.sortedWith(
compareByDescending<KeyUsageStat> { it.cost }
.thenByDescending { it.requests }
.thenBy { it.name }
)
)
}
/** 解析 usage/stats → (requests, totalTokens, actualCost)。 */
private fun parseKeyUsage(body: String): Triple<Long, Long, Double>? {
val root = json.parseToJsonElement(body).jsonObject
val d = root["data"]?.jsonObject ?: root
val requests = d.longOr("total_requests") ?: return null
val tokens = d.longOr("total_tokens") ?: 0L
val cost = d.numberOr("total_actual_cost") ?: d.numberOr("total_cost") ?: 0.0
return Triple(requests, tokens, cost)
}
/**
* 新建 Key:POST /api/v1/keys {name, group_id?} → 返回明文 sk-key。
* 兼容多种响应形态:data 为对象(key / api_key / sk_key / token 字段)、data 为字符串,

View File

@ -120,6 +120,7 @@ fun ServiceDetailScreen(
// 面板管理(Key/分组/兑换)已移到首页 Sub2API 卡片的「面板管理」入口,
// 详情页只保留用量明细所需的窗口统计状态。
val panelWindowStats by viewModel.panelWindowStats.collectAsStateWithLifecycle()
val panelKeyStats by viewModel.panelKeyStats.collectAsStateWithLifecycle()
val config = ServiceConfigProvider.get(service)
val isManualMode = config.method == FetchMethod.MANUAL
@ -205,7 +206,9 @@ fun ServiceDetailScreen(
Sub2ApiUsageCard(
state = uiState.state,
windowStatsState = panelWindowStats,
onLoadWindowModelStats = { from, to -> viewModel.loadPanelWindowModelStats(from, to) }
onLoadWindowModelStats = { from, to -> viewModel.loadPanelWindowModelStats(from, to) },
keyStatsState = panelKeyStats,
onLoadKeyStats = { from, to -> viewModel.loadPanelKeyStats(from, to) }
)
}
// 面板管理(Key 列表/新建/分组/兑换)不再在详情页展示:

View File

@ -132,6 +132,36 @@ class ServiceDetailViewModel @Inject constructor(
}
}
/** 「按 Key」用量状态(面板使用记录口径,按当前时间周期)。 */
private val _panelKeyStats = MutableStateFlow<PanelKeyStatsState>(PanelKeyStatsState.Idle)
val panelKeyStats: StateFlow<PanelKeyStatsState> = _panelKeyStats.asStateFlow()
private var panelKeyStatsGeneration = 0
/** 按所选周期拉取「按 Key」用量。 */
fun loadPanelKeyStats(from: LocalDate, to: LocalDate) {
val generation = ++panelKeyStatsGeneration
_panelKeyStats.value = PanelKeyStatsState.Loading
viewModelScope.launch {
val result = sub2ApiRepository.fetchKeyUsageStats(from, to)
if (generation != panelKeyStatsGeneration) return@launch
result.fold(
onSuccess = { list ->
_panelKeyStats.value = PanelKeyStatsState.Success(
list.map {
PanelKeyStatsState.KeyStat(
keyId = it.keyId, name = it.name, groupName = it.groupName,
requests = it.requests, totalTokens = it.totalTokens,
cost = it.cost, unused = it.unused
)
}
)
},
onFailure = { e -> _panelKeyStats.value = PanelKeyStatsState.Error(e.message ?: "加载按 Key 用量失败") }
)
}
}
/** 刷新面板 Key 列表(仅 SUB2API 且有面板 Token)。 */
fun loadPanelKeys() {
viewModelScope.launch {
@ -777,6 +807,19 @@ sealed class PanelWindowStatsState {
data class Error(val message: String) : PanelWindowStatsState()
}
/** Sub2API「按 Key」用量状态(按当前时间周期查询面板使用记录)。 */
sealed class PanelKeyStatsState {
data object Idle : PanelKeyStatsState()
data object Loading : PanelKeyStatsState()
data class Success(val stats: List<KeyStat>) : PanelKeyStatsState()
data class Error(val message: String) : PanelKeyStatsState()
data class KeyStat(
val keyId: Long, val name: String, val groupName: String?,
val requests: Long, val totalTokens: Long, val cost: Double, val unused: Boolean
)
}
sealed class State {
data object Loading : State()
data class Fresh(val data: ServiceBalance) : State()

View File

@ -5,6 +5,7 @@ import android.content.ClipboardManager
import android.content.Context
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@ -242,21 +243,44 @@ internal fun Sub2ApiPanelManagement(
overflow = TextOverflow.Ellipsis
)
}
TextButton(onClick = {
copyToClipboard(context, key.key, copiedToast)
}) { Text(stringResource(R.string.action_copy), style = MaterialTheme.typography.labelSmall) }
// 三个操作紧凑排布(用户反馈「间隔有点宽」)。
// 注意:Material3 的 TextButton 会把 58dp 最小宽度与内边距加在
// 其内层节点上,外层传 defaultMinSize(0)/contentPadding(0) 压不下去
//(实测按钮可点区仍有 232px),所以这里直接用可点击 Text。
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = stringResource(R.string.action_copy),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.clickable { copyToClipboard(context, key.key, copiedToast) }
.padding(horizontal = 2.dp, vertical = 4.dp)
)
if (groups.isNotEmpty()) {
TextButton(onClick = { switchForKeyId = key.id }) {
Text(stringResource(R.string.sub2_panel_group_column), style = MaterialTheme.typography.labelSmall)
}
Text(
text = stringResource(R.string.sub2_panel_group_column),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.clickable { switchForKeyId = key.id }
.padding(horizontal = 2.dp, vertical = 4.dp)
)
}
// 删除:用图标而非文字,避免一行挤下三个按钮在窄屏折行
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)
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.clickable { deleteForKeyId = key.id }
.padding(2.dp)
.size(18.dp)
)
}
}

View File

@ -33,6 +33,7 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.rainy.token.R
import com.rainy.token.domain.model.Sub2DailyUsage
@ -65,7 +66,10 @@ internal fun Sub2ApiUsageCard(
onLoadWindowModelStats: ((from: LocalDate, to: LocalDate) -> Unit)? = null,
/** 「今天」窗口的按小时趋势;非空时用小时柱替代按天柱(按天只有一根柱没有信息量)。 */
hourlyTrend: List<Sub2DailyUsage> = emptyList(),
onLoadHourlyTrend: (() -> Unit)? = null
onLoadHourlyTrend: (() -> Unit)? = null,
/** 「按 Key」tab 的数据状态(按当前时间周期查询面板使用记录)。 */
keyStatsState: PanelKeyStatsState = PanelKeyStatsState.Idle,
onLoadKeyStats: ((from: LocalDate, to: LocalDate) -> Unit)? = null
) {
val balance = when (state) {
is State.Fresh -> state.data
@ -104,6 +108,8 @@ internal fun Sub2ApiUsageCard(
var window by remember { mutableStateOf(Sub2Window.TODAY) }
// key=detail:刷新后模型列表变化时重置选中态,避免「每日区隐藏+无模型说明」的悬空状态
var selectedModel by remember(detail) { mutableStateOf<String?>(null) }
// 明细分区 tab:按模型 / 按 Key
var detailTab by remember(detail) { mutableStateOf(DetailTab.MODEL) }
// ── 时间窗口选择 ──
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
@ -140,6 +146,14 @@ internal fun Sub2ApiUsageCard(
onLoadWindowModelStats(from, to)
}
}
// 「按 Key」tab 打开或切换时间周期时,按当前周期拉取按 Key 用量
LaunchedEffect(window, detailTab) {
if (onLoadKeyStats != null && detailTab == DetailTab.KEY) {
val (from, to) = window.range()
onLoadKeyStats(from, to)
}
}
if (rangeLabel != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
@ -270,7 +284,7 @@ internal fun Sub2ApiUsageCard(
}
}
// ── 按模型统计(整卡始终展示,数据随所选时间周期变化)──
// ── 明细分区:「按模型 / 按 Key」两个 tab(数据都随所选时间周期变化)──
val statsLoading = window != Sub2Window.ALL &&
(windowStatsState is PanelWindowStatsState.Idle || windowStatsState is PanelWindowStatsState.Loading)
val statsError = (windowStatsState as? PanelWindowStatsState.Error)?.message
@ -279,6 +293,33 @@ internal fun Sub2ApiUsageCard(
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
Spacer(modifier = Modifier.height(14.dp))
// tab 切换:按模型 / 按 Key
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = detailTab == DetailTab.MODEL,
onClick = { detailTab = DetailTab.MODEL },
label = { Text(stringResource(R.string.sub2_tab_by_model)) },
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = MaterialTheme.colorScheme.primary,
selectedLabelColor = MaterialTheme.colorScheme.onPrimary
)
)
FilterChip(
selected = detailTab == DetailTab.KEY,
onClick = { detailTab = DetailTab.KEY },
label = { Text(stringResource(R.string.sub2_tab_by_key)) },
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = MaterialTheme.colorScheme.primary,
selectedLabelColor = MaterialTheme.colorScheme.onPrimary
)
)
}
Spacer(modifier = Modifier.height(12.dp))
if (detailTab == DetailTab.KEY) {
KeyUsageSection(keyStatsState)
} else {
Text(
text = stringResource(R.string.sub2_models_header) +
if (windowStatsAllTime) stringResource(R.string.sub2_models_alltime_suffix) else "",
@ -343,6 +384,91 @@ internal fun Sub2ApiUsageCard(
}
}
}
}
/** 明细分区 tab:按模型 / 按 Key。 */
private enum class DetailTab { MODEL, KEY }
/** 按 Key 用量区域:加载中/错误/空态/列表。 */
@Composable
private fun KeyUsageSection(state: PanelKeyStatsState) {
when (state) {
is PanelKeyStatsState.Idle,
is PanelKeyStatsState.Loading -> Text(
text = stringResource(R.string.common_loading),
style = MaterialTheme.typography.bodySmall,
color = inkMuted()
)
is PanelKeyStatsState.Error -> Text(
text = "✗ " + state.message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
is PanelKeyStatsState.Success -> {
if (state.stats.isEmpty()) {
Text(
text = stringResource(R.string.sub2_no_key_data),
style = MaterialTheme.typography.bodySmall,
color = inkMuted()
)
} else {
state.stats.forEachIndexed { index, stat ->
KeyUsageRow(stat)
if (index != state.stats.lastIndex) {
HorizontalDivider(
modifier = Modifier.padding(vertical = 2.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)
)
}
}
}
}
}
}
/** Key 一行:名称(+分组) + 请求/tokens + cost;未使用的 Key 灰显。 */
@Composable
private fun KeyUsageRow(stat: PanelKeyStatsState.KeyStat) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stat.name.ifBlank { "#" + stat.keyId },
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = if (stat.unused) inkMuted() else MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = buildString {
stat.groupName?.takeIf { it.isNotBlank() }?.let { append(it).append(" · ") }
if (stat.unused) append(stringResource(R.string.sub2_key_unused))
else append(
stringResource(
R.string.sub2_model_meta,
compactNum(stat.requests),
compactNum(stat.totalTokens)
)
)
},
style = MaterialTheme.typography.bodySmall,
color = inkMuted(),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Text(
text = if (stat.unused) "-" else formatCost(stat.cost),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = if (stat.unused) inkMuted() else MaterialTheme.colorScheme.onSurface
)
}
}
/** 时间窗口:按日期切片(近 N 天 = 今天及之前 N-1 天),不再按行数。 */
private enum class Sub2Window(val labelRes: Int) {

View File

@ -677,4 +677,12 @@
<string name="sub2_auth_apikey">API 登录</string>
<string name="sub2_home_requests">消耗 %1$d 次</string>
<string name="sub2_balance_label">余额</string>
<string name="sub2_tab_by_model">按模型</string>
<string name="sub2_tab_by_key">按 Key</string>
<string name="sub2_no_key_data">该周期内没有 API Key 用量</string>
<string name="sub2_key_unused">本周期未使用</string>
<string name="sub2_home_spend">消耗 $%1$.2f</string>
<string name="sub2_panel_delete_confirm_title">删除 API Key</string>
<string name="sub2_panel_delete_confirm_body">确定删除「%1$s」吗?删除后无法恢复,正在使用该 Key 的客户端会立即失效。</string>
<string name="sub2_trend_account_scope">此图为账号整体趋势(无单模型维度)</string>
</resources>

View File

@ -677,4 +677,12 @@
<string name="sub2_auth_apikey">API 登錄</string>
<string name="sub2_home_requests">消耗 %1$d 次</string>
<string name="sub2_balance_label">餘額</string>
<string name="sub2_tab_by_model">按模型</string>
<string name="sub2_tab_by_key">按 Key</string>
<string name="sub2_no_key_data">該週期內沒有 API Key 用量</string>
<string name="sub2_key_unused">本週期未使用</string>
<string name="sub2_home_spend">消耗 $%1$.2f</string>
<string name="sub2_panel_delete_confirm_title">刪除 API Key</string>
<string name="sub2_panel_delete_confirm_body">確定刪除「%1$s」嗎?刪除後無法復原,正在使用該 Key 的用戶端會立即失效。</string>
<string name="sub2_trend_account_scope">此圖為帳號整體趨勢(無單模型維度)</string>
</resources>

View File

@ -682,4 +682,12 @@
<string name="sub2_auth_apikey">API key login</string>
<string name="sub2_home_requests">%1$d calls</string>
<string name="sub2_balance_label">Balance</string>
<string name="sub2_tab_by_model">By model</string>
<string name="sub2_tab_by_key">By API key</string>
<string name="sub2_no_key_data">No API key usage in this period</string>
<string name="sub2_key_unused">Not used in this period</string>
<string name="sub2_home_spend">Spend $%1$.2f</string>
<string name="sub2_panel_delete_confirm_title">Delete API key</string>
<string name="sub2_panel_delete_confirm_body">Delete "%1$s"? This cannot be undone and any client using this key will stop working.</string>
<string name="sub2_trend_account_scope">Chart shows the whole account (no per-model breakdown)</string>
</resources>