feat(crm): 新增销售报表功能并优化订单异议处理

- 新增销售报表功能,包括汇总数据展示、图表统计和订单明细
- 优化订单异议处理流程,增加处理状态显示和操作按钮条件渲染
- 重构订单状态枚举导入和使用方式
- 移除不必要的查询条件和冗余代码
- 修复部分UI样式问题
This commit is contained in:
砂糖
2025-12-29 11:11:23 +08:00
parent 11c21f2a33
commit 980a9cf2b7
8 changed files with 1122 additions and 227 deletions

View File

@@ -0,0 +1,791 @@
<template>
<div class="crm-sales-report-page">
<!-- 1. 时间筛选区域单独一行默认当月第一天到今天 -->
<div class="date-filter-container">
<el-form :model="dateQuery" inline class="date-form">
<el-form-item label="统计时间" prop="dateRange">
<el-date-picker
v-model="dateQuery.dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
format="yyyy-MM-dd"
value-format="yyyy-MM-dd"
clearable
></el-date-picker>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" @click="queryAllData">查询</el-button>
<el-button icon="el-icon-refresh" @click="resetDateRange">重置</el-button>
</el-form-item>
</el-form>
</div>
<!-- 2. 统计区域销售汇总关键指标 -->
<div class="summary-stat-container" v-loading="summaryLoading">
<el-card class="summary-card" shadow="hover">
<div class="summary-list">
<div class="summary-item">
<span class="item-label">总订单数</span>
<span class="item-value">{{ summaryData.totalOrderCount || 0 }}</span>
</div>
<div class="summary-item">
<span class="item-label">总销售金额()</span>
<span class="item-value">{{ formatAmount(summaryData.totalSalesAmount) }}</span>
</div>
<div class="summary-item">
<span class="item-label">已完成订单数</span>
<span class="item-value">{{ summaryData.completedOrderCount || 0 }}</span>
</div>
<div class="summary-item">
<span class="item-label">已完成销售金额()</span>
<span class="item-value">{{ formatAmount(summaryData.completedSalesAmount) }}</span>
</div>
<div class="summary-item">
<span class="item-label">未结款总金额()</span>
<span class="item-value">{{ formatAmount(summaryData.totalUnpaidAmount) }}</span>
</div>
<div class="summary-item">
<span class="item-label">平均订单金额()</span>
<span class="item-value">{{ formatAmount(summaryData.avgOrderAmount) }}</span>
</div>
</div>
</el-card>
</div>
<!-- 3. ECharts图表区域销售员+客户等级+行业统计 -->
<div class="echarts-container" v-loading="chartLoading">
<el-row :gutter="20">
<!-- 销售员统计图表 -->
<el-col :span="8">
<el-card class="echarts-card" shadow="hover">
<div slot="header" class="card-header">
<span>销售员统计</span>
</div>
<!-- 确保DOM元素唯一且存在 -->
<div ref="salesmanChartRef" class="chart-item"></div>
</el-card>
</el-col>
<!-- 客户等级统计图表 -->
<el-col :span="8">
<el-card class="echarts-card" shadow="hover">
<div slot="header" class="card-header">
<span>客户等级统计</span>
</div>
<div ref="customerLevelChartRef" class="chart-item"></div>
</el-card>
</el-col>
<!-- 行业统计图表 -->
<el-col :span="8">
<el-card class="echarts-card" shadow="hover">
<div slot="header" class="card-header">
<span>行业统计</span>
</div>
<div ref="industryChartRef" class="chart-item"></div>
</el-card>
</el-col>
</el-row>
</div>
<!-- 4. 订单明细表格区域 -->
<div class="order-detail-container" v-loading="orderLoading">
<div class="table-toolbar">
<el-button
type="success"
icon="el-icon-download"
@click="exportOrderDetails"
v-hasPermi="['crm:salesReport:export']"
>
导出订单明细
</el-button>
</div>
<el-table
:data="orderDetailList"
border
style="width: 100%;"
@selection-change="handleSelectionChange"
>
<el-table-column prop="orderCode" label="订单编号" min-width="120"></el-table-column>
<el-table-column prop="customerCode" label="客户编码" min-width="100"></el-table-column>
<el-table-column prop="companyName" label="公司名称" min-width="150"></el-table-column>
<el-table-column prop="contactPerson" label="联系人" min-width="80"></el-table-column>
<el-table-column prop="customerLevel" label="客户等级" min-width="100"></el-table-column>
<el-table-column prop="industry" label="所属行业"></el-table-column>
<el-table-column prop="orderAmount" label="订单金额">
<template #default="scope">
{{ formatAmount(scope.row.orderAmount) }}
</template>
</el-table-column>
<el-table-column prop="salesman" label="销售员"></el-table-column>
<el-table-column prop="deliveryDate" label="交货日期" min-width="100">
<template #default="scope">
{{ scope.row.deliveryDate ? formatDate(scope.row.deliveryDate) : '-' }}
</template>
</el-table-column>
<el-table-column prop="orderStatus" label="订单状态">
<template #default="scope">
<el-tag v-if="scope.row.orderStatus === 1" type="success">已完成</el-tag>
<el-tag v-else-if="scope.row.orderStatus === 0" type="warning">待处理</el-tag>
<el-tag v-else type="danger">已取消</el-tag>
</template>
</el-table-column>
<el-table-column prop="financeStatus" label="财务状态">
<template #default="scope">
<el-tag v-if="scope.row.financeStatus === 1" type="success">已结款</el-tag>
<el-tag v-else type="danger">未结款</el-tag>
</template>
</el-table-column>
<el-table-column prop="unpaidAmount" label="未结金额">
<template #default="scope">
{{ formatAmount(scope.row.unpaidAmount) }}
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间">
<template #default="scope">
{{ scope.row.createTime ? formatDateTime(scope.row.createTime) : '-' }}
</template>
</el-table-column>
<el-table-column prop="itemCount" label="明细数量"></el-table-column>
<el-table-column prop="objectionCount" label="异议数量"></el-table-column>
</el-table>
<!-- 分页组件 -->
<el-pagination
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
:current-page="pageParams.pageNum"
:page-sizes="[10, 20, 50, 100]"
:page-size="pageParams.pageSize"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
style="margin-top: 20px; text-align: right;"
></el-pagination>
</div>
</div>
</template>
<script>
// 导入API函数
import {
getSummary,
getOrderDetails,
getSalesmanStats,
getCustomerLevelStats,
getIndustryStats,
exportOrderDetails
} from "@/api/crm/report";
// 导入ECharts
import * as echarts from "echarts";
export default {
name: "CrmSalesReport",
dicts: ['customer_level'],
data() {
return {
// 时间查询参数(单独抽离,默认当月第一天到今天)
dateQuery: {
dateRange: []
},
// 分页参数
pageParams: {
pageNum: 1,
pageSize: 10
},
// 汇总数据
summaryData: {},
// 订单明细数据
orderDetailList: [],
// 总条数
total: 0,
// 图表数据
salesmanStatList: [],
customerLevelStatList: [],
industryStatList: [],
// 加载状态
summaryLoading: false,
chartLoading: false,
orderLoading: false,
// 表格勾选数据
multipleSelection: [],
// ECharts实例
salesmanChart: null,
customerLevelChart: null,
industryChart: null
};
},
created() {
// 初始化默认时间:当月第一天到今天
this.initDefaultDateRange();
// 页面加载时查询所有数据
this.queryAllData();
},
mounted() {
// 延迟初始化ECharts确保DOM完全渲染
this.$nextTick(() => {
this.initECharts();
// 统一绑定resize事件避免重复绑定
window.addEventListener("resize", this.handleChartResize);
});
},
beforeDestroy() {
// 移除resize事件监听
window.removeEventListener("resize", this.handleChartResize);
// 销毁ECharts实例避免内存泄漏
if (this.salesmanChart) {
this.salesmanChart.dispose();
this.salesmanChart = null;
}
if (this.customerLevelChart) {
this.customerLevelChart.dispose();
this.customerLevelChart = null;
}
if (this.industryChart) {
this.industryChart.dispose();
this.industryChart = null;
}
},
methods: {
// 初始化默认时间范围:当月第一天到今天
initDefaultDateRange() {
const now = new Date();
// 当月第一天
const monthFirstDay = new Date(now.getFullYear(), now.getMonth(), 1);
// 格式化日期为yyyy-MM-dd
const firstDayStr = this.formatToDateStr(monthFirstDay);
const todayStr = this.formatToDateStr(now);
// 赋值给日期选择器
this.dateQuery.dateRange = [firstDayStr, todayStr];
},
// 日期对象格式化为yyyy-MM-dd字符串
formatToDateStr(date) {
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, "0");
const day = date.getDate().toString().padStart(2, "0");
return `${year}-${month}-${day}`;
},
// 格式化金额处理BigDecimal空值
formatAmount(amount) {
if (!amount) {
return "0.00";
}
return Number(amount).toFixed(2);
},
// 导入时间格式化函数(简单优化,可替换为项目真实工具函数)
formatDate(timestamp) {
if (!timestamp) return "-";
return timestamp;
},
formatDateTime(timestamp) {
if (!timestamp) return "-";
return timestamp;
},
// 重置日期范围
resetDateRange() {
this.initDefaultDateRange();
this.queryAllData();
},
// 统一查询所有数据(汇总+图表+订单明细)
queryAllData() {
this.loadSummaryData();
this.loadChartData();
this.loadOrderDetailData();
},
// 组装公共查询参数(时间+分页<仅订单明细>
getCommonParams(needPage = false) {
const params = {};
// 处理时间范围参数
if (this.dateQuery.dateRange && this.dateQuery.dateRange.length === 2) {
params.beginDate = this.dateQuery.dateRange[0];
params.endDate = this.dateQuery.dateRange[1];
}
// 是否需要分页参数
if (needPage) {
params.pageNum = this.pageParams.pageNum;
params.pageSize = this.pageParams.pageSize;
}
return params;
},
// 1. 加载销售汇总数据
loadSummaryData() {
this.summaryLoading = true;
const params = this.getCommonParams();
getSummary(params)
.then((res) => {
if (res.code === 200) {
this.summaryData = res.data || {};
}
})
.finally(() => {
this.summaryLoading = false;
});
},
// 2. 加载图表数据并更新ECharts
loadChartData() {
this.chartLoading = true;
const params = this.getCommonParams();
// 并行加载三个图表数据
Promise.all([
getSalesmanStats(params),
getCustomerLevelStats(params),
getIndustryStats(params)
])
.then(([salesmanRes, customerRes, industryRes]) => {
if (salesmanRes.code === 200) {
this.salesmanStatList = salesmanRes.data || [];
}
if (customerRes.code === 200) {
this.customerLevelStatList = customerRes.data || [];
}
if (industryRes.code === 200) {
this.industryStatList = industryRes.data || [];
}
// 数据加载完成后更新图表(确保实例已创建)
this.$nextTick(() => {
this.updateSalesmanChart();
this.updateCustomerLevelChart();
this.updateIndustryChart();
});
})
.finally(() => {
this.chartLoading = false;
});
},
// 3. 加载订单明细数据
loadOrderDetailData() {
this.orderLoading = true;
const params = this.getCommonParams(true); // 需要分页参数
getOrderDetails(params)
.then((res) => {
if (res.code === 200) {
this.orderDetailList = res.rows || [];
this.total = res.total || 0;
}
})
.finally(() => {
this.orderLoading = false;
});
},
// 初始化ECharts实例使用ref获取DOM更可靠
initECharts() {
// 销售员统计图表判断DOM是否存在
const salesmanDom = this.$refs.salesmanChartRef;
const customerLevelDom = this.$refs.customerLevelChartRef;
const industryDom = this.$refs.industryChartRef;
if (salesmanDom) {
this.salesmanChart = echarts.init(salesmanDom);
}
if (customerLevelDom) {
this.customerLevelChart = echarts.init(customerLevelDom);
}
if (industryDom) {
this.industryChart = echarts.init(industryDom);
}
// 初始化默认配置(空数据状态)
this.initDefaultEChartsOption();
},
// 初始化ECharts默认配置
initDefaultEChartsOption() {
const barEmptyOption = {
tooltip: {
trigger: "axis"
},
legend: {
show: false
},
xAxis: {
type: "category",
data: []
},
yAxis: {
type: "value"
},
series: [
{
data: [],
type: "bar"
}
],
graphic: {
type: "text",
left: "center",
top: "center",
style: {
text: "暂无数据",
fontSize: 14,
color: "#999"
}
}
};
const pieEmptyOption = {
tooltip: {
trigger: "item"
},
legend: {
orient: "vertical",
left: "left",
textStyle: {
fontSize: 12 // 正确配置字体大小
}
},
series: [
{
name: "销售金额",
type: "pie",
radius: ["40%", "70%"],
center: ["50%", "50%"],
data: [],
label: {
show: false
}
}
],
graphic: {
type: "text",
left: "center",
top: "center",
style: {
text: "暂无数据",
fontSize: 14,
color: "#999"
}
}
};
// 给三个图表设置默认配置
if (this.salesmanChart) {
this.salesmanChart.setOption(barEmptyOption);
}
if (this.customerLevelChart) {
this.customerLevelChart.setOption(pieEmptyOption);
}
if (this.industryChart) {
this.industryChart.setOption(barEmptyOption);
}
},
// 更新销售员统计图表柱状图修复tooltip格式化移除无效extraData
updateSalesmanChart() {
if (!this.salesmanChart || this.salesmanStatList.length === 0) {
return;
}
const salesmanNames = this.salesmanStatList.map(item => item.salesman);
const salesAmounts = this.salesmanStatList.map(item => Number(item.salesAmount || 0));
// 单独提取订单数量用于tooltip格式化
const orderCounts = this.salesmanStatList.map(item => item.orderCount || 0);
const option = {
tooltip: {
trigger: "axis",
axisPointer: {
type: "shadow"
},
// 回调函数格式化tooltip正确获取订单数量
formatter: (params) => {
const index = params[0].dataIndex;
const salesmanName = salesmanNames[index];
const salesAmount = salesAmounts[index].toFixed(2);
const orderCount = orderCounts[index];
return `${salesmanName}<br/>销售金额:${salesAmount} 元<br/>订单数量:${orderCount}`;
}
},
legend: {
show: false
},
grid: {
left: "3%",
right: "4%",
bottom: "3%",
containLabel: true
},
xAxis: {
type: "category",
data: salesmanNames,
axisLabel: {
rotate: 30, // 文字旋转,避免重叠
fontSize: 12
}
},
yAxis: {
type: "value",
name: "销售金额(元)"
},
series: [
{
name: "销售金额",
type: "bar",
data: salesAmounts,
itemStyle: {
color: "#4895ef"
}
}
],
graphic: null // 有数据时隐藏暂无数据提示
};
this.salesmanChart.setOption(option, true);
},
// 更新客户等级统计图表饼图修复legend配置正常渲染
updateCustomerLevelChart() {
if (!this.customerLevelChart || this.customerLevelStatList.length === 0) {
return;
}
const pieData = this.customerLevelStatList.map(item => ({
name: this.dict.type.customer_level.find(d => d.value === item.customerLevel)?.label || item.customerLevel,
value: Number(item.salesAmount || 0)
}));
const option = {
tooltip: {
trigger: "item",
formatter: "{b}<br/>销售金额:{c} 元<br/>占比:{d}%"
},
legend: {
orient: "vertical",
left: "left",
textStyle: {
fontSize: 12 // 正确配置字体大小
}
},
series: [
{
name: "销售金额",
type: "pie",
radius: ["40%", "70%"],
center: ["50%", "50%"],
avoidLabelOverlap: false,
label: {
show: false,
position: "center"
},
emphasis: {
label: {
show: true,
fontSize: 14,
fontWeight: "bold"
}
},
labelLine: {
show: false
},
data: pieData
}
],
graphic: null // 有数据时隐藏暂无数据提示
};
this.customerLevelChart.setOption(option, true);
},
// 更新行业统计图表柱状图修复tooltip格式化移除无效extraData
updateIndustryChart() {
if (!this.industryChart || this.industryStatList.length === 0) {
return;
}
const industryNames = this.industryStatList.map(item => item.industry);
const salesAmounts = this.industryStatList.map(item => Number(item.salesAmount || 0));
// 单独提取客户数量用于tooltip格式化
const customerCounts = this.industryStatList.map(item => item.customerCount || 0);
const option = {
tooltip: {
trigger: "axis",
axisPointer: {
type: "shadow"
},
// 回调函数格式化tooltip正确获取客户数量
formatter: (params) => {
const index = params[0].dataIndex;
const industryName = industryNames[index];
const salesAmount = salesAmounts[index].toFixed(2);
const customerCount = customerCounts[index];
return `${industryName}<br/>销售金额:${salesAmount} 元<br/>客户数量:${customerCount}`;
}
},
legend: {
show: false
},
grid: {
left: "3%",
right: "4%",
bottom: "3%",
containLabel: true
},
xAxis: {
type: "category",
data: industryNames,
axisLabel: {
rotate: 30,
fontSize: 12
}
},
yAxis: {
type: "value",
name: "销售金额(元)"
},
series: [
{
name: "销售金额",
type: "bar",
data: salesAmounts,
itemStyle: {
color: "#f72585"
}
}
],
graphic: null // 有数据时隐藏暂无数据提示
};
this.industryChart.setOption(option, true);
},
// 统一处理图表自适应避免重复绑定resize事件
handleChartResize() {
if (this.salesmanChart) {
this.salesmanChart.resize();
}
if (this.customerLevelChart) {
this.customerLevelChart.resize();
}
if (this.industryChart) {
this.industryChart.resize();
}
},
// 分页大小改变
handleSizeChange(val) {
this.pageParams.pageSize = val;
this.loadOrderDetailData();
},
// 当前页改变
handleCurrentChange(val) {
this.pageParams.pageNum = val;
this.loadOrderDetailData();
},
// 表格勾选事件
handleSelectionChange(val) {
this.multipleSelection = val;
},
// 导出订单明细
exportOrderDetails() {
const params = this.getCommonParams();
exportOrderDetails(params).then((res) => {
this.handleExportBlob(res, "销售报表订单明细.xlsx");
});
},
// 处理blob文件导出
handleExportBlob(res, fileName) {
const blob = new Blob([res], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
this.$message.success("导出成功!");
}
}
};
</script>
<style scoped>
.crm-sales-report-page {
padding: 20px;
background-color: #f5f7fa;
min-height: calc(100vh - 120px);
}
/* 时间筛选区域样式 */
.date-filter-container {
background-color: #fff;
padding: 20px;
border-radius: 4px;
margin-bottom: 20px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.04);
}
.date-form {
display: flex;
align-items: center;
}
/* 统计区域样式 */
.summary-stat-container {
margin-bottom: 20px;
}
.summary-card {
border: none;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.04);
}
.summary-list {
display: flex;
flex-wrap: wrap;
gap: 20px;
padding: 15px 0;
}
.summary-item {
width: calc(16.666% - 20px);
text-align: center;
padding: 15px 10px;
border: 1px solid #ebeef5;
border-radius: 4px;
}
.item-label {
display: block;
font-size: 14px;
color: #606266;
margin-bottom: 10px;
}
.item-value {
display: block;
font-size: 24px;
font-weight: bold;
color: #1f2d3d;
}
/* ECharts图表区域样式确保容器高度足够且可见 */
.echarts-container {
margin-bottom: 20px;
}
.echarts-card {
height: 400px;
border: none;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.04);
}
.card-header {
font-size: 14px;
font-weight: bold;
color: #1f2d3d;
}
.chart-item {
width: 100%;
height: calc(100% - 50px); /* 减去卡片头部高度,确保高度足够 */
min-height: 300px; /* 最小高度兜底 */
}
/* 订单明细区域样式 */
.order-detail-container {
background-color: #fff;
border-radius: 4px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.04);
}
.table-toolbar {
padding: 15px 20px;
background-color: #fafafa;
border-bottom: 1px solid #ebeef5;
text-align: right;
}
</style>