<template>
|
<div class="app-container indicator-stats">
|
<!-- KPI 汇总 -->
|
<el-row :gutter="20" class="stats-row">
|
<el-col :xs="24" :sm="12" :md="8">
|
<div class="stat-card stat-card-blue">
|
<div class="stat-icon-wrapper">
|
<div class="stat-icon">
|
<el-icon :size="32"><Document /></el-icon>
|
</div>
|
</div>
|
<div class="stat-content">
|
<div class="stat-value">{{ indicatorKpis.orderCount.toLocaleString() }}</div>
|
<div class="stat-label">订单数量</div>
|
</div>
|
<div class="stat-bg-decoration"></div>
|
</div>
|
</el-col>
|
<el-col :xs="24" :sm="12" :md="8">
|
<div class="stat-card stat-card-green">
|
<div class="stat-icon-wrapper">
|
<div class="stat-icon">
|
<el-icon :size="32"><Tickets /></el-icon>
|
</div>
|
</div>
|
<div class="stat-content">
|
<div class="stat-value">¥{{ indicatorKpis.salesAmount.toLocaleString() }}</div>
|
<div class="stat-label">销售额</div>
|
</div>
|
<div class="stat-bg-decoration"></div>
|
</div>
|
</el-col>
|
<el-col :xs="24" :sm="12" :md="8">
|
<div class="stat-card stat-card-orange">
|
<div class="stat-icon-wrapper">
|
<div class="stat-icon">
|
<el-icon :size="32"><Van /></el-icon>
|
</div>
|
</div>
|
<div class="stat-content">
|
<div class="stat-value">{{ indicatorKpis.shipRate }}%</div>
|
<div class="stat-label">发货率</div>
|
</div>
|
<div class="stat-bg-decoration"></div>
|
</div>
|
</el-col>
|
</el-row>
|
|
<!-- 图表区(包含筛选条件) -->
|
<el-card class="chart-card" shadow="hover">
|
<template #header>
|
<div class="card-header">
|
<div class="header-left">
|
<span class="card-title">销售趋势分析</span>
|
<span class="card-subtitle">筛选条件仅影响下方图表数据</span>
|
</div>
|
</div>
|
</template>
|
|
<!-- 图表筛选条件 -->
|
<div class="chart-filter-section">
|
<el-row :gutter="16" class="search-row">
|
<el-col :xs="24" :sm="12" :md="6">
|
<div class="filter-item">
|
<label class="filter-label">产品类别</label>
|
<el-tree-select
|
v-model="indicatorFilter.productCategory"
|
placeholder="请选择产品类别"
|
clearable
|
check-strictly
|
:data="productOptions"
|
:render-after-expand="false"
|
style="width: 100%"
|
/>
|
</div>
|
</el-col>
|
<el-col :xs="24" :sm="12" :md="6">
|
<div class="filter-item">
|
<label class="filter-label">客户</label>
|
<el-select
|
v-model="indicatorFilter.customerName"
|
placeholder="请选择客户"
|
clearable
|
filterable
|
style="width: 100%"
|
>
|
<el-option
|
v-for="item in customerOption"
|
:key="item.id"
|
:label="item.customerName"
|
:value="item.customerName"
|
/>
|
</el-select>
|
</div>
|
</el-col>
|
<el-col :xs="24" :sm="12" :md="6">
|
<div class="filter-item">
|
<label class="filter-label">日期范围</label>
|
<el-date-picker
|
v-model="indicatorFilter.dateRange"
|
type="daterange"
|
range-separator="至"
|
start-placeholder="开始日期"
|
end-placeholder="结束日期"
|
value-format="YYYY-MM-DD"
|
style="width: 100%"
|
/>
|
</div>
|
</el-col>
|
<el-col :xs="24" :sm="12" :md="6">
|
<div class="filter-item filter-buttons">
|
<el-button type="primary" :loading="loading" @click="applyIndicatorFilter">
|
<el-icon><Search /></el-icon>
|
查询图表
|
</el-button>
|
<el-button @click="resetIndicatorFilter">
|
<el-icon><Refresh /></el-icon>
|
重置
|
</el-button>
|
</div>
|
</el-col>
|
</el-row>
|
</div>
|
|
<!-- 图表展示区 -->
|
<div class="chart-container" v-loading="loading">
|
<div ref="indicatorChartRef" class="chart-wrapper"></div>
|
</div>
|
</el-card>
|
|
<!-- 业绩统计(团队维度,无个人姓名) -->
|
<el-card v-if="showTeamPerformance" class="table-card" shadow="hover">
|
<template #header>
|
<div class="card-header">
|
<span class="card-title">团队业绩统计</span>
|
</div>
|
</template>
|
<el-table
|
:data="teamPerformanceList"
|
border
|
stripe
|
style="width: 100%"
|
:header-cell-style="{ background: '#f5f7fa', color: '#606266', fontWeight: 'bold' }"
|
>
|
<el-table-column prop="team" label="销售团队" min-width="120"/>
|
<el-table-column prop="orderCount" label="订单数" align="right" min-width="100"/>
|
<el-table-column prop="salesAmount" label="销售额" align="right" min-width="140">
|
<template #default="scope">¥{{ scope.row.salesAmount.toLocaleString() }}</template>
|
</el-table-column>
|
<el-table-column prop="shipRate" label="发货率" align="right" min-width="100">
|
<template #default="scope">{{ scope.row.shipRate }}%</template>
|
</el-table-column>
|
<el-table-column prop="attainment" label="目标达成率" align="center" min-width="120">
|
<template #default="scope">
|
<el-tag
|
:type="scope.row.attainment >= 100 ? 'success' : scope.row.attainment >= 80 ? 'warning' : 'danger'"
|
effect="dark"
|
>
|
{{ scope.row.attainment }}%
|
</el-tag>
|
</template>
|
</el-table-column>
|
</el-table>
|
</el-card>
|
</div>
|
</template>
|
|
<script setup>
|
import { ref, reactive, onMounted, onUnmounted, nextTick } from 'vue'
|
import { Document, Van, Tickets, Search, Refresh } from '@element-plus/icons-vue'
|
import * as echarts from 'echarts'
|
import { getTotalStatistics, getStatisticsTable } from '@/api/salesManagement/indicatorStats'
|
import { productTreeList } from '@/api/basicData/product.js'
|
import { customerList } from '@/api/salesManagement/salesLedger.js'
|
import { ElMessage } from 'element-plus'
|
|
const indicatorKpis = reactive({
|
orderCount: 0,
|
salesAmount: 0,
|
shipRate: 0
|
})
|
|
// 是否展示销售团队明细表,按需开启
|
const showTeamPerformance = ref(false)
|
const loading = ref(false)
|
|
const indicatorFilter = reactive({
|
productCategory: '',
|
customerName: '',
|
dateRange: []
|
})
|
|
const indicatorChartRef = ref(null)
|
let indicatorChart = null
|
|
const productOptions = ref([])
|
const customerOption = ref([])
|
|
const teamPerformanceList = ref([
|
{ team: '华东大区', orderCount: 320, salesAmount: 2850000, shipRate: 90, attainment: 105 },
|
{ team: '华北大区', orderCount: 280, salesAmount: 2150000, shipRate: 86, attainment: 92 },
|
{ team: '华南大区', orderCount: 210, salesAmount: 1850000, shipRate: 88, attainment: 78 },
|
{ team: '西南大区', orderCount: 180, salesAmount: 1500000, shipRate: 83, attainment: 74 }
|
])
|
|
// 转换产品树数据,将 id 改为 value
|
function convertIdToValue(data) {
|
return data.map((item) => {
|
const { id, children, ...rest } = item
|
const newItem = {
|
...rest,
|
value: id, // 将 id 改为 value
|
}
|
if (children && children.length > 0) {
|
newItem.children = convertIdToValue(children)
|
}
|
return newItem
|
})
|
}
|
|
// 获取产品树数据
|
const getProductOptions = () => {
|
return productTreeList().then((res) => {
|
productOptions.value = convertIdToValue(res)
|
}).catch((error) => {
|
console.error('获取产品树失败:', error)
|
ElMessage.error('获取产品类别失败')
|
})
|
}
|
|
// 获取客户列表
|
const getCustomerList = () => {
|
return customerList().then((res) => {
|
customerOption.value = res || []
|
}).catch((error) => {
|
console.error('获取客户列表失败:', error)
|
ElMessage.error('获取客户列表失败')
|
})
|
}
|
|
// 根据 id 查找产品类别名称
|
const findNodeLabelById = (nodes, id) => {
|
if (!id) return null
|
for (let i = 0; i < nodes.length; i++) {
|
if (nodes[i].value === id) {
|
return nodes[i].label
|
}
|
if (nodes[i].children && nodes[i].children.length > 0) {
|
const found = findNodeLabelById(nodes[i].children, id)
|
if (found) return found
|
}
|
}
|
return null
|
}
|
|
// 获取头部统计数据
|
const fetchTotalStatistics = async () => {
|
try {
|
loading.value = true
|
const params = {}
|
if (indicatorFilter.customerName) {
|
params.customerName = indicatorFilter.customerName
|
}
|
if (indicatorFilter.productCategory) {
|
// 根据 id 查找产品类别名称
|
const categoryName = findNodeLabelById(productOptions.value, indicatorFilter.productCategory)
|
if (categoryName) {
|
params.productCategory = categoryName
|
}
|
}
|
if (indicatorFilter.dateRange && indicatorFilter.dateRange.length === 2) {
|
params.entryDateStart = indicatorFilter.dateRange[0]
|
params.entryDateEnd = indicatorFilter.dateRange[1]
|
}
|
const res = await getTotalStatistics(params)
|
if (res && res.data) {
|
indicatorKpis.orderCount = res.data.total || 0
|
indicatorKpis.salesAmount = res.data.contractAmountTotal || 0
|
// 发货率如果接口没有返回,保持原值或设为0
|
// indicatorKpis.shipRate = res.data.shipRate || 0
|
}
|
} catch (error) {
|
console.error('获取头部统计失败:', error)
|
ElMessage.error('获取统计数据失败')
|
} finally {
|
loading.value = false
|
}
|
}
|
|
// 获取柱状图数据
|
const fetchStatisticsTable = async () => {
|
try {
|
loading.value = true
|
const params = {}
|
if (indicatorFilter.customerName) {
|
params.customerName = indicatorFilter.customerName
|
}
|
if (indicatorFilter.productCategory) {
|
// 根据 id 查找产品类别名称
|
const categoryName = findNodeLabelById(productOptions.value, indicatorFilter.productCategory)
|
if (categoryName) {
|
params.productCategory = categoryName
|
}
|
}
|
if (indicatorFilter.dateRange && indicatorFilter.dateRange.length === 2) {
|
params.entryDateStart = indicatorFilter.dateRange[0]
|
params.entryDateEnd = indicatorFilter.dateRange[1]
|
}
|
const res = await getStatisticsTable(params)
|
if (res && res.data) {
|
updateChart(res.data)
|
}
|
} catch (error) {
|
console.error('获取图表数据失败:', error)
|
ElMessage.error('获取图表数据失败')
|
} finally {
|
loading.value = false
|
}
|
}
|
|
// 更新图表
|
const updateChart = (chartData) => {
|
if (!indicatorChartRef.value) return
|
if (indicatorChart) indicatorChart.dispose()
|
indicatorChart = echarts.init(indicatorChartRef.value)
|
|
// 根据接口返回的数据结构更新图表
|
// 接口返回: dateList, orderCountList, salesAmountList
|
const option = {
|
title: { text: '多维度销售指标趋势', left: 'center' },
|
tooltip: { trigger: 'axis' },
|
legend: { data: ['订单数', '销售额'], top: 30 },
|
grid: { left: '3%', right: '8%', bottom: '3%', containLabel: true },
|
xAxis: {
|
type: 'category',
|
data: chartData.dateList || []
|
},
|
yAxis: [
|
{ type: 'value', name: '金额', position: 'left', axisLabel: { formatter: '{value}' } },
|
{
|
type: 'value',
|
name: '数量',
|
position: 'right',
|
minInterval: 1,
|
axisLabel: {
|
formatter: (value) => {
|
const intValue = Math.round(value)
|
return intValue.toString()
|
}
|
}
|
}
|
],
|
series: [
|
{
|
name: '订单数',
|
type: 'line',
|
yAxisIndex: 1,
|
data: chartData.orderCountList || [],
|
itemStyle: { color: '#409eff' }
|
},
|
{
|
name: '销售额',
|
type: 'bar',
|
yAxisIndex: 0,
|
data: chartData.salesAmountList || [],
|
itemStyle: { color: '#67c23a' }
|
}
|
]
|
}
|
indicatorChart.setOption(option)
|
}
|
|
const initIndicatorChart = () => {
|
if (!indicatorChartRef.value) return
|
if (indicatorChart) indicatorChart.dispose()
|
indicatorChart = echarts.init(indicatorChartRef.value)
|
const option = {
|
title: { text: '多维度销售指标趋势', left: 'center' },
|
tooltip: { trigger: 'axis' },
|
legend: { data: ['订单数', '销售额'], top: 30 },
|
grid: { left: '3%', right: '8%', bottom: '3%', containLabel: true },
|
xAxis: { type: 'category', data: [] },
|
yAxis: [
|
{ type: 'value', name: '金额', position: 'left', axisLabel: { formatter: '{value}' } },
|
{
|
type: 'value',
|
name: '数量',
|
position: 'right',
|
minInterval: 1,
|
axisLabel: {
|
formatter: (value) => {
|
const intValue = Math.round(value)
|
return intValue.toString()
|
}
|
}
|
}
|
],
|
series: [
|
{ name: '订单数', type: 'line', yAxisIndex: 1, data: [], itemStyle: { color: '#409eff' } },
|
{ name: '销售额', type: 'bar', yAxisIndex: 0, data: [], itemStyle: { color: '#67c23a' } }
|
]
|
}
|
indicatorChart.setOption(option)
|
}
|
|
const applyIndicatorFilter = async () => {
|
// 筛选条件只影响图表数据,不影响KPI汇总
|
await fetchStatisticsTable()
|
}
|
|
const resetIndicatorFilter = () => {
|
indicatorFilter.productCategory = ''
|
indicatorFilter.customerName = ''
|
indicatorFilter.dateRange = []
|
applyIndicatorFilter()
|
}
|
|
// 窗口大小变化时调整图表大小
|
const handleResize = () => {
|
if (indicatorChart) {
|
indicatorChart.resize()
|
}
|
}
|
|
onMounted(() => {
|
nextTick(() => {
|
initIndicatorChart()
|
getProductOptions()
|
getCustomerList()
|
fetchTotalStatistics()
|
fetchStatisticsTable()
|
})
|
// 监听窗口大小变化
|
window.addEventListener('resize', handleResize)
|
})
|
|
onUnmounted(() => {
|
// 移除窗口大小变化监听器
|
window.removeEventListener('resize', handleResize)
|
// 销毁图表实例
|
if (indicatorChart) {
|
indicatorChart.dispose()
|
indicatorChart = null
|
}
|
})
|
</script>
|
|
<style scoped lang="scss">
|
.indicator-stats {
|
padding: 20px;
|
background: #f5f7fa;
|
min-height: calc(100vh - 84px);
|
}
|
|
.page-header {
|
margin-bottom: 24px;
|
padding: 20px 0;
|
|
.page-title {
|
font-size: 24px;
|
font-weight: 600;
|
color: #303133;
|
margin: 0 0 8px 0;
|
}
|
|
.page-desc {
|
font-size: 14px;
|
color: #909399;
|
margin: 0;
|
}
|
}
|
|
.stats-row {
|
margin-bottom: 24px;
|
}
|
|
.stat-card {
|
position: relative;
|
display: flex;
|
align-items: center;
|
padding: 24px;
|
background: #fff;
|
border-radius: 12px;
|
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.08);
|
transition: all 0.3s ease;
|
overflow: hidden;
|
|
&:hover {
|
transform: translateY(-4px);
|
box-shadow: 0 8px 24px 0 rgba(0, 0, 0, 0.12);
|
}
|
|
.stat-icon-wrapper {
|
margin-right: 20px;
|
|
.stat-icon {
|
width: 64px;
|
height: 64px;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
border-radius: 12px;
|
transition: all 0.3s ease;
|
}
|
}
|
|
.stat-content {
|
flex: 1;
|
z-index: 1;
|
|
.stat-value {
|
font-size: 32px;
|
font-weight: 700;
|
color: #303133;
|
margin-bottom: 8px;
|
line-height: 1.2;
|
}
|
|
.stat-label {
|
font-size: 14px;
|
color: #909399;
|
font-weight: 500;
|
}
|
}
|
|
.stat-bg-decoration {
|
position: absolute;
|
right: -20px;
|
top: -20px;
|
width: 120px;
|
height: 120px;
|
border-radius: 50%;
|
opacity: 0.1;
|
z-index: 0;
|
}
|
|
&.stat-card-blue {
|
.stat-icon {
|
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
|
color: #fff;
|
}
|
|
.stat-bg-decoration {
|
background: #409eff;
|
}
|
}
|
|
&.stat-card-green {
|
.stat-icon {
|
background: linear-gradient(135deg, #67c23a 0%, #85ce61 100%);
|
color: #fff;
|
}
|
|
.stat-bg-decoration {
|
background: #67c23a;
|
}
|
}
|
|
&.stat-card-orange {
|
.stat-icon {
|
background: linear-gradient(135deg, #e6a23c 0%, #ebb563 100%);
|
color: #fff;
|
}
|
|
.stat-bg-decoration {
|
background: #e6a23c;
|
}
|
}
|
}
|
|
.chart-card,
|
.table-card {
|
margin-bottom: 20px;
|
border-radius: 12px;
|
border: none;
|
|
:deep(.el-card__header) {
|
padding: 18px 20px;
|
border-bottom: 1px solid #ebeef5;
|
background: linear-gradient(135deg, #f5f7fa 0%, #ffffff 100%);
|
}
|
|
:deep(.el-card__body) {
|
padding: 0;
|
}
|
}
|
|
.card-header {
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
|
.header-left {
|
display: flex;
|
flex-direction: column;
|
gap: 4px;
|
}
|
|
.card-title {
|
font-size: 16px;
|
font-weight: 600;
|
color: #303133;
|
}
|
|
.card-subtitle {
|
font-size: 12px;
|
color: #909399;
|
font-weight: normal;
|
}
|
}
|
|
.chart-filter-section {
|
padding: 20px;
|
background: #fafbfc;
|
border-bottom: 1px solid #ebeef5;
|
margin-bottom: 0;
|
}
|
|
.search-row {
|
.filter-item {
|
margin-bottom: 0;
|
|
.filter-label {
|
display: block;
|
font-size: 13px;
|
color: #606266;
|
margin-bottom: 8px;
|
font-weight: 500;
|
}
|
|
&.filter-buttons {
|
display: flex;
|
align-items: flex-end;
|
gap: 10px;
|
padding-top: 28px;
|
|
.el-button {
|
flex: 1;
|
font-size: 14px;
|
}
|
}
|
}
|
}
|
|
.chart-container {
|
width: 100%;
|
overflow: hidden;
|
position: relative;
|
padding: 20px;
|
background: #fff;
|
|
.chart-wrapper {
|
width: 100%;
|
height: 420px;
|
min-width: 0;
|
}
|
}
|
|
.table-card {
|
:deep(.el-table) {
|
border-radius: 8px;
|
overflow: hidden;
|
}
|
|
:deep(.el-table__header-wrapper) {
|
.el-table__header {
|
th {
|
background: #f5f7fa;
|
color: #606266;
|
font-weight: 600;
|
}
|
}
|
}
|
|
:deep(.el-table__body-wrapper) {
|
.el-table__body {
|
tr:hover {
|
background-color: #f5f7fa;
|
}
|
}
|
}
|
}
|
|
// 响应式设计
|
@media (max-width: 768px) {
|
.indicator-stats {
|
padding: 12px;
|
}
|
|
.stat-card {
|
padding: 20px;
|
|
.stat-content .stat-value {
|
font-size: 24px;
|
}
|
|
.stat-icon-wrapper .stat-icon {
|
width: 56px;
|
height: 56px;
|
}
|
}
|
|
.chart-filter-section {
|
padding: 16px;
|
}
|
|
.search-row {
|
.filter-item.filter-buttons {
|
padding-top: 0;
|
margin-top: 12px;
|
}
|
}
|
|
.chart-container {
|
padding: 16px;
|
|
.chart-wrapper {
|
height: 320px;
|
}
|
}
|
|
.card-header {
|
.header-left {
|
.card-title {
|
font-size: 15px;
|
}
|
|
.card-subtitle {
|
font-size: 11px;
|
}
|
}
|
}
|
}
|
|
@media (max-width: 576px) {
|
.page-header {
|
.page-title {
|
font-size: 20px;
|
}
|
|
.page-desc {
|
font-size: 12px;
|
}
|
}
|
|
.stat-card {
|
flex-direction: column;
|
text-align: center;
|
|
.stat-icon-wrapper {
|
margin-right: 0;
|
margin-bottom: 12px;
|
}
|
}
|
}
|
</style>
|