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:
浅念
2026-09-08 16:14:44 +08:00
parent dfa4fdd420
commit 2e526cd056
7 changed files with 812 additions and 6 deletions
+96 -3
View File
@@ -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"'
)
},
)
+4
View File
@@ -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
+94 -3
View File
@@ -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
+3
View File
@@ -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
+9
View File
@@ -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',
+587
View File
@@ -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>
+19
View File
@@ -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)