Files
yunke/yunkes/src/views/SearchView.vue
T
浅念 6de1662131 feat(staffchat): CallList/SearchView 微信行切新员工聊天窗口(入口改造收尾)
与 StaffList/CustomerList 同口径:微信行→staff-chat 新窗(单聊=single tab,
群语音/@chatroom 消息=group tab),WA 行保留旧 ChatTimeline(ChatTimeline
保留给 WA)。通话行跳转带「通话当天」整天下窗——语音通话本身是消息流里的
voice_call 行,当天窗必含该通通话,等价实现旧页 ?start= 滚动定位(新窗
rangeFromQuery 需 start+end 成对)。SearchView 客户抽屉同 CustomerList。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-27 16:07:54 +08:00

803 lines
22 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)
/**
* Split `text` into segments, marking ones that match the active keyword.
* The template renders each segment via `<mark>` / `<span>` — NO v-html, so
* user content cannot break out of the snippet (XSS-safe by construction).
* Empty needle → single non-hit segment. Matching is case-insensitive; the
* original case is preserved in the output text.
*/
interface HighlightSegment {
text: string
hit: boolean
}
function highlightSegments(text: string | null | undefined): HighlightSegment[] {
const body = text ?? ''
if (!body) return []
const needle = submittedQ.value.trim()
if (!needle) return [{ text: body, hit: false }]
const lower = body.toLowerCase()
const needleLower = needle.toLowerCase()
const segments: HighlightSegment[] = []
let lastIdx = 0
let idx = lower.indexOf(needleLower)
while (idx >= 0) {
if (idx > lastIdx) segments.push({ text: body.slice(lastIdx, idx), hit: false })
segments.push({ text: body.slice(idx, idx + needle.length), hit: true })
lastIdx = idx + needle.length
idx = lower.indexOf(needleLower, lastIdx)
}
if (lastIdx < body.length) segments.push({ text: body.slice(lastIdx), hit: false })
return segments
}
/** 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
}
// 微信 → 新员工聊天窗口(群消息=group tab 按 room_id);WA 保留旧 ChatTimeline。
if (m.channel === 'wechat') {
router.push({
name: 'staff-chat',
params: {
ownerId: m.owner_account_id,
tab: m.room_id ? 'group' : 'single',
talkerId: (m.room_id ?? m.talker_id) as string,
},
})
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
}
// 微信 → 新员工聊天窗口;WA 保留旧 ChatTimeline(与 CustomerList.viewChat 一致)。
if (row.channel === 'wechat') {
router.push({
name: 'staff-chat',
params: { ownerId: row.owner_account_id, tab: 'single', talkerId: row.friend_id },
})
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">
<template v-for="(seg, i) in highlightSegments(snippet(m.text))">
<mark v-if="seg.hit" :key="`m${i}`" class="yk-search__hit">{{ seg.text }}</mark>
<span v-else :key="`s${i}`">{{ seg.text }}</span>
</template>
</div>
</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">
<template v-for="(seg, i) in highlightSegments(c.friend_nickname ?? c.friend_id)">
<mark v-if="seg.hit" :key="`m${i}`" class="yk-search__hit">{{ seg.text }}</mark>
<span v-else :key="`s${i}`">{{ seg.text }}</span>
</template>
</span>
<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"
>
<template v-for="(seg, i) in highlightSegments(cellText(c, 'realName'))">
<mark v-if="seg.hit" :key="`m${i}`" class="yk-search__hit">{{ seg.text }}</mark>
<span v-else :key="`s${i}`">{{ seg.text }}</span>
</template>
</span>
<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') !== '--'">
手机:
<template v-for="(seg, i) in highlightSegments(cellText(c, 'phone'))">
<mark v-if="seg.hit" :key="`m${i}`" class="yk-search__hit">{{ seg.text }}</mark>
<span v-else :key="`s${i}`">{{ seg.text }}</span>
</template>
</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 — `<mark>` elements are rendered in this template, so they
* carry the scoped attribute; `:deep` is retained so the rule still applies if
* the markup ever moves into a child component. */
.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>