From 31f530a24d78da47c884ba3cd5780cd0e8c080ac Mon Sep 17 00:00:00 2001 From: yibo Date: Mon, 8 Jun 2026 18:58:20 +0800 Subject: [PATCH] feat(lifelog/photolog): 人物合并(并重复联系人为一条) --- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PersonProfileScreen.kt | 9 ++++++++- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt | 103 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt | 41 +++++++++++++++++++++++++++++++++++++++++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt | 9 +++++++++ 4 files changed, 161 insertions(+), 1 deletion(-) diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PersonProfileScreen.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PersonProfileScreen.kt index 99b9c35..600f63e 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PersonProfileScreen.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PersonProfileScreen.kt @@ -72,6 +72,7 @@ internal fun PersonProfileScreen( personId: Long, onBack: () -> Unit, onOpenPhoto: (Long) -> Unit, + onRequestMerge: () -> Unit, ) { val person by vm.repo.persons.observe(personId).collectAsState(initial = null) val photos by vm.repo.photos.observePhotosOfPerson(personId).collectAsState(initial = emptyList()) @@ -120,6 +121,7 @@ internal fun PersonProfileScreen( onRecordVoice = { vm.resetVoiceEnroll(); recordVoice = true }, onEnrich = { vm.enrichImpression(personId) }, onEditProfile = { editProfile = true }, + onMerge = onRequestMerge, ) } OutlinedTextField( @@ -196,6 +198,7 @@ internal fun PersonProfileScreen( ) } } + } /** 当场录 6 秒 → 声纹 → 绑到 TA。录音期间 24/7 服务自动暂停让麦克风。 */ @@ -253,6 +256,7 @@ private fun ProfileHeader( onRecordVoice: () -> Unit, onEnrich: () -> Unit, onEditProfile: () -> Unit, + onMerge: () -> Unit, ) { Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { @@ -297,7 +301,10 @@ private fun ProfileHeader( } Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { TextButton(onClick = onEditProfile, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 4.dp, vertical = 0.dp)) { - Text("✎ 编辑资料(真名/简历)", style = MaterialTheme.typography.labelSmall) + Text("✎ 编辑资料", style = MaterialTheme.typography.labelSmall) + } + TextButton(onClick = onMerge, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 4.dp, vertical = 0.dp)) { + Text("🔗 合并重复", style = MaterialTheme.typography.labelSmall) } TextButton(onClick = onEnrich, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 4.dp, vertical = 0.dp)) { Text(if (p.impression == null) "🧠 AI 印象" else "重新生成印象", style = MaterialTheme.typography.labelSmall) diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt index f3e342a..cdc54a8 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt @@ -91,15 +91,19 @@ internal fun ContactsTab(repo: Repository) { var detailPhotoId by remember { mutableStateOf(null) } var showPicker by remember { mutableStateOf(false) } var showPhonePicker by remember { mutableStateOf(false) } + var mergeForId by remember { mutableStateOf(null) } // 人物 tab 只显示「我管理的人物」;全部微信联系人是池子(在选人页里)。 val managed by vm.repo.persons.observeManaged().collectAsState(initial = emptyList()) val pid = profilePersonId + val mergeId = mergeForId when { + mergeId != null -> MergePersonPickerScreen(vm = vm, survivorId = mergeId, onDone = { mergeForId = null }) pid != null -> PersonProfileScreen( vm = vm, personId = pid, onBack = { profilePersonId = null }, onOpenPhoto = { detailPhotoId = it }, + onRequestMerge = { mergeForId = pid }, ) showPhonePicker -> PhoneContactPickerScreen(vm = vm, onBack = { showPhonePicker = false }) showPicker -> WeChatPickerScreen( @@ -647,3 +651,102 @@ private fun PhoneContactRow(pc: PhoneContact, checked: Boolean, onToggle: () -> } } } + +/** + * 合并重复人物:选一个跟当前人物(survivor)是同一人的人物 → 确认 → 并入 survivor 并删除被选的。 + * 候选来自全部人物池(含微信池子),可搜索;独立子页,避免把列表塞进 AlertDialog 被挤没。 + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun MergePersonPickerScreen(vm: PhotologViewModel, survivorId: Long, onDone: () -> Unit) { + val all by vm.repo.persons.observeAll().collectAsState(initial = emptyList()) + val survivor = all.firstOrNull { it.id == survivorId } + var query by remember { mutableStateOf("") } + var confirm by remember { mutableStateOf(null) } + + val candidates = remember(all, query, survivorId) { + val q = query.trim() + all.asSequence().filter { it.id != survivorId } + .filter { + q.isBlank() || it.name.contains(q, true) || it.nickname?.contains(q, true) == true || + it.remark?.contains(q, true) == true || it.alias?.contains(q, true) == true || + it.pinyin?.contains(q, true) == true || it.phone?.contains(q) == true + } + .sortedWith(compareBy({ sectionLetter(it) }, { it.pinyin ?: it.name })) + .toList() + } + + Column(modifier = Modifier.fillMaxSize()) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + TextButton(onClick = onDone) { Text("← 返回") } + Text("合并到「${survivor?.name ?: "?"}」", style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + Text( + "选一个跟 TA 是同一个人的人物,合并后那条会并入「${survivor?.name ?: ""}」并删除。", + style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(horizontal = 16.dp), + ) + OutlinedTextField( + value = query, onValueChange = { query = it }, singleLine = true, + placeholder = { Text("搜索人物(共 ${(all.size - 1).coerceAtLeast(0)})") }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), + ) + HorizontalDivider() + if (candidates.isEmpty()) { + Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + Text("没有其它人物可合并。", style = MaterialTheme.typography.bodyMedium) + } + } else { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(candidates, key = { it.id }) { other -> + Row( + modifier = Modifier.fillMaxWidth().clickable { confirm = other }.padding(horizontal = 16.dp, vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (other.avatarPath != null) { + AsyncImage( + model = File(other.avatarPath), contentDescription = null, contentScale = ContentScale.Crop, + modifier = Modifier.size(44.dp).clip(RoundedCornerShape(6.dp)), + ) + } else { + Box(Modifier.size(44.dp).clip(RoundedCornerShape(6.dp)).background(MaterialTheme.colorScheme.surfaceVariant)) + } + Column(Modifier.weight(1f)) { + Text(other.name, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.titleSmall) + val src = when { + other.contactRef?.startsWith("wechat:") == true -> "微信" + other.contactRef?.startsWith("phone:") == true -> "手机" + else -> null + } + val sub = listOfNotNull(src, other.phone?.takeIf { it.isNotBlank() }).joinToString(" · ") + if (sub.isNotBlank()) Text(sub, style = MaterialTheme.typography.labelSmall) + } + } + } + } + } + } + + confirm?.let { target -> + AlertDialog( + onDismissRequest = { confirm = null }, + title = { Text("确认合并") }, + text = { + Text( + "把「${target.name}」并入「${survivor?.name ?: ""}」?\n" + + "「${target.name}」的人脸、声纹、微信号/手机号、资料会归到「${survivor?.name ?: ""}」,然后「${target.name}」被删除。此操作不可撤销。", + ) + }, + confirmButton = { + TextButton(onClick = { + vm.mergePersons(fromId = target.id, intoId = survivorId) {} + confirm = null + onDone() + }) { Text("合并") } + }, + dismissButton = { TextButton(onClick = { confirm = null }) { Text("返回") } }, + ) + } +} diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt index d454ab0..5c566aa 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt @@ -200,6 +200,47 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { return true } + /** + * 合并两个人物:把 [fromId] 并入 [intoId](intoId 存活,fromId 删除)。 + * - 人脸归属全部改派给 into; + * - into 缺的字段从 from 补:声纹 / 人脸锚点 / 头像 / 昵称-备注-微信号 / 手机号 / 真名 / 简历 / AI 印象; + * - contactRef 优先保留 wechat: 引用(聊天/朋友圈/AI 印象都靠它),其次 phone:,避免合并后丢微信记录; + * - 合并后 into 一定是 managed。 + * 注意:双方各有不同声纹时只保留 into 的;from 的声纹段需要的话另去「声纹」页合并说话人。 + */ + fun mergePersons(fromId: Long, intoId: Long, onDone: () -> Unit = {}) { + if (fromId == intoId) { onDone(); return } + viewModelScope.launch { + withContext(Dispatchers.IO) { + val from = repo.persons.get(fromId) ?: return@withContext + val into = repo.persons.get(intoId) ?: return@withContext + repo.faces.reassignPerson(fromId, intoId) + fun pick(a: String?, b: String?) = a?.takeIf { it.isNotBlank() } ?: b?.takeIf { it.isNotBlank() } + val mergedRef = listOf(into.contactRef, from.contactRef).firstOrNull { it?.startsWith("wechat:") == true } + ?: into.contactRef ?: from.contactRef + val merged = into.copy( + contactRef = mergedRef, + voiceSpeakerId = into.voiceSpeakerId ?: from.voiceSpeakerId, + faceEmbedding = into.faceEmbedding ?: from.faceEmbedding, + avatarPath = pick(into.avatarPath, from.avatarPath), + nickname = pick(into.nickname, from.nickname), + remark = pick(into.remark, from.remark), + alias = pick(into.alias, from.alias), + pinyin = pick(into.pinyin, from.pinyin), + pyInitial = pick(into.pyInitial, from.pyInitial), + phone = pick(into.phone, from.phone), + realName = pick(into.realName, from.realName), + resume = pick(into.resume, from.resume), + impression = pick(into.impression, from.impression), + managed = true, + ) + repo.persons.update(merged) + repo.persons.delete(fromId) + } + onDone() + } + } + /** 点开档案 / 互动时刷新最近访问 → 进通讯录置顶区。 */ fun touchPerson(personId: Long) { viewModelScope.launch { repo.persons.touch(personId, System.currentTimeMillis()) } diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt index 137c0b1..00ac268 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt @@ -3,6 +3,7 @@ package com.lemon.lifelog.data import androidx.room.Dao import androidx.room.Insert import androidx.room.Query +import androidx.room.Update import kotlinx.coroutines.flow.Flow data class SegmentRow( @@ -348,6 +349,10 @@ interface PersonDao { @Query("UPDATE persons SET name = :name WHERE id = :id") suspend fun updateName(id: Long, name: String) + /** 合并人物:写回合并后的整条记录(按主键更新)。 */ + @Update + suspend fun update(p: Person) + /** 融合:把一个声纹 SpeakerProfile 绑到这个人。 */ @Query("UPDATE persons SET voiceSpeakerId = :speakerId WHERE id = :id") suspend fun linkVoice(id: Long, speakerId: Long?) @@ -432,6 +437,10 @@ interface FaceDao { @Query("UPDATE faces SET personId = NULL, personSim = NULL WHERE personId = :personId") suspend fun clearPersonAssignments(personId: Long) + /** 合并人物:把 [from] 名下所有人脸改派给 [into]。 */ + @Query("UPDATE faces SET personId = :into WHERE personId = :from") + suspend fun reassignPerson(from: Long, into: Long) + @Query("DELETE FROM faces WHERE photoId = :photoId") suspend fun deleteByPhoto(photoId: Long) } -- libgit2 0.21.4