HapeLee 2 месяцев назад
Родитель
Сommit
8eda494462

+ 1 - 1
app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecordSession.kt

@@ -18,7 +18,7 @@ data class ReadRecordSession(
     val startTime: Long = 0,
     val endTime: Long = 0,
 
-    // 本次阅读的字数
+    // 本次阅读时所在的章节序号(durChapterIndex),用于时间线定位章节标题;不是字数
     val words: Long = 0
 ) {
     /** 跨设备稳定身份,不依赖 Room 自动生成的数据库行 ID。 */

+ 7 - 3
app/src/main/java/io/legado/app/domain/usecase/readRecord/GetReadRecordOverviewUseCase.kt

@@ -35,8 +35,13 @@ class GetReadRecordOverviewUseCase {
             }
         }
 
-        val totalTime = filteredDetails.sumOf { it.readTime }
-        val totalWords = filteredDetails.sumOf { it.readWords }
+        // 阅读时间口径与阅读记录页/首页一致:「总」模式使用合并后的 readRecord 汇总
+        // (包含无每日详情来源的旧版遗留时长),周期模式只能按有日期的详情统计。
+        val totalTime = if (period == ReadPeriod.ALL) {
+            latestRecords.sumOf { it.readTime }
+        } else {
+            filteredDetails.sumOf { it.readTime }
+        }
         val readingDays = filteredDetails.map { it.date }.distinct().size
 
         val periodBooks = filteredDetails.groupBy { it.bookName to it.bookAuthor }
@@ -139,7 +144,6 @@ class GetReadRecordOverviewUseCase {
             totalBooks = totalBooks,
             finishedBooks = finishedCount,
             readingBooks = readingCount,
-            totalWords = totalWords,
             dailyTimeData = dailyTimeData,
             topBooks = topBooks,
             dailyTopBook = dailyTopBookMap,

+ 0 - 8
app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordFormatter.kt

@@ -6,14 +6,6 @@ import kotlin.time.Duration.Companion.milliseconds
 object ReadRecordFormatter {
     data class HourMinuteDuration(val hours: Long, val minutes: Int)
 
-    fun formatWords(words: Long): String {
-        return if (words >= 10000) {
-            String.format("%.1f万字", words / 10000f)
-        } else {
-            "${words}字"
-        }
-    }
-
     fun formatDuration(millis: Long): String = formatReadDuration(millis)
 
     fun hourMinuteDuration(millis: Long): HourMinuteDuration =

+ 4 - 2
app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordOverviewScreen.kt

@@ -167,8 +167,10 @@ fun ReadRecordOverviewScreen(
                         StatItem(stringResource(R.string.reading_days), stringResource(R.string.days_format, state.readingDays)),
                         StatItem(stringResource(R.string.total_read_books), stringResource(R.string.books_format, state.totalBooks)),
                         StatItem(stringResource(R.string.finished_books), stringResource(R.string.books_format, state.finishedBooks)),
-                        StatItem(stringResource(R.string.reading_books), stringResource(R.string.books_format, state.readingBooks)),
-                        StatItem(stringResource(R.string.reading_words), ReadRecordFormatter.formatWords(state.totalWords))
+                        StatItem(
+                            stringResource(R.string.reading_books),
+                            stringResource(R.string.books_format, state.readingBooks)
+                        )
                     )
                     StatsGridCard(title = stringResource(R.string.reading_data), items = stats)
                 }

+ 1 - 2
app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordOverviewViewModel.kt

@@ -1,7 +1,7 @@
 package io.legado.app.ui.book.readRecord
 
-import androidx.lifecycle.ViewModel
 import androidx.compose.runtime.Stable
+import androidx.lifecycle.ViewModel
 import androidx.lifecycle.viewModelScope
 import io.legado.app.data.repository.BookRepository
 import io.legado.app.data.repository.ReadRecordRepository
@@ -22,7 +22,6 @@ data class ReadRecordOverviewUiState(
     val totalBooks: Int = 0,
     val finishedBooks: Int = 0,
     val readingBooks: Int = 0,
-    val totalWords: Long = 0,
     val dailyTimeData: List<Pair<LocalDate, Long>> = emptyList(),
     val topBooks: List<ReadBookRanking> = emptyList(),
     val dailyTopBook: Map<LocalDate, Pair<String, String>> = emptyMap(),

+ 0 - 90
app/src/main/java/io/legado/app/ui/book/readRecord/component/ReadRecordHeatmap.kt

@@ -1,90 +0,0 @@
-package io.legado.app.ui.book.readRecord.component
-
-import androidx.compose.foundation.layout.*
-import androidx.compose.foundation.lazy.LazyRow
-import androidx.compose.foundation.lazy.items
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.unit.dp
-import io.legado.app.ui.theme.LegadoTheme
-import io.legado.app.ui.widget.components.heatmap.*
-import io.legado.app.ui.widget.components.text.AppText
-import java.time.LocalDate
-
-@Composable
-fun HeatmapCalendarSection(
-    modifier: Modifier = Modifier,
-    dailyReadCounts: Map<LocalDate, Int>,
-    dailyReadTimes: Map<LocalDate, Long>,
-    currentMode: HeatmapMode,
-    selectedDate: LocalDate?,
-    onDateSelected: (LocalDate) -> Unit,
-    config: HeatmapConfig = HeatmapConfig()
-) {
-    val (startDate, endDate) = rememberDateRange(dailyReadCounts, dailyReadTimes)
-    val days = rememberDaysInRange(startDate, endDate)
-    val weeks = rememberWeeks(days, startDate)
-
-    Column(
-        modifier = modifier.fillMaxWidth(),
-        horizontalAlignment = Alignment.Start
-    ) {
-        Row(
-            modifier = Modifier.fillMaxWidth(),
-            verticalAlignment = Alignment.CenterVertically
-        ) {
-            AppText(
-                text = heatmapCalendarTitle(),
-                style = LegadoTheme.typography.titleSmall,
-                color = LegadoTheme.colorScheme.onSurfaceVariant
-            )
-            Spacer(modifier = Modifier.weight(1f))
-            HeatmapLegend(mode = currentMode, config = config)
-        }
-
-        Spacer(modifier = Modifier.height(12.dp))
-
-        Row(modifier = Modifier.fillMaxWidth()) {
-            WeekdayLabelsColumn(
-                cellSize = config.interactiveCellSize,
-                cellSpacing = config.cellSpacing
-            )
-            Spacer(modifier = Modifier.width(4.dp))
-            LazyRow(
-                modifier = Modifier.weight(1f),
-                horizontalArrangement = Arrangement.spacedBy(config.cellSpacing),
-                reverseLayout = true
-            ) {
-                item {
-                    HeatmapCalendarEndAction(
-                        onClearDate = { onDateSelected(LocalDate.now()) }
-                    )
-                }
-                items(weeks) { week ->
-                    HeatmapWeekColumn(
-                        week = week,
-                        mode = currentMode,
-                        dailyReadCounts = dailyReadCounts,
-                        dailyReadTimes = dailyReadTimes,
-                        selectedDate = selectedDate,
-                        config = config,
-                        onDateSelected = onDateSelected
-                    )
-                }
-                item {
-                    HeatmapCalendarStartAction(
-                        currentMode = currentMode,
-                        onModeChanged = {}
-                    )
-                }
-                item {
-                    NoEarlierDataIndicator(
-                        cellSize = config.cellSize,
-                        touchTargetSize = config.interactiveCellSize,
-                    )
-                }
-            }
-        }
-    }
-}

+ 0 - 218
app/src/main/java/io/legado/app/ui/book/readRecord/component/ReadRecordListItems.kt

@@ -1,218 +0,0 @@
-package io.legado.app.ui.book.readRecord.component
-
-import androidx.compose.foundation.basicMarquee
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Arrangement
-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.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.width
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.text.buildAnnotatedString
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.compose.ui.unit.dp
-import io.legado.app.data.entities.readRecord.ReadRecord
-import io.legado.app.data.entities.readRecord.ReadRecordDetail
-import io.legado.app.ui.book.readRecord.ReadRecordFormatter
-import io.legado.app.ui.book.readRecord.ReadRecordViewModel
-import io.legado.app.ui.book.readRecord.TimelineItem
-import io.legado.app.ui.theme.LegadoTheme
-import io.legado.app.ui.widget.components.image.cover.CoilBookCover
-import io.legado.app.ui.widget.components.text.AppText
-import io.legado.app.utils.StringUtils.formatFriendlyDate
-
-@Composable
-fun LatestReadItem(
-    record: ReadRecord,
-    viewModel: ReadRecordViewModel,
-    onClick: () -> Unit,
-    modifier: Modifier = Modifier
-) {
-    var coverPath by remember { mutableStateOf<String?>(null) }
-    LaunchedEffect(record.bookName, record.bookAuthor) {
-        coverPath = viewModel.getBookCover(record.bookName, record.bookAuthor)
-    }
-
-    Row(
-        modifier = modifier
-            .fillMaxWidth()
-            .clickable { onClick() }
-            .padding(16.dp),
-        verticalAlignment = Alignment.CenterVertically
-    ) {
-        CoilBookCover(
-            name = record.bookName,
-            author = record.bookAuthor,
-            path = coverPath,
-            modifier = Modifier.size(48.dp, 64.dp)
-        )
-        Spacer(modifier = Modifier.width(16.dp))
-        Column(modifier = Modifier.weight(1f)) {
-            AppText(
-                text = record.bookName,
-                style = LegadoTheme.typography.titleMedium,
-                fontWeight = FontWeight.Bold,
-                maxLines = 1,
-                modifier = Modifier.basicMarquee()
-            )
-            AppText(
-                text = record.bookAuthor,
-                style = LegadoTheme.typography.labelSmall,
-                color = LegadoTheme.colorScheme.onSurfaceVariant
-            )
-            AppText(
-                text = "最后阅读: ${formatFriendlyDate(record.lastRead.toString())}",
-                style = LegadoTheme.typography.labelSmall,
-                color = LegadoTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
-            )
-        }
-        AppText(
-            text = ReadRecordFormatter.formatDuration(record.readTime),
-            style = LegadoTheme.typography.bodyMedium,
-            color = LegadoTheme.colorScheme.primary,
-            fontWeight = FontWeight.SemiBold
-        )
-    }
-}
-
-@Composable
-fun TimelineSessionItem(
-    item: TimelineItem,
-    viewModel: ReadRecordViewModel,
-    onBookClick: (String, String) -> Unit
-) {
-    val session = item.session
-    var coverPath by remember { mutableStateOf<String?>(null) }
-
-    LaunchedEffect(session.bookName, session.bookAuthor) {
-        coverPath = viewModel.getBookCover(session.bookName, session.bookAuthor)
-    }
-
-    Row(
-        modifier = Modifier
-            .fillMaxWidth()
-            .clickable { onBookClick(session.bookName, session.bookAuthor) }
-            .padding(horizontal = 16.dp, vertical = 12.dp),
-        verticalAlignment = Alignment.CenterVertically
-    ) {
-        CoilBookCover(
-            name = session.bookName,
-            author = session.bookAuthor,
-            path = coverPath,
-            modifier = Modifier.size(40.dp, 56.dp)
-        )
-        Spacer(modifier = Modifier.width(12.dp))
-        Column(modifier = Modifier.weight(1f)) {
-            AppText(
-                text = session.bookName,
-                style = LegadoTheme.typography.bodyMedium,
-                fontWeight = FontWeight.Bold,
-                maxLines = 1,
-                overflow = TextOverflow.Ellipsis
-            )
-            AppText(
-                text = buildAnnotatedString {
-                    append(formatFriendlyDate(session.startTime.toString()))
-                    append(" · ")
-                    append(ReadRecordFormatter.formatDuration(session.endTime - session.startTime))
-                },
-                style = LegadoTheme.typography.labelSmall,
-                color = LegadoTheme.colorScheme.onSurfaceVariant
-            )
-        }
-    }
-}
-
-@Composable
-fun ReadRecordItem(
-    detail: ReadRecordDetail,
-    viewModel: ReadRecordViewModel,
-    onClick: () -> Unit,
-    modifier: Modifier = Modifier
-) {
-    var coverPath by remember { mutableStateOf<String?>(null) }
-    LaunchedEffect(detail.bookName, detail.bookAuthor) {
-        coverPath = viewModel.getBookCover(detail.bookName, detail.bookAuthor)
-    }
-
-    Row(
-        modifier = modifier
-            .fillMaxWidth()
-            .clickable { onClick() }
-            .padding(16.dp),
-        verticalAlignment = Alignment.CenterVertically
-    ) {
-        CoilBookCover(
-            name = detail.bookName,
-            author = detail.bookAuthor,
-            path = coverPath,
-            modifier = Modifier.size(40.dp, 56.dp)
-        )
-        Spacer(modifier = Modifier.width(16.dp))
-        Column(modifier = Modifier.weight(1f)) {
-            AppText(
-                text = detail.bookName,
-                style = LegadoTheme.typography.bodyLarge,
-                fontWeight = FontWeight.Bold,
-                maxLines = 1,
-                overflow = TextOverflow.Ellipsis
-            )
-            AppText(
-                text = buildAnnotatedString {
-                    append(detail.bookAuthor)
-                    if (detail.readWords > 0) {
-                        append(" · ")
-                        append(ReadRecordFormatter.formatWords(detail.readWords))
-                    }
-                },
-                style = LegadoTheme.typography.labelSmall,
-                color = LegadoTheme.colorScheme.onSurfaceVariant
-            )
-        }
-        AppText(
-            text = ReadRecordFormatter.formatDuration(detail.readTime),
-            style = LegadoTheme.typography.bodyMedium,
-            color = LegadoTheme.colorScheme.primary
-        )
-    }
-}
-
-@Composable
-fun DateHeader(
-    date: String,
-    totalTimeMillis: Long? = null,
-    modifier: Modifier = Modifier
-) {
-    Row(
-        modifier = modifier
-            .fillMaxWidth()
-            .padding(horizontal = 16.dp, vertical = 8.dp),
-        verticalAlignment = Alignment.CenterVertically,
-        horizontalArrangement = Arrangement.SpaceBetween
-    ) {
-        AppText(
-            text = date,
-            style = LegadoTheme.typography.titleSmall,
-            color = LegadoTheme.colorScheme.primary,
-            fontWeight = FontWeight.Bold
-        )
-        if (totalTimeMillis != null) {
-            AppText(
-                text = ReadRecordFormatter.formatDuration(totalTimeMillis),
-                style = LegadoTheme.typography.labelSmall,
-                color = LegadoTheme.colorScheme.onSurfaceVariant
-            )
-        }
-    }
-}

+ 0 - 131
app/src/main/java/io/legado/app/ui/book/readRecord/component/ReadRecordSummary.kt

@@ -1,131 +0,0 @@
-package io.legado.app.ui.book.readRecord.component
-
-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.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.unit.dp
-import io.legado.app.ui.book.readRecord.ReadRecordFormatter
-import io.legado.app.ui.book.readRecord.ReadRecordUiState
-import io.legado.app.ui.book.readRecord.ReadRecordViewModel
-import io.legado.app.ui.theme.LegadoTheme
-import io.legado.app.ui.theme.adaptiveHorizontalPadding
-import io.legado.app.ui.widget.components.card.GlassCard
-import io.legado.app.ui.widget.components.image.cover.CoilBookCover
-import io.legado.app.ui.widget.components.text.AppText
-import java.time.format.DateTimeFormatter
-
-@Composable
-fun SummarySection(
-    state: ReadRecordUiState,
-    viewModel: ReadRecordViewModel,
-    onSummaryClick: () -> Unit
-) {
-    val selectedDate = state.selectedDate
-
-    if (selectedDate != null) {
-        val dateKey = selectedDate.format(DateTimeFormatter.ISO_LOCAL_DATE)
-        val dailyDetails = state.groupedRecords[dateKey] ?: emptyList()
-
-        if (dailyDetails.isNotEmpty()) {
-            val distinctBooks = dailyDetails.map { it.bookName to it.bookAuthor }.distinct()
-            val dailyTime = dailyDetails.sumOf { it.readTime }
-
-            ReadingSummaryCard(
-                title = selectedDate.format(DateTimeFormatter.ofPattern("M月d日阅读概览")),
-                bookCount = distinctBooks.size,
-                totalTimeMillis = dailyTime,
-                bookNamesForCover = distinctBooks.take(3),
-                viewModel = viewModel,
-                onClick = onSummaryClick
-            )
-        }
-    } else {
-        val allBooksCount = state.latestRecords.size
-        val totalTime = state.totalReadTime
-
-        if (allBooksCount > 0) {
-            ReadingSummaryCard(
-                title = "累计阅读成就",
-                bookCount = allBooksCount,
-                totalTimeMillis = totalTime,
-                bookNamesForCover = state.latestRecords.take(5).map { it.bookName to it.bookAuthor },
-                viewModel = viewModel,
-                onClick = onSummaryClick
-            )
-        }
-    }
-}
-
-@Composable
-fun ReadingSummaryCard(
-    title: String,
-    bookCount: Int,
-    totalTimeMillis: Long,
-    bookNamesForCover: List<Pair<String, String>>,
-    viewModel: ReadRecordViewModel,
-    onClick: () -> Unit
-) {
-    GlassCard(
-        modifier = Modifier
-            .fillMaxWidth()
-            .adaptiveHorizontalPadding(vertical = 8.dp),
-        onClick = onClick
-    ) {
-        Row(
-            modifier = Modifier.padding(16.dp),
-            verticalAlignment = Alignment.CenterVertically
-        ) {
-            Column(modifier = Modifier.weight(1f)) {
-                AppText(title, style = LegadoTheme.typography.titleMedium)
-                Spacer(modifier = Modifier.height(4.dp))
-                AppText(
-                    text = "共阅读 $bookCount 本书,时长 ${ReadRecordFormatter.formatDuration(totalTimeMillis)}",
-                    style = LegadoTheme.typography.bodyMedium,
-                    color = LegadoTheme.colorScheme.onSurfaceVariant
-                )
-            }
-            
-            BookStackView(
-                bookNamesForCover = bookNamesForCover,
-                viewModel = viewModel
-            )
-        }
-    }
-}
-
-@Composable
-fun BookStackView(
-    bookNamesForCover: List<Pair<String, String>>,
-    viewModel: ReadRecordViewModel
-) {
-    Box(contentAlignment = Alignment.CenterEnd) {
-        bookNamesForCover.reversed().forEachIndexed { index, (name, author) ->
-            var coverPath by remember { mutableStateOf<String?>(null) }
-            LaunchedEffect(name, author) {
-                coverPath = viewModel.getBookCover(name, author)
-            }
-            
-            CoilBookCover(
-                name = name,
-                author = author,
-                path = coverPath,
-                modifier = Modifier
-                    .padding(end = (index * 12).dp)
-                    .width(32.dp)
-            )
-        }
-    }
-}

+ 0 - 1
app/src/main/res/values-zh-rCN/strings.xml

@@ -2685,7 +2685,6 @@
     <string name="total_read_books">累计读过</string>
     <string name="finished_books">读完书籍</string>
     <string name="reading_books">在读书籍</string>
-    <string name="reading_words">阅读字数</string>
     <string name="reading_data">阅读数据</string>
     <string name="reading_heatmap">阅读热力图</string>
     <string name="reading_time_ranking">阅读时长榜</string>

+ 0 - 1
app/src/main/res/values-zh-rHK/strings.xml

@@ -2554,7 +2554,6 @@
     <string name="total_read_books">累計讀過</string>
     <string name="finished_books">讀完書籍</string>
     <string name="reading_books">在讀書籍</string>
-    <string name="reading_words">閱讀字數</string>
     <string name="reading_data">閱讀資料</string>
     <string name="reading_heatmap">閱讀熱力圖</string>
     <string name="reading_time_ranking">閱讀時長榜</string>

+ 0 - 1
app/src/main/res/values-zh-rTW/strings.xml

@@ -2559,7 +2559,6 @@
     <string name="total_read_books">累計讀過</string>
     <string name="finished_books">讀完書籍</string>
     <string name="reading_books">在讀書籍</string>
-    <string name="reading_words">閱讀字數</string>
     <string name="reading_data">閱讀資料</string>
     <string name="reading_heatmap">閱讀熱力圖</string>
     <string name="reading_time_ranking">閱讀時長榜</string>

+ 0 - 1
app/src/main/res/values/strings.xml

@@ -2690,7 +2690,6 @@
     <string name="total_read_books">Books read</string>
     <string name="finished_books">Finished books</string>
     <string name="reading_books">Reading books</string>
-    <string name="reading_words">Words read</string>
     <string name="reading_data">Reading data</string>
     <string name="reading_heatmap">Reading heatmap</string>
     <string name="reading_time_ranking">Reading time ranking</string>

+ 148 - 0
app/src/test/java/io/legado/app/data/repository/ReadRecordStatisticsConsistencyTest.kt

@@ -0,0 +1,148 @@
+package io.legado.app.data.repository
+
+import android.app.Application
+import androidx.room.Room
+import io.legado.app.data.AppDatabase
+import io.legado.app.data.entities.readRecord.ReadRecord
+import io.legado.app.data.entities.readRecord.ReadRecordSession
+import io.legado.app.help.config.AppConfigStore
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.runBlocking
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.annotation.Config
+
+/**
+ * 阅读统计口径回归测试:
+ *
+ * 1. 阅读总览「总」模式与阅读记录页、首页的总时长同源(readRecord 汇总,包含旧版遗留时长),
+ *    三者保持一致;旧版只有 readRecord、没有 readRecordDetail 的时长不再被总览漏掉。
+ * 2. 阅读字数(readRecordDetail.readWords)是章节序号的累加,不作为「阅读字数」展示,
+ *    总览统计卡片不再显示该误导性指标。
+ */
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class, sdk = [35])
+class ReadRecordStatisticsConsistencyTest {
+
+    private lateinit var database: AppDatabase
+    private lateinit var repository: ReadRecordRepository
+
+    @Before
+    fun setUp() {
+        AppConfigStore.init(RuntimeEnvironment.getApplication())
+        database = Room.inMemoryDatabaseBuilder(
+            RuntimeEnvironment.getApplication(),
+            AppDatabase::class.java
+        )
+            .allowMainThreadQueries().build()
+        repository = ReadRecordRepository(database.readRecordDao, database, SettingsRepository())
+    }
+
+    @After
+    fun tearDown() = database.close()
+
+    // ---------- 问题 1:总览(readRecordDetail)与记录(readRecord)总时长不一致 ----------
+
+    @Test
+    fun `records total equals overview total in pure session flow`() = runBlocking {
+        val day = "2026-01-02"
+        val base = day.toEpochDay() * 86_400_000L
+        repository.saveReadSession(session(base, base + 100_000, words = 45))
+        repository.saveReadSession(session(base + 200_000, base + 300_000, words = 46))
+
+        val recordsTotal = repository.getTotalReadTime().first()
+        val overviewTotal = repository.getAllRecordDetails("").first().sumOf { it.readTime }
+
+        assertEquals(200_000L, recordsTotal)
+        assertEquals(recordsTotal, overviewTotal)
+    }
+
+    @Test
+    fun `legacy record time is missing from overview total`() = runBlocking {
+        // 旧版本/上游备份只有 readRecord.readTime,没有 readRecordDetail(升级前无此表)。
+        database.readRecordDao.insert(
+            ReadRecord(
+                deviceId,
+                bookName,
+                author,
+                readTime = 3_600_000L,
+                lastRead = 1_700_000_000_000L
+            )
+        )
+
+        val recordsTotal = repository.getTotalReadTime().first() // 阅读记录页「累计阅读成就」
+        val overviewTotal =
+            repository.getAllRecordDetails("").first().sumOf { it.readTime } // 阅读总览「阅读时间」
+
+        assertEquals(3_600_000L, recordsTotal)
+        assertEquals(0L, overviewTotal)
+        // 两个页面展示的累计阅读时长不一致(记录 > 总览)。
+    }
+
+    @Test
+    fun `cross-device synced duplicates inflate totals but sessions dedupe`() = runBlocking {
+        // 同一本书在设备 A/B 各有一条同步副本(同一时段的 session 与 detail 都重复)。
+        // 记录汇总与详情聚合都会把同步副本算两遍;会话时间线按内容去重。
+        // 阅读总览「总」模式与记录页同源(readRecord 汇总),两者保持一致;
+        // 同步副本的重复时长由「修复」流程(repairDuplicateSessions)清理。
+        val day = "2026-01-02"
+        val base = day.toEpochDay() * 86_400_000L
+        val sessionA = session(base, base + 100_000, words = 45, device = "device-a")
+        val sessionB = sessionA.copy(deviceId = "device-b")
+        repository.saveReadSession(sessionA)
+        repository.saveReadSession(sessionB)
+
+        val recordsTotal = repository.getTotalReadTime().first() // 记录页总时长(与总览「总」同源)
+        val overviewTotal = repository.getAllRecordDetails("").first().sumOf { it.readTime } // 详情聚合
+        val sessionsTotal = repository.getAllSessions().first()
+            .sumOf { it.endTime - it.startTime } // 时间线/热力图:按内容去重后的会话
+
+        assertEquals(200_000L, recordsTotal)
+        assertEquals(200_000L, overviewTotal)
+        assertEquals(100_000L, sessionsTotal)
+    }
+
+    // ---------- 问题 2:阅读字数 = 章节序号之和,不是字数 ----------
+
+    @Test
+    fun `overview words are summed chapter indexes not word counts`() = runBlocking {
+        val day = "2026-01-02"
+        val base = day.toEpochDay() * 86_400_000L
+        // 读者在章节 45、46、47 各读了一段(words 字段 = durChapterIndex)。
+        repository.saveReadSession(session(base, base + 60_000, words = 45))
+        repository.saveReadSession(session(base + 100_000, base + 160_000, words = 46))
+        repository.saveReadSession(session(base + 200_000, base + 260_000, words = 47))
+
+        val merged = repository.getAllRecordDetails("").first()
+        assertEquals(180_000L, merged.sumOf { it.readTime })
+        // 当天实际阅读 3 个章节,却显示"阅读字数"为章节序号之和 45+46+47=138。
+        assertEquals(138L, merged.sumOf { it.readWords })
+    }
+
+    private fun session(
+        start: Long,
+        end: Long,
+        words: Long,
+        device: String = deviceId,
+    ) = ReadRecordSession(
+        deviceId = device,
+        bookName = bookName,
+        bookAuthor = author,
+        startTime = start,
+        endTime = end,
+        words = words,
+    )
+
+    private fun String.toEpochDay(): Long = java.time.LocalDate.parse(this).toEpochDay()
+
+    private companion object {
+        const val deviceId = "device"
+        const val bookName = "book"
+        const val author = "author"
+    }
+}

+ 56 - 0
app/src/test/java/io/legado/app/domain/usecase/readRecord/GetReadRecordOverviewUseCaseTest.kt

@@ -0,0 +1,56 @@
+package io.legado.app.domain.usecase.readRecord
+
+import io.legado.app.data.entities.readRecord.ReadRecord
+import io.legado.app.data.entities.readRecord.ReadRecordDetail
+import io.legado.app.ui.book.readRecord.ReadPeriod
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import java.time.LocalDate
+
+class GetReadRecordOverviewUseCaseTest {
+
+    private val useCase = GetReadRecordOverviewUseCase()
+
+    @Test
+    fun `ALL mode total time uses merged record totals including legacy`() {
+        // 每日详情只有新版本数据;旧版遗留时长只存在于 readRecord。
+        val details = listOf(
+            detail("2026-01-02", readTime = 100_000L),
+            detail("2026-01-03", readTime = 200_000L),
+        )
+        val records = listOf(
+            ReadRecord("", "book", "author", readTime = 800_000L, lastRead = 0L)
+        )
+
+        val state = useCase(ReadPeriod.ALL, LocalDate.of(2026, 1, 3), details, records, emptyList())
+
+        // 与阅读记录页/首页的总时长同源:100_000 + 200_000 + 遗留 500_000。
+        assertEquals(800_000L, state.totalTime)
+    }
+
+    @Test
+    fun `period mode total time uses filtered details`() {
+        val details = listOf(
+            detail("2026-01-02", readTime = 100_000L),
+            detail("2026-02-02", readTime = 200_000L),
+        )
+        val records = listOf(
+            ReadRecord("", "book", "author", readTime = 300_000L, lastRead = 0L)
+        )
+
+        val state =
+            useCase(ReadPeriod.MONTH, LocalDate.of(2026, 1, 15), details, records, emptyList())
+
+        // 周期视图没有日期的旧版时长无法归属,仍按详情统计。
+        assertEquals(100_000L, state.totalTime)
+    }
+
+    private fun detail(date: String, readTime: Long) = ReadRecordDetail(
+        deviceId = "",
+        bookName = "book",
+        bookAuthor = "author",
+        date = date,
+        readTime = readTime,
+        readWords = 0L,
+    )
+}