Просмотр исходного кода

[优化] 增加书籍详情界面的阅读历史

HapeLee 6 месяцев назад
Родитель
Сommit
85b90f77f7

+ 6 - 0
app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecordTimelineDay.kt

@@ -0,0 +1,6 @@
+package io.legado.app.data.entities.readRecord
+
+data class ReadRecordTimelineDay(
+    val date: String,
+    val sessions: List<ReadRecordSession>
+)

+ 6 - 0
app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt

@@ -4,6 +4,7 @@ import android.net.Uri
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookSource
+import io.legado.app.data.entities.readRecord.ReadRecordTimelineDay
 
 data class BookInfoUiState(
     val book: Book? = null,
@@ -12,6 +13,8 @@ data class BookInfoUiState(
     val kindLabels: List<String> = emptyList(),
     val groupNames: String? = null,
     val hasCustomGroup: Boolean = false,
+    val readRecordTotalTime: Long = 0L,
+    val readRecordTimelineDays: List<ReadRecordTimelineDay> = emptyList(),
     val inBookshelf: Boolean = false,
     val bookSource: BookSource? = null,
     val isTocLoading: Boolean = true,
@@ -26,6 +29,7 @@ sealed interface BookInfoSheet {
     data object CoverPicker : BookInfoSheet
     data object GroupPicker : BookInfoSheet
     data object SourcePicker : BookInfoSheet
+    data object ReadRecord : BookInfoSheet
     data class WebFiles(val openAfterImport: Boolean) : BookInfoSheet
     data class ArchiveEntries(
         val archiveUri: Uri,
@@ -68,6 +72,7 @@ sealed interface BookInfoIntent {
     data object CoverLongClick : BookInfoIntent
     data object GroupClick : BookInfoIntent
     data object ChangeSourceClick : BookInfoIntent
+    data object ReadRecordClick : BookInfoIntent
     data object RemarkClick : BookInfoIntent
     data object ConfirmBackAddToShelf : BookInfoIntent
     data class ConfirmDelete(val deleteOriginal: Boolean) : BookInfoIntent
@@ -141,6 +146,7 @@ enum class BookInfoMenuAction {
     Upload,
     SyncRemote,
     Refresh,
+    ReadRecord,
     Login,
     Top,
     SetSourceVariable,

+ 164 - 0
app/src/main/java/io/legado/app/ui/book/info/BookInfoReadRecordSheet.kt

@@ -0,0 +1,164 @@
+package io.legado.app.ui.book.info
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Timeline
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import cn.hutool.core.date.DateUtil
+import io.legado.app.R
+import io.legado.app.data.entities.readRecord.ReadRecordSession
+import io.legado.app.data.entities.readRecord.ReadRecordTimelineDay
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.EmptyMessage
+import io.legado.app.ui.widget.components.card.GlassCard
+import io.legado.app.ui.widget.components.card.TextCard
+import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
+import io.legado.app.ui.widget.components.text.AppText
+import io.legado.app.utils.formatReadDuration
+import java.util.Date
+
+@Composable
+fun BookReadRecordSheet(
+    show: Boolean,
+    totalReadTime: Long,
+    timelineDays: List<ReadRecordTimelineDay>,
+    onDismissRequest: () -> Unit,
+) {
+    AppModalBottomSheet(
+        show = show,
+        onDismissRequest = onDismissRequest,
+        title = stringResource(R.string.read_record),
+    ) {
+        GlassCard(
+            modifier = Modifier.fillMaxWidth(),
+            containerColor = LegadoTheme.colorScheme.surfaceContainerLow
+        ) {
+            Row(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .padding(horizontal = 16.dp, vertical = 14.dp),
+                verticalAlignment = Alignment.CenterVertically,
+                horizontalArrangement = Arrangement.spacedBy(12.dp)
+            ) {
+                Icon(
+                    imageVector = Icons.Default.Timeline,
+                    contentDescription = null,
+                    tint = LegadoTheme.colorScheme.primary
+                )
+                Column {
+                    AppText(
+                        text = stringResource(R.string.all_read_time),
+                        style = LegadoTheme.typography.labelMedium,
+                        color = LegadoTheme.colorScheme.primary
+                    )
+                    AppText(
+                        text = formatReadDuration(totalReadTime),
+                        style = LegadoTheme.typography.titleLarge,
+                        fontWeight = FontWeight.Bold
+                    )
+                }
+            }
+        }
+        Spacer(modifier = Modifier.height(12.dp))
+        if (timelineDays.isEmpty()) {
+            Box(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .padding(vertical = 24.dp),
+                contentAlignment = Alignment.Center
+            ) {
+                EmptyMessage(message = stringResource(R.string.empty))
+            }
+        } else {
+            LazyColumn(
+                modifier = Modifier.fillMaxWidth(),
+                verticalArrangement = Arrangement.spacedBy(4.dp)
+            ) {
+                timelineDays.forEach { day ->
+                    item(key = "header_${day.date}") {
+                        AppText(
+                            text = day.date,
+                            style = LegadoTheme.typography.titleSmall,
+                            color = LegadoTheme.colorScheme.primary,
+                            fontWeight = FontWeight.SemiBold,
+                            modifier = Modifier.padding(top = 8.dp, bottom = 4.dp)
+                        )
+                    }
+                    items(day.sessions, key = { it.id }) { session ->
+                        TimelineSessionRow(session = session)
+                    }
+                }
+            }
+        }
+        Spacer(modifier = Modifier.height(16.dp))
+    }
+}
+
+@Composable
+private fun TimelineSessionRow(session: ReadRecordSession) {
+    val lineColor = LegadoTheme.colorScheme.surfaceContainerHigh
+    val nodeColor = LegadoTheme.colorScheme.primary
+    val duration = (session.endTime - session.startTime).coerceAtLeast(0L)
+    Row(
+        modifier = Modifier
+            .fillMaxWidth()
+            .drawBehind {
+                val x = 12.dp.toPx()
+                val centerY = size.height / 2f
+                drawLine(
+                    color = lineColor,
+                    start = Offset(x, 0f),
+                    end = Offset(x, size.height),
+                    strokeWidth = 2.dp.toPx()
+                )
+                drawCircle(
+                    color = nodeColor,
+                    radius = 4.dp.toPx(),
+                    center = Offset(x, centerY)
+                )
+            }
+            .padding(start = 28.dp, top = 6.dp, bottom = 6.dp),
+        verticalAlignment = Alignment.CenterVertically
+    ) {
+        Spacer(modifier = Modifier.width(8.dp))
+        Column(modifier = Modifier.weight(1f)) {
+            AppText(
+                text = DateUtil.format(Date(session.endTime), "HH:mm"),
+                style = LegadoTheme.typography.bodyMedium,
+                fontWeight = FontWeight.SemiBold
+            )
+            AppText(
+                text = formatReadDuration(duration),
+                style = LegadoTheme.typography.bodySmall,
+                color = LegadoTheme.colorScheme.onSurfaceVariant
+            )
+        }
+        if (session.words > 0) {
+            TextCard(
+                text = "第${session.words}章",
+                textStyle = LegadoTheme.typography.labelSmall,
+                backgroundColor = LegadoTheme.colorScheme.secondaryContainer,
+                contentColor = LegadoTheme.colorScheme.onSecondaryContainer,
+            )
+        }
+    }
+}

+ 34 - 15
app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt

@@ -33,7 +33,6 @@ import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.automirrored.outlined.FormatListBulleted
 import androidx.compose.material.icons.filled.Add
 import androidx.compose.material.icons.filled.Book
-import androidx.compose.material.icons.filled.Bookmark
 import androidx.compose.material.icons.filled.BookmarkAdd
 import androidx.compose.material.icons.filled.Code
 import androidx.compose.material.icons.filled.Edit
@@ -41,9 +40,8 @@ import androidx.compose.material.icons.filled.MoreVert
 import androidx.compose.material.icons.filled.Refresh
 import androidx.compose.material.icons.filled.Share
 import androidx.compose.material.icons.filled.Star
+import androidx.compose.material.icons.filled.Timeline
 import androidx.compose.material.icons.outlined.Book
-import androidx.compose.material.icons.outlined.Bookmark
-import androidx.compose.material.icons.outlined.BookmarkBorder
 import androidx.compose.material.icons.outlined.CollectionsBookmark
 import androidx.compose.material.icons.outlined.FolderZip
 import androidx.compose.material.icons.outlined.Image
@@ -265,12 +263,12 @@ private fun BookInfoScreenContent(
                                     .padding(bottom = 24.dp)
                             ) {
                                 BookInfoActions(
-                                    hasCustomGroup = state.hasCustomGroup,
                                     inBookshelf = state.inBookshelf,
                                     onShelfClick = { onIntent(BookInfoIntent.ShelfClick) },
                                     onTocClick = { onIntent(BookInfoIntent.TocClick) },
                                     onGroupClick = { onIntent(BookInfoIntent.GroupClick) },
                                     onSourceClick = { onIntent(BookInfoIntent.ChangeSourceClick) },
+                                    onReadRecordClick = { onIntent(BookInfoIntent.ReadRecordClick) },
                                 )
                                 BookInfoSummary(
                                     book = book,
@@ -325,6 +323,12 @@ private fun BookInfoScreenContent(
                 },
             )
         }
+        BookInfoSheet.ReadRecord -> BookReadRecordSheet(
+            show = currentSheet == BookInfoSheet.ReadRecord,
+            totalReadTime = state.readRecordTotalTime,
+            timelineDays = state.readRecordTimelineDays,
+            onDismissRequest = { onIntent(BookInfoIntent.DismissSheet) },
+        )
         is BookInfoSheet.WebFiles -> WebFileSheet(
             show = currentSheet is BookInfoSheet.WebFiles,
             files = state.webFiles,
@@ -551,6 +555,10 @@ private fun BookInfoOverflowMenu(
             text = stringResource(R.string.refresh),
             onClick = { onMenuAction(BookInfoMenuAction.Refresh) }
         )
+        RoundDropdownMenuItem(
+            text = stringResource(R.string.read_record),
+            onClick = { onMenuAction(BookInfoMenuAction.ReadRecord) }
+        )
         if (book?.isLocal == true) {
             RoundDropdownMenuItem(
                 text = stringResource(R.string.re_sync_webdav),
@@ -736,31 +744,38 @@ private fun BookInfoHeader(
 
 @Composable
 private fun BookInfoActions(
-    hasCustomGroup: Boolean,
     inBookshelf: Boolean,
     onShelfClick: () -> Unit,
     onTocClick: () -> Unit,
     onGroupClick: () -> Unit,
     onSourceClick: () -> Unit,
+    onReadRecordClick: () -> Unit,
 ) {
     var awaitingShelfAddition by rememberSaveable { mutableStateOf(false) }
     var showShelfRemoveHint by rememberSaveable { mutableStateOf(false) }
+    var showLongPressGroupHint by rememberSaveable { mutableStateOf(false) }
 
-    LaunchedEffect(inBookshelf, awaitingShelfAddition) {
+    LaunchedEffect(inBookshelf) {
         if (awaitingShelfAddition && inBookshelf) {
             awaitingShelfAddition = false
             showShelfRemoveHint = true
             delay(1000)
             showShelfRemoveHint = false
+            showLongPressGroupHint = true
+            delay(1000)
+            showLongPressGroupHint = false
         } else if (!inBookshelf) {
+            awaitingShelfAddition = false
             showShelfRemoveHint = false
+            showLongPressGroupHint = false
         }
     }
 
     val shelfLabel = when {
-        !inBookshelf -> stringResource(R.string.add_to_bookshelf)
         showShelfRemoveHint -> stringResource(R.string.click_to_remove)
-        else -> stringResource(R.string.remove_from_bookshelf)
+        showLongPressGroupHint -> stringResource(R.string.long_press_group)
+        inBookshelf -> stringResource(R.string.already_in_bookshelf)
+        else -> stringResource(R.string.add_to_bookshelf)
     }
 
     Row(
@@ -774,12 +789,14 @@ private fun BookInfoActions(
             modifier = Modifier.weight(1f),
             icon = if (inBookshelf) Icons.Outlined.Book else Icons.Default.BookmarkAdd,
             label = shelfLabel,
+            onLongClick = onGroupClick,
             onClick = {
                 if (!inBookshelf) {
                     awaitingShelfAddition = true
                 } else {
                     awaitingShelfAddition = false
                     showShelfRemoveHint = false
+                    showLongPressGroupHint = false
                 }
                 onShelfClick()
             },
@@ -790,18 +807,18 @@ private fun BookInfoActions(
             label = stringResource(R.string.view_toc),
             onClick = onTocClick
         )
-        BookInfoActionCard(
-            modifier = Modifier.weight(1f),
-            icon = if (hasCustomGroup) Icons.Default.Bookmark else Icons.Outlined.BookmarkBorder,
-            label = stringResource(R.string.change_group),
-            onClick = onGroupClick
-        )
         BookInfoActionCard(
             modifier = Modifier.weight(1f),
             icon = Icons.Default.Code,
             label = stringResource(R.string.book_source),
             onClick = onSourceClick
         )
+        BookInfoActionCard(
+            modifier = Modifier.weight(1f),
+            icon = Icons.Default.Timeline,
+            label = stringResource(R.string.read_record),
+            onClick = onReadRecordClick
+        )
     }
 }
 
@@ -810,12 +827,14 @@ private fun BookInfoActionCard(
     modifier: Modifier = Modifier,
     icon: ImageVector,
     label: String,
+    onLongClick: (() -> Unit)? = null,
     onClick: () -> Unit
 ) {
     GlassCard(
         modifier = modifier,
+        onLongClick = onLongClick,
         onClick = onClick,
-        containerColor = LegadoTheme.colorScheme.surfaceContainerLow.copy(alpha = 0.2f),
+        containerColor = LegadoTheme.colorScheme.surfaceContainerLow,
         contentColor = LegadoTheme.colorScheme.onSurface,
     ) {
         Column(

+ 33 - 13
app/src/main/java/io/legado/app/ui/book/info/BookInfoSheets.kt

@@ -398,8 +398,8 @@ fun ChangeSourceSheet(
                 }.collectAsStateWithLifecycle()
                 SelectionItemCard(
                     title = item.originName,
-                    containerColor = LegadoTheme.colorScheme.surfaceContainer,
-                    selectedContainerColor = LegadoTheme.colorScheme.primaryContainer.copy(alpha = 0.2f),
+                    containerColor = LegadoTheme.colorScheme.onSheetContent,
+                    selectedContainerColor = LegadoTheme.colorScheme.primaryContainer.copy(alpha = 0.32f),
                     leadingContent = {
                         MediumIconButton(
                             onClick = {
@@ -503,15 +503,35 @@ fun ChangeSourceSheet(
         })
     }
 
-    if (mismatchBook != null) {
-        AppAlertDialog(show = true, onDismissRequest = { mismatchBook = null }, title = stringResource(R.string.book_type_different), text = stringResource(R.string.soure_change_source), confirmText = stringResource(android.R.string.ok), onConfirm = { actionBook = mismatchBook; mismatchBook = null }, dismissText = stringResource(android.R.string.cancel), onDismiss = { mismatchBook = null })
-    }
-    actionBook?.let { searchBook ->
-        AppAlertDialog(show = true, onDismissRequest = { actionBook = null }, title = stringResource(R.string.change_source_option_title), dismissText = stringResource(R.string.add_as_new_book), onDismiss = { performAction(searchBook, false) }, confirmText = stringResource(R.string.replace_current_book), onConfirm = { performAction(searchBook, true) })
-    }
-    if (loadingAction) {
-        AppAlertDialog(show = true, onDismissRequest = {}, content = {
-            Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
-        })
-    }
+    AppAlertDialog(
+        data = mismatchBook,
+        onDismissRequest = { mismatchBook = null },
+        title = stringResource(R.string.book_type_different),
+        text = stringResource(R.string.soure_change_source),
+        confirmText = stringResource(android.R.string.ok),
+        onConfirm = { searchBook ->
+            actionBook = searchBook
+            mismatchBook = null
+        },
+        dismissText = stringResource(android.R.string.cancel),
+        onDismiss = { mismatchBook = null }
+    )
+    AppAlertDialog(
+        data = actionBook,
+        onDismissRequest = { actionBook = null },
+        title = stringResource(R.string.change_source_option_title),
+        dismissText = stringResource(R.string.add_as_new_book),
+        onDismiss = { actionBook?.let { performAction(it, false) } },
+        confirmText = stringResource(R.string.replace_current_book),
+        onConfirm = { performAction(it, true) }
+    )
+    AppAlertDialog(
+        show = loadingAction,
+        onDismissRequest = {},
+        content = {
+            Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
+                CircularProgressIndicator()
+            }
+        }
+    )
 }

+ 56 - 1
app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt

@@ -15,6 +15,8 @@ import io.legado.app.data.appDb
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookSource
+import io.legado.app.data.entities.readRecord.ReadRecordTimelineDay
+import io.legado.app.data.repository.ReadRecordRepository
 import io.legado.app.data.repository.RemoteBookRepository
 import io.legado.app.exception.NoBooksDirException
 import io.legado.app.exception.NoStackTraceException
@@ -48,15 +50,20 @@ import io.legado.app.utils.postEvent
 import io.legado.app.utils.toastOnUi
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers.IO
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.collectLatest
 import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
 
 class BookInfoViewModel(
     application: Application,
-    private val remoteBookRepository: RemoteBookRepository
+    private val remoteBookRepository: RemoteBookRepository,
+    private val readRecordRepository: ReadRecordRepository
 ) : BaseViewModel(application) {
 
     private val _uiState = MutableStateFlow(BookInfoUiState())
@@ -66,11 +73,18 @@ class BookInfoViewModel(
     val effects = _effects.asSharedFlow()
 
     private var currentBook: Book? = null
+        set(value) {
+            field = value
+            observeReadRecordIfNeeded(value)
+        }
     private var currentChapterList: List<BookChapter> = emptyList()
     private var currentWebFiles: List<BookInfoWebFile> = emptyList()
     private var currentKindLabels: List<String> = emptyList()
     private var currentGroupNames: String? = null
     private var currentHasCustomGroup = false
+    private var currentReadRecordTotalTime = 0L
+    private var currentReadRecordTimelineDays: List<ReadRecordTimelineDay> = emptyList()
+    private var observingReadRecordKey: String? = null
     private var chapterChanged = false
 
     var inBookshelf = false
@@ -79,6 +93,7 @@ class BookInfoViewModel(
         private set
 
     private var changeSourceCoroutine: Coroutine<*>? = null
+    private var readRecordObserveJob: Job? = null
 
     fun initData(intent: Intent) {
         if (currentBook != null) return
@@ -123,6 +138,7 @@ class BookInfoViewModel(
 
             BookInfoIntent.GroupClick -> setSheet(BookInfoSheet.GroupPicker)
             BookInfoIntent.ChangeSourceClick -> setSheet(BookInfoSheet.SourcePicker)
+            BookInfoIntent.ReadRecordClick -> setSheet(BookInfoSheet.ReadRecord)
             BookInfoIntent.RemarkClick -> showDialog(BookInfoDialog.EditRemark(currentBook?.remark))
             BookInfoIntent.ConfirmBackAddToShelf -> {
                 dismissDialog()
@@ -924,6 +940,7 @@ class BookInfoViewModel(
             }
             BookInfoMenuAction.SyncRemote -> syncFromRemote()
             BookInfoMenuAction.Refresh -> refreshCurrentBook()
+            BookInfoMenuAction.ReadRecord -> setSheet(BookInfoSheet.ReadRecord)
             BookInfoMenuAction.Login -> bookSource?.let {
                 emitEffect(BookInfoEffect.OpenSourceLogin(it.bookSourceUrl))
             }
@@ -1081,6 +1098,42 @@ class BookInfoViewModel(
         }
     }
 
+    private fun observeReadRecordIfNeeded(book: Book?) {
+        if (book == null) {
+            clearReadRecordObserve()
+            return
+        }
+        val key = "${book.name}|||${book.author}"
+        if (observingReadRecordKey == key && readRecordObserveJob?.isActive == true) return
+        observingReadRecordKey = key
+        readRecordObserveJob?.cancel()
+        readRecordObserveJob = viewModelScope.launch {
+            combine(
+                readRecordRepository.getBookReadTime(book.name, book.author),
+                readRecordRepository.getBookTimelineDays(book.name, book.author)
+            ) { totalTime, timelineDays ->
+                totalTime to timelineDays
+            }.collectLatest { (totalTime, timelineDays) ->
+                currentReadRecordTotalTime = totalTime
+                currentReadRecordTimelineDays = timelineDays
+                _uiState.update {
+                    it.copy(
+                        readRecordTotalTime = currentReadRecordTotalTime,
+                        readRecordTimelineDays = currentReadRecordTimelineDays
+                    )
+                }
+            }
+        }
+    }
+
+    private fun clearReadRecordObserve() {
+        readRecordObserveJob?.cancel()
+        readRecordObserveJob = null
+        observingReadRecordKey = null
+        currentReadRecordTotalTime = 0L
+        currentReadRecordTimelineDays = emptyList()
+    }
+
     private fun dismissSheet() {
         setSheet(BookInfoSheet.None)
     }
@@ -1110,6 +1163,8 @@ class BookInfoViewModel(
                 kindLabels = currentKindLabels,
                 groupNames = currentGroupNames,
                 hasCustomGroup = currentHasCustomGroup,
+                readRecordTotalTime = currentReadRecordTotalTime,
+                readRecordTimelineDays = currentReadRecordTimelineDays,
                 inBookshelf = inBookshelf,
                 bookSource = bookSource,
                 isTocLoading = isTocLoading,

+ 13 - 0
app/src/main/java/io/legado/app/utils/ReadRecordTimeFormatter.kt

@@ -0,0 +1,13 @@
+package io.legado.app.utils
+
+fun formatReadDuration(millis: Long): String {
+    val days = millis / (1000 * 60 * 60 * 24)
+    val hours = millis % (1000 * 60 * 60 * 24) / (1000 * 60 * 60)
+    val minutes = millis % (1000 * 60 * 60) / (1000 * 60)
+    val seconds = millis % (1000 * 60) / 1000
+    val d = if (days > 0) "${days}天" else ""
+    val h = if (hours > 0) "${hours}小时" else ""
+    val m = if (minutes > 0) "${minutes}分钟" else ""
+    val s = if (seconds > 0) "${seconds}秒" else ""
+    return if ("$d$h$m$s".isBlank()) "0秒" else "$d$h$m$s"
+}