feat(chat): 会话内消息搜索 D2 —— 顶栏入口 + 搜索态面板 + 点结果定位
改了什么 - data/api/SessionsApi.kt:messages 端点加可选 q(契约 §1 行 6:q 是同一个端点上的 查询参数,不是新端点)。不传 q 时 Retrofit 整个省掉该参数,既有调用线上请求逐字不变 —— 这条用真 HTTP 服务端读原始 URI 钉住,而不是靠读代码相信。 - data/repository/MessageRepository.kt:新增 search()(走既有 apiCall/Result 风格, 倒序返回统一转成升序的 PagedMessages),以及 normalizeSearchQuery(只 trim: 不转义 % _ \、不改大小写 —— 这两件事服务端已做,两边都做必然错一边)。 空查询不发请求(契约里空 q = 未传,那会把整段历史当成搜索结果回给调用方)。 - ui/chat/SessionSearchModel.kt(新):全部纯逻辑 —— 命中片段投影(先过 domain/parse 这一处解析器再拼回纯文本,绝不把标记当富文本;标记里才有的词只显示不高亮)、 结果合并去重排序(倒序,与聊天页的升序 MessageOrder 刻意相反)、游标推进、 「这条在不在窗口里」的判定、失败文案。 - ui/chat/SessionSearchViewModel.kt(新):只属于聊天页搜索态的小 VM,带代际号, 连打两次回车时慢的那个请求回来不会顶掉新关键词的结果。 - ui/chat/SessionSearchPanel.kt(新):全屏搜索面板 —— 自动聚焦输入框(IME 搜索键与 放大镜都能提交)+ 结果列表(角色 / 时间 / 命中片段,BasicComponent 包 PreferenceCard) + 往下翻加载更早的命中 + 未搜/搜索中/空结果/失败 四态分开渲染。 - ui/chat/ChatScreen.kt:只加接线 —— 顶栏搜索入口、面板叠层、返回键先关搜索、 点结果之后的如实告知。 为什么这么做 - 搜索态独立成小 VM:ChatViewModel 正被另一个并行模块改动,而搜索不改消息窗、不发消息、 不碰 WS,没有任何理由挤进去。 - 面板叠在聊天页之上而不是替换它:底下的 LazyColumn 必须保持挂载,点一条命中要 animateScrollToItem 定位,而 LazyColumn 一旦被移出组合,滚动就落空。 - 命中不在「已加载窗口」里时不插一条临时气泡,而是如实提示(比一个假的跳转诚实): 聊天列表是升序倒过来喂给 reverseLayout 的,凭空插一条几千条之前的消息会出现在列表最底 ——也就是「刚刚发出的位置」——与它自己的时间戳矛盾,还会带偏跟随底部的判断; 而把消息真的并进窗口要改 ChatViewModel,本模块不许。 - 失败绝不渲染成「没有找到」:两者都是一张空列表,但一个该换关键词、一个该修网络或权限。 - 返回键先关搜索:新处理器注册在 ChatHomeScreen 那几条之后(优先级更高), 且搜索没开时是 disabled,抽屉 / 管理页 / 设置页的既有层级不变。 测试 706 -> 737(+31:纯逻辑 25 + 数据层 6)
This commit is contained in:
1 parent
76d49b73d1
commit
23b46f9310
8 files changed
+1456
-106
No files matched your search
@@ -68,9 +68,18 @@ interface SessionsApi {
|
||||
|
||||
/**
|
||||
* 消息热分页,服务端按 `createdAt` 倒序(newest first)返回。
|
||||
*
|
||||
* 普通分页与**会话内内容搜索**共用这一个端点(契约 §1 行 6:`q` 是 messages 端点上的一个
|
||||
* 可选查询参数,不是另一个端点)。
|
||||
*
|
||||
* @param limit 默认 50、上限 200(由 [top.yeij.cyrene.data.repository.MessageRepository] 钳制)
|
||||
* @param before 上一页最旧消息的 `createdAt`(Unix 毫秒);null = 最新一页
|
||||
* @param platform 来源平台过滤;null = 全量
|
||||
* @param query `q`:**会话内内容搜索**(D2)。null / 空 = 未传,走普通分页——线上请求因此与本端
|
||||
* 支持搜索之前**逐字节相同**(Retrofit 会整个省掉 null 的查询参数)。非空时服务端按
|
||||
* 大小写不敏感的 ASCII LIKE 匹配 `content`(`%` / `_` / `\` 已转义为字面量),
|
||||
* 仍按时间倒序、`before` / `limit` 同分页语义,搜索**严格限定在 `{id}` 会话内**。
|
||||
* **`q` 与 `after` 互斥**(同传 `400`)——本方法本来就没有 `after` 参数,天然不冲突。
|
||||
*/
|
||||
@GET("api/v1/sessions/{id}/messages")
|
||||
suspend fun listMessages(
|
||||
@@ -78,6 +87,7 @@ interface SessionsApi {
|
||||
@Query("limit") limit: Int? = null,
|
||||
@Query("before") before: Long? = null,
|
||||
@Query("platform") platform: String? = null,
|
||||
@Query("q") query: String? = null,
|
||||
): Response<ApiEnvelope<List<HistoryMessageDto>>>
|
||||
|
||||
/**
|
||||
|
||||
@@ -41,12 +41,57 @@ class MessageRepository(
|
||||
platform: String? = null,
|
||||
): Result<PagedMessages> = loadPage(sessionId, limit, before = null, platform = platform)
|
||||
|
||||
/**
|
||||
* 会话内**内容搜索**(D2,契约 §1 行 6)。
|
||||
*
|
||||
* 与 [loadPage] 走的是**同一个端点、同一套分页语义**,唯一差别是会带上 `q`:
|
||||
* 服务端按大小写不敏感的 ASCII LIKE 在**本会话内**匹配 `content`(`%` / `_` / `\` 已转义为
|
||||
* 字面量),仍按 `createdAt` 倒序返回,`before` 仍是「上一页最旧那条的时间戳」。
|
||||
* 搜索绝不跨会话,所以这里没有 `platform` 参数——那是我方对多端来源的过滤,与搜索正交。
|
||||
*
|
||||
* **空查询不发请求**:契约里「空 `q` = 未传」意味着服务端会退化成普通分页,
|
||||
* 把整段历史当成搜索结果回给调用方——那比不发更像一个谎。所以空/纯空白一律返回一个
|
||||
* 空页(真实的「没有命中」由调用方在**已提交过查询**的前提下渲染)。
|
||||
*/
|
||||
suspend fun search(
|
||||
sessionId: String,
|
||||
query: String,
|
||||
limit: Int = DEFAULT_SEARCH_PAGE_SIZE,
|
||||
before: Long? = null,
|
||||
): Result<PagedMessages> = withContext(dispatcher) {
|
||||
val normalized = normalizeSearchQuery(query)
|
||||
if (normalized == null) return@withContext Result.success(EMPTY_PAGE)
|
||||
val pageSize = limit.coerceIn(1, MAX_PAGE_SIZE)
|
||||
apiCall { api.listMessages(sessionId, pageSize, before, platform = null, query = normalized) }
|
||||
.map { raw -> toPagedMessages(raw, pageSize) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_PAGE_SIZE = 50
|
||||
const val MAX_PAGE_SIZE = 200
|
||||
|
||||
/** 搜索结果一页的条数:命中项要一条条读,比历史分页小一些更好翻。 */
|
||||
const val DEFAULT_SEARCH_PAGE_SIZE = 30
|
||||
|
||||
private val EMPTY_PAGE = PagedMessages(
|
||||
messages = emptyList(),
|
||||
hasMore = false,
|
||||
oldestCreatedAt = null,
|
||||
newestCreatedAt = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 规整搜索关键词:去首尾空白后仍为空 → null(调用方据此**不发请求**,见
|
||||
* [MessageRepository.search])。
|
||||
*
|
||||
* 只做 trim,**不改大小写、不转义 `%` / `_` / `\`**:服务端自己把这三个字符当字面量处理
|
||||
* (契约 §1 行 6),客户端再转一次会把 `\%` 这种输入搜成别的东西——两边都转义必然有一边是错的。
|
||||
* 大小写同理:匹配由服务端做 ASCII 不敏感比较,客户端保留用户原样输入。
|
||||
*/
|
||||
fun normalizeSearchQuery(raw: String): String? = raw.trim().takeIf { it.isNotEmpty() }
|
||||
|
||||
private fun toPagedMessages(
|
||||
rawNewestFirst: List<HistoryMessageDto>,
|
||||
pageSize: Int,
|
||||
|
||||
Reference in new issue
Block a user