| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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="monthrange" |
| | | range-separator="è³" |
| | | start-placeholder="å¼å§æ¥æ" |
| | | end-placeholder="ç»ææ¥æ" |
| | | value-format="YYYY-MM" |
| | | 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> |
| | | </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([]) |
| | | |
| | | // 转æ¢äº§åæ æ°æ®ï¼å° 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; |
| | | 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> |
| | | |
| | | |