Files
guilixu-admin/src/views/shop/shopOrder/useOrderNotify.ts
赵忠林 5106fe101c feat(shopDashboard): 为 Dashboard 添加新订单声音提醒功能
- 将 useOrderNotify 改造为实例安全的 Composable,支持多个页面独立调用
- 在 shop/dashboard 和 cms/dashboard 页面添加新订单提醒开关和测试按钮
- 新订单提醒触发时自动刷新 Dashboard 数据,提升数据同步性
- 新订单提醒包括叮声和语音播报,用户交互后自动解锁音频播放
- cms/dashboard 概况卡片标题添加提醒控件,shop/dashboard 欢迎横幅右侧添加提醒栏
- 提取 cms/dashboard loadData 函数实现复用,统一数据加载逻辑
- 优化组件状态管理,防止多个实例间状态冲突,提升稳定性和可维护性
2026-07-14 20:58:40 +08:00

257 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
/** 叮声频率Hz880 = 高音叮 */
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<typeof setInterval> | 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
};
}