feat(mealSurplus): 重构告警逻辑,每个菜品独立30秒闪烁+本地语音播报
- 每个lowStock菜品独立30秒倒计时,30秒内class=lowStock(红色闪烁),超时后class=danger(静态红) - 闪烁动画在border-color和background-color之间交替(danger色 ↔ danger 0.5) - Web Speech API替换为本地音频包(src/assets/audio/mealSurplusInformation.mp3) - 每个菜品首次lowStock播报一次,避免轮询重复播报 - 菜品恢复正常后自动清理标记,再次告警重新计时+播报 - 餐次变化重置所有状态 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
6b6628b2b6
commit
902e3115d8
Binary file not shown.
@@ -8,11 +8,11 @@
|
||||
<div class="content">
|
||||
<div class="content_l">
|
||||
<template v-for="item in nutritionList">
|
||||
<div :class="['content_l_list', item.lowStock ? 'danger' : 'normal']">
|
||||
<div :class="['content_l_list', item.lowStock ? (flashedOut[item.dishName] ? 'danger' : 'lowStock') : 'normal']">
|
||||
<div class="content_l_list_name">
|
||||
<div></div>
|
||||
<div>{{ item.dishName }}</div>
|
||||
<div :class="'content_l_list_name_' + (item.lowStock ? 'danger' : 'normal')"></div>
|
||||
<div :class="'content_l_list_name_' + (item.lowStock ? (flashedOut[item.dishName] ? 'danger' : 'lowStock') : 'normal')"></div>
|
||||
</div>
|
||||
<div class="content_l_list_margin">
|
||||
当前余量
|
||||
@@ -119,72 +119,62 @@ export default {
|
||||
nutritionList: [], // 营养秤实时数据
|
||||
unmadeList: [], // 未制作列表
|
||||
madeList: [], // 已制作列表
|
||||
speechQueue: [], // 语音播报队列 [{dishName, spoken}]
|
||||
speaking: false, // 是否正在播报
|
||||
warningAudio: null, // 预加载的告警音频实例
|
||||
flashTimers: {}, // 每个菜品独立的30秒定时器 { dishName: timerId }
|
||||
flashedOut: {}, // 已过30秒的菜品 { dishName: true }
|
||||
announcedDishes: {}, // 已播报过的菜品 { dishName: true }
|
||||
}
|
||||
},
|
||||
created() {
|
||||
// 预加载告警音频
|
||||
try {
|
||||
this.warningAudio = new Audio(require('@/assets/audio/mealSurplusInformation.mp3'));
|
||||
this.warningAudio.load();
|
||||
} catch (e) {
|
||||
console.error('[Audio] 初始化失败:', e);
|
||||
}
|
||||
|
||||
this.getKitchenInfo();
|
||||
},
|
||||
watch: {
|
||||
mealTypeName(newVal, oldVal) {
|
||||
if (newVal && newVal !== oldVal) {
|
||||
// 餐次变化,重置所有菜品计时器和播报记录
|
||||
Object.values(this.flashTimers).forEach(clearTimeout);
|
||||
this.flashTimers = {};
|
||||
this.flashedOut = {};
|
||||
this.announcedDishes = {};
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// 语音播报:Web Speech API 依次播报队列中未播报的菜品
|
||||
speakWarning() {
|
||||
if (this.speaking) return;
|
||||
const unspoken = this.speechQueue.filter(item => !item.spoken);
|
||||
if (!unspoken.length) return;
|
||||
// 播放本地语音告警(仅在30秒窗口内、且未播报过的菜品才触发)
|
||||
playWarningAudio() {
|
||||
const newItems = this.nutritionList.filter(
|
||||
item => item.lowStock && !this.flashedOut[item.dishName] && !this.announcedDishes[item.dishName]
|
||||
);
|
||||
if (!newItems.length) return;
|
||||
|
||||
this.speaking = true;
|
||||
let index = 0;
|
||||
if (!this.warningAudio) return;
|
||||
|
||||
const utterance = new SpeechSynthesisUtterance();
|
||||
utterance.lang = 'zh-CN';
|
||||
utterance.rate = 0.9;
|
||||
utterance.volume = 1;
|
||||
// 标记已播报(电视端由 WebView/Kiosk 配置允许自动播放)
|
||||
newItems.forEach(item => {
|
||||
this.$set(this.announcedDishes, item.dishName, true);
|
||||
});
|
||||
|
||||
const playNext = () => {
|
||||
if (index >= unspoken.length) {
|
||||
this.speaking = false;
|
||||
return;
|
||||
}
|
||||
const item = unspoken[index];
|
||||
index++;
|
||||
utterance.text = `${item.dishName}、余量不足,请注意补餐`;
|
||||
console.log('[TTS] 开始播报:', utterance.text);
|
||||
utterance.onend = () => {
|
||||
item.spoken = true;
|
||||
console.log('[TTS] 播报完成:', item.dishName);
|
||||
playNext();
|
||||
};
|
||||
utterance.onerror = (e) => {
|
||||
console.error('[TTS] 播报错误:', e.error);
|
||||
item.spoken = true;
|
||||
playNext();
|
||||
};
|
||||
speechSynthesis.speak(utterance);
|
||||
};
|
||||
|
||||
playNext();
|
||||
this.warningAudio.currentTime = 0;
|
||||
this.warningAudio.play().catch(e => {
|
||||
console.error('[Audio] 播放失败,请检查设备自动播放策略:', e.message);
|
||||
});
|
||||
},
|
||||
getKitchenInfo() {
|
||||
var params = {};
|
||||
var _this = this;
|
||||
|
||||
let date = new Date();
|
||||
let year = date.getFullYear();
|
||||
let month = date.getMonth() + 1;
|
||||
let day = date.getDate();
|
||||
let hour = date.getHours();
|
||||
let minute = date.getMinutes();
|
||||
let second = date.getSeconds();
|
||||
const times = "现在是:" + year + "年" + month + "月" + day + "号," + hour + "点" + minute + "分" + second + "秒";
|
||||
_this.speechQueue.push({dishName: times, spoken: false});
|
||||
|
||||
httpRequest("GET", "https://dev.yixiong-tech.com:8081/nutrition/neglect/large-screen/kitchen", params, {
|
||||
"X-DEVICE-CODE": "XIAOMI-00999",
|
||||
"authorization": "57ee87183f2a4fa59683ec9ef41c8f5d"
|
||||
}).then(function(res) {
|
||||
_this.mealTypeName = "";
|
||||
_this.mealTime = "";
|
||||
_this.nutritionList = [];
|
||||
_this.unmadeList = [];
|
||||
_this.madeList = [];
|
||||
@@ -215,13 +205,38 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
// 余量不足的菜品直接推入播报队列
|
||||
_this.nutritionList.forEach(function(item) {
|
||||
if (item.lowStock) {
|
||||
_this.speechQueue.push({dishName: item.dishName, spoken: false});
|
||||
// 清理已恢复正常菜品的所有定时器和标记(以便再次告警时重新闪烁+播报)
|
||||
var currentLowStockNames = _this.nutritionList
|
||||
.filter(function(item) { return item.lowStock; })
|
||||
.map(function(item) { return item.dishName; });
|
||||
|
||||
Object.keys(_this.flashTimers).forEach(function(dishName) {
|
||||
if (currentLowStockNames.indexOf(dishName) === -1) {
|
||||
clearTimeout(_this.flashTimers[dishName]);
|
||||
_this.$delete(_this.flashTimers, dishName);
|
||||
}
|
||||
});
|
||||
_this.speakWarning();
|
||||
Object.keys(_this.flashedOut).forEach(function(dishName) {
|
||||
if (currentLowStockNames.indexOf(dishName) === -1) {
|
||||
_this.$delete(_this.flashedOut, dishName);
|
||||
}
|
||||
});
|
||||
Object.keys(_this.announcedDishes).forEach(function(dishName) {
|
||||
if (currentLowStockNames.indexOf(dishName) === -1) {
|
||||
_this.$delete(_this.announcedDishes, dishName);
|
||||
}
|
||||
});
|
||||
// 为每个新出现的lowStock菜品启动独立30秒计时器
|
||||
_this.nutritionList.forEach(function(item) {
|
||||
if (item.lowStock && !_this.flashedOut[item.dishName] && !_this.flashTimers[item.dishName]) {
|
||||
_this.$set(_this.flashTimers, item.dishName, setTimeout(function() {
|
||||
_this.$set(_this.flashedOut, item.dishName, true);
|
||||
_this.$delete(_this.flashTimers, item.dishName);
|
||||
}, 30000));
|
||||
}
|
||||
});
|
||||
// 有菜品在闪烁窗口内才播报语音
|
||||
_this.playWarningAudio();
|
||||
|
||||
setTimeout(function() {
|
||||
_this.getKitchenInfo();
|
||||
@@ -244,6 +259,10 @@ export default {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
Object.values(this.flashTimers).forEach(clearTimeout);
|
||||
this.flashTimers = {};
|
||||
this.flashedOut = {};
|
||||
this.announcedDishes = {};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -506,6 +525,56 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
&.lowStock {
|
||||
background-color: #220203;
|
||||
border: 2px solid #FF3232;
|
||||
animation: lowStockFlash .3s infinite alternate;
|
||||
|
||||
.content_l_list_name {
|
||||
> div:nth-of-type(1) {
|
||||
background-color: #FF3232;
|
||||
}
|
||||
|
||||
> div:nth-of-type(2) {
|
||||
color: #FFC8C8;
|
||||
}
|
||||
|
||||
.content_l_list_name_lowStock {
|
||||
background-image: url("@/assets/images/mealSurplusInformation/danger_name_icon.png");
|
||||
}
|
||||
}
|
||||
|
||||
.content_l_list_margin {
|
||||
color: #FFC8C8;
|
||||
|
||||
span {
|
||||
color: #FF3232;
|
||||
}
|
||||
}
|
||||
|
||||
.content_l_list_status_ {
|
||||
width: 200px;
|
||||
background-color: #5A0A0A;
|
||||
animation: lowStockStatusFlash .3s infinite alternate;
|
||||
|
||||
.content_l_list_status_icon {
|
||||
background-image: url("@/assets/images/mealSurplusInformation/danger_status_icon.png");
|
||||
}
|
||||
|
||||
.content_l_list_status_text {
|
||||
color: #FF6464;
|
||||
}
|
||||
}
|
||||
|
||||
.content_l_list_progress {
|
||||
background-color: rgba(200, 50, 50, .3);
|
||||
|
||||
> div {
|
||||
background-color: #FF3232;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.noData {
|
||||
background-color: #0F1A29;
|
||||
border: 2px solid #263249;
|
||||
@@ -529,7 +598,7 @@ export default {
|
||||
line-height: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -640,4 +709,12 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
@keyframes lowStockFlash {
|
||||
from { border-color: #FF3232; background-color: #220203; }
|
||||
to { border-color: #801919; background-color: #110102; }
|
||||
}
|
||||
@keyframes lowStockStatusFlash {
|
||||
from { background-color: #5A0A0A; }
|
||||
to { background-color: #2D0505; }
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user