Files
yunke/yunkes/src/views/SearchView.vue
T
浅念 a92fa2927c 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>
2026-08-05 11:46:21 +08:00

768 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>