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:
parent
100b698f38
commit
dc3027417e
@ -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 为字符串,
|
||||||
|
|||||||
@ -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 列表/新建/分组/兑换)不再在详情页展示:
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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>
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
Loading…
Reference in New Issue
Block a user