feat(销售管理): 优化团队成员详情展示和录音下载功能

- 在团队成员详情组件中添加memberDetails属性,展示更详细的数据统计
- 改进录音下载功能,处理HTTPS页面下载HTTP资源的情况并优化文件名获取
- 新增下载专用弹窗组件,防止与普通弹窗冲突
- 修复销售时间线中"点击未支付"阶段的显示文本
- 增强模态框的滚动控制和样式一致性
This commit is contained in:
2025-09-17 10:56:11 +08:00
parent 3033326def
commit 4885674f23
5 changed files with 185 additions and 72 deletions

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="member-details"> <div class="member-details">
<div class="details-header" @click="toggleDetailsCollapse"> <div class="details-header" @click="toggleDetailsCollapse">
<h2>{{ selectedMember?.user_name || selectedMember?.name||'张三' }} 的详细数据</h2> <h2>{{ memberDetails?.user_name || selectedMember?.user_name || selectedMember?.name||'张三' }} 的详细数据</h2>
<div class="collapse-toggle" :class="{ 'collapsed': isDetailsCollapsed }"> <div class="collapse-toggle" :class="{ 'collapsed': isDetailsCollapsed }">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"> <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 4l4 4H4l4-4z"/> <path d="M8 4l4 4H4l4-4z"/>
@@ -15,28 +15,28 @@
总通话次数 总通话次数
<span class="info-icon" @mouseenter="showTooltip($event, 'totalCalls')" @mouseleave="hideTooltip"></span> <span class="info-icon" @mouseenter="showTooltip($event, 'totalCalls')" @mouseleave="hideTooltip"></span>
</div> </div>
<div class="detail-value">{{ selectedMember?.calls || 0 }} </div> <div class="detail-value">{{ memberDetails?.call_count || 0 }} </div>
</div> </div>
<div class="detail-card"> <div class="detail-card">
<div class="detail-label"> <div class="detail-label">
通话时长 通话时长
<span class="info-icon" @mouseenter="showTooltip($event, 'callTime')" @mouseleave="hideTooltip"></span> <span class="info-icon" @mouseenter="showTooltip($event, 'callTime')" @mouseleave="hideTooltip"></span>
</div> </div>
<div class="detail-value">{{ selectedMember?.callTime || 0 }} 小时</div> <div class="detail-value">{{ memberDetails?.total_call_duration_hour || 0 }} 小时</div>
</div> </div>
<div class="detail-card"> <div class="detail-card">
<div class="detail-label"> <div class="detail-label">
新增客户 新增客户
<span class="info-icon" @mouseenter="showTooltip($event, 'newClients')" @mouseleave="hideTooltip"></span> <span class="info-icon" @mouseenter="showTooltip($event, 'newClients')" @mouseleave="hideTooltip"></span>
</div> </div>
<div class="detail-value">{{ selectedMember?.newClients || 0 }} </div> <div class="detail-value">{{ memberDetails?.add_customer_count || 0 }} </div>
</div> </div>
<div class="detail-card"> <div class="detail-card">
<div class="detail-label"> <div class="detail-label">
成交单数 成交单数
<span class="info-icon" @mouseenter="showTooltip($event, 'deals')" @mouseleave="hideTooltip"></span> <span class="info-icon" @mouseenter="showTooltip($event, 'deals')" @mouseleave="hideTooltip"></span>
</div> </div>
<div class="detail-value">{{ selectedMember?.deals || 0 }} </div> <div class="detail-value">{{ memberDetails?.month_order_count || 0 }} </div>
</div> </div>
<div class="detail-card"> <div class="detail-card">
<div class="detail-label"> <div class="detail-label">
@@ -59,8 +59,8 @@
</div> </div>
</div> </div>
<div class="guidance-cards" v-show="!isGuidanceCollapsed" :class="{ 'collapsing': isGuidanceCollapsed }"> <div class="guidance-cards" v-show="!isGuidanceCollapsed" :class="{ 'collapsing': isGuidanceCollapsed }">
<div class="guidance-card" v-if="getGuidanceForMember(selectedMember).length > 0"> <div class="guidance-card" v-if="getGuidanceForMember(memberDetails).length > 0">
<div class="guidance-item" v-for="(guidance, index) in getGuidanceForMember(selectedMember)" :key="index"> <div class="guidance-item" v-for="(guidance, index) in getGuidanceForMember(memberDetails)" :key="index">
<div class="guidance-icon" :class="guidance.type"> <div class="guidance-icon" :class="guidance.type">
{{ guidance.icon }} {{ guidance.icon }}
</div> </div>
@@ -77,7 +77,7 @@
<div class="no-guidance" v-else> <div class="no-guidance" v-else>
<div class="celebration-icon">🎉</div> <div class="celebration-icon">🎉</div>
<h4>表现优秀</h4> <h4>表现优秀</h4>
<p>{{ selectedMember?.user_name || selectedMember?.name }} 的各项指标都很不错继续保持这种状态</p> <p>{{ memberDetails?.user_name || selectedMember?.user_name || selectedMember?.name }} 的各项指标都很不错继续保持这种状态</p>
</div> </div>
</div> </div>
</div> </div>
@@ -140,6 +140,10 @@ const props = defineProps({
selectedMember: { selectedMember: {
type: Object, type: Object,
required: true required: true
},
memberDetails: {
type: Object,
required: true
} }
}) })
@@ -231,7 +235,7 @@ const getRecordingsForMember = (member) => {
const recordings = [] const recordings = []
// 根据成员ID返回对应的录音这里简化处理 // 根据成员ID返回对应的录音这里简化处理
return recordings.slice(0, Math.min(3, member?.calls || 0)) return recordings.slice(0, Math.min(3, member?.call_count || 0))
} }
// 下载录音文件 // 下载录音文件
@@ -257,7 +261,7 @@ const getGuidanceForMember = (member) => {
} }
// 业绩相关建议 // 业绩相关建议
if (member.performance === 0) { if (member.month_order_count === 0) {
guidance.push({ guidance.push({
type: 'urgent', type: 'urgent',
icon: '🚨', icon: '🚨',
@@ -265,7 +269,7 @@ const getGuidanceForMember = (member) => {
description: '当前还未有成交记录,需要重点关注转化技巧和客户跟进。', description: '当前还未有成交记录,需要重点关注转化技巧和客户跟进。',
action: '建议参加销售技巧培训,加强客户需求挖掘' action: '建议参加销售技巧培训,加强客户需求挖掘'
}) })
} else if (member.performance < 80000) { } else if (member.month_order_count < 5) {
guidance.push({ guidance.push({
type: 'warning', type: 'warning',
icon: '📈', icon: '📈',
@@ -295,7 +299,7 @@ const getGuidanceForMember = (member) => {
} }
// 通话相关建议 // 通话相关建议
if (member.calls < 100) { if (member.call_count < 100) {
guidance.push({ guidance.push({
type: 'warning', type: 'warning',
icon: '📞', icon: '📞',
@@ -306,7 +310,7 @@ const getGuidanceForMember = (member) => {
} }
// 客户开发建议 // 客户开发建议
if (member.newClients < 5) { if (member.add_customer_count < 5) {
guidance.push({ guidance.push({
type: 'info', type: 'info',
icon: '👥', icon: '👥',

View File

@@ -60,7 +60,7 @@
<!-- Right Section --> <!-- Right Section -->
<div class="right-section"> <div class="right-section">
<!-- Member Details --> <!-- Member Details -->
<MemberDetails :selected-member="selectedMember" /> <MemberDetails :selected-member="selectedMember" :memberDetails="memberDetails" />
</div> </div>
</div> </div>
</main> </main>
@@ -81,7 +81,7 @@ import CustomerDetail from "../person/components/CustomerDetail.vue";
import { useUserStore } from "@/stores/user"; import { useUserStore } from "@/stores/user";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import {getGroupAbnormalResponse, getWeekTotalCall, getWeekAddCustomerTotal, getWeekAddDealTotal, import {getGroupAbnormalResponse, getWeekTotalCall, getWeekAddCustomerTotal, getWeekAddDealTotal,
getWeekAddFeeTotal, getGroupFunnel,getPayDepositToMoneyRate,getGroupRanking, getGroupCallDuration} from "@/api/manager.js"; getWeekAddFeeTotal, getGroupFunnel,getPayDepositToMoneyRate,getGroupRanking, getGroupCallDuration,getGroupDetail} from "@/api/manager.js";
// 团队成员数据 // 团队成员数据
const teamMembers = [ const teamMembers = [
@@ -292,8 +292,31 @@ const selectedMember = ref(null);
// 选择成员函数 // 选择成员函数
const selectMember = (member) => { const selectMember = (member) => {
selectedMember.value = member; selectedMember.value = member;
console.log(122331,member)
TeamGetGroupDetail(member.user_name)
}; };
// 成员详细数据
async function TeamGetGroupDetail(member) {
const res = await getGroupDetail({user_name:member})
console.log(res)
if (res.code === 200) {
memberDetails.value = res.data
/**
* add_customer_count:32
call_count:96
month_order_count:5
total_call_duration_hour
:
1.92
user_name
:
"李晓雪"
week_order_count
:
2
*/
}
}
// 团队异常预警 // 团队异常预警

View File

@@ -258,55 +258,94 @@ const downloadRecording = async (call) => {
// 检查是否有录音文件地址 // 检查是否有录音文件地址
if (call.record_file_addr) { if (call.record_file_addr) {
try {
// 显示下载开始提示
emit('show-modal', '下载提示', '正在下载录音文件,请稍候...')
const recordingUrl = call.record_file_addr const recordingUrl = call.record_file_addr
// 从URL中提取文件名 try {
const urlParts = recordingUrl.split('/') // 显示下载开始提示
const fileName = urlParts[urlParts.length - 1] emit('show-download-modal', '下载提示', '正在下载录音文件,请稍候...')
// 使用fetch获取文件 // 若为 HTTPS 页面请求 HTTP 资源,浏览器会拦截,回退为在新标签页打开
if (window.location.protocol === 'https:' && recordingUrl.startsWith('http://')) {
const parts = recordingUrl.split('/')
const fallbackName = parts[parts.length - 1] || 'recording'
const link = document.createElement('a')
link.href = recordingUrl
link.target = '_blank'
link.rel = 'noopener'
link.download = fallbackName
link.style.display = 'none'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
emit('show-download-modal', '提示', '目标为不安全的HTTP资源已在新标签页打开下载链接。')
return
}
// 通过请求的方式下载
const response = await fetch(recordingUrl, { const response = await fetch(recordingUrl, {
method: 'GET', method: 'GET',
headers: { mode: 'cors',
'Content-Type': 'application/octet-stream', credentials: 'omit',
}, redirect: 'follow',
referrerPolicy: 'no-referrer'
}) })
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`) throw new Error(`下载失败,状态码: ${response.status}`)
}
// 从响应头或URL中提取文件名
let fileName = 'recording'
const disposition = response.headers.get('content-disposition')
if (disposition) {
const match = disposition.match(/filename\*=UTF-8''([^;]+)|filename="?([^";]+)"?/i)
if (match) fileName = decodeURIComponent(match[1] || match[2])
}
if (!fileName || fileName === 'recording') {
try {
const urlObj = new URL(recordingUrl, window.location.href)
const segments = urlObj.pathname.split('/')
fileName = segments[segments.length - 1] || 'recording'
} catch (e) {
const parts = recordingUrl.split('/')
fileName = parts[parts.length - 1] || 'recording'
}
} }
// 获取文件blob
const blob = await response.blob() const blob = await response.blob()
const objectUrl = URL.createObjectURL(blob)
// 创建下载链接 const a = document.createElement('a')
const url = window.URL.createObjectURL(blob) a.href = objectUrl
const link = document.createElement('a') a.download = fileName
link.href = url a.style.display = 'none'
link.download = fileName document.body.appendChild(a)
link.style.display = 'none' a.click()
document.body.removeChild(a)
// 触发下载 setTimeout(() => URL.revokeObjectURL(objectUrl), 1000)
document.body.appendChild(link)
link.click()
// 清理
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
// 下载成功提示 // 下载成功提示
emit('show-modal', '下载成功', '录音文件下载完成!') emit('show-download-modal', '下载成功', '录音文件下载完成!')
} catch (error) { } catch (error) {
console.error('下载录音文件失败:', error) console.error('下载录音文件失败:', error)
emit('show-modal', '下载失败', '下载录音文件失败,请检查网络连接或文件是否存在')
// 回退尝试在新标签页直接打开原始链接适用于CORS或其他限制
try {
const link = document.createElement('a')
link.href = recordingUrl
link.target = '_blank'
link.rel = 'noopener'
link.style.display = 'none'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
emit('show-download-modal', '提示', '无法直接下载,已在新标签页打开录音链接。')
} catch (e) {
emit('show-download-modal', '下载失败', '下载录音文件失败,请检查网络连接或文件是否存在')
}
} }
} else { } else {
emit('show-modal', '提示', '该通话记录暂无录音文件') emit('show-download-modal', '提示', '该通话记录暂无录音文件')
} }
} }

View File

@@ -79,7 +79,7 @@
<div class="mini-stage" :class="{ active: getCourseStageCount(2, '点击未支付') > 0 }" @click="selectCourseStage(2, '点击未支付')"> <div class="mini-stage" :class="{ active: getCourseStageCount(2, '点击未支付') > 0 }" @click="selectCourseStage(2, '点击未支付')">
<div class="mini-marker"></div> <div class="mini-marker"></div>
<div class="mini-content"> <div class="mini-content">
<span class="mini-title">点击</span> <span class="mini-title">点击</span>
<span class="mini-count">{{ getCourseStageCount(2, '点击未支付') }}</span> <span class="mini-count">{{ getCourseStageCount(2, '点击未支付') }}</span>
</div> </div>
</div> </div>

View File

@@ -77,7 +77,8 @@
@view-chat-data="handleViewChatData" @view-chat-data="handleViewChatData"
@view-call-data="handleViewCallData" @view-call-data="handleViewCallData"
@analyze-sop="handleAnalyzeSop" @analyze-sop="handleAnalyzeSop"
@show-modal="handleShowModal" /> @show-modal="handleShowModal"
@show-download-modal="handleShowDownloadModal" />
</div> </div>
</section> </section>
@@ -129,8 +130,8 @@
</section> </section>
<!-- 自定义弹框 --> <!-- 自定义弹框 -->
<div v-if="showModal" class="modal-overlay" @click="closeModal"> <div v-if="showModal" class="modal-overlay" @click="closeModal" @wheel.prevent @touchmove.prevent>
<div class="modal-container" @click.stop> <div class="modal-container" @click.stop @wheel.stop @touchmove.stop>
<div class="modal-header"> <div class="modal-header">
<h3 class="modal-title">{{ modalTitle }}</h3> <h3 class="modal-title">{{ modalTitle }}</h3>
<button class="modal-close-btn" @click="closeModal"> <button class="modal-close-btn" @click="closeModal">
@@ -147,6 +148,26 @@
</div> </div>
</div> </div>
</div> </div>
<!-- 下载弹框 -->
<div v-if="showDownloadModal" class="modal-overlay" @click="closeDownloadModal" @wheel.prevent @touchmove.prevent>
<div class="modal-container" @click.stop @wheel.stop @touchmove.stop>
<div class="modal-header">
<h3 class="modal-title">{{ downloadModalTitle }}</h3>
<button class="modal-close-btn" @click="closeDownloadModal">
<i class="icon-close">×</i>
</button>
</div>
<div class="modal-body">
<div class="modal-content">
{{ downloadModalContent }}
</div>
</div>
<div class="modal-footer">
<button class="modal-btn modal-btn-primary" @click="closeDownloadModal">确定</button>
</div>
</div>
</div>
</div> </div>
</template> </template>
@@ -273,6 +294,11 @@ const showModal = ref(false)
const modalContent = ref('') const modalContent = ref('')
const modalTitle = ref('') const modalTitle = ref('')
// 下载弹框状态
const showDownloadModal = ref(false)
const downloadModalContent = ref('')
const downloadModalTitle = ref('')
// 时间线数据 // 时间线数据
const timelineData = ref({}); const timelineData = ref({});
@@ -838,8 +864,9 @@ const handleViewCallData = (contact) => {
// 处理弹框显示事件 // 处理弹框显示事件
const handleShowModal = (title, content) => { const handleShowModal = (title, content) => {
modalTitle.value = title console.log('handleShowModal0000', title)
modalContent.value = content modalTitle.value = title.title
modalContent.value = title.content
showModal.value = true showModal.value = true
} }
@@ -850,6 +877,20 @@ const closeModal = () => {
modalTitle.value = '' modalTitle.value = ''
} }
// 处理下载弹框显示
const handleShowDownloadModal = (title, content) => {
downloadModalTitle.value = title
downloadModalContent.value = content
showDownloadModal.value = true
}
// 关闭下载弹框
const closeDownloadModal = () => {
showDownloadModal.value = false
downloadModalContent.value = ''
downloadModalTitle.value = ''
}
// // 处理SOP分析事件 // // 处理SOP分析事件
// const handleAnalyzeSop = (analyzeData) => { // const handleAnalyzeSop = (analyzeData) => {
// console.log('handleAnalyzeSop', analyzeData) // console.log('handleAnalyzeSop', analyzeData)
@@ -1767,6 +1808,7 @@ $primary: #3b82f6;
right: 0; right: 0;
bottom: 0; bottom: 0;
background: rgba(0, 0, 0, 0.5); background: rgba(0, 0, 0, 0.5);
// 使用 Flexbox 实现垂直和水平居中
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -1781,8 +1823,13 @@ $primary: #3b82f6;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
max-width: 600px; max-width: 600px;
width: 90%; width: 90%;
max-height: 80vh; // 设置最大高度,防止弹窗超出屏幕
max-height: 35vh;
// 防止内容溢出容器,配合内部滚动
overflow: hidden; overflow: hidden;
// 使用 Flexbox 布局,让 .modal-body 可以伸缩
display: flex;
flex-direction: column;
animation: slideIn 0.3s ease-out; animation: slideIn 0.3s ease-out;
} }
@@ -1792,7 +1839,6 @@ $primary: #3b82f6;
justify-content: space-between; justify-content: space-between;
padding: 20px 24px; padding: 20px 24px;
border-bottom: 1px solid #e5e7eb; border-bottom: 1px solid #e5e7eb;
background: #f9fafb;
} }
.modal-title { .modal-title {
@@ -1827,21 +1873,25 @@ $primary: #3b82f6;
} }
.modal-body { .modal-body {
padding: 5px; // 关键:让内容区域占据所有剩余空间
max-height: 60vh; flex: 1;
// 关键:当内容超出时,只在垂直方向显示滚动条
overflow-y: auto; overflow-y: auto;
// 防止滚动链传递到页面,仅在弹框内滚动
overscroll-behavior: contain;
// 为内容提供统一内边距
padding: 24px;
// 配合 flex: 1 使用,防止 flex item 在某些浏览器中无法正确收缩
min-height: 0;
} }
.modal-content { .modal-content {
font-size: 14px; font-size: 14px;
line-height: 1.6; line-height: 1.6;
color: #374151; color: #374151;
// 支持长文本和换行
white-space: pre-wrap; white-space: pre-wrap;
word-wrap: break-word; word-wrap: break-word;
padding: 16px;
margin-left: 30px;
border-radius: 8px;
// border: 1px solid #e5e7eb;
} }
.modal-footer { .modal-footer {
@@ -1850,7 +1900,7 @@ $primary: #3b82f6;
gap: 12px; gap: 12px;
padding: 16px 24px; padding: 16px 24px;
border-top: 1px solid #e5e7eb; border-top: 1px solid #e5e7eb;
background: #f9fafb; // flex-shrink: 0; // 确保 footer 不会被压缩
} }
.modal-btn { .modal-btn {
@@ -1911,17 +1961,14 @@ $primary: #3b82f6;
.modal-body { .modal-body {
padding: 20px; padding: 20px;
max-height: 55vh;
} }
.modal-content { .modal-content {
font-size: 13px; font-size: 13px;
padding: 14px;
} }
.modal-footer { .modal-footer {
padding: 12px 20px; padding: 12px 20px;
} }
} }
</style> </style>