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 java.util.TimeZone
import javax.inject.Singleton import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonArray
@ -526,6 +529,23 @@ class Sub2ApiRepository constructor(
*/ */
data class WindowModelStats(val stats: List<Sub2ModelStat>, val allTime: Boolean = false) 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 = "") data class PanelGroup(val id: Long, val name: String, val rateMultiplier: Double, val description: String = "")
@ -663,6 +683,77 @@ class Sub2ApiRepository constructor(
}.getOrNull() }.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。 * 新建 Key:POST /api/v1/keys {name, group_id?} → 返回明文 sk-key。
* 兼容多种响应形态:data 为对象(key / api_key / sk_key / token 字段)、data 为字符串, * 兼容多种响应形态:data 为对象(key / api_key / sk_key / token 字段)、data 为字符串,

View File

@ -120,6 +120,7 @@ fun ServiceDetailScreen(
// 面板管理(Key/分组/兑换)已移到首页 Sub2API 卡片的「面板管理」入口, // 面板管理(Key/分组/兑换)已移到首页 Sub2API 卡片的「面板管理」入口,
// 详情页只保留用量明细所需的窗口统计状态。 // 详情页只保留用量明细所需的窗口统计状态。
val panelWindowStats by viewModel.panelWindowStats.collectAsStateWithLifecycle() val panelWindowStats by viewModel.panelWindowStats.collectAsStateWithLifecycle()
val panelKeyStats by viewModel.panelKeyStats.collectAsStateWithLifecycle()
val config = ServiceConfigProvider.get(service) val config = ServiceConfigProvider.get(service)
val isManualMode = config.method == FetchMethod.MANUAL val isManualMode = config.method == FetchMethod.MANUAL
@ -205,7 +206,9 @@ fun ServiceDetailScreen(
Sub2ApiUsageCard( Sub2ApiUsageCard(
state = uiState.state, state = uiState.state,
windowStatsState = panelWindowStats, 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 列表/新建/分组/兑换)不再在详情页展示: // 面板管理(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)。 */ /** 刷新面板 Key 列表(仅 SUB2API 且有面板 Token)。 */
fun loadPanelKeys() { fun loadPanelKeys() {
viewModelScope.launch { viewModelScope.launch {
@ -777,6 +807,19 @@ sealed class PanelWindowStatsState {
data class Error(val message: String) : 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 { sealed class State {
data object Loading : State() data object Loading : State()
data class Fresh(val data: ServiceBalance) : State() data class Fresh(val data: ServiceBalance) : State()

View File

@ -5,6 +5,7 @@ import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@ -242,21 +243,44 @@ internal fun Sub2ApiPanelManagement(
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
} }
TextButton(onClick = { // 三个操作紧凑排布(用户反馈「间隔有点宽」)。
copyToClipboard(context, key.key, copiedToast) // 注意:Material3 的 TextButton 会把 58dp 最小宽度与内边距加在
}) { Text(stringResource(R.string.action_copy), style = MaterialTheme.typography.labelSmall) } // 其内层节点上,外层传 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()) { if (groups.isNotEmpty()) {
TextButton(onClick = { switchForKeyId = key.id }) { Text(
Text(stringResource(R.string.sub2_panel_group_column), style = MaterialTheme.typography.labelSmall) 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( Icon(
imageVector = Icons.Filled.Delete, imageVector = Icons.Filled.Delete,
contentDescription = stringResource(R.string.action_delete), contentDescription = stringResource(R.string.action_delete),
tint = MaterialTheme.colorScheme.error, 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.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.rainy.token.R import com.rainy.token.R
import com.rainy.token.domain.model.Sub2DailyUsage import com.rainy.token.domain.model.Sub2DailyUsage
@ -65,7 +66,10 @@ internal fun Sub2ApiUsageCard(
onLoadWindowModelStats: ((from: LocalDate, to: LocalDate) -> Unit)? = null, onLoadWindowModelStats: ((from: LocalDate, to: LocalDate) -> Unit)? = null,
/** 「今天」窗口的按小时趋势;非空时用小时柱替代按天柱(按天只有一根柱没有信息量)。 */ /** 「今天」窗口的按小时趋势;非空时用小时柱替代按天柱(按天只有一根柱没有信息量)。 */
hourlyTrend: List<Sub2DailyUsage> = emptyList(), 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) { val balance = when (state) {
is State.Fresh -> state.data is State.Fresh -> state.data
@ -104,6 +108,8 @@ internal fun Sub2ApiUsageCard(
var window by remember { mutableStateOf(Sub2Window.TODAY) } var window by remember { mutableStateOf(Sub2Window.TODAY) }
// key=detail:刷新后模型列表变化时重置选中态,避免「每日区隐藏+无模型说明」的悬空状态 // key=detail:刷新后模型列表变化时重置选中态,避免「每日区隐藏+无模型说明」的悬空状态
var selectedModel by remember(detail) { mutableStateOf<String?>(null) } var selectedModel by remember(detail) { mutableStateOf<String?>(null) }
// 明细分区 tab:按模型 / 按 Key
var detailTab by remember(detail) { mutableStateOf(DetailTab.MODEL) }
// ── 时间窗口选择 ── // ── 时间窗口选择 ──
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
@ -140,6 +146,14 @@ internal fun Sub2ApiUsageCard(
onLoadWindowModelStats(from, to) 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) { if (rangeLabel != null) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
@ -270,7 +284,7 @@ internal fun Sub2ApiUsageCard(
} }
} }
// ── 按模型统计(整卡始终展示,数据随所选时间周期变化)── // ── 明细分区:「按模型 / 按 Key」两个 tab(数据都随所选时间周期变化)──
val statsLoading = window != Sub2Window.ALL && val statsLoading = window != Sub2Window.ALL &&
(windowStatsState is PanelWindowStatsState.Idle || windowStatsState is PanelWindowStatsState.Loading) (windowStatsState is PanelWindowStatsState.Idle || windowStatsState is PanelWindowStatsState.Loading)
val statsError = (windowStatsState as? PanelWindowStatsState.Error)?.message val statsError = (windowStatsState as? PanelWindowStatsState.Error)?.message
@ -279,6 +293,33 @@ internal fun Sub2ApiUsageCard(
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
Spacer(modifier = Modifier.height(14.dp)) 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(
text = stringResource(R.string.sub2_models_header) + text = stringResource(R.string.sub2_models_header) +
if (windowStatsAllTime) stringResource(R.string.sub2_models_alltime_suffix) else "", 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 天),不再按行数。 */ /** 时间窗口:按日期切片(近 N 天 = 今天及之前 N-1 天),不再按行数。 */
private enum class Sub2Window(val labelRes: Int) { private enum class Sub2Window(val labelRes: Int) {

View File

@ -677,4 +677,12 @@
<string name="sub2_auth_apikey">API 登录</string> <string name="sub2_auth_apikey">API 登录</string>
<string name="sub2_home_requests">消耗 %1$d 次</string> <string name="sub2_home_requests">消耗 %1$d 次</string>
<string name="sub2_balance_label">余额</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> </resources>

View File

@ -677,4 +677,12 @@
<string name="sub2_auth_apikey">API 登錄</string> <string name="sub2_auth_apikey">API 登錄</string>
<string name="sub2_home_requests">消耗 %1$d 次</string> <string name="sub2_home_requests">消耗 %1$d 次</string>
<string name="sub2_balance_label">餘額</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> </resources>

View File

@ -682,4 +682,12 @@
<string name="sub2_auth_apikey">API key login</string> <string name="sub2_auth_apikey">API key login</string>
<string name="sub2_home_requests">%1$d calls</string> <string name="sub2_home_requests">%1$d calls</string>
<string name="sub2_balance_label">Balance</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> </resources>