初始版本,目前线上可用

This commit is contained in:
2025-11-19 12:49:16 +08:00
commit cb7f1c45e8
178 changed files with 30336 additions and 0 deletions

View File

@@ -0,0 +1,498 @@
<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>