Commit 31f530a24d78da47c884ba3cd5780cd0e8c080ac
1 parent
9b821e1a
feat(lifelog/photolog): 人物合并(并重复联系人为一条)
档案页「🔗 合并重复」→ 独立选人子页(搜索+全部人物列表,含微信池) → 确认 → 并入当前人物: - 人脸归属改派、声纹/人脸锚点/头像/昵称-备注-微信号/手机号/真名/简历/AI 印象 缺啥补啥 - contactRef 优先保留 wechat: 引用,合并后微信聊天/朋友圈/AI 印象不丢 - 被合并的那条删除;当前人物存活并置 managed - DAO: FaceDao.reassignPerson + PersonDao @Update - 选人改用整页子页(MergePersonPickerScreen),不再塞进 AlertDialog(之前被布局挤没、看不到搜索/列表/按钮) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Showing
4 changed files
with
161 additions
and
1 deletions
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PersonProfileScreen.kt
| ... | ... | @@ -72,6 +72,7 @@ internal fun PersonProfileScreen( |
| 72 | 72 | personId: Long, |
| 73 | 73 | onBack: () -> Unit, |
| 74 | 74 | onOpenPhoto: (Long) -> Unit, |
| 75 | + onRequestMerge: () -> Unit, | |
| 75 | 76 | ) { |
| 76 | 77 | val person by vm.repo.persons.observe(personId).collectAsState(initial = null) |
| 77 | 78 | val photos by vm.repo.photos.observePhotosOfPerson(personId).collectAsState(initial = emptyList()) |
| ... | ... | @@ -120,6 +121,7 @@ internal fun PersonProfileScreen( |
| 120 | 121 | onRecordVoice = { vm.resetVoiceEnroll(); recordVoice = true }, |
| 121 | 122 | onEnrich = { vm.enrichImpression(personId) }, |
| 122 | 123 | onEditProfile = { editProfile = true }, |
| 124 | + onMerge = onRequestMerge, | |
| 123 | 125 | ) |
| 124 | 126 | } |
| 125 | 127 | OutlinedTextField( |
| ... | ... | @@ -196,6 +198,7 @@ internal fun PersonProfileScreen( |
| 196 | 198 | ) |
| 197 | 199 | } |
| 198 | 200 | } |
| 201 | + | |
| 199 | 202 | } |
| 200 | 203 | |
| 201 | 204 | /** 当场录 6 秒 → 声纹 → 绑到 TA。录音期间 24/7 服务自动暂停让麦克风。 */ |
| ... | ... | @@ -253,6 +256,7 @@ private fun ProfileHeader( |
| 253 | 256 | onRecordVoice: () -> Unit, |
| 254 | 257 | onEnrich: () -> Unit, |
| 255 | 258 | onEditProfile: () -> Unit, |
| 259 | + onMerge: () -> Unit, | |
| 256 | 260 | ) { |
| 257 | 261 | Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp)) { |
| 258 | 262 | Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { |
| ... | ... | @@ -297,7 +301,10 @@ private fun ProfileHeader( |
| 297 | 301 | } |
| 298 | 302 | Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { |
| 299 | 303 | TextButton(onClick = onEditProfile, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 4.dp, vertical = 0.dp)) { |
| 300 | - Text("✎ 编辑资料(真名/简历)", style = MaterialTheme.typography.labelSmall) | |
| 304 | + Text("✎ 编辑资料", style = MaterialTheme.typography.labelSmall) | |
| 305 | + } | |
| 306 | + TextButton(onClick = onMerge, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 4.dp, vertical = 0.dp)) { | |
| 307 | + Text("🔗 合并重复", style = MaterialTheme.typography.labelSmall) | |
| 301 | 308 | } |
| 302 | 309 | TextButton(onClick = onEnrich, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 4.dp, vertical = 0.dp)) { |
| 303 | 310 | Text(if (p.impression == null) "🧠 AI 印象" else "重新生成印象", style = MaterialTheme.typography.labelSmall) | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt
| ... | ... | @@ -91,15 +91,19 @@ internal fun ContactsTab(repo: Repository) { |
| 91 | 91 | var detailPhotoId by remember { mutableStateOf<Long?>(null) } |
| 92 | 92 | var showPicker by remember { mutableStateOf(false) } |
| 93 | 93 | var showPhonePicker by remember { mutableStateOf(false) } |
| 94 | + var mergeForId by remember { mutableStateOf<Long?>(null) } | |
| 94 | 95 | // 人物 tab 只显示「我管理的人物」;全部微信联系人是池子(在选人页里)。 |
| 95 | 96 | val managed by vm.repo.persons.observeManaged().collectAsState(initial = emptyList()) |
| 96 | 97 | |
| 97 | 98 | val pid = profilePersonId |
| 99 | + val mergeId = mergeForId | |
| 98 | 100 | when { |
| 101 | + mergeId != null -> MergePersonPickerScreen(vm = vm, survivorId = mergeId, onDone = { mergeForId = null }) | |
| 99 | 102 | pid != null -> PersonProfileScreen( |
| 100 | 103 | vm = vm, personId = pid, |
| 101 | 104 | onBack = { profilePersonId = null }, |
| 102 | 105 | onOpenPhoto = { detailPhotoId = it }, |
| 106 | + onRequestMerge = { mergeForId = pid }, | |
| 103 | 107 | ) |
| 104 | 108 | showPhonePicker -> PhoneContactPickerScreen(vm = vm, onBack = { showPhonePicker = false }) |
| 105 | 109 | showPicker -> WeChatPickerScreen( |
| ... | ... | @@ -647,3 +651,102 @@ private fun PhoneContactRow(pc: PhoneContact, checked: Boolean, onToggle: () -> |
| 647 | 651 | } |
| 648 | 652 | } |
| 649 | 653 | } |
| 654 | + | |
| 655 | +/** | |
| 656 | + * 合并重复人物:选一个跟当前人物(survivor)是同一人的人物 → 确认 → 并入 survivor 并删除被选的。 | |
| 657 | + * 候选来自全部人物池(含微信池子),可搜索;独立子页,避免把列表塞进 AlertDialog 被挤没。 | |
| 658 | + */ | |
| 659 | +@OptIn(ExperimentalFoundationApi::class) | |
| 660 | +@Composable | |
| 661 | +private fun MergePersonPickerScreen(vm: PhotologViewModel, survivorId: Long, onDone: () -> Unit) { | |
| 662 | + val all by vm.repo.persons.observeAll().collectAsState(initial = emptyList()) | |
| 663 | + val survivor = all.firstOrNull { it.id == survivorId } | |
| 664 | + var query by remember { mutableStateOf("") } | |
| 665 | + var confirm by remember { mutableStateOf<Person?>(null) } | |
| 666 | + | |
| 667 | + val candidates = remember(all, query, survivorId) { | |
| 668 | + val q = query.trim() | |
| 669 | + all.asSequence().filter { it.id != survivorId } | |
| 670 | + .filter { | |
| 671 | + q.isBlank() || it.name.contains(q, true) || it.nickname?.contains(q, true) == true || | |
| 672 | + it.remark?.contains(q, true) == true || it.alias?.contains(q, true) == true || | |
| 673 | + it.pinyin?.contains(q, true) == true || it.phone?.contains(q) == true | |
| 674 | + } | |
| 675 | + .sortedWith(compareBy({ sectionLetter(it) }, { it.pinyin ?: it.name })) | |
| 676 | + .toList() | |
| 677 | + } | |
| 678 | + | |
| 679 | + Column(modifier = Modifier.fillMaxSize()) { | |
| 680 | + Row( | |
| 681 | + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), | |
| 682 | + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), | |
| 683 | + ) { | |
| 684 | + TextButton(onClick = onDone) { Text("← 返回") } | |
| 685 | + Text("合并到「${survivor?.name ?: "?"}」", style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) | |
| 686 | + } | |
| 687 | + Text( | |
| 688 | + "选一个跟 TA 是同一个人的人物,合并后那条会并入「${survivor?.name ?: ""}」并删除。", | |
| 689 | + style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(horizontal = 16.dp), | |
| 690 | + ) | |
| 691 | + OutlinedTextField( | |
| 692 | + value = query, onValueChange = { query = it }, singleLine = true, | |
| 693 | + placeholder = { Text("搜索人物(共 ${(all.size - 1).coerceAtLeast(0)})") }, | |
| 694 | + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), | |
| 695 | + ) | |
| 696 | + HorizontalDivider() | |
| 697 | + if (candidates.isEmpty()) { | |
| 698 | + Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { | |
| 699 | + Text("没有其它人物可合并。", style = MaterialTheme.typography.bodyMedium) | |
| 700 | + } | |
| 701 | + } else { | |
| 702 | + LazyColumn(modifier = Modifier.fillMaxSize()) { | |
| 703 | + items(candidates, key = { it.id }) { other -> | |
| 704 | + Row( | |
| 705 | + modifier = Modifier.fillMaxWidth().clickable { confirm = other }.padding(horizontal = 16.dp, vertical = 7.dp), | |
| 706 | + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), | |
| 707 | + ) { | |
| 708 | + if (other.avatarPath != null) { | |
| 709 | + AsyncImage( | |
| 710 | + model = File(other.avatarPath), contentDescription = null, contentScale = ContentScale.Crop, | |
| 711 | + modifier = Modifier.size(44.dp).clip(RoundedCornerShape(6.dp)), | |
| 712 | + ) | |
| 713 | + } else { | |
| 714 | + Box(Modifier.size(44.dp).clip(RoundedCornerShape(6.dp)).background(MaterialTheme.colorScheme.surfaceVariant)) | |
| 715 | + } | |
| 716 | + Column(Modifier.weight(1f)) { | |
| 717 | + Text(other.name, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.titleSmall) | |
| 718 | + val src = when { | |
| 719 | + other.contactRef?.startsWith("wechat:") == true -> "微信" | |
| 720 | + other.contactRef?.startsWith("phone:") == true -> "手机" | |
| 721 | + else -> null | |
| 722 | + } | |
| 723 | + val sub = listOfNotNull(src, other.phone?.takeIf { it.isNotBlank() }).joinToString(" · ") | |
| 724 | + if (sub.isNotBlank()) Text(sub, style = MaterialTheme.typography.labelSmall) | |
| 725 | + } | |
| 726 | + } | |
| 727 | + } | |
| 728 | + } | |
| 729 | + } | |
| 730 | + } | |
| 731 | + | |
| 732 | + confirm?.let { target -> | |
| 733 | + AlertDialog( | |
| 734 | + onDismissRequest = { confirm = null }, | |
| 735 | + title = { Text("确认合并") }, | |
| 736 | + text = { | |
| 737 | + Text( | |
| 738 | + "把「${target.name}」并入「${survivor?.name ?: ""}」?\n" + | |
| 739 | + "「${target.name}」的人脸、声纹、微信号/手机号、资料会归到「${survivor?.name ?: ""}」,然后「${target.name}」被删除。此操作不可撤销。", | |
| 740 | + ) | |
| 741 | + }, | |
| 742 | + confirmButton = { | |
| 743 | + TextButton(onClick = { | |
| 744 | + vm.mergePersons(fromId = target.id, intoId = survivorId) {} | |
| 745 | + confirm = null | |
| 746 | + onDone() | |
| 747 | + }) { Text("合并") } | |
| 748 | + }, | |
| 749 | + dismissButton = { TextButton(onClick = { confirm = null }) { Text("返回") } }, | |
| 750 | + ) | |
| 751 | + } | |
| 752 | +} | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt
| ... | ... | @@ -200,6 +200,47 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { |
| 200 | 200 | return true |
| 201 | 201 | } |
| 202 | 202 | |
| 203 | + /** | |
| 204 | + * 合并两个人物:把 [fromId] 并入 [intoId](intoId 存活,fromId 删除)。 | |
| 205 | + * - 人脸归属全部改派给 into; | |
| 206 | + * - into 缺的字段从 from 补:声纹 / 人脸锚点 / 头像 / 昵称-备注-微信号 / 手机号 / 真名 / 简历 / AI 印象; | |
| 207 | + * - contactRef 优先保留 wechat: 引用(聊天/朋友圈/AI 印象都靠它),其次 phone:,避免合并后丢微信记录; | |
| 208 | + * - 合并后 into 一定是 managed。 | |
| 209 | + * 注意:双方各有不同声纹时只保留 into 的;from 的声纹段需要的话另去「声纹」页合并说话人。 | |
| 210 | + */ | |
| 211 | + fun mergePersons(fromId: Long, intoId: Long, onDone: () -> Unit = {}) { | |
| 212 | + if (fromId == intoId) { onDone(); return } | |
| 213 | + viewModelScope.launch { | |
| 214 | + withContext(Dispatchers.IO) { | |
| 215 | + val from = repo.persons.get(fromId) ?: return@withContext | |
| 216 | + val into = repo.persons.get(intoId) ?: return@withContext | |
| 217 | + repo.faces.reassignPerson(fromId, intoId) | |
| 218 | + fun pick(a: String?, b: String?) = a?.takeIf { it.isNotBlank() } ?: b?.takeIf { it.isNotBlank() } | |
| 219 | + val mergedRef = listOf(into.contactRef, from.contactRef).firstOrNull { it?.startsWith("wechat:") == true } | |
| 220 | + ?: into.contactRef ?: from.contactRef | |
| 221 | + val merged = into.copy( | |
| 222 | + contactRef = mergedRef, | |
| 223 | + voiceSpeakerId = into.voiceSpeakerId ?: from.voiceSpeakerId, | |
| 224 | + faceEmbedding = into.faceEmbedding ?: from.faceEmbedding, | |
| 225 | + avatarPath = pick(into.avatarPath, from.avatarPath), | |
| 226 | + nickname = pick(into.nickname, from.nickname), | |
| 227 | + remark = pick(into.remark, from.remark), | |
| 228 | + alias = pick(into.alias, from.alias), | |
| 229 | + pinyin = pick(into.pinyin, from.pinyin), | |
| 230 | + pyInitial = pick(into.pyInitial, from.pyInitial), | |
| 231 | + phone = pick(into.phone, from.phone), | |
| 232 | + realName = pick(into.realName, from.realName), | |
| 233 | + resume = pick(into.resume, from.resume), | |
| 234 | + impression = pick(into.impression, from.impression), | |
| 235 | + managed = true, | |
| 236 | + ) | |
| 237 | + repo.persons.update(merged) | |
| 238 | + repo.persons.delete(fromId) | |
| 239 | + } | |
| 240 | + onDone() | |
| 241 | + } | |
| 242 | + } | |
| 243 | + | |
| 203 | 244 | /** 点开档案 / 互动时刷新最近访问 → 进通讯录置顶区。 */ |
| 204 | 245 | fun touchPerson(personId: Long) { |
| 205 | 246 | viewModelScope.launch { repo.persons.touch(personId, System.currentTimeMillis()) } | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt
| ... | ... | @@ -3,6 +3,7 @@ package com.lemon.lifelog.data |
| 3 | 3 | import androidx.room.Dao |
| 4 | 4 | import androidx.room.Insert |
| 5 | 5 | import androidx.room.Query |
| 6 | +import androidx.room.Update | |
| 6 | 7 | import kotlinx.coroutines.flow.Flow |
| 7 | 8 | |
| 8 | 9 | data class SegmentRow( |
| ... | ... | @@ -348,6 +349,10 @@ interface PersonDao { |
| 348 | 349 | @Query("UPDATE persons SET name = :name WHERE id = :id") |
| 349 | 350 | suspend fun updateName(id: Long, name: String) |
| 350 | 351 | |
| 352 | + /** 合并人物:写回合并后的整条记录(按主键更新)。 */ | |
| 353 | + @Update | |
| 354 | + suspend fun update(p: Person) | |
| 355 | + | |
| 351 | 356 | /** 融合:把一个声纹 SpeakerProfile 绑到这个人。 */ |
| 352 | 357 | @Query("UPDATE persons SET voiceSpeakerId = :speakerId WHERE id = :id") |
| 353 | 358 | suspend fun linkVoice(id: Long, speakerId: Long?) |
| ... | ... | @@ -432,6 +437,10 @@ interface FaceDao { |
| 432 | 437 | @Query("UPDATE faces SET personId = NULL, personSim = NULL WHERE personId = :personId") |
| 433 | 438 | suspend fun clearPersonAssignments(personId: Long) |
| 434 | 439 | |
| 440 | + /** 合并人物:把 [from] 名下所有人脸改派给 [into]。 */ | |
| 441 | + @Query("UPDATE faces SET personId = :into WHERE personId = :from") | |
| 442 | + suspend fun reassignPerson(from: Long, into: Long) | |
| 443 | + | |
| 435 | 444 | @Query("DELETE FROM faces WHERE photoId = :photoId") |
| 436 | 445 | suspend fun deleteByPhoto(photoId: Long) |
| 437 | 446 | } | ... | ... |