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:
parent
26693c92d4
commit
ea9f6f7e72
23
AGENTS.md
23
AGENTS.md
@ -14,6 +14,7 @@ CommandCode Go 走 JSON API 抓取用量数据。APP 名为「雨晴Token」(
|
|||||||
- Retrofit 2 + OkHttp 4 + Kotlinx Serialization
|
- Retrofit 2 + OkHttp 4 + Kotlinx Serialization
|
||||||
- DataStore(本地缓存)+ Android Keystore(凭据加密,AES-256 GCM)
|
- DataStore(本地缓存)+ Android Keystore(凭据加密,AES-256 GCM)
|
||||||
- `minSdk=31`(Android 12+)
|
- `minSdk=31`(Android 12+)
|
||||||
|
- `material3-window-size-class` — 平板自适应布局
|
||||||
- WorkManager(计划中)
|
- WorkManager(计划中)
|
||||||
|
|
||||||
## 当前实现状态
|
## 当前实现状态
|
||||||
@ -52,13 +53,27 @@ CommandCode Go 走 JSON API 抓取用量数据。APP 名为「雨晴Token」(
|
|||||||
|
|
||||||
> Dashboard 使用 `PullToRefreshBox` → `Column` + `verticalScroll`(非 `LazyColumn`)。
|
> Dashboard 使用 `PullToRefreshBox` → `Column` + `verticalScroll`(非 `LazyColumn`)。
|
||||||
> 页面仅 7 个 item,`LazyColumn` 的 dispose/recompose 会导致用量卡片的 `LaunchedEffect` 反复触发,产生卡顿。
|
> 页面仅 7 个 item,`LazyColumn` 的 dispose/recompose 会导致用量卡片的 `LaunchedEffect` 反复触发,产生卡顿。
|
||||||
|
>
|
||||||
|
> **自适应断点**:容器宽度 > 600dp 时卡片双列(`FlowRow`),≤600dp 时单列。判断使用 `BoxWithConstraints` 而非全局 `WindowSizeClass`,避免嵌套面板误判。
|
||||||
|
|
||||||
|
**图表自适应**:
|
||||||
|
|
||||||
|
> `UsageDetailScreen` / `UsageChartScreen` 通过 `BoxWithConstraints` 判断容器宽度 > 700dp 时图表并排(前两张 50/50,第三张独占一行),反之纵向堆叠。阈值 700dp 高于 Dashboard 的 600dp,因为图表卡片需要更多空间。
|
||||||
|
|
||||||
**页面导航**:
|
**页面导航**:
|
||||||
```
|
```
|
||||||
Dashboard → OCGO: UsageDetail(图表) → UsageOverview(总统计)
|
Compact(手机):
|
||||||
↘ UsageData(原始数据)
|
Dashboard → OCGO: UsageDetail(图表) → UsageOverview(总统计)
|
||||||
→ CCGO: CCGO_USAGE_DETAIL(图表) → CCGO_USAGE_OVERVIEW(总统计)
|
↘ UsageData(原始数据)
|
||||||
↘ CCGO_USAGE_DATA(原始数据)
|
→ CCGO: CCGO_USAGE_DETAIL(图表) → CCGO_USAGE_OVERVIEW(总统计)
|
||||||
|
↘ CCGO_USAGE_DATA(原始数据)
|
||||||
|
|
||||||
|
Expanded(平板,≥840dp):
|
||||||
|
┌─ 左侧 35%: Dashboard(固定) ─┐ ┌─ 右侧 65%: AnimatedContent ─────────┐
|
||||||
|
│ │ │ ServiceDetail / OCGOUsage / CCGOUsage │
|
||||||
|
│ │ │ Settings(内嵌 NavHost) │
|
||||||
|
└────────────────────────────────┘ └────────────────────────────────────────┘
|
||||||
|
右侧用量详情支持内部子路由:图表 → 总览 / 原始数据(通过 UsageSubRoute 状态)
|
||||||
```
|
```
|
||||||
|
|
||||||
**桌面小组件(Widget)**:
|
**桌面小组件(Widget)**:
|
||||||
|
|||||||
@ -28,8 +28,9 @@ Android AI 余额与用量查询 APP —— 统一查看 DeepSeek、OpenCode Go
|
|||||||
|
|
||||||
| 特性 | 说明 |
|
| 特性 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 📊 **仪表盘** | DeepSeek 余额(¥)+ OpenCode Go 用量(5h/本周/本月)+ CommandCode Go 余额($)+ 下拉全局刷新 |
|
| 📊 **仪表盘** | DeepSeek 余额(¥)+ OpenCode Go 用量(5h/本周/本月)+ CommandCode Go 余额($)+ 下拉全局刷新 · 平板自适应双窗格布局 |
|
||||||
| 📈 **用量图表** | 3 张 Canvas 手绘图表 — 消耗金额 / API 请求次数 / Token 消耗(OCGO & CCGO 双数据源);支持 UTC+0/UTC+8 时区切换;自动降级(近5h无数据→12h→7天→当月) |
|
| 📈 **用量图表** | 3 张 Canvas 手绘图表 — 消耗金额 / API 请求次数 / Token 消耗(OCGO & CCGO 双数据源);支持 UTC+0/UTC+8 时区切换;自动降级(近5h无数据→12h→7天→当月);平板并排展示 |
|
||||||
|
| 📱 **平板适配** | 全局 `BoxWithConstraints` 自适应容器宽度;≥600dp 卡片双列,≥700dp 图表并排;双窗格 35/65 左右分栏(Expanded 模式) |
|
||||||
| 📋 **详细数据** | 原始记录分页浏览,支持时间 + 模型筛选,点击查看完整字段 |
|
| 📋 **详细数据** | 原始记录分页浏览,支持时间 + 模型筛选,点击查看完整字段 |
|
||||||
| 🔍 **多粒度筛选** | 5小时 / 12小时(10分钟桶) / 24小时 / 今天 / 昨天 / 最近7天 / 当月 / 自定义日·月·范围 |
|
| 🔍 **多粒度筛选** | 5小时 / 12小时(10分钟桶) / 24小时 / 今天 / 昨天 / 最近7天 / 当月 / 自定义日·月·范围 |
|
||||||
| 🏷️ **模型筛选** | 多选 / 单选 / 全选,动态图例自适应换行 |
|
| 🏷️ **模型筛选** | 多选 / 单选 / 全选,动态图例自适应换行 |
|
||||||
|
|||||||
@ -85,6 +85,7 @@ dependencies {
|
|||||||
implementation(libs.androidx.ui.graphics)
|
implementation(libs.androidx.ui.graphics)
|
||||||
implementation(libs.androidx.ui.tooling.preview)
|
implementation(libs.androidx.ui.tooling.preview)
|
||||||
implementation(libs.androidx.material3)
|
implementation(libs.androidx.material3)
|
||||||
|
implementation(libs.androidx.material3.window.size)
|
||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
implementation(libs.androidx.navigation.compose)
|
implementation(libs.androidx.navigation.compose)
|
||||||
|
|||||||
@ -1,14 +1,38 @@
|
|||||||
package com.rainy.token.ui
|
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.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
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.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.navigation.NavType
|
import androidx.navigation.NavType
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import androidx.navigation.navArgument
|
import androidx.navigation.navArgument
|
||||||
|
import com.rainy.token.data.repository.CommandCodeUsageRepository
|
||||||
import com.rainy.token.domain.service.ServiceType
|
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.DashboardScreen
|
||||||
import com.rainy.token.ui.dashboard.UsageChartViewModel
|
import com.rainy.token.ui.dashboard.UsageChartViewModel
|
||||||
import com.rainy.token.ui.dashboard.UsageDataScreen
|
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.servicedetail.ServiceDetailScreen
|
||||||
import com.rainy.token.ui.settings.CredentialEditScreen
|
import com.rainy.token.ui.settings.CredentialEditScreen
|
||||||
import com.rainy.token.ui.settings.SettingsScreen
|
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
|
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 {
|
object Routes {
|
||||||
const val DASHBOARD = "dashboard"
|
const val DASHBOARD = "dashboard"
|
||||||
@ -48,8 +78,41 @@ private fun parseServiceType(typeName: String?): ServiceType =
|
|||||||
ServiceType.fromStorageKey(it) ?: runCatching { ServiceType.valueOf(it) }.getOrNull()
|
ServiceType.fromStorageKey(it) ?: runCatching { ServiceType.valueOf(it) }.getOrNull()
|
||||||
} ?: ServiceType.DEEPSEEK
|
} ?: 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() {
|
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()
|
val navController = rememberNavController()
|
||||||
NavHost(navController = navController, startDestination = Routes.DASHBOARD) {
|
NavHost(navController = navController, startDestination = Routes.DASHBOARD) {
|
||||||
composable(Routes.DASHBOARD) {
|
composable(Routes.DASHBOARD) {
|
||||||
@ -68,18 +131,13 @@ fun RainyTokenNavHost() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(Routes.USAGE_OVERVIEW) {
|
composable(Routes.USAGE_OVERVIEW) {
|
||||||
UsageOverviewScreen(
|
UsageOverviewScreen(onBack = { navController.popBackStack() })
|
||||||
onBack = { navController.popBackStack() }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
composable(Routes.USAGE_DATA) {
|
composable(Routes.USAGE_DATA) {
|
||||||
UsageDataScreen(
|
UsageDataScreen(onBack = { navController.popBackStack() })
|
||||||
onBack = { navController.popBackStack() }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
// CCGO 用量详情 — 隔离实例通过 key 参数标记 workspaceId
|
|
||||||
composable(Routes.CCGO_USAGE_DETAIL) {
|
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 chartVm: UsageChartViewModel = hiltViewModel(key = "ccgo_chart_$wid")
|
||||||
val usageVm: UsageViewModel = hiltViewModel(key = "ccgo_$wid")
|
val usageVm: UsageViewModel = hiltViewModel(key = "ccgo_$wid")
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
@ -95,30 +153,19 @@ fun RainyTokenNavHost() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(Routes.CCGO_USAGE_OVERVIEW) {
|
composable(Routes.CCGO_USAGE_OVERVIEW) {
|
||||||
val wid = com.rainy.token.data.repository.CommandCodeUsageRepository.CCGO_WORKSPACE_ID
|
val key = "ccgo_${CommandCodeUsageRepository.CCGO_WORKSPACE_ID}"
|
||||||
val key = "ccgo_$wid"
|
|
||||||
val ovVm: UsageViewModel = hiltViewModel(key = key)
|
val ovVm: UsageViewModel = hiltViewModel(key = key)
|
||||||
LaunchedEffect(Unit) { ovVm.setWorkspace(wid) }
|
LaunchedEffect(Unit) { ovVm.setWorkspace(CommandCodeUsageRepository.CCGO_WORKSPACE_ID) }
|
||||||
UsageOverviewScreen(
|
UsageOverviewScreen(onBack = { navController.popBackStack() }, viewModel = ovVm, autoLoad = false)
|
||||||
onBack = { navController.popBackStack() },
|
|
||||||
viewModel = ovVm,
|
|
||||||
autoLoad = false
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
composable(Routes.CCGO_USAGE_DATA) {
|
composable(Routes.CCGO_USAGE_DATA) {
|
||||||
val wid = com.rainy.token.data.repository.CommandCodeUsageRepository.CCGO_WORKSPACE_ID
|
val key = "ccgo_${CommandCodeUsageRepository.CCGO_WORKSPACE_ID}"
|
||||||
val key = "ccgo_$wid"
|
|
||||||
val dataVm: UsageDataViewModel = hiltViewModel(key = key)
|
val dataVm: UsageDataViewModel = hiltViewModel(key = key)
|
||||||
LaunchedEffect(Unit) { dataVm.setWorkspace(wid) }
|
LaunchedEffect(Unit) { dataVm.setWorkspace(CommandCodeUsageRepository.CCGO_WORKSPACE_ID) }
|
||||||
UsageDataScreen(
|
UsageDataScreen(onBack = { navController.popBackStack() }, viewModel = dataVm, autoLoad = false)
|
||||||
onBack = { navController.popBackStack() },
|
|
||||||
viewModel = dataVm,
|
|
||||||
autoLoad = false
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
composable(Routes.SETTINGS) {
|
composable(Routes.SETTINGS) {
|
||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
// 使用 popBackStack 到指定路由而非无参 popBackStack,防止双击返回键导致空白页
|
|
||||||
onBack = { navController.popBackStack(Routes.DASHBOARD, inclusive = false) },
|
onBack = { navController.popBackStack(Routes.DASHBOARD, inclusive = false) },
|
||||||
onEditCredential = { type -> navController.navigate(Routes.credentialEdit(type)) }
|
onEditCredential = { type -> navController.navigate(Routes.credentialEdit(type)) }
|
||||||
)
|
)
|
||||||
@ -132,7 +179,7 @@ fun RainyTokenNavHost() {
|
|||||||
service = type,
|
service = type,
|
||||||
onBack = { navController.popBackStack() },
|
onBack = { navController.popBackStack() },
|
||||||
onStartWebViewLogin = { service -> navController.navigate(Routes.webviewLogin(service)) },
|
onStartWebViewLogin = { service -> navController.navigate(Routes.webviewLogin(service)) },
|
||||||
onWebViewLoginSuccess = { /* 登录后回到编辑页,由 LaunchedEffect 触发刷新 */ }
|
onWebViewLoginSuccess = { }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(
|
composable(
|
||||||
@ -143,10 +190,7 @@ fun RainyTokenNavHost() {
|
|||||||
WebViewLoginScreen(
|
WebViewLoginScreen(
|
||||||
service = type,
|
service = type,
|
||||||
onBack = { navController.popBackStack() },
|
onBack = { navController.popBackStack() },
|
||||||
onLoginSucceeded = {
|
onLoginSucceeded = { navController.popBackStack() }
|
||||||
// 登录成功直接回退到 CredentialEdit 页
|
|
||||||
navController.popBackStack()
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(
|
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() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
@ -7,7 +7,10 @@ import androidx.compose.foundation.background
|
|||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Column
|
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.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
@ -51,6 +54,7 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
@ -67,7 +71,6 @@ import com.rainy.token.ui.theme.StrawberryPink
|
|||||||
import android.appwidget.AppWidgetManager
|
import android.appwidget.AppWidgetManager
|
||||||
import android.content.ComponentName
|
import android.content.ComponentName
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import com.rainy.token.ui.widget.OpenCodeGoWidgetProvider
|
import com.rainy.token.ui.widget.OpenCodeGoWidgetProvider
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
@ -85,7 +88,7 @@ import java.util.Locale
|
|||||||
* - 卡片底部展示"更新于 X 分钟前"或错误信息
|
* - 卡片底部展示"更新于 X 分钟前"或错误信息
|
||||||
* - 主数字加粗超大,视觉锚点
|
* - 主数字加粗超大,视觉锚点
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun DashboardScreen(
|
fun DashboardScreen(
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
@ -190,30 +193,65 @@ fun DashboardScreen(
|
|||||||
CircularProgressIndicator(color = StrawberryPink)
|
CircularProgressIndicator(color = StrawberryPink)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Column(
|
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||||
modifier = Modifier
|
// 容器自身宽度 > 600dp 时开双列(自适应父容器,而非全局窗口)
|
||||||
.fillMaxSize()
|
val wideEnough = maxWidth > 600.dp
|
||||||
.verticalScroll(rememberScrollState())
|
val contentPadding = if (wideEnough) 16.dp else 16.dp
|
||||||
.padding(16.dp),
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
modifier = Modifier
|
||||||
) {
|
.fillMaxSize()
|
||||||
// 用量统计卡片(主页面,独立 ViewModel)
|
.verticalScroll(rememberScrollState())
|
||||||
UsageStatsCard(onOpenDetail = onOpenUsageDetail, refreshTrigger = usageSyncTrigger)
|
.padding(contentPadding),
|
||||||
// CommandCode Go 用量统计卡片(风格完全一致)
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
CommandCodeUsageStatsCard(onOpenDetail = onOpenCcgoUsageDetail, refreshTrigger = usageSyncTrigger)
|
) {
|
||||||
// 分隔
|
// 用量统计卡片(主页面,独立 ViewModel)
|
||||||
Text(
|
if (wideEnough) {
|
||||||
text = "服务余额",
|
// 面板够宽:两张用量卡片并排
|
||||||
style = MaterialTheme.typography.labelMedium,
|
Row(
|
||||||
color = inkMuted(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
modifier = Modifier.padding(top = 8.dp)
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
)
|
) {
|
||||||
uiState.cards.forEach { card ->
|
Box(modifier = Modifier.weight(1f)) {
|
||||||
DashboardCard(card = card, onClick = { onOpenService(card.service) },
|
UsageStatsCard(onOpenDetail = onOpenUsageDetail, refreshTrigger = usageSyncTrigger)
|
||||||
onOpenCcgoUsageDetail = if (card.service == ServiceType.COMMANDCODE_GO) onOpenCcgoUsageDetail else null)
|
}
|
||||||
|
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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -125,13 +125,14 @@ fun UsageChartScreen(
|
|||||||
Text("加载中…", color = InkMuted)
|
Text("加载中…", color = InkMuted)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
LazyColumn(
|
BoxWithConstraints(modifier = Modifier.fillMaxSize().padding(innerPadding)) {
|
||||||
modifier = Modifier
|
// 容器自身宽度 > 700dp 时图表并排
|
||||||
.fillMaxSize()
|
val wideEnough = maxWidth > 700.dp
|
||||||
.padding(innerPadding)
|
val hPad = if (wideEnough) 24.dp else 16.dp
|
||||||
.padding(horizontal = 16.dp),
|
LazyColumn(
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
modifier = Modifier.fillMaxSize().padding(horizontal = hPad),
|
||||||
) {
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
// ─── 顶部控制:粒度 + 模型 ───
|
// ─── 顶部控制:粒度 + 模型 ───
|
||||||
item {
|
item {
|
||||||
Row(
|
Row(
|
||||||
@ -195,90 +196,166 @@ fun UsageChartScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 图表1:消耗金额(按模型堆叠柱状图) ───
|
if (wideEnough) {
|
||||||
item {
|
// 面板够宽:前两张图表并排,第三张独立
|
||||||
val models = state.selectedModels.ifEmpty { state.allModels.toSet() }
|
item {
|
||||||
val costTotal = state.buckets.sumOf { it.totalCost.toDouble() / 100_000_000.0 }
|
val models = state.selectedModels.ifEmpty { state.allModels.toSet() }
|
||||||
ChartCard(
|
val costTotal = state.buckets.sumOf { it.totalCost.toDouble() / 100_000_000.0 }
|
||||||
title = "消耗金额 (USD)",
|
val reqTotal = state.buckets.sumOf { it.totalRequests }
|
||||||
summary = "$${String.format(Locale.US, "%.4f", costTotal)}",
|
Row(
|
||||||
onSummaryClick = { showCostDetail = true }
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
StackedBarChart(
|
) {
|
||||||
buckets = state.buckets,
|
Box(modifier = Modifier.weight(1f)) {
|
||||||
valueSelector = { it.totalCost.toDouble() / 100_000_000.0 },
|
ChartCard("消耗金额 (USD)", "$${String.format(Locale.US, "%.4f", costTotal)}", { showCostDetail = true }) {
|
||||||
stackSelector = { bucket ->
|
StackedBarChart(
|
||||||
models.mapIndexedNotNull { idx, model ->
|
buckets = state.buckets,
|
||||||
val v = bucket.byModel[model]?.cost ?: return@mapIndexedNotNull null
|
valueSelector = { it.totalCost.toDouble() / 100_000_000.0 },
|
||||||
v.toDouble() / 100_000_000.0 to modelColors[idx % modelColors.size]
|
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 {
|
||||||
item {
|
val reqTotal = state.buckets.sumOf { it.totalRequests }
|
||||||
val reqTotal = state.buckets.sumOf { it.totalRequests }
|
ChartCard(
|
||||||
ChartCard(
|
title = "API 请求次数",
|
||||||
title = "API 请求次数",
|
summary = "${reqTotal}次",
|
||||||
summary = "${reqTotal}次",
|
onSummaryClick = { showReqDetail = true }
|
||||||
onSummaryClick = { showReqDetail = true }
|
) {
|
||||||
) {
|
LineChart(
|
||||||
LineChart(
|
buckets = state.buckets,
|
||||||
buckets = state.buckets,
|
valueSelector = { it.totalRequests.toFloat() },
|
||||||
valueSelector = { it.totalRequests.toFloat() },
|
lineColor = StrawberryPink,
|
||||||
lineColor = StrawberryPink,
|
formatValue = { "${it.toInt()}次" },
|
||||||
formatValue = { "${it.toInt()}次" },
|
granularity = state.granularity
|
||||||
granularity = state.granularity
|
)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 图表3:Token 消耗(底→顶:输出 / 未命中输入 / 命中缓存) ───
|
item {
|
||||||
item {
|
val tokTotal = state.buckets.sumOf { it.cacheHitTokens + it.inputTokens + it.outputTokens }
|
||||||
val tokTotal = state.buckets.sumOf { it.cacheHitTokens + it.inputTokens + it.outputTokens }
|
ChartCard(
|
||||||
ChartCard(
|
title = "Token 消耗",
|
||||||
title = "Token 消耗",
|
summary = formatTokenComma(tokTotal),
|
||||||
summary = formatTokenComma(tokTotal),
|
onSummaryClick = { showTokenDetail = true }
|
||||||
onSummaryClick = { showTokenDetail = true }
|
) {
|
||||||
) {
|
StackedBarChart(
|
||||||
StackedBarChart(
|
buckets = state.buckets,
|
||||||
buckets = state.buckets,
|
valueSelector = { (it.cacheHitTokens + it.inputTokens + it.outputTokens).toDouble() },
|
||||||
valueSelector = { (it.cacheHitTokens + it.inputTokens + it.outputTokens).toDouble() },
|
stackSelector = { bucket ->
|
||||||
stackSelector = { bucket ->
|
listOfNotNull(
|
||||||
listOfNotNull(
|
bucket.outputTokens.toDouble() to tokenColors[2],
|
||||||
bucket.outputTokens.toDouble() to tokenColors[2], // 输出 — 底
|
bucket.inputTokens.toDouble() to tokenColors[1],
|
||||||
bucket.inputTokens.toDouble() to tokenColors[1], // 输入(未命中) — 中
|
bucket.cacheHitTokens.toDouble() to tokenColors[0]
|
||||||
bucket.cacheHitTokens.toDouble() to tokenColors[0] // 命中缓存 — 顶
|
)
|
||||||
)
|
},
|
||||||
},
|
stackLabels = { listOf("输出", "输入(未命中)", "命中缓存") },
|
||||||
stackLabels = { listOf("输出", "输入(未命中)", "命中缓存") },
|
formatValue = { formatTokenComma(it.toLong()) },
|
||||||
formatValue = { formatTokenComma(it.toLong()) },
|
granularity = state.granularity,
|
||||||
granularity = state.granularity,
|
tooltipReversed = true
|
||||||
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)) }
|
item { Spacer(Modifier.height(32.dp)) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -198,11 +198,11 @@ fun UsageDataScreen(
|
|||||||
Card(Modifier.fillMaxWidth(), RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp),
|
Card(Modifier.fillMaxWidth(), RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp),
|
||||||
CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) {
|
CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) {
|
||||||
Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp)) {
|
Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp)) {
|
||||||
HeaderCell("时间", Modifier.weight(1.5f))
|
HeaderCell("时间", Modifier.weight(1.5f), false)
|
||||||
HeaderCell("模型", Modifier.weight(1.8f))
|
HeaderCell("模型", Modifier.weight(1.8f), false)
|
||||||
HeaderCell("输入(缓存)", Modifier.weight(1.5f))
|
HeaderCell("输入(缓存)", Modifier.weight(1.5f), false)
|
||||||
HeaderCell("输出", Modifier.weight(1.2f))
|
HeaderCell("输出", Modifier.weight(1.2f), false)
|
||||||
HeaderCell("成本", Modifier.weight(1.0f))
|
HeaderCell("成本", Modifier.weight(1.0f), false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -218,7 +218,7 @@ fun UsageDataScreen(
|
|||||||
} else {
|
} else {
|
||||||
LazyColumn(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(1.dp)) {
|
LazyColumn(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(1.dp)) {
|
||||||
itemsIndexed(state.records, key = { _, r -> r.id }) { idx, record ->
|
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)) }
|
item { Spacer(Modifier.height(8.dp)) }
|
||||||
}
|
}
|
||||||
@ -271,30 +271,35 @@ fun UsageDataScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun HeaderCell(text: String, modifier: Modifier) {
|
private fun HeaderCell(text: String, modifier: Modifier, expanded: Boolean = false) {
|
||||||
Text(text, modifier = modifier, fontSize = 12.sp, fontWeight = FontWeight.Bold,
|
Text(text, modifier = modifier,
|
||||||
|
fontSize = if (expanded) 14.sp else 12.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
color = inkMuted(), textAlign = TextAlign.Center)
|
color = inkMuted(), textAlign = TextAlign.Center)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@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 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) },
|
Card(Modifier.fillMaxWidth().clickable { onClick(record) },
|
||||||
RoundedCornerShape(0.dp), CardDefaults.cardColors(containerColor = bg)) {
|
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) {
|
verticalAlignment = Alignment.CenterVertically) {
|
||||||
DataCell(formatUtcTimeShort(record.timeCreated), Modifier.weight(1.5f))
|
DataCell(formatUtcTimeShort(record.timeCreated), Modifier.weight(1.5f), expanded)
|
||||||
DataCell(record.model, Modifier.weight(1.8f))
|
DataCell(record.model, Modifier.weight(1.8f), expanded)
|
||||||
DataCell(formatInputWithCache(record), Modifier.weight(1.5f))
|
DataCell(formatInputWithCache(record), Modifier.weight(1.5f), expanded)
|
||||||
DataCell("%,d".format(Locale.US, record.outputTokens), Modifier.weight(1.2f))
|
DataCell("%,d".format(Locale.US, record.outputTokens), Modifier.weight(1.2f), expanded)
|
||||||
DataCell("$${String.format(Locale.US, "%.4f", record.costUsd)}", Modifier.weight(1.0f))
|
DataCell("$${String.format(Locale.US, "%.4f", record.costUsd)}", Modifier.weight(1.0f), expanded)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DataCell(text: String, modifier: Modifier) {
|
private fun DataCell(text: String, modifier: Modifier, expanded: Boolean = false) {
|
||||||
Text(text, modifier = modifier, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurface,
|
Text(text, modifier = modifier,
|
||||||
|
fontSize = if (expanded) 13.sp else 11.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
textAlign = TextAlign.Center, maxLines = 1)
|
textAlign = TextAlign.Center, maxLines = 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,13 +2,17 @@ package com.rainy.token.ui.dashboard
|
|||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Column
|
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.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ArrowBack
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
@ -65,7 +69,7 @@ import java.util.Locale
|
|||||||
/**
|
/**
|
||||||
* 用量详情页 —— 统计图表。
|
* 用量详情页 —— 统计图表。
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun UsageDetailScreen(
|
fun UsageDetailScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
@ -122,125 +126,176 @@ fun UsageDetailScreen(
|
|||||||
Text("加载中…", color = InkMuted)
|
Text("加载中…", color = InkMuted)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
LazyColumn(
|
BoxWithConstraints(modifier = Modifier.fillMaxSize().padding(innerPadding)) {
|
||||||
modifier = Modifier.fillMaxSize().padding(innerPadding).padding(horizontal = 16.dp),
|
// 容器自身宽度 > 700dp 时图表并排(自适应父容器,而非全局窗口)
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
val wideEnough = maxWidth > 700.dp
|
||||||
) {
|
val hPad = if (wideEnough) 24.dp else 16.dp
|
||||||
item {
|
LazyColumn(
|
||||||
Row(Modifier.fillMaxWidth(), Arrangement.spacedBy(8.dp), Alignment.CenterVertically) {
|
modifier = Modifier.fillMaxSize().padding(horizontal = hPad),
|
||||||
Box {
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
TextButton(onClick = { granularityExpanded = true }) {
|
) {
|
||||||
Text(state.granularity.label, color = StrawberryPink)
|
item {
|
||||||
Icon(Icons.Filled.ArrowDropDown, null, tint = StrawberryPink)
|
Row(Modifier.fillMaxWidth(), Arrangement.spacedBy(8.dp), Alignment.CenterVertically) {
|
||||||
}
|
Box {
|
||||||
DropdownMenu(granularityExpanded, { granularityExpanded = false }) {
|
TextButton(onClick = { granularityExpanded = true }) {
|
||||||
ChartGranularity.entries.forEach { g ->
|
Text(state.granularity.label, color = StrawberryPink)
|
||||||
DropdownMenuItem(text = { Text(g.label) }, onClick = {
|
Icon(Icons.Filled.ArrowDropDown, null, tint = StrawberryPink)
|
||||||
granularityExpanded = false
|
}
|
||||||
when (g) {
|
DropdownMenu(granularityExpanded, { granularityExpanded = false }) {
|
||||||
ChartGranularity.CUSTOM_DAY_HOURLY -> showCustomDayPicker = true
|
ChartGranularity.entries.forEach { g ->
|
||||||
ChartGranularity.CUSTOM_MONTH_DAILY -> showCustomMonthPicker = true
|
DropdownMenuItem(text = { Text(g.label) }, onClick = {
|
||||||
ChartGranularity.CUSTOM_RANGE_DAILY -> showCustomRangeStart = true
|
granularityExpanded = false
|
||||||
else -> viewModel.setGranularity(g)
|
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 = {
|
||||||
TextButton(onClick = {
|
viewModel.toggleUtc8()
|
||||||
viewModel.toggleUtc8()
|
scope.launch {
|
||||||
scope.launch {
|
snackbarHostState.showSnackbar(
|
||||||
snackbarHostState.showSnackbar(
|
if (state.useUtc8) "已切换到 UTC+8,图表按北京时间显示" else "已切换到 UTC+0,图表按世界协调时显示",
|
||||||
if (state.useUtc8) "已切换到 UTC+8,图表按北京时间显示" else "已切换到 UTC+0,图表按世界协调时显示",
|
duration = SnackbarDuration.Short
|
||||||
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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}) {
|
Spacer(Modifier.weight(1f))
|
||||||
Text(
|
Box {
|
||||||
if (state.useUtc8) "UTC+8」" else "UTC+0」",
|
val label = if (state.selectedModels.isEmpty()) "全部模型" else "${state.selectedModels.size} 个模型"
|
||||||
style = MaterialTheme.typography.bodySmall,
|
TextButton(onClick = { modelExpanded = true }) {
|
||||||
color = if (state.useUtc8) StrawberryPink else InkMuted,
|
Text(label, color = StrawberryPink, style = MaterialTheme.typography.bodySmall)
|
||||||
fontWeight = if (state.useUtc8) FontWeight.SemiBold else FontWeight.Normal
|
Icon(Icons.Filled.ArrowDropDown, null, tint = StrawberryPink)
|
||||||
)
|
}
|
||||||
Text(
|
DropdownMenu(modelExpanded, { modelExpanded = false }) {
|
||||||
" 点击切换",
|
DropdownMenuItem(
|
||||||
style = MaterialTheme.typography.bodySmall,
|
text = { Text("全部模型", fontWeight = FontWeight.Bold) },
|
||||||
color = InkMuted.copy(alpha = 0.5f),
|
onClick = { modelExpanded = false; viewModel.selectAllModels() })
|
||||||
fontWeight = FontWeight.Normal
|
state.allModels.forEach { model ->
|
||||||
)
|
DropdownMenuItem(text = {
|
||||||
}
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Spacer(Modifier.weight(1f))
|
Checkbox(state.selectedModels.isEmpty() || model in state.selectedModels, { viewModel.toggleModel(model) })
|
||||||
Box {
|
Text(model, modifier = Modifier.padding(start = 4.dp))
|
||||||
val label = if (state.selectedModels.isEmpty()) "全部模型" else "${state.selectedModels.size} 个模型"
|
}
|
||||||
TextButton(onClick = { modelExpanded = true }) {
|
}, onClick = { viewModel.toggleModel(model) })
|
||||||
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
|
if (wideEnough) {
|
||||||
item {
|
// 面板够宽:前两张图表并排
|
||||||
val models = state.selectedModels.ifEmpty { state.allModels.toSet() }
|
item {
|
||||||
val costTotal = state.buckets.sumOf { it.totalCost.toDouble() / 100_000_000.0 }
|
val models = state.selectedModels.ifEmpty { state.allModels.toSet() }
|
||||||
ChartCard("消耗金额 (USD)", "$${String.format(Locale.US, "%.4f", costTotal)}", { showCostDetail = true }) {
|
val costTotal = state.buckets.sumOf { it.totalCost.toDouble() / 100_000_000.0 }
|
||||||
StackedBarChart(state.buckets,
|
val reqTotal = state.buckets.sumOf { it.totalRequests }
|
||||||
{ it.totalCost.toDouble() / 100_000_000.0 },
|
Row(
|
||||||
{ 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] } },
|
modifier = Modifier.fillMaxWidth(),
|
||||||
{ bucket -> models.mapNotNull { if (bucket.byModel[it] != null) it else null } },
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
formatValue = { "$${String.format(Locale.US, "%.4f", it)}" },
|
) {
|
||||||
granularity = state.granularity,
|
Box(modifier = Modifier.weight(1f)) {
|
||||||
legendItems = models.mapIndexed { idx, m ->
|
ChartCard("消耗金额 (USD)", "$${String.format(Locale.US, "%.4f", costTotal)}", { showCostDetail = true }) {
|
||||||
m to modelColors[idx % modelColors.size]
|
StackedBarChart(state.buckets,
|
||||||
},
|
{ it.totalCost.toDouble() / 100_000_000.0 },
|
||||||
useUtc8 = state.useUtc8
|
{ 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]))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
item {
|
||||||
// 图表2
|
TextButton(onClick = onOpenOverview, modifier = Modifier.fillMaxWidth()) {
|
||||||
item {
|
Text("📋 总统计", color = StrawberryPink, fontWeight = FontWeight.Bold)
|
||||||
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 { 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 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
|
val ed = Instant.ofEpochMilli(ms).atOffset(utc).toLocalDate().atStartOfDay(utc).toInstant().toEpochMilli() + 86400_000L - 1
|
||||||
viewModel.setCustomRange(sd, ed); showCustomRangeEnd = false }, { showCustomRangeEnd = false })
|
viewModel.setCustomRange(sd, ed); showCustomRangeEnd = false }, { showCustomRangeEnd = false })
|
||||||
// 详情弹窗
|
|
||||||
val models = state.selectedModels.ifEmpty { state.allModels.toSet() }
|
val models = state.selectedModels.ifEmpty { state.allModels.toSet() }
|
||||||
if (showCostDetail) ChartDetailDialog("消费明细", { showCostDetail = false }) {
|
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)}") }
|
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.inputTokens }))
|
||||||
DetailRow("输出", formatTokenComma(state.buckets.sumOf { it.outputTokens }))
|
DetailRow("输出", formatTokenComma(state.buckets.sumOf { it.outputTokens }))
|
||||||
}
|
}
|
||||||
// 清除数据警告弹窗(仅 CCGO)
|
|
||||||
if (showClearDialog && clearViewModel != null) {
|
if (showClearDialog && clearViewModel != null) {
|
||||||
val cd = clearCountdown
|
val cd = clearCountdown
|
||||||
androidx.compose.material3.AlertDialog(
|
androidx.compose.material3.AlertDialog(
|
||||||
@ -301,7 +354,6 @@ fun UsageDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
// 倒计时启动
|
|
||||||
LaunchedEffect(showClearDialog) {
|
LaunchedEffect(showClearDialog) {
|
||||||
if (!showClearDialog) return@LaunchedEffect
|
if (!showClearDialog) return@LaunchedEffect
|
||||||
clearCountdown = 3
|
clearCountdown = 3
|
||||||
@ -417,4 +469,4 @@ internal fun DateTimePickerDialog(
|
|||||||
TimePicker(state = timeState)
|
TimePicker(state = timeState)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -3,6 +3,8 @@ package com.rainy.token.ui.dashboard
|
|||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
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.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@ -53,7 +55,7 @@ import java.util.Locale
|
|||||||
/**
|
/**
|
||||||
* 总统计页 —— 总览、按模型、按天统计。
|
* 总统计页 —— 总览、按模型、按天统计。
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun UsageOverviewScreen(
|
fun UsageOverviewScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
@ -125,7 +127,7 @@ fun UsageOverviewScreen(
|
|||||||
}
|
}
|
||||||
// 总览
|
// 总览
|
||||||
uiState.overview?.let { overview ->
|
uiState.overview?.let { overview ->
|
||||||
item { OverviewCard(overview) }
|
item { OverviewCard(overview, false) }
|
||||||
if (overview.cacheReadTokens > 0 || overview.cacheWriteTokens > 0) {
|
if (overview.cacheReadTokens > 0 || overview.cacheWriteTokens > 0) {
|
||||||
item { CacheBreakdownCard(overview) }
|
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
|
val inputTotal = overview.inputTokens + overview.cacheReadTokens
|
||||||
Card(Modifier.fillMaxWidth(), RoundedCornerShape(20.dp), CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), elevation = CardDefaults.cardElevation(0.dp)) {
|
Card(Modifier.fillMaxWidth(), RoundedCornerShape(20.dp), CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), elevation = CardDefaults.cardElevation(0.dp)) {
|
||||||
Column(Modifier.padding(16.dp)) {
|
Column(Modifier.padding(16.dp)) {
|
||||||
Row(Modifier.fillMaxWidth(), Arrangement.SpaceEvenly) {
|
if (isExpanded) {
|
||||||
StatItem("输入 Token", formatTokenCount(inputTotal))
|
// 平板:FlowRow 横向排列 6 项统计
|
||||||
StatItem("输出 Token", formatTokenCount(overview.outputTokens))
|
FlowRow(
|
||||||
StatItem("推理 Token", formatTokenCount(overview.reasoningTokens))
|
modifier = Modifier.fillMaxWidth(),
|
||||||
}
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
Spacer(Modifier.height(12.dp))
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
Row(Modifier.fillMaxWidth(), Arrangement.SpaceEvenly) {
|
) {
|
||||||
StatItem("总计", formatTokenCount(overview.totalTokens))
|
StatItem("输入 Token", formatTokenCount(inputTotal))
|
||||||
StatItem("总花费", "$${String.format(Locale.US, "%.4f", overview.totalCost / 100_000_000.0)}")
|
StatItem("输出 Token", formatTokenCount(overview.outputTokens))
|
||||||
StatItem("记录数", "${overview.totalCount}")
|
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}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
|
||||||
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||||
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
|
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||||
|
androidx-material3-window-size = { group = "androidx.compose.material3", name = "material3-window-size-class" }
|
||||||
|
|
||||||
# Navigation
|
# Navigation
|
||||||
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
|
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user