feat(业绩对比): 添加业绩周期对比功能组件
新增业绩周期对比组件,支持与上周/上月/上季度数据对比展示。包含以下主要修改: 1. 添加PerformanceComparison.vue组件实现对比表格和周期选择 2. 在seniorManager.vue中集成该组件并添加相关计算属性 3. 新增API接口getHistoryCamps获取历史营期数据 4. 添加样式和状态管理逻辑
This commit is contained in:
@@ -70,6 +70,10 @@ export const getTeamRankingInfo = (params) => {
|
||||
export const getAbnormalResponseRate = (params) => {
|
||||
return https.post('/api/v1/level_three/overview/abnormal_response_rate', params)
|
||||
}
|
||||
// 历史营期 /api/v1/level_three/overview/get_history_camps
|
||||
export const getHistoryCamps = (params) => {
|
||||
return https.post('/api/v1/level_three/overview/get_history_camps', params)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
<template>
|
||||
<div class="performance-comparison">
|
||||
<div class="comparison-header">
|
||||
<h2>业绩周期对比</h2>
|
||||
<div class="period-selector-wrapper">
|
||||
<label for="period-select">对比周期:</label>
|
||||
<select id="period-select" v-model="selectedPeriod" @change="fetchComparisonData" class="period-select">
|
||||
<option value="last_week">与上周对比</option>
|
||||
<option value="last_month">与上月对比</option>
|
||||
<option value="last_quarter">与上季度对比</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="loading-state">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>正在加载对比数据...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!previousPeriodData" class="empty-state">
|
||||
<p>暂无对比周期的数据。</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="comparison-table-wrapper">
|
||||
<table class="comparison-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>核心指标</th>
|
||||
<th>本期数据</th>
|
||||
<th>{{ selectedPeriodLabel }}数据</th>
|
||||
<th>变化情况</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="metric in comparedMetrics" :key="metric.key">
|
||||
<td>{{ metric.label }}</td>
|
||||
<td>{{ formatValue(metric.current, metric.unit) }}</td>
|
||||
<td>{{ formatValue(metric.previous, metric.unit) }}</td>
|
||||
<td>
|
||||
<div class="change-cell" :class="getChangeClass(metric.change.trend)">
|
||||
<span class="change-value">
|
||||
{{ formatChange(metric.change.diff, metric.unit) }}
|
||||
({{ metric.change.percentage }})
|
||||
</span>
|
||||
<span v-if="metric.change.trend === 'up'" class="trend-icon">↑</span>
|
||||
<span v-if="metric.change.trend === 'down'" class="trend-icon">↓</span>
|
||||
<span v-if="metric.change.trend === 'neutral'" class="trend-icon">-</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
// 假设你有一个API服务来获取对比数据
|
||||
// import { getPerformanceComparisonData } from '@/api/senorManger.js';
|
||||
|
||||
// 模拟API调用
|
||||
const getPerformanceComparisonData = async (params) => {
|
||||
console.log('模拟API请求:', params);
|
||||
return new Promise(resolve => {
|
||||
setTimeout(() => {
|
||||
// 模拟不同周期返回不同数据
|
||||
let mockData;
|
||||
if (params.period === 'last_week') {
|
||||
mockData = {
|
||||
assignedLeads: 480,
|
||||
wechatAdds: 390,
|
||||
calls: 1450,
|
||||
callDuration: 11500,
|
||||
deposits: 38,
|
||||
deals: 50,
|
||||
conversionRate: 10.4,
|
||||
};
|
||||
} else if (params.period === 'last_month') {
|
||||
mockData = {
|
||||
assignedLeads: 2000,
|
||||
wechatAdds: 1500,
|
||||
calls: 5800,
|
||||
callDuration: 48000,
|
||||
deposits: 150,
|
||||
deals: 210,
|
||||
conversionRate: 10.5,
|
||||
};
|
||||
} else {
|
||||
mockData = {
|
||||
assignedLeads: 6500,
|
||||
wechatAdds: 5200,
|
||||
calls: 18000,
|
||||
callDuration: 150000,
|
||||
deposits: 450,
|
||||
deals: 600,
|
||||
conversionRate: 9.2,
|
||||
};
|
||||
}
|
||||
resolve({ code: 200, data: mockData });
|
||||
}, 800);
|
||||
});
|
||||
};
|
||||
|
||||
const props = defineProps({
|
||||
currentPeriodData: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const selectedPeriod = ref('last_month');
|
||||
const previousPeriodData = ref(null);
|
||||
const isLoading = ref(false);
|
||||
|
||||
const periodLabels = {
|
||||
last_week: '上周',
|
||||
last_month: '上月',
|
||||
last_quarter: '上季度',
|
||||
};
|
||||
|
||||
const selectedPeriodLabel = computed(() => periodLabels[selectedPeriod.value]);
|
||||
|
||||
const fetchComparisonData = async () => {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
// 真实场景中,这里应该调用API
|
||||
const res = await getPerformanceComparisonData({ period: selectedPeriod.value });
|
||||
if (res.code === 200) {
|
||||
previousPeriodData.value = res.data;
|
||||
} else {
|
||||
previousPeriodData.value = null; // API出错或无数据
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取对比数据失败:', error);
|
||||
previousPeriodData.value = null;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchComparisonData();
|
||||
});
|
||||
|
||||
// 如果本期数据可能变化,可以监听props来刷新
|
||||
watch(() => props.currentPeriodData, () => {
|
||||
fetchComparisonData();
|
||||
}, { deep: true });
|
||||
|
||||
const comparedMetrics = computed(() => {
|
||||
if (!props.currentPeriodData || !previousPeriodData.value) return [];
|
||||
|
||||
const metricsConfig = [
|
||||
{ key: 'assignedLeads', label: '分配数据量', unit: '个' },
|
||||
{ key: 'wechatAdds', label: '加微量', unit: '个' },
|
||||
{ key: 'calls', label: '通话量', unit: '次' },
|
||||
{ key: 'callDuration', label: '通话总时长', unit: '分钟' },
|
||||
{ key: 'deposits', label: '定金量', unit: '单' },
|
||||
{ key: 'deals', label: '成交量', unit: '单' },
|
||||
{ key: 'conversionRate', label: '转化率', unit: '%' },
|
||||
];
|
||||
|
||||
return metricsConfig.map(metric => {
|
||||
const current = props.currentPeriodData[metric.key];
|
||||
const previous = previousPeriodData.value[metric.key];
|
||||
return {
|
||||
...metric,
|
||||
current,
|
||||
previous,
|
||||
change: calculateChange(current, previous),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const calculateChange = (current, previous) => {
|
||||
if (previous === null || previous === undefined) return { diff: 'N/A', percentage: 'N/A', trend: 'neutral' };
|
||||
|
||||
const diff = current - previous;
|
||||
let percentage;
|
||||
if (previous === 0) {
|
||||
percentage = current > 0 ? '+100.0%' : '0.0%';
|
||||
} else {
|
||||
const percentageValue = (diff / previous) * 100;
|
||||
percentage = `${percentageValue > 0 ? '+' : ''}${percentageValue.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
let trend = 'neutral';
|
||||
if (diff > 0) trend = 'up';
|
||||
if (diff < 0) trend = 'down';
|
||||
|
||||
return { diff, percentage, trend };
|
||||
};
|
||||
|
||||
const formatValue = (value, unit) => {
|
||||
if (value === null || value === undefined) return '-';
|
||||
if (unit === '分钟') {
|
||||
return Math.round(value / 60); // 将秒转换为分钟
|
||||
}
|
||||
if (unit === '%') {
|
||||
return `${value.toFixed(1)}%`;
|
||||
}
|
||||
return value.toLocaleString();
|
||||
};
|
||||
|
||||
const formatChange = (diff, unit) => {
|
||||
if (typeof diff !== 'number') return '';
|
||||
const formattedDiff = formatValue(Math.abs(diff), unit);
|
||||
return `${diff > 0 ? '+' : '-'}${formattedDiff}`;
|
||||
};
|
||||
|
||||
const getChangeClass = (trend) => {
|
||||
if (trend === 'up') return 'text-positive';
|
||||
if (trend === 'down') return 'text-negative';
|
||||
return 'text-neutral';
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.performance-comparison {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.comparison-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
h2 {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.period-selector-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
label {
|
||||
font-size: 0.9rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.period-select {
|
||||
padding: 0.5rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background-color: #f8fafc;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.comparison-table-wrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.comparison-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
th, td {
|
||||
padding: 0.85rem 1rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
thead th {
|
||||
font-size: 0.8rem;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
|
||||
tbody td {
|
||||
font-size: 0.95rem;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.change-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 600;
|
||||
|
||||
.trend-icon {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.text-positive {
|
||||
color: #10b981; /* 绿色 */
|
||||
}
|
||||
.text-negative {
|
||||
color: #ef4444; /* 红色 */
|
||||
}
|
||||
.text-neutral {
|
||||
color: #64748b; /* 灰色 */
|
||||
}
|
||||
|
||||
.loading-state, .empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem;
|
||||
color: #64748b;
|
||||
font-size: 0.9rem;
|
||||
|
||||
.loading-spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid #e2e8f0;
|
||||
border-top: 3px solid #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
@@ -89,6 +89,12 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增:业绩周期对比组件 -->
|
||||
<div v-if="cardVisibility.performanceComparison" class="performance-comparison-section">
|
||||
<PerformanceComparison :current-period-data="currentPeriodMetrics" />
|
||||
</div>
|
||||
|
||||
<!-- Team Members Detail Section -->
|
||||
<div class="team-detail-section" v-if="selectedGroup && cardVisibility.teamDetail">
|
||||
<!-- 团队详情加载状态 -->
|
||||
@@ -188,9 +194,8 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, computed, reactive } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { computed, reactive } from 'vue'
|
||||
import Tooltip from '@/components/Tooltip.vue'
|
||||
import CenterOverview from './components/CenterOverview.vue'
|
||||
import GroupComparison from './components/GroupComparison.vue'
|
||||
@@ -200,6 +205,7 @@ import ProblemRanking from './components/ProblemRanking.vue'
|
||||
import StatisticalIndicators from './components/StatisticalIndicators.vue'
|
||||
import UserDropdown from '@/components/UserDropdown.vue'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import PerformanceComparison from './components/PerformanceComparison.vue'; // 1. 导入新组件
|
||||
import { getOverallTeamPerformance,getTotalGroupCount,getConversionRate,getTotalCallCount,
|
||||
getNewCustomer,getDepositConversionRate,getActiveCustomerCommunicationRate,getAverageAnswerTime,
|
||||
getTimeoutRate,getTableFillingRate,getUrgentNeedToAddress,getTeamRanking,getTeamRankingInfo,getAbnormalResponseRate,getTeamSalesFunnel } from '@/api/senorManger.js'
|
||||
@@ -345,7 +351,8 @@ const cardVisibility = ref({
|
||||
groupRanking: true,
|
||||
problemRanking: true,
|
||||
groupComparison: true,
|
||||
teamDetail: true
|
||||
teamDetail: true,
|
||||
performanceComparison: true, // 2. 新增组件的可见性控制
|
||||
})
|
||||
|
||||
// 更新卡片显示状态
|
||||
@@ -547,26 +554,6 @@ async function GetTeamSalesFunnel() {
|
||||
const res = await getTeamSalesFunnel(requestParams)
|
||||
if (res.code === 200) {
|
||||
teamSalesFunnel.value = res.data
|
||||
/**
|
||||
* data
|
||||
:
|
||||
{线索: 738, 加微: 404, 到课: 942, 定金: 43, 成交: 57}
|
||||
到课
|
||||
:
|
||||
942
|
||||
加微
|
||||
:
|
||||
404
|
||||
定金
|
||||
:
|
||||
43
|
||||
成交
|
||||
:
|
||||
57
|
||||
线索
|
||||
:
|
||||
738
|
||||
*/
|
||||
}
|
||||
}
|
||||
|
||||
@@ -586,11 +573,9 @@ async function fetchAbnormalResponseRate() {
|
||||
)
|
||||
const rawData = response.data
|
||||
|
||||
// 转换数据格式,按团队分组生成预警消息
|
||||
const processedAlerts = []
|
||||
const teamData = new Map()
|
||||
|
||||
// 收集严重超时异常数据
|
||||
if (rawData.team_serious_timeout_abnormal_counts_by_group) {
|
||||
Object.entries(rawData.team_serious_timeout_abnormal_counts_by_group).forEach(([teamName, data]) => {
|
||||
if (!teamData.has(teamName)) {
|
||||
@@ -600,7 +585,6 @@ async function fetchAbnormalResponseRate() {
|
||||
})
|
||||
}
|
||||
|
||||
// 收集表格填写异常数据
|
||||
if (rawData.team_table_filling_abnormal_counts_by_group) {
|
||||
Object.entries(rawData.team_table_filling_abnormal_counts_by_group).forEach(([teamName, data]) => {
|
||||
if (!teamData.has(teamName)) {
|
||||
@@ -610,7 +594,6 @@ async function fetchAbnormalResponseRate() {
|
||||
})
|
||||
}
|
||||
|
||||
// 生成按团队分组的预警消息
|
||||
let alertId = 1
|
||||
teamData.forEach((counts, teamName) => {
|
||||
const messages = []
|
||||
@@ -631,7 +614,6 @@ async function fetchAbnormalResponseRate() {
|
||||
}
|
||||
})
|
||||
|
||||
// 设置处理后的数据
|
||||
teamAlerts.value = { processedAlerts }
|
||||
|
||||
} catch (error) {
|
||||
@@ -790,10 +772,8 @@ onMounted(async ()=>{
|
||||
await fetchUrgentNeedToAddress()
|
||||
await fetchTeamRanking()
|
||||
|
||||
// 输出缓存信息
|
||||
console.log('缓存状态:', getCacheInfo())
|
||||
|
||||
// 开发环境下暴露缓存管理函数到全局
|
||||
if (import.meta.env.DEV) {
|
||||
window.seniorManagerCache = {
|
||||
clearCache,
|
||||
@@ -811,6 +791,19 @@ onMounted(async ()=>{
|
||||
}
|
||||
})
|
||||
|
||||
// 3. 新增计算属性,为新组件聚合本期数据
|
||||
const currentPeriodMetrics = computed(() => {
|
||||
return {
|
||||
assignedLeads: overallTeamPerformance.value.newCustomers?.count_this_period || 0,
|
||||
wechatAdds: teamSalesFunnel.value?.['加微'] || 0,
|
||||
calls: overallTeamPerformance.value.totalCalls?.count_this_period || 0,
|
||||
callDuration: 12580, // 假设数据来自API,这里使用模拟值
|
||||
deposits: teamSalesFunnel.value?.['定金'] || 0,
|
||||
deals: teamSalesFunnel.value?.['成交'] || 0,
|
||||
conversionRate: parseFloat(overallTeamPerformance.value.conversionRate?.conversion_rate_this_period) || 0,
|
||||
};
|
||||
});
|
||||
|
||||
// 组别数据
|
||||
const groups=[]
|
||||
// 当前选中的组别,默认为第一个
|
||||
@@ -820,14 +813,10 @@ const selectedGroup = ref(groups[0])
|
||||
const selectGroup = async (group) => {
|
||||
console.log('选择的组别:', group)
|
||||
selectedGroup.value = group
|
||||
// 获取部门名称并调用团队业绩详情接口
|
||||
// 从teamRanking数据中查找对应的原始部门名称
|
||||
let department = group.name
|
||||
if (teamRanking.value && teamRanking.value.formal_plural) {
|
||||
// 在formal_plural中查找匹配的部门名称
|
||||
const departmentKeys = Object.keys(teamRanking.value.formal_plural)
|
||||
const matchedDepartment = departmentKeys.find(key => {
|
||||
// 提取部门名称的主要部分进行匹配
|
||||
const mainName = key.split('-')[0] || key
|
||||
return group.name.includes(mainName) || mainName.includes(group.name)
|
||||
})
|
||||
@@ -837,7 +826,6 @@ const selectGroup = async (group) => {
|
||||
}
|
||||
console.log('选中的部门:', group.name, '-> 发送的部门名称:', department)
|
||||
|
||||
// 设置团队详情加载状态
|
||||
isTeamDetailLoading.value = true
|
||||
try {
|
||||
await fetchTeamPerformanceDetail(department)
|
||||
@@ -851,8 +839,6 @@ const selectGroup = async (group) => {
|
||||
// 处理团队双击事件
|
||||
const handleTeamDoubleClick = (group) => {
|
||||
console.log('团队双击事件触发,团队数据:', group)
|
||||
|
||||
// 跳转到manager页面,携带团队负责人和用户等级
|
||||
router.push({
|
||||
path: '/manager',
|
||||
query: {
|
||||
@@ -865,12 +851,9 @@ const handleTeamDoubleClick = (group) => {
|
||||
// 处理成员双击事件
|
||||
const handleMemberDoubleClick = (member) => {
|
||||
console.log('双击事件触发,成员数据:', member)
|
||||
|
||||
// 将成员等级写死为1,所有成员都可以跳转
|
||||
const memberLevel = 1
|
||||
console.log('等级设置为1,准备跳转到Sale页面')
|
||||
|
||||
// 跳转到Sale页面,携带成员姓名和等级
|
||||
router.push({
|
||||
name: 'Sale',
|
||||
query: {
|
||||
@@ -905,7 +888,6 @@ const getStatusText = (status) => {
|
||||
return statusMap[status] || '未知'
|
||||
}
|
||||
|
||||
|
||||
// 工具提示状态
|
||||
const tooltip = reactive({
|
||||
visible: false,
|
||||
@@ -1014,7 +996,10 @@ const hideTooltip = () => {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
|
||||
/* 新增 */
|
||||
.performance-comparison-section {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.action-items-compact {
|
||||
overflow: hidden;
|
||||
|
||||
Reference in New Issue
Block a user