|
@@ -0,0 +1,338 @@
|
|
|
|
|
+import { onUnmounted, ref } from 'vue'
|
|
|
|
|
+import { useAuthStore } from '@/store'
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 酒店订单SSE通知 composable。
|
|
|
|
|
+ * 提供:SSE订阅、Web Audio 提示音、浏览器桌面通知、防抖合并。
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param {object} options 回调选项
|
|
|
|
|
+ * @param {Function} options.onNewOrder 收到新订单通知时的回调
|
|
|
|
|
+ * @param {Function} options.onRefundRequest 收到退单申请通知时的回调
|
|
|
|
|
+ * @returns {object} { connected, connect, disconnect }
|
|
|
|
|
+ */
|
|
|
|
|
+export function useOrderNotification(options = {}) {
|
|
|
|
|
+ const connected = ref(false)
|
|
|
|
|
+ let eventSource = null
|
|
|
|
|
+ let clientId = null
|
|
|
|
|
+
|
|
|
|
|
+ // 将 eventSource 暴露到全局 window 对象,方便调试
|
|
|
|
|
+ if (typeof window !== 'undefined') {
|
|
|
|
|
+ window.__hotelSSEEventSource = () => eventSource
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 防抖合并队列 ====================
|
|
|
|
|
+ const newOrderQueue = ref([])
|
|
|
|
|
+ const refundQueue = ref([])
|
|
|
|
|
+ let mergeTimer = null
|
|
|
|
|
+ const MERGE_WINDOW = 3000 // 3秒防抖窗口
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 触发防抖合并通知。
|
|
|
|
|
+ * 3秒内的多个同类型通知合并为一条提示。
|
|
|
|
|
+ */
|
|
|
|
|
+ function scheduleMergeFlush() {
|
|
|
|
|
+ if (mergeTimer !== null) {
|
|
|
|
|
+ clearTimeout(mergeTimer)
|
|
|
|
|
+ }
|
|
|
|
|
+ mergeTimer = setTimeout(() => {
|
|
|
|
|
+ mergeTimer = null
|
|
|
|
|
+ flushNotifications()
|
|
|
|
|
+ }, MERGE_WINDOW)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 刷新通知队列,合并显示。
|
|
|
|
|
+ */
|
|
|
|
|
+ function flushNotifications() {
|
|
|
|
|
+ if (newOrderQueue.value.length > 0) {
|
|
|
|
|
+ const count = newOrderQueue.value.length
|
|
|
|
|
+ const lastOrder = newOrderQueue.value[newOrderQueue.value.length - 1]
|
|
|
|
|
+ if (count === 1) {
|
|
|
|
|
+ showInPageNotification('new-order', `新订单:房间 ${lastOrder.roomNo}`, 'info')
|
|
|
|
|
+ }
|
|
|
|
|
+ else {
|
|
|
|
|
+ showInPageNotification('new-order', `您有 ${count} 个新订单待处理`, 'info')
|
|
|
|
|
+ }
|
|
|
|
|
+ newOrderQueue.value = []
|
|
|
|
|
+ // 触发业务回调(刷新数据)
|
|
|
|
|
+ if (typeof options.onNewOrder === 'function') {
|
|
|
|
|
+ options.onNewOrder(lastOrder)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (refundQueue.value.length > 0) {
|
|
|
|
|
+ const count = refundQueue.value.length
|
|
|
|
|
+ const lastRefund = refundQueue.value[refundQueue.value.length - 1]
|
|
|
|
|
+ if (count === 1) {
|
|
|
|
|
+ showInPageNotification('refund', `退单申请:房间 ${lastRefund.roomNo}`, 'warning')
|
|
|
|
|
+ }
|
|
|
|
|
+ else {
|
|
|
|
|
+ showInPageNotification('refund', `您有 ${count} 个退单申请待处理`, 'warning')
|
|
|
|
|
+ }
|
|
|
|
|
+ refundQueue.value = []
|
|
|
|
|
+ if (typeof options.onRefundRequest === 'function') {
|
|
|
|
|
+ options.onRefundRequest(lastRefund)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 声音提醒(使用 MP3 文件) ====================
|
|
|
|
|
+
|
|
|
|
|
+ // MP3 语音文件路径(放在 public 目录)
|
|
|
|
|
+ const ORDER_ALERT_MP3 = '/女生-订单提醒.mp3'
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 播放提示音(使用 MP3 文件)。
|
|
|
|
|
+ * @param {string} type 类型:'new-order' 或 'refund'
|
|
|
|
|
+ */
|
|
|
|
|
+ function playSound(type) {
|
|
|
|
|
+ console.warn(`[SSE] 📢 开始播放声音: type=${type}`)
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (type === 'new-order') {
|
|
|
|
|
+ // 新订单:播放 MP3 语音
|
|
|
|
|
+ console.warn('[SSE] 播放订单提醒 MP3')
|
|
|
|
|
+ playMp3Audio(ORDER_ALERT_MP3)
|
|
|
|
|
+ }
|
|
|
|
|
+ else if (type === 'refund') {
|
|
|
|
|
+ // 退单申请:也播放同样的 MP3(或者可以换成其他音效)
|
|
|
|
|
+ console.warn('[SSE] 播放退单提醒 MP3')
|
|
|
|
|
+ playMp3Audio(ORDER_ALERT_MP3)
|
|
|
|
|
+ }
|
|
|
|
|
+ console.warn('[SSE] ✅ playSound 函数执行完毕')
|
|
|
|
|
+ }
|
|
|
|
|
+ catch (err) {
|
|
|
|
|
+ console.error('[SSE] ❌ playSound 异常:', err)
|
|
|
|
|
+ void err
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 播放 MP3 音频文件。
|
|
|
|
|
+ * @param {string} mp3Path MP3 文件路径
|
|
|
|
|
+ */
|
|
|
|
|
+ function playMp3Audio(mp3Path) {
|
|
|
|
|
+ console.warn(`[SSE] 准备播放 MP3: ${mp3Path}`)
|
|
|
|
|
+ const audio = new Audio(mp3Path)
|
|
|
|
|
+ audio.volume = 0.8
|
|
|
|
|
+ console.warn(`[SSE] Audio 对象已创建, volume=${audio.volume}, duration=${audio.duration || 'unknown'}`)
|
|
|
|
|
+
|
|
|
|
|
+ // 监听播放事件
|
|
|
|
|
+ audio.addEventListener('canplaythrough', () => {
|
|
|
|
|
+ console.warn('[SSE] 音频可以播放了')
|
|
|
|
|
+ })
|
|
|
|
|
+ audio.addEventListener('playing', () => {
|
|
|
|
|
+ console.warn('[SSE] 音频正在播放中!')
|
|
|
|
|
+ })
|
|
|
|
|
+ audio.addEventListener('ended', () => {
|
|
|
|
|
+ console.warn('[SSE] 音频播放结束')
|
|
|
|
|
+ })
|
|
|
|
|
+ audio.addEventListener('error', (e) => {
|
|
|
|
|
+ console.error('[SSE] 音频加载/播放错误:', e)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ 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)
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 浏览器桌面通知 ====================
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 请求桌面通知权限(首次调用时自动请求)。
|
|
|
|
|
+ */
|
|
|
|
|
+ function requestNotificationPermission() {
|
|
|
|
|
+ if ('Notification' in window && Notification.permission === 'default') {
|
|
|
|
|
+ Notification.requestPermission()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 显示桌面通知。
|
|
|
|
|
+ * @param {string} title 通知标题
|
|
|
|
|
+ * @param {string} body 通知内容
|
|
|
|
|
+ */
|
|
|
|
|
+ function showDesktopNotification(title, body) {
|
|
|
|
|
+ if ('Notification' in window && Notification.permission === 'granted') {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const n = new Notification(title, {
|
|
|
|
|
+ body,
|
|
|
|
|
+ icon: '/favicon.ico',
|
|
|
|
|
+ tag: `hotel-order-${Date.now()}`,
|
|
|
|
|
+ })
|
|
|
|
|
+ setTimeout(() => n.close(), 8000)
|
|
|
|
|
+ }
|
|
|
|
|
+ catch (err) {
|
|
|
|
|
+ // 静默失败
|
|
|
|
|
+ void err
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 页面内通知(通过事件派发,由 order.vue 监听显示) ====================
|
|
|
|
|
+ // 使用自定义事件,避免 composable 直接依赖 Naive UI
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 显示页面内通知(派发 DOM 事件,由 order.vue 监听)。
|
|
|
|
|
+ */
|
|
|
|
|
+ function showInPageNotification(type, message, level) {
|
|
|
|
|
+ window.dispatchEvent(new CustomEvent('hotel-notification', {
|
|
|
|
|
+ detail: { type, message, level },
|
|
|
|
|
+ }))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== SSE 连接管理 ====================
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 建立SSE连接。
|
|
|
|
|
+ */
|
|
|
|
|
+ function connect() {
|
|
|
|
|
+ console.warn('[SSE] ========== connect() 函数被调用 ==========')
|
|
|
|
|
+ console.warn('[SSE] 当前时间:', new Date().toLocaleString())
|
|
|
|
|
+ console.warn('[SSE] eventSource 是否存在:', !!eventSource)
|
|
|
|
|
+
|
|
|
|
|
+ if (eventSource) {
|
|
|
|
|
+ console.warn('[SSE] ⚠️ eventSource 已存在,跳过连接')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 生成客户端ID(UUID)
|
|
|
|
|
+ clientId = `pc-${Math.random().toString(36).substring(2, 10)}-${Date.now()}`
|
|
|
|
|
+ console.warn('[SSE] 生成 clientId:', clientId)
|
|
|
|
|
+
|
|
|
|
|
+ // 获取登录token(Sa-Token isReadBody=true 会从请求参数中读取)
|
|
|
|
|
+ const authStore = useAuthStore()
|
|
|
|
|
+ const token = authStore.accessToken || ''
|
|
|
|
|
+ console.warn('[SSE] 获取 token:', token ? '有token' : '无token')
|
|
|
|
|
+
|
|
|
|
|
+ // 构建SSE URL(使用与 Axios 相同的请求前缀,token通过查询参数传递)
|
|
|
|
|
+ const prefix = import.meta.env.VITE_REQUEST_PREFIX || ''
|
|
|
|
|
+ const url = `${prefix}/hotel/notification/subscribe?clientId=${encodeURIComponent(clientId)}&Authorization=${encodeURIComponent(token)}`
|
|
|
|
|
+ console.warn('[SSE] SSE URL:', url)
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ eventSource = new EventSource(url)
|
|
|
|
|
+ console.warn('[SSE] ✅ EventSource 创建成功')
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.error('[SSE] ❌ EventSource 创建失败:', err)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 连接成功
|
|
|
|
|
+ eventSource.addEventListener('connected', () => {
|
|
|
|
|
+ connected.value = true
|
|
|
|
|
+ console.warn('[SSE] ✅ 通知连接已建立')
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // 监听 open 事件(表示连接成功打开)
|
|
|
|
|
+ eventSource.onopen = () => {
|
|
|
|
|
+ console.warn('[SSE] 🟢 EventSource 连接已打开')
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 新订单通知
|
|
|
|
|
+ eventSource.addEventListener('NEW_ORDER', (event) => {
|
|
|
|
|
+ console.warn('[SSE] 🔔 收到 NEW_ORDER 事件')
|
|
|
|
|
+ console.warn('[SSE] 原始数据:', event.data)
|
|
|
|
|
+ try {
|
|
|
|
|
+ const data = JSON.parse(event.data)
|
|
|
|
|
+ console.warn('[SSE] 解析数据:', data)
|
|
|
|
|
+ const orderData = data.data || {}
|
|
|
|
|
+ console.warn('[SSE] 订单数据:', orderData)
|
|
|
|
|
+ // 播放声音
|
|
|
|
|
+ console.warn('[SSE] 调用 playSound("new-order")')
|
|
|
|
|
+ playSound('new-order')
|
|
|
|
|
+ // 桌面通知
|
|
|
|
|
+ showDesktopNotification('新订单提醒', `房间 ${orderData.roomNo} 有新订单,金额 ¥${orderData.totalAmount}`)
|
|
|
|
|
+ // 加入防抖队列
|
|
|
|
|
+ newOrderQueue.value.push(orderData)
|
|
|
|
|
+ scheduleMergeFlush()
|
|
|
|
|
+ console.warn('[SSE] ✅ NEW_ORDER 处理完成')
|
|
|
|
|
+ }
|
|
|
|
|
+ catch (e) {
|
|
|
|
|
+ console.error('[SSE] 解析NEW_ORDER消息失败:', e)
|
|
|
|
|
+ console.error('[SSE] 错误详情:', e.message, e.stack)
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // 退单申请通知
|
|
|
|
|
+ eventSource.addEventListener('REFUND_REQUEST', (event) => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const data = JSON.parse(event.data)
|
|
|
|
|
+ const orderData = data.data || {}
|
|
|
|
|
+ // 播放声音
|
|
|
|
|
+ playSound('refund')
|
|
|
|
|
+ // 桌面通知
|
|
|
|
|
+ showDesktopNotification('退单申请', `房间 ${orderData.roomNo} 申请退单`)
|
|
|
|
|
+ // 加入防抖队列
|
|
|
|
|
+ refundQueue.value.push(orderData)
|
|
|
|
|
+ scheduleMergeFlush()
|
|
|
|
|
+ }
|
|
|
|
|
+ catch (e) {
|
|
|
|
|
+ console.warn('[SSE] 解析REFUND_REQUEST消息失败:', e)
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // 连接错误(自动重连由 EventSource 内置处理)
|
|
|
|
|
+ eventSource.onerror = () => {
|
|
|
|
|
+ connected.value = false
|
|
|
|
|
+ console.warn('[SSE] 连接断开,等待自动重连...')
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 请求桌面通知权限
|
|
|
|
|
+ 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)
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ document.removeEventListener('click', initAudioOnFirstInteraction)
|
|
|
|
|
+ document.removeEventListener('keydown', initAudioOnFirstInteraction)
|
|
|
|
|
+ }
|
|
|
|
|
+ document.addEventListener('click', initAudioOnFirstInteraction)
|
|
|
|
|
+ document.addEventListener('keydown', initAudioOnFirstInteraction)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 断开SSE连接。
|
|
|
|
|
+ */
|
|
|
|
|
+ function disconnect() {
|
|
|
|
|
+ if (mergeTimer !== null) {
|
|
|
|
|
+ clearTimeout(mergeTimer)
|
|
|
|
|
+ mergeTimer = null
|
|
|
|
|
+ }
|
|
|
|
|
+ if (eventSource) {
|
|
|
|
|
+ eventSource.close()
|
|
|
|
|
+ eventSource = null
|
|
|
|
|
+ connected.value = false
|
|
|
|
|
+ console.warn('[SSE] 通知连接已断开')
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 组件卸载时自动断开
|
|
|
|
|
+ onUnmounted(() => {
|
|
|
|
|
+ disconnect()
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ connected,
|
|
|
|
|
+ connect,
|
|
|
|
|
+ disconnect,
|
|
|
|
|
+ playSound, // 暴露 playSound 方法,供外部调用
|
|
|
|
|
+ }
|
|
|
|
|
+}
|