Files
DJKB/my-vue-app/src/views/topOne/components/KpiMetrics.vue
lbw_9527443 d385d22cf5 refactor(views): 移除Loading组件并简化指标描述
更新API基础URL为192.168.15.53
调整漏斗图时间选择器默认值和选项顺序
优化KPI卡片显示,移除部分提示图标并简化描述文本
2025-08-23 21:01:06 +08:00

466 lines
12 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="overview-container">
<!-- 加载状态 -->
<div v-if="isLoading" class="state-container">
<div class="spinner"></div>
<p>正在加载数据...</p>
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="state-container">
<p class="error-text">数据加载失败{{ error }}</p>
<button @click="fetchData" class="retry-button">重试</button>
</div>
<!-- 成功状态显示数据网格 -->
<div v-else class="kpi-grid">
<!-- 1. 主卡片中心总业绩 -->
<div class="kpi-card primary">
<div class="card-header">
<span class="card-label">
总成交单数
</span>
<span class="card-trend" :class="getTrendClass(kpiData.totalSales.trend)">
{{ formatTrend(kpiData.totalSales.trend) }} vs 上期
</span>
</div>
<div class="card-body">
<span class="card-value">{{ formatNumber(kpiData.totalSales.value) }}</span>
<span class="card-unit">单数</span>
</div>
<div class="card-footer">
月目标完成率{{ kpiData.totalSales.targetCompletion }}%
</div>
</div>
<!-- 2. 定金转化率 -->
<div class="kpi-card">
<div class="card-header">
<span class="card-label">
定金转化率
<span class="info-icon"
@mouseenter="showTooltip($event, 'depositConversion')"
@mouseleave="hideTooltip">!</span>
</span>
<span class="card-trend" :class="getTrendClass(kpiData.activeTeams.trend)">
{{ formatTrend(kpiData.activeTeams.trend, true) }} vs 上期
</span>
</div>
<div class="card-body">
<span class="card-value">{{ kpiData.activeTeams.value }}</span>
<span class="card-unit">%</span>
</div>
<div class="card-footer">
上月转化率{{ kpiData.activeTeams.totalMembers }}
</div>
</div>
<!-- 3. 总通话次数 -->
<div class="kpi-card">
<div class="card-header">
<span class="card-label">
总通话
<span class="info-icon"
@mouseenter="showTooltip($event, 'totalCalls')"
@mouseleave="hideTooltip">!</span>
</span>
<span class="card-trend" :class="getTrendClass(kpiData.totalCalls.trend)">
{{ formatTrend(kpiData.totalCalls.trend) }} vs 上期
</span>
</div>
<div class="card-body">
<span class="card-value">{{ formatNumber(kpiData.totalCalls.value) }}</span>
<span class="card-unit"></span>
</div>
<div class="card-footer">
有效通话{{ formatNumber(kpiData.totalCalls.effectiveCalls) }}
</div>
</div>
<!-- 4. 新增客户 -->
<div class="kpi-card">
<div class="card-header">
<span class="card-label">
今日新增客户
</span>
<span class="card-trend" :class="getTrendClass(kpiData.newCustomers.trend)">
{{ formatTrend(kpiData.newCustomers.trend) }} vs 上期
</span>
</div>
<div class="card-body">
<span class="card-value">{{ formatNumber(kpiData.newCustomers.value) }}</span>
<span class="card-unit"></span>
</div>
<div class="card-footer">
意向客户{{ formatNumber(kpiData.newCustomers.interestedCustomers) }}
</div>
</div>
<!-- 5. 中心转化率 -->
<div class="kpi-card">
<div class="card-header">
<span class="card-label">
转化率
<span class="info-icon"
@mouseenter="showTooltip($event, 'conversionRate')"
@mouseleave="hideTooltip">!</span>
</span>
<span class="card-trend" :class="getTrendClass(kpiData.conversionRate.trend)">
{{ formatTrend(kpiData.conversionRate.trend, true) }} vs 上期
</span>
</div>
<div class="card-body">
<span class="card-value">{{ kpiData.conversionRate.value }}</span>
<span class="card-unit">%</span>
</div>
</div>
</div>
<!-- Tooltip组件 -->
<Tooltip
:visible="tooltip.visible"
:x="tooltip.x"
:y="tooltip.y"
:title="tooltip.title"
:description="tooltip.description"
/>
</div>
</template>
<script setup>
import { ref, computed, watch, reactive } from 'vue';
import Tooltip from '@/components/Tooltip.vue';
// 定义props
const props = defineProps({
kpiData: {
type: Object,
default: () => ({})
},
formatNumber: {
type: Function,
default: (num) => num?.toLocaleString() || '0'
}
});
const isLoading = ref(false);
const error = ref(null);
// Tooltip状态管理
const tooltip = reactive({
visible: false,
x: 0,
y: 0,
title: '',
description: ''
});
// 指标描述
const metricDescriptions = {
depositConversion: {
title: '定金转化率计算方式',
description: '定金转化率 = (支付定金客户数 / 意向客户总数) × 100%'
},
totalCalls: {
title: '总通话次数计算方式',
description: '有效通话为接通电话次数,总通话为接通电话次数'
},
newCustomers: {
title: '新增客户计算方式',
description: '统计新建档的客户数量,不包括重复录入的客户,按首次录入时间计算。'
},
conversionRate: {
title: '转化率计算方式',
description: '转化率 = (成交客户数 / 总客户数) × 100%'
}
};
// 显示tooltip
function showTooltip(event, metricType) {
const rect = event.target.getBoundingClientRect();
tooltip.visible = true;
tooltip.x = rect.left + rect.width / 2;
tooltip.y = rect.top - 10;
tooltip.title = metricDescriptions[metricType].title;
tooltip.description = metricDescriptions[metricType].description;
}
// 隐藏tooltip
function hideTooltip() {
tooltip.visible = false;
}
// 计算属性将API数据转换为组件需要的格式
const kpiData = computed(() => {
const data = props.kpiData;
if (!data || Object.keys(data).length === 0) {
return {
totalSales: {},
activeTeams: {},
conversionRate: {},
totalCalls: {},
newCustomers: {},
};
}
return {
totalSales: {
value: data.totalDeal?.company_monthly_deal_count || 0,
trend: parseFloat(data.totalDeals?.company_monthly_vs_previous_month_deals_comparison) || 0,
targetCompletion: parseFloat(data.totalDeals?.company_monthly_target_completion_rate) || 0
},
activeTeams: {
value: parseFloat(data.DingconversionRate?.company_current_deposit_conversion_rate) || 0,
trend: parseFloat(data.DingconversionRate?.company_monthly_vs_last_month_rate_comparison) || 0,
totalMembers: data.DingconversionRate?.company_last_month_deposit_conversion_rate || '0.00%'
},
conversionRate: {
value: parseFloat(data.conversionRate?.company_conversion_rate) || 0,
trend: parseFloat(data.conversionRate?.center_monthly_vs_previous_deals) || 0,
industryAvg: 4.8
},
totalCalls: {
value: data.totalCallCount?.company_total_call_count || 0,
trend: parseFloat(data.totalCallCount?.company_total_call_count_vs_last) || 0,
effectiveCalls: data.totalCallCount?.company_effective_call_count || 0
},
newCustomers: {
value: data.newCustomer?.company_new_leads_count || 0,
trend: parseFloat(data.newCustomer?.company_new_leads_vs_previous_period) || 0,
interestedCustomers: data.newCustomer?.company_new_v_customer_count || 0
}
};
});
// --- 以下是辅助函数,保持不变 ---
// 格式化数字,添加千位分隔符
function formatNumber(num) {
if (num == null) return '0';
return num.toLocaleString();
}
// 根据趋势值返回 'positive' 或 'negative' 类
function getTrendClass(trend) {
if (trend > 0) return 'positive';
if (trend < 0) return 'negative';
return 'neutral';
}
// 格式化趋势百分比,自动添加 '+'
function formatTrend(trend, isPercentagePoint = false) {
if (trend == null) return '';
const sign = trend > 0 ? '+' : '';
const unit = isPercentagePoint ? '' : '%';
return `${sign}${trend}${unit}`;
}
</script>
<style scoped>
/* 可将此背景色应用到页面body或父容器 */
.overview-container {
background-color: #ffffff;
padding: 0.5rem;
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif;
max-height: 350px; /* 给容器一个最小高度以容纳加载/错误状态 */
border-radius: 8px;
}
/* --- 新增:加载和错误状态的样式 --- */
.state-container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 200px;
color: #86909c;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid rgba(0, 0, 0, 0.1);
border-left-color: #3a7afe;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 16px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.error-text {
color: #f53f3f;
margin-bottom: 16px;
}
.retry-button {
background-color: #3a7afe;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
}
.retry-button:hover {
background-color: #2f68ee;
}
/* --- 以下是卡片网格样式,保持不变 --- */
.kpi-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
.kpi-card {
background-color: #ffffff;
border-radius: 8px;
padding: 20px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
display: flex;
flex-direction: column;
gap: 8px;
transition: all 0.2s ease-in-out;
}
.kpi-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.08);
}
.kpi-card.primary {
grid-column: 1 / 3;
background: linear-gradient(135deg, #3a7afe, #2f68ee);
color: #ffffff;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-label {
font-size: 14px;
color: #86909c;
}
.primary .card-label {
color: rgba(255, 255, 255, 0.8);
}
.card-trend {
font-size: 14px;
font-weight: 500;
}
.card-trend.positive { color: #00b42a; }
.card-trend.negative { color: #f53f3f; }
.primary .card-trend { color: #ffffff; }
.card-secondary-info {
font-size: 14px;
color: #4e5969;
font-weight: 500;
}
.card-body {
display: flex;
align-items: baseline;
margin-top: 4px;
margin-bottom: 4px;
}
.card-value {
font-size: 36px;
font-weight: 700;
color: #1d2129;
line-height: 1.2;
}
.primary .card-value {
font-size: 48px;
color: #ffffff;
}
.card-unit {
font-size: 16px;
font-weight: 500;
color: #1d2129;
margin-left: 8px;
}
.primary .card-unit {
font-size: 20px;
color: #ffffff;
}
.card-footer {
font-size: 12px;
color: #86909c;
}
.primary .card-footer {
color: rgba(255, 255, 255, 0.8);
}
@media (max-width: 1200px) {
.kpi-grid {
grid-template-columns: repeat(2, 1fr);
}
.kpi-card.primary {
grid-column: 1 / 3;
}
}
@media (max-width: 768px) {
.kpi-grid {
grid-template-columns: 1fr;
}
.kpi-card.primary {
grid-column: auto;
}
.primary .card-value {
font-size: 40px;
}
}
/* 感叹号图标样式 */
.info-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
background: rgba(255, 255, 255, 0.2);
color: #fff;
border-radius: 50%;
font-size: 10px;
font-weight: bold;
margin-left: 6px;
cursor: pointer;
opacity: 0.7;
transition: all 0.2s ease;
}
.info-icon:hover {
opacity: 1;
background: rgba(255, 255, 255, 0.3);
transform: scale(1.1);
}
/* 非主卡片中的图标样式 */
.kpi-card:not(.primary) .info-icon {
background: rgba(0, 0, 0, 0.1);
color: #666;
}
.kpi-card:not(.primary) .info-icon:hover {
background: rgba(0, 0, 0, 0.15);
color: #333;
}
</style>