Compare commits
2 Commits
9d3cc8e76e
...
2e526cd056
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e526cd056 | |||
| dfa4fdd420 |
@@ -1,21 +1,25 @@
|
|||||||
"""GET /api/export/chat-html — 导出一段 1:1 / 群聊天为独立 HTML 文件。
|
"""GET /api/export/chat-html — 导出一段 1:1 / 群聊天为独立 HTML 文件。
|
||||||
|
GET /api/export/chat-html-zip — 勾选的多个 1:1 会话打成一个 zip(每会话一个 HTML)。
|
||||||
|
|
||||||
对齐云客微信客户列表「导出HTML聊天记录」:按 (channel, owner, talker_id|room_id)
|
对齐云客微信客户列表「导出HTML聊天记录」:按 (channel, owner, talker_id|room_id)
|
||||||
拉消息(可选 start/end 时间范围),渲染自包含 HTML 聊天页(is_send 决定左右气泡),
|
拉消息(可选 start/end 时间范围),渲染自包含 HTML 聊天页(is_send 决定左右气泡),
|
||||||
作为 attachment 返回下载。
|
作为 attachment 返回下载。zip 版对齐员工客户页的批量「导出聊天记录」(2026-09-08)。
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import re
|
||||||
|
import zipfile
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from html import escape
|
from html import escape
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from fastapi import APIRouter, Depends, Query, Response
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.api.deps import get_session
|
from app.api.deps import get_session
|
||||||
from app.db.models import Message
|
from app.db.models import Customer, Message
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/export", tags=["export"])
|
router = APIRouter(prefix="/api/export", tags=["export"])
|
||||||
|
|
||||||
@@ -170,3 +174,92 @@ async def export_chat_html(
|
|||||||
media_type="text/html; charset=utf-8",
|
media_type="text/html; charset=utf-8",
|
||||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 员工客户页批量「导出聊天记录」:一次勾选的会话数上限(防误点全选把服务端打爆;
|
||||||
|
# 50 个会话 × 每会话 EXPORT_MAX_ROWS 已是内存上界内的最坏情况)。
|
||||||
|
ZIP_MAX_CONVS = 50
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_zip_name(name: str, fallback: str) -> str:
|
||||||
|
"""会话显示名 → zip 内文件名:去路径/Windows 非法字符,限长,空回退。"""
|
||||||
|
cleaned = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", name).strip("._ ")
|
||||||
|
return cleaned[:40] or fallback
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/chat-html-zip")
|
||||||
|
async def export_chat_html_zip(
|
||||||
|
session: SessionDep,
|
||||||
|
channel: Annotated[str, Query()],
|
||||||
|
owner_account_id: Annotated[str, Query()],
|
||||||
|
talker_ids: Annotated[str, Query()] = "",
|
||||||
|
start: Annotated[int | None, Query(ge=0)] = None,
|
||||||
|
end: Annotated[int | None, Query(ge=0)] = None,
|
||||||
|
) -> Response:
|
||||||
|
"""勾选客户的 1:1 聊天批量导出为一个 zip(每会话一个 HTML,复用单导出渲染器)。
|
||||||
|
|
||||||
|
``talker_ids`` 逗号分隔的 friend_id 列表;文件名取客户备注/昵称(缺省回退
|
||||||
|
friend_id),加序号前缀保唯一;单会话行数沿用 EXPORT_MAX_ROWS 截断告警。
|
||||||
|
"""
|
||||||
|
ids = [t.strip() for t in talker_ids.split(",") if t.strip()]
|
||||||
|
if not ids:
|
||||||
|
raise HTTPException(status_code=422, detail="talker_ids 不能为空")
|
||||||
|
if len(ids) > ZIP_MAX_CONVS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422, detail=f"一次最多导出 {ZIP_MAX_CONVS} 个会话"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 文件名显示名:批量取一次 Customer(备注 > 昵称 > friend_id)
|
||||||
|
cust_rows = (await session.execute(
|
||||||
|
sa.select(
|
||||||
|
Customer.friend_id,
|
||||||
|
Customer.friend_nickname,
|
||||||
|
Customer.friend_remark,
|
||||||
|
).where(
|
||||||
|
Customer.channel == channel,
|
||||||
|
Customer.owner_account_id == owner_account_id,
|
||||||
|
Customer.friend_id.in_(ids),
|
||||||
|
)
|
||||||
|
)).all()
|
||||||
|
names = {
|
||||||
|
r.friend_id: (r.friend_remark or r.friend_nickname or r.friend_id)
|
||||||
|
for r in cust_rows
|
||||||
|
}
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
for i, tid in enumerate(ids, 1):
|
||||||
|
q = sa.select(Message).where(
|
||||||
|
Message.channel == channel,
|
||||||
|
Message.owner_account_id == owner_account_id,
|
||||||
|
Message.talker_id == tid,
|
||||||
|
# DM:限定 room_id 为空,避免误捞群消息(同单导出)
|
||||||
|
(Message.room_id == None) | (Message.room_id == ""), # noqa: E711
|
||||||
|
)
|
||||||
|
if start is not None:
|
||||||
|
q = q.where(Message.timestamp >= start)
|
||||||
|
if end is not None:
|
||||||
|
q = q.where(Message.timestamp <= end)
|
||||||
|
q = q.order_by(
|
||||||
|
sa.nullslast(Message.timestamp.asc()), Message.msg_id.asc()
|
||||||
|
).limit(EXPORT_MAX_ROWS)
|
||||||
|
rows = list((await session.execute(q)).scalars().all())
|
||||||
|
capped = len(rows) >= EXPORT_MAX_ROWS
|
||||||
|
warning = (
|
||||||
|
f'<div class="export-warning">⚠️ 结果超过 {EXPORT_MAX_ROWS} 条,'
|
||||||
|
f"仅导出前 {EXPORT_MAX_ROWS} 条。请缩小日期范围。</div>"
|
||||||
|
if capped
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
html = _render_chat_html(rows, channel, owner_account_id, tid, warning=warning)
|
||||||
|
safe = _safe_zip_name(names.get(tid) or tid, f"conv{i}")
|
||||||
|
zf.writestr(f"{i:02d}_{safe}.html", html)
|
||||||
|
return Response(
|
||||||
|
content=buf.getvalue(),
|
||||||
|
media_type="application/zip",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": (
|
||||||
|
f'attachment; filename="chat-records-{channel}-{owner_account_id}.zip"'
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ class CustomerOut(BaseModel):
|
|||||||
last_chat_time: int | None = None
|
last_chat_time: int | None = None
|
||||||
add_time: int | None = None
|
add_time: int | None = None
|
||||||
message_count: int | None = None
|
message_count: int | None = None
|
||||||
|
# 累计发送/接收条数(同步自云客 friend 记录的 sendNum/receiveNum;员工
|
||||||
|
# 客户页「发送条数/接收条数」列用,2026-09-08)
|
||||||
|
send_num: int | None = None
|
||||||
|
receive_num: int | None = None
|
||||||
version: str | None = None
|
version: str | None = None
|
||||||
# Verbatim upstream friend record (~60 camelCase fields). Surfaced so the
|
# Verbatim upstream friend record (~60 camelCase fields). Surfaced so the
|
||||||
# all-fields drawer can render every column the 云客 source page shows
|
# all-fields drawer can render every column the 云客 source page shows
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
"""Tests for GET /api/export/chat-html — 导出 1:1/群聊天为自包含 HTML。
|
"""Tests for GET /api/export/chat-html(+zip) — 导出 1:1/群聊天为自包含 HTML。
|
||||||
|
|
||||||
对齐云客微信客户/群列表「导出HTML聊天记录」:按 (channel, owner, talker_id|room_id)
|
对齐云客微信客户/群列表「导出HTML聊天记录」:按 (channel, owner, talker_id|room_id)
|
||||||
拉消息(可选 start/end 时间范围),渲染为左右气泡 HTML 作为 attachment 返回下载。
|
拉消息(可选 start/end 时间范围),渲染为左右气泡 HTML 作为 attachment 返回下载。
|
||||||
|
zip 版:员工客户页勾选多个客户批量「导出聊天记录」(2026-09-08)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import functools
|
import functools
|
||||||
|
import io
|
||||||
|
import zipfile
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.api.routers.export import EXPORT_MAX_ROWS
|
from app.api.routers.export import EXPORT_MAX_ROWS, ZIP_MAX_CONVS
|
||||||
from app.db.models import Message
|
from app.db.models import Customer, Message
|
||||||
|
|
||||||
|
|
||||||
async def _seed(session: AsyncSession) -> None:
|
async def _seed(session: AsyncSession) -> None:
|
||||||
@@ -160,3 +163,91 @@ async def test_export_group_ignores_owner_mismatch(make_client):
|
|||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert "群消息1" in r.text
|
assert "群消息1" in r.text
|
||||||
assert "群消息2" in r.text
|
assert "群消息2" in r.text
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /api/export/chat-html-zip — 批量导出(2026-09-08 员工客户页)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _seed_zip(session: AsyncSession) -> None:
|
||||||
|
"""两个客户各几条 DM + 一条同 talker 的群消息(不应混入 DM zip)。"""
|
||||||
|
session.add_all([
|
||||||
|
Customer(channel="wechat", owner_account_id="wx_owner", friend_id="f1",
|
||||||
|
friend_nickname="小蜜", friend_remark="蜜姐"),
|
||||||
|
Customer(channel="wechat", owner_account_id="wx_owner", friend_id="f2",
|
||||||
|
friend_nickname="老王"),
|
||||||
|
Customer(channel="wechat", owner_account_id="wx_owner", friend_id="f3",
|
||||||
|
friend_nickname='坏/名字<>:"|'),
|
||||||
|
Message(msg_id="z1", channel="wechat", owner_account_id="wx_owner",
|
||||||
|
talker_id="f1", room_id=None, text="f1你好", timestamp=1000,
|
||||||
|
is_send=False, type="text", type_raw=1),
|
||||||
|
Message(msg_id="z2", channel="wechat", owner_account_id="wx_owner",
|
||||||
|
talker_id="f2", room_id=None, text="f2在吗", timestamp=2000,
|
||||||
|
is_send=True, type="text", type_raw=1),
|
||||||
|
Message(msg_id="z3", channel="wechat", owner_account_id="wx_owner",
|
||||||
|
talker_id="f2", room_id=None, text="f2更早", timestamp=500,
|
||||||
|
is_send=True, type="text", type_raw=1),
|
||||||
|
# f1 名下的群消息:room_id 有值,zip 的 DM 查询必须排除
|
||||||
|
Message(msg_id="z4", channel="wechat", owner_account_id="wx_owner",
|
||||||
|
talker_id="f1", room_id="g@chatroom", text="群内消息不进zip",
|
||||||
|
timestamp=3000, is_send=True, type="text", type_raw=1),
|
||||||
|
])
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _zip_entries(r) -> dict[str, str]:
|
||||||
|
assert r.headers["content-type"].startswith("application/zip")
|
||||||
|
with zipfile.ZipFile(io.BytesIO(r.content)) as zf:
|
||||||
|
return {name: zf.read(name).decode("utf-8") for name in zf.namelist()}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_zip_two_conversations_with_display_names(make_client):
|
||||||
|
"""zip 含两个会话 HTML;文件名取备注>昵称;群消息不混入。"""
|
||||||
|
client = await make_client(_seed_zip)
|
||||||
|
r = await client.get("/api/export/chat-html-zip", params={
|
||||||
|
"channel": "wechat", "owner_account_id": "wx_owner",
|
||||||
|
"talker_ids": "f1,f2"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "attachment" in r.headers["content-disposition"]
|
||||||
|
entries = _zip_entries(r)
|
||||||
|
assert sorted(entries) == ["01_蜜姐.html", "02_老王.html"]
|
||||||
|
assert "f1你好" in entries["01_蜜姐.html"]
|
||||||
|
assert "f2在吗" in entries["02_老王.html"]
|
||||||
|
# DM 查询排除 room 消息
|
||||||
|
assert "群内消息不进zip" not in "".join(entries.values())
|
||||||
|
|
||||||
|
|
||||||
|
async def test_zip_time_range_filter(make_client):
|
||||||
|
"""start/end 同样作用于 zip 内每个会话。"""
|
||||||
|
client = await make_client(_seed_zip)
|
||||||
|
r = await client.get("/api/export/chat-html-zip", params={
|
||||||
|
"channel": "wechat", "owner_account_id": "wx_owner",
|
||||||
|
"talker_ids": "f2", "start": 1000})
|
||||||
|
assert r.status_code == 200
|
||||||
|
entries = _zip_entries(r)
|
||||||
|
assert "f2在吗" in entries["01_老王.html"]
|
||||||
|
assert "f2更早" not in entries["01_老王.html"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_zip_filename_sanitized_and_fallback(make_client):
|
||||||
|
"""非法字符客户名清洗;无 customer 行的 talker 回退 friend_id。"""
|
||||||
|
client = await make_client(_seed_zip)
|
||||||
|
r = await client.get("/api/export/chat-html-zip", params={
|
||||||
|
"channel": "wechat", "owner_account_id": "wx_owner",
|
||||||
|
"talker_ids": "f3,f_unknown"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
names = sorted(_zip_entries(r))
|
||||||
|
assert names[0].startswith("01_") and "/" not in names[0] and "<" not in names[0]
|
||||||
|
assert names[1] == "02_f_unknown.html"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_zip_rejects_empty_and_over_limit(make_client):
|
||||||
|
"""空列表/超上限 422,防误传。"""
|
||||||
|
client = await make_client(_seed_zip)
|
||||||
|
r_empty = await client.get("/api/export/chat-html-zip", params={
|
||||||
|
"channel": "wechat", "owner_account_id": "wx_owner", "talker_ids": " , "})
|
||||||
|
assert r_empty.status_code == 422
|
||||||
|
many = ",".join(f"t{i}" for i in range(ZIP_MAX_CONVS + 1))
|
||||||
|
r_many = await client.get("/api/export/chat-html-zip", params={
|
||||||
|
"channel": "wechat", "owner_account_id": "wx_owner", "talker_ids": many})
|
||||||
|
assert r_many.status_code == 422
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ export interface Customer {
|
|||||||
/** 今日发送/接收条数(本地消息聚合,对齐云客今日口径)。 */
|
/** 今日发送/接收条数(本地消息聚合,对齐云客今日口径)。 */
|
||||||
send_today?: number | null
|
send_today?: number | null
|
||||||
receive_today?: number | null
|
receive_today?: number | null
|
||||||
|
/** 累计发送/接收条数(同步自云客 friend 记录 sendNum/receiveNum;员工客户页列)。 */
|
||||||
|
send_num?: number | null
|
||||||
|
receive_num?: number | null
|
||||||
/** ms epoch of friend add time (from raw_json.addTime). */
|
/** ms epoch of friend add time (from raw_json.addTime). */
|
||||||
add_time?: number | null
|
add_time?: number | null
|
||||||
message_count?: number | null
|
message_count?: number | null
|
||||||
|
|||||||
@@ -117,6 +117,15 @@ const routes: RouteRecordRaw[] = [
|
|||||||
props: true,
|
props: true,
|
||||||
meta: { title: '员工聊天窗口' },
|
meta: { title: '员工聊天窗口' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// 员工客户列表(2026-09-08 对齐云客员工详情):员工列表「好友数/微信群数」
|
||||||
|
// 账号维度下钻,与全局 /customers 分离; ?name= 直带员工显示名(直链自动解析)
|
||||||
|
path: '/staff-friends/:ownerId/:tab(customers|groups)?',
|
||||||
|
name: 'staff-friends',
|
||||||
|
component: () => import('@/views/StaffFriends.vue'),
|
||||||
|
props: true,
|
||||||
|
meta: { title: '员工客户列表' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
// 登录页(public:未登录可进;守卫据此放行)
|
// 登录页(public:未登录可进;守卫据此放行)
|
||||||
path: '/login',
|
path: '/login',
|
||||||
|
|||||||
@@ -43,6 +43,20 @@ function freshTabState(pageSize = 5): TabState {
|
|||||||
/** 云客口径客户合计(分渠道,Σ员工好友数声明值,含云客索引重复;页脚对齐云客)。 */
|
/** 云客口径客户合计(分渠道,Σ员工好友数声明值,含云客索引重复;页脚对齐云客)。 */
|
||||||
const declaredTotals = ref<{ wechat: number | null; whatsapp: number | null }>({ wechat: null, whatsapp: null })
|
const declaredTotals = ref<{ wechat: number | null; whatsapp: number | null }>({ wechat: null, whatsapp: null })
|
||||||
|
|
||||||
|
/** 列表过滤是否生效(部门/员工)——生效时计数与分页必须用真实 total,声明口径是
|
||||||
|
* 全局值不吃过滤:员工列表点「好友数」跳入时曾把 8956 当分页总数,列表却只有
|
||||||
|
* 该员工的 9 条,翻页按全部客户算(2026-09-08 用户报「不是员工下面的好友列表」)。 */
|
||||||
|
const filtered = computed(
|
||||||
|
() => departmentId.value !== null || ownerId.value !== null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 展示用总数:无过滤时对齐云客声明口径(产品定调),有过滤时回到真实结果数。 */
|
||||||
|
function displayTotal(tab: TabKey): number {
|
||||||
|
return !filtered.value && declaredTotals.value[tab] !== null
|
||||||
|
? (declaredTotals.value[tab] as number)
|
||||||
|
: tabState[tab].total
|
||||||
|
}
|
||||||
|
|
||||||
async function loadDeclaredTotal(): Promise<void> {
|
async function loadDeclaredTotal(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const r = await getCustomerDeclaredTotal()
|
const r = await getCustomerDeclaredTotal()
|
||||||
@@ -440,7 +454,7 @@ onMounted(() => {
|
|||||||
<div>
|
<div>
|
||||||
<h2 style="margin: 0 0 4px; font-size: 16px">{{ activeTab === 'wechat' ? '微信' : 'WhatsApp' }}客户列表</h2>
|
<h2 style="margin: 0 0 4px; font-size: 16px">{{ activeTab === 'wechat' ? '微信' : 'WhatsApp' }}客户列表</h2>
|
||||||
<span class="yk-customers__sub">
|
<span class="yk-customers__sub">
|
||||||
共 {{ declaredTotals[activeTab] !== null ? declaredTotals[activeTab] : tabState[activeTab].total }} 条
|
共 {{ displayTotal(activeTab) }} 条
|
||||||
<template v-if="departmentName"> · 部门:{{ departmentName }}</template>
|
<template v-if="departmentName"> · 部门:{{ departmentName }}</template>
|
||||||
<template v-if="ownerId"> · 员工账号:{{ ownerId }}</template>
|
<template v-if="ownerId"> · 员工账号:{{ ownerId }}</template>
|
||||||
</span>
|
</span>
|
||||||
@@ -589,7 +603,7 @@ onMounted(() => {
|
|||||||
:current-page="tabState.wechat.page"
|
:current-page="tabState.wechat.page"
|
||||||
:page-size="tabState.wechat.pageSize"
|
:page-size="tabState.wechat.pageSize"
|
||||||
:page-sizes="[5, 10, 20, 50, 100]"
|
:page-sizes="[5, 10, 20, 50, 100]"
|
||||||
:total="declaredTotals.wechat ?? tabState.wechat.total"
|
:total="displayTotal('wechat')"
|
||||||
layout="total, sizes, prev, pager, next, jumper"
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
background
|
background
|
||||||
@current-change="onWechatPageChange"
|
@current-change="onWechatPageChange"
|
||||||
@@ -726,7 +740,7 @@ onMounted(() => {
|
|||||||
:current-page="tabState.whatsapp.page"
|
:current-page="tabState.whatsapp.page"
|
||||||
:page-size="tabState.whatsapp.pageSize"
|
:page-size="tabState.whatsapp.pageSize"
|
||||||
:page-sizes="[5, 10, 20, 50, 100]"
|
:page-sizes="[5, 10, 20, 50, 100]"
|
||||||
:total="declaredTotals.whatsapp ?? tabState.whatsapp.total"
|
:total="displayTotal('whatsapp')"
|
||||||
layout="total, sizes, prev, pager, next, jumper"
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
background
|
background
|
||||||
@current-change="onWhatsappPageChange"
|
@current-change="onWhatsappPageChange"
|
||||||
|
|||||||
@@ -0,0 +1,587 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 员工客户列表页(2026-09-08,对齐云客员工详情客户列表,用户截图版式)。
|
||||||
|
*
|
||||||
|
* 入口:员工列表行内「好友数/微信群数」(微信行)——账号维度下钻,与全局
|
||||||
|
* 客户列表(/customers)分离:固定 owner 过滤、微信客户/微信群两 tab、
|
||||||
|
* 搜索(昵称/微信号/备注名/手机号)、勾选批量「导出聊天记录」(服务端打
|
||||||
|
* zip,每会话一个 HTML)、行内查看/导出聊天记录(沿用 staff-chat 与
|
||||||
|
* /export/chat-html)。
|
||||||
|
*
|
||||||
|
* 列口径:发送/接收条数用 Customer.send_num/receive_num(云客 friend 记录
|
||||||
|
* 累计值,与截图同源);分页用真实过滤 total(本页恒有 owner 过滤,不存在
|
||||||
|
* 全局声明口径问题)。
|
||||||
|
*/
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { ArrowLeft } from '@element-plus/icons-vue'
|
||||||
|
import { getAccounts, getCustomers, getGroups } from '@/api'
|
||||||
|
import { downloadBlob } from '@/api/client'
|
||||||
|
import type { Account, Customer, Group } from '@/api/types'
|
||||||
|
import { exportListToCsv } from '@/utils/csvExport'
|
||||||
|
import { formatTime } from '@/utils/format'
|
||||||
|
import { fromTypeLabel, readField } from '@/utils/customerFields'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/** 归属员工账号(wechatId) */
|
||||||
|
ownerId: string
|
||||||
|
/** customers=微信客户 tab(默认) / groups=微信群 tab */
|
||||||
|
tab?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const activeTab = ref<'customers' | 'groups'>(props.tab === 'groups' ? 'groups' : 'customers')
|
||||||
|
|
||||||
|
// ---- 员工名:入口 query ?name= 直带;直链/刷新时经 /api/accounts 解析 ----
|
||||||
|
const staffName = ref<string | null>(
|
||||||
|
route.query.name ? String(route.query.name) : null,
|
||||||
|
)
|
||||||
|
const resolvedName = ref<string | null>(null)
|
||||||
|
const displayTitle = computed(() => staffName.value ?? resolvedName.value ?? props.ownerId)
|
||||||
|
|
||||||
|
async function resolveName(): Promise<void> {
|
||||||
|
if (staffName.value) return
|
||||||
|
try {
|
||||||
|
const res = await getAccounts({ channel: 'wechat', page: 1, page_size: 100 })
|
||||||
|
const hit = res.items.find((a: Account) => a.account_id === props.ownerId)
|
||||||
|
resolvedName.value = hit?.nickname || hit?.user_name || null
|
||||||
|
} catch {
|
||||||
|
// 静默降级:标题显示 ownerId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 搜索(作用于当前 tab;/api/customers 与 /api/groups 均支持 q) ----
|
||||||
|
const keyword = ref('')
|
||||||
|
|
||||||
|
// ---- 微信客户 tab ----
|
||||||
|
const customers = ref<Customer[]>([])
|
||||||
|
const custTotal = ref(0)
|
||||||
|
const custPage = ref(1)
|
||||||
|
const custPageSize = ref(10)
|
||||||
|
const custLoading = ref(false)
|
||||||
|
const custError = ref<string | null>(null)
|
||||||
|
|
||||||
|
// ---- 微信群 tab ----
|
||||||
|
const groups = ref<Group[]>([])
|
||||||
|
const groupTotal = ref(0)
|
||||||
|
const groupPage = ref(1)
|
||||||
|
const groupPageSize = ref(10)
|
||||||
|
const groupLoading = ref(false)
|
||||||
|
const groupError = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function loadCustomers(): Promise<void> {
|
||||||
|
custLoading.value = true
|
||||||
|
custError.value = null
|
||||||
|
try {
|
||||||
|
const res = await getCustomers({
|
||||||
|
channel: 'wechat',
|
||||||
|
owner_account_id: props.ownerId,
|
||||||
|
q: keyword.value.trim() || undefined,
|
||||||
|
page: custPage.value,
|
||||||
|
page_size: custPageSize.value,
|
||||||
|
})
|
||||||
|
customers.value = res.items
|
||||||
|
custTotal.value = res.total
|
||||||
|
} catch (e) {
|
||||||
|
custError.value = (e as Error).message
|
||||||
|
ElMessage.error(`客户列表加载失败:${(e as Error).message}`)
|
||||||
|
} finally {
|
||||||
|
custLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadGroups(): Promise<void> {
|
||||||
|
groupLoading.value = true
|
||||||
|
groupError.value = null
|
||||||
|
try {
|
||||||
|
const res = await getGroups({
|
||||||
|
channel: 'wechat',
|
||||||
|
owner_account_id: props.ownerId,
|
||||||
|
q: keyword.value.trim() || undefined,
|
||||||
|
page: groupPage.value,
|
||||||
|
page_size: groupPageSize.value,
|
||||||
|
})
|
||||||
|
groups.value = res.items
|
||||||
|
groupTotal.value = res.total
|
||||||
|
} catch (e) {
|
||||||
|
groupError.value = (e as Error).message
|
||||||
|
ElMessage.error(`群列表加载失败:${(e as Error).message}`)
|
||||||
|
} finally {
|
||||||
|
groupLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSearch(): void {
|
||||||
|
custPage.value = 1
|
||||||
|
groupPage.value = 1
|
||||||
|
clearSelection()
|
||||||
|
if (activeTab.value === 'customers') loadCustomers()
|
||||||
|
else loadGroups()
|
||||||
|
}
|
||||||
|
|
||||||
|
// tab 切换:懒加载目标 tab(搜索词共享);URL param 同步,便于直链/刷新。
|
||||||
|
function onTabChange(t: 'customers' | 'groups'): void {
|
||||||
|
clearSelection()
|
||||||
|
router.replace({
|
||||||
|
name: 'staff-friends',
|
||||||
|
params: { ownerId: props.ownerId, tab: t === 'groups' ? 'groups' : 'customers' },
|
||||||
|
query: route.query.name ? { name: String(route.query.name) } : undefined,
|
||||||
|
})
|
||||||
|
if (t === 'customers') {
|
||||||
|
if (customers.value.length === 0 && custTotal.value === 0) loadCustomers()
|
||||||
|
} else {
|
||||||
|
if (groups.value.length === 0 && groupTotal.value === 0) loadGroups()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 勾选 + 批量导出聊天记录(服务端 zip) ----
|
||||||
|
const selected = ref<Customer[]>([])
|
||||||
|
|
||||||
|
function onSelectionChange(rows: Customer[]): void {
|
||||||
|
selected.value = rows
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelection(): void {
|
||||||
|
selected.value = []
|
||||||
|
custTableRef.value?.clearSelection()
|
||||||
|
}
|
||||||
|
const custTableRef = ref<{
|
||||||
|
clearSelection: () => void
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
|
const ZIP_LIMIT = 50
|
||||||
|
const batchExporting = ref(false)
|
||||||
|
|
||||||
|
async function exportChatZip(): Promise<void> {
|
||||||
|
if (selected.value.length === 0) return
|
||||||
|
if (selected.value.length > ZIP_LIMIT) {
|
||||||
|
ElMessage.warning(`一次最多导出 ${ZIP_LIMIT} 个会话,请减少勾选`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
batchExporting.value = true
|
||||||
|
try {
|
||||||
|
await downloadBlob('/export/chat-html-zip', {
|
||||||
|
channel: 'wechat',
|
||||||
|
owner_account_id: props.ownerId,
|
||||||
|
talker_ids: selected.value.map((c) => c.friend_id).join(','),
|
||||||
|
})
|
||||||
|
ElMessage.success(`已导出 ${selected.value.length} 个会话的聊天记录`)
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(`批量导出失败:${(e as Error).message}`)
|
||||||
|
} finally {
|
||||||
|
batchExporting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 行内:查看聊天记录 → 员工聊天窗口(single/group tab) ----
|
||||||
|
function viewChat(row: Customer): void {
|
||||||
|
router.push({
|
||||||
|
name: 'staff-chat',
|
||||||
|
params: { ownerId: props.ownerId, tab: 'single', talkerId: row.friend_id },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewGroupChat(row: Group): void {
|
||||||
|
router.push({
|
||||||
|
name: 'staff-chat',
|
||||||
|
params: { ownerId: props.ownerId, tab: 'group', talkerId: row.room_id },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 行内:导出HTML聊天记录(可选时间范围,沿用全局客户列表的弹窗模式) ----
|
||||||
|
const exportOpen = ref(false)
|
||||||
|
const exportRow = ref<Customer | null>(null)
|
||||||
|
// value-format="X" 绑秒级 unix 字符串;confirm 时 *1000 转毫秒。
|
||||||
|
const exportRange = ref<[string, string] | null>(null)
|
||||||
|
|
||||||
|
function openExport(row: Customer): void {
|
||||||
|
exportRow.value = row
|
||||||
|
exportRange.value = null
|
||||||
|
exportOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmExport(): Promise<void> {
|
||||||
|
const row = exportRow.value
|
||||||
|
if (!row) return
|
||||||
|
const params: Record<string, unknown> = {
|
||||||
|
channel: 'wechat',
|
||||||
|
owner_account_id: props.ownerId,
|
||||||
|
talker_id: row.friend_id,
|
||||||
|
}
|
||||||
|
if (exportRange.value) {
|
||||||
|
params.start = Number(exportRange.value[0]) * 1000
|
||||||
|
params.end = Number(exportRange.value[1]) * 1000
|
||||||
|
}
|
||||||
|
exportOpen.value = false
|
||||||
|
try {
|
||||||
|
await downloadBlob('/export/chat-html', params)
|
||||||
|
ElMessage.success('导出已开始')
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(`导出失败,请稍后重试:${(e as Error).message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 导出 CSV(当前 tab + 搜索词 + owner 过滤) ----
|
||||||
|
const exporting = ref(false)
|
||||||
|
|
||||||
|
async function exportCsv(): Promise<void> {
|
||||||
|
exporting.value = true
|
||||||
|
try {
|
||||||
|
if (activeTab.value === 'customers') {
|
||||||
|
const r = await exportListToCsv<Customer>({
|
||||||
|
fetchPage: (page, pageSize) =>
|
||||||
|
getCustomers({
|
||||||
|
channel: 'wechat',
|
||||||
|
owner_account_id: props.ownerId,
|
||||||
|
q: keyword.value.trim() || undefined,
|
||||||
|
page,
|
||||||
|
page_size: pageSize,
|
||||||
|
}),
|
||||||
|
columns: [
|
||||||
|
{ key: 'friend_nickname', label: '客户昵称' },
|
||||||
|
{ key: 'friend_id', label: '微信号' },
|
||||||
|
{ key: 'friend_remark', label: '备注名' },
|
||||||
|
{ key: 'from_type', label: '数据来源', format: (r) => fromTypeLabel(readField(r, 'fromType')) },
|
||||||
|
{
|
||||||
|
key: 'last_chat_time',
|
||||||
|
label: '最近沟通时间',
|
||||||
|
format: (r) => (r.last_chat_time ? formatTime(r.last_chat_time) : '-'),
|
||||||
|
},
|
||||||
|
{ key: 'send_num', label: '发送条数', format: (r) => r.send_num ?? '-' },
|
||||||
|
{ key: 'receive_num', label: '接收条数', format: (r) => r.receive_num ?? '-' },
|
||||||
|
],
|
||||||
|
filename: `微信客户_${props.ownerId}`,
|
||||||
|
})
|
||||||
|
ElMessage.success(`已导出 ${r.rows} 行`)
|
||||||
|
} else {
|
||||||
|
const r = await exportListToCsv<Group>({
|
||||||
|
fetchPage: (page, pageSize) =>
|
||||||
|
getGroups({
|
||||||
|
channel: 'wechat',
|
||||||
|
owner_account_id: props.ownerId,
|
||||||
|
q: keyword.value.trim() || undefined,
|
||||||
|
page,
|
||||||
|
page_size: pageSize,
|
||||||
|
}),
|
||||||
|
columns: [
|
||||||
|
{ key: 'room_name', label: '群名称' },
|
||||||
|
{ key: 'room_id', label: '群ID' },
|
||||||
|
{ key: 'owner_name', label: '群主昵称' },
|
||||||
|
{ key: 'owner_wxid', label: '群主wxId' },
|
||||||
|
{ key: 'member_count', label: '群成员数量' },
|
||||||
|
{ key: 'message_count', label: '消息数量' },
|
||||||
|
],
|
||||||
|
filename: `微信群_${props.ownerId}`,
|
||||||
|
})
|
||||||
|
ElMessage.success(`已导出 ${r.rows} 行`)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(`导出失败:${(e as Error).message}`)
|
||||||
|
} finally {
|
||||||
|
exporting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 展示 helper ----
|
||||||
|
function avatarUrl(c: Customer): string | undefined {
|
||||||
|
const v = readField(c, 'friendHeadImageUrl')
|
||||||
|
return typeof v === 'string' && v !== '' ? v : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupAvatarUrl(g: Group): string | undefined {
|
||||||
|
const v = g.raw_json?.headImageUrl
|
||||||
|
return typeof v === 'string' && v !== '' ? v : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function initials(c: Customer): string {
|
||||||
|
const nick = c.friend_nickname || c.friend_id
|
||||||
|
return nick ? nick.slice(0, 1).toUpperCase() : '?'
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupInitials(g: Group): string {
|
||||||
|
const n = g.room_name || g.room_id
|
||||||
|
return n ? n.slice(0, 1) : '?'
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack(): void {
|
||||||
|
if (window.history.length > 1) router.back()
|
||||||
|
else router.push({ path: '/staff', query: { channel: 'wechat' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 生命周期 + 路由参数变化(同组件不同员工切换) ----
|
||||||
|
watch(
|
||||||
|
() => [props.ownerId, props.tab] as const,
|
||||||
|
() => {
|
||||||
|
activeTab.value = props.tab === 'groups' ? 'groups' : 'customers'
|
||||||
|
resetAndLoad()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function resetAndLoad(): void {
|
||||||
|
custPage.value = 1
|
||||||
|
groupPage.value = 1
|
||||||
|
clearSelection()
|
||||||
|
loadCustomers()
|
||||||
|
if (activeTab.value === 'groups') loadGroups()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
resolveName()
|
||||||
|
loadCustomers()
|
||||||
|
if (activeTab.value === 'groups') loadGroups()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="sf-page">
|
||||||
|
<!-- 头部:返回 + 员工名 + 两 tab + 搜索/导出行 -->
|
||||||
|
<div class="sf-header">
|
||||||
|
<div class="sf-header__title">
|
||||||
|
<el-button :icon="ArrowLeft" circle size="small" @click="goBack" />
|
||||||
|
<h1>{{ displayTitle }}</h1>
|
||||||
|
<span class="sf-header__sub">微信号:{{ ownerId }}</span>
|
||||||
|
</div>
|
||||||
|
<el-tabs v-model="activeTab" class="sf-tabs" @tab-change="onTabChange($event as 'customers' | 'groups')">
|
||||||
|
<el-tab-pane label="微信客户" name="customers" />
|
||||||
|
<el-tab-pane label="微信群" name="groups" />
|
||||||
|
</el-tabs>
|
||||||
|
<div class="sf-toolbar">
|
||||||
|
<el-input
|
||||||
|
v-model="keyword"
|
||||||
|
class="sf-search"
|
||||||
|
placeholder="支持微信昵称、微信号、备注名、手机号搜索"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="onSearch"
|
||||||
|
@clear="onSearch"
|
||||||
|
/>
|
||||||
|
<el-button type="primary" @click="onSearch">查询</el-button>
|
||||||
|
<el-button :loading="exporting" @click="exportCsv">导出</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="activeTab === 'customers'"
|
||||||
|
:disabled="selected.length === 0"
|
||||||
|
:loading="batchExporting"
|
||||||
|
@click="exportChatZip"
|
||||||
|
>
|
||||||
|
导出聊天记录{{ selected.length > 0 ? `(${selected.length})` : '' }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 微信客户表 -->
|
||||||
|
<div v-show="activeTab === 'customers'" class="sf-card" v-loading="custLoading">
|
||||||
|
<el-alert v-if="custError" :title="`客户列表加载失败:${custError}`" type="error" show-icon :closable="false" />
|
||||||
|
<el-table
|
||||||
|
ref="custTableRef"
|
||||||
|
:data="customers"
|
||||||
|
row-key="friend_id"
|
||||||
|
empty-text="该员工暂无客户"
|
||||||
|
@selection-change="onSelectionChange"
|
||||||
|
>
|
||||||
|
<el-table-column type="selection" width="42" reserve-selection />
|
||||||
|
<el-table-column label="客户微信" min-width="220">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="sf-wechat">
|
||||||
|
<el-avatar :size="36" :src="avatarUrl(row)" shape="square">
|
||||||
|
{{ initials(row) }}
|
||||||
|
</el-avatar>
|
||||||
|
<div class="sf-wechat__meta">
|
||||||
|
<span class="sf-wechat__nick">{{ row.friend_nickname || '--' }}</span>
|
||||||
|
<span class="sf-wechat__id">{{ row.friend_id }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="数据来源" width="110">
|
||||||
|
<template #default="{ row }">{{ fromTypeLabel(readField(row, 'fromType')) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="客户备注名" min-width="120">
|
||||||
|
<template #default="{ row }">{{ row.friend_remark || '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="最近沟通时间" width="170">
|
||||||
|
<template #default="{ row }">{{ row.last_chat_time ? formatTime(row.last_chat_time) : '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="发送条数" width="95" align="right">
|
||||||
|
<template #default="{ row }">{{ row.send_num ?? '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="接收条数" width="95" align="right">
|
||||||
|
<template #default="{ row }">{{ row.receive_num ?? '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="160" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="sf-actions">
|
||||||
|
<el-button link type="primary" size="small" @click="viewChat(row)">查看聊天记录</el-button>
|
||||||
|
<el-button link type="primary" size="small" @click="openExport(row)">导出HTML聊天记录</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<div class="sf-pager">
|
||||||
|
<el-pagination
|
||||||
|
:current-page="custPage"
|
||||||
|
:page-size="custPageSize"
|
||||||
|
:page-sizes="[10, 20, 50]"
|
||||||
|
:total="custTotal"
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
@current-change="(p: number) => { custPage = p; loadCustomers() }"
|
||||||
|
@size-change="(s: number) => { custPageSize = s; custPage = 1; loadCustomers() }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 微信群表 -->
|
||||||
|
<div v-show="activeTab === 'groups'" class="sf-card" v-loading="groupLoading">
|
||||||
|
<el-alert v-if="groupError" :title="`群列表加载失败:${groupError}`" type="error" show-icon :closable="false" />
|
||||||
|
<el-table :data="groups" row-key="room_id" empty-text="该员工暂无群">
|
||||||
|
<el-table-column label="群名称" min-width="240">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="sf-wechat">
|
||||||
|
<el-avatar :size="36" :src="groupAvatarUrl(row)" shape="square">
|
||||||
|
{{ groupInitials(row) }}
|
||||||
|
</el-avatar>
|
||||||
|
<div class="sf-wechat__meta">
|
||||||
|
<span class="sf-wechat__nick">{{ row.room_name || '--' }}</span>
|
||||||
|
<span class="sf-wechat__id">{{ row.room_id }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="群主" min-width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="sf-wechat__meta">
|
||||||
|
<span>{{ row.owner_name || '-' }}</span>
|
||||||
|
<span v-if="row.owner_wxid" class="sf-wechat__id">{{ row.owner_wxid }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="群成员数量" width="110" align="right">
|
||||||
|
<template #default="{ row }">{{ row.member_count ?? '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="消息数量" width="100" align="right">
|
||||||
|
<template #default="{ row }">{{ row.message_count ?? '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="最近消息时间" width="170">
|
||||||
|
<template #default="{ row }">{{ row.last_chat_time ? formatTime(row.last_chat_time) : '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="130" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="primary" size="small" @click="viewGroupChat(row)">查看群聊记录</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<div class="sf-pager">
|
||||||
|
<el-pagination
|
||||||
|
:current-page="groupPage"
|
||||||
|
:page-size="groupPageSize"
|
||||||
|
:page-sizes="[10, 20, 50]"
|
||||||
|
:total="groupTotal"
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
@current-change="(p: number) => { groupPage = p; loadGroups() }"
|
||||||
|
@size-change="(s: number) => { groupPageSize = s; groupPage = 1; loadGroups() }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 行内导出时间范围弹窗(对齐全局客户列表) -->
|
||||||
|
<el-dialog v-model="exportOpen" title="导出HTML聊天记录" width="420px">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="exportRange"
|
||||||
|
type="datetimerange"
|
||||||
|
value-format="X"
|
||||||
|
range-separator="至"
|
||||||
|
start-placeholder="开始时间"
|
||||||
|
end-placeholder="结束时间"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="exportOpen = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="confirmExport">导出</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sf-page {
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.sf-header {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 16px 0;
|
||||||
|
}
|
||||||
|
.sf-header__title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.sf-header__title h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
.sf-header__sub {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.sf-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 0 12px;
|
||||||
|
}
|
||||||
|
.sf-search {
|
||||||
|
width: 340px;
|
||||||
|
}
|
||||||
|
.sf-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.sf-wechat {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.sf-wechat__meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
line-height: 1.4;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.sf-wechat__nick {
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.sf-wechat__id {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.sf-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.sf-actions .el-button + .el-button {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
.sf-pager {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -239,6 +239,16 @@ function waSerial(index: number): number {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function viewCustomers(row: Account): void {
|
function viewCustomers(row: Account): void {
|
||||||
|
// 微信行 → 员工客户列表独立页(2026-09-08 对齐云客员工详情,账号维度
|
||||||
|
// 下钻:两 tab/搜索/批量导出);WA 行保持全局客户列表(该页暂仅微信)。
|
||||||
|
if (row.channel === 'wechat') {
|
||||||
|
router.push({
|
||||||
|
name: 'staff-friends',
|
||||||
|
params: { ownerId: row.account_id, tab: 'customers' },
|
||||||
|
query: { name: row.nickname || row.user_name || '' },
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
channelStore.setChannel(row.channel)
|
channelStore.setChannel(row.channel)
|
||||||
router.push({
|
router.push({
|
||||||
path: '/customers',
|
path: '/customers',
|
||||||
@@ -247,6 +257,15 @@ function viewCustomers(row: Account): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function viewGroups(row: Account): void {
|
function viewGroups(row: Account): void {
|
||||||
|
// 同上:微信群数 → 员工客户列表的群 tab;WA 保持原路径。
|
||||||
|
if (row.channel === 'wechat') {
|
||||||
|
router.push({
|
||||||
|
name: 'staff-friends',
|
||||||
|
params: { ownerId: row.account_id, tab: 'groups' },
|
||||||
|
query: { name: row.nickname || row.user_name || '' },
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
channelStore.setChannel(row.channel)
|
channelStore.setChannel(row.channel)
|
||||||
if (row.channel === 'whatsapp') {
|
if (row.channel === 'whatsapp') {
|
||||||
// WA:对齐云客,群列表在客户列表页的子 tab(2026-08-24)
|
// WA:对齐云客,群列表在客户列表页的子 tab(2026-08-24)
|
||||||
|
|||||||
Reference in New Issue
Block a user