@@ -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=" 420 px ">
<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 : 12 px ;
display : flex ;
flex - direction : column ;
gap : 12 px ;
height : 100 % ;
overflow : auto ;
}
. sf - header {
background : # fff ;
border - radius : 8 px ;
padding : 12 px 16 px 0 ;
}
. sf - header _ _title {
display : flex ;
align - items : center ;
gap : 10 px ;
}
. sf - header _ _title h1 {
margin : 0 ;
font - size : 17 px ;
}
. sf - header _ _sub {
color : var ( -- el - text - color - secondary ) ;
font - size : 12 px ;
}
. sf - toolbar {
display : flex ;
align - items : center ;
gap : 8 px ;
padding : 10 px 0 12 px ;
}
. sf - search {
width : 340 px ;
}
. sf - card {
background : # fff ;
border - radius : 8 px ;
padding : 12 px 16 px ;
flex : 1 ;
min - height : 0 ;
}
. sf - wechat {
display : flex ;
align - items : center ;
gap : 10 px ;
}
. 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 : 12 px ;
overflow : hidden ;
text - overflow : ellipsis ;
white - space : nowrap ;
}
. sf - actions {
display : flex ;
flex - direction : column ;
align - items : flex - start ;
gap : 2 px ;
}
. sf - actions . el - button + . el - button {
margin - left : 0 ;
}
. sf - pager {
display : flex ;
justify - content : flex - end ;
padding - top : 12 px ;
}
< / style >