@@ -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 ( '&' , '&' )
. replaceAll ( '<' , '<' )
. replaceAll ( '>' , '>' )
. replaceAll ( '"' , '"' )
. replaceAll ( "'" , ''' )
}
/** 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 : 16 px ;
flex - wrap : wrap ;
margin - bottom : 12 px ;
}
. yk - search _ _sub {
color : # 909399 ;
font - size : 13 px ;
}
. yk - search _ _filters {
display : flex ;
gap : 8 px ;
align - items : center ;
flex - wrap : wrap ;
}
. yk - search _ _bar {
display : flex ;
gap : 8 px ;
margin - bottom : 16 px ;
}
. yk - search _ _input {
flex : 1 1 auto ;
}
. yk - search _ _section {
margin - bottom : 20 px ;
}
. yk - search _ _section - head {
display : flex ;
align - items : baseline ;
gap : 12 px ;
margin - bottom : 8 px ;
}
. yk - search _ _section - head h3 {
margin : 0 ;
font - size : 14 px ;
font - weight : 600 ;
}
. yk - search _ _count {
color : # 909399 ;
font - size : 12 px ;
}
. yk - search _ _list {
display : flex ;
flex - direction : column ;
gap : 6 px ;
}
. yk - search _ _empty {
padding : 60 px 0 ;
text - align : center ;
color : # c0c4cc ;
}
. yk - search _ _empty -- small {
padding : 24 px 0 ;
text - align : center ;
color : # c0c4cc ;
font - size : 13 px ;
}
. yk - search _ _row {
border : 1 px solid var ( -- el - border - color - lighter , # ebeef5 ) ;
border - radius : 4 px ;
padding : 10 px 12 px ;
cursor : pointer ;
transition : background 0.15 s ease , border - color 0.15 s 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 : 10 px ;
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 : 8 px ;
flex - wrap : wrap ;
font - size : 13 px ;
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 : 260 px ;
}
. yk - search _ _row - time {
margin - left : auto ;
color : # c0c4cc ;
font - size : 12 px ;
}
. yk - search _ _row - snippet {
margin - top : 6 px ;
font - size : 13 px ;
color : # 303133 ;
line - height : 1.6 ;
word - break : break - word ;
}
. yk - search _ _row - meta {
margin - top : 4 px ;
font - size : 12 px ;
color : # 909399 ;
display : flex ;
gap : 6 px ;
flex - wrap : wrap ;
}
. yk - search _ _pager {
margin - top : 10 px ;
display : flex ;
justify - content : flex - end ;
}
. yk - search _ _hint {
margin : 8 px 2 px 0 ;
font - size : 12 px ;
color : # c0c4cc ;
display : flex ;
align - items : center ;
gap : 8 px ;
}
/* 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 : 2 px ;
padding : 0 2 px ;
}
. yk - drawer _ _topbar {
display : flex ;
align - items : center ;
gap : 12 px ;
margin - bottom : 12 px ;
}
. yk - drawer _ _topbar - hint {
color : # c0c4cc ;
font - size : 12 px ;
}
. yk - drawer _ _avatar {
display : flex ;
align - items : center ;
gap : 12 px ;
margin - bottom : 4 px ;
}
. yk - drawer _ _avatar - meta {
min - width : 0 ;
}
. yk - drawer _ _avatar - name {
font - size : 16 px ;
font - weight : 600 ;
white - space : nowrap ;
overflow : hidden ;
text - overflow : ellipsis ;
}
. yk - drawer _ _avatar - sub {
font - size : 12 px ;
color : # 909399 ;
margin - top : 2 px ;
}
. yk - drawer _ _kv {
margin : 0 0 8 px ;
}
. yk - drawer _ _kv - item {
display : grid ;
grid - template - columns : 110 px 1 fr ;
gap : 8 px 12 px ;
padding : 3 px 0 ;
}
. yk - drawer _ _kv dt {
color : # 909399 ;
font - size : 13 px ;
word - break : break - all ;
}
. yk - drawer _ _kv dd {
margin : 0 ;
font - size : 13 px ;
color : # 303133 ;
word - break : break - all ;
min - width : 0 ;
}
. yk - drawer _ _img : deep ( . el - image ) {
width : 64 px ;
height : 64 px ;
border - radius : 4 px ;
border : 1 px solid var ( -- el - border - color - light , # e4e7ed ) ;
}
. yk - drawer _ _empty {
color : # c0c4cc ;
}
< / style >