fix: 修复自定义日期时区并统一用量入口

This commit is contained in:
WaterRain 2026-07-01 21:06:47 +00:00
parent 1d98d4dee1
commit 45c8f71b8a
No known key found for this signature in database
4 changed files with 146 additions and 65 deletions

View File

@ -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(

View File

@ -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<LocalDate?>(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)
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,

View File

@ -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<Long, Long> = 0L to 0L
private var _customMonthStart: Long = 0L
private var _customRange: Pair<Long, Long> = 0L to 0L
private var _customDay: LocalDate? = null
private var _customMonth: LocalDate? = null
private var _customRange: Pair<LocalDate, LocalDate>? = null
private fun timeRangeFor(g: ChartGranularity): Pair<Long?, Long?> {
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<Long, Long> {
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<Long, Long> {
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<ChartBucket>,
granularity: ChartGranularity,

View File

@ -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<LocalDate?>(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(