feat(shop): 实现新订单声音和语音提醒功能

- 新增 useOrderNotify 组合式函数,支持每60秒轮询检测新订单
- 采用 Web Audio API 播放叮声,Speech Synthesis API 进行语音播报
- shopOrder 页面添加提醒开关栏,包含开关、提示和测试按钮
- 开关默认关闭,需用户点击启用后启动声音和轮询功能
- 测试按钮仅在提醒开启时可用,用于验证提醒效果
- 完善样式,提升提醒栏的视觉效果和交互体验
- 统一更新相关环境变量,调整项目名称和接口地址
This commit is contained in:
2026-07-06 12:41:05 +08:00
parent d4ca571c01
commit ac574335a9
6 changed files with 328 additions and 6 deletions

View File

@@ -10,6 +10,30 @@
/>
</a-card>
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<div class="order-notify-bar">
<a-space>
<span class="order-notify-label">新订单提醒</span>
<a-switch
v-model:checked="notifyEnabled"
checked-children=""
un-checked-children=""
@change="onNotifyToggle"
/>
<a-tooltip
title="开启后每60秒自动检测新订单检测到后播放叮声并语音播报"
>
<InfoCircleOutlined class="order-notify-tip" />
</a-tooltip>
<a-button
type="link"
size="small"
:disabled="!notifyEnabled"
@click="onTestNotify"
>
测试提醒
</a-button>
</a-space>
</div>
<a-tabs type="card" v-model:activeKey="activeKey" @change="onTabs">
<a-tab-pane key="all" tab="全部" />
<a-tab-pane key="undelivered" tab="待发货" />
@@ -315,7 +339,8 @@
CheckCircleOutlined,
CloseCircleOutlined,
RedoOutlined,
DeleteOutlined
DeleteOutlined,
InfoCircleOutlined
} from '@ant-design/icons-vue';
import Search from './components/search.vue';
import { getPageTitle } from '@/utils/common';
@@ -333,6 +358,7 @@
import { updateUser } from '@/api/system/user';
import { getPayType } from '@/utils/shop';
import { message, Modal } from 'ant-design-vue';
import { useOrderNotify } from './useOrderNotify';
// 表格实例
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
@@ -351,6 +377,9 @@
const loading = ref(true);
// 激活的标签
const activeKey = ref<string>('undelivered');
// ============ 新订单提醒 ============
const { enabled: notifyEnabled, toggle: onNotifyToggle, testNotify: onTestNotify } = useOrderNotify();
// 表格数据源
const datasource: DatasourceFunction = ({
page,
@@ -775,4 +804,23 @@
};
</script>
<style lang="less" scoped></style>
<style lang="less" scoped>
.order-notify-bar {
display: flex;
align-items: center;
margin-bottom: 12px;
padding: 8px 12px;
background: var(--ant-color-fill-alter, #fafafa);
border-radius: 6px;
}
.order-notify-label {
font-size: 14px;
font-weight: 500;
}
.order-notify-tip {
color: var(--ant-color-text-secondary, #999);
cursor: help;
}
</style>

View File

@@ -0,0 +1,241 @@
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
*
* 浏览器策略:音频播放需要用户交互后才允许。
* 默认开启轮询,音频在用户首次交互后自动解锁。
*/
// ============ 配置 ============
/** 轮询间隔(毫秒) */
const POLL_INTERVAL = 60_000;
/** 叮声频率Hz880 = 高音叮 */
const DING_FREQUENCY = 880;
/** 叮声持续时间(秒) */
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;
// ============ 声音播放 ============
/**
* 初始化 AudioContext必须在用户交互后调用
*/
function initAudioContext() {
if (!audioCtx) {
audioCtx = new (window.AudioContext ||
(window as any).webkitAudioContext)();
}
// 如果上下文被暂停(浏览器策略),尝试恢复
if (audioCtx.state === 'suspended') {
audioCtx.resume();
}
}
/**
* 播放"叮"声 - 使用 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();
// 起始音量 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);
// 连接:振荡器 → 增益 → 输出
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() {
playDingSound();
speak(SPEECH_TEXT);
}
// ============ 轮询逻辑 ============
/**
* 查询最新订单,检测是否有新订单
*/
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;
}
}
// ============ 对外接口 ============
/**
* 新订单提醒组合式函数
*
* 用法:
* ```ts
* const { enabled, toggle } = useOrderNotify();
* // 用户点击开关时调用 toggle(true/false)
* ```
*/
export function useOrderNotify() {
// 组件卸载时自动清理
onBeforeUnmount(() => {
stopPolling();
});
/**
* 切换提醒开关
* @param value 是否启用
*/
const toggle = (value: boolean) => {
enabled.value = value;
if (value) {
// 用户交互触发,初始化音频上下文
initAudioContext();
startPolling();
} else {
stopPolling();
}
};
/**
* 测试提醒(用户点击测试按钮时调用)
*/
const testNotify = () => {
initAudioContext();
triggerNotify();
};
return {
enabled,
toggle,
testNotify
};
}