diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt index cb24bb8..09050d4 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt @@ -299,7 +299,11 @@ private fun rememberDashboardItems( DashboardCard( card = card, onClick = { onOpenService(card.service) }, - onOpenCcgoUsageDetail = if (card.service == ServiceType.COMMANDCODE_GO) onOpenCcgoUsageDetail else null + onOpenUsageDetail = when (card.service) { + ServiceType.OPENCODE_GO -> onOpenUsageDetail + ServiceType.COMMANDCODE_GO -> onOpenCcgoUsageDetail + else -> null + } ) }) } @@ -544,7 +548,7 @@ private fun DraggableDashboardCards( } @Composable -private fun DashboardCard(card: DashboardCardUi, onClick: () -> Unit, onOpenCcgoUsageDetail: (() -> Unit)? = null) { +private fun DashboardCard(card: DashboardCardUi, onClick: () -> Unit, onOpenUsageDetail: (() -> Unit)? = null) { Card( modifier = Modifier .fillMaxWidth() @@ -578,10 +582,10 @@ private fun DashboardCard(card: DashboardCardUi, onClick: () -> Unit, onOpenCcgo // ─── 主体:服务特定的主信息 ─── BalanceMainArea(card) - // CCGO 卡片底部:用量详情入口(仅当有凭证且非 CCGO 未配置状态时显示) - if (onOpenCcgoUsageDetail != null && card.credentialState != com.rainy.token.domain.model.CredentialStatus.State.NOT_CONFIGURED) { + // 用量服务卡片底部:用量详情入口(仅当有凭证时显示) + if (onOpenUsageDetail != null && card.credentialState != com.rainy.token.domain.model.CredentialStatus.State.NOT_CONFIGURED) { Spacer(modifier = Modifier.height(8.dp)) - TextButton(onClick = onOpenCcgoUsageDetail) { + TextButton(onClick = onOpenUsageDetail) { Text("查看用量详情", color = StrawberryPink) Spacer(modifier = Modifier.width(2.dp)) Icon( diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/UsageChartScreen.kt b/app/src/main/java/com/rainy/token/ui/dashboard/UsageChartScreen.kt index 12cd25e..c24607d 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/UsageChartScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/UsageChartScreen.kt @@ -64,6 +64,9 @@ import com.rainy.token.data.local.ChartGranularity import com.rainy.token.ui.theme.InkMuted import com.rainy.token.ui.theme.StrawberryPink import java.text.SimpleDateFormat +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneOffset import java.util.Date import java.util.Locale @@ -103,7 +106,7 @@ fun UsageChartScreen( var showCustomMonthPicker by remember { mutableStateOf(false) } var showCustomRangeStart by remember { mutableStateOf(false) } var showCustomRangeEnd by remember { mutableStateOf(false) } - var customRangeStartMs by remember { mutableStateOf(0L) } + var customRangeStartDate by remember { mutableStateOf(null) } var showCostDetail by remember { mutableStateOf(false) } var showReqDetail by remember { mutableStateOf(false) } var showTokenDetail by remember { mutableStateOf(false) } @@ -364,10 +367,7 @@ fun UsageChartScreen( DateTimePickerDialog( title = "选择日期", onConfirm = { ms -> - val utc = java.time.ZoneOffset.UTC - val dayStart = java.time.Instant.ofEpochMilli(ms).atOffset(utc).toLocalDate() - .atStartOfDay(utc).toInstant().toEpochMilli() - viewModel.setCustomDayRange(dayStart, dayStart + 86400_000L - 1) + viewModel.setCustomDay(ms.toUtcLocalDate()) showCustomDayPicker = false }, onDismiss = { showCustomDayPicker = false } @@ -377,7 +377,7 @@ fun UsageChartScreen( DateTimePickerDialog( title = "选择月份(任意一天)", onConfirm = { ms -> - viewModel.setCustomMonth(ms) + viewModel.setCustomMonth(ms.toUtcLocalDate()) showCustomMonthPicker = false }, onDismiss = { showCustomMonthPicker = false } @@ -387,7 +387,7 @@ fun UsageChartScreen( DateTimePickerDialog( title = "开始日期", onConfirm = { ms -> - customRangeStartMs = ms + customRangeStartDate = ms.toUtcLocalDate() showCustomRangeStart = false showCustomRangeEnd = true }, @@ -398,13 +398,12 @@ fun UsageChartScreen( DateTimePickerDialog( title = "结束日期", onConfirm = { ms -> - val utc = java.time.ZoneOffset.UTC - val startDay = java.time.Instant.ofEpochMilli(customRangeStartMs).atOffset(utc).toLocalDate() - .atStartOfDay(utc).toInstant().toEpochMilli() - val endDay = java.time.Instant.ofEpochMilli(ms).atOffset(utc).toLocalDate() - .atStartOfDay(utc).toInstant().toEpochMilli() + 86400_000L - 1 - viewModel.setCustomRange(startDay, endDay) - showCustomRangeEnd = false + val startDate = customRangeStartDate ?: return@DateTimePickerDialog + val endDate = ms.toUtcLocalDate() + if (!endDate.isBefore(startDate)) { + viewModel.setCustomRange(startDate, endDate) + showCustomRangeEnd = false + } }, onDismiss = { showCustomRangeEnd = false } ) @@ -486,6 +485,9 @@ fun UsageChartScreen( // 图表卡片容器 // ═══════════════════════════════════════════ +private fun Long.toUtcLocalDate(): LocalDate = + Instant.ofEpochMilli(this).atOffset(ZoneOffset.UTC).toLocalDate() + @Composable internal fun ChartCard( title: String, diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/UsageChartViewModel.kt b/app/src/main/java/com/rainy/token/ui/dashboard/UsageChartViewModel.kt index b7ce61b..352122d 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/UsageChartViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/UsageChartViewModel.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.time.Instant +import java.time.LocalDate import java.time.ZoneOffset import javax.inject.Inject import javax.inject.Provider @@ -157,33 +158,32 @@ class UsageChartViewModel @Inject constructor( } } } - - fun setCustomDayRange(from: Long, to: Long) { + fun setCustomDay(date: LocalDate) { _state.update { it.copy(granularity = ChartGranularity.CUSTOM_DAY_HOURLY) } - _customDayRange = from to to + _customDay = date load() } - fun setCustomMonth(monthStartMs: Long) { + fun setCustomMonth(monthDate: LocalDate) { _state.update { it.copy(granularity = ChartGranularity.CUSTOM_MONTH_DAILY) } - _customMonthStart = monthStartMs + _customMonth = monthDate.withDayOfMonth(1) load() } - fun setCustomRange(from: Long, to: Long) { + fun setCustomRange(fromDate: LocalDate, toDate: LocalDate) { + if (toDate.isBefore(fromDate)) return _state.update { it.copy(granularity = ChartGranularity.CUSTOM_RANGE_DAILY) } - _customRange = from to to + _customRange = fromDate to toDate load() } - private var _customDayRange: Pair = 0L to 0L - private var _customMonthStart: Long = 0L - private var _customRange: Pair = 0L to 0L + private var _customDay: LocalDate? = null + private var _customMonth: LocalDate? = null + private var _customRange: Pair? = null private fun timeRangeFor(g: ChartGranularity): Pair { val now = System.currentTimeMillis() - val useUtc8 = _state.value.useUtc8 - val zoneOffset = if (useUtc8) ZoneOffset.ofHours(8) else ZoneOffset.UTC + val zoneOffset = if (_state.value.useUtc8) ZoneOffset.ofHours(8) else ZoneOffset.UTC val today = Instant.ofEpochMilli(now).atOffset(zoneOffset).toLocalDate() val todayMidnight = today.atStartOfDay(zoneOffset).toInstant().toEpochMilli() return when (g) { @@ -192,37 +192,36 @@ class UsageChartViewModel @Inject constructor( ChartGranularity.LAST_24H_HOURLY -> now - 24 * 3600_000L to null ChartGranularity.TODAY_HOURLY -> todayMidnight to todayMidnight + 86400_000L - 1 ChartGranularity.YESTERDAY_HOURLY -> todayMidnight - 86400_000L to todayMidnight - 1 - ChartGranularity.LAST_7D_DAILY -> { - todayMidnight - 6 * 86400_000L to todayMidnight + 86400_000L - 1 - } - ChartGranularity.CUSTOM_DAY_HOURLY -> { - if (_customDayRange.first == 0L) todayMidnight to todayMidnight + 86400_000L - 1 - else _customDayRange.first to _customDayRange.second - } - ChartGranularity.THIS_MONTH_DAILY -> { - val firstOfMonth = today.withDayOfMonth(1).atStartOfDay(zoneOffset).toInstant().toEpochMilli() - val lastOfMonth = today.withDayOfMonth(today.lengthOfMonth()) - .atStartOfDay(zoneOffset).toInstant().toEpochMilli() + 86400_000L - 1 - firstOfMonth to lastOfMonth - } + ChartGranularity.LAST_7D_DAILY -> todayMidnight - 6 * 86400_000L to todayMidnight + 86400_000L - 1 + ChartGranularity.CUSTOM_DAY_HOURLY -> dayRange(_customDay ?: today, zoneOffset) + ChartGranularity.THIS_MONTH_DAILY -> monthRange(today.withDayOfMonth(1), zoneOffset) ChartGranularity.CUSTOM_MONTH_DAILY -> { - if (_customMonthStart == 0L) null to null - else { - val targetMonth = Instant.ofEpochMilli(_customMonthStart).atOffset(zoneOffset).toLocalDate() - .withDayOfMonth(1) - val firstOfMonth = targetMonth.atStartOfDay(zoneOffset).toInstant().toEpochMilli() - val lastOfMonth = targetMonth.withDayOfMonth(targetMonth.lengthOfMonth()) - .atStartOfDay(zoneOffset).toInstant().toEpochMilli() + 86400_000L - 1 - firstOfMonth to lastOfMonth - } + val month = _customMonth ?: return null to null + monthRange(month, zoneOffset) } ChartGranularity.CUSTOM_RANGE_DAILY -> { - if (_customRange.first == 0L) null to null - else _customRange.first to _customRange.second + val range = _customRange ?: return null to null + val from = range.first.atStartOfDay(zoneOffset).toInstant().toEpochMilli() + val to = range.second.plusDays(1).atStartOfDay(zoneOffset).toInstant().toEpochMilli() - 1 + from to to } } } + private fun dayRange(date: LocalDate, zoneOffset: ZoneOffset): Pair { + val from = date.atStartOfDay(zoneOffset).toInstant().toEpochMilli() + val to = date.plusDays(1).atStartOfDay(zoneOffset).toInstant().toEpochMilli() - 1 + return from to to + } + + private fun monthRange(month: LocalDate, zoneOffset: ZoneOffset): Pair { + val firstDay = month.withDayOfMonth(1) + val from = firstDay.atStartOfDay(zoneOffset).toInstant().toEpochMilli() + val to = firstDay.plusMonths(1).atStartOfDay(zoneOffset).toInstant().toEpochMilli() - 1 + return from to to + } + + private fun fillGaps( buckets: List, granularity: ChartGranularity, diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/UsageDetailScreen.kt b/app/src/main/java/com/rainy/token/ui/dashboard/UsageDetailScreen.kt index 610c612..3cf3526 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/UsageDetailScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/UsageDetailScreen.kt @@ -29,6 +29,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SelectableDates import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState @@ -93,7 +94,7 @@ fun UsageDetailScreen( var showCustomMonthPicker by remember { mutableStateOf(false) } var showCustomRangeStart by remember { mutableStateOf(false) } var showCustomRangeEnd by remember { mutableStateOf(false) } - var customRangeStartMs by remember { mutableStateOf(0L) } + var customRangeStartDate by remember { mutableStateOf(null) } var showCostDetail by remember { mutableStateOf(false) } var showReqDetail by remember { mutableStateOf(false) } var showTokenDetail by remember { mutableStateOf(false) } @@ -299,15 +300,33 @@ fun UsageDetailScreen( } } // 自定义日期选择器 - if (showCustomDayPicker) DateTimePickerDialog("选择日期", { ms -> - val utc = ZoneOffset.UTC; val ds = Instant.ofEpochMilli(ms).atOffset(utc).toLocalDate().atStartOfDay(utc).toInstant().toEpochMilli() - viewModel.setCustomDayRange(ds, ds + 86400_000L - 1); showCustomDayPicker = false }, { showCustomDayPicker = false }) - if (showCustomMonthPicker) DateTimePickerDialog("选择月份(任意一天)", { viewModel.setCustomMonth(it); showCustomMonthPicker = false }, { showCustomMonthPicker = false }) - if (showCustomRangeStart) DateTimePickerDialog("开始日期", { customRangeStartMs = it; showCustomRangeStart = false; showCustomRangeEnd = true }, { showCustomRangeStart = false }) - if (showCustomRangeEnd) DateTimePickerDialog("结束日期", { ms -> - 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 }) + if (showCustomDayPicker) DateOnlyPickerDialog("选择日期", { date -> + viewModel.setCustomDay(date) + showCustomDayPicker = false + }, { showCustomDayPicker = false }) + if (showCustomMonthPicker) DateOnlyPickerDialog("选择月份(任意一天)", { date -> + viewModel.setCustomMonth(date) + showCustomMonthPicker = false + }, { showCustomMonthPicker = false }) + if (showCustomRangeStart) DateOnlyPickerDialog("开始日期", { date -> + customRangeStartDate = date + showCustomRangeStart = false + showCustomRangeEnd = true + }, { showCustomRangeStart = false }) + if (showCustomRangeEnd) { + val startDate = customRangeStartDate + DateOnlyPickerDialog( + title = "结束日期", + onConfirm = { endDate -> + if (startDate != null && !endDate.isBefore(startDate)) { + viewModel.setCustomRange(startDate, endDate) + showCustomRangeEnd = false + } + }, + onDismiss = { showCustomRangeEnd = false }, + minDate = startDate + ) + } 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)}") } @@ -423,6 +442,63 @@ internal fun CustomTimeRangeRow( } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DateOnlyPickerDialog( + title: String, + onConfirm: (LocalDate) -> Unit, + onDismiss: () -> Unit, + minDate: LocalDate? = null +) { + val utc = remember { ZoneOffset.UTC } + val minDateStartMs = remember(minDate) { minDate?.atStartOfDay(utc)?.toInstant()?.toEpochMilli() } + val selectableDates = remember(minDateStartMs) { + object : SelectableDates { + override fun isSelectableDate(utcTimeMillis: Long): Boolean { + return minDateStartMs == null || utcTimeMillis >= minDateStartMs + } + } + } + val dateState = rememberDatePickerState(selectableDates = selectableDates) + + DatePickerDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton( + onClick = { + val millis = dateState.selectedDateMillis ?: return@TextButton + onConfirm(Instant.ofEpochMilli(millis).atOffset(utc).toLocalDate()) + }, + enabled = dateState.selectedDateMillis != null + ) { + Text("确定") + } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } } + ) { + Column { + Text( + text = title, + modifier = Modifier.padding(start = 24.dp, top = 20.dp, end = 24.dp), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold + ) + if (minDate != null) { + Text( + text = "结束日期不能早于开始日期 ${minDate.formatDateLabel()}", + modifier = Modifier.padding(start = 24.dp, top = 8.dp, end = 24.dp), + style = MaterialTheme.typography.bodySmall, + color = InkMuted + ) + } + DatePicker(state = dateState) + } + } +} + +private fun LocalDate.formatDateLabel(): String = + String.format(Locale.US, "%04d-%02d-%02d", year, monthValue, dayOfMonth) + @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun DateTimePickerDialog(