feat(shopDashboard): 为 Dashboard 添加新订单声音提醒功能

- 将 useOrderNotify 改造为实例安全的 Composable,支持多个页面独立调用
- 在 shop/dashboard 和 cms/dashboard 页面添加新订单提醒开关和测试按钮
- 新订单提醒触发时自动刷新 Dashboard 数据,提升数据同步性
- 新订单提醒包括叮声和语音播报,用户交互后自动解锁音频播放
- cms/dashboard 概况卡片标题添加提醒控件,shop/dashboard 欢迎横幅右侧添加提醒栏
- 提取 cms/dashboard loadData 函数实现复用,统一数据加载逻辑
- 优化组件状态管理,防止多个实例间状态冲突,提升稳定性和可维护性
This commit is contained in:
2026-07-14 20:58:40 +08:00
parent a2640ba7dc
commit 5106fe101c
4 changed files with 192 additions and 244 deletions

View File

@@ -13,6 +13,9 @@ import type { ShopOrder } from '@/api/shop/shopOrder/model';
*
* 浏览器策略:音频播放需要用户交互后才允许。
* 默认开启轮询,音频在用户首次交互后自动解锁。
*
* **实例安全**:每次调用 useOrderNotify() 创建独立实例,
* 多个页面可同时使用而不冲突。AudioContext 在模块级共享。
*/
// ============ 配置 ============
@@ -29,32 +32,14 @@ const DING_DURATION = 0.3;
/** 语音播报文案 */
const SPEECH_TEXT = '您有一条新的订单';
// ============ 状态 ============
// ============ 模块级共享状态(音频相关) ============
/** 是否启用提醒 */
const enabled = ref(true);
/** 是否正在轮询 */
let polling = false;
/** 定时器 ID */
let timerId: ReturnType<typeof setInterval> | null = null;
/** 上次记录的最新订单 ID */
let lastOrderId: number | undefined = undefined;
/** 音频上下文(延迟创建,需用户交互后) */
/** 音频上下文(模块级共享,延迟创建,需用户交互后) */
let audioCtx: AudioContext | null = null;
/** 是否已初始化(首次查询记录基准值,不触发提醒 */
let initialized = false;
/** 用户交互解锁音频的监听器(一次性) */
/** 用户交互解锁音频的监听器(模块级,全局只注册一次 */
let unlockHandler: (() => void) | null = null;
/** 新订单回调函数(由调用方传入,用于刷新页面数据) */
let onNewOrderCallback: (() => void) | null = null;
// ============ 声音播放 ============
/**
@@ -79,6 +64,7 @@ function initAudioContext() {
/**
* 注册用户交互监听器,首次交互时解锁音频
* 浏览器策略:音频播放需要用户交互后才允许,注册一次性监听器自动解锁
* 全局只注册一次,多实例共享
*/
function registerAudioUnlock() {
if (unlockHandler) return;
@@ -112,11 +98,8 @@ function playDingSound() {
// 创建增益节点(控制音量包络)
const gainNode = audioCtx.createGain();
// 起始音量 0
gainNode.gain.setValueAtTime(0, now);
// 快速上升到 0.5(攻击阶段)
gainNode.gain.linearRampToValueAtTime(0.5, now + 0.01);
// 然后指数衰减到 0释放阶段
gainNode.gain.exponentialRampToValueAtTime(0.001, now + DING_DURATION);
// 连接:振荡器 → 增益 → 输出
@@ -139,99 +122,25 @@ function speak(text: string) {
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = 'zh-CN';
utterance.rate = 1; // 语速
utterance.pitch = 1; // 音调
utterance.volume = 1; // 音量
utterance.rate = 1;
utterance.pitch = 1;
utterance.volume = 1;
window.speechSynthesis.speak(utterance);
}
/**
* 触发提醒:叮声 + 语音播报 + 刷新页面
* 触发提醒:叮声 + 语音播报 + 通知回调
*/
function triggerNotify() {
// 尝试初始化音频上下文(如果已解锁则生效,未解锁则静默失败)
function triggerNotify(onNewOrderCallback: (() => void) | null) {
initAudioContext();
playDingSound();
speak(SPEECH_TEXT);
// 通知调用方刷新页面数据
if (onNewOrderCallback) {
onNewOrderCallback();
}
}
// ============ 轮询逻辑 ============
/**
* 查询最新订单,检测是否有新订单
*/
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;
}
// 比较 orderId如果变了且新 orderId 更大,说明有新订单
if (
currentOrderId !== lastOrderId &&
(lastOrderId === undefined || (currentOrderId ?? 0) > lastOrderId)
) {
// 有新订单!
triggerNotify();
}
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;
}
}
// ============ 对外接口 ============
/**
@@ -239,47 +148,94 @@ function stopPolling() {
*
* 用法:
* ```ts
* const { enabled, toggle } = useOrderNotify({
* onNewOrder: () => reload() // 检测到新订单时刷新表格
* const { enabled, toggle, testNotify } = useOrderNotify({
* onNewOrder: () => reload()
* });
* // 用户点击开关时调用 toggle(true/false)
* ```
*
* @param options.onNewOrder 检测到新订单时的回调(通常用于刷新页面数据)
*/
export function useOrderNotify(options?: { onNewOrder?: () => void }) {
// 缓存回调
onNewOrderCallback = options?.onNewOrder ?? null;
// ============ 实例级状态 ============
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();
// 清理交互监听器
if (unlockHandler) {
document.removeEventListener('click', unlockHandler);
document.removeEventListener('keydown', unlockHandler);
unlockHandler = null;
}
// 清理回调引用
onNewOrderCallback = null;
});
/**
* 切换提醒开关
* @param value 是否启用
*/
// ============ 对外方法 ============
const toggle = (value: boolean) => {
enabled.value = value;
if (value) {
// 用户交互触发,初始化音频上下文
initAudioContext();
startPolling();
} else {
@@ -287,12 +243,9 @@ export function useOrderNotify(options?: { onNewOrder?: () => void }) {
}
};
/**
* 测试提醒(用户点击测试按钮时调用)
*/
const testNotify = () => {
initAudioContext();
triggerNotify();
triggerNotify(onNewOrderCallback);
};
return {