6de1662131
与 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>
803 lines
22 KiB
Vue
803 lines
22 KiB
Vue
<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>
|