Commit f54168be799ecfd70783bfa8fe6d2866b939b968

Authored by yibo
1 parent 2b1f5fb3

feat(crm): 人物列表显示真实最近联系时间

- ContactRow 右侧显示最近联系时间(今天/昨天/N天前/N月前)
- 超过 30 天显示红色「⚠ N月前」警告
- ★ 最近联系人排序改用真实联系时间(微信消息 MAX + 被提到 MAX),
  替代原来只记打开档案时间的 lastAccess

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt
@@ -671,6 +671,20 @@ private fun TagFaceDialog( @@ -671,6 +671,20 @@ private fun TagFaceDialog(
671 private fun ContactList(vm: PhotologViewModel, persons: List<Person>, onOpen: (Long) -> Unit, onOpenPicker: () -> Unit, onOpenCommunities: () -> Unit, onOpenFollowups: () -> Unit, onOpenTopics: () -> Unit) { 671 private fun ContactList(vm: PhotologViewModel, persons: List<Person>, onOpen: (Long) -> Unit, onOpenPicker: () -> Unit, onOpenCommunities: () -> Unit, onOpenFollowups: () -> Unit, onOpenTopics: () -> Unit) {
672 var query by remember { mutableStateOf("") } 672 var query by remember { mutableStateOf("") }
673 673
  674 + // 真实最近联系时间:微信最后一条消息 + 最后一次被提到,取 MAX
  675 + val wxTimes by vm.repo.wechatMessages.observeLastContactTimes().collectAsState(emptyList())
  676 + val mentionTimes by vm.repo.mentions.observeLastMentionTimes().collectAsState(emptyList())
  677 + val lastContactMap = remember(persons, wxTimes, mentionTimes) {
  678 + val wxMap = wxTimes.associate { it.talker to it.maxTime }
  679 + val mnMap = mentionTimes.associate { it.personId to it.maxTime }
  680 + persons.associate { p ->
  681 + val wxid = p.contactRef?.takeIf { it.startsWith("wechat:") }?.removePrefix("wechat:")
  682 + val msgTs = if (wxid != null) wxMap[wxid] ?: 0L else 0L
  683 + val mnTs = mnMap[p.id] ?: 0L
  684 + p.id to maxOf(msgTs, mnTs)
  685 + }
  686 + }
  687 +
674 val sections = remember(persons, query) { 688 val sections = remember(persons, query) {
675 val q = query.trim() 689 val q = query.trim()
676 val filtered = if (q.isBlank()) persons else persons.filter { p -> 690 val filtered = if (q.isBlank()) persons else persons.filter { p ->
@@ -682,9 +696,12 @@ private fun ContactList(vm: PhotologViewModel, persons: List&lt;Person&gt;, onOpen: (L @@ -682,9 +696,12 @@ private fun ContactList(vm: PhotologViewModel, persons: List&lt;Person&gt;, onOpen: (L
682 .groupBy { sectionLetter(it) } 696 .groupBy { sectionLetter(it) }
683 .toSortedMap(compareBy { if (it == "#") "[" else it }) // # 排最后 697 .toSortedMap(compareBy { if (it == "#") "[" else it }) // # 排最后
684 } 698 }
685 - val recent = remember(persons, query) { 699 + val recent = remember(persons, query, lastContactMap) {
686 if (query.isNotBlank()) emptyList() 700 if (query.isNotBlank()) emptyList()
687 - else persons.filter { it.lastAccess > 0 }.sortedByDescending { it.lastAccess }.take(12) 701 + else persons
  702 + .filter { (lastContactMap[it.id] ?: 0L) > 0L }
  703 + .sortedByDescending { lastContactMap[it.id] ?: 0L }
  704 + .take(12)
688 } 705 }
689 706
690 val ctx = LocalContext.current 707 val ctx = LocalContext.current
@@ -730,7 +747,7 @@ private fun ContactList(vm: PhotologViewModel, persons: List&lt;Person&gt;, onOpen: (L @@ -730,7 +747,7 @@ private fun ContactList(vm: PhotologViewModel, persons: List&lt;Person&gt;, onOpen: (L
730 Text("★ 最近联系人", style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(horizontal = 16.dp, vertical = 3.dp)) 747 Text("★ 最近联系人", style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(horizontal = 16.dp, vertical = 3.dp))
731 } 748 }
732 } 749 }
733 - items(recent, key = { "r${it.id}" }) { p -> ContactRow(p) { onOpen(p.id) } } 750 + items(recent, key = { "r${it.id}" }) { p -> ContactRow(p, lastContactMap[p.id] ?: 0L) { onOpen(p.id) } }
734 } 751 }
735 sections.forEach { (letter, group) -> 752 sections.forEach { (letter, group) ->
736 stickyHeader { 753 stickyHeader {
@@ -738,7 +755,7 @@ private fun ContactList(vm: PhotologViewModel, persons: List&lt;Person&gt;, onOpen: (L @@ -738,7 +755,7 @@ private fun ContactList(vm: PhotologViewModel, persons: List&lt;Person&gt;, onOpen: (L
738 Text(letter, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(horizontal = 16.dp, vertical = 3.dp)) 755 Text(letter, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(horizontal = 16.dp, vertical = 3.dp))
739 } 756 }
740 } 757 }
741 - items(group, key = { it.id }) { p -> ContactRow(p) { onOpen(p.id) } } 758 + items(group, key = { it.id }) { p -> ContactRow(p, lastContactMap[p.id] ?: 0L) { onOpen(p.id) } }
742 } 759 }
743 } 760 }
744 } 761 }
@@ -751,7 +768,7 @@ private fun sectionLetter(p: Person): String { @@ -751,7 +768,7 @@ private fun sectionLetter(p: Person): String {
751 } 768 }
752 769
753 @Composable 770 @Composable
754 -private fun ContactRow(p: Person, onClick: () -> Unit) { 771 +private fun ContactRow(p: Person, lastContactTs: Long = 0L, onClick: () -> Unit) {
755 Row( 772 Row(
756 modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 16.dp, vertical = 7.dp), 773 modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 16.dp, vertical = 7.dp),
757 verticalAlignment = Alignment.CenterVertically, 774 verticalAlignment = Alignment.CenterVertically,
@@ -773,6 +790,23 @@ private fun ContactRow(p: Person, onClick: () -&gt; Unit) { @@ -773,6 +790,23 @@ private fun ContactRow(p: Person, onClick: () -&gt; Unit) {
773 ) 790 )
774 if (tags.isNotEmpty()) Text(tags.joinToString(" · "), style = MaterialTheme.typography.labelSmall) 791 if (tags.isNotEmpty()) Text(tags.joinToString(" · "), style = MaterialTheme.typography.labelSmall)
775 } 792 }
  793 + // 最近联系时间(右侧对齐)
  794 + if (lastContactTs > 0L) {
  795 + val days = ((System.currentTimeMillis() - lastContactTs) / 86_400_000L).toInt()
  796 + val label = when {
  797 + days == 0 -> "今天"
  798 + days == 1 -> "昨天"
  799 + days < 30 -> "${days}天前"
  800 + days < 365 -> "${days / 30}月前"
  801 + else -> "${days / 365}年前"
  802 + }
  803 + Text(
  804 + if (days > 30) "⚠ $label" else label,
  805 + style = MaterialTheme.typography.labelSmall,
  806 + color = if (days > 30) MaterialTheme.colorScheme.error
  807 + else MaterialTheme.colorScheme.onSurfaceVariant,
  808 + )
  809 + }
776 } 810 }
777 } 811 }
778 812
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt
@@ -344,6 +344,10 @@ interface MentionDao { @@ -344,6 +344,10 @@ interface MentionDao {
344 @Query("SELECT segmentId, personId FROM mentions") 344 @Query("SELECT segmentId, personId FROM mentions")
345 suspend fun allPairs(): List<MentionPair> 345 suspend fun allPairs(): List<MentionPair>
346 346
  347 + /** 联系人列表用:每个人最后一次被提到的时间戳。 */
  348 + @Query("SELECT personId, MAX(createdAt) AS maxTime FROM mentions GROUP BY personId")
  349 + fun observeLastMentionTimes(): Flow<List<PersonMentionTime>>
  350 +
347 /** 某人「被提到」的录音段(join 转写 + 段 + 说话人=谁说的)。 */ 351 /** 某人「被提到」的录音段(join 转写 + 段 + 说话人=谁说的)。 */
348 @Query( 352 @Query(
349 """ 353 """
@@ -368,6 +372,9 @@ interface MentionDao { @@ -368,6 +372,9 @@ interface MentionDao {
368 372
369 data class MentionPair(val segmentId: Long, val personId: Long) 373 data class MentionPair(val segmentId: Long, val personId: Long)
370 374
  375 +/** 最近联系时间:每个人最后一次被提到的时间戳。 */
  376 +data class PersonMentionTime(val personId: Long, val maxTime: Long)
  377 +
371 /** 段元信息(共现重建:按时间切会话 + 声纹在场)。 */ 378 /** 段元信息(共现重建:按时间切会话 + 声纹在场)。 */
372 data class SegMeta(val id: Long, val startTs: Long, val speakerId: Long?) 379 data class SegMeta(val id: Long, val startTs: Long, val speakerId: Long?)
373 380
@@ -881,6 +888,9 @@ interface MomentDao { @@ -881,6 +888,9 @@ interface MomentDao {
881 fun observeRecent(n: Int): Flow<List<Moment>> 888 fun observeRecent(n: Int): Flow<List<Moment>>
882 } 889 }
883 890
  891 +/** 最近联系时间:每个 wxid 最后一条消息的时间戳。 */
  892 +data class TalkerTime(val talker: String, val maxTime: Long)
  893 +
884 @Dao 894 @Dao
885 interface WechatMessageDao { 895 interface WechatMessageDao {
886 @Insert 896 @Insert
@@ -919,6 +929,10 @@ interface WechatMessageDao { @@ -919,6 +929,10 @@ interface WechatMessageDao {
919 /** 时间轴用:最近 n 条私聊(排除群聊)。 */ 929 /** 时间轴用:最近 n 条私聊(排除群聊)。 */
920 @Query("SELECT * FROM wechat_messages WHERE talker NOT LIKE '%@chatroom' ORDER BY createTime DESC LIMIT :n") 930 @Query("SELECT * FROM wechat_messages WHERE talker NOT LIKE '%@chatroom' ORDER BY createTime DESC LIMIT :n")
921 fun observeRecent(n: Int): Flow<List<WechatMessage>> 931 fun observeRecent(n: Int): Flow<List<WechatMessage>>
  932 +
  933 + /** 联系人列表用:每个私聊对象最后一条消息时间(用于最近联系时间排序)。 */
  934 + @Query("SELECT talker, MAX(createTime) AS maxTime FROM wechat_messages WHERE talker NOT LIKE '%@chatroom' GROUP BY talker")
  935 + fun observeLastContactTimes(): Flow<List<TalkerTime>>
922 } 936 }
923 937
924 @Dao 938 @Dao