Commit 727ff70e0e73e0b37b47460a19b8baa8648bedf3
1 parent
660b6ec5
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,6 +72,7 @@ internal fun PersonProfileScreen( | ||
| 72 | personId: Long, | 72 | personId: Long, |
| 73 | onBack: () -> Unit, | 73 | onBack: () -> Unit, |
| 74 | onOpenPhoto: (Long) -> Unit, | 74 | onOpenPhoto: (Long) -> Unit, |
| 75 | + onRequestMerge: () -> Unit, | ||
| 75 | ) { | 76 | ) { |
| 76 | val person by vm.repo.persons.observe(personId).collectAsState(initial = null) | 77 | val person by vm.repo.persons.observe(personId).collectAsState(initial = null) |
| 77 | val photos by vm.repo.photos.observePhotosOfPerson(personId).collectAsState(initial = emptyList()) | 78 | val photos by vm.repo.photos.observePhotosOfPerson(personId).collectAsState(initial = emptyList()) |
| @@ -120,6 +121,7 @@ internal fun PersonProfileScreen( | @@ -120,6 +121,7 @@ internal fun PersonProfileScreen( | ||
| 120 | onRecordVoice = { vm.resetVoiceEnroll(); recordVoice = true }, | 121 | onRecordVoice = { vm.resetVoiceEnroll(); recordVoice = true }, |
| 121 | onEnrich = { vm.enrichImpression(personId) }, | 122 | onEnrich = { vm.enrichImpression(personId) }, |
| 122 | onEditProfile = { editProfile = true }, | 123 | onEditProfile = { editProfile = true }, |
| 124 | + onMerge = onRequestMerge, | ||
| 123 | ) | 125 | ) |
| 124 | } | 126 | } |
| 125 | OutlinedTextField( | 127 | OutlinedTextField( |
| @@ -196,6 +198,7 @@ internal fun PersonProfileScreen( | @@ -196,6 +198,7 @@ internal fun PersonProfileScreen( | ||
| 196 | ) | 198 | ) |
| 197 | } | 199 | } |
| 198 | } | 200 | } |
| 201 | + | ||
| 199 | } | 202 | } |
| 200 | 203 | ||
| 201 | /** 当场录 6 秒 → 声纹 → 绑到 TA。录音期间 24/7 服务自动暂停让麦克风。 */ | 204 | /** 当场录 6 秒 → 声纹 → 绑到 TA。录音期间 24/7 服务自动暂停让麦克风。 */ |
| @@ -253,6 +256,7 @@ private fun ProfileHeader( | @@ -253,6 +256,7 @@ private fun ProfileHeader( | ||
| 253 | onRecordVoice: () -> Unit, | 256 | onRecordVoice: () -> Unit, |
| 254 | onEnrich: () -> Unit, | 257 | onEnrich: () -> Unit, |
| 255 | onEditProfile: () -> Unit, | 258 | onEditProfile: () -> Unit, |
| 259 | + onMerge: () -> Unit, | ||
| 256 | ) { | 260 | ) { |
| 257 | Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp)) { | 261 | Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp)) { |
| 258 | Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { | 262 | Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { |
| @@ -297,7 +301,10 @@ private fun ProfileHeader( | @@ -297,7 +301,10 @@ private fun ProfileHeader( | ||
| 297 | } | 301 | } |
| 298 | Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { | 302 | Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { |
| 299 | TextButton(onClick = onEditProfile, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 4.dp, vertical = 0.dp)) { | 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 | TextButton(onClick = onEnrich, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 4.dp, vertical = 0.dp)) { | 309 | TextButton(onClick = onEnrich, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 4.dp, vertical = 0.dp)) { |
| 303 | Text(if (p.impression == null) "🧠 AI 印象" else "重新生成印象", style = MaterialTheme.typography.labelSmall) | 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,15 +91,19 @@ internal fun ContactsTab(repo: Repository) { | ||
| 91 | var detailPhotoId by remember { mutableStateOf<Long?>(null) } | 91 | var detailPhotoId by remember { mutableStateOf<Long?>(null) } |
| 92 | var showPicker by remember { mutableStateOf(false) } | 92 | var showPicker by remember { mutableStateOf(false) } |
| 93 | var showPhonePicker by remember { mutableStateOf(false) } | 93 | var showPhonePicker by remember { mutableStateOf(false) } |
| 94 | + var mergeForId by remember { mutableStateOf<Long?>(null) } | ||
| 94 | // 人物 tab 只显示「我管理的人物」;全部微信联系人是池子(在选人页里)。 | 95 | // 人物 tab 只显示「我管理的人物」;全部微信联系人是池子(在选人页里)。 |
| 95 | val managed by vm.repo.persons.observeManaged().collectAsState(initial = emptyList()) | 96 | val managed by vm.repo.persons.observeManaged().collectAsState(initial = emptyList()) |
| 96 | 97 | ||
| 97 | val pid = profilePersonId | 98 | val pid = profilePersonId |
| 99 | + val mergeId = mergeForId | ||
| 98 | when { | 100 | when { |
| 101 | + mergeId != null -> MergePersonPickerScreen(vm = vm, survivorId = mergeId, onDone = { mergeForId = null }) | ||
| 99 | pid != null -> PersonProfileScreen( | 102 | pid != null -> PersonProfileScreen( |
| 100 | vm = vm, personId = pid, | 103 | vm = vm, personId = pid, |
| 101 | onBack = { profilePersonId = null }, | 104 | onBack = { profilePersonId = null }, |
| 102 | onOpenPhoto = { detailPhotoId = it }, | 105 | onOpenPhoto = { detailPhotoId = it }, |
| 106 | + onRequestMerge = { mergeForId = pid }, | ||
| 103 | ) | 107 | ) |
| 104 | showPhonePicker -> PhoneContactPickerScreen(vm = vm, onBack = { showPhonePicker = false }) | 108 | showPhonePicker -> PhoneContactPickerScreen(vm = vm, onBack = { showPhonePicker = false }) |
| 105 | showPicker -> WeChatPickerScreen( | 109 | showPicker -> WeChatPickerScreen( |
| @@ -647,3 +651,102 @@ private fun PhoneContactRow(pc: PhoneContact, checked: Boolean, onToggle: () -> | @@ -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,6 +200,47 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { | ||
| 200 | return true | 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 | fun touchPerson(personId: Long) { | 245 | fun touchPerson(personId: Long) { |
| 205 | viewModelScope.launch { repo.persons.touch(personId, System.currentTimeMillis()) } | 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,6 +3,7 @@ package com.lemon.lifelog.data | ||
| 3 | import androidx.room.Dao | 3 | import androidx.room.Dao |
| 4 | import androidx.room.Insert | 4 | import androidx.room.Insert |
| 5 | import androidx.room.Query | 5 | import androidx.room.Query |
| 6 | +import androidx.room.Update | ||
| 6 | import kotlinx.coroutines.flow.Flow | 7 | import kotlinx.coroutines.flow.Flow |
| 7 | 8 | ||
| 8 | data class SegmentRow( | 9 | data class SegmentRow( |
| @@ -348,6 +349,10 @@ interface PersonDao { | @@ -348,6 +349,10 @@ interface PersonDao { | ||
| 348 | @Query("UPDATE persons SET name = :name WHERE id = :id") | 349 | @Query("UPDATE persons SET name = :name WHERE id = :id") |
| 349 | suspend fun updateName(id: Long, name: String) | 350 | suspend fun updateName(id: Long, name: String) |
| 350 | 351 | ||
| 352 | + /** 合并人物:写回合并后的整条记录(按主键更新)。 */ | ||
| 353 | + @Update | ||
| 354 | + suspend fun update(p: Person) | ||
| 355 | + | ||
| 351 | /** 融合:把一个声纹 SpeakerProfile 绑到这个人。 */ | 356 | /** 融合:把一个声纹 SpeakerProfile 绑到这个人。 */ |
| 352 | @Query("UPDATE persons SET voiceSpeakerId = :speakerId WHERE id = :id") | 357 | @Query("UPDATE persons SET voiceSpeakerId = :speakerId WHERE id = :id") |
| 353 | suspend fun linkVoice(id: Long, speakerId: Long?) | 358 | suspend fun linkVoice(id: Long, speakerId: Long?) |
| @@ -432,6 +437,10 @@ interface FaceDao { | @@ -432,6 +437,10 @@ interface FaceDao { | ||
| 432 | @Query("UPDATE faces SET personId = NULL, personSim = NULL WHERE personId = :personId") | 437 | @Query("UPDATE faces SET personId = NULL, personSim = NULL WHERE personId = :personId") |
| 433 | suspend fun clearPersonAssignments(personId: Long) | 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 | @Query("DELETE FROM faces WHERE photoId = :photoId") | 444 | @Query("DELETE FROM faces WHERE photoId = :photoId") |
| 436 | suspend fun deleteByPhoto(photoId: Long) | 445 | suspend fun deleteByPhoto(photoId: Long) |
| 437 | } | 446 | } |