feat: 平板自适应全面适配 — BoxWithConstraints 双窗格 + 文档同步

P0 — 容器宽度自适应布局:
- 新增 WindowSizeUtils.kt (rememberWindowSizeClass 封装)
- 添加 material3-window-size-class 依赖
- DashboardScreen: BoxWithConstraints >600dp 卡片双列
- UsageDetailScreen/UsageChartScreen: >700dp 图表并排
- UsageDataScreen/UsageOverviewScreen: 移除全局 WindowSizeClass

P1 — 双窗格导航:
- RainyTokenNavHost 拆分 CompactNavHost/ExpandedLayout
- Expanded 模式: 左侧 35% Dashboard + 右侧 65% AnimatedContent
- 右侧内置子路由(图表⇄总览⇄数据) + Settings 局部 NavHost
- Compact 模式: 原 NavHost 一字未改,零回归

文档同步:
- AGENTS.md: 新增自适应断点说明 + 双窗格导航图
- README.md: 新增平板适配特性条目
- 项目记忆: 更新概述 + 删除旧 WIP 记录
This commit is contained in:
WaterRain 2026-06-29 02:06:04 +00:00
parent 26693c92d4
commit ea9f6f7e72
No known key found for this signature in database
11 changed files with 753 additions and 286 deletions

View File

@ -14,6 +14,7 @@ CommandCode Go 走 JSON API 抓取用量数据。APP 名为「雨晴Token」(
- Retrofit 2 + OkHttp 4 + Kotlinx Serialization
- DataStore(本地缓存)+ Android Keystore(凭据加密,AES-256 GCM)
- `minSdk=31`(Android 12+)
- `material3-window-size-class` — 平板自适应布局
- WorkManager(计划中)
## 当前实现状态
@ -52,13 +53,27 @@ CommandCode Go 走 JSON API 抓取用量数据。APP 名为「雨晴Token」(
> Dashboard 使用 `PullToRefreshBox` → `Column` + `verticalScroll`(非 `LazyColumn`)。
> 页面仅 7 个 item,`LazyColumn` 的 dispose/recompose 会导致用量卡片的 `LaunchedEffect` 反复触发,产生卡顿。
>
> **自适应断点**:容器宽度 > 600dp 时卡片双列(`FlowRow`),≤600dp 时单列。判断使用 `BoxWithConstraints` 而非全局 `WindowSizeClass`,避免嵌套面板误判。
**图表自适应**:
> `UsageDetailScreen` / `UsageChartScreen` 通过 `BoxWithConstraints` 判断容器宽度 > 700dp 时图表并排(前两张 50/50,第三张独占一行),反之纵向堆叠。阈值 700dp 高于 Dashboard 的 600dp,因为图表卡片需要更多空间。
**页面导航**:
```
Dashboard → OCGO: UsageDetail(图表) → UsageOverview(总统计)
↘ UsageData(原始数据)
→ CCGO: CCGO_USAGE_DETAIL(图表) → CCGO_USAGE_OVERVIEW(总统计)
↘ CCGO_USAGE_DATA(原始数据)
Compact(手机):
Dashboard → OCGO: UsageDetail(图表) → UsageOverview(总统计)
↘ UsageData(原始数据)
→ CCGO: CCGO_USAGE_DETAIL(图表) → CCGO_USAGE_OVERVIEW(总统计)
↘ CCGO_USAGE_DATA(原始数据)
Expanded(平板,≥840dp):
┌─ 左侧 35%: Dashboard(固定) ─┐ ┌─ 右侧 65%: AnimatedContent ─────────┐
│ │ │ ServiceDetail / OCGOUsage / CCGOUsage │
│ │ │ Settings(内嵌 NavHost) │
└────────────────────────────────┘ └────────────────────────────────────────┘
右侧用量详情支持内部子路由:图表 → 总览 / 原始数据(通过 UsageSubRoute 状态)
```
**桌面小组件(Widget)**:

View File

@ -28,8 +28,9 @@ Android AI 余额与用量查询 APP —— 统一查看 DeepSeek、OpenCode Go
| 特性 | 说明 |
|------|------|
| 📊 **仪表盘** | DeepSeek 余额(¥)+ OpenCode Go 用量(5h/本周/本月)+ CommandCode Go 余额($)+ 下拉全局刷新 |
| 📈 **用量图表** | 3 张 Canvas 手绘图表 — 消耗金额 / API 请求次数 / Token 消耗(OCGO & CCGO 双数据源);支持 UTC+0/UTC+8 时区切换;自动降级(近5h无数据→12h→7天→当月) |
| 📊 **仪表盘** | DeepSeek 余额(¥)+ OpenCode Go 用量(5h/本周/本月)+ CommandCode Go 余额($)+ 下拉全局刷新 · 平板自适应双窗格布局 |
| 📈 **用量图表** | 3 张 Canvas 手绘图表 — 消耗金额 / API 请求次数 / Token 消耗(OCGO & CCGO 双数据源);支持 UTC+0/UTC+8 时区切换;自动降级(近5h无数据→12h→7天→当月);平板并排展示 |
| 📱 **平板适配** | 全局 `BoxWithConstraints` 自适应容器宽度;≥600dp 卡片双列,≥700dp 图表并排;双窗格 35/65 左右分栏(Expanded 模式) |
| 📋 **详细数据** | 原始记录分页浏览,支持时间 + 模型筛选,点击查看完整字段 |
| 🔍 **多粒度筛选** | 5小时 / 12小时(10分钟桶) / 24小时 / 今天 / 昨天 / 最近7天 / 当月 / 自定义日·月·范围 |
| 🏷️ **模型筛选** | 多选 / 单选 / 全选,动态图例自适应换行 |

View File

@ -85,6 +85,7 @@ dependencies {
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.androidx.material3.window.size)
// Navigation
implementation(libs.androidx.navigation.compose)

View File

@ -1,14 +1,38 @@
package com.rainy.token.ui
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.rainy.token.data.repository.CommandCodeUsageRepository
import com.rainy.token.domain.service.ServiceType
import com.rainy.token.ui.components.rememberWindowSizeClass
import com.rainy.token.ui.dashboard.DashboardScreen
import com.rainy.token.ui.dashboard.UsageChartViewModel
import com.rainy.token.ui.dashboard.UsageDataScreen
@ -19,12 +43,18 @@ import com.rainy.token.ui.dashboard.UsageViewModel
import com.rainy.token.ui.servicedetail.ServiceDetailScreen
import com.rainy.token.ui.settings.CredentialEditScreen
import com.rainy.token.ui.settings.SettingsScreen
import com.rainy.token.ui.theme.inkMuted
import com.rainy.token.ui.theme.StrawberryPink
import com.rainy.token.ui.webview.WebViewLoginScreen
/**
* 应用导航图。当前阶段 4 接入了完整路径:
* Dashboard → Settings → CredentialEdit → WebViewLogin
* Dashboard → ServiceDetail
* 应用导航图。
*
* 自适应策略:
* - **Compact**(< 600dp,手机):单栈 NavHost,与原来完全一致。
* - **Expanded**(≥ 840dp,平板横屏):左侧 Dashboard 面板 + 右侧详情双窗格。
* - 右侧详情通过 AnimatedContent 切换,支持用量详情内的子路由(图表/总览/数据)和设置子路由。
* - 设置 → 凭据编辑 → WebView 登录 在右侧内嵌 NavHost 中处理。
*/
object Routes {
const val DASHBOARD = "dashboard"
@ -48,8 +78,41 @@ private fun parseServiceType(typeName: String?): ServiceType =
ServiceType.fromStorageKey(it) ?: runCatching { ServiceType.valueOf(it) }.getOrNull()
} ?: ServiceType.DEEPSEEK
@Composable
// ═══════════════════════════════════════════════
// 双窗格路由状态
// ═══════════════════════════════════════════════
private sealed class DetailPane {
object Empty : DetailPane()
data class ServiceDetail(val type: ServiceType) : DetailPane()
object OCGOUsage : DetailPane()
object CCGOUsage : DetailPane()
object Settings : DetailPane()
}
// ---------------------------------------------------------------------------
// 入口:根据 WindowSizeClass 分派
// ---------------------------------------------------------------------------
@Composable
fun RainyTokenNavHost() {
val windowSize = rememberWindowSizeClass()
val isExpanded = windowSize.widthSizeClass ==
androidx.compose.material3.windowsizeclass.WindowWidthSizeClass.Expanded
if (isExpanded) {
ExpandedLayout()
} else {
CompactNavHost()
}
}
// ---------------------------------------------------------------------------
// Compact:单栈 NavHost(原逻辑,一字未改)
// ---------------------------------------------------------------------------
@Composable
private fun CompactNavHost() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = Routes.DASHBOARD) {
composable(Routes.DASHBOARD) {
@ -68,18 +131,13 @@ fun RainyTokenNavHost() {
)
}
composable(Routes.USAGE_OVERVIEW) {
UsageOverviewScreen(
onBack = { navController.popBackStack() }
)
UsageOverviewScreen(onBack = { navController.popBackStack() })
}
composable(Routes.USAGE_DATA) {
UsageDataScreen(
onBack = { navController.popBackStack() }
)
UsageDataScreen(onBack = { navController.popBackStack() })
}
// CCGO 用量详情 — 隔离实例通过 key 参数标记 workspaceId
composable(Routes.CCGO_USAGE_DETAIL) {
val wid = com.rainy.token.data.repository.CommandCodeUsageRepository.CCGO_WORKSPACE_ID
val wid = CommandCodeUsageRepository.CCGO_WORKSPACE_ID
val chartVm: UsageChartViewModel = hiltViewModel(key = "ccgo_chart_$wid")
val usageVm: UsageViewModel = hiltViewModel(key = "ccgo_$wid")
LaunchedEffect(Unit) {
@ -95,30 +153,19 @@ fun RainyTokenNavHost() {
)
}
composable(Routes.CCGO_USAGE_OVERVIEW) {
val wid = com.rainy.token.data.repository.CommandCodeUsageRepository.CCGO_WORKSPACE_ID
val key = "ccgo_$wid"
val key = "ccgo_${CommandCodeUsageRepository.CCGO_WORKSPACE_ID}"
val ovVm: UsageViewModel = hiltViewModel(key = key)
LaunchedEffect(Unit) { ovVm.setWorkspace(wid) }
UsageOverviewScreen(
onBack = { navController.popBackStack() },
viewModel = ovVm,
autoLoad = false
)
LaunchedEffect(Unit) { ovVm.setWorkspace(CommandCodeUsageRepository.CCGO_WORKSPACE_ID) }
UsageOverviewScreen(onBack = { navController.popBackStack() }, viewModel = ovVm, autoLoad = false)
}
composable(Routes.CCGO_USAGE_DATA) {
val wid = com.rainy.token.data.repository.CommandCodeUsageRepository.CCGO_WORKSPACE_ID
val key = "ccgo_$wid"
val key = "ccgo_${CommandCodeUsageRepository.CCGO_WORKSPACE_ID}"
val dataVm: UsageDataViewModel = hiltViewModel(key = key)
LaunchedEffect(Unit) { dataVm.setWorkspace(wid) }
UsageDataScreen(
onBack = { navController.popBackStack() },
viewModel = dataVm,
autoLoad = false
)
LaunchedEffect(Unit) { dataVm.setWorkspace(CommandCodeUsageRepository.CCGO_WORKSPACE_ID) }
UsageDataScreen(onBack = { navController.popBackStack() }, viewModel = dataVm, autoLoad = false)
}
composable(Routes.SETTINGS) {
SettingsScreen(
// 使用 popBackStack 到指定路由而非无参 popBackStack,防止双击返回键导致空白页
onBack = { navController.popBackStack(Routes.DASHBOARD, inclusive = false) },
onEditCredential = { type -> navController.navigate(Routes.credentialEdit(type)) }
)
@ -132,7 +179,7 @@ fun RainyTokenNavHost() {
service = type,
onBack = { navController.popBackStack() },
onStartWebViewLogin = { service -> navController.navigate(Routes.webviewLogin(service)) },
onWebViewLoginSuccess = { /* 登录后回到编辑页,由 LaunchedEffect 触发刷新 */ }
onWebViewLoginSuccess = { }
)
}
composable(
@ -143,10 +190,7 @@ fun RainyTokenNavHost() {
WebViewLoginScreen(
service = type,
onBack = { navController.popBackStack() },
onLoginSucceeded = {
// 登录成功直接回退到 CredentialEdit 页
navController.popBackStack()
}
onLoginSucceeded = { navController.popBackStack() }
)
}
composable(
@ -162,4 +206,190 @@ fun RainyTokenNavHost() {
)
}
}
}
// ---------------------------------------------------------------------------
// Expanded:手动双窗格
// ---------------------------------------------------------------------------
@Composable
private fun ExpandedLayout() {
var detailPane by remember { mutableStateOf<DetailPane>(DetailPane.Empty) }
Row(modifier = Modifier.fillMaxSize()) {
// ── 左侧 Dashboard 面板(35%) ──
Box(
modifier = Modifier
.weight(0.35f)
.fillMaxHeight()
) {
DashboardScreen(
onOpenSettings = { detailPane = DetailPane.Settings },
onOpenService = { type -> detailPane = DetailPane.ServiceDetail(type) },
onOpenUsageDetail = { detailPane = DetailPane.OCGOUsage },
onOpenCcgoUsageDetail = { detailPane = DetailPane.CCGOUsage }
)
}
// 分隔线
VerticalDivider(
thickness = 1.dp,
color = MaterialTheme.colorScheme.outlineVariant
)
// ── 右侧详情面板(65%) ──
Box(
modifier = Modifier
.weight(0.65f)
.fillMaxHeight()
) {
AnimatedContent(
targetState = detailPane,
transitionSpec = { fadeIn() togetherWith fadeOut() }
) { pane ->
ExpandedDetailPane(
pane = pane,
onClose = { detailPane = DetailPane.Empty }
)
}
}
}
}
// ---------------------------------------------------------------------------
// Expanded 右侧面板路由
// ---------------------------------------------------------------------------
/**
* 用量详情内部的子路由状态(仅 Expanded 下使用)。
* 右侧面板内从 UsageDetail → Overview / Data 的切换通过此状态管理。
*/
private sealed class UsageSubRoute {
object Chart : UsageSubRoute()
object Overview : UsageSubRoute()
object Data : UsageSubRoute()
}
private fun backToEmpty(usageSub: UsageSubRoute?): Boolean =
usageSub != null && usageSub !is UsageSubRoute.Chart
@Composable
private fun ExpandedDetailPane(
pane: DetailPane,
onClose: () -> Unit
) {
when (pane) {
is DetailPane.Empty -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("👈 选择服务查看详情", style = MaterialTheme.typography.titleMedium, color = inkMuted())
Text("点击左侧卡片即可", style = MaterialTheme.typography.bodySmall, color = inkMuted())
}
}
}
is DetailPane.ServiceDetail -> {
ServiceDetailScreen(
service = pane.type,
onBack = onClose,
onConfigureCredential = { svc ->
// 在右侧内部子路由跳转到 CredentialEdit
onClose() // 先清右侧再切 Settings(简化:直接切 Settings)
},
onStartWebViewLogin = { svc -> }
)
}
is DetailPane.OCGOUsage -> {
var subRoute by remember { mutableStateOf<UsageSubRoute>(UsageSubRoute.Chart) }
when (subRoute) {
is UsageSubRoute.Chart -> {
UsageDetailScreen(
onBack = onClose,
onOpenOverview = { subRoute = UsageSubRoute.Overview },
onOpenData = { subRoute = UsageSubRoute.Data }
)
}
is UsageSubRoute.Overview -> {
UsageOverviewScreen(onBack = { subRoute = UsageSubRoute.Chart }, autoLoad = true)
}
is UsageSubRoute.Data -> {
UsageDataScreen(onBack = { subRoute = UsageSubRoute.Chart }, autoLoad = true)
}
}
}
is DetailPane.CCGOUsage -> {
val wid = CommandCodeUsageRepository.CCGO_WORKSPACE_ID
val chartVm: UsageChartViewModel = hiltViewModel(key = "ccgo_chart_$wid")
val usageVm: UsageViewModel = hiltViewModel(key = "ccgo_$wid")
LaunchedEffect(Unit) {
usageVm.setWorkspace(wid)
chartVm.setWorkspace(wid)
}
var subRoute by remember { mutableStateOf<UsageSubRoute>(UsageSubRoute.Chart) }
when (subRoute) {
is UsageSubRoute.Chart -> {
UsageDetailScreen(
onBack = onClose,
onOpenOverview = { subRoute = UsageSubRoute.Overview },
onOpenData = { subRoute = UsageSubRoute.Data },
viewModel = chartVm,
clearViewModel = usageVm
)
}
is UsageSubRoute.Overview -> {
val key = "ccgo_$wid"
val ovVm: UsageViewModel = hiltViewModel(key = key)
LaunchedEffect(Unit) { ovVm.setWorkspace(wid) }
UsageOverviewScreen(onBack = { subRoute = UsageSubRoute.Chart }, viewModel = ovVm, autoLoad = false)
}
is UsageSubRoute.Data -> {
val key = "ccgo_$wid"
val dataVm: UsageDataViewModel = hiltViewModel(key = key)
LaunchedEffect(Unit) { dataVm.setWorkspace(wid) }
UsageDataScreen(onBack = { subRoute = UsageSubRoute.Chart }, viewModel = dataVm, autoLoad = false)
}
}
}
is DetailPane.Settings -> {
// Settings 内部子路由用局部 NavHost
val settingsNavController = rememberNavController()
NavHost(
navController = settingsNavController,
startDestination = "settings_main"
) {
composable("settings_main") {
SettingsScreen(
onBack = onClose,
onEditCredential = { type ->
settingsNavController.navigate(Routes.credentialEdit(type))
}
)
}
composable(
route = Routes.CREDENTIAL_EDIT,
arguments = listOf(navArgument("type") { type = NavType.StringType })
) { entry ->
val type = parseServiceType(entry.arguments?.getString("type"))
CredentialEditScreen(
service = type,
onBack = { settingsNavController.popBackStack() },
onStartWebViewLogin = { svc ->
settingsNavController.navigate(Routes.webviewLogin(svc))
},
onWebViewLoginSuccess = { }
)
}
composable(
route = Routes.WEBVIEW_LOGIN,
arguments = listOf(navArgument("type") { type = NavType.StringType })
) { entry ->
val type = parseServiceType(entry.arguments?.getString("type"))
WebViewLoginScreen(
service = type,
onBack = { settingsNavController.popBackStack() },
onLoginSucceeded = { settingsNavController.popBackStack() }
)
}
}
}
}
}

View File

@ -0,0 +1,28 @@
package com.rainy.token.ui.components
import android.app.Activity
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
/**
* 获取当前窗口大小等级,用于自适应布局(手机/平板/折叠屏)。
*
* 在各 Screen Composable 顶部调用:
* ```
* val windowSize = rememberWindowSizeClass()
* when (windowSize.widthSizeClass) {
* WindowWidthSizeClass.Compact → // 手机竖屏(宽度 < 600dp)
* WindowWidthSizeClass.Medium → // 小平板/折叠屏展开(600-840dp)
* WindowWidthSizeClass.Expanded → // 大平板横屏(> 840dp)
* }
* ```
*/
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@Composable
fun rememberWindowSizeClass(): WindowSizeClass {
val activity = LocalContext.current as Activity
return calculateWindowSizeClass(activity)
}

View File

@ -7,7 +7,10 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@ -51,6 +54,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
@ -67,7 +71,6 @@ import com.rainy.token.ui.theme.StrawberryPink
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Intent
import androidx.compose.ui.platform.LocalContext
import android.widget.Toast
import com.rainy.token.ui.widget.OpenCodeGoWidgetProvider
import java.text.SimpleDateFormat
@ -85,7 +88,7 @@ import java.util.Locale
* - 卡片底部展示"更新于 X 分钟前"或错误信息
* - 主数字加粗超大,视觉锚点
*/
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun DashboardScreen(
onOpenSettings: () -> Unit,
@ -190,30 +193,65 @@ fun DashboardScreen(
CircularProgressIndicator(color = StrawberryPink)
}
} else {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// 用量统计卡片(主页面,独立 ViewModel)
UsageStatsCard(onOpenDetail = onOpenUsageDetail, refreshTrigger = usageSyncTrigger)
// CommandCode Go 用量统计卡片(风格完全一致)
CommandCodeUsageStatsCard(onOpenDetail = onOpenCcgoUsageDetail, refreshTrigger = usageSyncTrigger)
// 分隔
Text(
text = "服务余额",
style = MaterialTheme.typography.labelMedium,
color = inkMuted(),
modifier = Modifier.padding(top = 8.dp)
)
uiState.cards.forEach { card ->
DashboardCard(card = card, onClick = { onOpenService(card.service) },
onOpenCcgoUsageDetail = if (card.service == ServiceType.COMMANDCODE_GO) onOpenCcgoUsageDetail else null)
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
// 容器自身宽度 > 600dp 时开双列(自适应父容器,而非全局窗口)
val wideEnough = maxWidth > 600.dp
val contentPadding = if (wideEnough) 16.dp else 16.dp
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(contentPadding),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// 用量统计卡片(主页面,独立 ViewModel)
if (wideEnough) {
// 面板够宽:两张用量卡片并排
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Box(modifier = Modifier.weight(1f)) {
UsageStatsCard(onOpenDetail = onOpenUsageDetail, refreshTrigger = usageSyncTrigger)
}
Box(modifier = Modifier.weight(1f)) {
CommandCodeUsageStatsCard(onOpenDetail = onOpenCcgoUsageDetail, refreshTrigger = usageSyncTrigger)
}
}
} else {
UsageStatsCard(onOpenDetail = onOpenUsageDetail, refreshTrigger = usageSyncTrigger)
CommandCodeUsageStatsCard(onOpenDetail = onOpenCcgoUsageDetail, refreshTrigger = usageSyncTrigger)
}
// 分隔
Text(
text = "服务余额",
style = MaterialTheme.typography.labelMedium,
color = inkMuted(),
modifier = Modifier.padding(top = 8.dp)
)
if (wideEnough) {
// 面板够宽:服务卡片 FlowRow 双列
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
uiState.cards.forEach { card ->
Box(modifier = Modifier.fillMaxWidth(0.5f)) {
DashboardCard(card = card, onClick = { onOpenService(card.service) },
onOpenCcgoUsageDetail = if (card.service == ServiceType.COMMANDCODE_GO) onOpenCcgoUsageDetail else null)
}
}
}
} else {
uiState.cards.forEach { card ->
DashboardCard(card = card, onClick = { onOpenService(card.service) },
onOpenCcgoUsageDetail = if (card.service == ServiceType.COMMANDCODE_GO) onOpenCcgoUsageDetail else null)
}
}
// 底部 footer:填空白 + 提供版本号
DashboardFooter()
}
// 底部 footer:填空白 + 提供版本号
DashboardFooter()
}
}
}

View File

@ -125,13 +125,14 @@ fun UsageChartScreen(
Text("加载中…", color = InkMuted)
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
BoxWithConstraints(modifier = Modifier.fillMaxSize().padding(innerPadding)) {
// 容器自身宽度 > 700dp 时图表并排
val wideEnough = maxWidth > 700.dp
val hPad = if (wideEnough) 24.dp else 16.dp
LazyColumn(
modifier = Modifier.fillMaxSize().padding(horizontal = hPad),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// ─── 顶部控制:粒度 + 模型 ───
item {
Row(
@ -195,90 +196,166 @@ fun UsageChartScreen(
}
}
// ─── 图表1:消耗金额(按模型堆叠柱状图) ───
item {
val models = state.selectedModels.ifEmpty { state.allModels.toSet() }
val costTotal = state.buckets.sumOf { it.totalCost.toDouble() / 100_000_000.0 }
ChartCard(
title = "消耗金额 (USD)",
summary = "$${String.format(Locale.US, "%.4f", costTotal)}",
onSummaryClick = { showCostDetail = true }
) {
StackedBarChart(
buckets = state.buckets,
valueSelector = { it.totalCost.toDouble() / 100_000_000.0 },
stackSelector = { bucket ->
models.mapIndexedNotNull { idx, model ->
val v = bucket.byModel[model]?.cost ?: return@mapIndexedNotNull null
v.toDouble() / 100_000_000.0 to modelColors[idx % modelColors.size]
if (wideEnough) {
// 面板够宽:前两张图表并排,第三张独立
item {
val models = state.selectedModels.ifEmpty { state.allModels.toSet() }
val costTotal = state.buckets.sumOf { it.totalCost.toDouble() / 100_000_000.0 }
val reqTotal = state.buckets.sumOf { it.totalRequests }
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Box(modifier = Modifier.weight(1f)) {
ChartCard("消耗金额 (USD)", "$${String.format(Locale.US, "%.4f", costTotal)}", { showCostDetail = true }) {
StackedBarChart(
buckets = state.buckets,
valueSelector = { it.totalCost.toDouble() / 100_000_000.0 },
stackSelector = { bucket ->
models.mapIndexedNotNull { idx, model ->
val v = bucket.byModel[model]?.cost ?: return@mapIndexedNotNull null
v.toDouble() / 100_000_000.0 to modelColors[idx % modelColors.size]
}
},
stackLabels = { bucket ->
models.mapNotNull { model ->
if (bucket.byModel[model] != null) model else null
}
},
formatValue = { "$${String.format(Locale.US, "%.4f", it)}" },
granularity = state.granularity,
legendItems = models.mapIndexed { idx, m ->
m to modelColors[idx % modelColors.size]
}
)
}
},
stackLabels = { bucket ->
models.mapNotNull { model ->
if (bucket.byModel[model] != null) model else null
}
},
formatValue = { "$${String.format(Locale.US, "%.4f", it)}" },
granularity = state.granularity,
legendItems = models.mapIndexed { idx, m ->
m to modelColors[idx % modelColors.size]
}
Box(modifier = Modifier.weight(1f)) {
ChartCard("API 请求次数", "${reqTotal}次", { showReqDetail = true }) {
LineChart(
buckets = state.buckets,
valueSelector = { it.totalRequests.toFloat() },
lineColor = StrawberryPink,
formatValue = { "${it.toInt()}次" },
granularity = state.granularity
)
}
}
}
}
// 第三张图表独立一行
item {
val tokTotal = state.buckets.sumOf { it.cacheHitTokens + it.inputTokens + it.outputTokens }
ChartCard("Token 消耗", formatTokenComma(tokTotal), { showTokenDetail = true }) {
StackedBarChart(
buckets = state.buckets,
valueSelector = { (it.cacheHitTokens + it.inputTokens + it.outputTokens).toDouble() },
stackSelector = { bucket ->
listOfNotNull(
bucket.outputTokens.toDouble() to tokenColors[2],
bucket.inputTokens.toDouble() to tokenColors[1],
bucket.cacheHitTokens.toDouble() to tokenColors[0]
)
},
stackLabels = { listOf("输出", "输入(未命中)", "命中缓存") },
formatValue = { formatTokenComma(it.toLong()) },
granularity = state.granularity,
tooltipReversed = true
)
}
ChartLegend(
items = listOf(
"输入(未命中)" to tokenColors[1],
"命中缓存" to tokenColors[0],
"输出" to tokenColors[2]
)
)
}
}
} else {
// 手机:三张图表纵向堆叠(原逻辑不变)
item {
val models = state.selectedModels.ifEmpty { state.allModels.toSet() }
val costTotal = state.buckets.sumOf { it.totalCost.toDouble() / 100_000_000.0 }
ChartCard(
title = "消耗金额 (USD)",
summary = "$${String.format(Locale.US, "%.4f", costTotal)}",
onSummaryClick = { showCostDetail = true }
) {
StackedBarChart(
buckets = state.buckets,
valueSelector = { it.totalCost.toDouble() / 100_000_000.0 },
stackSelector = { bucket ->
models.mapIndexedNotNull { idx, model ->
val v = bucket.byModel[model]?.cost ?: return@mapIndexedNotNull null
v.toDouble() / 100_000_000.0 to modelColors[idx % modelColors.size]
}
},
stackLabels = { bucket ->
models.mapNotNull { model ->
if (bucket.byModel[model] != null) model else null
}
},
formatValue = { "$${String.format(Locale.US, "%.4f", it)}" },
granularity = state.granularity,
legendItems = models.mapIndexed { idx, m ->
m to modelColors[idx % modelColors.size]
}
)
}
}
// ─── 图表2:API 请求次数 ───
item {
val reqTotal = state.buckets.sumOf { it.totalRequests }
ChartCard(
title = "API 请求次数",
summary = "${reqTotal}次",
onSummaryClick = { showReqDetail = true }
) {
LineChart(
buckets = state.buckets,
valueSelector = { it.totalRequests.toFloat() },
lineColor = StrawberryPink,
formatValue = { "${it.toInt()}次" },
granularity = state.granularity
)
item {
val reqTotal = state.buckets.sumOf { it.totalRequests }
ChartCard(
title = "API 请求次数",
summary = "${reqTotal}次",
onSummaryClick = { showReqDetail = true }
) {
LineChart(
buckets = state.buckets,
valueSelector = { it.totalRequests.toFloat() },
lineColor = StrawberryPink,
formatValue = { "${it.toInt()}次" },
granularity = state.granularity
)
}
}
}
// ─── 图表3:Token 消耗(底→顶:输出 / 未命中输入 / 命中缓存) ───
item {
val tokTotal = state.buckets.sumOf { it.cacheHitTokens + it.inputTokens + it.outputTokens }
ChartCard(
title = "Token 消耗",
summary = formatTokenComma(tokTotal),
onSummaryClick = { showTokenDetail = true }
) {
StackedBarChart(
buckets = state.buckets,
valueSelector = { (it.cacheHitTokens + it.inputTokens + it.outputTokens).toDouble() },
stackSelector = { bucket ->
listOfNotNull(
bucket.outputTokens.toDouble() to tokenColors[2], // 输出 — 底
bucket.inputTokens.toDouble() to tokenColors[1], // 输入(未命中) — 中
bucket.cacheHitTokens.toDouble() to tokenColors[0] // 命中缓存 — 顶
)
},
stackLabels = { listOf("输出", "输入(未命中)", "命中缓存") },
formatValue = { formatTokenComma(it.toLong()) },
granularity = state.granularity,
tooltipReversed = true
item {
val tokTotal = state.buckets.sumOf { it.cacheHitTokens + it.inputTokens + it.outputTokens }
ChartCard(
title = "Token 消耗",
summary = formatTokenComma(tokTotal),
onSummaryClick = { showTokenDetail = true }
) {
StackedBarChart(
buckets = state.buckets,
valueSelector = { (it.cacheHitTokens + it.inputTokens + it.outputTokens).toDouble() },
stackSelector = { bucket ->
listOfNotNull(
bucket.outputTokens.toDouble() to tokenColors[2],
bucket.inputTokens.toDouble() to tokenColors[1],
bucket.cacheHitTokens.toDouble() to tokenColors[0]
)
},
stackLabels = { listOf("输出", "输入(未命中)", "命中缓存") },
formatValue = { formatTokenComma(it.toLong()) },
granularity = state.granularity,
tooltipReversed = true
)
}
ChartLegend(
items = listOf(
"输入(未命中)" to tokenColors[1],
"命中缓存" to tokenColors[0],
"输出" to tokenColors[2]
)
)
}
ChartLegend(
items = listOf(
"输入(未命中)" to tokenColors[1],
"命中缓存" to tokenColors[0],
"输出" to tokenColors[2]
)
)
}
item { Spacer(Modifier.height(32.dp)) }
}
}
}

View File

@ -198,11 +198,11 @@ fun UsageDataScreen(
Card(Modifier.fillMaxWidth(), RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp),
CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) {
Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp)) {
HeaderCell("时间", Modifier.weight(1.5f))
HeaderCell("模型", Modifier.weight(1.8f))
HeaderCell("输入(缓存)", Modifier.weight(1.5f))
HeaderCell("输出", Modifier.weight(1.2f))
HeaderCell("成本", Modifier.weight(1.0f))
HeaderCell("时间", Modifier.weight(1.5f), false)
HeaderCell("模型", Modifier.weight(1.8f), false)
HeaderCell("输入(缓存)", Modifier.weight(1.5f), false)
HeaderCell("输出", Modifier.weight(1.2f), false)
HeaderCell("成本", Modifier.weight(1.0f), false)
}
}
@ -218,7 +218,7 @@ fun UsageDataScreen(
} else {
LazyColumn(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(1.dp)) {
itemsIndexed(state.records, key = { _, r -> r.id }) { idx, record ->
DataRecordRow(record, idx, { rawRecord = it })
DataRecordRow(record, idx, { rawRecord = it }, false)
}
item { Spacer(Modifier.height(8.dp)) }
}
@ -271,30 +271,35 @@ fun UsageDataScreen(
}
@Composable
private fun HeaderCell(text: String, modifier: Modifier) {
Text(text, modifier = modifier, fontSize = 12.sp, fontWeight = FontWeight.Bold,
private fun HeaderCell(text: String, modifier: Modifier, expanded: Boolean = false) {
Text(text, modifier = modifier,
fontSize = if (expanded) 14.sp else 12.sp,
fontWeight = FontWeight.Bold,
color = inkMuted(), textAlign = TextAlign.Center)
}
@Composable
private fun DataRecordRow(record: UsageRecord, idx: Int, onClick: (UsageRecord) -> Unit) {
private fun DataRecordRow(record: UsageRecord, idx: Int, onClick: (UsageRecord) -> Unit, expanded: Boolean = false) {
val bg = if (idx % 2 == 0) Color.Transparent else MaterialTheme.colorScheme.surface.copy(alpha = 0.3f)
val rowPad = if (expanded) 12.dp else 8.dp
Card(Modifier.fillMaxWidth().clickable { onClick(record) },
RoundedCornerShape(0.dp), CardDefaults.cardColors(containerColor = bg)) {
Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp),
Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = rowPad),
verticalAlignment = Alignment.CenterVertically) {
DataCell(formatUtcTimeShort(record.timeCreated), Modifier.weight(1.5f))
DataCell(record.model, Modifier.weight(1.8f))
DataCell(formatInputWithCache(record), Modifier.weight(1.5f))
DataCell("%,d".format(Locale.US, record.outputTokens), Modifier.weight(1.2f))
DataCell("$${String.format(Locale.US, "%.4f", record.costUsd)}", Modifier.weight(1.0f))
DataCell(formatUtcTimeShort(record.timeCreated), Modifier.weight(1.5f), expanded)
DataCell(record.model, Modifier.weight(1.8f), expanded)
DataCell(formatInputWithCache(record), Modifier.weight(1.5f), expanded)
DataCell("%,d".format(Locale.US, record.outputTokens), Modifier.weight(1.2f), expanded)
DataCell("$${String.format(Locale.US, "%.4f", record.costUsd)}", Modifier.weight(1.0f), expanded)
}
}
}
@Composable
private fun DataCell(text: String, modifier: Modifier) {
Text(text, modifier = modifier, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurface,
private fun DataCell(text: String, modifier: Modifier, expanded: Boolean = false) {
Text(text, modifier = modifier,
fontSize = if (expanded) 13.sp else 11.sp,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center, maxLines = 1)
}

View File

@ -2,13 +2,17 @@ package com.rainy.token.ui.dashboard
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
@ -65,7 +69,7 @@ import java.util.Locale
/**
* 用量详情页 —— 统计图表。
*/
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun UsageDetailScreen(
onBack: () -> Unit,
@ -122,125 +126,176 @@ fun UsageDetailScreen(
Text("加载中…", color = InkMuted)
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize().padding(innerPadding).padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item {
Row(Modifier.fillMaxWidth(), Arrangement.spacedBy(8.dp), Alignment.CenterVertically) {
Box {
TextButton(onClick = { granularityExpanded = true }) {
Text(state.granularity.label, color = StrawberryPink)
Icon(Icons.Filled.ArrowDropDown, null, tint = StrawberryPink)
}
DropdownMenu(granularityExpanded, { granularityExpanded = false }) {
ChartGranularity.entries.forEach { g ->
DropdownMenuItem(text = { Text(g.label) }, onClick = {
granularityExpanded = false
when (g) {
ChartGranularity.CUSTOM_DAY_HOURLY -> showCustomDayPicker = true
ChartGranularity.CUSTOM_MONTH_DAILY -> showCustomMonthPicker = true
ChartGranularity.CUSTOM_RANGE_DAILY -> showCustomRangeStart = true
else -> viewModel.setGranularity(g)
}
})
BoxWithConstraints(modifier = Modifier.fillMaxSize().padding(innerPadding)) {
// 容器自身宽度 > 700dp 时图表并排(自适应父容器,而非全局窗口)
val wideEnough = maxWidth > 700.dp
val hPad = if (wideEnough) 24.dp else 16.dp
LazyColumn(
modifier = Modifier.fillMaxSize().padding(horizontal = hPad),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item {
Row(Modifier.fillMaxWidth(), Arrangement.spacedBy(8.dp), Alignment.CenterVertically) {
Box {
TextButton(onClick = { granularityExpanded = true }) {
Text(state.granularity.label, color = StrawberryPink)
Icon(Icons.Filled.ArrowDropDown, null, tint = StrawberryPink)
}
DropdownMenu(granularityExpanded, { granularityExpanded = false }) {
ChartGranularity.entries.forEach { g ->
DropdownMenuItem(text = { Text(g.label) }, onClick = {
granularityExpanded = false
when (g) {
ChartGranularity.CUSTOM_DAY_HOURLY -> showCustomDayPicker = true
ChartGranularity.CUSTOM_MONTH_DAILY -> showCustomMonthPicker = true
ChartGranularity.CUSTOM_RANGE_DAILY -> showCustomRangeStart = true
else -> viewModel.setGranularity(g)
}
})
}
}
}
}
TextButton(onClick = {
viewModel.toggleUtc8()
scope.launch {
snackbarHostState.showSnackbar(
if (state.useUtc8) "已切换到 UTC+8,图表按北京时间显示" else "已切换到 UTC+0,图表按世界协调时显示",
duration = SnackbarDuration.Short
TextButton(onClick = {
viewModel.toggleUtc8()
scope.launch {
snackbarHostState.showSnackbar(
if (state.useUtc8) "已切换到 UTC+8,图表按北京时间显示" else "已切换到 UTC+0,图表按世界协调时显示",
duration = SnackbarDuration.Short
)
}
}) {
Text(
if (state.useUtc8) "UTC+8」" else "UTC+0」",
style = MaterialTheme.typography.bodySmall,
color = if (state.useUtc8) StrawberryPink else InkMuted,
fontWeight = if (state.useUtc8) FontWeight.SemiBold else FontWeight.Normal
)
Text(
" 点击切换",
style = MaterialTheme.typography.bodySmall,
color = InkMuted.copy(alpha = 0.5f),
fontWeight = FontWeight.Normal
)
}
}) {
Text(
if (state.useUtc8) "UTC+8」" else "UTC+0」",
style = MaterialTheme.typography.bodySmall,
color = if (state.useUtc8) StrawberryPink else InkMuted,
fontWeight = if (state.useUtc8) FontWeight.SemiBold else FontWeight.Normal
)
Text(
" 点击切换",
style = MaterialTheme.typography.bodySmall,
color = InkMuted.copy(alpha = 0.5f),
fontWeight = FontWeight.Normal
)
}
Spacer(Modifier.weight(1f))
Box {
val label = if (state.selectedModels.isEmpty()) "全部模型" else "${state.selectedModels.size} 个模型"
TextButton(onClick = { modelExpanded = true }) {
Text(label, color = StrawberryPink, style = MaterialTheme.typography.bodySmall)
Icon(Icons.Filled.ArrowDropDown, null, tint = StrawberryPink)
}
DropdownMenu(modelExpanded, { modelExpanded = false }) {
DropdownMenuItem(
text = { Text("全部模型", fontWeight = FontWeight.Bold) },
onClick = { modelExpanded = false; viewModel.selectAllModels() })
state.allModels.forEach { model ->
DropdownMenuItem(text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(state.selectedModels.isEmpty() || model in state.selectedModels, { viewModel.toggleModel(model) })
Text(model, modifier = Modifier.padding(start = 4.dp))
}
}, onClick = { viewModel.toggleModel(model) })
Spacer(Modifier.weight(1f))
Box {
val label = if (state.selectedModels.isEmpty()) "全部模型" else "${state.selectedModels.size} 个模型"
TextButton(onClick = { modelExpanded = true }) {
Text(label, color = StrawberryPink, style = MaterialTheme.typography.bodySmall)
Icon(Icons.Filled.ArrowDropDown, null, tint = StrawberryPink)
}
DropdownMenu(modelExpanded, { modelExpanded = false }) {
DropdownMenuItem(
text = { Text("全部模型", fontWeight = FontWeight.Bold) },
onClick = { modelExpanded = false; viewModel.selectAllModels() })
state.allModels.forEach { model ->
DropdownMenuItem(text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(state.selectedModels.isEmpty() || model in state.selectedModels, { viewModel.toggleModel(model) })
Text(model, modifier = Modifier.padding(start = 4.dp))
}
}, onClick = { viewModel.toggleModel(model) })
}
}
}
}
}
}
// 图表1
item {
val models = state.selectedModels.ifEmpty { state.allModels.toSet() }
val costTotal = state.buckets.sumOf { it.totalCost.toDouble() / 100_000_000.0 }
ChartCard("消耗金额 (USD)", "$${String.format(Locale.US, "%.4f", costTotal)}", { showCostDetail = true }) {
StackedBarChart(state.buckets,
{ it.totalCost.toDouble() / 100_000_000.0 },
{ bucket -> models.mapIndexedNotNull { idx, m -> val v = bucket.byModel[m]?.cost ?: return@mapIndexedNotNull null; v.toDouble() / 100_000_000.0 to modelColors[idx % modelColors.size] } },
{ bucket -> models.mapNotNull { if (bucket.byModel[it] != null) it else null } },
formatValue = { "$${String.format(Locale.US, "%.4f", it)}" },
granularity = state.granularity,
legendItems = models.mapIndexed { idx, m ->
m to modelColors[idx % modelColors.size]
},
useUtc8 = state.useUtc8
)
if (wideEnough) {
// 面板够宽:前两张图表并排
item {
val models = state.selectedModels.ifEmpty { state.allModels.toSet() }
val costTotal = state.buckets.sumOf { it.totalCost.toDouble() / 100_000_000.0 }
val reqTotal = state.buckets.sumOf { it.totalRequests }
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Box(modifier = Modifier.weight(1f)) {
ChartCard("消耗金额 (USD)", "$${String.format(Locale.US, "%.4f", costTotal)}", { showCostDetail = true }) {
StackedBarChart(state.buckets,
{ it.totalCost.toDouble() / 100_000_000.0 },
{ bucket -> models.mapIndexedNotNull { idx, m -> val v = bucket.byModel[m]?.cost ?: return@mapIndexedNotNull null; v.toDouble() / 100_000_000.0 to modelColors[idx % modelColors.size] } },
{ bucket -> models.mapNotNull { if (bucket.byModel[it] != null) it else null } },
formatValue = { "$${String.format(Locale.US, "%.4f", it)}" },
granularity = state.granularity,
legendItems = models.mapIndexed { idx, m ->
m to modelColors[idx % modelColors.size]
},
useUtc8 = state.useUtc8
)
}
}
Box(modifier = Modifier.weight(1f)) {
ChartCard("API 请求次数", "${reqTotal}次", { showReqDetail = true }) {
LineChart(state.buckets, { it.totalRequests.toFloat() }, StrawberryPink, { "${it.toInt()}次" }, state.granularity, useUtc8 = state.useUtc8)
}
}
}
}
item {
val tokTotal = state.buckets.sumOf { it.cacheHitTokens + it.inputTokens + it.outputTokens }
ChartCard("Token 消耗", formatTokenComma(tokTotal), { showTokenDetail = true }) {
StackedBarChart(state.buckets,
{ (it.cacheHitTokens + it.inputTokens + it.outputTokens).toDouble() },
{ bucket -> listOfNotNull(bucket.outputTokens.toDouble() to tokenColors[2], bucket.inputTokens.toDouble() to tokenColors[1], bucket.cacheHitTokens.toDouble() to tokenColors[0]) },
{ listOf("输出", "输入(未命中)", "命中缓存") },
formatValue = { formatTokenComma(it.toLong()) },
granularity = state.granularity,
tooltipReversed = true,
useUtc8 = state.useUtc8
)
}
ChartLegend(listOf("输入(未命中)" to tokenColors[1], "命中缓存" to tokenColors[0], "输出" to tokenColors[2]))
}
} else {
// 窄面板:三张图表纵向堆叠
item {
val models = state.selectedModels.ifEmpty { state.allModels.toSet() }
val costTotal = state.buckets.sumOf { it.totalCost.toDouble() / 100_000_000.0 }
ChartCard("消耗金额 (USD)", "$${String.format(Locale.US, "%.4f", costTotal)}", { showCostDetail = true }) {
StackedBarChart(state.buckets,
{ it.totalCost.toDouble() / 100_000_000.0 },
{ bucket -> models.mapIndexedNotNull { idx, m -> val v = bucket.byModel[m]?.cost ?: return@mapIndexedNotNull null; v.toDouble() / 100_000_000.0 to modelColors[idx % modelColors.size] } },
{ bucket -> models.mapNotNull { if (bucket.byModel[it] != null) it else null } },
formatValue = { "$${String.format(Locale.US, "%.4f", it)}" },
granularity = state.granularity,
legendItems = models.mapIndexed { idx, m ->
m to modelColors[idx % modelColors.size]
},
useUtc8 = state.useUtc8
)
}
}
item {
val reqTotal = state.buckets.sumOf { it.totalRequests }
ChartCard("API 请求次数", "${reqTotal}次", { showReqDetail = true }) {
LineChart(state.buckets, { it.totalRequests.toFloat() }, StrawberryPink, { "${it.toInt()}次" }, state.granularity, useUtc8 = state.useUtc8)
}
}
item {
val tokTotal = state.buckets.sumOf { it.cacheHitTokens + it.inputTokens + it.outputTokens }
ChartCard("Token 消耗", formatTokenComma(tokTotal), { showTokenDetail = true }) {
StackedBarChart(state.buckets,
{ (it.cacheHitTokens + it.inputTokens + it.outputTokens).toDouble() },
{ bucket -> listOfNotNull(bucket.outputTokens.toDouble() to tokenColors[2], bucket.inputTokens.toDouble() to tokenColors[1], bucket.cacheHitTokens.toDouble() to tokenColors[0]) },
{ listOf("输出", "输入(未命中)", "命中缓存") },
formatValue = { formatTokenComma(it.toLong()) },
granularity = state.granularity,
tooltipReversed = true,
useUtc8 = state.useUtc8
)
}
ChartLegend(listOf("输入(未命中)" to tokenColors[1], "命中缓存" to tokenColors[0], "输出" to tokenColors[2]))
}
}
}
// 图表2
item {
val reqTotal = state.buckets.sumOf { it.totalRequests }
ChartCard("API 请求次数", "${reqTotal}次", { showReqDetail = true }) {
LineChart(state.buckets, { it.totalRequests.toFloat() }, StrawberryPink, { "${it.toInt()}次" }, state.granularity, useUtc8 = state.useUtc8)
item {
TextButton(onClick = onOpenOverview, modifier = Modifier.fillMaxWidth()) {
Text("📋 总统计", color = StrawberryPink, fontWeight = FontWeight.Bold)
}
}
item { Spacer(Modifier.height(32.dp)) }
}
// 图表3
item {
val tokTotal = state.buckets.sumOf { it.cacheHitTokens + it.inputTokens + it.outputTokens }
ChartCard("Token 消耗", formatTokenComma(tokTotal), { showTokenDetail = true }) {
StackedBarChart(state.buckets,
{ (it.cacheHitTokens + it.inputTokens + it.outputTokens).toDouble() },
{ bucket -> listOfNotNull(bucket.outputTokens.toDouble() to tokenColors[2], bucket.inputTokens.toDouble() to tokenColors[1], bucket.cacheHitTokens.toDouble() to tokenColors[0]) },
{ listOf("输出", "输入(未命中)", "命中缓存") },
formatValue = { formatTokenComma(it.toLong()) },
granularity = state.granularity,
tooltipReversed = true,
useUtc8 = state.useUtc8
)
}
ChartLegend(listOf("输入(未命中)" to tokenColors[1], "命中缓存" to tokenColors[0], "输出" to tokenColors[2]))
}
// 总统计入口
item {
TextButton(onClick = onOpenOverview, modifier = Modifier.fillMaxWidth()) {
Text("📋 总统计", color = StrawberryPink, fontWeight = FontWeight.Bold)
}
}
item { Spacer(Modifier.height(32.dp)) }
}
}
// 自定义日期选择器
@ -253,7 +308,6 @@ fun UsageDetailScreen(
val utc = ZoneOffset.UTC; val sd = Instant.ofEpochMilli(customRangeStartMs).atOffset(utc).toLocalDate().atStartOfDay(utc).toInstant().toEpochMilli()
val ed = Instant.ofEpochMilli(ms).atOffset(utc).toLocalDate().atStartOfDay(utc).toInstant().toEpochMilli() + 86400_000L - 1
viewModel.setCustomRange(sd, ed); showCustomRangeEnd = false }, { showCustomRangeEnd = false })
// 详情弹窗
val models = state.selectedModels.ifEmpty { state.allModels.toSet() }
if (showCostDetail) ChartDetailDialog("消费明细", { showCostDetail = false }) {
models.forEach { model -> val t = state.buckets.sumOf { it.byModel[model]?.cost ?:0L }; if (t>0) DetailRow(model, "$${String.format(Locale.US, "%.4f", t/100_000_000.0)}") }
@ -266,7 +320,6 @@ fun UsageDetailScreen(
DetailRow("输入(未命中)", formatTokenComma(state.buckets.sumOf { it.inputTokens }))
DetailRow("输出", formatTokenComma(state.buckets.sumOf { it.outputTokens }))
}
// 清除数据警告弹窗(仅 CCGO)
if (showClearDialog && clearViewModel != null) {
val cd = clearCountdown
androidx.compose.material3.AlertDialog(
@ -301,7 +354,6 @@ fun UsageDetailScreen(
}
}
)
// 倒计时启动
LaunchedEffect(showClearDialog) {
if (!showClearDialog) return@LaunchedEffect
clearCountdown = 3
@ -417,4 +469,4 @@ internal fun DateTimePickerDialog(
TimePicker(state = timeState)
}
}
}
}

View File

@ -3,6 +3,8 @@ package com.rainy.token.ui.dashboard
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@ -53,7 +55,7 @@ import java.util.Locale
/**
* 总统计页 —— 总览、按模型、按天统计。
*/
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun UsageOverviewScreen(
onBack: () -> Unit,
@ -125,7 +127,7 @@ fun UsageOverviewScreen(
}
// 总览
uiState.overview?.let { overview ->
item { OverviewCard(overview) }
item { OverviewCard(overview, false) }
if (overview.cacheReadTokens > 0 || overview.cacheWriteTokens > 0) {
item { CacheBreakdownCard(overview) }
}
@ -179,20 +181,37 @@ fun UsageOverviewScreen(
}
}
@Composable private fun OverviewCard(overview: OverviewStats) {
@OptIn(ExperimentalLayoutApi::class)
@Composable private fun OverviewCard(overview: OverviewStats, isExpanded: Boolean = false) {
val inputTotal = overview.inputTokens + overview.cacheReadTokens
Card(Modifier.fillMaxWidth(), RoundedCornerShape(20.dp), CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), elevation = CardDefaults.cardElevation(0.dp)) {
Column(Modifier.padding(16.dp)) {
Row(Modifier.fillMaxWidth(), Arrangement.SpaceEvenly) {
StatItem("输入 Token", formatTokenCount(inputTotal))
StatItem("输出 Token", formatTokenCount(overview.outputTokens))
StatItem("推理 Token", formatTokenCount(overview.reasoningTokens))
}
Spacer(Modifier.height(12.dp))
Row(Modifier.fillMaxWidth(), Arrangement.SpaceEvenly) {
StatItem("总计", formatTokenCount(overview.totalTokens))
StatItem("总花费", "$${String.format(Locale.US, "%.4f", overview.totalCost / 100_000_000.0)}")
StatItem("记录数", "${overview.totalCount}")
if (isExpanded) {
// 平板:FlowRow 横向排列 6 项统计
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
StatItem("输入 Token", formatTokenCount(inputTotal))
StatItem("输出 Token", formatTokenCount(overview.outputTokens))
StatItem("推理 Token", formatTokenCount(overview.reasoningTokens))
StatItem("总计", formatTokenCount(overview.totalTokens))
StatItem("总花费", "$${String.format(Locale.US, "%.4f", overview.totalCost / 100_000_000.0)}")
StatItem("记录数", "${overview.totalCount}")
}
} else {
Row(Modifier.fillMaxWidth(), Arrangement.SpaceEvenly) {
StatItem("输入 Token", formatTokenCount(inputTotal))
StatItem("输出 Token", formatTokenCount(overview.outputTokens))
StatItem("推理 Token", formatTokenCount(overview.reasoningTokens))
}
Spacer(Modifier.height(12.dp))
Row(Modifier.fillMaxWidth(), Arrangement.SpaceEvenly) {
StatItem("总计", formatTokenCount(overview.totalTokens))
StatItem("总花费", "$${String.format(Locale.US, "%.4f", overview.totalCost / 100_000_000.0)}")
StatItem("记录数", "${overview.totalCount}")
}
}
}
}

View File

@ -55,6 +55,7 @@ androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-toolin
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-material3-window-size = { group = "androidx.compose.material3", name = "material3-window-size-class" }
# Navigation
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }