Commit f54168be799ecfd70783bfa8fe6d2866b939b968
1 parent
2b1f5fb3
feat(crm): 人物列表显示真实最近联系时间
- ContactRow 右侧显示最近联系时间(今天/昨天/N天前/N月前) - 超过 30 天显示红色「⚠ N月前」警告 - ★ 最近联系人排序改用真实联系时间(微信消息 MAX + 被提到 MAX), 替代原来只记打开档案时间的 lastAccess Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Showing
2 changed files
with
53 additions
and
5 deletions
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt
| ... | ... | @@ -671,6 +671,20 @@ private fun TagFaceDialog( |
| 671 | 671 | private fun ContactList(vm: PhotologViewModel, persons: List<Person>, onOpen: (Long) -> Unit, onOpenPicker: () -> Unit, onOpenCommunities: () -> Unit, onOpenFollowups: () -> Unit, onOpenTopics: () -> Unit) { |
| 672 | 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 | 688 | val sections = remember(persons, query) { |
| 675 | 689 | val q = query.trim() |
| 676 | 690 | val filtered = if (q.isBlank()) persons else persons.filter { p -> |
| ... | ... | @@ -682,9 +696,12 @@ private fun ContactList(vm: PhotologViewModel, persons: List<Person>, onOpen: (L |
| 682 | 696 | .groupBy { sectionLetter(it) } |
| 683 | 697 | .toSortedMap(compareBy { if (it == "#") "[" else it }) // # 排最后 |
| 684 | 698 | } |
| 685 | - val recent = remember(persons, query) { | |
| 699 | + val recent = remember(persons, query, lastContactMap) { | |
| 686 | 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 | 707 | val ctx = LocalContext.current |
| ... | ... | @@ -730,7 +747,7 @@ private fun ContactList(vm: PhotologViewModel, persons: List<Person>, onOpen: (L |
| 730 | 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 | 752 | sections.forEach { (letter, group) -> |
| 736 | 753 | stickyHeader { |
| ... | ... | @@ -738,7 +755,7 @@ private fun ContactList(vm: PhotologViewModel, persons: List<Person>, onOpen: (L |
| 738 | 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 | 768 | } |
| 752 | 769 | |
| 753 | 770 | @Composable |
| 754 | -private fun ContactRow(p: Person, onClick: () -> Unit) { | |
| 771 | +private fun ContactRow(p: Person, lastContactTs: Long = 0L, onClick: () -> Unit) { | |
| 755 | 772 | Row( |
| 756 | 773 | modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 16.dp, vertical = 7.dp), |
| 757 | 774 | verticalAlignment = Alignment.CenterVertically, |
| ... | ... | @@ -773,6 +790,23 @@ private fun ContactRow(p: Person, onClick: () -> Unit) { |
| 773 | 790 | ) |
| 774 | 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 | 344 | @Query("SELECT segmentId, personId FROM mentions") |
| 345 | 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 | 351 | /** 某人「被提到」的录音段(join 转写 + 段 + 说话人=谁说的)。 */ |
| 348 | 352 | @Query( |
| 349 | 353 | """ |
| ... | ... | @@ -368,6 +372,9 @@ interface MentionDao { |
| 368 | 372 | |
| 369 | 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 | 379 | data class SegMeta(val id: Long, val startTs: Long, val speakerId: Long?) |
| 373 | 380 | |
| ... | ... | @@ -881,6 +888,9 @@ interface MomentDao { |
| 881 | 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 | 894 | @Dao |
| 885 | 895 | interface WechatMessageDao { |
| 886 | 896 | @Insert |
| ... | ... | @@ -919,6 +929,10 @@ interface WechatMessageDao { |
| 919 | 929 | /** 时间轴用:最近 n 条私聊(排除群聊)。 */ |
| 920 | 930 | @Query("SELECT * FROM wechat_messages WHERE talker NOT LIKE '%@chatroom' ORDER BY createTime DESC LIMIT :n") |
| 921 | 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 | 938 | @Dao | ... | ... |