feat(shopOrder): 实现电脑版后台全局新订单红点提示
- 新建模块级全局单例 useOrderNotify,统一轮询检测新订单,避免多组件重复请求 - 轮询检测到新订单时触发叮声、语音播报,通知所有已注册回调刷新数据 - 订单管理菜单及 dashboard 快速入口显示红点提示新订单未查看 - 进入订单列表页时调用 clearBadge 同步最新订单ID并清除红点,标记订单已查看 - layout 全局启动新订单提醒,任意页面均生效 - menu-title 组件新增纯红点(dot)徽章显示支持 - dashboard 快速入口图标加红点显示及脉冲动画,提升视觉提示效果 - 维护新订单提醒回调集合,组件卸载自动移除回调避免内存泄漏 - 新订单提醒配置项和状态均为模块共享响应式,支持多组件无冲突使用
This commit is contained in:
@@ -101,7 +101,10 @@
|
||||
class="quick-card"
|
||||
@click="navigateTo(item.to)"
|
||||
>
|
||||
<div class="quick-icon" :style="{ background: item.bg }">{{ item.icon }}</div>
|
||||
<div class="quick-icon" :style="{ background: item.bg }">
|
||||
{{ item.icon }}
|
||||
<span v-if="item.badge" class="quick-icon-dot"></span>
|
||||
</div>
|
||||
<div class="quick-label">{{ item.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -290,7 +293,6 @@
|
||||
<!-- 支付 Modal:微信扫码支付 -->
|
||||
<a-modal
|
||||
v-model:open="payModalVisible"
|
||||
title="微信扫码支付"
|
||||
:footer="null"
|
||||
:mask-closable="false"
|
||||
@cancel="onPayModalClose"
|
||||
@@ -644,15 +646,15 @@ const todayStats = computed(() => ({
|
||||
couponUsed: couponUsedCount.value || 0,
|
||||
}));
|
||||
|
||||
// 快速入口
|
||||
const quickLinks = [
|
||||
{ to: '/shop/shopOrder?tab=all', icon: '📦', label: '订单管理', bg: '#f0fdf4' },
|
||||
// 快速入口(订单管理项随新订单状态显示红点)
|
||||
const quickLinks = computed(() => [
|
||||
{ to: '/shop/shopOrder?tab=all', icon: '📦', label: '订单管理', bg: '#f0fdf4', badge: hasNewOrder.value },
|
||||
{ to: '/shop/shopGoods', icon: '🏸', label: '商品管理', bg: '#ecfdf5' },
|
||||
{ to: '/shop/shopGoodsCategory', icon: '🗂️', label: '商品分类', bg: '#eff6ff' },
|
||||
{ to: '/market/coupon', icon: '🎁', label: '优惠券', bg: '#fff7ed' },
|
||||
{ to: '/system/user', icon: '👥', label: '会员管理', bg: '#faf5ff' },
|
||||
{ to: '/shop/shopSetting', icon: '⚙️', label: '商城设置', bg: '#fefce8' },
|
||||
];
|
||||
]);
|
||||
|
||||
// 导航跳转
|
||||
const navigateTo = (path: string) => {
|
||||
@@ -678,8 +680,8 @@ const handleClearCache = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// 新订单声音提醒
|
||||
const { enabled: notifyEnabled, toggle: onNotifyToggle, testNotify: onTestNotify } = useOrderNotify({
|
||||
// 新订单声音提醒(全局单例:轮询由 layout 启动,此处仅消费状态 + UI)
|
||||
const { enabled: notifyEnabled, hasNewOrder, toggle: onNotifyToggle, testNotify: onTestNotify } = useOrderNotify({
|
||||
onNewOrder: () => loadData()
|
||||
});
|
||||
|
||||
@@ -903,6 +905,24 @@ onUnmounted(() => {
|
||||
.quick-icon {
|
||||
width: 44px; height: 44px; border-radius: 10px;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 22px;
|
||||
position: relative;
|
||||
}
|
||||
/* 快速入口新订单红点 */
|
||||
.quick-icon-dot {
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
right: -3px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #ff4d4f;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 0 0 1px rgba(255, 77, 79, 0.3);
|
||||
animation: quick-dot-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes quick-dot-pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.25); opacity: 0.85; }
|
||||
}
|
||||
.quick-label { font-size: 13px; color: rgba(0,0,0,0.75); font-weight: 500; }
|
||||
|
||||
|
||||
@@ -380,7 +380,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { createVNode, ref } from 'vue';
|
||||
import { createVNode, ref, onMounted, onActivated } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import type { EleProTable } from 'ele-admin-pro';
|
||||
import type {
|
||||
@@ -447,9 +447,19 @@
|
||||
);
|
||||
|
||||
// ============ 新订单提醒 ============
|
||||
const { enabled: notifyEnabled, toggle: onNotifyToggle, testNotify: onTestNotify } = useOrderNotify({
|
||||
const { enabled: notifyEnabled, toggle: onNotifyToggle, testNotify: onTestNotify, clearBadge } = useOrderNotify({
|
||||
onNewOrder: () => reload()
|
||||
});
|
||||
// 进入订单列表页时清除菜单红点(标记已查看),并同步 lastOrderId 为当前最新订单
|
||||
// 兼容 keep-alive:onMounted 处理首次进入,onActivated 处理缓存再次激活
|
||||
let orderBadgeMounted = false;
|
||||
onMounted(() => {
|
||||
clearBadge();
|
||||
orderBadgeMounted = true;
|
||||
});
|
||||
onActivated(() => {
|
||||
if (orderBadgeMounted) clearBadge();
|
||||
});
|
||||
// 根据tab key获取对应的statusFilter值
|
||||
// undefined全部,0待付款,1待发货,2待核销,3待收货,4待评价,5已完成,6已退款,7已删除,8已关闭
|
||||
const getStatusFilterByTab = (key: string): number | undefined => {
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { ref, onBeforeUnmount } from 'vue';
|
||||
import { pageShopOrder } from '@/api/shop/shopOrder';
|
||||
import type { ShopOrder } from '@/api/shop/shopOrder/model';
|
||||
import { useUserStore } from '@/store/modules/user';
|
||||
|
||||
/**
|
||||
* 新订单提醒 - 轮询检测 + 声音/语音播报
|
||||
* 新订单提醒 - 全局单例轮询 + 红点徽章 + 声音/语音播报
|
||||
*
|
||||
* 原理:
|
||||
* 1. 定时轮询 pageShopOrder(只查最新 1 条)
|
||||
* 2. 与上次记录的 orderId 比较,不同则说明有新订单
|
||||
* 3. 播放"叮"声(Web Audio API) + 语音播报"您有一条新的订单"(Speech Synthesis API)
|
||||
* 4. 同时触发 onNewOrder 回调,通知调用方刷新页面数据
|
||||
* 设计要点:
|
||||
* 1. **全局单例**:轮询状态(timer、lastOrderId、enabled、hasNewOrder)全部为模块级变量,
|
||||
* 无论多少个组件调用 useOrderNotify(),始终只有一个轮询在运行,避免重复请求。
|
||||
* 2. **红点徽章**:检测到新订单时,调用 userStore.setMenuBadge 给「订单管理」菜单挂红点;
|
||||
* 进入订单列表页时调用 clearBadge() 清除,并同步 lastOrderId 为最新(标记“已查看”)。
|
||||
* 3. **声音/语音**:保留原 Web Audio 叮声 + Speech Synthesis 语音播报。
|
||||
* 4. **回调集合**:多组件可各自注册 onNewOrder 回调(如 dashboard 刷新统计、订单页刷新表格),
|
||||
* 检测到新订单时统一遍历触发。
|
||||
*
|
||||
* 浏览器策略:音频播放需要用户交互后才允许。
|
||||
* 默认开启轮询,音频在用户首次交互后自动解锁。
|
||||
*
|
||||
* **实例安全**:每次调用 useOrderNotify() 创建独立实例,
|
||||
* 多个页面可同时使用而不冲突。AudioContext 在模块级共享。
|
||||
* 生命周期:
|
||||
* - layout 组件挂载时调用 useOrderNotify() 并触发 start(),全局启动一次,常驻不停。
|
||||
* - dashboard / 订单列表页 / cms dashboard 调用 useOrderNotify() 仅消费状态与 UI,不重复启动。
|
||||
*/
|
||||
|
||||
// ============ 配置 ============
|
||||
@@ -32,6 +34,9 @@ const DING_DURATION = 0.3;
|
||||
/** 语音播报文案 */
|
||||
const SPEECH_TEXT = '您有一条新的订单';
|
||||
|
||||
/** 订单管理菜单路径(红点挂在这里) */
|
||||
const ORDER_MENU_PATH = '/shop/shopOrder';
|
||||
|
||||
// ============ 模块级共享状态(音频相关) ============
|
||||
|
||||
/** 音频上下文(模块级共享,延迟创建,需用户交互后) */
|
||||
@@ -40,6 +45,32 @@ let audioCtx: AudioContext | null = null;
|
||||
/** 用户交互解锁音频的监听器(模块级,全局只注册一次) */
|
||||
let unlockHandler: (() => void) | null = null;
|
||||
|
||||
// ============ 模块级单例状态(轮询 + 红点) ============
|
||||
|
||||
/** 是否开启提醒(所有组件共享同一开关) */
|
||||
const enabled = ref(true);
|
||||
|
||||
/** 是否有未查看的新订单(驱动红点 / 快速入口标记) */
|
||||
const hasNewOrder = ref(false);
|
||||
|
||||
/** 轮询是否进行中 */
|
||||
let polling = false;
|
||||
|
||||
/** 轮询定时器 */
|
||||
let timerId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
/** 上次记录的最新订单 ID(用于判断是否有新订单) */
|
||||
let lastOrderId: number | undefined = undefined;
|
||||
|
||||
/** 是否已完成首次初始化(首次只记录基准,不触发提醒) */
|
||||
let initialized = false;
|
||||
|
||||
/** 是否已全局启动(避免重复 start) */
|
||||
let started = false;
|
||||
|
||||
/** 新订单回调集合(多组件注册,统一触发) */
|
||||
const newOrderCallbacks = new Set<() => void>();
|
||||
|
||||
// ============ 声音播放 ============
|
||||
|
||||
/**
|
||||
@@ -130,109 +161,171 @@ function speak(text: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发提醒:叮声 + 语音播报 + 通知回调
|
||||
* 触发提醒:叮声 + 语音播报 + 通知所有已注册回调
|
||||
*/
|
||||
function triggerNotify(onNewOrderCallback: (() => void) | null) {
|
||||
function triggerNotify() {
|
||||
initAudioContext();
|
||||
playDingSound();
|
||||
speak(SPEECH_TEXT);
|
||||
if (onNewOrderCallback) {
|
||||
onNewOrderCallback();
|
||||
// 触发所有已注册回调(刷新各页面数据)
|
||||
newOrderCallbacks.forEach((cb) => {
|
||||
try {
|
||||
cb();
|
||||
} catch (e) {
|
||||
console.warn('[订单提醒] 回调执行失败:', e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ============ 红点徽章控制 ============
|
||||
|
||||
/**
|
||||
* 设置/清除订单管理菜单的红点徽章
|
||||
* @param on true=显示红点,false=清除红点
|
||||
*/
|
||||
function setOrderBadge(on: boolean) {
|
||||
hasNewOrder.value = on;
|
||||
try {
|
||||
const userStore = useUserStore();
|
||||
if (on) {
|
||||
// 'dot' 表示纯红点(不显示数字),由 menu-title.vue 渲染为 a-badge dot
|
||||
userStore.setMenuBadge(ORDER_MENU_PATH, 'dot', '#ff4d4f');
|
||||
} else {
|
||||
// 传 undefined 清除徽章
|
||||
userStore.setMenuBadge(ORDER_MENU_PATH, undefined);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[订单提醒] 设置菜单红点失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 轮询逻辑 ============
|
||||
|
||||
/**
|
||||
* 检测是否有新订单
|
||||
* 只查最新 1 条,与 lastOrderId 比较:不同且更大则说明有新订单
|
||||
*/
|
||||
async function checkNewOrder() {
|
||||
if (!enabled.value) return;
|
||||
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();
|
||||
setOrderBadge(true);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 全局启动 / 停止 ============
|
||||
|
||||
/**
|
||||
* 全局启动订单提醒(由 layout 组件调用一次即可)
|
||||
* 内部用 started 标志保证幂等,重复调用安全
|
||||
*/
|
||||
function start() {
|
||||
if (started) return;
|
||||
started = true;
|
||||
registerAudioUnlock();
|
||||
if (enabled.value) {
|
||||
startPolling();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除红点并同步 lastOrderId 为当前最新订单(标记“已查看”)
|
||||
* 进入订单列表页时调用
|
||||
*/
|
||||
async function clearBadge() {
|
||||
// 立即清除红点(同步,体验即时)
|
||||
setOrderBadge(false);
|
||||
// 重新查询最新订单,把 lastOrderId 同步为最新,标记“已查看到此订单”
|
||||
// 这样后续只有比这个更新的订单才会再次触发红点
|
||||
try {
|
||||
const result = await pageShopOrder({ page: 1, limit: 1, type: 0 });
|
||||
const latest: ShopOrder | undefined = result?.list?.[0];
|
||||
lastOrderId = latest?.orderId;
|
||||
initialized = true;
|
||||
} catch (e) {
|
||||
console.warn('[订单提醒] 同步最新订单失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 对外接口 ============
|
||||
|
||||
/**
|
||||
* 新订单提醒组合式函数
|
||||
* 新订单提醒组合式函数(全局单例)
|
||||
*
|
||||
* 用法:
|
||||
* ```ts
|
||||
* const { enabled, toggle, testNotify } = useOrderNotify({
|
||||
* // layout 全局启动(仅一次)
|
||||
* const { start } = useOrderNotify();
|
||||
* start();
|
||||
*
|
||||
* // dashboard / 订单页消费状态与 UI
|
||||
* const { enabled, hasNewOrder, toggle, testNotify, clearBadge } = useOrderNotify({
|
||||
* onNewOrder: () => reload()
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* @param options.onNewOrder 检测到新订单时的回调(通常用于刷新页面数据)
|
||||
* @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);
|
||||
}
|
||||
// 注册回调(组件卸载时自动移除,避免内存泄漏)
|
||||
const cb = options?.onNewOrder;
|
||||
if (cb) {
|
||||
newOrderCallbacks.add(cb);
|
||||
onBeforeUnmount(() => {
|
||||
newOrderCallbacks.delete(cb);
|
||||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -240,17 +333,28 @@ export function useOrderNotify(options?: { onNewOrder?: () => void }) {
|
||||
startPolling();
|
||||
} else {
|
||||
stopPolling();
|
||||
setOrderBadge(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 测试提醒
|
||||
const testNotify = () => {
|
||||
initAudioContext();
|
||||
triggerNotify(onNewOrderCallback);
|
||||
triggerNotify();
|
||||
};
|
||||
|
||||
return {
|
||||
/** 是否开启提醒(响应式,所有组件共享) */
|
||||
enabled,
|
||||
/** 是否有未查看的新订单(响应式,驱动红点) */
|
||||
hasNewOrder,
|
||||
/** 开关切换 */
|
||||
toggle,
|
||||
testNotify
|
||||
/** 测试提醒(声音 + 语音) */
|
||||
testNotify,
|
||||
/** 清除红点并标记已查看(进入订单页时调用) */
|
||||
clearBadge,
|
||||
/** 全局启动(layout 调用一次) */
|
||||
start
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user