forked from lxbfYeaaGbeDLMCi/deShanXiao
499 lines
13 KiB
Vue
499 lines
13 KiB
Vue
<template>
|
||
<div style="background-color: #fff; padding: 15px">
|
||
<baseTableHeader title="销售统计报表" @resetSearch="reset" @search="query">
|
||
<template #content>
|
||
<el-form v-model="searchForm">
|
||
<el-row :gutter="25">
|
||
<el-col :span="12">
|
||
<el-form-item label="统计日期">
|
||
<el-date-picker
|
||
v-model="searchForm.dateRange"
|
||
type="datetimerange"
|
||
range-separator="至"
|
||
start-placeholder="选择开始日期"
|
||
end-placeholder="选择结束日期"
|
||
value-format="YYYY-MM-DD HH:mm:ss"
|
||
@change="dateChange" />
|
||
</el-form-item>
|
||
</el-col>
|
||
<el-col :span="6">
|
||
<el-form-item label="服务名称">
|
||
<el-input
|
||
v-model="searchForm.serviceName"
|
||
placeholder="输入服务名称" />
|
||
</el-form-item>
|
||
</el-col>
|
||
<el-col :span="6">
|
||
<el-form-item label="项目分类">
|
||
<el-select
|
||
v-model="searchForm.categoryName"
|
||
placeholder="选择分类"
|
||
clearable>
|
||
<el-option
|
||
v-for="category in categories"
|
||
:key="category.id"
|
||
:label="category.name"
|
||
:value="category.name" />
|
||
<el-option label="其他服务" value="其他服务" />
|
||
</el-select>
|
||
</el-form-item>
|
||
</el-col>
|
||
<!-- <div>
|
||
<el-button
|
||
type="primary"
|
||
style="margin-left: 15px"
|
||
@click="query"
|
||
:loading="loading">
|
||
查询
|
||
</el-button>
|
||
<el-button type="warning" @click="reset">重置</el-button>
|
||
</div> -->
|
||
</el-row>
|
||
</el-form>
|
||
</template>
|
||
<template #operateBtns>
|
||
<el-button size="small" v-print="print"
|
||
><img
|
||
src="/assets/icon/打印机.svg"
|
||
style="margin-right: 5px"
|
||
width="20"
|
||
height="20" />打印单据</el-button
|
||
>
|
||
<el-button @click="exportTableToExcel"
|
||
><img
|
||
src="/assets/images/Excel.svg"
|
||
alt=""
|
||
width="20"
|
||
height="20" />导出</el-button
|
||
>
|
||
</template>
|
||
</baseTableHeader>
|
||
|
||
<!-- 加载状态 -->
|
||
<div v-if="loading" class="loading-mask">
|
||
<el-icon class="is-loading" color="#409EFC" :size="30">
|
||
<Loading />
|
||
</el-icon>
|
||
<span style="margin-left: 10px">数据加载中...</span>
|
||
</div>
|
||
<div id="print-container">
|
||
<h2
|
||
style="
|
||
color: #000;
|
||
text-align: center;
|
||
font-size: 20pt;
|
||
padding: 15px;
|
||
font-weight: bold;
|
||
">
|
||
销售统计报表
|
||
</h2>
|
||
<table
|
||
v-show="!loading"
|
||
style="
|
||
table-layout: fixed;
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-family: 宋体;
|
||
">
|
||
<!-- 表头 -->
|
||
<thead>
|
||
<tr>
|
||
<th style="border: 1px solid #000; padding: 8px">序号</th>
|
||
<th style="border: 1px solid #000; padding: 8px">服务项目</th>
|
||
<th style="border: 1px solid #000; padding: 8px">单价(元)</th>
|
||
<th style="border: 1px solid #000; padding: 8px">数量</th>
|
||
<th style="border: 1px solid #000; padding: 8px">小计(元)</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<!-- 新增空状态 -->
|
||
<tr v-if="showEmpty">
|
||
<td
|
||
colspan="4"
|
||
style="text-align: center; color: #999; padding: 20px">
|
||
未查询到相关数据
|
||
</td>
|
||
</tr>
|
||
<!-- 分类数据 -->
|
||
<template
|
||
v-for="category in statsData.categories"
|
||
:key="category.categoryName">
|
||
<tr>
|
||
<td
|
||
colspan="5"
|
||
style="font-weight: bold; padding: 8px; text-align: center">
|
||
{{ category.categoryName }}
|
||
</td>
|
||
</tr>
|
||
<tr
|
||
v-for="(service, index) in category.services"
|
||
:key="service.serviceName">
|
||
<td style="padding: 8px">{{ index + 1 }}</td>
|
||
<td style="border: 1px solid #000; padding: 8px">
|
||
{{ service.serviceName }}
|
||
</td>
|
||
<td
|
||
style="border: 1px solid #000; text-align: right; padding: 8px">
|
||
{{ service.price.toFixed(2) }}
|
||
</td>
|
||
<td
|
||
style="border: 1px solid #000; text-align: right; padding: 8px">
|
||
{{ service.quantity }}
|
||
</td>
|
||
<td
|
||
style="border: 1px solid #000; text-align: right; padding: 8px">
|
||
{{ service.subtotal.toFixed(2) }}
|
||
</td>
|
||
</tr>
|
||
<!-- <tr style="font-weight: bold">
|
||
<td colspan="2" style="border: 1px solid #000; padding: 8px">
|
||
分类合计
|
||
</td>
|
||
<td
|
||
style="border: 1px solid #000; text-align: right; padding: 8px">
|
||
{{ category.totalQuantity }}
|
||
</td>
|
||
<td
|
||
style="border: 1px solid #000; text-align: right; padding: 8px">
|
||
{{ category.totalAmount.toFixed(2) }}
|
||
</td>
|
||
</tr> -->
|
||
</template>
|
||
|
||
<!-- 其他服务 -->
|
||
<tr v-if="statsData.otherCategory.services.length">
|
||
<td colspan="5" style="font-weight: bold; padding: 8px">
|
||
{{ statsData.otherCategory.categoryName }}
|
||
</td>
|
||
</tr>
|
||
<tr
|
||
v-if="statsData.otherCategory.services.length"
|
||
v-for="(service, index) in statsData.otherCategory.services"
|
||
:key="'other-' + service.serviceName">
|
||
<td style="padding: 8px">{{ index + 1 }}</td>
|
||
<td style="border: 1px solid #000; padding: 8px">
|
||
{{ service.serviceName }}
|
||
</td>
|
||
<td style="border: 1px solid #000; text-align: right; padding: 8px">
|
||
{{ service.price.toFixed(2) }}
|
||
</td>
|
||
<td style="border: 1px solid #000; text-align: right; padding: 8px">
|
||
{{ service.quantity }}
|
||
</td>
|
||
<td style="border: 1px solid #000; text-align: right; padding: 8px">
|
||
{{ service.subtotal.toFixed(2) }}
|
||
</td>
|
||
</tr>
|
||
|
||
<!-- 总计行 -->
|
||
<tr>
|
||
<td
|
||
colspan="3"
|
||
style="
|
||
border: 1px solid #000;
|
||
padding: 8px;
|
||
font-weight: bold;
|
||
font-size: 20px;
|
||
">
|
||
数量和金额总计
|
||
</td>
|
||
<td
|
||
style="
|
||
border: 1px solid #000;
|
||
text-align: right;
|
||
padding: 8px;
|
||
font-weight: bold;
|
||
font-size: 20px;
|
||
">
|
||
{{ statsData.grandTotalQuantity }}
|
||
</td>
|
||
<td
|
||
style="
|
||
border: 1px solid #000;
|
||
text-align: right;
|
||
padding: 8px;
|
||
font-weight: bold;
|
||
font-size: 20px;
|
||
">
|
||
{{ statsData.grandTotalAmount.toFixed(2) }} 元
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script lang="ts" setup>
|
||
import { ref, computed, onMounted } from "vue";
|
||
import { ElMessage } from "element-plus";
|
||
import { Loading } from "@element-plus/icons-vue";
|
||
import request from "@/lib/request";
|
||
import dayjs from "dayjs";
|
||
import * as XLSX from "xlsx";
|
||
|
||
interface ServiceStatsItem {
|
||
serviceName: string;
|
||
price: number;
|
||
unit: string;
|
||
quantity: number;
|
||
subtotal: number;
|
||
}
|
||
|
||
interface CategoryStats {
|
||
categoryName: string;
|
||
services: ServiceStatsItem[];
|
||
totalQuantity: number;
|
||
totalAmount: number;
|
||
}
|
||
|
||
interface StatsResponse {
|
||
categories: CategoryStats[];
|
||
otherCategory: CategoryStats;
|
||
grandTotalQuantity: number;
|
||
grandTotalAmount: number;
|
||
}
|
||
const print = {
|
||
id: "print-container",
|
||
};
|
||
// 保持原有响应式数据结构
|
||
const loading = ref(false);
|
||
const searchForm = ref({
|
||
dateRange: [
|
||
dayjs().startOf("day").format("YYYY-MM-DD 00:00:00"),
|
||
dayjs(new Date()).format("YYYY-MM-DD HH:mm:ss"),
|
||
] as string[],
|
||
serviceName: "",
|
||
categoryName: "",
|
||
});
|
||
|
||
const statsData = ref<StatsResponse>({
|
||
categories: [],
|
||
otherCategory: {
|
||
categoryName: "其他服务",
|
||
services: [],
|
||
totalQuantity: 0,
|
||
totalAmount: 0,
|
||
},
|
||
grandTotalQuantity: 0,
|
||
grandTotalAmount: 0,
|
||
});
|
||
|
||
const categories = ref<Array<{ id: number; name: string }>>([]);
|
||
const showEmpty = computed(
|
||
() =>
|
||
statsData.value?.categories?.length === 0 &&
|
||
statsData.value.otherCategory.services.length === 0
|
||
);
|
||
|
||
onMounted(async () => {
|
||
try {
|
||
await fetchCategories();
|
||
await query();
|
||
} catch (error) {
|
||
handleError(error);
|
||
}
|
||
});
|
||
|
||
const fetchCategories = async () => {
|
||
try {
|
||
const res = await request().get("/public/service-categories");
|
||
categories.value = res.data.map((item: any) => ({
|
||
id: item.value,
|
||
name: item.label,
|
||
}));
|
||
} catch (error) {
|
||
ElMessage.error("分类加载失败");
|
||
}
|
||
};
|
||
|
||
const query = async () => {
|
||
try {
|
||
loading.value = true;
|
||
|
||
const params = {
|
||
startDate: searchForm.value.dateRange[0],
|
||
endDate: searchForm.value.dateRange[1],
|
||
serviceName: searchForm.value.serviceName,
|
||
categoryName: searchForm.value.categoryName,
|
||
};
|
||
|
||
const res = await request().get("/stats/servicesStats", { params });
|
||
statsData.value = res.data || res;
|
||
} catch (error) {
|
||
ElMessage.error(`查询失败: ${(error as Error).message}`);
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
};
|
||
|
||
const reset = () => {
|
||
searchForm.value = {
|
||
dateRange: [
|
||
dayjs().startOf("day").format("YYYY-MM-DD 00:00:00"),
|
||
dayjs().format("YYYY-MM-DD 23:59:59"),
|
||
],
|
||
serviceName: "",
|
||
categoryName: "",
|
||
};
|
||
query();
|
||
};
|
||
|
||
const dateChange = (val: string[]) => {
|
||
if (val?.length === 2) {
|
||
searchForm.value.dateRange = val;
|
||
}
|
||
};
|
||
|
||
const handleError = (error: unknown) => {
|
||
ElMessage({
|
||
type: "error",
|
||
message: "系统异常,请稍后重试",
|
||
duration: 3000,
|
||
});
|
||
};
|
||
|
||
function exportTableToExcel() {
|
||
const table = document.querySelector(
|
||
"#print-container table"
|
||
) as HTMLTableElement;
|
||
if (!table) {
|
||
console.error("找不到表格");
|
||
return;
|
||
}
|
||
|
||
// 将 HTML 表格转换为 worksheet
|
||
const worksheet = XLSX.utils.table_to_sheet(table, {
|
||
raw: true,
|
||
});
|
||
|
||
// 获取范围
|
||
const range = XLSX.utils.decode_range(worksheet["!ref"]!);
|
||
|
||
// 遍历每个单元格,设置样式
|
||
for (let R = range.s.r; R <= range.e.r; ++R) {
|
||
for (let C = range.s.c; C <= range.e.c; ++C) {
|
||
const cellAddress = XLSX.utils.encode_cell({ r: R, c: C });
|
||
const cell = worksheet[cellAddress];
|
||
if (!cell || !cell.v) continue;
|
||
|
||
// 是否是最后一行(数量和金额总计行)
|
||
const isLastRow = R === range.e.r;
|
||
|
||
cell.s = {
|
||
alignment: {
|
||
horizontal: "center",
|
||
vertical: "center",
|
||
wrapText: true,
|
||
},
|
||
font: {
|
||
bold: isLastRow, // 最后一行加粗
|
||
sz: 12,
|
||
},
|
||
border: {
|
||
top: { style: "thin", color: { rgb: "000000" } },
|
||
bottom: { style: "thin", color: { rgb: "000000" } },
|
||
left: { style: "thin", color: { rgb: "000000" } },
|
||
right: { style: "thin", color: { rgb: "000000" } },
|
||
},
|
||
};
|
||
}
|
||
}
|
||
|
||
// 创建工作簿并导出
|
||
const workbook = XLSX.utils.book_new();
|
||
XLSX.utils.book_append_sheet(workbook, worksheet, "销售统计");
|
||
XLSX.writeFile(workbook, "销售统计报表.xlsx");
|
||
}
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.loading-mask {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: rgba(255, 255, 255, 0.9);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 999;
|
||
}
|
||
|
||
.is-loading {
|
||
animation: rotating 2s linear infinite;
|
||
}
|
||
|
||
@keyframes rotating {
|
||
from {
|
||
transform: rotate(0deg);
|
||
}
|
||
to {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
|
||
th,
|
||
td {
|
||
border: 1px solid #000;
|
||
padding: 8px;
|
||
font-size: 14px;
|
||
text-align: center !important;
|
||
}
|
||
@media print {
|
||
/* 移除全局布局影响 */
|
||
body,
|
||
html {
|
||
display: block !important;
|
||
height: auto !important;
|
||
margin: 2mm !important; /* 添加安全边距 */
|
||
}
|
||
|
||
#print-container {
|
||
width: auto !important; /* 改为自动宽度 */
|
||
margin: 0 auto !important; /* 居中显示 */
|
||
font-size: 12pt !important; /* 调整字号 */
|
||
min-height: 297mm;
|
||
}
|
||
|
||
table {
|
||
table-layout: fixed;
|
||
width: 100% !important;
|
||
}
|
||
|
||
/* 优化单元格内边距 */
|
||
td,
|
||
th {
|
||
padding: 4px 2mm !important;
|
||
line-height: 1.4 !important;
|
||
}
|
||
}
|
||
|
||
tr,
|
||
td {
|
||
font-size: 14px !important;
|
||
}
|
||
|
||
#print-container {
|
||
width: 100% !important;
|
||
margin: 0 !important;
|
||
padding: 0 5mm !important;
|
||
font-size: 14pt;
|
||
color: #000 !important;
|
||
font-family: Microsoft YaHei, "SimSun", serif !important;
|
||
}
|
||
/* 添加表格布局优化 */
|
||
table {
|
||
width: 100% !important;
|
||
table-layout: fixed;
|
||
border-collapse: collapse !important;
|
||
|
||
td,
|
||
th {
|
||
padding: 10px 2mm !important;
|
||
word-wrap: break-word;
|
||
}
|
||
}
|
||
</style>
|