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:
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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: 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>
|
||||||
Reference in New Issue
Block a user