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
+3
View File
@@ -16,6 +16,7 @@ Includes:
* ``GET /api/calls`` (see :mod:`app.api.routers.calls`). * ``GET /api/calls`` (see :mod:`app.api.routers.calls`).
* ``GET /api/sms`` (see :mod:`app.api.routers.sms`). * ``GET /api/sms`` (see :mod:`app.api.routers.sms`).
* ``GET /api/sync/status`` (see :mod:`app.api.routers.sync`). * ``GET /api/sync/status`` (see :mod:`app.api.routers.sync`).
* ``GET /api/search`` (see :mod:`app.api.routers.search`).
CORS is configured permissively (wildcard origin, no credentials) so the local CORS is configured permissively (wildcard origin, no credentials) so the local
frontend can call the API during development. frontend can call the API during development.
@@ -41,6 +42,7 @@ from app.api.routers import (
media, media,
messages, messages,
organization, organization,
search,
sms, sms,
sync, sync,
) )
@@ -62,6 +64,7 @@ app.include_router(groups.router)
app.include_router(calls.router) app.include_router(calls.router)
app.include_router(sms.router) app.include_router(sms.router)
app.include_router(sync.router) app.include_router(sync.router)
app.include_router(search.router)
@app.get("/api/health") @app.get("/api/health")
+165
View File
@@ -0,0 +1,165 @@
"""GET /api/search — full-text search over messages + customers.
Read-only ``LIKE '%q%'`` search across two mirrored tables:
* **Messages** — matches against ``message.text``. Filters: ``channel``,
``start``/``end`` (ms-epoch window compared against ``timestamp``). Ordered
by ``timestamp desc`` so the most recent matches surface first.
* **Customers** — matches against any of ``friend_nickname`` / ``real_name`` /
``phone`` / ``wechat_phone``. Filter: ``channel``. Ordered by
``last_chat_time desc`` (nulls last).
The ``type`` selector picks which section(s) to run:
* ``messages`` -> ``{"messages": Page[MessageOut]}``
* ``customers`` -> ``{"customers": Page[CustomerOut]}``
* ``all`` (default) -> ``{"messages": Page[MessageOut], "customers":
Page[CustomerOut]}``; both run with the supplied ``page`` / ``page_size``.
No external search dependencies (no FTS index, no Elasticsearch) — this is a
plain SQL ``LIKE`` scan over the stored mirror data. Sufficient for the
data-mirror's typical row counts and the same portability rules as the rest
of the API (MySQL in prod, in-memory SQLite in tests).
"""
from __future__ import annotations
from typing import Annotated, Any, Literal
import sqlalchemy as sa
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_session
from app.api.schemas import CustomerOut, MessageOut, Page
from app.db.models import Customer, Message
router = APIRouter(prefix="/api", tags=["search"])
SessionDep = Annotated[AsyncSession, Depends(get_session)]
PageParam = Annotated[int, Query(ge=1)]
PageSizeParam = Annotated[int, Query(ge=1, le=100)]
SearchType = Annotated[Literal["messages", "customers", "all"], Query()]
# Response shape: ``{"messages": Page[MessageOut], "customers": Page[CustomerOut]}``
# where each requested section is present and unrequested sections are omitted
# entirely (not serialized as null). Returning a plain dict keeps the key set
# explicit so the frontend can branch on ``section in payload``.
SearchPayload = dict[str, Page[MessageOut] | Page[CustomerOut]]
async def _search_messages(
session: AsyncSession,
q: str,
page: int,
page_size: int,
channel: str | None,
start: int | None,
end: int | None,
) -> Page[MessageOut]:
"""Run the message search. Caller validates ``q``."""
conditions: list[Any] = [Message.text.like(f"%{q}%")]
if channel is not None:
conditions.append(Message.channel == channel)
if start is not None:
conditions.append(Message.timestamp >= start)
if end is not None:
conditions.append(Message.timestamp <= end)
total = (
await session.execute(
sa.select(sa.func.count())
.select_from(Message)
.where(*conditions)
)
).scalar_one()
list_stmt = (
sa.select(Message)
.where(*conditions)
.order_by(
sa.nullslast(Message.timestamp.desc()),
Message.msg_id.asc(),
)
.limit(page_size)
.offset((page - 1) * page_size)
)
rows = (await session.execute(list_stmt)).scalars().all()
items = [MessageOut.model_validate(row) for row in rows]
return Page(items=items, total=total, page=page, page_size=page_size)
async def _search_customers(
session: AsyncSession,
q: str,
page: int,
page_size: int,
channel: str | None,
) -> Page[CustomerOut]:
"""Run the customer search. Caller validates ``q``."""
like = f"%{q}%"
text_conditions = [
Customer.friend_nickname.like(like),
Customer.real_name.like(like),
Customer.phone.like(like),
Customer.wechat_phone.like(like),
]
conditions: list[Any] = [sa.or_(*text_conditions)]
if channel is not None:
conditions.append(Customer.channel == channel)
total = (
await session.execute(
sa.select(sa.func.count())
.select_from(Customer)
.where(*conditions)
)
).scalar_one()
list_stmt = (
sa.select(Customer)
.where(*conditions)
.order_by(
sa.nullslast(Customer.last_chat_time.desc()),
Customer.friend_id.asc(),
)
.limit(page_size)
.offset((page - 1) * page_size)
)
rows = (await session.execute(list_stmt)).scalars().all()
items = [CustomerOut.model_validate(row) for row in rows]
return Page(items=items, total=total, page=page, page_size=page_size)
@router.get("/search")
async def search(
session: SessionDep,
q: Annotated[str | None, Query()] = None,
type: SearchType = "all",
channel: Annotated[str | None, Query()] = None,
start: Annotated[int | None, Query(ge=0)] = None,
end: Annotated[int | None, Query(ge=0)] = None,
page: PageParam = 1,
page_size: PageSizeParam = 20,
) -> SearchPayload:
"""Search messages (by ``text``) and/or customers (by name/phone).
``q`` is required — missing / empty / whitespace-only all map to HTTP 400
so callers see a consistent validation response rather than the framework
default 422. Only the requested section(s) are present in the payload;
unrequested ones are omitted entirely.
"""
keyword = (q or "").strip()
if not keyword:
raise HTTPException(status_code=400, detail="q must not be empty")
out: SearchPayload = {}
if type in ("messages", "all"):
out["messages"] = await _search_messages(
session, keyword, page, page_size, channel, start, end
)
if type in ("customers", "all"):
out["customers"] = await _search_customers(
session, keyword, page, page_size, channel
)
return out
+315
View File
@@ -0,0 +1,315 @@
"""Tests for GET /api/search — full-text search over messages + customers.
Drives the FastAPI app over an in-process ``httpx.AsyncClient`` (no real
network). Seeds a small corpus:
* Messages: a couple containing "报价", a couple containing other text, one
WhatsApp row to exercise the channel filter.
* Customers: nickname / real_name / phone / wechat_phone variants so each
LIKE branch is exercised.
Coverage:
* ``q=报价`` finds matching messages.
* ``q=张三`` finds customers by nickname; ``q=138`` finds by phone.
* ``type`` selector returns only the requested section; ``all`` returns both.
* ``channel`` filter narrows both sections.
* Empty / whitespace ``q`` -> 400.
* Time window ``start``/``end`` narrows the message section.
* Pagination (``page``/``page_size``) is applied independently per section.
"""
from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import Customer, Message
async def _seed(session: AsyncSession) -> None:
session.add_all(
[
# --- messages ---------------------------------------------------
Message(
msg_id="m_quote_a",
channel="wechat",
owner_account_id="wx_owner_1",
talker_id="t_a",
text="这是报价单,请查收",
timestamp=1000,
type="text",
type_raw=1,
),
Message(
msg_id="m_quote_b",
channel="wechat",
owner_account_id="wx_owner_1",
talker_id="t_b",
text="报价能不能再优惠一点",
timestamp=2000,
type="text",
type_raw=1,
),
Message(
msg_id="m_other",
channel="wechat",
owner_account_id="wx_owner_1",
talker_id="t_a",
text="明天见",
timestamp=3000,
type="text",
type_raw=1,
),
Message(
msg_id="m_quote_wa",
channel="whatsapp",
owner_account_id="wa_owner_1",
talker_id="t_wa",
text="quote 报价 via WhatsApp",
timestamp=4000,
type="text",
type_raw=0,
),
# --- customers --------------------------------------------------
Customer(
friend_id="wx_zhangsan",
channel="wechat",
owner_account_id="wx_owner_1",
friend_nickname="张三",
real_name="张三丰",
phone="13800000001",
wechat_phone="13800000001",
last_chat_time=2000,
),
Customer(
friend_id="wx_lisi",
channel="wechat",
owner_account_id="wx_owner_1",
friend_nickname="李四",
real_name=None,
phone="13900000002",
last_chat_time=1000,
),
Customer(
friend_id="wa_zhang",
channel="whatsapp",
owner_account_id="wa_owner_1",
friend_nickname="Zhang San",
phone="13700000003",
last_chat_time=None,
),
]
)
await session.commit()
# ---------------------------------------------------------------------------
# q validation
# ---------------------------------------------------------------------------
async def test_empty_q_returns_400(make_client):
"""Empty ``q`` -> HTTP 400 (the search keyword is required)."""
client = await make_client(_seed)
r = await client.get("/api/search", params={"q": ""})
assert r.status_code == 400
async def test_whitespace_q_returns_400(make_client):
"""Whitespace-only ``q`` -> HTTP 400 (we reject after stripping)."""
client = await make_client(_seed)
r = await client.get("/api/search", params={"q": " "})
assert r.status_code == 400
async def test_missing_q_returns_400(make_client):
"""Missing ``q`` -> HTTP 400 (the param is required)."""
client = await make_client(_seed)
r = await client.get("/api/search")
assert r.status_code == 400
# ---------------------------------------------------------------------------
# message search
# ---------------------------------------------------------------------------
async def test_search_messages_by_text(make_client):
"""``q=报价&type=messages`` -> only matching messages, ordered desc by time."""
client = await make_client(_seed)
r = await client.get(
"/api/search", params={"q": "报价", "type": "messages"}
)
assert r.status_code == 200
body = r.json()
# The wrapper exposes a ``messages`` page; ``customers`` is omitted when
# type=messages is requested.
assert "messages" in body
assert "customers" not in body
msgs = body["messages"]
assert msgs["total"] == 3 # m_quote_a, m_quote_b, m_quote_wa
ids = [it["msg_id"] for it in msgs["items"]]
assert set(ids) == {"m_quote_a", "m_quote_b", "m_quote_wa"}
# Ordered by timestamp DESC: m_quote_wa(4000) → m_quote_b(2000) → m_quote_a(1000)
assert ids == ["m_quote_wa", "m_quote_b", "m_quote_a"]
async def test_search_messages_channel_filter(make_client):
"""``q=报价&type=messages&channel=wechat`` excludes the WhatsApp hit."""
client = await make_client(_seed)
r = await client.get(
"/api/search",
params={"q": "报价", "type": "messages", "channel": "wechat"},
)
assert r.status_code == 200
msgs = r.json()["messages"]
assert msgs["total"] == 2
assert {it["msg_id"] for it in msgs["items"]} == {
"m_quote_a",
"m_quote_b",
}
assert all(it["channel"] == "wechat" for it in msgs["items"])
async def test_search_messages_time_window(make_client):
"""``start``/``end`` narrow the message section."""
client = await make_client(_seed)
r = await client.get(
"/api/search",
params={"q": "报价", "type": "messages", "start": 0, "end": 2500},
)
assert r.status_code == 200
msgs = r.json()["messages"]
# m_quote_a(1000) + m_quote_b(2000); m_quote_wa(4000) excluded.
assert {it["msg_id"] for it in msgs["items"]} == {
"m_quote_a",
"m_quote_b",
}
# ---------------------------------------------------------------------------
# customer search
# ---------------------------------------------------------------------------
async def test_search_customers_by_nickname(make_client):
"""``q=张三&type=customers`` -> finds customers with that nickname/real_name."""
client = await make_client(_seed)
r = await client.get(
"/api/search", params={"q": "张三", "type": "customers"}
)
assert r.status_code == 200
body = r.json()
assert "customers" in body
assert "messages" not in body
custs = body["customers"]
# wx_zhangsan (friend_nickname=张三) + wa_zhang (Zhang San matches via
# substring "Zhang" — but we search "张三" so only the wx row hits).
ids = [it["friend_id"] for it in custs["items"]]
assert "wx_zhangsan" in ids
# The whatsapp "Zhang San" row should NOT match a Chinese keyword.
assert "wa_zhang" not in ids
async def test_search_customers_by_phone(make_client):
"""``q=138`` -> customers whose phone/wechat_phone contains 138."""
client = await make_client(_seed)
r = await client.get(
"/api/search", params={"q": "138", "type": "customers"}
)
assert r.status_code == 200
custs = r.json()["customers"]
ids = [it["friend_id"] for it in custs["items"]]
assert ids == ["wx_zhangsan"] # only 13800000001 contains "138"
async def test_search_customers_channel_filter(make_client):
"""``channel`` narrows the customer section."""
client = await make_client(_seed)
r = await client.get(
"/api/search",
params={"q": "Zhang", "type": "customers", "channel": "whatsapp"},
)
assert r.status_code == 200
custs = r.json()["customers"]
assert custs["total"] == 1
assert custs["items"][0]["friend_id"] == "wa_zhang"
# ---------------------------------------------------------------------------
# type=all
# ---------------------------------------------------------------------------
async def test_search_all_returns_both_sections(make_client):
"""``type=all`` (default) -> response carries both ``messages`` and ``customers``."""
client = await make_client(_seed)
r = await client.get("/api/search", params={"q": "报价"})
assert r.status_code == 200
body = r.json()
assert "messages" in body
assert "customers" in body
assert body["messages"]["total"] == 3
# No customer has "报价" in their nickname/name/phone.
assert body["customers"]["total"] == 0
async def test_search_all_default_type(make_client):
"""Omitting ``type`` defaults to ``all``."""
client = await make_client(_seed)
r = await client.get("/api/search", params={"q": "张三"})
assert r.status_code == 200
body = r.json()
assert "messages" in body
assert "customers" in body
# No message text contains "张三".
assert body["messages"]["total"] == 0
assert body["customers"]["total"] >= 1
async def test_search_all_with_channel_applies_to_both(make_client):
"""``channel`` filters both sections when ``type=all``."""
client = await make_client(_seed)
r = await client.get(
"/api/search", params={"q": "报价", "channel": "whatsapp"}
)
assert r.status_code == 200
body = r.json()
# Only the WhatsApp message matches.
assert body["messages"]["total"] == 1
assert body["messages"]["items"][0]["msg_id"] == "m_quote_wa"
# No whatsapp customer has "报价".
assert body["customers"]["total"] == 0
# ---------------------------------------------------------------------------
# pagination
# ---------------------------------------------------------------------------
async def test_message_pagination(make_client):
"""``page_size=1`` -> one message per page, total still 3."""
client = await make_client(_seed)
r = await client.get(
"/api/search",
params={"q": "报价", "type": "messages", "page": 1, "page_size": 1},
)
assert r.status_code == 200
msgs = r.json()["messages"]
assert msgs["total"] == 3
assert len(msgs["items"]) == 1
# Descending by timestamp -> first item is the WhatsApp row (4000).
assert msgs["items"][0]["msg_id"] == "m_quote_wa"
async def test_customer_pagination(make_client):
"""Customers paginate independently with their own page_size."""
client = await make_client(_seed)
r = await client.get(
"/api/search",
params={"q": "1", "type": "customers", "page": 1, "page_size": 1},
)
assert r.status_code == 200
custs = r.json()["customers"]
# All seeded phone numbers contain "1" -> all 3.
assert custs["total"] == 3
assert len(custs["items"]) == 1
+43
View File
@@ -215,6 +215,49 @@ export function getSyncStatus(): Promise<SyncStatus> {
return getJson<SyncStatus>('/sync/status') return getJson<SyncStatus>('/sync/status')
} }
// ---------------------------------------------------------------------------
// Full-text search (messages + customers)
// ---------------------------------------------------------------------------
export type SearchType = 'messages' | 'customers' | 'all'
export interface SearchQuery {
q: string
/** which section(s) to query; defaults to `all`. */
type?: SearchType
channel?: Channel | 'all'
/** ms-epoch inclusive window compared against message.timestamp. */
start?: number
end?: number
page?: number
page_size?: number
}
/**
* Combined search payload returned by `/api/search`. Each requested section is
* present; unrequested sections are omitted entirely by the backend (so the
* caller can branch on `section in payload`).
*/
export interface SearchPayload {
messages?: Page<Message>
customers?: Page<Customer>
}
export function search(query: SearchQuery): Promise<SearchPayload> {
return getJson<SearchPayload>(
'/search',
clean({
q: query.q,
type: query.type ?? 'all',
channel: query.channel === 'all' ? undefined : query.channel,
start: query.start,
end: query.end,
page: query.page ?? 1,
page_size: query.page_size ?? 20,
}),
)
}
export type { export type {
Account, Account,
CallChannel, CallChannel,
+4
View File
@@ -143,6 +143,10 @@ onMounted(async () => {
<el-icon><Refresh /></el-icon> <el-icon><Refresh /></el-icon>
<span>同步</span> <span>同步</span>
</el-menu-item> </el-menu-item>
<el-menu-item index="/search">
<el-icon><Search /></el-icon>
<span>搜索</span>
</el-menu-item>
</el-menu> </el-menu>
</el-header> </el-header>
+6
View File
@@ -51,6 +51,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/SyncStatus.vue'), component: () => import('@/views/SyncStatus.vue'),
meta: { title: '同步状态' }, meta: { title: '同步状态' },
}, },
{
path: '/search',
name: 'search',
component: () => import('@/views/SearchView.vue'),
meta: { title: '搜索' },
},
{ {
path: '/chat/:channel/:ownerId/:talkerId', path: '/chat/:channel/:ownerId/:talkerId',
name: 'chat', name: 'chat',
+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>