- 将 useOrderNotify 改造为实例安全的 Composable,支持多个页面独立调用 - 在 shop/dashboard 和 cms/dashboard 页面添加新订单提醒开关和测试按钮 - 新订单提醒触发时自动刷新 Dashboard 数据,提升数据同步性 - 新订单提醒包括叮声和语音播报,用户交互后自动解锁音频播放 - cms/dashboard 概况卡片标题添加提醒控件,shop/dashboard 欢迎横幅右侧添加提醒栏 - 提取 cms/dashboard loadData 函数实现复用,统一数据加载逻辑 - 优化组件状态管理,防止多个实例间状态冲突,提升稳定性和可维护性
257 lines
6.5 KiB
TypeScript
257 lines
6.5 KiB
TypeScript
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<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
|
||
};
|
||
}
|