From a61d5fc857b581c22e85b9b4dfb5636369e7a096 Mon Sep 17 00:00:00 2001 From: yibo Date: Sun, 7 Jun 2026 18:43:26 +0800 Subject: [PATCH] feat(lifelog/photolog): 人物升为底部第一个 tab + 最近联系人置顶 --- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt | 184 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------------------ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt | 129 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------------------------------------------------- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt | 10 +++++++++- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt | 11 +++++++++-- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt | 4 ++++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt | 2 ++ 6 files changed, 234 insertions(+), 106 deletions(-) diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt index bd49322..7867601 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt @@ -120,10 +120,11 @@ class MainActivity : ComponentActivity() { } private enum class Tab(val label: String, val icon: String) { + People("人物", "👤"), // 通讯录 + 联系人档案(CRM 核心,放第一位) Recording("录音", "⏺"), // 顶部子页:录音 / 时间轴 Chat("对话", "💬"), Memo("纪要", "✎"), // 顶部子页:声纹 / 纪要 - Photolog("相册", "🖼"), + Album("相册", "🖼"), Settings("设置", "⚙"), } @@ -142,7 +143,7 @@ enum class TimeFilter(val label: String) { @Composable private fun MainScreen() { - var tab by remember { mutableStateOf(Tab.Recording) } + var tab by remember { mutableStateOf(Tab.People) } val activity = androidx.compose.ui.platform.LocalContext.current val repo = remember(activity) { Repository(activity) } @@ -174,6 +175,7 @@ private fun MainScreen() { ) { inner -> Box(modifier = Modifier.padding(inner)) { when (tab) { + Tab.People -> ContactsTab(repo) Tab.Recording -> RecordingScreen( activity = activity, repo = repo, speakers = speakers, sub = recordingSub, onSub = { recordingSub = it }, @@ -187,7 +189,7 @@ private fun MainScreen() { onFilterBySpeaker = { id -> filterSpeakerId = id; recordingSub = 1; tab = Tab.Recording }, onJumpToTime = { ts -> filterSpeakerId = null; jumpToTs = ts; recordingSub = 1; tab = Tab.Recording }, ) - Tab.Photolog -> PhotologTab(repo) + Tab.Album -> AlbumTab(repo) Tab.Settings -> SettingsScreen(activity) } } @@ -430,27 +432,41 @@ private fun TimelineTab( if (filterSpeakerId != null) l = l.filter { it.speakerId == filterSpeakerId } timeCutoff(timeFilter)?.let { cutoff -> l = l.filter { it.startTs >= cutoff } } if (unmatchedOnly) l = l.filter { it.speakerId == null || (it.speakerSim != null && it.speakerSim < 0.55f) } - when (sortMode) { - SortMode.TimeDesc -> l.sortedByDescending { it.startTs } - SortMode.TimeAsc -> l.sortedBy { it.startTs } - SortMode.DurationDesc -> l.sortedByDescending { it.durationMs } + // 仅用于"全选/导出/计数"的平铺集合;展示按时间段分组(见下)。 + l.sortedByDescending { it.startTs } + } + + // 按「时间段」分组:turn 间隔 ≥ 5min(= 录音块静音边界)即切新段。一整场连续录音 + // (中间无 5min 静音)合成一个时间段,段内按声纹逐 turn 顺序展示。 + val periods = remember(shown, sortMode) { buildTimelinePeriods(shown, sortMode) } + val collapsed = remember { mutableStateListOf() } // 收起的时间段(key=段 startTs) + val displayItems = remember(periods, collapsed.toList()) { + buildList { + for (p in periods) { + add(TlItem.Head(p)) + if (!collapsed.contains(p.key)) p.rows.forEach { add(TlItem.Turn(it)) } + } } } - // Jump from a summary bullet: reset filters, scroll to the closest segment, highlight it. - LaunchedEffect(jumpToTs, base) { + // Jump from a summary bullet: reset filters, expand+scroll to the target period, highlight. + LaunchedEffect(jumpToTs, periods) { val target = jumpToTs ?: return@LaunchedEffect - if (base.isEmpty()) return@LaunchedEffect + if (periods.isEmpty()) return@LaunchedEffect searchQuery = "" sortMode = SortMode.TimeDesc timeFilter = TimeFilter.All unmatchedOnly = false - val sorted = base.sortedByDescending { it.startTs } - val idx = sorted.indices.minByOrNull { kotlin.math.abs(sorted[it].startTs - target) } - if (idx != null) { - highlightId = sorted[idx].id - lazyState.scrollToItem(idx) - } + // 找含目标时间最近 turn 的时间段,展开它,滚到其段头。 + val pIdx = periods.indices.minByOrNull { pi -> + periods[pi].rows.minOf { kotlin.math.abs(it.startTs - target) } + } ?: return@LaunchedEffect + val p = periods[pIdx] + collapsed.remove(p.key) + highlightId = p.rows.minByOrNull { kotlin.math.abs(it.startTs - target) }?.id + var idx = 0 + for (i in 0 until pIdx) idx += 1 + if (!collapsed.contains(periods[i].key)) periods[i].rows.size else 0 + lazyState.scrollToItem(idx) onJumpConsumed() } LaunchedEffect(highlightId) { @@ -560,21 +576,38 @@ private fun TimelineTab( modifier = Modifier.fillMaxWidth().weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp), ) { - items(shown, key = { it.id }) { row -> - TimelineRow( - row = row, - isPlaying = nowPlayingId == row.id, - selecting = selecting, - selected = selected.contains(row.id), - highlighted = highlightId == row.id, - onClick = { - if (selecting) { - if (selected.contains(row.id)) selected.remove(row.id) else selected.add(row.id) - } else { - actionRow = row - } - }, - ) + items( + displayItems, + key = { if (it is TlItem.Head) "h${it.period.key}" else "t${(it as TlItem.Turn).row.id}" }, + ) { item -> + when (item) { + is TlItem.Head -> TimelinePeriodHeader( + period = item.period, + collapsed = collapsed.contains(item.period.key), + onToggle = { + if (collapsed.contains(item.period.key)) collapsed.remove(item.period.key) + else collapsed.add(item.period.key) + }, + ) + is TlItem.Turn -> { + val row = item.row + TimelineRow( + row = row, + isPlaying = nowPlayingId == row.id, + selecting = selecting, + selected = selected.contains(row.id), + highlighted = highlightId == row.id, + indent = true, + onClick = { + if (selecting) { + if (selected.contains(row.id)) selected.remove(row.id) else selected.add(row.id) + } else { + actionRow = row + } + }, + ) + } + } } } } @@ -1314,9 +1347,9 @@ private fun TimelineRow( selecting: Boolean = false, selected: Boolean = false, highlighted: Boolean = false, + indent: Boolean = false, ) { val time = TIMELINE_FMT.format(Date(row.startTs)) - val durSec = "%.1fs".format(row.durationMs / 1000.0) val speakerLabel = when { row.speakerName != null && row.speakerSim != null -> "${row.speakerName} (${"%.2f".format(row.speakerSim)})" row.speakerName != null -> row.speakerName @@ -1326,24 +1359,103 @@ private fun TimelineRow( val playMarker = if (isPlaying) "▶ " else "" val bg = if (highlighted) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent Row( - modifier = Modifier.fillMaxWidth().background(bg).clickable(onClick = onClick).padding(vertical = 4.dp), + modifier = Modifier.fillMaxWidth().background(bg).clickable(onClick = onClick) + .padding(start = if (indent) 14.dp else 0.dp, top = 3.dp, bottom = 3.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { if (selecting) { Text(if (selected) "☑" else "☐", style = MaterialTheme.typography.titleMedium) } Column(modifier = Modifier.weight(1f)) { - Text("$playMarker$time · $durSec · #${row.id} · $speakerLabel", style = MaterialTheme.typography.labelSmall) + Text("$playMarker$time · $speakerLabel", style = MaterialTheme.typography.labelSmall) Text( - row.text ?: "(no transcript yet)", + row.text ?: "(无文字)", style = MaterialTheme.typography.bodyMedium, - maxLines = 3, + maxLines = 4, overflow = TextOverflow.Ellipsis, ) } } } +// ---- 时间段分组(按 5min 静音切,= 录音块边界)---- + +private const val TIMELINE_GROUP_GAP_MS = 5 * 60 * 1000L + +/** 一个「时间段」:起止时间 + 段内 turn(按时间升序,对话式阅读)+ 去重说话人名单。 */ +private class TimelinePeriod( + val key: Long, // = 段首 turn 的 startTs + val startTs: Long, + val endTs: Long, + val rows: List, + val speakers: List, +) + +private sealed interface TlItem { + data class Head(val period: TimelinePeriod) : TlItem + data class Turn(val row: SegmentRow) : TlItem +} + +/** 把(已过滤的)turn 列表按 5min 间隔切成时间段;段间顺序随 [sortMode],段内恒时间升序。 */ +private fun buildTimelinePeriods(rows: List, sortMode: SortMode): List { + if (rows.isEmpty()) return emptyList() + val asc = rows.sortedBy { it.startTs } + val groups = ArrayList>() + var cur = mutableListOf(asc[0]) + for (i in 1 until asc.size) { + if (asc[i].startTs - asc[i - 1].startTs >= TIMELINE_GROUP_GAP_MS) { + groups.add(cur); cur = mutableListOf() + } + cur.add(asc[i]) + } + groups.add(cur) + val periods = groups.map { g -> + val last = g.last() + TimelinePeriod( + key = g.first().startTs, + startTs = g.first().startTs, + endTs = last.startTs + last.durationMs, + rows = g, + speakers = g.mapNotNull { it.speakerName }.distinct(), + ) + } + return when (sortMode) { + SortMode.TimeAsc -> periods + SortMode.TimeDesc -> periods.reversed() + SortMode.DurationDesc -> periods.sortedByDescending { it.endTs - it.startTs } + } +} + +private fun humanDur(ms: Long): String { + val s = ms / 1000 + val h = s / 3600; val m = (s % 3600) / 60; val sec = s % 60 + return when { + h > 0 -> "${h}h${m}m" + m > 0 -> "${m}m" + else -> "${sec}s" + } +} + +private val PERIOD_HEAD_FMT = SimpleDateFormat("MM-dd HH:mm", Locale.US) +private val PERIOD_END_FMT = SimpleDateFormat("HH:mm", Locale.US) + +@Composable +private fun TimelinePeriodHeader(period: TimelinePeriod, collapsed: Boolean, onToggle: () -> Unit) { + val range = "${PERIOD_HEAD_FMT.format(Date(period.startTs))}–${PERIOD_END_FMT.format(Date(period.endTs))}" + val spk = if (period.speakers.isEmpty()) "未识别" else period.speakers.joinToString("·") + Row( + modifier = Modifier.fillMaxWidth().clickable(onClick = onToggle).padding(top = 10.dp, bottom = 4.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(if (collapsed) "▸" else "▾", style = MaterialTheme.typography.titleSmall) + Column(modifier = Modifier.weight(1f)) { + Text("$range · ${humanDur(period.endTs - period.startTs)}", style = MaterialTheme.typography.titleSmall) + Text("${period.rows.size} 段 · $spk", style = MaterialTheme.typography.labelSmall) + } + } +} + private val EXPORT_FMT = SimpleDateFormat("MM-dd HH:mm:ss", Locale.US) /** Build a plain-text transcript and fire a share/save chooser. */ 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 7c60e68..8f4dedc 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 @@ -75,27 +75,14 @@ import java.util.Locale private val PHOTO_FMT = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US) +/** 「人物」一级 tab:通讯录 + 联系人档案页。 */ @OptIn(ExperimentalMaterial3Api::class) @Composable -internal fun PhotologTab(repo: Repository) { +internal fun ContactsTab(repo: Repository) { val vm: PhotologViewModel = viewModel() - var view by remember { mutableStateOf(0) } // 0=照片 1=人物 - var filterPersonId by remember { mutableStateOf(null) } - var detailPhotoId by remember { mutableStateOf(null) } var profilePersonId by remember { mutableStateOf(null) } - + var detailPhotoId by remember { mutableStateOf(null) } val persons by vm.repo.persons.observeAll().collectAsState(initial = emptyList()) - val pendingCount by vm.repo.photos.observeUnprocessedCount().collectAsState(initial = 0) - val personName = remember(persons, filterPersonId) { persons.firstOrNull { it.id == filterPersonId }?.name } - - val photos by remember(filterPersonId) { - val pid = filterPersonId - if (pid == null) vm.repo.photos.observeGrid(1000) else vm.repo.photos.observePhotosOfPerson(pid) - }.collectAsState(initial = emptyList()) - - val picker = rememberLauncherForActivityResult( - ActivityResultContracts.PickMultipleVisualMedia(30), - ) { uris -> vm.importUris(uris) } val pid = profilePersonId if (pid != null) { @@ -104,65 +91,60 @@ internal fun PhotologTab(repo: Repository) { onBack = { profilePersonId = null }, onOpenPhoto = { detailPhotoId = it }, ) - detailPhotoId?.let { id -> - PhotoDetailSheet(vm = vm, photoId = id, persons = persons, onDismiss = { detailPhotoId = null }) - } - return + } else { + ContactList(vm = vm, persons = persons, onOpen = { vm.touchPerson(it); profilePersonId = it }) + } + detailPhotoId?.let { id -> + PhotoDetailSheet(vm = vm, photoId = id, persons = persons, onDismiss = { detailPhotoId = null }) } +} - Column(modifier = Modifier.fillMaxSize()) { - TabRow(selectedTabIndex = view) { - Tab(selected = view == 0, onClick = { view = 0 }, text = { Text("照片") }) - Tab(selected = view == 1, onClick = { view = 1 }, text = { Text("人物") }) - } +/** 「相册」一级 tab:照片网格(导入 + 详情 + 标注)。 */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun AlbumTab(repo: Repository) { + val vm: PhotologViewModel = viewModel() + var detailPhotoId by remember { mutableStateOf(null) } + val persons by vm.repo.persons.observeAll().collectAsState(initial = emptyList()) + val pendingCount by vm.repo.photos.observeUnprocessedCount().collectAsState(initial = 0) + val photos by remember { vm.repo.photos.observeGrid(1000) }.collectAsState(initial = emptyList()) - if (view == 0) { - // 操作行:导入 + 人物筛选 + 处理进度。 - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Button(onClick = { - picker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) - }) { Text("+ 导入照片") } - if (filterPersonId != null) { - FilterChip(selected = true, onClick = { filterPersonId = null }, label = { Text("👤 ${personName ?: "?"} ✕") }) - } - if (pendingCount > 0) { - CircularProgressIndicator(modifier = Modifier.height(18.dp), strokeWidth = 2.dp) - Text("待处理 $pendingCount(后台识别中)", style = MaterialTheme.typography.labelSmall) - } - } + val picker = rememberLauncherForActivityResult( + ActivityResultContracts.PickMultipleVisualMedia(30), + ) { uris -> vm.importUris(uris) } - if (photos.isEmpty()) { - Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { - Text( - if (pendingCount > 0) "已导入 $pendingCount 张,后台识别中…稍后刷新" else "还没有照片。点「+ 导入照片」加入相册。\n导入瞬时完成,人脸+场景识别后台定时处理。", - style = MaterialTheme.typography.bodyMedium, - ) - } - } else { - LazyVerticalGrid( - columns = GridCells.Fixed(3), - modifier = Modifier.fillMaxSize().padding(horizontal = 4.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - gridItems(photos, key = { it.id }) { p -> - PhotoCell(p) { detailPhotoId = p.id } - } - } + Column(modifier = Modifier.fillMaxSize()) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button(onClick = { + picker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) + }) { Text("+ 导入照片") } + if (pendingCount > 0) { + CircularProgressIndicator(modifier = Modifier.height(18.dp), strokeWidth = 2.dp) + Text("待处理 $pendingCount(后台识别中)", style = MaterialTheme.typography.labelSmall) + } + } + if (photos.isEmpty()) { + Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + Text( + if (pendingCount > 0) "已导入 $pendingCount 张,后台识别中…稍后刷新" else "还没有照片。点「+ 导入照片」加入相册。\n导入瞬时完成,人脸+场景识别后台定时处理。", + style = MaterialTheme.typography.bodyMedium, + ) } } else { - ContactList( - vm = vm, - persons = persons, - onOpen = { id -> profilePersonId = id }, - ) + LazyVerticalGrid( + columns = GridCells.Fixed(3), + modifier = Modifier.fillMaxSize().padding(horizontal = 4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + gridItems(photos, key = { it.id }) { p -> PhotoCell(p) { detailPhotoId = p.id } } + } } } - detailPhotoId?.let { id -> PhotoDetailSheet(vm = vm, photoId = id, persons = persons, onDismiss = { detailPhotoId = null }) } @@ -349,6 +331,11 @@ private fun ContactList(vm: PhotologViewModel, persons: List, onOpen: (L .groupBy { sectionLetter(it) } .toSortedMap(compareBy { if (it == "#") "[" else it }) // # 排最后 } + // 最近联系人(点过 / 有过互动),置顶;搜索时不显示。 + val recent = remember(persons, query) { + if (query.isNotBlank()) emptyList() + else persons.filter { it.lastAccess > 0 }.sortedByDescending { it.lastAccess }.take(12) + } Column(modifier = Modifier.fillMaxSize()) { OutlinedTextField( @@ -375,6 +362,14 @@ private fun ContactList(vm: PhotologViewModel, persons: List, onOpen: (L } } else { LazyColumn(modifier = Modifier.fillMaxSize()) { + if (recent.isNotEmpty()) { + stickyHeader { + Surface(color = MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier.fillMaxWidth()) { + Text("★ 最近联系人", style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(horizontal = 16.dp, vertical = 3.dp)) + } + } + items(recent, key = { "r${it.id}" }) { p -> ContactRow(p) { onOpen(p.id) } } + } sections.forEach { (letter, group) -> stickyHeader { Surface(color = MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier.fillMaxWidth()) { 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 4204005..b7c4ec3 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 @@ -76,9 +76,17 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { viewModelScope.launch { repo.persons.updateName(personId, name.trim()) } } + /** 点开档案 / 互动时刷新最近访问 → 进通讯录置顶区。 */ + fun touchPerson(personId: Long) { + viewModelScope.launch { repo.persons.touch(personId, System.currentTimeMillis()) } + } + /** 融合:把一个声纹绑到联系人(null = 解绑)。绑定后档案页"录音列表"才有内容。 */ fun bindVoice(personId: Long, speakerId: Long?) { - viewModelScope.launch { repo.persons.linkVoice(personId, speakerId) } + viewModelScope.launch { + repo.persons.linkVoice(personId, speakerId) + if (speakerId != null) repo.persons.touch(personId, System.currentTimeMillis()) + } } /** AI 印象:汇总 TA 的朋友圈 + 聊天,本地 4B LLM 生成画像 + 标签(爱好/性格)。 */ diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt index 7502c7f..3bc572f 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt @@ -22,7 +22,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase WechatMessage::class, Moment::class, ], - version = 9, + version = 10, exportSchema = false, ) abstract class AppDatabase : RoomDatabase() { @@ -103,6 +103,13 @@ abstract class AppDatabase : RoomDatabase() { } } + /** 通讯录「最近联系人」置顶:persons.lastAccess。 */ + private val MIGRATION_9_10 = object : Migration(9, 10) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `persons` ADD COLUMN `lastAccess` INTEGER NOT NULL DEFAULT 0") + } + } + fun get(ctx: Context): AppDatabase = instance ?: synchronized(this) { instance ?: Room.databaseBuilder( @@ -110,7 +117,7 @@ abstract class AppDatabase : RoomDatabase() { AppDatabase::class.java, "lifelog.db", ) - .addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_9) + .addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_9, MIGRATION_9_10) // 不再 fallbackToDestructiveMigration:以后改 schema 必须写 Migration, // 否则启动直接抛 IllegalStateException(fail-loud),绝不静默清掉真实录音。 .build() 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 026cec5..fdf42d7 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 @@ -269,6 +269,10 @@ interface PersonDao { @Query("UPDATE persons SET impression = :text WHERE id = :id") suspend fun updateImpression(id: Long, text: String) + /** 刷新最近访问时间(点开档案/绑声纹时调,进「最近联系人」置顶)。 */ + @Query("UPDATE persons SET lastAccess = :ts WHERE id = :id") + suspend fun touch(id: Long, ts: Long) + @Query("UPDATE persons SET name = :name WHERE id = :id") suspend fun updateName(id: Long, name: String) diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt index e4bdad9..f89b1f8 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt @@ -106,6 +106,8 @@ data class Person( val phone: String? = null, // 手机号(短信关联) /** AI 印象:本地 LLM 综合 TA 的朋友圈/聊天生成的一句话画像 + 标签(爱好/性格)。 */ val impression: String? = null, + /** 最近访问/互动时间(点开档案、绑声纹等会刷新);>0 进通讯录「最近联系人」置顶区。 */ + val lastAccess: Long = 0, val createdAt: Long = System.currentTimeMillis(), ) -- libgit2 0.21.4