feat: full-text search (messages + customers)

Backend (yunke-server):
- GET /api/search?q=&type=messages|customers|all&channel=&start=&end=&page=&page_size=
  LIKE '%q%' scan over message.text + customer friend_nickname/real_name/
  phone/wechat_phone. type=all returns both sections; each is its own Page[T].
- type=messages orders by timestamp desc; type=customers orders by
  last_chat_time desc (nulls last).
- q required: missing/empty/whitespace -> HTTP 400.
- 14 new tests in tests/test_api_search.py (TDD red -> green).

Frontend (yunkes):
- New /search route + 搜索 nav item with Search icon.
- SearchView.vue: search bar, two stacked sections (消息结果 / 客户结果),
  counts + independent pagination per section, keyword highlighting (escaped),
  click message -> /chat/:channel/:ownerId/:talkerId,
  click customer -> all-fields drawer (CustomerList pattern reuse).
- search() wrapper in src/api/index.ts with SearchQuery/SearchPayload types.
- Empty state: "输入关键词搜索消息和客户".

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
浅念
2026-08-05 11:46:21 +08:00
parent 8fde1d8597
commit a92fa2927c
7 changed files with 1303 additions and 0 deletions
+767
View File
@@ -0,0 +1,767 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Search } from '@element-plus/icons-vue'
import { search } from '@/api'
import type { Channel, Customer, Message } from '@/api/types'
import { useChannelStore } from '@/stores/channel'
import { channelLabel, formatTime } from '@/utils/format'
import {
buildCustomerSections,
readField,
} from '@/utils/customerFields'
/**
* Full-text search across messages (text) + customers (nickname / real_name /
* phone / wechat_phone). Two stacked sections, each with its own pagination —
* mirroring the backend `/api/search` shape where every requested section is
* its own `Page[T]`.
*
* Click a message → jump to that conversation's timeline.
* Click a customer → opens the all-fields drawer (same pattern as CustomerList).
*/
const router = useRouter()
const channelStore = useChannelStore()
const keyword = ref('')
// The active query is only updated on submit, so deep editing the input doesn't
// trigger a request per keystroke.
const submittedQ = ref('')
const loading = ref(false)
const errorMsg = ref<string | null>(null)
// Independent pagination state per section — the backend runs both searches
// with the supplied page/page_size, but each section has its own total.
const msgPage = ref(1)
const msgPageSize = ref(10)
const custPage = ref(1)
const custPageSize = ref(10)
const messages = ref<Message[]>([])
const msgTotal = ref(0)
const customers = ref<Customer[]>([])
const custTotal = ref(0)
// Customer all-fields drawer (mirrors CustomerList.vue).
const drawerOpen = ref(false)
const drawerCustomer = ref<Customer | null>(null)
const channelOptions = [
{ value: 'all' as const, label: '全部渠道' },
{ value: 'wechat' as const, label: '微信' },
{ value: 'whatsapp' as const, label: 'WhatsApp' },
]
function onChannelChange(value: string | number | boolean | undefined): void {
channelStore.setChannel(value as Channel | 'all')
}
function channelTagType(c: Channel) {
return c === 'wechat' ? 'success' : 'warning'
}
function cell(row: Customer, camelKey: string): unknown {
return readField(row, camelKey)
}
function cellText(row: Customer, camelKey: string): string {
const v = cell(row, camelKey)
return v === null || v === undefined || v === '' ? '--' : String(v)
}
/** Image URL for an avatar/head column: a string value, else undefined. */
function imageCell(row: Customer, camelKey: string): string | undefined {
const v = cell(row, camelKey)
return typeof v === 'string' && v !== '' ? v : undefined
}
const hasSearched = computed(() => submittedQ.value.length > 0)
/**
* Highlight occurrences of the keyword inside `text`. Returns HTML with
* matches wrapped in `<mark class="yk-search__hit">`. Escapes the surrounding
* text so user content can't break out of the snippet. Empty / no-match input
* returns the escaped plain text.
*/
function highlight(text: string | null | undefined): string {
const needle = submittedQ.value.trim()
const body = text ?? ''
if (!body) return ''
const escaped = escapeHtml(body)
if (!needle) return escaped
const needleEsc = escapeHtml(needle)
// Escape regex metacharacters in the needle so a literal search is performed.
const needleRegex = needleEsc.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
return escaped.replace(
new RegExp(needleRegex, 'gi'),
(m) => `<mark class="yk-search__hit">${m}</mark>`,
)
}
function escapeHtml(s: string): string {
return s
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')
}
/** Snippet preview window around the first match (or the start of text). */
function snippet(text: string | null | undefined): string {
const body = text ?? ''
const needle = submittedQ.value.trim().toLowerCase()
if (!needle) return body.slice(0, 240)
const idx = body.toLowerCase().indexOf(needle)
if (idx <= 0) return body.slice(0, 240)
// Show ~60 chars before + the match + the remainder up to ~240 total.
const start = Math.max(0, idx - 60)
return (start > 0 ? '…' : '') + body.slice(start, start + 240)
}
function messageSender(m: Message): string {
if (m.sender_name) return m.sender_name
if (m.is_send) return '我方'
return m.talker_id ?? '对方'
}
function messageTarget(m: Message): string {
return m.room_id ?? m.talker_id ?? ''
}
async function runSearch(): Promise<void> {
const q = keyword.value.trim()
if (!q) {
ElMessage.warning('请输入搜索关键词')
return
}
// Reset pagination when a fresh search is submitted.
msgPage.value = 1
custPage.value = 1
submittedQ.value = q
await load()
}
async function load(): Promise<void> {
if (!submittedQ.value) return
loading.value = true
errorMsg.value = null
try {
// The backend supports a single page/page_size per call; we issue two
// requests so each section keeps its own pagination. channel=start/end are
// shared across both.
const baseParams = {
q: submittedQ.value,
channel: channelStore.channel === 'all' ? undefined : channelStore.channel,
}
const [msgRes, custRes] = await Promise.all([
search({
...baseParams,
type: 'messages',
page: msgPage.value,
page_size: msgPageSize.value,
}),
search({
...baseParams,
type: 'customers',
page: custPage.value,
page_size: custPageSize.value,
}),
])
messages.value = msgRes.messages?.items ?? []
msgTotal.value = msgRes.messages?.total ?? 0
customers.value = custRes.customers?.items ?? []
custTotal.value = custRes.customers?.total ?? 0
} catch (e) {
errorMsg.value = (e as Error).message
ElMessage.error(`搜索失败:${(e as Error).message}`)
} finally {
loading.value = false
}
}
function handleMsgPageChange(p: number): void {
msgPage.value = p
load()
}
function handleMsgPageSizeChange(s: number): void {
msgPageSize.value = s
msgPage.value = 1
load()
}
function handleCustPageChange(p: number): void {
custPage.value = p
load()
}
function handleCustPageSizeChange(s: number): void {
custPageSize.value = s
custPage.value = 1
load()
}
function openMessage(m: Message): void {
if (!m.owner_account_id) {
ElMessage.warning('该消息缺少归属账号,无法定位会话')
return
}
router.push({
name: 'chat',
params: {
channel: m.channel,
ownerId: m.owner_account_id,
talkerId: m.talker_id ?? m.room_id ?? '',
},
})
}
function openCustomer(c: Customer): void {
drawerCustomer.value = c
drawerOpen.value = true
}
function goCustomerList(): void {
router.push({ name: 'customers' })
}
function goChatFromDrawer(): void {
const row = drawerCustomer.value
if (!row) return
if (!row.owner_account_id) {
ElMessage.warning('该客户缺少归属账号,无法查看聊天记录')
return
}
router.push({
name: 'chat',
params: {
channel: row.channel,
ownerId: row.owner_account_id,
talkerId: row.friend_id,
},
})
}
const drawerSections = computed(() =>
drawerCustomer.value ? buildCustomerSections(drawerCustomer.value) : [],
)
const drawerAvatarSrc = computed<string | undefined>(() => {
const c = drawerCustomer.value
if (!c) return undefined
return imageCell(c, 'friendHeadImageUrl') ?? c.head_image_url ?? undefined
})
const drawerCanChat = computed(() => !!drawerCustomer.value?.owner_account_id)
const channelLabelCurrent = computed(() =>
channelLabel(channelStore.channel === 'all' ? null : channelStore.channel),
)
// React to the top-bar channel toggle.
watch(
() => channelStore.channel,
() => {
if (hasSearched.value) {
msgPage.value = 1
custPage.value = 1
load()
}
},
)
// Autofocus the input on mount.
nextTick(() => {
const el = document.querySelector<HTMLInputElement>('.yk-search__input input')
el?.focus()
})
</script>
<template>
<div class="yk-search">
<div class="yk-search__header">
<div>
<h2 style="margin: 0 0 4px; font-size: 16px">搜索</h2>
<span class="yk-search__sub">搜索消息正文与客户名称/手机 · 渠道:{{ channelLabelCurrent }}</span>
</div>
<div class="yk-search__filters">
<el-radio-group
:model-value="channelStore.channel"
size="small"
@change="onChannelChange"
>
<el-radio-button
v-for="opt in channelOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</el-radio-button>
</el-radio-group>
</div>
</div>
<div class="yk-search__bar">
<el-input
v-model="keyword"
class="yk-search__input"
size="large"
clearable
placeholder="输入关键词搜索消息和客户"
:prefix-icon="Search"
@keyup.enter="runSearch"
/>
<el-button
size="large"
type="primary"
:loading="loading"
@click="runSearch"
>
搜索
</el-button>
</div>
<el-alert
v-if="errorMsg"
:title="errorMsg"
type="error"
show-icon
:closable="false"
style="margin-bottom: 12px"
/>
<template v-if="!hasSearched">
<div class="yk-empty yk-search__empty">输入关键词搜索消息和客户</div>
</template>
<template v-else>
<!-- 消息结果 -->
<section class="yk-search__section">
<header class="yk-search__section-head">
<h3>消息结果</h3>
<span class="yk-search__count">共 {{ msgTotal }} 条</span>
</header>
<div v-loading="loading" class="yk-search__list">
<div
v-if="messages.length === 0 && !loading"
class="yk-empty yk-search__empty--small"
>
暂无匹配的消息
</div>
<div
v-for="m in messages"
:key="m.msg_id"
class="yk-search__row yk-search__row--msg"
@click="openMessage(m)"
>
<div class="yk-search__row-top">
<el-tag :type="channelTagType(m.channel)" size="small" effect="plain">
{{ channelLabel(m.channel) }}
</el-tag>
<span class="yk-search__row-sender">{{ messageSender(m) }}</span>
<span class="yk-search__row-target" :title="messageTarget(m)">
<template v-if="messageTarget(m)">· {{ messageTarget(m) }}</template>
</span>
<span class="yk-search__row-time">{{ formatTime(m.timestamp) }}</span>
</div>
<div
class="yk-search__row-snippet"
v-html="highlight(snippet(m.text))"
/>
</div>
</div>
<div v-if="msgTotal > 0" class="yk-search__pager">
<el-pagination
:current-page="msgPage"
:page-size="msgPageSize"
:page-sizes="[10, 20, 50]"
:total="msgTotal"
layout="total, sizes, prev, pager, next, jumper"
background
small
@current-change="handleMsgPageChange"
@size-change="handleMsgPageSizeChange"
/>
</div>
</section>
<!-- 客户结果 -->
<section class="yk-search__section">
<header class="yk-search__section-head">
<h3>客户结果</h3>
<span class="yk-search__count">共 {{ custTotal }} 条</span>
</header>
<div v-loading="loading" class="yk-search__list">
<div
v-if="customers.length === 0 && !loading"
class="yk-empty yk-search__empty--small"
>
暂无匹配的客户
</div>
<div
v-for="c in customers"
:key="c.friend_id"
class="yk-search__row yk-search__row--cust"
@click="openCustomer(c)"
>
<el-avatar
:size="36"
:src="imageCell(c, 'friendHeadImageUrl') ?? c.head_image_url ?? undefined"
shape="square"
>
{{ (c.friend_nickname || c.friend_id || '?').slice(0, 1) }}
</el-avatar>
<div class="yk-search__row-body">
<div class="yk-search__row-top">
<span
class="yk-search__row-sender"
v-html="highlight(c.friend_nickname ?? c.friend_id)"
/>
<el-tag :type="channelTagType(c.channel)" size="small" effect="plain">
{{ channelLabel(c.channel) }}
</el-tag>
<span
v-if="cellText(c, 'realName') !== '--'"
class="yk-search__row-target"
v-html="highlight(cellText(c, 'realName'))"
/>
<span class="yk-search__row-time">
最近聊天 {{ formatTime(c.last_chat_time) }}
</span>
</div>
<div class="yk-search__row-meta">
<span v-if="cellText(c, 'phone') !== '--'">
手机:
<span v-html="highlight(cellText(c, 'phone'))" />
</span>
<span v-if="cellText(c, 'friendWxAlias') !== '--'">
· 微信号:{{ cellText(c, 'friendWxAlias') }}
</span>
<span v-if="c.dept_name"> · 部门:{{ c.dept_name }} </span>
</div>
</div>
</div>
</div>
<div v-if="custTotal > 0" class="yk-search__pager">
<el-pagination
:current-page="custPage"
:page-size="custPageSize"
:page-sizes="[10, 20, 50]"
:total="custTotal"
layout="total, sizes, prev, pager, next, jumper"
background
small
@current-change="handleCustPageChange"
@size-change="handleCustPageSizeChange"
/>
</div>
</section>
<p class="yk-search__hint">
点击消息进入对应聊天记录 · 点击客户查看客户全部字段。
<el-button text size="small" @click="goCustomerList">前往客户列表</el-button>
</p>
</template>
<!-- All-fields drawer (mirrors CustomerList.vue) -->
<el-drawer
v-model="drawerOpen"
:title="drawerCustomer ? `客户详情 · ${drawerCustomer.friend_nickname ?? drawerCustomer.friend_id}` : '客户详情'"
direction="rtl"
size="560px"
>
<template v-if="drawerCustomer">
<div class="yk-drawer__topbar">
<el-button
type="primary"
size="small"
:disabled="!drawerCanChat"
@click="goChatFromDrawer"
>
查看聊天记录
</el-button>
<span v-if="!drawerCanChat" class="yk-drawer__topbar-hint">
该客户缺少归属账号,无法查看聊天
</span>
</div>
<div class="yk-drawer__avatar">
<el-avatar :size="56" :src="drawerAvatarSrc" shape="square">
{{ (drawerCustomer.friend_nickname || drawerCustomer.friend_id || '?').slice(0, 1) }}
</el-avatar>
<div class="yk-drawer__avatar-meta">
<div class="yk-drawer__avatar-name">
{{ drawerCustomer.friend_nickname || drawerCustomer.friend_id }}
</div>
<div class="yk-drawer__avatar-sub">
{{ channelLabel(drawerCustomer.channel) }}
<template v-if="cell(drawerCustomer, 'friendWxAlias')">
· {{ cell(drawerCustomer, 'friendWxAlias') }}
</template>
</div>
</div>
</div>
<div v-for="section in drawerSections" :key="section.title" class="yk-drawer__section">
<el-divider content-position="left">{{ section.title }}</el-divider>
<dl class="yk-drawer__kv">
<div v-for="f in section.fields" :key="f.key" class="yk-drawer__kv-item">
<dt>{{ f.label }}</dt>
<dd v-if="f.render === 'image'" class="yk-drawer__img">
<el-image
:src="String(f.value)"
:preview-src-list="[String(f.value)]"
fit="cover"
hide-on-click-modal
/>
</dd>
<dd v-else-if="f.render === 'empty'" class="yk-drawer__empty">—</dd>
<dd v-else>{{ f.text }}</dd>
</div>
</dl>
</div>
</template>
</el-drawer>
</div>
</template>
<style scoped>
.yk-search__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.yk-search__sub {
color: #909399;
font-size: 13px;
}
.yk-search__filters {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.yk-search__bar {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.yk-search__input {
flex: 1 1 auto;
}
.yk-search__section {
margin-bottom: 20px;
}
.yk-search__section-head {
display: flex;
align-items: baseline;
gap: 12px;
margin-bottom: 8px;
}
.yk-search__section-head h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
}
.yk-search__count {
color: #909399;
font-size: 12px;
}
.yk-search__list {
display: flex;
flex-direction: column;
gap: 6px;
}
.yk-search__empty {
padding: 60px 0;
text-align: center;
color: #c0c4cc;
}
.yk-search__empty--small {
padding: 24px 0;
text-align: center;
color: #c0c4cc;
font-size: 13px;
}
.yk-search__row {
border: 1px solid var(--el-border-color-lighter, #ebeef5);
border-radius: 4px;
padding: 10px 12px;
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease;
}
.yk-search__row:hover {
background: var(--el-fill-color-light, #f5f7fa);
border-color: var(--el-border-color, #dcdfe6);
}
.yk-search__row--cust {
display: flex;
gap: 10px;
align-items: flex-start;
}
.yk-search__row-body {
flex: 1 1 auto;
min-width: 0;
}
.yk-search__row-top {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
font-size: 13px;
color: #303133;
}
.yk-search__row-sender {
font-weight: 600;
}
.yk-search__row-target {
color: #909399;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 260px;
}
.yk-search__row-time {
margin-left: auto;
color: #c0c4cc;
font-size: 12px;
}
.yk-search__row-snippet {
margin-top: 6px;
font-size: 13px;
color: #303133;
line-height: 1.6;
word-break: break-word;
}
.yk-search__row-meta {
margin-top: 4px;
font-size: 12px;
color: #909399;
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.yk-search__pager {
margin-top: 10px;
display: flex;
justify-content: flex-end;
}
.yk-search__hint {
margin: 8px 2px 0;
font-size: 12px;
color: #c0c4cc;
display: flex;
align-items: center;
gap: 8px;
}
/* Match highlight — uses :deep so v-html <mark> picks it up despite `scoped`. */
.yk-search__list :deep(.yk-search__hit) {
background: var(--el-color-warning-light-7, #fdf6ec);
color: var(--el-color-warning-dark-2, #b88230);
border-radius: 2px;
padding: 0 2px;
}
.yk-drawer__topbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.yk-drawer__topbar-hint {
color: #c0c4cc;
font-size: 12px;
}
.yk-drawer__avatar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 4px;
}
.yk-drawer__avatar-meta {
min-width: 0;
}
.yk-drawer__avatar-name {
font-size: 16px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.yk-drawer__avatar-sub {
font-size: 12px;
color: #909399;
margin-top: 2px;
}
.yk-drawer__kv {
margin: 0 0 8px;
}
.yk-drawer__kv-item {
display: grid;
grid-template-columns: 110px 1fr;
gap: 8px 12px;
padding: 3px 0;
}
.yk-drawer__kv dt {
color: #909399;
font-size: 13px;
word-break: break-all;
}
.yk-drawer__kv dd {
margin: 0;
font-size: 13px;
color: #303133;
word-break: break-all;
min-width: 0;
}
.yk-drawer__img :deep(.el-image) {
width: 64px;
height: 64px;
border-radius: 4px;
border: 1px solid var(--el-border-color-light, #e4e7ed);
}
.yk-drawer__empty {
color: #c0c4cc;
}
</style>