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

@ -1,479 +1,503 @@
package com.rainy.token.ui.servicedetail package com.rainy.token.ui.servicedetail
import android.content.ClipData import android.content.ClipData
import android.content.ClipboardManager 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.layout.Arrangement import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.AlertDialog import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Button import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CardDefaults import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.TextButton import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.material3.TextButton
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Alignment
import androidx.compose.ui.draw.clip import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.text.style.TextOverflow
import com.rainy.token.R import androidx.compose.ui.unit.dp
import com.rainy.token.data.repository.Sub2ApiRepository import com.rainy.token.R
import com.rainy.token.domain.model.ServiceBalance import com.rainy.token.data.repository.Sub2ApiRepository
import com.rainy.token.ui.theme.inkMuted import com.rainy.token.domain.model.ServiceBalance
import java.util.Locale import com.rainy.token.ui.theme.inkMuted
import java.util.Locale
/**
* Sub2API 面板管理卡:Key 列表 / 新建 / 分组 / 兑换 / 低余额提醒。 /**
* * Sub2API 面板管理卡:Key 列表 / 新建 / 分组 / 兑换 / 低余额提醒。
* 首页 Sub2API 卡片与 Sub2API 详情页共用(有面板 Token 时才能加载; *
* API Key 模式只读余额,进入后提示先登录面板)。 * 首页 Sub2API 卡片与 Sub2API 详情页共用(有面板 Token 时才能加载;
* * API Key 模式只读余额,进入后提示先登录面板)。
* 交互细节: *
* - Key 列表只展示掩码(sk-…后缀),完整 Key 通过「复制」按钮复制并 Toast 反馈; * 交互细节:
* - 新建 Key 成功后弹窗展示完整 Key,避免把明文直接铺在长消息里; * - Key 列表只展示掩码(sk-…后缀),完整 Key 通过「复制」按钮复制并 Toast 反馈;
* - 兑换/切换分组等操作结果以 Toast + 行内文案双重反馈。 * - 新建 Key 成功后弹窗展示完整 Key,避免把明文直接铺在长消息里;
*/ * - 兑换/切换分组等操作结果以 Toast + 行内文案双重反馈。
@Composable */
internal fun Sub2ApiPanelManagement( @Composable
balance: ServiceBalance?, internal fun Sub2ApiPanelManagement(
keysState: PanelKeysState, balance: ServiceBalance?,
groups: List<Sub2ApiRepository.PanelGroup>, keysState: PanelKeysState,
actionMessage: String?, groups: List<Sub2ApiRepository.PanelGroup>,
createdKey: String? = null, actionMessage: String?,
onLoadKeys: () -> Unit, createdKey: String? = null,
onLoadGroups: () -> Unit, onLoadKeys: () -> Unit,
onCreateKey: (String, Long?) -> Unit, onLoadGroups: () -> Unit,
onSwitchGroup: (Long, Long) -> Unit, onCreateKey: (String, Long?) -> Unit,
onDeleteKey: (Long) -> Unit, onSwitchGroup: (Long, Long) -> Unit,
onRedeem: (String) -> Unit, onDeleteKey: (Long) -> Unit,
onConsumeMessage: () -> Unit, onRedeem: (String) -> Unit,
onConsumeCreatedKey: () -> Unit = {}, onConsumeMessage: () -> Unit,
/** 弹窗场景由 AlertDialog 的 title 槽展示标题,可关闭内部标题避免重复 */ onConsumeCreatedKey: () -> Unit = {},
showHeader: Boolean = true /** 弹窗场景由 AlertDialog 的 title 槽展示标题,可关闭内部标题避免重复 */
) { showHeader: Boolean = true
val context = LocalContext.current ) {
var showCreate by remember { mutableStateOf(false) } val context = LocalContext.current
var showRedeem by remember { mutableStateOf(false) } var showCreate by remember { mutableStateOf(false) }
var switchForKeyId by remember { mutableStateOf<Long?>(null) } var showRedeem by remember { mutableStateOf(false) }
// 待删除的 Key:先弹确认框(删除不可逆,避免列表里误触) var switchForKeyId by remember { mutableStateOf<Long?>(null) }
var deleteForKeyId by remember { mutableStateOf<Long?>(null) } // 待删除的 Key:先弹确认框(删除不可逆,避免列表里误触)
var deleteForKeyId by remember { mutableStateOf<Long?>(null) }
val groupMap = remember(groups) { groups.associateBy { it.id } }
val copiedToast = stringResource(R.string.sub2_panel_copied) val groupMap = remember(groups) { groups.associateBy { it.id } }
val copiedToast = stringResource(R.string.sub2_panel_copied)
// 进入卡片即自动拉取一次 Key 列表与分组(Idle 态),省掉一次手动点击
LaunchedEffect(Unit) { // 进入卡片即自动拉取一次 Key 列表与分组(Idle 态),省掉一次手动点击
if (keysState is PanelKeysState.Idle) { LaunchedEffect(Unit) {
onLoadKeys() if (keysState is PanelKeysState.Idle) {
onLoadGroups() onLoadKeys()
} onLoadGroups()
} }
}
// 最近一次操作结果:用于行内保留展示(Toast 消费后消息会被置空,这里留一份快照)
var lastResult by remember { mutableStateOf<String?>(null) } // 最近一次操作结果:用于行内保留展示(Toast 消费后消息会被置空,这里留一份快照)
var lastResult by remember { mutableStateOf<String?>(null) }
// 操作结果(新建/兑换/切组)用 Toast 提醒:避免结果消息藏在长页面里看不见。
// 消费后再置空,保证下一次「同样的结果」(如连续两次切换分组成功)仍能再次提示。 // 操作结果(新建/兑换/切组)用 Toast 提醒:避免结果消息藏在长页面里看不见。
LaunchedEffect(actionMessage) { // 消费后再置空,保证下一次「同样的结果」(如连续两次切换分组成功)仍能再次提示。
val msg = actionMessage ?: return@LaunchedEffect LaunchedEffect(actionMessage) {
lastResult = msg val msg = actionMessage ?: return@LaunchedEffect
Toast.makeText(context, msg, Toast.LENGTH_LONG).show() lastResult = msg
onConsumeMessage() Toast.makeText(context, msg, Toast.LENGTH_LONG).show()
} onConsumeMessage()
}
Card(
modifier = Modifier.fillMaxWidth(), Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)) modifier = Modifier.fillMaxWidth(),
) { colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f))
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { ) {
if (showHeader) { Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { if (showHeader) {
Text( Row(verticalAlignment = Alignment.CenterVertically) {
text = stringResource(R.string.sub2_panel_manage_title), Text(
style = MaterialTheme.typography.titleMedium, text = stringResource(R.string.sub2_panel_manage_title),
fontWeight = FontWeight.Bold style = MaterialTheme.typography.titleMedium,
) fontWeight = FontWeight.Bold
Spacer(modifier = Modifier.weight(1f)) )
IconButton(onClick = { onLoadKeys(); onLoadGroups() }) { Spacer(modifier = Modifier.weight(1f))
Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.action_refresh)) IconButton(onClick = { onLoadKeys(); onLoadGroups() }) {
} Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.action_refresh))
} }
} }
// showHeader=false 时不渲染任何标题/刷新行: }
// 弹窗场景由调用方把标题与刷新按钮放在同一行(见 DashboardScreen 的 title 槽), // showHeader=false 时不渲染任何标题/刷新行:
// 这里再放一行会让刷新按钮掉到标题下面,视觉上多出一条空行。 // 弹窗场景由调用方把标题与刷新按钮放在同一行(见 DashboardScreen 的 title 槽),
// 这里再放一行会让刷新按钮掉到标题下面,视觉上多出一条空行。
(actionMessage ?: lastResult)?.let { msg ->
Spacer(modifier = Modifier.height(8.dp)) (actionMessage ?: lastResult)?.let { msg ->
Text( Spacer(modifier = Modifier.height(8.dp))
text = msg.take(160) + if (msg.length > 160) "…" else "", Text(
style = MaterialTheme.typography.bodySmall, text = msg.take(160) + if (msg.length > 160) "…" else "",
color = if (msg.startsWith("已创建") || msg.startsWith("分组已") || msg.startsWith("兑换成功")) style = MaterialTheme.typography.bodySmall,
MaterialTheme.colorScheme.primary color = if (msg.startsWith("已创建") || msg.startsWith("分组已") || msg.startsWith("兑换成功"))
else MaterialTheme.colorScheme.primary
MaterialTheme.colorScheme.error else
) MaterialTheme.colorScheme.error
} )
}
val amount = balance?.amount ?: 0.0
if (amount > 0.0 && amount < 5.0) { val amount = balance?.amount ?: 0.0
Spacer(modifier = Modifier.height(10.dp)) if (amount > 0.0 && amount < 5.0) {
Text( Spacer(modifier = Modifier.height(10.dp))
text = stringResource( Text(
R.string.sub2_panel_low_balance, text = stringResource(
String.format(Locale.getDefault(), "%.2f", amount) R.string.sub2_panel_low_balance,
), String.format(Locale.getDefault(), "%.2f", amount)
style = MaterialTheme.typography.bodyMedium, ),
color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium,
modifier = Modifier color = MaterialTheme.colorScheme.error,
.fillMaxWidth() modifier = Modifier
.clip(RoundedCornerShape(10.dp)) .fillMaxWidth()
.background(MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.35f)) .clip(RoundedCornerShape(10.dp))
.padding(horizontal = 10.dp, vertical = 8.dp) .background(MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.35f))
) .padding(horizontal = 10.dp, vertical = 8.dp)
} )
}
Spacer(modifier = Modifier.height(12.dp))
Spacer(modifier = Modifier.height(12.dp))
// 主要操作(新建 Key / 兑换卡密)固定在列表上方:
// Key 数量多时列表很长,放在底部会被埋在滚动区尽头,不易发现。 // 主要操作(新建 Key / 兑换卡密)固定在列表上方:
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { // Key 数量多时列表很长,放在底部会被埋在滚动区尽头,不易发现。
Button(onClick = { onLoadGroups(); showCreate = true }, modifier = Modifier.weight(1f)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(R.string.sub2_panel_create)) Button(onClick = { onLoadGroups(); showCreate = true }, modifier = Modifier.weight(1f)) {
} Text(stringResource(R.string.sub2_panel_create))
OutlinedButton(onClick = { showRedeem = true }, modifier = Modifier.weight(1f)) { }
Text(stringResource(R.string.sub2_panel_redeem)) OutlinedButton(onClick = { showRedeem = true }, modifier = Modifier.weight(1f)) {
} Text(stringResource(R.string.sub2_panel_redeem))
} }
}
Spacer(modifier = Modifier.height(12.dp))
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(R.string.sub2_panel_keys_header), Text(
style = MaterialTheme.typography.labelLarge, text = stringResource(R.string.sub2_panel_keys_header),
color = inkMuted() style = MaterialTheme.typography.labelLarge,
) color = inkMuted()
Spacer(modifier = Modifier.height(4.dp)) )
Spacer(modifier = Modifier.height(4.dp))
when (keysState) {
is PanelKeysState.Idle -> { when (keysState) {
OutlinedButton(onClick = onLoadKeys, modifier = Modifier.fillMaxWidth()) { is PanelKeysState.Idle -> {
Text(stringResource(R.string.sub2_panel_load_keys)) OutlinedButton(onClick = onLoadKeys, modifier = Modifier.fillMaxWidth()) {
} Text(stringResource(R.string.sub2_panel_load_keys))
} }
is PanelKeysState.Loading -> { }
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 8.dp)) { is PanelKeysState.Loading -> {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 8.dp)) {
Spacer(modifier = Modifier.width(8.dp)) CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
Text(stringResource(R.string.common_loading), style = MaterialTheme.typography.bodySmall, color = inkMuted()) Spacer(modifier = Modifier.width(8.dp))
} Text(stringResource(R.string.common_loading), style = MaterialTheme.typography.bodySmall, color = inkMuted())
} }
is PanelKeysState.Error -> { }
Column { is PanelKeysState.Error -> {
Text(keysState.message, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) Column {
TextButton(onClick = onLoadKeys) { Text(stringResource(R.string.action_retry)) } Text(keysState.message, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
} TextButton(onClick = onLoadKeys) { Text(stringResource(R.string.action_retry)) }
} }
is PanelKeysState.Success -> { }
if (keysState.keys.isEmpty()) { is PanelKeysState.Success -> {
Text(stringResource(R.string.sub2_panel_no_keys), style = MaterialTheme.typography.bodySmall, color = inkMuted()) if (keysState.keys.isEmpty()) {
} else { Text(stringResource(R.string.sub2_panel_no_keys), style = MaterialTheme.typography.bodySmall, color = inkMuted())
keysState.keys.forEach { key -> } else {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) { keysState.keys.forEach { key ->
Column(modifier = Modifier.weight(1f)) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
Text( Column(modifier = Modifier.weight(1f)) {
text = stringResource(R.string.sub2_panel_key_id, key.name, key.id), Text(
style = MaterialTheme.typography.bodyMedium, text = stringResource(R.string.sub2_panel_key_id, key.name, key.id),
fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.bodyMedium,
maxLines = 1, fontWeight = FontWeight.SemiBold,
overflow = TextOverflow.Ellipsis maxLines = 1,
) overflow = TextOverflow.Ellipsis
// API Key 只显示掩码,避免页面展示过长明文 )
Text( // API Key 只显示掩码,避免页面展示过长明文
text = buildString { Text(
append(maskKey(key.key)) text = buildString {
append(" · ") append(maskKey(key.key))
append( append(" · ")
key.groupId?.let { groupMap[it]?.name } append(
?: stringResource(R.string.sub2_panel_default_group) key.groupId?.let { groupMap[it]?.name }
) ?: stringResource(R.string.sub2_panel_default_group)
append(" · ") )
append(key.status) append(" · ")
append(" · ") append(key.status)
append( append(" · ")
key.lastUsedAt?.let { stringResource(R.string.sub2_panel_last_used, it.take(10)) } append(
?: stringResource(R.string.sub2_panel_never_used) key.lastUsedAt?.let { stringResource(R.string.sub2_panel_last_used, it.take(10)) }
) ?: stringResource(R.string.sub2_panel_never_used)
}, )
style = MaterialTheme.typography.labelSmall, },
color = inkMuted(), style = MaterialTheme.typography.labelSmall,
maxLines = 1, color = inkMuted(),
overflow = TextOverflow.Ellipsis maxLines = 1,
) overflow = TextOverflow.Ellipsis
} )
TextButton(onClick = { }
copyToClipboard(context, key.key, copiedToast) // 三个操作紧凑排布(用户反馈「间隔有点宽」)。
}) { Text(stringResource(R.string.action_copy), style = MaterialTheme.typography.labelSmall) } // 注意:Material3 的 TextButton 会把 58dp 最小宽度与内边距加在
if (groups.isNotEmpty()) { // 其内层节点上,外层传 defaultMinSize(0)/contentPadding(0) 压不下去
TextButton(onClick = { switchForKeyId = key.id }) { //(实测按钮可点区仍有 232px),所以这里直接用可点击 Text。
Text(stringResource(R.string.sub2_panel_group_column), style = MaterialTheme.typography.labelSmall) Row(
} verticalAlignment = Alignment.CenterVertically,
} horizontalArrangement = Arrangement.spacedBy(10.dp)
// 删除:用图标而非文字,避免一行挤下三个按钮在窄屏折行 ) {
IconButton(onClick = { deleteForKeyId = key.id }) { Text(
Icon( text = stringResource(R.string.action_copy),
imageVector = Icons.Filled.Delete, style = MaterialTheme.typography.labelSmall,
contentDescription = stringResource(R.string.action_delete), color = MaterialTheme.colorScheme.primary,
tint = MaterialTheme.colorScheme.error, modifier = Modifier
modifier = Modifier.size(18.dp) .clip(RoundedCornerShape(4.dp))
) .clickable { copyToClipboard(context, key.key, copiedToast) }
} .padding(horizontal = 2.dp, vertical = 4.dp)
} )
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)) if (groups.isNotEmpty()) {
} 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)
// 删除确认弹窗:删除不可逆,二次确认后才发请求 )
deleteForKeyId?.let { keyId -> }
val targetName = (keysState as? PanelKeysState.Success) // 删除:用图标而非文字,避免一行挤下三个按钮在窄屏折行
?.keys?.firstOrNull { it.id == keyId }?.name Icon(
AlertDialog( imageVector = Icons.Filled.Delete,
onDismissRequest = { deleteForKeyId = null }, contentDescription = stringResource(R.string.action_delete),
title = { Text(stringResource(R.string.sub2_panel_delete_confirm_title), fontWeight = FontWeight.Bold) }, tint = MaterialTheme.colorScheme.error,
text = { modifier = Modifier
Text( .clip(RoundedCornerShape(4.dp))
stringResource( .clickable { deleteForKeyId = key.id }
R.string.sub2_panel_delete_confirm_body, .padding(2.dp)
targetName ?: ("#" + keyId) .size(18.dp)
) )
) }
}, }
confirmButton = { HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f))
Button( }
onClick = { }
onDeleteKey(keyId) }
deleteForKeyId = null }
},
colors = ButtonDefaults.buttonColors( }
containerColor = MaterialTheme.colorScheme.error, }
contentColor = MaterialTheme.colorScheme.onError
) // 删除确认弹窗:删除不可逆,二次确认后才发请求
) { Text(stringResource(R.string.action_delete)) } deleteForKeyId?.let { keyId ->
}, val targetName = (keysState as? PanelKeysState.Success)
dismissButton = { ?.keys?.firstOrNull { it.id == keyId }?.name
TextButton(onClick = { deleteForKeyId = null }) { Text(stringResource(R.string.action_cancel)) } AlertDialog(
} onDismissRequest = { deleteForKeyId = null },
) title = { Text(stringResource(R.string.sub2_panel_delete_confirm_title), fontWeight = FontWeight.Bold) },
} text = {
Text(
// 新建 Key 弹窗 stringResource(
if (showCreate) { R.string.sub2_panel_delete_confirm_body,
var name by remember { mutableStateOf("") } targetName ?: ("#" + keyId)
var groupId by remember { mutableStateOf<Long?>(null) } )
AlertDialog( )
onDismissRequest = { showCreate = false }, },
title = { Text(stringResource(R.string.sub2_panel_create_title), fontWeight = FontWeight.Bold) }, confirmButton = {
text = { Button(
Column { onClick = {
OutlinedTextField( onDeleteKey(keyId)
value = name, deleteForKeyId = null
onValueChange = { name = it }, },
label = { Text(stringResource(R.string.sub2_panel_key_name)) }, colors = ButtonDefaults.buttonColors(
singleLine = true, containerColor = MaterialTheme.colorScheme.error,
modifier = Modifier.fillMaxWidth() contentColor = MaterialTheme.colorScheme.onError
) )
Spacer(modifier = Modifier.height(10.dp)) ) { Text(stringResource(R.string.action_delete)) }
Text( },
text = stringResource(R.string.sub2_panel_group_optional), dismissButton = {
style = MaterialTheme.typography.labelMedium, TextButton(onClick = { deleteForKeyId = null }) { Text(stringResource(R.string.action_cancel)) }
color = inkMuted() }
) )
Spacer(modifier = Modifier.height(2.dp)) }
if (groups.isEmpty()) {
Text( // 新建 Key 弹窗
text = stringResource(R.string.sub2_panel_no_groups), if (showCreate) {
style = MaterialTheme.typography.bodySmall, var name by remember { mutableStateOf("") }
color = inkMuted() var groupId by remember { mutableStateOf<Long?>(null) }
) AlertDialog(
} else { onDismissRequest = { showCreate = false },
groups.forEach { g -> title = { Text(stringResource(R.string.sub2_panel_create_title), fontWeight = FontWeight.Bold) },
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { text = {
Text( Column {
text = g.name + (if (g.rateMultiplier != 1.0) "(x" + String.format(Locale.getDefault(), "%.2f", g.rateMultiplier) + ")" else ""), OutlinedTextField(
style = MaterialTheme.typography.bodySmall, value = name,
color = if (groupId == g.id) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, onValueChange = { name = it },
modifier = Modifier.weight(1f) label = { Text(stringResource(R.string.sub2_panel_key_name)) },
) singleLine = true,
TextButton(onClick = { groupId = if (groupId == g.id) null else g.id }) { modifier = Modifier.fillMaxWidth()
Text( )
text = if (groupId == g.id) stringResource(R.string.action_cancel) else stringResource(R.string.sub2_panel_group_column), Spacer(modifier = Modifier.height(10.dp))
style = MaterialTheme.typography.labelSmall Text(
) text = stringResource(R.string.sub2_panel_group_optional),
} style = MaterialTheme.typography.labelMedium,
} color = inkMuted()
} )
} Spacer(modifier = Modifier.height(2.dp))
} if (groups.isEmpty()) {
}, Text(
confirmButton = { text = stringResource(R.string.sub2_panel_no_groups),
Button( style = MaterialTheme.typography.bodySmall,
onClick = { onCreateKey(name.trim(), groupId); showCreate = false }, color = inkMuted()
enabled = name.isNotBlank() )
) { Text(stringResource(R.string.sub2_panel_create)) } } else {
}, groups.forEach { g ->
dismissButton = { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
TextButton(onClick = { showCreate = false }) { Text(stringResource(R.string.action_cancel)) } Text(
} text = g.name + (if (g.rateMultiplier != 1.0) "(x" + String.format(Locale.getDefault(), "%.2f", g.rateMultiplier) + ")" else ""),
) style = MaterialTheme.typography.bodySmall,
} color = if (groupId == g.id) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f)
// 新建成功:弹窗展示完整 Key,方便一次性复制(避免明文铺在长消息里) )
createdKey?.let { key -> TextButton(onClick = { groupId = if (groupId == g.id) null else g.id }) {
AlertDialog( Text(
onDismissRequest = onConsumeCreatedKey, text = if (groupId == g.id) stringResource(R.string.action_cancel) else stringResource(R.string.sub2_panel_group_column),
title = { Text(stringResource(R.string.sub2_panel_create_success_title), fontWeight = FontWeight.Bold) }, style = MaterialTheme.typography.labelSmall
text = { )
Column { }
Text( }
text = stringResource(R.string.sub2_panel_create_success_hint), }
style = MaterialTheme.typography.bodySmall, }
color = inkMuted() }
) },
Spacer(modifier = Modifier.height(8.dp)) confirmButton = {
SelectionContainer { Button(
Text( onClick = { onCreateKey(name.trim(), groupId); showCreate = false },
text = key, enabled = name.isNotBlank()
style = MaterialTheme.typography.bodyMedium, ) { Text(stringResource(R.string.sub2_panel_create)) }
color = MaterialTheme.colorScheme.onSurface, },
modifier = Modifier dismissButton = {
.fillMaxWidth() TextButton(onClick = { showCreate = false }) { Text(stringResource(R.string.action_cancel)) }
.clip(RoundedCornerShape(8.dp)) }
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) )
.padding(10.dp) }
)
} // 新建成功:弹窗展示完整 Key,方便一次性复制(避免明文铺在长消息里)
} createdKey?.let { key ->
}, AlertDialog(
confirmButton = { onDismissRequest = onConsumeCreatedKey,
Button(onClick = { copyToClipboard(context, key, copiedToast); onConsumeCreatedKey() }) { title = { Text(stringResource(R.string.sub2_panel_create_success_title), fontWeight = FontWeight.Bold) },
Text(stringResource(R.string.sub2_panel_copy_and_close)) text = {
} Column {
}, Text(
dismissButton = { text = stringResource(R.string.sub2_panel_create_success_hint),
TextButton(onClick = onConsumeCreatedKey) { Text(stringResource(R.string.action_close)) } style = MaterialTheme.typography.bodySmall,
} color = inkMuted()
) )
} Spacer(modifier = Modifier.height(8.dp))
SelectionContainer {
// 兑换弹窗 Text(
if (showRedeem) { text = key,
var code by remember { mutableStateOf("") } style = MaterialTheme.typography.bodyMedium,
AlertDialog( color = MaterialTheme.colorScheme.onSurface,
onDismissRequest = { showRedeem = false }, modifier = Modifier
title = { Text(stringResource(R.string.sub2_panel_redeem), fontWeight = FontWeight.Bold) }, .fillMaxWidth()
text = { .clip(RoundedCornerShape(8.dp))
OutlinedTextField( .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
value = code, .padding(10.dp)
onValueChange = { code = it }, )
label = { Text(stringResource(R.string.sub2_panel_redeem_code_label)) }, }
singleLine = true, }
modifier = Modifier.fillMaxWidth() },
) confirmButton = {
}, Button(onClick = { copyToClipboard(context, key, copiedToast); onConsumeCreatedKey() }) {
confirmButton = { Text(stringResource(R.string.sub2_panel_copy_and_close))
Button( }
onClick = { onRedeem(code.trim()); showRedeem = false }, },
enabled = code.isNotBlank() && code.length >= 6 dismissButton = {
) { Text(stringResource(R.string.sub2_panel_redeem)) } TextButton(onClick = onConsumeCreatedKey) { Text(stringResource(R.string.action_close)) }
}, }
dismissButton = { )
TextButton(onClick = { showRedeem = false }) { Text(stringResource(R.string.action_cancel)) } }
}
) // 兑换弹窗
} if (showRedeem) {
var code by remember { mutableStateOf("") }
// 切换分组弹窗 AlertDialog(
switchForKeyId?.let { keyId -> onDismissRequest = { showRedeem = false },
AlertDialog( title = { Text(stringResource(R.string.sub2_panel_redeem), fontWeight = FontWeight.Bold) },
onDismissRequest = { switchForKeyId = null }, text = {
title = { Text(stringResource(R.string.sub2_panel_switch_group), fontWeight = FontWeight.Bold) }, OutlinedTextField(
text = { value = code,
Column { onValueChange = { code = it },
if (groups.isEmpty()) { label = { Text(stringResource(R.string.sub2_panel_redeem_code_label)) },
Text( singleLine = true,
text = stringResource(R.string.sub2_panel_no_groups), modifier = Modifier.fillMaxWidth()
style = MaterialTheme.typography.bodySmall, )
color = inkMuted() },
) confirmButton = {
} else { Button(
groups.forEach { g -> onClick = { onRedeem(code.trim()); showRedeem = false },
TextButton(onClick = { onSwitchGroup(keyId, g.id); switchForKeyId = null }) { enabled = code.isNotBlank() && code.length >= 6
Text( ) { Text(stringResource(R.string.sub2_panel_redeem)) }
text = g.name + (if (g.rateMultiplier != 1.0) "(x" + String.format(Locale.getDefault(), "%.2f", g.rateMultiplier) + ")" else ""), },
color = MaterialTheme.colorScheme.onSurface dismissButton = {
) TextButton(onClick = { showRedeem = false }) { Text(stringResource(R.string.action_cancel)) }
} }
} )
} }
}
}, // 切换分组弹窗
confirmButton = {}, switchForKeyId?.let { keyId ->
dismissButton = { AlertDialog(
TextButton(onClick = { switchForKeyId = null }) { Text(stringResource(R.string.action_cancel)) } onDismissRequest = { switchForKeyId = null },
} title = { Text(stringResource(R.string.sub2_panel_switch_group), fontWeight = FontWeight.Bold) },
) text = {
} Column {
} if (groups.isEmpty()) {
Text(
/** text = stringResource(R.string.sub2_panel_no_groups),
* API Key 展示掩码:与面板网页保持一致的观感(sk-xxxx...yyyy)。 style = MaterialTheme.typography.bodySmall,
* 列表接口虽然返回完整 key(实测 67 字符),但页面上不该铺满明文, color = inkMuted()
* 复制按钮复制的仍是完整值(见 copyToClipboard(context, key.key, …))。 )
*/ } else {
private fun maskKey(key: String): String = when { groups.forEach { g ->
key.length <= 12 -> key TextButton(onClick = { onSwitchGroup(keyId, g.id); switchForKeyId = null }) {
else -> key.take(7) + "..." + key.takeLast(4) Text(
} text = g.name + (if (g.rateMultiplier != 1.0) "(x" + String.format(Locale.getDefault(), "%.2f", g.rateMultiplier) + ")" else ""),
color = MaterialTheme.colorScheme.onSurface
/** 复制到剪贴板并 Toast 反馈(用户感知「复制按钮」有效)。 */ )
private fun copyToClipboard(context: Context, text: String, toast: String) { }
val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager }
cm.setPrimaryClip(ClipData.newPlainText("API Key", text)) }
Toast.makeText(context, toast, Toast.LENGTH_SHORT).show() }
} },
confirmButton = {},
dismissButton = {
TextButton(onClick = { switchForKeyId = null }) { Text(stringResource(R.string.action_cancel)) }
}
)
}
}
/**
* API Key 展示掩码:与面板网页保持一致的观感(sk-xxxx...yyyy)。
* 列表接口虽然返回完整 key(实测 67 字符),但页面上不该铺满明文,
* 复制按钮复制的仍是完整值(见 copyToClipboard(context, key.key, …))。
*/
private fun maskKey(key: String): String = when {
key.length <= 12 -> key
else -> key.take(7) + "..." + key.takeLast(4)
}
/** 复制到剪贴板并 Toast 反馈(用户感知「复制按钮」有效)。 */
private fun copyToClipboard(context: Context, text: String, toast: String) {
val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
cm.setPrimaryClip(ClipData.newPlainText("API Key", text))
Toast.makeText(context, toast, Toast.LENGTH_SHORT).show()
}

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,62 +293,90 @@ 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))
Text(
text = stringResource(R.string.sub2_models_header) +
if (windowStatsAllTime) stringResource(R.string.sub2_models_alltime_suffix) else "",
style = MaterialTheme.typography.labelLarge,
color = inkMuted()
)
Spacer(modifier = Modifier.height(8.dp))
// 模型筛选 chips(按当前周期内有数据的模型;「全部」时用网关全量) // tab 切换:按模型 / 按 Key
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
item { FilterChip(
FilterChip( selected = detailTab == DetailTab.MODEL,
selected = selectedModel == null, onClick = { detailTab = DetailTab.MODEL },
onClick = { selectedModel = null }, label = { Text(stringResource(R.string.sub2_tab_by_model)) },
label = { Text(stringResource(R.string.sub2_model_all)) }, colors = FilterChipDefaults.filterChipColors(
colors = FilterChipDefaults.filterChipColors( selectedContainerColor = MaterialTheme.colorScheme.primary,
selectedContainerColor = MaterialTheme.colorScheme.primary, selectedLabelColor = MaterialTheme.colorScheme.onPrimary
selectedLabelColor = MaterialTheme.colorScheme.onPrimary
)
) )
} )
items(windowModelStats) { stat -> FilterChip(
FilterChip( selected = detailTab == DetailTab.KEY,
selected = selectedModel == stat.model, onClick = { detailTab = DetailTab.KEY },
onClick = { selectedModel = stat.model }, label = { Text(stringResource(R.string.sub2_tab_by_key)) },
label = { Text(stat.model) } colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = MaterialTheme.colorScheme.primary,
selectedLabelColor = MaterialTheme.colorScheme.onPrimary
) )
} )
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(12.dp))
when { if (detailTab == DetailTab.KEY) {
statsLoading -> Text( KeyUsageSection(keyStatsState)
text = stringResource(R.string.common_loading), } else {
style = MaterialTheme.typography.bodySmall, Text(
text = stringResource(R.string.sub2_models_header) +
if (windowStatsAllTime) stringResource(R.string.sub2_models_alltime_suffix) else "",
style = MaterialTheme.typography.labelLarge,
color = inkMuted() color = inkMuted()
) )
statsError != null -> Text( Spacer(modifier = Modifier.height(8.dp))
text = "✗ " + statsError,
style = MaterialTheme.typography.bodySmall, // 模型筛选 chips(按当前周期内有数据的模型;「全部」时用网关全量)
color = MaterialTheme.colorScheme.error LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
) item {
windowModelStats.isEmpty() -> Text( FilterChip(
text = stringResource(R.string.sub2_no_model_data), selected = selectedModel == null,
style = MaterialTheme.typography.bodySmall, onClick = { selectedModel = null },
color = inkMuted() label = { Text(stringResource(R.string.sub2_model_all)) },
) colors = FilterChipDefaults.filterChipColors(
else -> { selectedContainerColor = MaterialTheme.colorScheme.primary,
val shown = windowModelStats.filter { selectedModel == null || it.model == selectedModel } selectedLabelColor = MaterialTheme.colorScheme.onPrimary
shown.forEachIndexed { index, stat ->
ModelRow(stat, highlighted = selectedModel == stat.model)
if (index != shown.lastIndex) {
HorizontalDivider(
modifier = Modifier.padding(vertical = 2.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)
) )
)
}
items(windowModelStats) { stat ->
FilterChip(
selected = selectedModel == stat.model,
onClick = { selectedModel = stat.model },
label = { Text(stat.model) }
)
}
}
Spacer(modifier = Modifier.height(8.dp))
when {
statsLoading -> Text(
text = stringResource(R.string.common_loading),
style = MaterialTheme.typography.bodySmall,
color = inkMuted()
)
statsError != null -> Text(
text = "✗ " + statsError,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
windowModelStats.isEmpty() -> Text(
text = stringResource(R.string.sub2_no_model_data),
style = MaterialTheme.typography.bodySmall,
color = inkMuted()
)
else -> {
val shown = windowModelStats.filter { selectedModel == null || it.model == selectedModel }
shown.forEachIndexed { index, stat ->
ModelRow(stat, highlighted = selectedModel == stat.model)
if (index != shown.lastIndex) {
HorizontalDivider(
modifier = Modifier.padding(vertical = 2.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)
)
}
} }
} }
} }
@ -344,6 +386,90 @@ 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) {
TODAY(R.string.sub2_window_today), TODAY(R.string.sub2_window_today),

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff