Commit 31f530a24d78da47c884ba3cd5780cd0e8c080ac

Authored by yibo
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>
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: () -&gt;
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 }
... ...