feat(业绩对比): 添加业绩周期对比功能组件

新增业绩周期对比组件,支持与上周/上月/上季度数据对比展示。包含以下主要修改:
1. 添加PerformanceComparison.vue组件实现对比表格和周期选择
2. 在seniorManager.vue中集成该组件并添加相关计算属性
3. 新增API接口getHistoryCamps获取历史营期数据
4. 添加样式和状态管理逻辑
This commit is contained in:
2025-10-14 16:58:52 +08:00
parent 822afb422c
commit 3a529bafa8
3 changed files with 371 additions and 42 deletions

View File

@@ -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)
}

View File

@@ -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>

View File

@@ -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;