feat(shop): 实现新订单声音和语音提醒功能
- 新增 useOrderNotify 组合式函数,支持每60秒轮询检测新订单 - 采用 Web Audio API 播放叮声,Speech Synthesis API 进行语音播报 - shopOrder 页面添加提醒开关栏,包含开关、提示和测试按钮 - 开关默认关闭,需用户点击启用后启动声音和轮询功能 - 测试按钮仅在提醒开启时可用,用于验证提醒效果 - 完善样式,提升提醒栏的视觉效果和交互体验 - 统一更新相关环境变量,调整项目名称和接口地址
This commit is contained in:
2
.env
2
.env
@@ -1,2 +1,2 @@
|
|||||||
VITE_APP_NAME=塔吊
|
VITE_APP_NAME=小程序商城
|
||||||
VITE_API_URL=/api
|
VITE_API_URL=/api
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
VITE_APP_NAME=后台管理系统
|
VITE_APP_NAME=后台管理系统
|
||||||
VITE_API_URL=http://127.0.0.1:9200/api
|
#VITE_API_URL=http://127.0.0.1:9200/api
|
||||||
#VITE_API_URL=https://guilixu-api.websoft.top/api
|
VITE_API_URL=https://shop-api.websoft.top/api
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
VITE_APP_NAME=后台管理系统
|
VITE_APP_NAME=后台管理系统
|
||||||
VITE_API_URL=https://guilixu-api.websoft.top/api
|
VITE_API_URL=https://shop-api.websoft.top/api
|
||||||
|
|||||||
33
.workbuddy/memory/2026-07-06.md
Normal file
33
.workbuddy/memory/2026-07-06.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# 2026-07-06 工作日志
|
||||||
|
|
||||||
|
## 新订单声音/语音提醒功能
|
||||||
|
|
||||||
|
### 需求
|
||||||
|
订单管理页面 `/shop/shopOrder` 实现新订单的语音提醒("您有一条新的订单")和"叮"声提醒。
|
||||||
|
|
||||||
|
### 背景
|
||||||
|
- 订单页面原为纯 HTTP 请求获取数据(用户手动触发),无轮询、无 WebSocket
|
||||||
|
- 项目中无任何声音/音频系统
|
||||||
|
|
||||||
|
### 方案
|
||||||
|
采用**轮询检测 + Web Audio API 叮声 + Speech Synthesis API 语音播报**,纯前端实现,零依赖。
|
||||||
|
|
||||||
|
### 实现
|
||||||
|
1. 新建 `src/views/shop/shopOrder/useOrderNotify.ts` 组合式函数
|
||||||
|
- 每 60 秒轮询 `pageShopOrder({ page: 1, limit: 1, type: 0 })` 查询最新订单
|
||||||
|
- 比较最新 orderId,首次查询记录基准值不触发,之后检测到新 orderId 则触发提醒
|
||||||
|
- Web Audio API 生成 880Hz 正弦波"叮"声(含攻击+指数衰减包络)
|
||||||
|
- Speech Synthesis API 播报"您有一条新的订单"
|
||||||
|
- 组件卸载时自动清理定时器(onBeforeUnmount)
|
||||||
|
|
||||||
|
2. 修改 `src/views/shop/shopOrder/index.vue`
|
||||||
|
- 表格上方添加提醒开关栏(a-switch + tooltip + 测试按钮)
|
||||||
|
- 导入 InfoCircleOutlined 图标
|
||||||
|
- 集成 useOrderNotify 组合式函数
|
||||||
|
- 添加 .order-notify-bar 样式
|
||||||
|
|
||||||
|
### 注意事项
|
||||||
|
- 浏览器策略要求音频播放需用户交互后才能触发,所以开关默认关闭
|
||||||
|
- 开关由用户点击开启,此时初始化 AudioContext 并启动轮询
|
||||||
|
- "测试提醒"按钮仅在开关开启后可用,方便用户验证声音效果
|
||||||
|
- 轮询间隔可修改 useOrderNotify.ts 中的 POLL_INTERVAL 常量
|
||||||
@@ -10,6 +10,30 @@
|
|||||||
/>
|
/>
|
||||||
</a-card>
|
</a-card>
|
||||||
<a-card :bordered="false" :body-style="{ padding: '16px' }">
|
<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-tabs type="card" v-model:activeKey="activeKey" @change="onTabs">
|
||||||
<a-tab-pane key="all" tab="全部" />
|
<a-tab-pane key="all" tab="全部" />
|
||||||
<a-tab-pane key="undelivered" tab="待发货" />
|
<a-tab-pane key="undelivered" tab="待发货" />
|
||||||
@@ -315,7 +339,8 @@
|
|||||||
CheckCircleOutlined,
|
CheckCircleOutlined,
|
||||||
CloseCircleOutlined,
|
CloseCircleOutlined,
|
||||||
RedoOutlined,
|
RedoOutlined,
|
||||||
DeleteOutlined
|
DeleteOutlined,
|
||||||
|
InfoCircleOutlined
|
||||||
} from '@ant-design/icons-vue';
|
} from '@ant-design/icons-vue';
|
||||||
import Search from './components/search.vue';
|
import Search from './components/search.vue';
|
||||||
import { getPageTitle } from '@/utils/common';
|
import { getPageTitle } from '@/utils/common';
|
||||||
@@ -333,6 +358,7 @@
|
|||||||
import { updateUser } from '@/api/system/user';
|
import { updateUser } from '@/api/system/user';
|
||||||
import { getPayType } from '@/utils/shop';
|
import { getPayType } from '@/utils/shop';
|
||||||
import { message, Modal } from 'ant-design-vue';
|
import { message, Modal } from 'ant-design-vue';
|
||||||
|
import { useOrderNotify } from './useOrderNotify';
|
||||||
|
|
||||||
// 表格实例
|
// 表格实例
|
||||||
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
|
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
|
||||||
@@ -351,6 +377,9 @@
|
|||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
// 激活的标签
|
// 激活的标签
|
||||||
const activeKey = ref<string>('undelivered');
|
const activeKey = ref<string>('undelivered');
|
||||||
|
|
||||||
|
// ============ 新订单提醒 ============
|
||||||
|
const { enabled: notifyEnabled, toggle: onNotifyToggle, testNotify: onTestNotify } = useOrderNotify();
|
||||||
// 表格数据源
|
// 表格数据源
|
||||||
const datasource: DatasourceFunction = ({
|
const datasource: DatasourceFunction = ({
|
||||||
page,
|
page,
|
||||||
@@ -775,4 +804,23 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</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>
|
||||||
|
|||||||
241
src/views/shop/shopOrder/useOrderNotify.ts
Normal file
241
src/views/shop/shopOrder/useOrderNotify.ts
Normal 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;
|
||||||
|
|
||||||
|
/** 叮声频率(Hz),880 = 高音叮 */
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user