Files
klp-mono/apps/hand-factory/pages/meal/meal.vue
砂糖 95332ac8be feat(meal&wms): add employee spicy stats and upgrade version to 1.3.37
1. 升级应用版本号从1.3.34到1.3.37,同步更新配置文件和静态默认版本
2. 新增员工信息API接口,用于获取员工吃辣偏好数据
3. 重构报餐统计页面:
   - 拆分有效/无效/总计三个统计卡片,使用十字交叉表格展示吃辣/不吃辣、堂食/打包的统计数据
   - 新增员工吃辣偏好映射,基于员工信息计算分类统计数据
   - 优化报餐有效性判断逻辑,改为仅比较时分秒匹配PC端逻辑
   - 调整截止时间默认值和同步逻辑
4. 新增APP所需的系统权限模块配置
2026-06-30 11:20:55 +08:00

678 lines
19 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>
<view class="meal-report-container">
<!-- 日期和时间选择区域 -->
<view class="date-time-wrap">
<view class="picker-item">
<text class="picker-label">餐别</text>
<uni-data-select v-model="queryParams.mealType" :localdata="range" @change="getList"></uni-data-select>
</view>
<!-- 报餐日期选择 -->
<view class="picker-item">
<text class="picker-label">报餐日期</text>
<uni-datetime-picker v-model="queryParams.reportDate" type="date" placeholder="选择日期" @change="onDateConfirm"
class="picker-input"></uni-datetime-picker>
</view>
<!-- 自定义截止时间选择 -->
<view class="picker-item">
<text class="picker-label">截止时间</text>
<view class="custom-time-picker" @click="openTimePopup">
<text class="time-text">{{ formattedDeadlineTime }}</text>
<uni-icons type="arrowdown" size="14" color="#999"></uni-icons>
</view>
</view>
</view>
<!-- 卡片1有效报餐 -->
<view class="statistics-card" style="margin-bottom: 15px;">
<view class="card-title"> 有效报餐</view>
<view class="cross-grid">
<!-- 第1行表头 -->
<view class="stats-item cross-header"></view>
<view class="stats-item cross-header">堂食</view>
<view class="stats-item cross-header">打包</view>
<view class="stats-item cross-header">小计</view>
<!-- 第2行吃辣 -->
<view class="stats-item cross-label">🌶 吃辣</view>
<view class="stats-item">{{ ms.validDine }}</view>
<view class="stats-item">{{ ms.validTake }}</view>
<view class="stats-item">{{ ms.validTotal }}</view>
<!-- 第3行不吃辣 -->
<view class="stats-item cross-label">🌿 不吃辣</view>
<view class="stats-item">{{ mn.validDine }}</view>
<view class="stats-item">{{ mn.validTake }}</view>
<view class="stats-item">{{ mn.validTotal }}</view>
<!-- 第4行小计 -->
<view class="stats-item cross-label cross-total">小计</view>
<view class="stats-item cross-total">{{ ma.validDine }}</view>
<view class="stats-item cross-total">{{ ma.validTake }}</view>
<view class="stats-item cross-total">{{ ma.validTotal }}</view>
</view>
</view>
<!-- 卡片2无效报餐 -->
<view class="statistics-card" style="margin-bottom: 15px;">
<view class="card-title"> 无效报餐</view>
<view class="cross-grid">
<!-- 第1行表头 -->
<view class="stats-item cross-header"></view>
<view class="stats-item cross-header">堂食</view>
<view class="stats-item cross-header">打包</view>
<view class="stats-item cross-header">小计</view>
<!-- 第2行吃辣 -->
<view class="stats-item cross-label">🌶 吃辣</view>
<view class="stats-item">{{ ms.invalidDine }}</view>
<view class="stats-item">{{ ms.invalidTake }}</view>
<view class="stats-item">{{ ms.invalidTotal }}</view>
<!-- 第3行不吃辣 -->
<view class="stats-item cross-label">🌿 不吃辣</view>
<view class="stats-item">{{ mn.invalidDine }}</view>
<view class="stats-item">{{ mn.invalidTake }}</view>
<view class="stats-item">{{ mn.invalidTotal }}</view>
<!-- 第4行小计 -->
<view class="stats-item cross-label cross-total">小计</view>
<view class="stats-item cross-total">{{ ma.invalidDine }}</view>
<view class="stats-item cross-total">{{ ma.invalidTake }}</view>
<view class="stats-item cross-total">{{ ma.invalidTotal }}</view>
</view>
</view>
<!-- 卡片3总计 -->
<view class="statistics-card">
<view class="card-title">📊 总计</view>
<view class="cross-grid">
<!-- 第1行表头 -->
<view class="stats-item cross-header"></view>
<view class="stats-item cross-header">堂食</view>
<view class="stats-item cross-header">打包</view>
<view class="stats-item cross-header">小计</view>
<!-- 第2行吃辣 -->
<view class="stats-item cross-label">🌶 吃辣</view>
<view class="stats-item">{{ ms.dine }}</view>
<view class="stats-item">{{ ms.take }}</view>
<view class="stats-item">{{ ms.total }}</view>
<!-- 第3行不吃辣 -->
<view class="stats-item cross-label">🌿 不吃辣</view>
<view class="stats-item">{{ mn.dine }}</view>
<view class="stats-item">{{ mn.take }}</view>
<view class="stats-item">{{ mn.total }}</view>
<!-- 第4行小计 -->
<view class="stats-item cross-label cross-total">小计</view>
<view class="stats-item cross-total">{{ ma.dine }}</view>
<view class="stats-item cross-total">{{ ma.take }}</view>
<view class="stats-item cross-total">{{ ma.total }}</view>
</view>
</view>
<!-- 自定义时分秒选择弹窗 -->
<uni-popup ref="timePopup" type="bottom" border-radius="10px 10px 0 0">
<view class="time-popup-content">
<!-- 弹窗标题 -->
<view class="popup-header">
<text class="popup-title">选择截止时间</text>
<view class="popup-btns">
<button class="cancel-btn" @click="closeTimePopup">取消</button>
<button class="confirm-btn" @click="confirmTime">确认</button>
</view>
</view>
<!-- 时分秒选择区域 -->
<view class="time-select-wrap">
<!-- 小时选择 -->
<view class="time-unit">
<text class="unit-label"></text>
<uni-number-box v-model="timeSelect.hour" :min="0" :max="23" :step="1"
@change="handleTimeChange"></uni-number-box>
</view>
<!-- 分钟选择 -->
<view class="time-unit">
<text class="unit-label"></text>
<uni-number-box v-model="timeSelect.minute" :min="0" :max="59" :step="1"
@change="handleTimeChange"></uni-number-box>
</view>
<!-- 秒选择 -->
<view class="time-unit">
<text class="unit-label"></text>
<uni-number-box v-model="timeSelect.second" :min="0" :max="59" :step="1"
@change="handleTimeChange"></uni-number-box>
</view>
</view>
</view>
</uni-popup>
</view>
</template>
<script>
import {
listMealReport
} from "@/api/wms/mealReport";
import {
listEmployeeInfo
} from "@/api/wms/employeeInfo";
import {
getDicts
} from '@/api/system/dict/data.js'
import {
getConfigKey
} from '@/api/system/config.js'
export default {
name: 'MealReportStatistics',
data() {
return {
queryParams: {
mealType: undefined,
reportDate: '',
deptName: undefined,
reportUserName: undefined,
status: undefined,
deadlineTime: '16:00:00',
pageSize: 9999,
pageNum: 1
},
deadlineDate: '',
deadlineTime: '16:00:00', // 截止时间(原始值)
// 时分秒选择器临时变量
timeSelect: {
hour: 12,
minute: 0,
second: 0
},
list: [],
employeeSpicyMap: {}, // name -> isSpicyEater (1吃辣/0不吃辣)
loading: false,
// 三维交叉统计矩阵(吃辣×打包×有效)
matrix: {
spicy: { validDine: 0, validTake: 0, validTotal: 0, invalidDine: 0, invalidTake: 0, invalidTotal: 0, dine: 0, take: 0, total: 0 },
nonSpicy: { validDine: 0, validTake: 0, validTotal: 0, invalidDine: 0, invalidTake: 0, invalidTotal: 0, dine: 0, take: 0, total: 0 },
all: { validDine: 0, validTake: 0, validTotal: 0, invalidDine: 0, invalidTake: 0, invalidTotal: 0, dine: 0, take: 0, total: 0 }
},
range: []
}
},
computed: {
// 格式化截止时间显示(补零)
formattedDeadlineTime() {
const [hour, minute, second] = this.deadlineTime.split(':');
return `${hour.padStart(2, '0')}:${minute.padStart(2, '0')}:${second.padStart(2, '0')}`;
},
// 从 matrix 对象中提取快捷访问属性(保持模板简洁)
ms() { return this.matrix.spicy; },
mn() { return this.matrix.nonSpicy; },
ma() { return this.matrix.all; }
},
onLoad() {
// 初始化今日日期
this.initTodayDate();
// 初始化截止日期为今日
this.deadlineDate = this.queryParams.reportDate;
// 获取餐别字典数据
this.getRangeData();
// 加载员工信息(含吃辣偏好)
this.getEmployeeList();
// 加载报餐数据
this.getList();
this.getDeadlineConfig();
},
methods: {
/** 初始化今日日期为yyyy-MM-dd格式 */
initTodayDate() {
const today = new Date();
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, '0');
const day = String(today.getDate()).padStart(2, '0');
this.queryParams.reportDate = `${year}-${month}-${day}`;
},
getDeadlineConfig() {
getConfigKey('hrm.meal.deadline').then(response => {
const time = response.msg || '16:00:00';
this.queryParams.deadlineTime = time;
this.deadlineTime = time;
})
},
/** 获取餐别字典数据 */
getRangeData() {
getDicts('hrm_meal_type').then(res => {
this.range = res.data.map(item => ({
text: item.dictLabel,
value: item.dictValue
}));
});
},
/** 构建员工吃辣偏好映射name -> isSpicyEater */
getEmployeeList() {
listEmployeeInfo({ pageSize: 9999, pageNum: 1 }).then(response => {
const map = {};
(response.rows || []).forEach(emp => {
// 仅在职且明确吃辣偏好的员工
if (emp.name && emp.isLeave !== 1 && emp.isSpicyEater !== undefined && emp.isSpicyEater !== null) {
map[emp.name] = emp.isSpicyEater == 1 ? 1 : 0;
}
});
this.employeeSpicyMap = map;
if (this.list.length > 0) {
this.calcTableSum();
}
}).catch(err => {
console.error('获取员工信息失败:', err);
});
},
/** 从逗号分隔的姓名列表中统计不吃辣人数 */
countNonSpicy(nameList) {
if (!nameList) return 0;
const names = nameList.split(',').map(n => n.trim()).filter(n => n);
let count = 0;
const counted = {};
names.forEach(name => {
if (!counted[name] && this.employeeSpicyMap[name] === 0) {
count++;
counted[name] = true;
}
});
return count;
},
/** 三维交叉统计:吃辣/不吃辣 × 堂食/打包 × 有效/无效 */
calcTableSum() {
const m = {
spicy: { validDine: 0, validTake: 0, validTotal: 0, invalidDine: 0, invalidTake: 0, invalidTotal: 0, dine: 0, take: 0, total: 0 },
nonSpicy: { validDine: 0, validTake: 0, validTotal: 0, invalidDine: 0, invalidTake: 0, invalidTotal: 0, dine: 0, take: 0, total: 0 },
all: { validDine: 0, validTake: 0, validTotal: 0, invalidDine: 0, invalidTake: 0, invalidTotal: 0, dine: 0, take: 0, total: 0 }
};
this.list.forEach(item => {
const dine = item.dineInPeople ? Number(item.dineInPeople) : 0;
const take = item.takeoutPeople ? Number(item.takeoutPeople) : 0;
const total = item.totalPeople ? Number(item.totalPeople) : 0;
// 从姓名列表统计不吃辣人数
const nonSpicyDine = this.countNonSpicy(item.dineInPeopleList);
const nonSpicyTake = this.countNonSpicy(item.takeoutPeopleList);
const spicyDine = dine - nonSpicyDine;
const spicyTake = take - nonSpicyTake;
const nonSpicyTotal = nonSpicyDine + nonSpicyTake;
const spicyTotal = spicyDine + spicyTake;
// 有效/无效
const v = this.isValidMealReport(item.createTime) ? 'valid' : 'invalid';
// 吃辣
m.spicy[v + 'Dine'] += spicyDine;
m.spicy[v + 'Take'] += spicyTake;
m.spicy[v + 'Total'] += spicyTotal;
m.spicy.dine += spicyDine;
m.spicy.take += spicyTake;
m.spicy.total += spicyTotal;
// 不吃辣
m.nonSpicy[v + 'Dine'] += nonSpicyDine;
m.nonSpicy[v + 'Take'] += nonSpicyTake;
m.nonSpicy[v + 'Total'] += nonSpicyTotal;
m.nonSpicy.dine += nonSpicyDine;
m.nonSpicy.take += nonSpicyTake;
m.nonSpicy.total += nonSpicyTotal;
// 合计
m.all[v + 'Dine'] += dine;
m.all[v + 'Take'] += take;
m.all[v + 'Total'] += total;
m.all.dine += dine;
m.all.take += take;
m.all.total += total;
});
this.matrix = m;
console.log('【移动端矩阵结果】吃辣总计:', m.spicy.total, '不吃辣总计:', m.nonSpicy.total, '合计:', m.all.total);
console.log('【移动端矩阵明细】吃辣:', JSON.stringify(m.spicy), '不吃辣:', JSON.stringify(m.nonSpicy));
},
/** 查询部门报餐列表 */
getList() {
this.loading = true;
listMealReport(this.queryParams).then(response => {
this.list = response.rows || [];
this.loading = false;
this.calcTableSum();
}).catch(error => {
console.error('获取报餐数据失败:', error);
this.loading = false;
});
},
/** 判断报餐是否有效仅比较时分秒参考PC端逻辑 */
isValidMealReport(createTime) {
if (!createTime || !this.queryParams.deadlineTime) return true; // 无时间默认有效
// 提取报餐时间的时分秒
const reportTime = new Date(createTime);
const reportHms = this.addZero(reportTime.getHours()) + ':' +
this.addZero(reportTime.getMinutes()) + ':' +
this.addZero(reportTime.getSeconds());
// 比较时分秒字符串格式HH:mm:ss可直接字符串比较
return reportHms <= this.queryParams.deadlineTime;
},
/** 数字补零 */
addZero(num) {
return num < 10 ? '0' + num : num;
},
/** 报餐日期选择确认 */
onDateConfirm(e) {
this.queryParams.reportDate = e;
// 如果截止日期未选择,同步为报餐日期
if (!this.deadlineDate) {
this.deadlineDate = e;
}
this.getList();
},
/** 截止日期选择确认 */
onDeadlineDateConfirm(e) {
this.deadlineDate = e;
this.calcTableSum();
},
// ===== 自定义时间选择器逻辑 =====
/** 打开时间选择弹窗 */
openTimePopup() {
// 解析当前时间到选择器
const [hour, minute, second] = this.deadlineTime.split(':').map(Number);
this.timeSelect = {
hour,
minute,
second
};
// 打开弹窗
this.$refs.timePopup.open('center');
},
/** 关闭时间选择弹窗 */
closeTimePopup() {
this.$refs.timePopup.close();
},
/** 处理时分秒数值变化 */
handleTimeChange() {
// 确保数值在合法范围内
this.timeSelect.hour = Math.max(0, Math.min(23, this.timeSelect.hour));
this.timeSelect.minute = Math.max(0, Math.min(59, this.timeSelect.minute));
this.timeSelect.second = Math.max(0, Math.min(59, this.timeSelect.second));
},
/** 确认选择的时间 */
confirmTime() {
// 格式化时分秒(补零)
const hour = String(this.timeSelect.hour).padStart(2, '0');
const minute = String(this.timeSelect.minute).padStart(2, '0');
const second = String(this.timeSelect.second).padStart(2, '0');
// 更新截止时间(同步到 queryParams 供有效性判断使用)
this.deadlineTime = `${hour}:${minute}:${second}`;
this.queryParams.deadlineTime = `${hour}:${minute}:${second}`;
// 重新计算统计数据
this.calcTableSum();
// 关闭弹窗
this.closeTimePopup();
}
}
}
</script>
<style scoped>
/* 容器样式 */
.meal-report-container {
padding: 15px;
background-color: #f5f5f5;
min-height: 100vh;
}
/* 日期时间选择区域 */
.date-time-wrap {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 20px;
background-color: #fff;
padding: 15px;
border-radius: 8px;
}
.picker-item {
display: flex;
align-items: center;
gap: 8px;
}
.picker-label {
font-size: 14px;
color: #333;
min-width: 80px;
}
.picker-input {
flex: 1;
}
/* 自定义时间选择器样式 */
.custom-time-picker {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
border: 1px solid #eee;
border-radius: 4px;
background-color: #fff;
}
.time-text {
font-size: 14px;
color: #333;
}
/* 统计卡片样式 */
.statistics-card {
background-color: #fff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.card-title {
font-size: 16px;
font-weight: 600;
color: #333;
padding: 12px 15px;
border-bottom: 1px solid #eee;
}
/* 网格布局3列 */
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
border: 1px solid #eee;
border-top: none;
}
/* 交叉表网格4列标签 + 堂食 + 打包 + 小计) */
.cross-grid {
display: grid;
grid-template-columns: 70px repeat(3, 1fr);
border: 1px solid #eee;
border-top: none;
}
/* 统计项样式 */
.stats-item {
padding: 12px 8px;
text-align: center;
border-right: 1px solid #eee;
border-bottom: 1px solid #eee;
box-sizing: border-box;
font-size: 14px;
}
/* 3列网格去掉最后一列右边框 */
.stats-grid .stats-item:nth-child(3n) {
border-right: none;
}
/* 3列网格去掉最后一行下边框 */
.stats-grid .stats-item:last-child,
.stats-grid .stats-item:nth-last-child(2),
.stats-grid .stats-item:nth-last-child(3) {
border-bottom: none;
}
/* 4列交叉网格去掉最后一列右边框 */
.cross-grid .stats-item:nth-child(4n) {
border-right: none;
}
/* 4列交叉网格去掉最后一行下边框每行4个最后4个 */
.cross-grid .stats-item:nth-last-child(-n+4) {
border-bottom: none;
}
/* 交叉表行标签(吃辣/不吃辣/小计) */
.cross-label {
display: flex;
align-items: center;
justify-content: center;
gap: 3px;
font-weight: 500;
color: #333;
font-size: 13px;
background-color: #fafafa;
}
/* 交叉表头(堂食/打包/小计) */
.cross-header {
font-weight: 600;
color: #555;
font-size: 12px;
background-color: #f5f7fa;
}
/* 交叉表汇总行背景 */
.cross-total {
background-color: #f9f9f9;
font-weight: 600;
}
/* 标签和值样式 */
.item-label {
display: block;
color: #666;
font-size: 12px;
margin-bottom: 4px;
}
.item-value {
display: block;
color: #333;
font-size: 15px;
font-weight: 500;
}
/* 时间弹窗样式 */
.time-popup-content {
background-color: #fff;
padding-bottom: 20px;
}
.popup-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 15px;
border-bottom: 1px solid #eee;
}
.popup-title {
font-size: 16px;
font-weight: 600;
color: #333;
}
.popup-btns {
display: flex;
gap: 10px;
}
.cancel-btn,
.confirm-btn {
padding: 6px 12px;
font-size: 14px;
border-radius: 4px;
}
.cancel-btn {
background-color: #f5f5f5;
color: #666;
border: 1px solid #eee;
}
.confirm-btn {
background-color: #007aff;
color: #fff;
border: none;
}
/* 时分秒选择区域 */
.time-select-wrap {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 20px 15px;
}
.time-unit {
display: flex;
align-items: center;
gap: 8px;
}
.unit-label {
font-size: 12px;
color: #999;
}
/* 适配小屏手机 */
@media (max-width: 375px) {
.stats-item {
padding: 10px 5px;
}
.item-label {
font-size: 11px;
}
.item-value {
font-size: 14px;
}
.time-select-wrap {
gap: 5px;
padding: 15px 10px;
}
}
</style>