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
+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)