Files
deShanXiao/frontEnd/src/pages/statistics/sales/sales.vue

499 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>