import { ref, onMounted, onBeforeUnmount } from 'vue'; import { pageShopOrder } from '@/api/shop/shopOrder'; import type { ShopOrder } from '@/api/shop/shopOrder/model'; /** * 新订单提醒 - 轮询检测 + 声音/语音播报 * * 原理: * 1. 定时轮询 pageShopOrder(只查最新 1 条) * 2. 与上次记录的 orderId 比较,不同则说明有新订单 * 3. 播放"叮"声(Web Audio API) + 语音播报"您有一条新的订单"(Speech Synthesis API) * 4. 同时触发 onNewOrder 回调,通知调用方刷新页面数据 * * 浏览器策略:音频播放需要用户交互后才允许。 * 默认开启轮询,音频在用户首次交互后自动解锁。 * * **实例安全**:每次调用 useOrderNotify() 创建独立实例, * 多个页面可同时使用而不冲突。AudioContext 在模块级共享。 */ // ============ 配置 ============ /** 轮询间隔(毫秒) */ const POLL_INTERVAL = 60_000; /** 叮声频率(Hz),880 = 高音叮 */ const DING_FREQUENCY = 880; /** 叮声持续时间(秒) */ const DING_DURATION = 0.3; /** 语音播报文案 */ const SPEECH_TEXT = '您有一条新的订单'; // ============ 模块级共享状态(音频相关) ============ /** 音频上下文(模块级共享,延迟创建,需用户交互后) */ let audioCtx: AudioContext | null = null; /** 用户交互解锁音频的监听器(模块级,全局只注册一次) */ let unlockHandler: (() => void) | null = null; // ============ 声音播放 ============ /** * 初始化 AudioContext(必须在用户交互后调用才能生效) */ function initAudioContext() { if (!audioCtx) { try { audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)(); } catch (e) { console.warn('[订单提醒] AudioContext 创建失败:', e); return; } } // 如果上下文被暂停(浏览器策略),尝试恢复 if (audioCtx.state === 'suspended') { audioCtx.resume(); } } /** * 注册用户交互监听器,首次交互时解锁音频 * 浏览器策略:音频播放需要用户交互后才允许,注册一次性监听器自动解锁 * 全局只注册一次,多实例共享 */ function registerAudioUnlock() { if (unlockHandler) return; unlockHandler = () => { initAudioContext(); // 解锁后移除监听器 if (unlockHandler) { document.removeEventListener('click', unlockHandler); document.removeEventListener('keydown', unlockHandler); unlockHandler = null; } }; document.addEventListener('click', unlockHandler, { once: true }); document.addEventListener('keydown', unlockHandler, { once: true }); } /** * 播放"叮"声 - 使用 Web Audio API 生成,无需音频文件 */ function playDingSound() { if (!audioCtx) return; const now = audioCtx.currentTime; // 创建振荡器 const oscillator = audioCtx.createOscillator(); oscillator.type = 'sine'; oscillator.frequency.setValueAtTime(DING_FREQUENCY, now); // 创建增益节点(控制音量包络) const gainNode = audioCtx.createGain(); gainNode.gain.setValueAtTime(0, now); gainNode.gain.linearRampToValueAtTime(0.5, now + 0.01); gainNode.gain.exponentialRampToValueAtTime(0.001, now + DING_DURATION); // 连接:振荡器 → 增益 → 输出 oscillator.connect(gainNode); gainNode.connect(audioCtx.destination); // 播放 oscillator.start(now); oscillator.stop(now + DING_DURATION); } /** * 语音播报 - 使用浏览器原生 Speech Synthesis API */ function speak(text: string) { if (!('speechSynthesis' in window)) return; // 取消可能正在进行的播报 window.speechSynthesis.cancel(); const utterance = new SpeechSynthesisUtterance(text); utterance.lang = 'zh-CN'; utterance.rate = 1; utterance.pitch = 1; utterance.volume = 1; window.speechSynthesis.speak(utterance); } /** * 触发提醒:叮声 + 语音播报 + 通知回调 */ function triggerNotify(onNewOrderCallback: (() => void) | null) { initAudioContext(); playDingSound(); speak(SPEECH_TEXT); if (onNewOrderCallback) { onNewOrderCallback(); } } // ============ 对外接口 ============ /** * 新订单提醒组合式函数 * * 用法: * ```ts * const { enabled, toggle, testNotify } = useOrderNotify({ * onNewOrder: () => reload() * }); * ``` * * @param options.onNewOrder 检测到新订单时的回调(通常用于刷新页面数据) */ export function useOrderNotify(options?: { onNewOrder?: () => void }) { // ============ 实例级状态 ============ const enabled = ref(true); let polling = false; let timerId: ReturnType | null = null; let lastOrderId: number | undefined = undefined; let initialized = false; let onNewOrderCallback: (() => void) | null = options?.onNewOrder ?? null; // ============ 轮询逻辑 ============ async function checkNewOrder() { try { const result = await pageShopOrder({ page: 1, limit: 1, type: 0 }); const latestOrder: ShopOrder | undefined = result?.list?.[0]; if (!latestOrder) { lastOrderId = undefined; return; } const currentOrderId = latestOrder.orderId; if (!initialized) { lastOrderId = currentOrderId; initialized = true; return; } if ( currentOrderId !== lastOrderId && (lastOrderId === undefined || (currentOrderId ?? 0) > lastOrderId) ) { triggerNotify(onNewOrderCallback); } lastOrderId = currentOrderId; } catch (error) { console.warn('[订单提醒] 轮询查询失败:', error); } } function startPolling() { if (polling) return; polling = true; initialized = false; checkNewOrder(); timerId = setInterval(checkNewOrder, POLL_INTERVAL); } function stopPolling() { polling = false; if (timerId) { clearInterval(timerId); timerId = null; } } // ============ 生命周期 ============ onMounted(() => { registerAudioUnlock(); startPolling(); }); onBeforeUnmount(() => { stopPolling(); onNewOrderCallback = null; }); // ============ 对外方法 ============ const toggle = (value: boolean) => { enabled.value = value; if (value) { initAudioContext(); startPolling(); } else { stopPolling(); } }; const testNotify = () => { initAudioContext(); triggerNotify(onNewOrderCallback); }; return { enabled, toggle, testNotify }; }