Files
art-design/src/views/admin/customer/index.vue
T
2025-08-15 16:31:55 +08:00

829 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="customer-page">
<!-- 顶部操作栏 -->
<div class="customer-toolbar boxshadow">
<div class="customer-search-group">
<el-form-item label="关键字:" label-width="70px">
<el-input style="min-width: 120px" v-model="keyword" placeholder="请输入关键字" />
</el-form-item>
<el-form-item label="跟单人" label-width="70px">
<el-select
v-model="selectValue"
style="min-width: 120px"
filterable
multiple
collapse-tags
placeholder="请选择跟单人"
>
<el-option v-for="v in userOptions" :key="v.id" :label="v.name" :value="v.id">
</el-option>
</el-select>
</el-form-item>
</div>
<el-button type="primary" @click="exportfn" v-if="hasAuth('customer@export')">
<i class="iconfont-sys">&#xe89f;</i> 导出
</el-button>
</div>
<!-- 筛选标签 -->
<div class="customer-tags boxshadow">
<!-- 前3组 -->
<div class="filter-row" v-for="item in visibleTagGroups" :key="item.id">
<span class="filter-label">{{ item.name }}</span>
<div class="filter-tags">
<span
:class="['tag-itemall', { 'tag-active': tagActiveMap[item.id] === '' }]"
@click="handleTagClick(item.id, '')"
>全部</span
>
<span
class="tag-items"
v-for="item1 in item.tags"
:key="item1.id"
@click="handleTagClick(item.id, item1.id)"
>
<span :class="['tag-item', { 'tag-active': tagActiveMap[item.id] === item1.id }]">
{{ item1.name }}
</span>
<!-- <el-badge :value="item1.count" :max="99" class="itemtag" type="primary"> </el-badge> -->
</span>
</div>
</div>
<!-- 更多 -->
<transition name="fade">
<div v-if="showMore">
<div class="filter-row" v-for="item in moreTagGroups" :key="item.id">
<span class="filter-label">{{ item.name }}</span>
<div class="filter-tags">
<span
:class="['tag-itemall', { 'tag-active': tagActiveMap[item.id] === '' }]"
@click="handleTagClick(item.id, '')"
>全部</span
>
<span
class="tag-items"
v-for="item1 in item.tags"
:key="item1.id"
@click="handleTagClick(item.id, item1.id)"
>
<span :class="['tag-item', { 'tag-active': tagActiveMap[item.id] === item1.id }]">
{{ item1.name }}
</span>
<!-- <el-badge :value="item1.count" :max="99" class="itemtag" type="primary"> </el-badge> -->
</span>
</div>
</div>
</div>
</transition>
<div class="filter-more-btn" v-if="moreTagGroups.length">
<el-link underline="never" @click="showMore = !showMore">
{{ showMore ? '收起' : '更多' }}
<i v-if="showMore" class="iconfont-sys"> &#xe864;</i>
<i v-else class="iconfont-sys">&#xe867;</i>
</el-link>
</div>
</div>
<div class="boxshadow">
<el-button
type="primary"
@click="plzyDialogVisible = true"
v-if="hasAuth('customer@transfer')"
:disabled="selectedRows.length === 0"
>
<i class="iconfont-sys">&#xe794;</i> 批量转移
</el-button>
<!-- 表格 -->
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="tableData"
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
@selection-change="handleSelectionChange"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
lass="customer-table"
>
<template #default>
<el-table-column type="selection" width="55px" align="center"></el-table-column>
<el-table-column prop="code" label="编号" align="center" />
<el-table-column prop="contacts" label="客户名称" align="center" />
<el-table-column prop="magnitude_txt" label="客户商机" align="center" />
<el-table-column prop="channel_name" label="渠道" align="center" />
<el-table-column prop="country_name" label="国家" align="center" />
<el-table-column prop="phone" label="电话" align="center" />
<el-table-column prop="WhatsApp" label="WhatsApp" align="center" />
<el-table-column prop="email" label="邮箱" align="center" />
<el-table-column prop="grade_txt" label="等级" align="center" />
<el-table-column prop="follow" label="状态" align="center">
<template #default="scope">
<span :class="statusClass(scope.row.follow)">
{{ scope.row.follow_txt }}
</span>
</template>
</el-table-column>
<el-table-column prop="enter_user_name" label="录单人"></el-table-column>
<el-table-column prop="user_name" label="跟单人"></el-table-column>
<el-table-column prop="follow_next" label="下次跟进"></el-table-column>
<el-table-column prop="created_at" label="创建时间"></el-table-column>
<el-table-column fixed="right" label="操作" width="200px" align="center">
<template #default="scope">
<el-link
type="success"
underline="never"
style="margin-right: 8px"
@click="onDetail(scope.row)"
v-if="hasAuth('customer@detail')"
>
<i class="iconfont-sys"> &#xe70f;</i>详情
</el-link>
<el-link
type="primary"
underline="never"
style="margin-right: 8px"
@click="gotransfer(scope.row)"
v-if="hasAuth('customer@transfer')"
>
<i class="iconfont-sys"> &#xe794;</i>转移
</el-link>
<el-link
type="warning"
underline="never"
style="margin-right: 8px"
@click="gofollowdetail(scope.row)"
v-if="hasAuth('customer@follow_detail')"
>
<i class="iconfont-sys"> &#xe64e;</i>跟进记录
</el-link>
</template>
</el-table-column>
</template>
</ArtTable>
</div>
</div>
<!-- 详情对话框 -->
<el-dialog v-model="detailDialogVisible" title="详情" width="800px">
<div class="detail-info-table">
<div class="row">
<div>客户名称:{{ detailRow.contacts }}</div>
<div>电话:{{ detailRow.phone }}</div>
<div>国家:{{ detailRow.country_name }}</div>
</div>
<div class="row">
<div>渠道:{{ detailRow.channel_name }}</div>
<div>邮箱:{{ detailRow.email }}</div>
<div>WhatsApp:{{ detailRow.WhatsApp }}</div>
</div>
<div class="row">
<div>发布人:{{ detailRow.enter_user_name }}</div>
<div>跟单人:{{ detailRow.user_name }}</div>
<div>录单时间:{{ detailRow.created_at }}</div>
</div>
<div class="row">
<div>
等级:
<span>
{{ detailRow.magnitude_txt }}
</span>
</div>
<div>
状态:<span>{{ detailRow.grade_txt }}</span>
</div>
<div>
跟进状态:<span>{{ detailRow.follow_txt }}</span>
</div>
</div>
</div>
<div class="dtitle">详情内容</div>
<div v-if="detailRow" v-html="detailRow.desc_collect" class="detils-content"></div>
</el-dialog>
<!-- 转移跟单对话框 -->
<el-dialog v-model="zyDialogVisible" title="转移" width="360px">
<el-form :model="zyForm" label-width="0">
<el-form-item>
<el-select
v-model="zyForm.user_id"
filterable
placeholder="请选择转移人"
style="width: 100%"
>
<el-option
v-for="item in userOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<div style="display: flex; justify-content: space-around; margin-top: 24px">
<el-button @click="zyDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handlezySubmit">确定</el-button>
</div>
</el-form>
</el-dialog>
<!-- 批量转移对话框 -->
<el-dialog v-model="plzyDialogVisible" title="批量转移" width="360px">
<el-form :model="plzyForm" label-width="0">
<el-form-item>
<el-select
v-model="plzyForm.user_id"
filterable
placeholder="请选择转移人"
style="width: 100%"
>
<el-option
v-for="item in userOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<div style="display: flex; justify-content: space-around; margin-top: 24px">
<el-button @click="plzyDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleplzySubmit">确定</el-button>
</div>
</el-form>
</el-dialog>
<!-- 跟进记录对话框 -->
<el-dialog v-model="followRecordDialogVisible" title="跟进记录" width="800px">
<!-- 跟进记录列表 -->
<div class="follow-record-list">
<ElScrollbar>
<template v-if="followRecords.length > 0">
<ElTimeline>
<ElTimelineItem
v-for="item in followRecords"
:key="item.time"
:placement="TIMELINE_PLACEMENT"
:color="activecolor"
:center="true"
>
<div class="timeline-item">
<div class="timeline-content-row">
<div class="timeline-content">
<p class="timeline-text"
>{{ item.type_txt }}
<span class="timeline-mom">{{ item.created_at }}</span></p
>
<p class="timeline-mom"
><span v-if="item.type == 1">跟进描述</span><span v-else>拜访内容</span>
{{ item.audio_text }}</p
>
</div>
<img
v-if="item.picture_full"
:src="item.picture_full"
class="timeline-img"
alt="图片"
@click="handleImageClick(item.picture_full)"
style="cursor: pointer;"
/>
</div>
</div>
</ElTimelineItem>
</ElTimeline>
</template>
<template v-else>
<!-- 使用 Element Plus 的空状态组件 -->
<el-empty :image="emptyimg" description="暂无跟进记录" />
<!-- <el-empty /> -->
<!-- 或者自定义图片 -->
<!-- <img src="你的空图片地址.png" alt="暂无数据" class="empty-img" /> -->
</template>
</ElScrollbar>
</div>
</el-dialog>
<!-- 图片预览对话框 -->
<el-image-viewer
v-if="imageViewerVisible"
:url-list="imageList"
:initial-index="imageViewerIndex"
@close="closeImageViewer"
/>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { CrmService } from '@/api/crmApi'
import { manageService } from '@/api/adminApi'
import { useAuth } from '@/composables/useAuth'
import emptyimg from '@/assets/img/crm/empty.png'
const TIMELINE_PLACEMENT = 'top'
const activecolor = '#3471ff'
const { hasAuth } = useAuth()
const selectValue = ref<string[]>([])
const showMore = ref(false)
const loading = ref(false)
const detailDialogVisible = ref(false)
const zyDialogVisible = ref(false)
const plzyDialogVisible = ref(false)
const followRecordDialogVisible = ref(false)
const detailRow = ref<any>(null)
const currentCustomer = ref<any>(null)
const followRecords = ref<any[]>([])
const keyword = ref<string>('')
// 图片预览相关
const imageViewerVisible = ref(false)
const imageList = ref<string[]>([])
const imageViewerIndex = ref(0)
const pagination = reactive({
currentPage: 1,
pageSize: 10,
total: 0
})
const plzyForm = reactive({
customer_id: '',
user_id: ''
})
const zyForm = reactive({
customer_id: '',
user_id: ''
})
const userOptions = ref<any>()
const getuserList = async () => {
// 这里调用获取用户列表的接口
const res = await manageService.presetusers()
userOptions.value = res || []
// return res || []
}
// 获取列表数据
const tagActiveMap = reactive<{ [groupId: string]: string }>({})
//获取标签
type TagGroup = { id: string; name: string; tags: { id: string; name: string; count: number }[] }
const tagGroups = ref<TagGroup[]>([])
const tagGrouplistfn = async () => {
const res = await CrmService.tagGrouplist({ scene: 'customers' })
tagGroups.value = res || []
tagGroups.value.forEach((group) => {
if (group.tags && Array.isArray(group.tags)) {
tagActiveMap[group.id] = ''
}
})
}
// 计算前3组和更多组
// eslint-disable-next-line vue/valid-define-props
defineProps()
const visibleTagGroups = computed(() => tagGroups.value.slice(0, 3))
const moreTagGroups = computed(() => tagGroups.value.slice(3))
// 表格数据
const tableData = ref<any[]>([])
onMounted(() => {
getuserList()
gettableList()
tagGrouplistfn()
})
// tabs 切换联动
watch([selectValue, keyword], () => {
pagination.currentPage = 1
gettableList()
})
const gettableList = async () => {
loading.value = true
try {
const { currentPage, pageSize } = pagination
const params: any = {
current: currentPage,
size: pageSize
}
if (selectValue.value && selectValue.value.length) {
params.user_id = selectValue.value.join(',')
}
if (keyword.value) {
params.keyword = keyword.value
}
// 标签参数,所有选中的标签id组成数组
const selectedTagIds = Object.values(tagActiveMap).filter(Boolean)
if (selectedTagIds.length) {
params.tag_id = selectedTagIds
}
console.log(params)
const { data, current, size, total } = await manageService.getcrmCustomerList(params)
tableData.value = data.map((item: any) => ({
...item
}))
Object.assign(pagination, { currentPage: current, pageSize: size, total })
} catch (error) {
console.error('获取用户列表失败:', error)
} finally {
loading.value = false
}
}
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
gettableList()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.currentPage = newCurrentPage
gettableList()
}
function statusClass(status: any) {
if (status === 0) return 'status-following'
if (status === 1) return 'status-dealed'
if (status === 2) return 'status-lost'
return ''
}
//导出
const exportfn = () => {
const { currentPage, pageSize } = pagination
const params: any = {
current: currentPage,
size: pageSize,
user_id: selectValue.value.join(','),
tag_id: Object.values(tagActiveMap).filter(Boolean)
}
// manageService.crmCustomerExport(params)
try {
// 1. 调用接口,获取blob
// const res = await manageService.crmCustomerExport(params)
const urls =
import.meta.env.VITE_API_URL +
`/admin/crm/customer/export?current=${params.currentPage}&size=${params.pageSize}&user_id=${params.user_id}&tag_id=${params.tag_id}`
// 2. 创建blob对象
// const blob = new Blob([res], { type: 'application/vnd.ms-excel' }) // 或根据后端实际类型调整
// 3. 创建下载链接
const url = urls
const link = document.createElement('a')
link.href = url
link.download = '客户数据.xlsx' // 可自定义文件名
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
} catch (e) {
ElMessage.error('导出失败')
}
}
// 标签选中状态,key为组id,值为选中的标签id
function handleTagClick(groupId: string, tagId: string) {
// if (tagActiveMap[groupId] === tagId) {
// tagActiveMap[groupId] = '' // 取消选中
// } else {
tagActiveMap[groupId] = tagId // 选中
// }
pagination.currentPage = 1
gettableList()
}
function processImages(html: string) {
const parser = new DOMParser()
const doc = parser.parseFromString(html, 'text/html')
doc.querySelectorAll('img').forEach((img) => {
img.style.maxWidth = '100%'
img.style.height = 'auto'
})
return doc.body.innerHTML
}
function onDetail(row: any) {
row.desc_collect = processImages(row.desc_collect) // 处理图片
detailRow.value = row
detailDialogVisible.value = true
}
const gotransfer = (row: any) => {
zyForm.customer_id = row.id
zyDialogVisible.value = true
}
// 跟进记录
const gofollowdetail = async (row: any) => {
currentCustomer.value = row
followRecordDialogVisible.value = true
await manageService.crmCustomerFollow({ id: row.id }).then((res) => {
followRecords.value = res
})
}
// 图片预览相关函数
const handleImageClick = (url: string) => {
imageList.value = [url]
imageViewerIndex.value = 0
imageViewerVisible.value = true
}
const closeImageViewer = () => {
imageViewerVisible.value = false
imageList.value = []
imageViewerIndex.value = 0
}
// 转移
const handlezySubmit = async () => {
const res = await manageService.crmCustomerTransfer({
customer_id: zyForm.customer_id,
user_id: zyForm.user_id
})
ElMessage.success(res?.message || '转移成功!')
zyDialogVisible.value = false
gettableList()
}
// 选中的行数据
const selectedRows = ref<any[]>([])
// 处理表格行选择变化
const handleSelectionChange = (selection: any[]) => {
selectedRows.value = selection
}
// 批量转移
const handleplzySubmit = async () => {
if (selectedRows.value.length === 0) {
ElMessage.warning('请至少选择一条记录')
return
}
plzyForm.customer_id = selectedRows.value.map((item) => item.id).join(',')
const res = await manageService.crmCustomerTransfer(plzyForm)
ElMessage.success(res?.message || '批量转移成功!')
plzyDialogVisible.value = false
gettableList()
}
</script>
<style scoped lang="scss">
.timeline-img {
width: 180px;
cursor: pointer;
transition: transform 0.2s ease;
&:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
}
:deep(.el-badge) {
--el-badge-size: 16px;
}
.itemtag {
margin-top: -16px;
margin-left: -13px;
}
:deep(.el-table__header th) {
background: #f7f9fa !important;
color: #333;
font-weight: bold;
}
:deep(.el-table__empty-block) {
height: 450px !important;
}
:deep(.el-table__body tr.custom-table-row td) {
border-bottom: 1px solid #f7f9fa !important;
border-right: 1px solid #f7f9fa !important;
}
:deep(.el-table__body tr td:last-child) {
border-right: none !important;
}
:deep(.el-table__body tr:last-child td) {
border-bottom: none !important;
}
:deep(.el-table__header-wrapper th) {
border-bottom: 1px solid #f7f9fa !important;
border-right: 1px solid #f7f9fa !important;
}
:deep(.el-table__header-wrapper th:last-child) {
border-right: none !important;
}
:deep(.el-table) {
border: 1px solid #f7f9fa !important;
border-radius: 6px;
}
.customer-page {
background: var(--art-main-bg-color);
min-height: 100vh;
}
.customer-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding: 20px 20px;
background: var(--art-main-bg-color);
}
.customer-search-group {
display: flex;
gap: 12px;
}
.customer-tags {
background: var(--art-main-bg-color);
border-radius: 8px;
padding: 16px 20px;
margin-bottom: 16px;
}
.filter-row {
display: flex;
align-items: center;
margin-bottom: 14px;
}
.filter-label {
width: 120px;
color: #666;
margin-right: 8px;
flex-shrink: 0;
font-size: 14px;
}
.filter-tags {
flex: 1;
color: #333;
font-weight: 500;
font-size: 14px;
}
.customer-tabs {
margin-bottom: 8px;
}
.status-following {
color: #00a43f;
font-weight: bold;
}
.status-dealed {
color: #3471ff;
font-weight: bold;
}
.status-lost {
color: #ff0000;
font-weight: bold;
}
.customer-table {
background: var(--art-main-bg-color);
border-radius: 8px;
margin-top: 0;
}
.filter-more-btn {
text-align: center;
margin-top: 8px;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.tag-items {
cursor: pointer;
border-radius: 4px;
padding: 5px 10px;
transition: background 0.2s;
min-width: 100px;
display: inline-block;
margin-right: 30px;
}
.tag-item {
border-radius: 4px;
padding: 5px 10px;
}
.tag-itemall {
border-radius: 4px;
padding: 5px 10px;
margin-right: 20px;
}
.tag-active {
background: #3471ff;
color: #fff;
}
.tag-active :deep(.el-badge__content) {
color: #3471ff !important; /* 文字颜色 */
background-color: #fff !important; /* 背景色 */
}
.detail-info-table {
padding-left: 15px;
.row {
display: flex;
justify-content: flex-start;
margin-bottom: 6px;
> div {
flex: 1;
min-width: 0;
margin-right: 16px;
overflow: hidden;
font-size: 14px;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.dtitle {
line-height: 40px;
color: #333;
font-size: 16px;
font-weight: bold;
padding-left: 15px;
}
.detils-content {
padding-left: 15px;
img {
max-width: 100% !important;
}
}
.follow-record-header {
padding: 15px;
background-color: #f5f7fa;
border-radius: 4px;
margin-bottom: 15px;
.customer-info {
display: flex;
align-items: center;
.label {
font-size: 14px;
color: #606266;
margin-right: 5px;
}
.value {
font-size: 14px;
color: #303133;
font-weight: bold;
}
}
}
.follow-record-list {
padding: 15px;
max-height: 500px;
overflow-y: auto;
.list-title {
font-size: 16px;
font-weight: bold;
color: #303133;
margin-bottom: 15px;
padding-left: 10px;
}
.record-item {
background-color: #f5f7fa;
border-radius: 4px;
padding: 10px 15px;
margin-bottom: 10px;
.record-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 5px;
.record-type {
font-size: 14px;
color: #3471ff;
font-weight: bold;
background-color: #e6f7ff;
padding: 2px 8px;
border-radius: 4px;
}
.record-time {
font-size: 14px;
color: #909399;
}
.record-user {
font-size: 14px;
color: #3471ff;
font-weight: bold;
}
}
.record-content {
font-size: 14px;
color: #303133;
line-height: 1.6;
margin-bottom: 5px;
}
.record-footer {
.next-follow {
font-size: 14px;
color: #909399;
}
}
}
.no-records {
text-align: center;
color: #909399;
padding: 20px 0;
}
}
</style>