| | |
| | | <el-icon :size="30" color="#e6a23c"><Van /></el-icon> |
| | | </div> |
| | | <div class="stat-content"> |
| | | <div class="stat-value">{{ indicatorKpis.shipmentRate }}%</div> |
| | | <div class="stat-value">{{ indicatorKpis.shipRate }}%</div> |
| | | <div class="stat-label">发货率</div> |
| | | </div> |
| | | </div> |
| | |
| | | <!-- 维度筛选 --> |
| | | <el-row :gutter="20" class="search-row"> |
| | | <el-col :span="6"> |
| | | <el-select v-model="indicatorFilter.product" placeholder="产品" clearable> |
| | | <el-option label="全部产品" value="" /> |
| | | <el-option label="P.O 42.5普通硅酸盐水泥" value="P.O 42.5普通硅酸盐水泥" /> |
| | | <el-option label="P.S 32.5矿渣硅酸盐水泥" value="P.S 32.5矿渣硅酸盐水泥" /> |
| | | <el-option label="P.C 32.5复合硅酸盐水泥" value="P.C 32.5复合硅酸盐水泥" /> |
| | | </el-select> |
| | | <el-tree-select v-model="indicatorFilter.productCategory" placeholder="产品类别" clearable check-strictly |
| | | :data="productOptions" :render-after-expand="false" style="width: 100%" /> |
| | | </el-col> |
| | | <el-col :span="6"> |
| | | <el-select v-model="indicatorFilter.customer" placeholder="客户" clearable> |
| | | <el-option label="全部客户" value="" /> |
| | | <el-option label="华东建材集团" value="华东建材集团" /> |
| | | <el-option label="长江混凝土公司" value="长江混凝土公司" /> |
| | | <el-option label="浦江水泥制品厂" value="浦江水泥制品厂" /> |
| | | </el-select> |
| | | </el-col> |
| | | <el-col :span="6"> |
| | | <el-select v-model="indicatorFilter.region" placeholder="区域" clearable> |
| | | <el-option label="全部区域" value="" /> |
| | | <el-option label="华东地区" value="华东地区" /> |
| | | <el-option label="华南地区" value="华南地区" /> |
| | | <el-option label="华北地区" value="华北地区" /> |
| | | <el-select v-model="indicatorFilter.customerName" placeholder="客户" clearable filterable> |
| | | <el-option v-for="item in customerOption" :key="item.id" :label="item.customerName" :value="item.customerName" /> |
| | | </el-select> |
| | | </el-col> |
| | | <el-col :span="6"> |
| | | <el-date-picker v-model="indicatorFilter.dateRange" type="daterange" range-separator="至" |
| | | start-placeholder="开始日期" end-placeholder="结束日期" value-format="YYYY-MM-DD" style="width: 100%" /> |
| | | </el-col> |
| | | <el-col :span="24" style="text-align: right; margin-top: 10px;"> |
| | | <el-col :span="6" style="text-align: right;"> |
| | | <el-button type="primary" @click="applyIndicatorFilter">查询</el-button> |
| | | <el-button @click="resetIndicatorFilter">重置</el-button> |
| | | <el-button @click="exportIndicatorTable">导出报表</el-button> |
| | | <el-button @click="exportIndicatorChart">导出图表</el-button> |
| | | </el-col> |
| | | </el-row> |
| | | |
| | | <!-- 图表区 --> |
| | | <div class="chart-container"> |
| | | <div ref="indicatorChartRef" style="width: 100%; height: 360px;"></div> |
| | | <div ref="indicatorChartRef" class="chart-wrapper"></div> |
| | | </div> |
| | | |
| | | <!-- 业绩统计(团队维度,无个人姓名) --> |
| | |
| | | <el-table-column prop="salesAmount" label="销售额"> |
| | | <template #default="scope">¥{{ scope.row.salesAmount.toLocaleString() }}</template> |
| | | </el-table-column> |
| | | <el-table-column prop="shipmentRate" label="发货率"> |
| | | <template #default="scope">{{ scope.row.shipmentRate }}%</template> |
| | | <el-table-column prop="shipRate" label="发货率"> |
| | | <template #default="scope">{{ scope.row.shipRate }}</template> |
| | | </el-table-column> |
| | | <el-table-column prop="attainment" label="目标达成率"> |
| | | <template #default="scope"> |
| | |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { ref, reactive, onMounted, nextTick } from 'vue' |
| | | import { ref, reactive, onMounted, onUnmounted, nextTick } from 'vue' |
| | | import { Document, Van, Tickets } 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: 1280, |
| | | salesAmount: 9650000, |
| | | shipmentRate: 89.2 |
| | | orderCount: 0, |
| | | salesAmount: 0, |
| | | shipRate: 0 |
| | | }) |
| | | |
| | | // 是否展示销售团队明细表,按需开启 |
| | | const showTeamPerformance = ref(false) |
| | | const loading = ref(false) |
| | | |
| | | const indicatorFilter = reactive({ |
| | | product: '', |
| | | customer: '', |
| | | region: '', |
| | | productCategory: '', |
| | | customerName: '', |
| | | dateRange: [] |
| | | }) |
| | | |
| | | const indicatorChartRef = ref(null) |
| | | let indicatorChart = null |
| | | |
| | | const productOptions = ref([]) |
| | | const customerOption = ref([]) |
| | | |
| | | const teamPerformanceList = ref([ |
| | | { team: '华东大区', orderCount: 320, salesAmount: 2850000, shipmentRate: 90, attainment: 105 }, |
| | | { team: '华北大区', orderCount: 280, salesAmount: 2150000, shipmentRate: 86, attainment: 92 }, |
| | | { team: '华南大区', orderCount: 210, salesAmount: 1850000, shipmentRate: 88, attainment: 78 }, |
| | | { team: '西南大区', orderCount: 180, salesAmount: 1500000, shipmentRate: 83, attainment: 74 } |
| | | { 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 |
| | |
| | | const option = { |
| | | title: { text: '多维度销售指标趋势', left: 'center' }, |
| | | tooltip: { trigger: 'axis' }, |
| | | legend: { data: ['订单数', '销售额', '发货率'], top: 30 }, |
| | | grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true }, |
| | | xAxis: { type: 'category', data: ['2024-12', '2025-01', '2025-02', '2025-03', '2025-04', '2025-05'] }, |
| | | legend: { data: ['订单数', '销售额'], top: 30 }, |
| | | grid: { left: '3%', right: '8%', bottom: '3%', containLabel: true }, |
| | | xAxis: { type: 'category', data: [] }, |
| | | yAxis: [ |
| | | { type: 'value', name: '数量/金额', axisLabel: { formatter: '{value}' } }, |
| | | { type: 'value', name: '比例(%)', min: 0, max: 100, axisLabel: { formatter: '{value}%' } } |
| | | { 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: 'bar', data: [180, 220, 210, 260, 205, 225], itemStyle: { color: '#409eff' } }, |
| | | { name: '销售额', type: 'bar', data: [820, 950, 910, 1080, 980, 1020], itemStyle: { color: '#67c23a' } }, |
| | | { name: '发货率', type: 'line', yAxisIndex: 1, data: [86, 89, 88, 91, 87, 90], itemStyle: { color: '#e6a23c' } } |
| | | { name: '订单数', type: 'line', yAxisIndex: 1, data: [], itemStyle: { color: '#409eff' } }, |
| | | { name: '销售额', type: 'bar', yAxisIndex: 0, data: [], itemStyle: { color: '#67c23a' } } |
| | | ] |
| | | } |
| | | indicatorChart.setOption(option) |
| | | } |
| | | |
| | | const applyIndicatorFilter = () => { |
| | | const random = (base, delta) => { |
| | | const v = base + Math.round((Math.random() - 0.5) * delta) |
| | | return v < 0 ? 0 : v |
| | | } |
| | | indicatorKpis.orderCount = random(1280, 120) |
| | | indicatorKpis.salesAmount = random(9650000, 350000) |
| | | indicatorKpis.shipmentRate = (85 + Math.random() * 10).toFixed(1) * 1 |
| | | setTimeout(() => initIndicatorChart(), 200) |
| | | const applyIndicatorFilter = async () => { |
| | | await Promise.all([ |
| | | fetchTotalStatistics(), |
| | | fetchStatisticsTable() |
| | | ]) |
| | | } |
| | | |
| | | const resetIndicatorFilter = () => { |
| | | indicatorFilter.product = '' |
| | | indicatorFilter.customer = '' |
| | | indicatorFilter.region = '' |
| | | indicatorFilter.productCategory = '' |
| | | indicatorFilter.customerName = '' |
| | | indicatorFilter.dateRange = [] |
| | | applyIndicatorFilter() |
| | | } |
| | | |
| | | const exportIndicatorTable = () => { |
| | | const header = ['销售团队', '订单数', '销售额', '发货率(%)', '目标达成率(%)'] |
| | | const rows = teamPerformanceList.value.map(r => [ |
| | | r.team, |
| | | r.orderCount, |
| | | r.salesAmount, |
| | | r.shipmentRate, |
| | | r.attainment |
| | | ]) |
| | | const csv = [header, ...rows].map(r => r.join(',')).join('\n') |
| | | const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }) |
| | | const url = URL.createObjectURL(blob) |
| | | const link = document.createElement('a') |
| | | link.href = url |
| | | link.download = '指标统计-团队业绩.csv' |
| | | document.body.appendChild(link) |
| | | link.click() |
| | | document.body.removeChild(link) |
| | | URL.revokeObjectURL(url) |
| | | // 窗口大小变化时调整图表大小 |
| | | const handleResize = () => { |
| | | if (indicatorChart) { |
| | | indicatorChart.resize() |
| | | } |
| | | |
| | | const exportIndicatorChart = () => { |
| | | if (!indicatorChart) return |
| | | const url = indicatorChart.getDataURL({ type: 'png', pixelRatio: 2, backgroundColor: '#fff' }) |
| | | const link = document.createElement('a') |
| | | link.href = url |
| | | link.download = '指标统计-图表.png' |
| | | document.body.appendChild(link) |
| | | link.click() |
| | | document.body.removeChild(link) |
| | | } |
| | | |
| | | onMounted(() => { |
| | | nextTick(() => initIndicatorChart()) |
| | | nextTick(() => { |
| | | initIndicatorChart() |
| | | getProductOptions() |
| | | getCustomerList() |
| | | fetchTotalStatistics() |
| | | fetchStatisticsTable() |
| | | }) |
| | | // 监听窗口大小变化 |
| | | window.addEventListener('resize', handleResize) |
| | | }) |
| | | |
| | | onUnmounted(() => { |
| | | // 移除窗口大小变化监听器 |
| | | window.removeEventListener('resize', handleResize) |
| | | // 销毁图表实例 |
| | | if (indicatorChart) { |
| | | indicatorChart.dispose() |
| | | indicatorChart = null |
| | | } |
| | | }) |
| | | </script> |
| | | |
| | |
| | | .stat-content { flex: 1; } |
| | | .stat-value { font-size: 28px; font-weight: bold; color: #303133; margin-bottom: 4px; } |
| | | .stat-label { font-size: 14px; color: #909399; } |
| | | .chart-container { margin: 20px 0; padding: 20px; background: #fff; border-radius: 8px; box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1); } |
| | | .chart-container { |
| | | margin: 20px 0; |
| | | padding: 20px; |
| | | background: #fff; |
| | | border-radius: 8px; |
| | | box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1); |
| | | width: 100%; |
| | | overflow: hidden; |
| | | } |
| | | .chart-wrapper { |
| | | width: 100%; |
| | | height: 360px; |
| | | min-width: 0; |
| | | } |
| | | </style> |
| | | |
| | | |