feat(staff-friends): 员工客户列表独立页——对齐云客员工详情,好友数/微信群数下钻
员工列表点「好友数」原先跳全局 /customers?owner_account_id=x,布局与全局
列表无异且计数/分页绑全局声明口径(同日 dfa4fdd 修的显示 bug),与云客
「员工名下客户列表」体验割裂。新页 /staff-friends/:ownerId/:tab:
- 微信客户/微信群两 tab(对齐云客截图);标题=员工显示名(入口直带,
直链经 /api/accounts 解析),返回箭头回员工列表
- 搜索(昵称/微信号/备注名/手机号,服务端 q)+ 查询/导出 CSV
- 发送/接收条数=Customer.send_num/receive_num 累计值(CustomerOut 补
两列,同步自云客 friend 记录,与截图同源)
- 勾选批量「导出聊天记录」:新端点 GET /api/export/chat-html-zip,复用
chat-html 渲染器每会话一个 HTML 打 zip(文件名=备注>昵称>friend_id,
非法字符清洗;单会话 5000 行截断告警;一次上限 50 会话防误选打爆)
- 行内:查看聊天记录→staff-chat;导出HTML聊天记录(时间范围弹窗)
- WA 行保持原入口(该页暂仅微信)
后端 605 测试绿(zip 新 4 例);本地浏览器端到端验证:过滤 9 条/勾选
激活/群 tab/返回箭头;zip 产物 application/zip 双会话双气泡。
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -1,21 +1,25 @@
|
||||
"""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)
|
||||
拉消息(可选 start/end 时间范围),渲染自包含 HTML 聊天页(is_send 决定左右气泡),
|
||||
作为 attachment 返回下载。
|
||||
作为 attachment 返回下载。zip 版对齐员工客户页的批量「导出聊天记录」(2026-09-08)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import re
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
from html import escape
|
||||
from typing import Annotated
|
||||
|
||||
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 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"])
|
||||
|
||||
@@ -170,3 +174,92 @@ async def export_chat_html(
|
||||
media_type="text/html; charset=utf-8",
|
||||
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
|
||||
add_time: 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
|
||||
# Verbatim upstream friend record (~60 camelCase fields). Surfaced so the
|
||||
# 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)
|
||||
拉消息(可选 start/end 时间范围),渲染为左右气泡 HTML 作为 attachment 返回下载。
|
||||
zip 版:员工客户页勾选多个客户批量「导出聊天记录」(2026-09-08)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import io
|
||||
import zipfile
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.routers.export import EXPORT_MAX_ROWS
|
||||
from app.db.models import Message
|
||||
from app.api.routers.export import EXPORT_MAX_ROWS, ZIP_MAX_CONVS
|
||||
from app.db.models import Customer, Message
|
||||
|
||||
|
||||
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 "群消息1" 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
|
||||
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). */
|
||||
add_time?: number | null
|
||||
message_count?: number | null
|
||||
|
||||
@@ -117,6 +117,15 @@ const routes: RouteRecordRaw[] = [
|
||||
props: true,
|
||||
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:未登录可进;守卫据此放行)
|
||||
path: '/login',
|
||||
|
||||
@@ -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 {
|
||||
// 微信行 → 员工客户列表独立页(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)
|
||||
router.push({
|
||||
path: '/customers',
|
||||
@@ -247,6 +257,15 @@ function viewCustomers(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)
|
||||
if (row.channel === 'whatsapp') {
|
||||
// WA:对齐云客,群列表在客户列表页的子 tab(2026-08-24)
|
||||
|
||||
Reference in New Issue
Block a user