Преглед на файлове

bug修复和功能优化

徐滕 преди 3 седмици
родител
ревизия
975fef20ac

+ 109 - 40
forge-admin-ui/src/composables/useOrderNotification.js

@@ -7,14 +7,22 @@ import { useAuthStore } from '@/store'
  *
  * @param {object} options 回调选项
  * @param {Function} options.onNewOrder     收到新订单通知时的回调
+ * @param {Function} options.onOrderAccepted 收到接单通知时的回调(厨房端语音播报)
  * @param {Function} options.onRefundRequest 收到退单申请通知时的回调
  * @param {Function} options.onOrderReady   收到出餐完成通知时的回调
+ * @param {boolean}  [options.disableSound]  是否禁用声音提醒(默认 false)
+ * @param {string[]} [options.silentEvents]  静默事件列表,这些事件不触发声音/通知/页面弹窗,仅执行回调
+ *                                           可选值:'order-accepted', 'order-ready'
  * @returns {object} { connected, connect, disconnect }
  */
 export function useOrderNotification(options = {}) {
   const connected = ref(false)
   let eventSource = null
   let clientId = null
+  // 是否禁用声音提醒(PC 管理端等不需要声音的场景可设为 true)
+  const disableSound = options.disableSound === true
+  // 静默事件列表:这些事件不触发声音/通知/页面弹窗,仅执行回调
+  const silentEvents = new Set(options.silentEvents || [])
 
   // 将 eventSource 暴露到全局 window 对象,方便调试
   if (typeof window !== 'undefined') {
@@ -28,6 +36,9 @@ export function useOrderNotification(options = {}) {
   let mergeTimer = null
   const MERGE_WINDOW = 3000 // 3秒防抖窗口
 
+  // 接单通知队列
+  const orderAcceptedQueue = ref([])
+
   /**
    * 触发防抖合并通知。
    * 3秒内的多个同类型通知合并为一条提示。
@@ -62,6 +73,24 @@ export function useOrderNotification(options = {}) {
       }
     }
 
+    if (orderAcceptedQueue.value.length > 0) {
+      const count = orderAcceptedQueue.value.length
+      const lastAccepted = orderAcceptedQueue.value[orderAcceptedQueue.value.length - 1]
+      // 静默事件不显示页面内通知,仅执行回调
+      if (!silentEvents.has('order-accepted')) {
+        if (count === 1) {
+          showInPageNotification('order-accepted', `房间 ${lastAccepted.roomNo} 订单已接单,请开始备餐`, 'info')
+        }
+        else {
+          showInPageNotification('order-accepted', `您有 ${count} 个订单已接单,请开始备餐`, 'info')
+        }
+      }
+      orderAcceptedQueue.value = []
+      if (typeof options.onOrderAccepted === 'function') {
+        options.onOrderAccepted(lastAccepted)
+      }
+    }
+
     if (refundQueue.value.length > 0) {
       const count = refundQueue.value.length
       const lastRefund = refundQueue.value[refundQueue.value.length - 1]
@@ -80,11 +109,14 @@ export function useOrderNotification(options = {}) {
     if (orderReadyQueue.value.length > 0) {
       const count = orderReadyQueue.value.length
       const lastReady = orderReadyQueue.value[orderReadyQueue.value.length - 1]
-      if (count === 1) {
-        showInPageNotification('order-ready', `房间 ${lastReady.roomNo} 订单已出餐,请及时配送`, 'success')
-      }
-      else {
-        showInPageNotification('order-ready', `您有 ${count} 个订单已出餐,请及时配送`, 'success')
+      // 静默事件不显示页面内通知,仅执行回调
+      if (!silentEvents.has('order-ready')) {
+        if (count === 1) {
+          showInPageNotification('order-ready', `房间 ${lastReady.roomNo} 订单已出餐,请及时配送`, 'success')
+        }
+        else {
+          showInPageNotification('order-ready', `您有 ${count} 个订单已出餐,请及时配送`, 'success')
+        }
       }
       orderReadyQueue.value = []
       if (typeof options.onOrderReady === 'function') {
@@ -94,21 +126,22 @@ export function useOrderNotification(options = {}) {
   }
 
   // ==================== 声音提醒(使用 MP3 文件) ====================
-  
+
   // MP3 语音文件路径(放在 public 目录)
   const ORDER_ALERT_MP3 = '/女生-订单提醒.mp3'
-  
+
   // 用户交互状态:页面加载后首次用户交互前,音频播放会被 Chrome 阻止
   let userInteracted = false
   // 待播放队列:用户交互前尝试播放失败的声音,在首次交互后补播
   const pendingSounds = []
-  
+
   /**
    * 注册首次用户交互监听器。
    * 用户首次点击或按键后,播放队列中积压的声音。
    */
   function setupUserInteractionListener() {
-    if (userInteracted) return
+    if (userInteracted)
+      return
     const onInteract = () => {
       userInteracted = true
       console.warn('[SSE] 用户交互触发,开始播放积压的声音')
@@ -124,13 +157,16 @@ export function useOrderNotification(options = {}) {
     document.addEventListener('click', onInteract, { once: true })
     document.addEventListener('keydown', onInteract, { once: true })
   }
-  
+
   /**
    * 播放提示音(使用 MP3 文件)。
    * 如果用户尚未交互(Chrome 自动播放策略),会延迟到首次交互后播放。
    * @param {string} type 类型:'new-order' 或 'refund'
    */
   function playSound(type) {
+    // 禁用声音时直接跳过
+    if (disableSound)
+      return
     console.warn(`[SSE] 📢 开始播放声音: type=${type}, userInteracted=${userInteracted}`)
     try {
       if (type === 'new-order') {
@@ -148,7 +184,7 @@ export function useOrderNotification(options = {}) {
       void err
     }
   }
-  
+
   /**
    * 播放 MP3 音频文件。
    * 如果因 Chrome 自动播放策略失败,会加入待播放队列,等用户首次交互后补播。
@@ -160,7 +196,7 @@ export function useOrderNotification(options = {}) {
     const audio = new Audio(mp3Path)
     audio.volume = 0.8
     console.warn(`[SSE] Audio 对象已创建, volume=${audio.volume}`)
-  
+
     // 监听播放事件
     audio.addEventListener('canplaythrough', () => {
       console.warn('[SSE] 音频可以播放了')
@@ -174,7 +210,7 @@ export function useOrderNotification(options = {}) {
     audio.addEventListener('error', (e) => {
       console.error('[SSE] 音频加载/播放错误:', e)
     })
-  
+
     const playPromise = audio.play()
     if (playPromise !== undefined) {
       playPromise.then(() => {
@@ -269,7 +305,7 @@ export function useOrderNotification(options = {}) {
     console.warn('[SSE] ========== connect() 函数被调用 ==========')
     console.warn('[SSE] 当前时间:', new Date().toLocaleString())
     console.warn('[SSE] eventSource 是否存在:', !!eventSource)
-    
+
     if (eventSource) {
       console.warn('[SSE] ⚠️ eventSource 已存在,跳过连接')
       return
@@ -292,7 +328,8 @@ export function useOrderNotification(options = {}) {
     try {
       eventSource = new EventSource(url)
       console.warn('[SSE] ✅ EventSource 创建成功')
-    } catch (err) {
+    }
+    catch (err) {
       console.error('[SSE] ❌ EventSource 创建失败:', err)
       return
     }
@@ -317,8 +354,7 @@ export function useOrderNotification(options = {}) {
         console.warn('[SSE] 解析数据:', data)
         const orderData = data.data || {}
         console.warn('[SSE] 订单数据:', orderData)
-        // 播放声音
-        console.warn('[SSE] 调用 playSound("new-order")')
+        // 播放声音(disableSound 时自动跳过)
         playSound('new-order')
         // 桌面通知
         showDesktopNotification('新订单提醒', `房间 ${orderData.roomNo} 有新订单,金额 ¥${orderData.totalAmount}`)
@@ -333,12 +369,33 @@ export function useOrderNotification(options = {}) {
       }
     })
 
+    // 接单通知(餐厅前台接单后,厨房端收到)
+    // 如果调用方将此事件列入 silentEvents,则不触发声音/通知,仅执行回调
+    // 注意:此事件默认不播放 MP3 提示音,由调用方的 onOrderAccepted 回调负责语音播报
+    eventSource.addEventListener('ORDER_ACCEPTED', (event) => {
+      try {
+        const data = JSON.parse(event.data)
+        const orderData = data.data || {}
+        const isSilent = silentEvents.has('order-accepted')
+        if (!isSilent) {
+          // 桌面通知(可选,厨房端可能需要看到弹窗)
+          showDesktopNotification('订单已接单', `房间 ${orderData.roomNo} 订单已接单,请开始备餐`)
+        }
+        // 加入防抖队列
+        orderAcceptedQueue.value.push(orderData)
+        scheduleMergeFlush()
+      }
+      catch (e) {
+        console.warn('[SSE] 解析ORDER_ACCEPTED消息失败:', e)
+      }
+    })
+
     // 退单申请通知
     eventSource.addEventListener('REFUND_REQUEST', (event) => {
       try {
         const data = JSON.parse(event.data)
         const orderData = data.data || {}
-        // 播放声音
+        // 播放声音(disableSound 时自动跳过)
         playSound('refund')
         // 桌面通知
         showDesktopNotification('退单申请', `房间 ${orderData.roomNo} 申请退单`)
@@ -351,18 +408,27 @@ export function useOrderNotification(options = {}) {
       }
     })
 
-    // 出餐完成通知(厨房点击"备餐完成·出餐"后触发)
+    // 出餐完成通知(厨房点击“备餐完成·出餐”后触发)
+    // 如果调用方将此事件列入 silentEvents,则不触发语音/通知,仅执行回调
     eventSource.addEventListener('ORDER_READY', (event) => {
+      console.log('[SSE] ✅ ORDER_READY 事件触发:', event.data)
       try {
         const data = JSON.parse(event.data)
         const orderData = data.data || {}
-        // 语音播报:XX房间订单已出餐,请及时配送(不再播放MP3提示音,避免重复)
-        speakOrderReady(orderData)
-        // 桌面通知
-        showDesktopNotification('出餐完成', `房间 ${orderData.roomNo} 订单已出餐,请及时配送`)
+        console.log('[SSE] ORDER_READY 数据解析:', orderData)
+        const isSilent = silentEvents.has('order-ready')
+        console.log('[SSE] ORDER_READY 是否静默:', isSilent)
+        if (!isSilent) {
+          // 语音播报:XX房间订单已出餐,请及时配送(不再播放MP3提示音,避免重复)
+          speakOrderReady(orderData)
+          // 桌面通知
+          showDesktopNotification('出餐完成', `房间 ${orderData.roomNo} 订单已出餐,请及时配送`)
+        }
         // 加入防抖队列
         orderReadyQueue.value.push(orderData)
+        console.log('[SSE] ORDER_READY 加入队列,当前队列长度:', orderReadyQueue.value.length)
         scheduleMergeFlush()
+        console.log('[SSE] ORDER_READY 已调度 flush,将在 3 秒后执行')
       }
       catch (e) {
         console.warn('[SSE] 解析ORDER_READY消息失败:', e)
@@ -377,27 +443,30 @@ export function useOrderNotification(options = {}) {
 
     // 请求桌面通知权限
     requestNotificationPermission()
-    
+
     // 预加载音频:用户首次点击页面时播放一次 MP3,
     // 绕过 Chrome 自动播放策略,确保后续 SSE 推送时能正常发声
-    function initAudioOnFirstInteraction() {
-      console.warn('[SSE] 用户交互触发,开始预加载音频...')
-      const audio = new Audio(ORDER_ALERT_MP3)
-      audio.volume = 0
-      const playPromise = audio.play()
-      if (playPromise !== undefined) {
-        playPromise.then(() => {
-          console.warn('[SSE] ✅ 音频已预加载(用户交互触发)')
-        }).catch((err) => {
-          console.error('[SSE] ❌ 音频预加载失败:', err)
-          console.error('[SSE] 错误详情 - name:', err.name, ', message:', err.message)
-        })
+    // disableSound 时跳过预加载
+    if (!disableSound) {
+      function initAudioOnFirstInteraction() {
+        console.warn('[SSE] 用户交互触发,开始预加载音频...')
+        const audio = new Audio(ORDER_ALERT_MP3)
+        audio.volume = 0
+        const playPromise = audio.play()
+        if (playPromise !== undefined) {
+          playPromise.then(() => {
+            console.warn('[SSE] ✅ 音频已预加载(用户交互触发)')
+          }).catch((err) => {
+            console.error('[SSE] ❌ 音频预加载失败:', err)
+            console.error('[SSE] 错误详情 - name:', err.name, ', message:', err.message)
+          })
+        }
+        document.removeEventListener('click', initAudioOnFirstInteraction)
+        document.removeEventListener('keydown', initAudioOnFirstInteraction)
       }
-      document.removeEventListener('click', initAudioOnFirstInteraction)
-      document.removeEventListener('keydown', initAudioOnFirstInteraction)
+      document.addEventListener('click', initAudioOnFirstInteraction)
+      document.addEventListener('keydown', initAudioOnFirstInteraction)
     }
-    document.addEventListener('click', initAudioOnFirstInteraction)
-    document.addEventListener('keydown', initAudioOnFirstInteraction)
   }
 
   /**

+ 245 - 58
forge-admin-ui/src/views/hotel/kitchenOrder.vue

@@ -28,6 +28,21 @@
       </div>
     </div>
 
+    <!-- ==================== 新订单语音播报栏 ==================== -->
+    <div v-if="latestPendingOrder" class="voice-broadcast-banner mb-4">
+      <div class="banner-content flex items-center gap-4">
+        <i class="i-mdi:volume-high text-3xl text-white animate-pulse" />
+        <div class="flex-1">
+          <div class="text-sm text-white/80 mb-1 font-medium">
+            新订单语音播报
+          </div>
+          <div class="text-base text-white font-semibold">
+            [{{ latestPendingOrder.roomNo }}房间, {{ formatItemsBrief(latestPendingOrder.items) }}]
+          </div>
+        </div>
+      </div>
+    </div>
+
     <!-- 统计卡片 -->
     <div class="stats-row mb-4 flex gap-4">
       <div class="stat-card stat-pending">
@@ -49,19 +64,20 @@
     <div v-if="pendingOrders.length > 0" class="order-section">
       <h3 class="section-title">
         <span class="section-dot pending" />
-        待备餐
+        待制作(按时间排序)
         <span class="section-count">{{ pendingOrders.length }}</span>
       </h3>
       <div class="order-list">
         <div
-          v-for="order in pendingOrders"
+          v-for="(order, index) in pendingOrders"
           :key="order.id"
           class="order-row row-pending"
+          :class="{ 'first-order-zoom': index === 0 }"
           @click="openDetail(order)"
         >
           <div class="row-color-bar" />
           <div class="row-content flex-1">
-            <div class="row-header flex items-center justify-between mb-2">
+            <div class="row-header mb-2 flex items-center justify-between">
               <div class="flex items-center gap-3">
                 <span class="room-no">{{ order.roomNo || '--' }}</span>
                 <span class="order-no">{{ order.orderNo }}</span>
@@ -80,7 +96,9 @@
           </div>
           <div class="row-actions">
             <div class="action-wrapper">
-              <NButton type="warning" size="medium" @click.stop="handlePrepare(order)">开始备餐</NButton>
+              <NButton type="warning" size="medium" @click.stop="handlePrepare(order)">
+                开始备餐
+              </NButton>
             </div>
           </div>
         </div>
@@ -103,7 +121,7 @@
         >
           <div class="row-color-bar" />
           <div class="row-content flex-1">
-            <div class="row-header flex items-center justify-between mb-2">
+            <div class="row-header mb-2 flex items-center justify-between">
               <div class="flex items-center gap-3">
                 <span class="room-no">{{ order.roomNo || '--' }}</span>
                 <span class="order-no">{{ order.orderNo }}</span>
@@ -122,7 +140,9 @@
           </div>
           <div class="row-actions">
             <div class="action-wrapper">
-              <NButton type="success" size="medium" @click.stop="handleReady(order)">备餐完成·出餐</NButton>
+              <NButton type="success" size="medium" @click.stop="handleReady(order)">
+                备餐完成·出餐
+              </NButton>
             </div>
           </div>
         </div>
@@ -145,7 +165,7 @@
         >
           <div class="row-color-bar" />
           <div class="row-content flex-1">
-            <div class="row-header flex items-center justify-between mb-2">
+            <div class="row-header mb-2 flex items-center justify-between">
               <div class="flex items-center gap-3">
                 <span class="room-no">{{ order.roomNo || '--' }}</span>
                 <span class="order-no">{{ order.orderNo }}</span>
@@ -187,19 +207,19 @@
     >
       <div v-if="detailOrder" class="receipt-full">
         <!-- 顶部深绿色标题栏 -->
-        <div class="receipt-header-bar mb-6 rounded-t-lg p-4 flex items-center justify-between">
+        <div class="receipt-header-bar mb-6 flex items-center justify-between rounded-t-lg p-4">
           <div class="flex items-center gap-3">
             <i class="i-mdi:arrow-left text-xl cursor-pointer hover:text-white/80" @click="showDetail = false" />
-            <span class="text-white text-base font-semibold">订单详情</span>
+            <span class="text-base text-white font-semibold">订单详情</span>
           </div>
         </div>
 
         <!-- 房间号大图标区域 -->
-        <div class="room-hero-section text-center mb-6">
-          <div class="room-icon-wrapper inline-flex items-center justify-center w-16 h-16 rounded-full bg-green-100 mb-3">
+        <div class="room-hero-section mb-6 text-center">
+          <div class="room-icon-wrapper mb-3 h-16 w-16 inline-flex items-center justify-center rounded-full bg-green-100">
             <i class="i-mdi:home-city text-3xl text-green-600" />
           </div>
-          <div class="room-number text-5xl font-bold text-gray-800 mb-2">
+          <div class="room-number mb-2 text-5xl text-gray-800 font-bold">
             {{ detailOrder.roomNo || '--' }}
           </div>
           <div class="room-meta text-sm text-gray-500">
@@ -208,12 +228,14 @@
         </div>
 
         <!-- 语音播报区 -->
-        <div class="voice-broadcast-section mb-6 rounded-lg bg-green-50 border border-green-200 p-4 flex items-center justify-between">
-          <div class="flex items-center gap-3 flex-1 min-w-0">
-            <i class="i-mdi:volume-high text-2xl text-green-600 flex-shrink-0" />
+        <div class="voice-broadcast-section mb-6 flex items-center justify-between border border-green-200 rounded-lg bg-green-50 p-4">
+          <div class="min-w-0 flex flex-1 items-center gap-3">
+            <i class="i-mdi:volume-high flex-shrink-0 text-2xl text-green-600" />
             <div class="min-w-0">
-              <div class="text-xs text-green-700 font-medium mb-1">语音播报</div>
-              <div class="text-sm text-gray-700 truncate">
+              <div class="text-xs mb-1 text-green-700 font-medium">
+                语音播报
+              </div>
+              <div class="text-sm truncate text-gray-700">
                 {{ detailOrder.roomNo }}房间 · {{ formatItemsBrief(detailOrder.items) }}
               </div>
             </div>
@@ -227,20 +249,26 @@
         </div>
 
         <!-- 小票主体(虚线边框) -->
-        <div class="receipt-body mb-6 rounded-lg border-2 border-dashed border-gray-300 p-6 bg-white">
+        <div class="receipt-body mb-6 border-2 border-gray-300 rounded-lg border-dashed bg-white p-6">
           <!-- 小票头部 -->
-          <div class="receipt-title text-center mb-4 pb-4 border-b border-dashed border-gray-300">
-            <div class="text-lg font-bold text-gray-800 mb-1">璞石酒店 · 厨房联</div>
-            <div class="text-sm text-gray-500">{{ detailOrder.orderNo }}</div>
-            <div class="text-xs text-gray-400 mt-1">{{ formatDateTime(detailOrder.createTime) }}</div>
+          <div class="receipt-title mb-4 border-b border-gray-300 border-dashed pb-4 text-center">
+            <div class="text-lg mb-1 text-gray-800 font-bold">
+              璞石酒店 · 厨房联
+            </div>
+            <div class="text-sm text-gray-500">
+              {{ detailOrder.orderNo }}
+            </div>
+            <div class="text-xs mt-1 text-gray-400">
+              {{ formatDateTime(detailOrder.createTime) }}
+            </div>
           </div>
 
           <!-- 桌号与备注 -->
-          <div class="receipt-info mb-4 pb-4 border-b border-dashed border-gray-300">
-            <div class="flex items-center gap-2 mb-2">
+          <div class="receipt-info mb-4 border-b border-gray-300 border-dashed pb-4">
+            <div class="mb-2 flex items-center gap-2">
               <span class="text-sm text-gray-600">桌号:</span>
-              <span class="text-base font-semibold text-gray-800">{{ detailOrder.roomNo }}</span>
-              <span class="text-sm text-gray-600 ml-2">{{ detailOrder.roomType || '豪华大床房' }}</span>
+              <span class="text-base text-gray-800 font-semibold">{{ detailOrder.roomNo }}</span>
+              <span class="text-sm ml-2 text-gray-600">{{ detailOrder.roomType || '豪华大床房' }}</span>
             </div>
             <div class="flex items-center gap-2">
               <span class="text-sm text-gray-600">备注:</span>
@@ -249,38 +277,38 @@
           </div>
 
           <!-- 菜品明细 -->
-          <div class="receipt-items mb-4 pb-4 border-b border-dashed border-gray-300">
+          <div class="receipt-items mb-4 border-b border-gray-300 border-dashed pb-4">
             <div v-for="(item, idx) in (detailOrder.items || [])" :key="item.id" class="receipt-item-row mb-3 last:mb-0">
-              <div class="item-header flex justify-between items-start mb-1">
+              <div class="item-header mb-1 flex items-start justify-between">
                 <div class="flex-1">
-                  <span class="item-index text-sm text-gray-500 mr-2">[{{ idx + 1 }}]</span>
-                  <span class="item-name text-base font-medium text-gray-800">{{ item.dishName }}</span>
+                  <span class="item-index text-sm mr-2 text-gray-500">[{{ idx + 1 }}]</span>
+                  <span class="item-name text-base text-gray-800 font-medium">{{ item.dishName }}</span>
                 </div>
-                <span class="item-price text-right font-semibold text-gray-800 whitespace-nowrap ml-3">
+                <span class="item-price ml-3 whitespace-nowrap text-right text-gray-800 font-semibold">
                   ¥{{ item.subtotal || '0.00' }}
                 </span>
               </div>
               <!-- 规格与加料 -->
-              <div v-if="item.specDesc || item.additionsDesc" class="item-details text-xs text-gray-500 ml-6">
+              <div v-if="item.specDesc || item.additionsDesc" class="item-details text-xs ml-6 text-gray-500">
                 <span v-if="item.specDesc">{{ item.specDesc }}</span>
                 <span v-if="item.specDesc && item.additionsDesc"> / </span>
                 <span v-if="item.additionsDesc">{{ item.additionsDesc }}</span>
               </div>
               <!-- 单条备注 -->
-              <div v-if="item.note" class="item-note text-xs text-orange-600 ml-6 mt-1">
+              <div v-if="item.note" class="item-note text-xs ml-6 mt-1 text-orange-600">
                 备注: {{ item.note }}
               </div>
             </div>
           </div>
 
           <!-- 合计 -->
-          <div class="receipt-total text-right text-lg font-bold text-gray-800">
+          <div class="receipt-total text-lg text-right text-gray-800 font-bold">
             合计:¥{{ detailOrder.totalAmount || '0.00' }}
           </div>
 
           <!-- 自动打印时间 -->
-          <div class="receipt-footer text-center mt-4 pt-4 border-t border-dashed border-gray-300">
-            <div class="text-xs text-gray-400 flex items-center justify-center gap-1">
+          <div class="receipt-footer mt-4 border-t border-gray-300 border-dashed pt-4 text-center">
+            <div class="text-xs flex items-center justify-center gap-1 text-gray-400">
               <i class="i-mdi:printer" />
               自动打印 · {{ formatDateTime(detailOrder.createTime) }}
             </div>
@@ -324,7 +352,7 @@
           >
             已出餐,等待配送
           </NButton>
-          <div class="action-hint text-center text-xs text-gray-400 mt-2">
+          <div class="action-hint text-xs mt-2 text-center text-gray-400">
             点击后将通知餐厅前台安排送餐
           </div>
         </div>
@@ -345,6 +373,8 @@ import {
 import { useOrderNotification } from '@/composables/useOrderNotification'
 
 const message = useMessage()
+// 使用全局 $notification(与 restaurantOrder.vue 保持一致)
+const $notification = window.$notification
 
 // ==================== 数据状态 ====================
 const orderList = ref([])
@@ -352,17 +382,27 @@ const stats = ref({ pendingCount: 0, preparingCount: 0, todayCompletedCount: 0 }
 const showDetail = ref(false)
 const detailOrder = ref(null)
 
+// 标记是否首次加载(用于控制首次加载时播放语音提示)
+let isFirstLoad = true
+
 // ==================== 分组过滤 ====================
 const pendingOrders = computed(() => orderList.value.filter(o => o.status === 2))
 const preparingOrders = computed(() => orderList.value.filter(o => o.status === 3))
+// 已完成:包含 status >= 4 的订单(已出餐、配送中、已完成),与后端 kitchenStats 统计逻辑保持一致
 const completedOrders = computed(() => orderList.value.filter(o => o.status >= 4))
 
+// 最新待备餐订单(用于顶部语音播报栏)
+const latestPendingOrder = computed(() => {
+  return pendingOrders.value.length > 0 ? pendingOrders.value[0] : null
+})
+
 // 日期筛选(默认今天,NDatePicker 使用毫秒时间戳)
 const selectedDate = ref(Date.now())
 
 /** 将毫秒时间戳格式化为 yyyy-MM-dd */
 function formatDateParam(ts) {
-  if (!ts) return null
+  if (!ts)
+    return null
   const d = new Date(ts)
   const yyyy = d.getFullYear()
   const mm = String(d.getMonth() + 1).padStart(2, '0')
@@ -375,13 +415,43 @@ function onDateChange() {
 }
 
 // ==================== SSE 通知 ====================
+// 厨房端监听 NEW_ORDER 和 ORDER_ACCEPTED
+// ORDER_READY 是餐厅端专用事件(出餐完成通知前厅配送),厨房端静默处理
 const { connected, connect, disconnect } = useOrderNotification({
-  onNewOrder() {
-    loadData()
-    speakNewOrder(detailOrder.value)
+  async onNewOrder(sseData) {
+    await loadData()
+    // SSE 数据不含菜品,从已加载列表中查找完整订单以获取菜品名
+    const fullOrder = orderList.value.find(o => o.id === sseData.orderId) || sseData
+    speakNewOrder(fullOrder)
+  },
+  async onOrderAccepted(sseData) {
+    await loadData()
+    const fullOrder = orderList.value.find(o => o.id === sseData.orderId) || sseData
+    speakOrderAccepted(fullOrder)
   },
+  // 厨房端静默处理出餐完成事件,避免自己显示自己的操作通知
+  silentEvents: ['order-ready'],
 })
 
+// ==================== 处理页面内通知事件 ====================
+function handleHotelNotification(e) {
+  const { type, message: msg, level } = e.detail
+  const titleMap = {
+    'new-order': '新订单',
+    'order-accepted': '订单已接单',
+    'refund': '退单申请',
+    'order-ready': '出餐完成',
+  }
+  const nType = level === 'warning' ? 'warning' : level === 'success' ? 'success' : 'info'
+  if ($notification && $notification[nType]) {
+    $notification[nType]({
+      title: titleMap[type] || '订单通知',
+      content: msg,
+      duration: 5000,
+    })
+  }
+}
+
 // ==================== 格式化 ====================
 function formatDateTime(dateStr) {
   if (!dateStr)
@@ -464,10 +534,14 @@ function statusLabel(status) {
 }
 
 function statusBadgeClass(status) {
-  if (status <= 1) return 'badge-gray'
-  if (status === 2) return 'badge-warning'
-  if (status === 3) return 'badge-info'
-  if (status >= 4 && status <= 6) return 'badge-success'
+  if (status <= 1)
+    return 'badge-gray'
+  if (status === 2)
+    return 'badge-warning'
+  if (status === 3)
+    return 'badge-info'
+  if (status >= 4 && status <= 6)
+    return 'badge-success'
   return 'badge-gray'
 }
 
@@ -475,6 +549,7 @@ function statusBadgeClass(status) {
 async function loadData() {
   try {
     const dateStr = formatDateParam(selectedDate.value)
+    console.log('[厨房订单] 传入后端的日期参数:', dateStr, 'selectedDate.value:', selectedDate.value)
     const [listRes, statsRes] = await Promise.all([
       getKitchenOrderList(dateStr),
       getKitchenStats(dateStr),
@@ -484,6 +559,45 @@ async function loadData() {
     if (listRes.code === 200 || listRes.code === 0) {
       orderList.value = listRes.data || []
       console.log('[厨房订单] orderList.value 赋值后:', orderList.value, '长度:', orderList.value.length)
+      
+      // 调试:打印所有订单的状态分布
+      const statusCounts = {}
+      orderList.value.forEach(o => {
+        const s = o.status
+        statusCounts[s] = (statusCounts[s] || 0) + 1
+      })
+      console.log('[厨房订单] 订单状态分布:', statusCounts)
+      console.log('[厨房订单] completedOrders 数量:', orderList.value.filter(o => o.status >= 4).length)
+      
+      // 首次加载时,播放第一个待备餐订单的语音提示并显示顶部通知栏
+      if (isFirstLoad) {
+        isFirstLoad = false
+        const firstPending = orderList.value.find(o => o.status === 2)
+        if (firstPending) {
+          console.log('[厨房订单] 首次加载,播放第一个待备餐订单语音:', firstPending.roomNo)
+          
+          // 1. 播放语音播报
+          speakOrderAccepted(firstPending)
+          
+          // 2. 显示顶部通知栏(通过 showInPageNotification 触发)
+          try {
+            const items = firstPending.items || []
+            const dishNames = items.map(i => `${i.dishName}×${i.quantity}`).join('、')
+            const contentText = dishNames
+              ? `${firstPending.roomNo || '未知'}房间订单已接单,请开始备餐 菜品:${dishNames}`
+              : `${firstPending.roomNo || '未知'}房间订单已接单,请开始备餐`
+            
+            // 派发 DOM 事件,触发顶部通知栏显示
+            window.dispatchEvent(new CustomEvent('hotel-notification', {
+              detail: { type: 'order-accepted', message: contentText, level: 'info' },
+            }))
+            console.log('[厨房订单] 顶部通知栏已显示')
+          }
+          catch (e) {
+            console.error('[厨房订单] 顶部通知栏失败:', e)
+          }
+        }
+      }
     }
     else {
       console.error('[厨房订单] listRes.code !== 200:', listRes)
@@ -534,8 +648,31 @@ function speakNewOrder(order) {
     return
   try {
     const items = order.items || []
-    const dishNames = items.map(i => i.dishName).join('、')
-    const text = `新订单,${order.roomNo || '未知'}房间,${dishNames}`
+    const dishNames = items.map(i => `${i.dishName}×${i.quantity}`).join('、')
+    const text = dishNames
+      ? `新订单,${order.roomNo || '未知'}房间,${dishNames}`
+      : `新订单,${order.roomNo || '未知'}房间`
+    const utterance = new SpeechSynthesisUtterance(text)
+    utterance.lang = 'zh-CN'
+    utterance.rate = 0.9
+    utterance.volume = 1.0
+    window.speechSynthesis.speak(utterance)
+  }
+  catch (e) {
+    console.error('语音播报失败:', e)
+  }
+}
+
+// 接单语音播报
+function speakOrderAccepted(order) {
+  if (!order || !('speechSynthesis' in window))
+    return
+  try {
+    const items = order.items || []
+    const dishNames = items.map(i => `${i.dishName}×${i.quantity}`).join('、')
+    const text = dishNames
+      ? `${order.roomNo || '未知'}房间订单已接单,请开始备餐,${dishNames}`
+      : `${order.roomNo || '未知'}房间订单已接单,请开始备餐`
     const utterance = new SpeechSynthesisUtterance(text)
     utterance.lang = 'zh-CN'
     utterance.rate = 0.9
@@ -549,7 +686,8 @@ function speakNewOrder(order) {
 
 // 重播语音播报
 function playVoiceBroadcast() {
-  if (!detailOrder.value) return
+  if (!detailOrder.value)
+    return
   speakNewOrder(detailOrder.value)
   message.success('已重播语音通知')
 }
@@ -558,10 +696,12 @@ function playVoiceBroadcast() {
 onMounted(() => {
   loadData()
   connect()
+  window.addEventListener('hotel-notification', handleHotelNotification)
 })
 
 onUnmounted(() => {
   disconnect()
+  window.removeEventListener('hotel-notification', handleHotelNotification)
 })
 </script>
 
@@ -642,6 +782,14 @@ onUnmounted(() => {
   color: #2080f0;
 }
 
+/* ==================== 新订单语音播报栏 ==================== */
+.voice-broadcast-banner {
+  background: linear-gradient(135deg, #1e7e5a 0%, #2d9b6e 100%);
+  border-radius: 12px;
+  padding: 16px 24px;
+  box-shadow: 0 4px 12px rgba(30, 126, 90, 0.3);
+}
+
 /* ==================== 订单分组列表 ==================== */
 .order-section {
   margin-bottom: 20px;
@@ -664,9 +812,15 @@ onUnmounted(() => {
   border-radius: 50%;
 }
 
-.section-dot.pending { background: #f0a020; }
-.section-dot.preparing { background: #18a058; }
-.section-dot.completed { background: #2080f0; }
+.section-dot.pending {
+  background: #f0a020;
+}
+.section-dot.preparing {
+  background: #18a058;
+}
+.section-dot.completed {
+  background: #2080f0;
+}
 
 .section-count {
   font-size: 12px;
@@ -684,7 +838,9 @@ onUnmounted(() => {
 .row-completed {
   opacity: 0.85;
 }
-.row-completed .row-color-bar { background: #2080f0; }
+.row-completed .row-color-bar {
+  background: #2080f0;
+}
 
 .order-row {
   display: flex;
@@ -693,7 +849,9 @@ onUnmounted(() => {
   border: 1px solid #e8e8e8;
   overflow: hidden;
   cursor: pointer;
-  transition: box-shadow 0.2s, transform 0.15s;
+  transition:
+    box-shadow 0.2s,
+    transform 0.15s;
   min-height: 140px; /* 增加高度,容纳更多内容 */
 }
 
@@ -702,15 +860,38 @@ onUnmounted(() => {
   transform: translateY(-2px);
 }
 
+/* 第一个待备餐订单的放大动画 */
+.first-order-zoom {
+  animation: first-order-pulse 2s ease-in-out infinite;
+  border: 2px solid #f0a020 !important;
+  box-shadow: 0 8px 24px rgba(240, 160, 32, 0.3) !important;
+}
+
+@keyframes first-order-pulse {
+  0%,
+  100% {
+    transform: scale(1);
+  }
+  50% {
+    transform: scale(1.03);
+  }
+}
+
 /* 左侧色条 - 加宽 */
 .row-color-bar {
   width: 8px; /* 从 6px 增加到 8px */
   flex-shrink: 0;
 }
 
-.row-pending .row-color-bar { background: #f0a020; }
-.row-preparing .row-color-bar { background: #18a058; }
-.row-ready .row-color-bar { background: #18a058; }
+.row-pending .row-color-bar {
+  background: #f0a020;
+}
+.row-preparing .row-color-bar {
+  background: #18a058;
+}
+.row-ready .row-color-bar {
+  background: #18a058;
+}
 
 /* 状态标签 */
 .status-badge {
@@ -775,8 +956,14 @@ onUnmounted(() => {
   white-space: nowrap;
 }
 
-.elapsed-time.time-warning { color: #f0a020; font-weight: 600; }
-.elapsed-time.time-overdue { color: #d03050; font-weight: 600; }
+.elapsed-time.time-warning {
+  color: #f0a020;
+  font-weight: 600;
+}
+.elapsed-time.time-overdue {
+  color: #d03050;
+  font-weight: 600;
+}
 
 /* 菜品标签 */
 .row-items {

+ 7 - 7
forge-admin-ui/src/views/hotel/order.vue

@@ -178,7 +178,9 @@ async function loadDashboard(queryDate) {
 // 暂停接单功能已移至其他入口管理
 
 // ==================== SSE 实时通知 ====================
-const { connected, connect, disconnect, playSound } = useOrderNotification({
+// 注:PC 管理端暂不启用声音提醒,仅保留数据刷新和页面内通知弹窗
+const { connected, connect, disconnect } = useOrderNotification({
+  disableSound: true,
   onNewOrder: () => {
     crudRef.value?.refresh()
     loadDashboard(dashboardDate.value)
@@ -230,8 +232,8 @@ watch(connected, (newVal) => {
 })
 
 /**
- * 页面加载时检查待接单订单,如果有则触发语音通知。
- * 这样刷新页面就能听到声音提醒了。
+ * 页面加载时检查待接单订单,如果有则触发通知弹窗(不播放声音)。
+ * 这样刷新页面就能看到待处理订单提示了。
  */
 async function checkPendingOrders() {
   try {
@@ -243,10 +245,8 @@ async function checkPendingOrders() {
     })
 
     if (res.code === 200 && res.data?.total > 0) {
-      console.warn('[SSE] 📢 检测到待接单订单,触发语音通知')
-      // 播放声音
-      playSound('new-order')
-      // 触发一次新订单通知(显示桌面弹窗)
+      console.warn('[SSE] 检测到待接单订单,触发通知弹窗')
+      // 仅触发通知弹窗,不播放声音
       window.dispatchEvent(new CustomEvent('hotel-notification', {
         detail: {
           type: 'new-order',

+ 314 - 41
forge-admin-ui/src/views/hotel/restaurantOrder.vue

@@ -32,6 +32,23 @@
               </NSpace>
             </NFormItem>
           </NForm>
+
+          <!-- 新订单提醒横幅 -->
+          <div v-if="latestPendingOrder" class="new-order-banner" @click="openDetail(latestPendingOrder)">
+            <div class="banner-left">
+              <span class="banner-bell">🔔</span>
+              <div class="banner-info">
+                <div class="banner-title">
+                  新订单提醒!{{ latestPendingOrder.roomNo || '--' }} 房间
+                </div>
+                <div class="banner-items">
+                  {{ buildItemSummary(latestPendingOrder) }} · ¥{{ latestPendingOrder.totalAmount }}
+                </div>
+              </div>
+            </div>
+            <span class="banner-time">刚刚</span>
+          </div>
+
           <!-- 统计卡片 -->
           <div class="stats-row">
             <div class="stat-card stat-pending">
@@ -50,6 +67,10 @@
               <span class="stat-value">{{ rejectedOrders.length }}</span>
               <span class="stat-label">已拒单</span>
             </div>
+            <div class="stat-card stat-refunded">
+              <span class="stat-value">{{ refundedOrders.length }}</span>
+              <span class="stat-label">已退单</span>
+            </div>
             <div v-if="connected" class="sse-indicator" title="实时通知已连接">
               <span class="sse-dot" />
             </div>
@@ -164,6 +185,37 @@
             </div>
           </div>
 
+          <!-- 退单待审核列表 -->
+          <div v-if="refundRequestOrders.length > 0" class="order-section">
+            <h3 class="section-title">
+              <span class="section-dot refund" />
+              退单待审核
+              <span class="section-count">{{ refundRequestOrders.length }}</span>
+            </h3>
+            <div class="order-list">
+              <div v-for="order in refundRequestOrders" :key="order.id" class="order-row order-row-refund" @click="openDetail(order)">
+                <div class="order-room">
+                  {{ order.roomNo || '--' }}
+                </div>
+                <div class="order-info">
+                  <div class="order-no">
+                    {{ order.orderNo }}
+                  </div>
+                  <div class="order-items">
+                    {{ buildItemSummary(order) }}
+                  </div>
+                  <div class="order-meta">
+                    <DictTag dict-type="hotel_order_status" :value="String(order.status)" size="small" />
+                    <span class="order-amount">¥{{ order.totalAmount }}</span>
+                  </div>
+                  <div v-if="order.refundReason" class="order-reject-reason text-xs mt-1 text-orange-500">
+                    退单原因:{{ order.refundReason }}
+                  </div>
+                </div>
+              </div>
+            </div>
+          </div>
+
           <!-- 已拒单列表 -->
           <div v-if="rejectedOrders.length > 0" class="order-section">
             <h3 class="section-title">
@@ -195,8 +247,36 @@
             </div>
           </div>
 
+          <!-- 已退单列表 -->
+          <div v-if="refundedOrders.length > 0" class="order-section">
+            <h3 class="section-title">
+              <span class="section-dot refunded" />
+              已退单
+              <span class="section-count">{{ refundedOrders.length }}</span>
+            </h3>
+            <div class="order-list">
+              <div v-for="order in refundedOrders" :key="order.id" class="order-row order-row-refunded" @click="openDetail(order)">
+                <div class="order-room">
+                  {{ order.roomNo || '--' }}
+                </div>
+                <div class="order-info">
+                  <div class="order-no">
+                    {{ order.orderNo }}
+                  </div>
+                  <div class="order-items">
+                    {{ buildItemSummary(order) }}
+                  </div>
+                  <div class="order-meta">
+                    <DictTag dict-type="hotel_order_status" :value="String(order.status)" size="small" />
+                    <span class="order-amount">¥{{ order.totalAmount }}</span>
+                  </div>
+                </div>
+              </div>
+            </div>
+          </div>
+
           <!-- 空状态 -->
-          <div v-if="pendingOrders.length === 0 && inProgressOrders.length === 0 && completedOrders.length === 0 && rejectedOrders.length === 0" class="empty-state">
+          <div v-if="pendingOrders.length === 0 && inProgressOrders.length === 0 && completedOrders.length === 0 && rejectedOrders.length === 0 && refundRequestOrders.length === 0 && refundedOrders.length === 0" class="empty-state">
             <span class="text-gray-400">暂无订单</span>
           </div>
         </div>
@@ -461,20 +541,20 @@
     >
       <div v-if="detailOrder" class="receipt-full">
         <!-- 顶部标题栏 -->
-        <div class="receipt-header-bar mb-6 rounded-t-lg p-4 flex items-center justify-between">
+        <div class="receipt-header-bar mb-6 flex items-center justify-between rounded-t-lg p-4">
           <div class="flex items-center gap-3">
             <i class="i-mdi:arrow-left text-xl cursor-pointer hover:text-white/80" @click="showDetail = false" />
-            <span class="text-white text-base font-semibold">订单详情</span>
+            <span class="text-base text-white font-semibold">订单详情</span>
           </div>
           <DictTag dict-type="hotel_order_status" :value="String(detailOrder.status)" />
         </div>
 
         <!-- 房间号区域 -->
-        <div class="room-hero-section text-center mb-6">
-          <div class="room-icon-wrapper inline-flex items-center justify-center w-16 h-16 rounded-full bg-blue-100 mb-3">
+        <div class="room-hero-section mb-6 text-center">
+          <div class="room-icon-wrapper mb-3 h-16 w-16 inline-flex items-center justify-center rounded-full bg-blue-100">
             <i class="i-mdi:home-city text-3xl text-blue-600" />
           </div>
-          <div class="room-number text-5xl font-bold text-gray-800 mb-2">
+          <div class="room-number mb-2 text-5xl text-gray-800 font-bold">
             {{ detailOrder.roomNo || '--' }}
           </div>
           <div class="room-meta text-sm text-gray-500">
@@ -483,22 +563,28 @@
         </div>
 
         <!-- 小票主体(虚线边框) -->
-        <div class="receipt-body mb-6 rounded-lg border-2 border-dashed border-gray-300 p-6 bg-white">
+        <div class="receipt-body mb-6 border-2 border-gray-300 rounded-lg border-dashed bg-white p-6">
           <!-- 小票头部 -->
-          <div class="receipt-title text-center mb-4 pb-4 border-b border-dashed border-gray-300">
-            <div class="text-lg font-bold text-gray-800 mb-1">璞石酒店 · 前台联</div>
-            <div class="text-sm text-gray-500">{{ detailOrder.orderNo }}</div>
-            <div class="text-xs text-gray-400 mt-1">{{ formatDateTime(detailOrder.createTime) }}</div>
+          <div class="receipt-title mb-4 border-b border-gray-300 border-dashed pb-4 text-center">
+            <div class="text-lg mb-1 text-gray-800 font-bold">
+              璞石酒店 · 前台联
+            </div>
+            <div class="text-sm text-gray-500">
+              {{ detailOrder.orderNo }}
+            </div>
+            <div class="text-xs mt-1 text-gray-400">
+              {{ formatDateTime(detailOrder.createTime) }}
+            </div>
           </div>
 
           <!-- 桌号与备注 -->
-          <div class="receipt-info mb-4 pb-4 border-b border-dashed border-gray-300">
-            <div class="flex items-center gap-2 mb-2">
+          <div class="receipt-info mb-4 border-b border-gray-300 border-dashed pb-4">
+            <div class="mb-2 flex items-center gap-2">
               <span class="text-sm text-gray-600">桌号:</span>
-              <span class="text-base font-semibold text-gray-800">{{ detailOrder.roomNo }}</span>
-              <span class="text-sm text-gray-600 ml-2">{{ detailOrder.roomType || '' }}</span>
+              <span class="text-base text-gray-800 font-semibold">{{ detailOrder.roomNo }}</span>
+              <span class="text-sm ml-2 text-gray-600">{{ detailOrder.roomType || '' }}</span>
             </div>
-            <div v-if="detailOrder.contactName" class="flex items-center gap-2 mb-2">
+            <div v-if="detailOrder.contactName" class="mb-2 flex items-center gap-2">
               <span class="text-sm text-gray-600">联系人:</span>
               <span class="text-sm text-gray-700">{{ detailOrder.contactName }}</span>
             </div>
@@ -509,30 +595,30 @@
           </div>
 
           <!-- 菜品明细 -->
-          <div class="receipt-items mb-4 pb-4 border-b border-dashed border-gray-300">
+          <div class="receipt-items mb-4 border-b border-gray-300 border-dashed pb-4">
             <div v-for="(item, idx) in (detailOrder.items || [])" :key="item.id" class="receipt-item-row mb-3 last:mb-0">
-              <div class="item-header flex justify-between items-start mb-1">
+              <div class="item-header mb-1 flex items-start justify-between">
                 <div class="flex-1">
-                  <span class="item-index text-sm text-gray-500 mr-2">[{{ idx + 1 }}]</span>
-                  <span class="item-name text-base font-medium text-gray-800">{{ item.dishName }}</span>
+                  <span class="item-index text-sm mr-2 text-gray-500">[{{ idx + 1 }}]</span>
+                  <span class="item-name text-base text-gray-800 font-medium">{{ item.dishName }}</span>
                 </div>
-                <span class="item-price text-right font-semibold text-gray-800 whitespace-nowrap ml-3">
+                <span class="item-price ml-3 whitespace-nowrap text-right text-gray-800 font-semibold">
                   ¥{{ item.subtotal || '0.00' }}
                 </span>
               </div>
-              <div v-if="item.specDesc || item.additionsDesc" class="item-details text-xs text-gray-500 ml-6">
+              <div v-if="item.specDesc || item.additionsDesc" class="item-details text-xs ml-6 text-gray-500">
                 <span v-if="item.specDesc">{{ item.specDesc }}</span>
                 <span v-if="item.specDesc && item.additionsDesc"> / </span>
                 <span v-if="item.additionsDesc">{{ item.additionsDesc }}</span>
               </div>
-              <div v-if="item.note" class="item-note text-xs text-orange-600 ml-6 mt-1">
+              <div v-if="item.note" class="item-note text-xs ml-6 mt-1 text-orange-600">
                 备注: {{ item.note }}
               </div>
             </div>
           </div>
 
           <!-- 合计 -->
-          <div class="receipt-total text-right text-lg font-bold text-gray-800">
+          <div class="receipt-total text-lg text-right text-gray-800 font-bold">
             合计:¥{{ detailOrder.totalAmount || '0.00' }}
           </div>
         </div>
@@ -547,7 +633,9 @@
             class="action-btn-primary"
             @click="handleAcceptFromDetail"
           >
-            <template #icon><i class="i-mdi:check-circle" /></template>
+            <template #icon>
+              <i class="i-mdi:check-circle" />
+            </template>
             接单
           </NButton>
           <NButton
@@ -558,7 +646,9 @@
             class="mt-3"
             @click="handleRejectFromDetail"
           >
-            <template #icon><i class="i-mdi:close-circle" /></template>
+            <template #icon>
+              <i class="i-mdi:close-circle" />
+            </template>
             拒单
           </NButton>
           <NButton
@@ -569,7 +659,9 @@
             class="action-btn-primary"
             @click="handleDeliverFromDetail"
           >
-            <template #icon><i class="i-mdi:truck-delivery" /></template>
+            <template #icon>
+              <i class="i-mdi:truck-delivery" />
+            </template>
             开始配送
           </NButton>
           <NButton
@@ -580,16 +672,18 @@
             class="action-btn-primary"
             @click="handleCompleteFromDetail"
           >
-            <template #icon><i class="i-mdi:flag-checkered" /></template>
+            <template #icon>
+              <i class="i-mdi:flag-checkered" />
+            </template>
             完成配送
           </NButton>
-          <div v-if="detailOrder.status >= 2 && detailOrder.status <= 3" class="status-hint text-center text-sm text-gray-400 py-3">
+          <div v-if="detailOrder.status >= 2 && detailOrder.status <= 3" class="status-hint text-sm py-3 text-center text-gray-400">
             <i class="i-mdi:chef-hat mr-1" />订单正在厨房制作中,请稍候
           </div>
-          <div v-if="detailOrder.status === 6" class="status-hint text-center text-sm text-green-600 py-3">
+          <div v-if="detailOrder.status === 6" class="status-hint text-sm py-3 text-center text-green-600">
             <i class="i-mdi:check-circle mr-1" />订单已完成
           </div>
-          <div v-if="detailOrder.status === 7" class="status-hint text-center text-sm text-gray-400 py-3">
+          <div v-if="detailOrder.status === 7" class="status-hint text-sm py-3 text-center text-gray-400">
             <i class="i-mdi:close-circle mr-1" />订单已拒单
           </div>
         </div>
@@ -666,6 +760,8 @@ const kanbanColumns = [
   { key: 'delivering', label: '配送中', color: '#18a058', statuses: [5] },
   { key: 'completed', label: '已结单', color: '#999', statuses: [6] },
   { key: 'rejected', label: '已拒单', color: '#999', statuses: [7] },
+  { key: 'refund', label: '退单审核', color: '#f0a020', statuses: [8] },
+  { key: 'refunded', label: '已退单', color: '#999', statuses: [9] },
 ]
 
 // ==================== 数据状态 ====================
@@ -682,6 +778,8 @@ const businessHours = ref([])
 const isPaused = ref(false)
 
 // ==================== SSE 通知 ====================
+// 餐厅端监听 NEW_ORDER、REFUND_REQUEST 和 ORDER_READY
+// ORDER_ACCEPTED 是厨房端专用事件,餐厅端静默处理(不播放声音、不显示弹窗)
 const { connected, connect, disconnect, playSound } = useOrderNotification({
   onNewOrder: () => {
     refreshAll()
@@ -689,13 +787,45 @@ const { connected, connect, disconnect, playSound } = useOrderNotification({
   onRefundRequest: () => {
     refreshAll()
   },
+  // 出餐完成后刷新数据并播放语音提醒前厅配送
+  onOrderReady: (sseData) => {
+    console.log('[餐厅订单] onOrderReady 回调被调用:', sseData)
+    refreshAll()
+    // 播放语音提醒:XX房间订单已出餐,请及时配送
+    if ('speechSynthesis' in window) {
+      try {
+        const text = `${sseData.roomNo || '未知'}房间订单已出餐,请及时配送`
+        console.log('[餐厅订单] 准备播放语音:', text)
+        const utterance = new SpeechSynthesisUtterance(text)
+        utterance.lang = 'zh-CN'
+        utterance.rate = 0.9
+        utterance.volume = 1.0
+        window.speechSynthesis.speak(utterance)
+        console.log('[餐厅订单] 语音播报已触发')
+      }
+      catch (e) {
+        console.error('[餐厅订单] 语音播报失败:', e)
+      }
+    }
+    else {
+      console.warn('[餐厅订单] 浏览器不支持语音合成')
+    }
+  },
+  // 餐厅端静默处理厨房端接单事件,避免重复播放声音和显示错误弹窗
+  silentEvents: ['order-accepted'],
 })
 
 function handleHotelNotification(e) {
   const { type, message: msg, level } = e.detail
-  const nType = level === 'warning' ? 'warning' : 'info'
+  const titleMap = {
+    'new-order': '新订单',
+    'order-accepted': '订单已接单',
+    'refund': '退单申请',
+    'order-ready': '出餐完成',
+  }
+  const nType = level === 'warning' ? 'warning' : level === 'success' ? 'success' : 'info'
   $notification[nType]({
-    title: type === 'new-order' ? '新订单' : '退单申请',
+    title: titleMap[type] || '订单通知',
     content: msg,
     duration: 5000,
   })
@@ -725,7 +855,7 @@ async function loadDashboard() {
 async function loadOrders() {
   try {
     const dateStr = getFilterDateStr()
-    const statuses = [1, 2, 3, 4, 5, 6, 7]
+    const statuses = [1, 2, 3, 4, 5, 6, 7, 8, 9]
     const promises = statuses.map((s) => {
       return getOrderPage({ pageNum: 1, pageSize: 100, status: s, queryDate: dateStr })
     })
@@ -829,10 +959,27 @@ const pendingOrders = computed(() => {
   })
 })
 
+// 最新待接单订单(仅展示第一个,即最新的)
+const latestPendingOrder = computed(() => {
+  const pending = pendingOrders.value
+  return pending.length > 0 ? pending[0] : null
+})
+
 const inProgressOrders = computed(() => {
-  return allOrders.value.filter((o) => {
+  const orders = allOrders.value.filter((o) => {
     return o.status >= 2 && o.status <= 5
   })
+  // 排序:需要配送的订单(status=4)排在前面,其他订单排在后面
+  // 同一优先级内按创建时间降序(最新的在前)
+  return orders.sort((a, b) => {
+    const aNeedDelivery = a.status === 4 ? 0 : 1
+    const bNeedDelivery = b.status === 4 ? 0 : 1
+    if (aNeedDelivery !== bNeedDelivery) {
+      return aNeedDelivery - bNeedDelivery
+    }
+    // 同一优先级,按创建时间降序
+    return new Date(b.createTime).getTime() - new Date(a.createTime).getTime()
+  })
 })
 
 const inProgressCount = computed(() => {
@@ -845,6 +992,18 @@ const rejectedOrders = computed(() => {
   })
 })
 
+const refundRequestOrders = computed(() => {
+  return allOrders.value.filter((o) => {
+    return o.status === 8
+  })
+})
+
+const refundedOrders = computed(() => {
+  return allOrders.value.filter((o) => {
+    return o.status === 9
+  })
+})
+
 const completedOrders = computed(() => {
   return allOrders.value.filter((o) => {
     return o.status === 6
@@ -1265,13 +1424,15 @@ function openDetail(order) {
 }
 
 function formatDetailTime(dateStr) {
-  if (!dateStr) return '--'
+  if (!dateStr)
+    return '--'
   const d = new Date(dateStr)
   return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
 }
 
 function formatDateTime(dateStr) {
-  if (!dateStr) return '--'
+  if (!dateStr)
+    return '--'
   const d = new Date(dateStr)
   const hh = String(d.getHours()).padStart(2, '0')
   const mm = String(d.getMinutes()).padStart(2, '0')
@@ -1280,7 +1441,8 @@ function formatDateTime(dateStr) {
 }
 
 function handleAcceptFromDetail() {
-  if (!detailOrder.value) return
+  if (!detailOrder.value)
+    return
   const order = detailOrder.value
   dialog.warning({
     title: '确认接单',
@@ -1303,14 +1465,16 @@ function handleAcceptFromDetail() {
 }
 
 function handleRejectFromDetail() {
-  if (!detailOrder.value) return
+  if (!detailOrder.value)
+    return
   rejectOrderId = detailOrder.value.id
   reasonText.value = ''
   reasonVisible.value = true
 }
 
 function handleDeliverFromDetail() {
-  if (!detailOrder.value) return
+  if (!detailOrder.value)
+    return
   const order = detailOrder.value
   dialog.warning({
     title: '开始配送',
@@ -1333,7 +1497,8 @@ function handleDeliverFromDetail() {
 }
 
 function handleCompleteFromDetail() {
-  if (!detailOrder.value) return
+  if (!detailOrder.value)
+    return
   const order = detailOrder.value
   dialog.warning({
     title: '完成配送',
@@ -1377,6 +1542,7 @@ function handleCompleteFromDetail() {
 .tab-content {
   height: 100%;
   overflow-y: auto;
+  overflow-x: hidden;
   padding: 16px;
 }
 
@@ -1433,6 +1599,9 @@ function handleCompleteFromDetail() {
 .stat-rejected .stat-value {
   color: #999;
 }
+.stat-refunded .stat-value {
+  color: #bbb;
+}
 
 .sse-indicator {
   display: flex;
@@ -1459,6 +1628,93 @@ function handleCompleteFromDetail() {
   }
 }
 
+/* ==================== 新订单提醒横幅 ==================== */
+.new-order-banner {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  background: linear-gradient(135deg, #f87171, #ef4444);
+  border-radius: 10px;
+  padding: 14px 20px;
+  margin-bottom: 20px;
+  cursor: pointer;
+  animation: banner-pulse 1.5s ease-in-out infinite;
+  box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
+}
+
+.new-order-banner:hover {
+  box-shadow: 0 6px 16px rgba(239, 68, 68, 0.45);
+}
+
+@keyframes banner-pulse {
+  0%,
+  100% {
+    transform: scale(1);
+  }
+  50% {
+    transform: scale(1.02);
+  }
+}
+
+.banner-left {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+}
+
+.banner-bell {
+  font-size: 28px;
+  animation: bell-shake 1.5s ease-in-out infinite;
+}
+
+@keyframes bell-shake {
+  0%,
+  100% {
+    transform: rotate(0deg);
+  }
+  15% {
+    transform: rotate(15deg);
+  }
+  30% {
+    transform: rotate(-15deg);
+  }
+  45% {
+    transform: rotate(10deg);
+  }
+  60% {
+    transform: rotate(-10deg);
+  }
+  75% {
+    transform: rotate(5deg);
+  }
+  90% {
+    transform: rotate(-5deg);
+  }
+}
+
+.banner-info {
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+}
+
+.banner-title {
+  font-size: 16px;
+  font-weight: 700;
+  color: #fff;
+}
+
+.banner-items {
+  font-size: 13px;
+  color: rgba(255, 255, 255, 0.85);
+}
+
+.banner-time {
+  font-size: 13px;
+  color: rgba(255, 255, 255, 0.8);
+  white-space: nowrap;
+}
+
 /* ==================== 订单分组列表 ==================== */
 .order-section {
   margin-bottom: 20px;
@@ -1490,6 +1746,12 @@ function handleCompleteFromDetail() {
 .section-dot.rejected {
   background: #999;
 }
+.section-dot.refund {
+  background: #f0a020;
+}
+.section-dot.refunded {
+  background: #999;
+}
 .section-dot.completed {
   background: #18a058;
 }
@@ -1533,6 +1795,17 @@ function handleCompleteFromDetail() {
   opacity: 0.85;
 }
 
+.order-row-refund {
+  border-left-color: #f0a020;
+  background: #fffbeb;
+}
+
+.order-row-refunded {
+  border-left-color: #999;
+  background: #fafafa;
+  opacity: 0.7;
+}
+
 .order-row-completed {
   border-left-color: #18a058;
   background: #f6ffed;

+ 39 - 4
forge-h5-ui/src/pages/hotel/customer/order-status.vue

@@ -171,6 +171,9 @@ let orderId = ''
 let eventSource = null
 let fallbackTimer = null
 let sseConnected = false
+// SSE 最后收到事件的时间戳,用于检测 SSE 假死
+let lastSseEventTime = 0
+let sseHealthTimer = null
 
 onMounted(() => {
   const pages = getCurrentPages()
@@ -188,6 +191,7 @@ onMounted(() => {
 onUnmounted(() => {
   disconnectSSE()
   stopFallbackPolling()
+  stopSSEHealthCheck()
 })
 
 async function loadOrder() {
@@ -200,6 +204,7 @@ async function loadOrder() {
     if (order.value && !isFinished(order.value.status)) {
       connectSSE()
       startFallbackPolling()
+      startSSEHealthCheck()
     }
   } catch (e) {
     console.error('加载订单详情失败:', e)
@@ -220,6 +225,7 @@ function connectSSE() {
 
     eventSource.addEventListener('CUSTOMER_ORDER_STATUS', function(event) {
       try {
+        lastSseEventTime = Date.now()
         const payload = JSON.parse(event.data)
         if (payload.data && payload.data.orderId.toString() === orderId.toString()) {
           // 立即更新状态(界面秒级响应)
@@ -236,6 +242,7 @@ function connectSSE() {
 
     eventSource.onopen = function() {
       sseConnected = true
+      lastSseEventTime = Date.now()
       console.log('[SSE] 顾客端连接成功')
     }
 
@@ -264,30 +271,30 @@ async function silentRefresh() {
     if (order.value && isFinished(order.value.status)) {
       disconnectSSE()
       stopFallbackPolling()
+      stopSSEHealthCheck()
     }
   } catch (e) {
     // 静默失败
   }
 }
 
-// ==================== 兑底轮询(SSE 断开时保证最终一致) ====================
+// ==================== 兑底轮询(保证最终一致,SSE 断开/假死时兜底) ====================
 
 function startFallbackPolling() {
   stopFallbackPolling()
   fallbackTimer = setInterval(async () => {
-    // SSE 已连接时不轮询,节省服务器资源
-    if (sseConnected) return
     try {
       const res = await api.hotelOrderDetail(orderId, orderStore.tenantId)
       order.value = res?.data || res
       if (order.value && isFinished(order.value.status)) {
         disconnectSSE()
         stopFallbackPolling()
+        stopSSEHealthCheck()
       }
     } catch (e) {
       // 静默失败
     }
-  }, 30000)
+  }, 8000)
 }
 
 function stopFallbackPolling() {
@@ -297,6 +304,34 @@ function stopFallbackPolling() {
   }
 }
 
+// ==================== SSE 心跳检测(防止 SSE 假死导致状态永远不更新) ====================
+
+/**
+ * 启动 SSE 健康检查。
+ * 每 15 秒检查一次:如果 SSE 标记为已连接但超过 20 秒没收到任何事件,
+ * 说明 SSE 可能假死(移动端锁屏、浏览器节流等),强制标记为断开,
+ * 让兑底轮询接管。
+ */
+function startSSEHealthCheck() {
+  stopSSEHealthCheck()
+  sseHealthTimer = setInterval(() => {
+    if (sseConnected && lastSseEventTime > 0) {
+      const elapsed = Date.now() - lastSseEventTime
+      if (elapsed > 20000) {
+        console.warn(`[SSE] 心跳检测: SSE 已 ${Math.round(elapsed / 1000)}s 无事件,判定假死,降级为轮询`)
+        sseConnected = false
+      }
+    }
+  }, 15000)
+}
+
+function stopSSEHealthCheck() {
+  if (sseHealthTimer) {
+    clearInterval(sseHealthTimer)
+    sseHealthTimer = null
+  }
+}
+
 function isFinished(status) {
   // 11已取消 之前被漏掉,已取消的订单会一直轮询详情接口,白跑请求不停
   return status === 6 || status === 7 || status === 9 || status === 10 || status === 11

+ 51 - 1
forge-h5-ui/src/pages/hotel/customer/orders.vue

@@ -79,7 +79,7 @@
 </template>
 
 <script setup>
-import { computed, ref, watch, onMounted } from 'vue'
+import { computed, ref, watch, onMounted, onUnmounted } from 'vue'
 import { onShow } from '@dcloudio/uni-app'
 import { useHotelOrderStore } from '@/store'
 import api from '@/api'
@@ -93,6 +93,10 @@ const loading = ref(false)
 const noMore = ref(false)
 const pageNum = ref(1)
 const pageSize = 10
+let pollTimer = null
+
+// 终态状态码:这些状态的订单不会再变化,无需继续轮询
+const FINISHED_STATUSES = new Set([6, 7, 9, 10, 11])
 
 // 页面加载时获取订单
 onMounted(() => {
@@ -107,6 +111,10 @@ onShow(() => {
   }
 })
 
+onUnmounted(() => {
+  stopPolling()
+})
+
 // 在住会话变化时自动清空并重新加载:退房后新客人不应看到旧订单
 // 关键修复:监听 stayId 的变化(包括从 null → 有值、旧值 → 新值)
 watch(
@@ -129,6 +137,45 @@ function resetAndLoad() {
   loadOrders()
 }
 
+/** 检查是否有进行中的订单,有则启动轮询,无则停止 */
+function syncPolling() {
+  const hasActive = orderList.value.some(o => !FINISHED_STATUSES.has(o.status))
+  if (hasActive) {
+    startPolling()
+  } else {
+    stopPolling()
+  }
+}
+
+function startPolling() {
+  stopPolling()
+  pollTimer = setInterval(() => {
+    // 静默刷新:只重新加载第一页
+    const currentStayId = roomInfo.value?.stayId
+    if (!currentStayId) return
+    api.hotelOrderPage({
+      tenantId: orderStore.tenantId,
+      pageNum: 1,
+      pageSize: pageSize,
+      stayId: currentStayId,
+    }).then(res => {
+      const data = res?.data || res
+      const list = data?.records || (Array.isArray(data) ? data : [])
+      orderList.value = list
+      syncPolling()
+    }).catch(() => {
+      // 静默失败
+    })
+  }, 10000)
+}
+
+function stopPolling() {
+  if (pollTimer) {
+    clearInterval(pollTimer)
+    pollTimer = null
+  }
+}
+
 async function loadOrders() {
   if (loading.value) return
   loading.value = true
@@ -167,6 +214,9 @@ async function loadOrders() {
     if (list.length < pageSize.value) {
       noMore.value = true
     }
+
+    // 检查是否有进行中的订单,有则启动轮询
+    syncPolling()
   } catch (e) {
     console.error('加载订单列表失败:', e)
     uni.showToast({ title: '加载失败', icon: 'none' })

+ 25 - 0
forge-h5-ui/src/pages/hotel/hotel-order.vue

@@ -2,6 +2,9 @@
   <view class="page">
     <!-- 顶部导航 -->
     <view class="nav-bar">
+      <view class="nav-back" @click="goBack">
+        <text class="nav-back-icon">‹</text>
+      </view>
       <text class="nav-title">🏨 宾馆前台 · 订单总览</text>
     </view>
 
@@ -234,6 +237,10 @@ function goRefund() {
   uni.navigateTo({ url: '/pages/hotel/hotel-order-refund?mode=refund' })
 }
 
+function goBack() {
+  uni.navigateBack({ fail: () => { uni.switchTab({ url: '/pages/index/index' }) } })
+}
+
 // ==================== 生命周期 ====================
 onMounted(() => {
   loadDashboard()
@@ -257,6 +264,24 @@ onMounted(() => {
   display: flex;
   align-items: center;
   justify-content: center;
+  position: relative;
+}
+.nav-back {
+  position: absolute;
+  left: 20rpx;
+  top: 50%;
+  transform: translateY(-50%);
+  width: 56rpx;
+  height: 56rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.nav-back-icon {
+  font-size: 48rpx;
+  color: #fff;
+  font-weight: 700;
+  line-height: 1;
 }
 .nav-title {
   font-size: 32rpx;

+ 10 - 2
forge-h5-ui/src/pages/hotel/qr-scanner.vue

@@ -30,7 +30,7 @@
         <text class="qr-scanner-camera-error-title">无法访问摄像头</text>
         <text class="qr-scanner-camera-error-desc">{{ cameraErrorMsg }}</text>
         <view class="qr-scanner-camera-error-actions">
-          <AiButton size="sm" @click="startScanner">重试</AiButton>
+          <AiButton size="sm" @click="retryScan">重试</AiButton>
         </view>
       </view>
     </view>
@@ -127,7 +127,7 @@ async function startScanner() {
     } else if (err.name === 'NotReadableError') {
       cameraErrorMsg.value = '摄像头被占用,请关闭其他应用后重试'
     } else if (err.message?.includes('HTTPS') || err.message?.includes('secure')) {
-      cameraErrorMsg.value = '摄像头功能需要 HTTPS 或 localhost 环境'
+      cameraErrorMsg.value = '️ 扫码功能需要 HTTPS 环境\n\n请使用 ngrok 等工具提供 HTTPS 隧道:\n1. 下载 ngrok: https://ngrok.com/download\n2. 运行: ngrok http 3001\n3. 使用生成的 https://xxx.ngrok-free.app 访问'
     } else {
       cameraErrorMsg.value = err.message || '摄像头启动失败'
     }
@@ -190,6 +190,14 @@ function goBack() {
   uni.navigateBack({ delta: 1 })
 }
 
+// 重试按钮点击事件
+function retryScan() {
+  cameraError.value = false
+  cameraErrorMsg.value = ''
+  startScanner()
+}
+
+// 从相册选择二维码
 async function handleAlbum() {
   if (!html5QrCode) return
 

+ 87 - 14
forge-h5-ui/src/pages/hotel/staff-order.vue

@@ -23,6 +23,18 @@
     <scroll-view class="tab-content" scroll-y>
       <!-- ==================== Tab 1: 全部订单 ==================== -->
       <view v-if="activeTab === 'orders'" class="panel">
+        <!-- 新订单提醒横幅 -->
+        <view v-if="latestPendingOrder" class="new-order-banner" @click="openDetail(latestPendingOrder)">
+          <view class="banner-left">
+            <text class="banner-bell">🔔</text>
+            <view class="banner-info">
+              <text class="banner-title">新订单提醒!{{ latestPendingOrder.roomNo || '--' }} 房间</text>
+              <text class="banner-items">{{ buildItemSummary(latestPendingOrder) }} · ¥{{ latestPendingOrder.totalAmount }}</text>
+            </view>
+          </view>
+          <text class="banner-time">刚刚</text>
+        </view>
+
         <!-- 统计卡片 2×2 -->
         <view class="stats-grid">
           <view class="stat-card stat-pending">
@@ -410,6 +422,11 @@ const kanbanColumns = [
 
 // ==================== 计算属性 ====================
 const pendingOrders = computed(() => allOrders.value.filter(o => o.status === 1))
+// 最新待接单订单(仅展示第一个,即最新的)
+const latestPendingOrder = computed(() => {
+  const pending = pendingOrders.value
+  return pending.length > 0 ? pending[0] : null
+})
 const inProgressOrders = computed(() => allOrders.value.filter(o => o.status >= 2 && o.status <= 5))
 const inProgressCount = computed(() => inProgressOrders.value.length)
 const rejectedOrders = computed(() => allOrders.value.filter(o => o.status === 7))
@@ -515,36 +532,26 @@ function refreshAll() {
   loadOrders()
 }
 
-// ==================== 轮询(10 秒) ====================
+// ==================== 轮询(5 秒) ====================
 function startPolling() {
   stopPolling()
   pollTimer = setInterval(async () => {
     await loadDashboard()
     await loadOrders()
-    // 检测新订单
+    // 检测新订单(仅显示 toast 提示,不播放声音)
     const currentCount = dashboard.value.pendingCount || 0
     if (currentCount > lastPendingCount && lastPendingCount > 0) {
-      playAlertSound()
       uni.showToast({ title: `新订单!${currentCount} 个待接单`, icon: 'none', duration: 3000 })
     }
     lastPendingCount = currentCount
-  }, 10000)
+  }, 5000)
 }
 
 function stopPolling() {
   if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
 }
 
-function playAlertSound() {
-  try {
-    const audio = uni.createInnerAudioContext()
-    audio.src = '/static/audio/order-alert.mp3'
-    audio.volume = 0.8
-    audio.play()
-    audio.onEnded(() => audio.destroy())
-    audio.onError(() => audio.destroy())
-  } catch (e) { void e }
-}
+// playAlertSound 已移除(用户确认不需要声音提醒)
 
 // ==================== 看板 ====================
 function getKanbanOrders(col) {
@@ -891,6 +898,72 @@ onUnmounted(() => { stopPolling() })
   padding: 20rpx 20rpx 40rpx;
 }
 
+/* ==================== 新订单提醒横幅 ==================== */
+.new-order-banner {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  background: linear-gradient(135deg, #f87171, #ef4444);
+  border-radius: 16rpx;
+  padding: 24rpx 28rpx;
+  margin-bottom: 20rpx;
+  animation: banner-pulse 1.5s ease-in-out infinite;
+  box-shadow: 0 4rpx 16rpx rgba(239, 68, 68, 0.3);
+}
+@keyframes banner-pulse {
+  0%, 100% { transform: scale(1); }
+  50% { transform: scale(1.02); }
+}
+.banner-left {
+  display: flex;
+  align-items: center;
+  gap: 16rpx;
+  flex: 1;
+  min-width: 0;
+}
+.banner-bell {
+  font-size: 44rpx;
+  animation: bell-shake 1.5s ease-in-out infinite;
+  flex-shrink: 0;
+}
+@keyframes bell-shake {
+  0%, 100% { transform: rotate(0deg); }
+  15% { transform: rotate(15deg); }
+  30% { transform: rotate(-15deg); }
+  45% { transform: rotate(10deg); }
+  60% { transform: rotate(-10deg); }
+  75% { transform: rotate(5deg); }
+  90% { transform: rotate(-5deg); }
+}
+.banner-info {
+  display: flex;
+  flex-direction: column;
+  gap: 6rpx;
+  min-width: 0;
+}
+.banner-title {
+  font-size: 28rpx;
+  font-weight: 700;
+  color: #fff;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+.banner-items {
+  font-size: 22rpx;
+  color: rgba(255, 255, 255, 0.85);
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+.banner-time {
+  font-size: 22rpx;
+  color: rgba(255, 255, 255, 0.8);
+  white-space: nowrap;
+  flex-shrink: 0;
+  margin-left: 12rpx;
+}
+
 /* ==================== 统计卡片 ==================== */
 .stats-grid {
   display: grid;

+ 5 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/controller/HotelQrCodeController.java

@@ -19,6 +19,7 @@ import com.mdframe.forge.starter.core.annotation.log.OperationLog;
 import com.mdframe.forge.starter.core.domain.OperationType;
 import com.mdframe.forge.starter.core.domain.PageQuery;
 import com.mdframe.forge.starter.core.domain.RespInfo;
+import cn.dev33.satoken.annotation.SaCheckPermission;
 import jakarta.servlet.http.HttpServletResponse;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.web.bind.annotation.GetMapping;
@@ -97,6 +98,7 @@ public class HotelQrCodeController {
      * @return 操作结果
      */
     @PostMapping("/qrcode/bind")
+    @SaCheckPermission("hotel:qrcode:scan")
     @OperationLog(module = "酒店二维码", type = OperationType.UPDATE, desc = "绑定房间")
     public RespInfo<Void> qrCodeBind(@RequestParam Long id, @RequestBody QrCodeBindDTO dto) {
         hotelQrCodeService.qrCodeBind(id, dto);
@@ -114,6 +116,7 @@ public class HotelQrCodeController {
      * @return 操作结果
      */
     @PostMapping("/qrcode/rebind")
+    @SaCheckPermission("hotel:qrcode:scan")
     @OperationLog(module = "酒店二维码", type = OperationType.UPDATE, desc = "重新绑定房间")
     public RespInfo<Void> qrCodeRebind(@RequestParam Long id, @RequestBody QrCodeBindDTO dto) {
         hotelQrCodeService.qrCodeRebind(id, dto);
@@ -129,6 +132,7 @@ public class HotelQrCodeController {
      * @return 操作结果
      */
     @PostMapping("/qrcode/unbind")
+    @SaCheckPermission("hotel:qrcode:scan")
     @OperationLog(module = "酒店二维码", type = OperationType.UPDATE, desc = "解绑房间")
     public RespInfo<Void> qrCodeUnbind(@RequestParam Long id) {
         hotelQrCodeService.qrCodeUnbind(id);
@@ -273,6 +277,7 @@ public class HotelQrCodeController {
      * @return 全部房间列表(含房型名称)
      */
     @GetMapping("/room/list-all")
+    @SaCheckPermission("hotel:qrcode:scan")
     @OperationLog(module = "酒店房间", type = OperationType.QUERY, desc = "查询全部房间")
     public RespInfo<List<HotelRoomVO>> roomListAll() {
         return RespInfo.success(hotelQrCodeService.roomListAll());

+ 26 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/notification/manager/SseEmitterManager.java

@@ -184,6 +184,32 @@ public class SseEmitterManager {
     }
 
     /**
+     * 发布接单通知(餐厅前台接单后触发)。
+     * 厨房端收到后刷新订单列表并语音播报,提醒厨师开始备餐。
+     *
+     * @param orderId      订单ID
+     * @param orderNo      订单号
+     * @param roomNo       房间号
+     * @param contactName  联系人
+     * @param totalAmount  订单金额
+     */
+    public void notifyOrderAccepted(Long orderId, String orderNo, String roomNo,
+                                    String contactName, String totalAmount) {
+        String json = "{\"type\":\"ORDER_ACCEPTED\""
+                + ",\"data\":{"
+                + "\"orderId\":" + orderId
+                + ",\"orderNo\":\"" + escapeJson(orderNo) + "\""
+                + ",\"roomNo\":\"" + escapeJson(roomNo) + "\""
+                + ",\"contactName\":\"" + escapeJson(contactName) + "\""
+                + ",\"totalAmount\":" + totalAmount
+                + "}"
+                + ",\"timestamp\":" + System.currentTimeMillis()
+                + "}";
+        broadcast("ORDER_ACCEPTED", json);
+        log.info("发布接单通知: orderNo={}, roomNo={}", orderNo, roomNo);
+    }
+
+    /**
      * 发布退单申请通知(客户申请退单且订单已接单时触发)。
      * 前端收到后播放提示音并刷新看板数据。
      *

+ 17 - 1
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/order/service/impl/HotelOrderServiceImpl.java

@@ -408,6 +408,18 @@ public class HotelOrderServiceImpl implements HotelOrderService {
         orderMapper.updateById(order);
         log.info("订单接单: {}", order.getOrderNo());
         notifyCustomerStatus(order, HotelOrderConstants.STATUS_ACCEPTED, "餐厅已接单");
+
+        // 发布接单SSE通知,厨房端收到后刷新列表并语音播报
+        try {
+            sseEmitterManager.notifyOrderAccepted(
+                    order.getId(),
+                    order.getOrderNo(),
+                    order.getRoomNo(),
+                    order.getContactName(),
+                    order.getTotalAmount() != null ? order.getTotalAmount().toPlainString() : "0");
+        } catch (Exception e) {
+            log.error("发布接单通知失败: orderId={}", order.getId(), e);
+        }
     }
 
     @Override
@@ -697,13 +709,17 @@ public class HotelOrderServiceImpl implements HotelOrderService {
 
     @Override
     public KitchenStatsVO kitchenStats(Date queryDate) {
-        KitchenStatsVO stats = orderMapper.selectKitchenStats(resolveTenantId(), queryDate);
+        Long tenantId = resolveTenantId();
+        log.info("[厨房统计] 查询参数: tenantId={}, queryDate={}", tenantId, queryDate);
+        KitchenStatsVO stats = orderMapper.selectKitchenStats(tenantId, queryDate);
         if (stats == null) {
             stats = new KitchenStatsVO();
             stats.setPendingCount(0);
             stats.setPreparingCount(0);
             stats.setTodayCompletedCount(0);
         }
+        log.info("[厨房统计] 统计结果: pendingCount={}, preparingCount={}, todayCompletedCount={}", 
+                stats.getPendingCount(), stats.getPreparingCount(), stats.getTodayCompletedCount());
         return stats;
     }
 

+ 3 - 2
forge-server/forge-business/forge-hotel/src/main/resources/mapper/business/hotel/order/HotelOrderMapper.xml

@@ -306,15 +306,16 @@
     </select>
 
     <!--
-      厨房端统计:待备餐数(status=2)、制作中数(status=3)、今日完成数(status>=4 即已出餐及之后)。
+      厨房端统计:待备餐数(status=2)、制作中数(status=3)、今日完成数(status=4/5/6 即已出餐/配送中/已完成)。
       厨房视角:出餐即完成,不需要等到配送/最终完成。
+      注意:不包括退单、拒单、取消等终态订单(status=7/8/9/10/11)。
       三条指标一条 SQL 查出,减少数据库往返。
     -->
     <select id="selectKitchenStats" resultType="com.mdframe.forge.business.core.hotel.order.vo.KitchenStatsVO">
         SELECT
             SUM(CASE WHEN o.status = 2 THEN 1 ELSE 0 END) AS pendingCount,
             SUM(CASE WHEN o.status = 3 THEN 1 ELSE 0 END) AS preparingCount,
-            SUM(CASE WHEN o.status >= 4 AND DATE(o.create_time) = COALESCE(DATE(#{queryDate}), CURDATE()) THEN 1 ELSE 0 END) AS todayCompletedCount
+            SUM(CASE WHEN o.status IN (4, 5, 6) AND DATE(o.create_time) = COALESCE(DATE(#{queryDate}), CURDATE()) THEN 1 ELSE 0 END) AS todayCompletedCount
         FROM hotel_order o
         WHERE o.tenant_id = #{tenantId}
           AND o.del_flag = 0