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:
17792275749
2026-08-11 11:40:02 +08:00
co-authored by Claude Opus 4.7
parent 6b6628b2b6
commit 902e3115d8
2 changed files with 133 additions and 56 deletions
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>