fix(staffchat): 搜索聚合取最新命中(ASC页1陷阱)+tab切换不清搜索结果+is_send守卫兜底名+截断提示+TAB_KEYS单源

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
浅念
2026-08-25 17:42:43 +08:00
parent 0f7dee9693
commit 026381f6e6
3 changed files with 47 additions and 12 deletions
@@ -29,11 +29,21 @@ const errorMsg = ref<string | null>(null)
const pinnedOpen = ref(true) const pinnedOpen = ref(true)
/** search 模式聚合结果(q 命中的消息按会话去重,取首条作为该会话快照)。 */ /** search 模式聚合结果(q 命中的消息按会话去重,取首条作为该会话快照)。 */
const searchResults = ref<ChatSession[]>([]) const searchResults = ref<ChatSession[]>([])
/** 搜索命中总数与截断标记(后端单页上限 100,超出时只聚合已取回条内的会话)。 */
const searchTotal = ref(0)
const searchTruncated = ref(false)
/** 清搜索态: 结果与截断提示一起清,不残留上次的统计。 */
function clearSearch() {
searchResults.value = []
searchTotal.value = 0
searchTruncated.value = false
}
async function load() { async function load() {
loading.value = true loading.value = true
errorMsg.value = null errorMsg.value = null
searchResults.value = [] clearSearch()
try { try {
const res = await getSessions({ const res = await getSessions({
owner_account_id: props.ownerId, owner_account_id: props.ownerId,
@@ -48,16 +58,21 @@ async function load() {
loading.value = false loading.value = false
} }
} }
watch(() => [props.ownerId, props.kind, friendTab.value] as const, load, { immediate: true }) watch(() => [props.ownerId, props.kind] as const, load, { immediate: true })
/** 好友/已删 tab 只过滤 list 会话列表(is_del);search 模式的消息搜索与 is_del
无关——切 tab 不重载、不清关键词/结果(搜索跨 tab 保持有效)。 */
watch(friendTab, () => {
if (mode.value === 'list') load()
})
/** 切模式清关键词与旧结果,避免 list 过滤词被误当搜索词。 */ /** 切模式清关键词与旧结果,避免 list 过滤词被误当搜索词。 */
watch(mode, () => { watch(mode, () => {
keyword.value = '' keyword.value = ''
searchResults.value = [] clearSearch()
}) })
/** search 模式清空关键词 → 回到未搜索态(不残留上次聚合结果)。 */ /** search 模式清空关键词 → 回到未搜索态(不残留上次聚合结果)。 */
watch(keyword, (k) => { watch(keyword, (k) => {
if (mode.value === 'search' && !k.trim()) searchResults.value = [] if (mode.value === 'search' && !k.trim()) clearSearch()
}) })
/** 置顶区: 仅 单聊+未删好友+list 模式(后端 pinned 目前只有 filehelper)。 */ /** 置顶区: 仅 单聊+未删好友+list 模式(后端 pinned 目前只有 filehelper)。 */
@@ -106,7 +121,7 @@ function sessionKey(s: ChatSession): string {
async function runSearch() { async function runSearch() {
const k = keyword.value.trim() const k = keyword.value.trim()
if (mode.value !== 'search' || !k) { if (mode.value !== 'search' || !k) {
searchResults.value = [] clearSearch()
return return
} }
loading.value = true loading.value = true
@@ -119,10 +134,14 @@ async function runSearch() {
page: 1, page: 1,
page_size: 100, page_size: 100,
}) })
searchTotal.value = res.total
searchTruncated.value = res.total > res.items.length
// 已加载的会话列表提供昵称/头像;未命中(search 只回消息)再以 sender_name/键兜底 // 已加载的会话列表提供昵称/头像;未命中(search 只回消息)再以 sender_name/键兜底
const known = new Map(sessions.value.map(s => [sessionKey(s), s])) const known = new Map(sessions.value.map(s => [sessionKey(s), s]))
const seen = new Map<string, ChatSession>() const seen = new Map<string, ChatSession>()
for (const m of res.items) { // 陷阱: 后端 /messages 按 timestamp ASC 排序,page 1 取回的是最旧的 100 条命中。
// 倒序遍历(新→旧)+ 首见即取,保证每个会话的快照时间 = 已取回命中里最新的那条。
for (const m of [...res.items].reverse()) {
const key = m.room_id ?? m.talker_id ?? '' const key = m.room_id ?? m.talker_id ?? ''
if (!key || seen.has(key)) continue if (!key || seen.has(key)) continue
const hit = known.get(key) const hit = known.get(key)
@@ -131,7 +150,8 @@ async function runSearch() {
type: m.room_id ? 'group' : 'friend', type: m.room_id ? 'group' : 'friend',
talker_id: m.room_id ? undefined : m.talker_id ?? undefined, talker_id: m.room_id ? undefined : m.talker_id ?? undefined,
room_id: m.room_id ?? undefined, room_id: m.room_id ?? undefined,
name: hit?.name ?? m.sender_name ?? key, // is_send=true 时 sender_name 是员工自己,不能当卡片标题
name: hit?.name ?? (!m.is_send ? m.sender_name : key),
avatar: hit?.avatar ?? undefined, avatar: hit?.avatar ?? undefined,
today_count: 0, today_count: 0,
last_chat_time: m.timestamp ?? null, last_chat_time: m.timestamp ?? null,
@@ -142,7 +162,7 @@ async function runSearch() {
) )
} catch (e) { } catch (e) {
errorMsg.value = (e as Error).message errorMsg.value = (e as Error).message
searchResults.value = [] clearSearch()
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -212,6 +232,11 @@ async function runSearch() {
</template> </template>
</div> </div>
<!-- ── 搜索截断提示: 命中超单页上限,聚合只覆盖已取回的最旧 100 条 ── -->
<div v-if="mode === 'search' && searchTruncated" class="sc-sessions__hint">
共 {{ searchTotal }} 条命中,仅聚合最旧 100 条内的会话
</div>
<!-- ── 主列表 ── --> <!-- ── 主列表 ── -->
<div v-if="!loading && visible.length === 0" class="yk-empty">{{ emptyText }}</div> <div v-if="!loading && visible.length === 0" class="yk-empty">{{ emptyText }}</div>
<div <div
@@ -330,7 +355,7 @@ async function runSearch() {
font-size: 12px; font-size: 12px;
transition: transform 0.25s cubic-bezier(0.22, 0.8, 0.36, 1); transition: transform 0.25s cubic-bezier(0.22, 0.8, 0.36, 1);
} }
/* 展开时箭头朝下(未转),折叠时转 180° 朝上 */ /* 展开时(is-open)转 180° 箭头朝上,折叠时未转朝下 */
.sc-sessions__fold-icon.is-open { .sc-sessions__fold-icon.is-open {
transform: rotate(180deg); transform: rotate(180deg);
} }
@@ -338,6 +363,14 @@ async function runSearch() {
margin-bottom: 6px; margin-bottom: 6px;
} }
/* 搜索截断提示(弱化小字,同 __time 的灰) */
.sc-sessions__hint {
padding: 4px 8px 6px;
font-size: 11px;
line-height: 1.4;
color: #8a94a4;
}
/* 会话卡(置顶/主列表共用) */ /* 会话卡(置顶/主列表共用) */
.sc-sessions__item { .sc-sessions__item {
display: flex; display: flex;
@@ -7,6 +7,8 @@ const NAVS = [
/** 员工聊天窗口导航键(与路由 :tab 段同名)。 */ /** 员工聊天窗口导航键(与路由 :tab 段同名)。 */
export type TabKey = (typeof NAVS)[number]['key'] export type TabKey = (typeof NAVS)[number]['key']
/** TabKey 的运行时键列表(StaffChat.vue 路由 tab 收窄复用,NAVS 单一事实源)。 */
export const TAB_KEYS: readonly TabKey[] = NAVS.map(n => n.key)
</script> </script>
<script setup lang="ts"> <script setup lang="ts">
+3 -3
View File
@@ -5,7 +5,7 @@ import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { getAccounts } from '@/api' import { getAccounts } from '@/api'
import type { Account } from '@/api/types' import type { Account } from '@/api/types'
import StaffChatSide, { type TabKey } from '@/components/staffchat/StaffChatSide.vue' import StaffChatSide, { TAB_KEYS, type TabKey } from '@/components/staffchat/StaffChatSide.vue'
import SessionPane from '@/components/staffchat/SessionPane.vue' import SessionPane from '@/components/staffchat/SessionPane.vue'
import ChatMain from '@/components/staffchat/ChatMain.vue' import ChatMain from '@/components/staffchat/ChatMain.vue'
import MomentsPane from '@/components/staffchat/MomentsPane.vue' import MomentsPane from '@/components/staffchat/MomentsPane.vue'
@@ -15,8 +15,8 @@ import StatsPane from '@/components/staffchat/StatsPane.vue'
const props = defineProps<{ ownerId: string; tab?: string; talkerId?: string }>() const props = defineProps<{ ownerId: string; tab?: string; talkerId?: string }>()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
// TabKey 从 StaffChatSide 导出(单一事实源);VALID 仅做运行时兜底 // TabKey/TAB_KEYS 从 StaffChatSide 导出(NAVS 单一事实源);VALID 仅做运行时兜底
const VALID: readonly TabKey[] = ['single', 'group', 'moments', 'contacts', 'stats'] const VALID: readonly TabKey[] = TAB_KEYS
function isTabKey(v: string | undefined): v is TabKey { function isTabKey(v: string | undefined): v is TabKey {
return !!v && (VALID as readonly string[]).includes(v) return !!v && (VALID as readonly string[]).includes(v)
} }