feat(shopOrder): 实现电脑版后台全局新订单红点提示

- 新建模块级全局单例 useOrderNotify,统一轮询检测新订单,避免多组件重复请求
- 轮询检测到新订单时触发叮声、语音播报,通知所有已注册回调刷新数据
- 订单管理菜单及 dashboard 快速入口显示红点提示新订单未查看
- 进入订单列表页时调用 clearBadge 同步最新订单ID并清除红点,标记订单已查看
- layout 全局启动新订单提醒,任意页面均生效
- menu-title 组件新增纯红点(dot)徽章显示支持
- dashboard 快速入口图标加红点显示及脉冲动画,提升视觉提示效果
- 维护新订单提醒回调集合,组件卸载自动移除回调避免内存泄漏
- 新订单提醒配置项和状态均为模块共享响应式,支持多组件无冲突使用
This commit is contained in:
2026-07-15 15:26:48 +08:00
parent 3a951c70f5
commit d5e040b1e6
6 changed files with 266 additions and 106 deletions

View File

@@ -113,3 +113,19 @@
- 引入 `getCompressedImageUrl`(来自 `src/utils/image.ts`),将商品图片 `:src="record.goodsImage"` 改为 `:src="getCompressedImageUrl(record.goodsImage)"`
-`shopGoods``shopOrder``dashboard` 等页面保持一致:默认宽度 240px、质量 90、自动补全 OSS 域名、跳过已含 `x-oss-process` 的 URL。
- 预览行为未改(仍走 a-image 默认 src 预览)。
## 电脑版后台新订单红点提示(全局)
- 需求:电脑版后台有新订单时显示红点,让管理员知道有新订单。
- 关键发现:系统已有菜单红点基础设施(`menu-title.vue``item.meta.badge` 渲染徽章;`user.ts` `setMenuBadge(path,value,color)``layout/index.vue``.ele-menu-badge` 样式),但 `useOrderNotify` 原为组件级dashboard/cms dashboard/shopOrder 三处各自独立轮询),未接通红点。
- 改造 `src/views/shop/shopOrder/useOrderNotify.ts` 为**模块级全局单例**
- 轮询/状态/红点全部模块级变量enabled、hasNewOrder、lastOrderId、timer多组件调用只一个轮询。
- 检测到新订单 → triggerNotify叮声+语音+遍历回调Set+ `setOrderBadge(true)``userStore.setMenuBadge('/shop/shopOrder','dot','#ff4d4f')`
- 新增 `clearBadge()`:立即清红点 + 重查最新订单同步 lastOrderId标记"已查看")。
- `onNewOrder` 回调用 Set 管理,组件 onBeforeUnmount 自动移除;`start()` 幂等,由 layout 调一次。
- `src/layout/components/menu-title.vue``badge==='dot'` 时用 `<a-badge dot />` 纯红点,否则原数字徽章逻辑。
- `src/layout/index.vue`setup 调 `useOrderNotify().start()` 全局启动,后台任意页面生效。
- `src/views/shop/shopOrder/index.vue``onMounted` + `onActivated``clearBadge()`(兼容 keep-alive `cache-key`,用 `orderBadgeMounted` 标志防首次双触发)。
- `src/views/shop/dashboard/index.vue``quickLinks` 改 computed订单管理项 `badge: hasNewOrder.value``quick-icon` 加红点 span + pulse 动画样式。
- 决策(用户确认):红点=新订单未查看(进订单页清除);位置=订单管理菜单+dashboard快速入口订单管理图标声音保留并全局化。
- 构建验证:`vite build --outDir dist_verify` 通过EXIT:0。原 `pnpm build` 失败仅因 vite 清空 `dist/assets` 被安全删除保护拦截83文件>50阈值与代码无关。

View File

@@ -1,7 +1,11 @@
<template>
<span>{{ item.meta.title }}</span>
<div v-if="item.meta && item.meta.badge" class="ele-menu-badge">
<!-- dot 模式纯红点不显示数字用于新订单提醒等 -->
<a-badge v-if="item.meta.badge === 'dot'" dot />
<!-- 数字徽章模式 -->
<a-badge
v-else
:count="item.meta.badge"
:number-style="{ background: item.meta.badgeColor as string }"
/>

View File

@@ -100,6 +100,7 @@
import HeaderTools from './components/header-tools.vue';
import PageFooter from './components/page-footer.vue';
import MenuTitle from './components/menu-title.vue';
import { useOrderNotify } from '@/views/shop/shopOrder/useOrderNotify';
import {
HIDE_SIDEBARS,
HIDE_FOOTERS,
@@ -127,6 +128,11 @@
const { t, locale } = useI18n();
const userStore = useUserStore();
// 全局启动新订单提醒:任意页面都会轮询检测新订单,
// 检测到后在「订单管理」菜单显示红点 + 叮声/语音播报
const { start: startOrderNotify } = useOrderNotify();
startOrderNotify();
// 是否刷新页面
if (localStorage.getItem('Reload')) {
window.location.reload();

View File

@@ -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; }

View File

@@ -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-aliveonMounted 处理首次进入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 => {

View File

@@ -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
};
}