feat(shopDashboard): 为 Dashboard 添加新订单声音提醒功能
- 将 useOrderNotify 改造为实例安全的 Composable,支持多个页面独立调用 - 在 shop/dashboard 和 cms/dashboard 页面添加新订单提醒开关和测试按钮 - 新订单提醒触发时自动刷新 Dashboard 数据,提升数据同步性 - 新订单提醒包括叮声和语音播报,用户交互后自动解锁音频播放 - cms/dashboard 概况卡片标题添加提醒控件,shop/dashboard 欢迎横幅右侧添加提醒栏 - 提取 cms/dashboard loadData 函数实现复用,统一数据加载逻辑 - 优化组件状态管理,防止多个实例间状态冲突,提升稳定性和可维护性
This commit is contained in:
@@ -1,103 +1,8 @@
|
|||||||
## 2026-07-14 修复 Dashboard 统计数据问题
|
# 2026-07-14
|
||||||
|
|
||||||
### 问题描述
|
## 为 Dashboard 页面添加新订单声音提醒
|
||||||
用户反馈 `/shop/dashboard` 页面所有统计数据都显示为 0,包括用户总数、订单总数、总营业额、运行天数、今日数据概况等。
|
|
||||||
|
|
||||||
### 修复内容
|
- 将 `src/views/shop/shopOrder/useOrderNotify.ts` 改造为实例安全的 Composable:模块级状态(enabled/polling/timerId/lastOrderId/initialized/callback)移入函数作用域,AudioContext 和音频解锁监听器保持模块级共享。每个页面调用可创建独立实例,互不冲突。
|
||||||
|
- 在 `src/views/shop/dashboard/index.vue` 的欢迎横幅右侧添加新订单提醒开关栏(开关 + 测试按钮),检测到新订单后自动调用 loadData() 刷新 Dashboard 数据。
|
||||||
#### 1. 修复 `src/store/modules/statistics.ts`
|
- 在 `src/views/cms/dashboard/index.vue` 的概况卡片标题栏添加新订单提醒控件,同样检测到新订单后刷新数据。
|
||||||
- 添加了 `todaySales`、`todayOrders`、`todayUsers` 从 `cmsStatistics` 表数据中提取的逻辑
|
- 提取了 CMS Dashboard 的 loadData 函数使其可复用。
|
||||||
- 在 `fetchStatistics` 中传入 `page: 1, limit: 1` 参数,减少不必要的数据传输
|
|
||||||
- 确保在没有 `cmsStatistics` 记录时,仍然能使用基础统计数据(用户总数、订单总数、总销售额)
|
|
||||||
|
|
||||||
#### 2. 修复 `src/views/shop/dashboard/index.vue`
|
|
||||||
- 将 `coreStats`、`todayStats`、`todoItems` 从 `reactive` 改为 `computed`,确保数据响应式更新
|
|
||||||
- 修复了今日数据使用错误来源的问题:
|
|
||||||
- 之前:`todayStats.salesAmount = totalSales.value`(使用总销售额作为今日销售额)
|
|
||||||
- 现在:`todayStats.salesAmount = todaySales.value`(使用真正的今日销售额)
|
|
||||||
- 添加了从 API 获取待处理事项数据的逻辑:
|
|
||||||
- 待发货订单:`statusFilter: 1` 查询
|
|
||||||
- 退款/售后:`statusFilter: 6` 查询
|
|
||||||
- 今日订单数:通过 `createTimeStart` 和 `createTimeEnd` 查询
|
|
||||||
|
|
||||||
### 注意事项
|
|
||||||
如果后端 `cmsStatistics` 表中没有今日数据(todaySales、todayOrders、todayUsers),今日数据仍会显示为 0。建议:
|
|
||||||
1. 检查后端是否有定时任务更新 `cmsStatistics` 表
|
|
||||||
2. 或手动触发统计数据更新
|
|
||||||
3. 检查浏览器控制台是否有 API 调用失败的错误日志
|
|
||||||
|
|
||||||
## 2026-07-14 Dashboard 标题改用 tenantName + 订单提醒同步刷新
|
|
||||||
|
|
||||||
### Dashboard 标题
|
|
||||||
- `src/views/shop/dashboard/index.vue` 欢迎横幅标题从 `siteStore.appName` 改为 `userStore.info?.tenantName`,显示当前登录用户所属租户名称。
|
|
||||||
|
|
||||||
### 订单新订单语音提醒同步刷新
|
|
||||||
- `src/views/shop/shopOrder/useOrderNotify.ts` 增加 `onNewOrder` 回调参数,检测到新订单触发提醒(叮声+语音)时同时调用回调。
|
|
||||||
- `src/views/shop/shopOrder/index.vue` 调用 `useOrderNotify({ onNewOrder: () => reload() })`,使新订单提醒时自动刷新表格数据,保持数据同步。
|
|
||||||
|
|
||||||
## 2026-07-14 实时统计 Dashboard 今日数据
|
|
||||||
|
|
||||||
### 问题
|
|
||||||
用户再次反馈 `/shop/dashboard` 的「今日数据概况」与「待处理事项」全部显示为 0,且已确认存在今日订单/用户数据。根本原因是 `cmsStatistics` 统计表未写入今日数据,而前端仍依赖该表读取 todaySales/todayOrders/todayUsers;另外 dashboard 中直接按日期查询时使用了 `YYYY-MM-DD` 格式,缺少时间部分,导致时间范围不匹配。
|
|
||||||
|
|
||||||
### 修复
|
|
||||||
- `src/store/modules/statistics.ts`:
|
|
||||||
- 引入 `dayjs` 与 `listShopOrder`。
|
|
||||||
- 在 `fetchStatistics` 中实时调用 `listShopOrder({ createTimeStart, createTimeEnd })` 获取今日订单列表,直接计算 `todayOrders`、`todaySales`(仅已付款订单)、`couponUsedCount`。
|
|
||||||
- 调用 `pageUsers({ createTimeStart, createTimeEnd })` 获取 `todayUsers`。
|
|
||||||
- 将今日数据同步写入 `cmsStatistics` 表(若存在记录),同时新增 `couponUsedCount` store 状态与 getter。
|
|
||||||
- `src/views/shop/dashboard/index.vue`:
|
|
||||||
- 移除原来使用 `createTimeStart/End: today`(仅日期)查询今日订单并作为优惠券使用数的占位逻辑。
|
|
||||||
- `couponUsedCount` 改为从 `statisticsStore.couponUsedCount` 读取。
|
|
||||||
- 今日销售额使用 `formatMoney` 保留两位小数显示。
|
|
||||||
|
|
||||||
### 验证
|
|
||||||
- `pnpm run build` 成功。
|
|
||||||
- `vue-tsc --noEmit` 在改动文件中未引入新错误。
|
|
||||||
|
|
||||||
### 备注
|
|
||||||
- 待发货/退款仍使用 `statusFilter=1` 与 `statusFilter=6`;若后续出现货到付款订单未计入待发货,需再检查订单 `payStatus` 在后端是否被正确标记为已付款。
|
|
||||||
|
|
||||||
## 2026-07-14 订单列表商品图片接入 OSS 压缩
|
|
||||||
|
|
||||||
### 改动内容
|
|
||||||
- 参考 `/Users/gxwebsoft/VUE/xinlong-shop-taro/src/utils/image.ts`,在管理后台新增 `src/utils/image.ts`:
|
|
||||||
- `ensureFullUrl(url)`:补全 OSS 域名为完整 URL
|
|
||||||
- `getCompressedImageUrl(url, options?)`:拼接 `x-oss-process=image/resize,w_${width}/quality,Q_${quality}` 压缩参数
|
|
||||||
- `src/views/shop/shopOrder/index.vue`:
|
|
||||||
- 引入 `getCompressedImageUrl`
|
|
||||||
- 订单列表商品图片列 `<a-avatar :src="item.image" />` 改为 `<a-avatar :src="getCompressedImageUrl(item.image)" />`
|
|
||||||
|
|
||||||
### 待办
|
|
||||||
- 用户提到后端也需要此方法,但当前工作区只有前端代码,后端实现需在后端项目补充。
|
|
||||||
|
|
||||||
## 2026-07-14 商城设置 Logo 改为直传
|
|
||||||
|
|
||||||
### 修改文件
|
|
||||||
- `src/views/shop/shopSetting/components/basic.vue`
|
|
||||||
|
|
||||||
### 修改内容
|
|
||||||
- 将商城 Logo 上传方式从 `SelectFile` 图片库选择改为 `a-upload` + `uploadOss` 直接上传,与商品编辑弹窗的商品图片上传方式保持一致。
|
|
||||||
- 已上传图片使用 `a-image` 预览,下方提供"上传"按钮覆盖替换。
|
|
||||||
- 删除不再使用的 `SelectFile` 导入、`logoList` 变量、`chooseImage`/`onDeleteImage` 回调。
|
|
||||||
- 使用 `ele-admin-pro` 的 `messageLoading` 保持上传加载提示与商品编辑弹窗一致。
|
|
||||||
|
|
||||||
## 2026-07-14 商品列表增加三价格展示
|
|
||||||
|
|
||||||
### 改动文件
|
|
||||||
- `src/views/shop/shopGoods/index.vue`
|
|
||||||
|
|
||||||
### 列表页列配置
|
|
||||||
- 原来只显示一列「价格」(`price`)
|
|
||||||
- 新增「市场价」列 (`salePrice`),数据为空时显示 `-`
|
|
||||||
- 新增「会员价」列 (`dealerPrice`),数据为空时显示 `-`
|
|
||||||
- 三列均设置 `width: 110`、`align: 'center'`、`customRender` 带 `¥` 前缀
|
|
||||||
|
|
||||||
### 导出功能同步更新
|
|
||||||
- 导出表头增加「市场价」「会员价」两列
|
|
||||||
- 数据行对应增加 `goods.salePrice` 和 `goods.dealerPrice` 输出
|
|
||||||
- 列宽配置同步增加两列 `{ wch: 12 }`
|
|
||||||
|
|
||||||
### 备注
|
|
||||||
- 编辑表单 `shopGoodsEdit.vue` 已支持三个价格编辑(价格/市场价/会员价),无需修改
|
|
||||||
- 数据模型字段映射:价格=price, 市场价=salePrice, 会员价=dealerPrice
|
|
||||||
|
|||||||
@@ -3,7 +3,21 @@
|
|||||||
<a-row :gutter="16">
|
<a-row :gutter="16">
|
||||||
<!-- 应用基本信息卡片 -->
|
<!-- 应用基本信息卡片 -->
|
||||||
<a-col :span="24" style="margin-bottom: 16px">
|
<a-col :span="24" style="margin-bottom: 16px">
|
||||||
<a-card title="概况" :bordered="false">
|
<a-card :bordered="false">
|
||||||
|
<template #title>
|
||||||
|
<a-space>
|
||||||
|
<span>概况</span>
|
||||||
|
<a-divider type="vertical" />
|
||||||
|
<div class="order-notify-bar">
|
||||||
|
<span class="order-notify-label">新订单提醒</span>
|
||||||
|
<a-switch v-model:checked="notifyEnabled" @change="onNotifyToggle" size="small" />
|
||||||
|
<a-tooltip title="开启后每60秒自动检测新订单,检测到后播放叮声并语音播报">
|
||||||
|
<InfoCircleOutlined class="order-notify-icon" />
|
||||||
|
</a-tooltip>
|
||||||
|
<a-button size="small" :disabled="!notifyEnabled" @click="onTestNotify">测试</a-button>
|
||||||
|
</div>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
<a-row :gutter="16">
|
<a-row :gutter="16">
|
||||||
<a-col :span="6">
|
<a-col :span="6">
|
||||||
<a-image
|
<a-image
|
||||||
@@ -183,6 +197,7 @@
|
|||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, onMounted, onUnmounted, computed } from 'vue';
|
import { ref, onMounted, onUnmounted, computed } from 'vue';
|
||||||
|
import { useOrderNotify } from '@/views/shop/shopOrder/useOrderNotify';
|
||||||
import {
|
import {
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
CalendarOutlined,
|
CalendarOutlined,
|
||||||
@@ -192,7 +207,8 @@
|
|||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
AccountBookOutlined,
|
AccountBookOutlined,
|
||||||
FileTextOutlined,
|
FileTextOutlined,
|
||||||
MoneyCollectOutlined
|
MoneyCollectOutlined,
|
||||||
|
InfoCircleOutlined
|
||||||
} from '@ant-design/icons-vue';
|
} from '@ant-design/icons-vue';
|
||||||
import { openNew } from '@/utils/common';
|
import { openNew } from '@/utils/common';
|
||||||
import { useSiteStore } from '@/store/modules/site';
|
import { useSiteStore } from '@/store/modules/site';
|
||||||
@@ -230,19 +246,27 @@
|
|||||||
// 加载状态
|
// 加载状态
|
||||||
const loading = computed(() => siteLoading.value || statisticsLoading.value);
|
const loading = computed(() => siteLoading.value || statisticsLoading.value);
|
||||||
|
|
||||||
onMounted(async () => {
|
// 加载数据
|
||||||
// 加载网站信息和统计数据
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
siteStore.fetchSiteInfo(),
|
siteStore.fetchSiteInfo(),
|
||||||
statisticsStore.fetchStatistics()
|
statisticsStore.fetchStatistics()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 开始自动刷新统计数据(每5分钟)
|
|
||||||
statisticsStore.startAutoRefresh();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载数据失败:', error);
|
console.error('加载数据失败:', error);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 新订单声音提醒
|
||||||
|
const { enabled: notifyEnabled, toggle: onNotifyToggle, testNotify: onTestNotify } = useOrderNotify({
|
||||||
|
onNewOrder: () => loadData()
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadData();
|
||||||
|
// 开始自动刷新统计数据(每5分钟)
|
||||||
|
statisticsStore.startAutoRefresh();
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -256,6 +280,27 @@
|
|||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 新订单提醒栏(概况卡片标题中) */
|
||||||
|
.order-notify-bar {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 2px 12px;
|
||||||
|
background: #f0fdf4;
|
||||||
|
border: 1px solid #bbf7d0;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.order-notify-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #166534;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.order-notify-icon {
|
||||||
|
color: #16a34a;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
.stat-card {
|
.stat-card {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
|
|||||||
@@ -7,6 +7,15 @@
|
|||||||
<p class="welcome-sub">欢迎回来,管理员,今日数据已更新</p>
|
<p class="welcome-sub">欢迎回来,管理员,今日数据已更新</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="welcome-right">
|
<div class="welcome-right">
|
||||||
|
<div class="welcome-actions">
|
||||||
|
<div class="order-notify-bar">
|
||||||
|
<span class="order-notify-label">新订单提醒</span>
|
||||||
|
<a-switch v-model:checked="notifyEnabled" @change="onNotifyToggle" size="small" />
|
||||||
|
<a-tooltip title="开启后每60秒自动检测新订单,检测到后播放叮声并语音播报">
|
||||||
|
<InfoCircleOutlined class="order-notify-info" />
|
||||||
|
</a-tooltip>
|
||||||
|
<a-button size="small" :disabled="!notifyEnabled" @click="onTestNotify">测试</a-button>
|
||||||
|
</div>
|
||||||
<a-space>
|
<a-space>
|
||||||
<a-tag color="green" style="font-size:13px;padding:4px 12px">{{ siteStore.statusText || '正常' }}</a-tag>
|
<a-tag color="green" style="font-size:13px;padding:4px 12px">{{ siteStore.statusText || '正常' }}</a-tag>
|
||||||
<a-button size="small" @click="refreshStatistics" :loading="loading">
|
<a-button size="small" @click="refreshStatistics" :loading="loading">
|
||||||
@@ -16,6 +25,7 @@
|
|||||||
</a-space>
|
</a-space>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 核心数据统计 -->
|
<!-- 核心数据统计 -->
|
||||||
<a-row :gutter="[16, 16]">
|
<a-row :gutter="[16, 16]">
|
||||||
@@ -207,6 +217,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { reactive, ref, computed, onMounted, onUnmounted } from 'vue';
|
import { reactive, ref, computed, onMounted, onUnmounted } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useOrderNotify } from '@/views/shop/shopOrder/useOrderNotify';
|
||||||
import {
|
import {
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
RightOutlined,
|
RightOutlined,
|
||||||
@@ -215,7 +226,8 @@ import {
|
|||||||
UserOutlined,
|
UserOutlined,
|
||||||
ShopOutlined,
|
ShopOutlined,
|
||||||
FileTextOutlined,
|
FileTextOutlined,
|
||||||
ClearOutlined
|
ClearOutlined,
|
||||||
|
InfoCircleOutlined
|
||||||
} from '@ant-design/icons-vue';
|
} from '@ant-design/icons-vue';
|
||||||
import { message } from 'ant-design-vue/es';
|
import { message } from 'ant-design-vue/es';
|
||||||
import { useSiteStore } from '@/store/modules/site';
|
import { useSiteStore } from '@/store/modules/site';
|
||||||
@@ -318,6 +330,11 @@ const handleClearCache = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 新订单声音提醒
|
||||||
|
const { enabled: notifyEnabled, toggle: onNotifyToggle, testNotify: onTestNotify } = useOrderNotify({
|
||||||
|
onNewOrder: () => loadData()
|
||||||
|
});
|
||||||
|
|
||||||
// 刷新统计数据
|
// 刷新统计数据
|
||||||
const refreshStatistics = async () => {
|
const refreshStatistics = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -405,6 +422,34 @@ onUnmounted(() => {
|
|||||||
.welcome-title { font-size: 20px; font-weight: 700; color: #fff; margin: 0 0 6px; }
|
.welcome-title { font-size: 20px; font-weight: 700; color: #fff; margin: 0 0 6px; }
|
||||||
.welcome-sub { font-size: 14px; color: rgba(255,255,255,0.7); margin: 0; }
|
.welcome-sub { font-size: 14px; color: rgba(255,255,255,0.7); margin: 0; }
|
||||||
|
|
||||||
|
/* 欢迎横幅右侧操作区 */
|
||||||
|
.welcome-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 新订单提醒栏 */
|
||||||
|
.order-notify-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: rgba(255,255,255,0.12);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 5px 12px;
|
||||||
|
}
|
||||||
|
.order-notify-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(255,255,255,0.85);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.order-notify-info {
|
||||||
|
color: rgba(255,255,255,0.6);
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
/* 核心统计块 */
|
/* 核心统计块 */
|
||||||
.stat-block {
|
.stat-block {
|
||||||
padding: 18px 20px;
|
padding: 18px 20px;
|
||||||
|
|||||||
@@ -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 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 audioCtx: AudioContext | null = null;
|
||||||
|
|
||||||
/** 是否已初始化(首次查询记录基准值,不触发提醒) */
|
/** 用户交互解锁音频的监听器(模块级,全局只注册一次) */
|
||||||
let initialized = false;
|
|
||||||
|
|
||||||
/** 用户交互解锁音频的监听器(一次性) */
|
|
||||||
let unlockHandler: (() => void) | null = null;
|
let unlockHandler: (() => void) | null = null;
|
||||||
|
|
||||||
/** 新订单回调函数(由调用方传入,用于刷新页面数据) */
|
|
||||||
let onNewOrderCallback: (() => void) | null = null;
|
|
||||||
|
|
||||||
// ============ 声音播放 ============
|
// ============ 声音播放 ============
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -79,6 +64,7 @@ function initAudioContext() {
|
|||||||
/**
|
/**
|
||||||
* 注册用户交互监听器,首次交互时解锁音频
|
* 注册用户交互监听器,首次交互时解锁音频
|
||||||
* 浏览器策略:音频播放需要用户交互后才允许,注册一次性监听器自动解锁
|
* 浏览器策略:音频播放需要用户交互后才允许,注册一次性监听器自动解锁
|
||||||
|
* 全局只注册一次,多实例共享
|
||||||
*/
|
*/
|
||||||
function registerAudioUnlock() {
|
function registerAudioUnlock() {
|
||||||
if (unlockHandler) return;
|
if (unlockHandler) return;
|
||||||
@@ -112,11 +98,8 @@ function playDingSound() {
|
|||||||
|
|
||||||
// 创建增益节点(控制音量包络)
|
// 创建增益节点(控制音量包络)
|
||||||
const gainNode = audioCtx.createGain();
|
const gainNode = audioCtx.createGain();
|
||||||
// 起始音量 0
|
|
||||||
gainNode.gain.setValueAtTime(0, now);
|
gainNode.gain.setValueAtTime(0, now);
|
||||||
// 快速上升到 0.5(攻击阶段)
|
|
||||||
gainNode.gain.linearRampToValueAtTime(0.5, now + 0.01);
|
gainNode.gain.linearRampToValueAtTime(0.5, now + 0.01);
|
||||||
// 然后指数衰减到 0(释放阶段)
|
|
||||||
gainNode.gain.exponentialRampToValueAtTime(0.001, now + DING_DURATION);
|
gainNode.gain.exponentialRampToValueAtTime(0.001, now + DING_DURATION);
|
||||||
|
|
||||||
// 连接:振荡器 → 增益 → 输出
|
// 连接:振荡器 → 增益 → 输出
|
||||||
@@ -139,32 +122,50 @@ function speak(text: string) {
|
|||||||
|
|
||||||
const utterance = new SpeechSynthesisUtterance(text);
|
const utterance = new SpeechSynthesisUtterance(text);
|
||||||
utterance.lang = 'zh-CN';
|
utterance.lang = 'zh-CN';
|
||||||
utterance.rate = 1; // 语速
|
utterance.rate = 1;
|
||||||
utterance.pitch = 1; // 音调
|
utterance.pitch = 1;
|
||||||
utterance.volume = 1; // 音量
|
utterance.volume = 1;
|
||||||
|
|
||||||
window.speechSynthesis.speak(utterance);
|
window.speechSynthesis.speak(utterance);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 触发提醒:叮声 + 语音播报 + 刷新页面
|
* 触发提醒:叮声 + 语音播报 + 通知回调
|
||||||
*/
|
*/
|
||||||
function triggerNotify() {
|
function triggerNotify(onNewOrderCallback: (() => void) | null) {
|
||||||
// 尝试初始化音频上下文(如果已解锁则生效,未解锁则静默失败)
|
|
||||||
initAudioContext();
|
initAudioContext();
|
||||||
playDingSound();
|
playDingSound();
|
||||||
speak(SPEECH_TEXT);
|
speak(SPEECH_TEXT);
|
||||||
// 通知调用方刷新页面数据
|
|
||||||
if (onNewOrderCallback) {
|
if (onNewOrderCallback) {
|
||||||
onNewOrderCallback();
|
onNewOrderCallback();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ 轮询逻辑 ============
|
// ============ 对外接口 ============
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询最新订单,检测是否有新订单
|
* 新订单提醒组合式函数
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* ```ts
|
||||||
|
* const { enabled, toggle, testNotify } = useOrderNotify({
|
||||||
|
* onNewOrder: () => reload()
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @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() {
|
async function checkNewOrder() {
|
||||||
try {
|
try {
|
||||||
const result = await pageShopOrder({
|
const result = await pageShopOrder({
|
||||||
@@ -176,7 +177,6 @@ async function checkNewOrder() {
|
|||||||
const latestOrder: ShopOrder | undefined = result?.list?.[0];
|
const latestOrder: ShopOrder | undefined = result?.list?.[0];
|
||||||
|
|
||||||
if (!latestOrder) {
|
if (!latestOrder) {
|
||||||
// 没有订单数据,记录为空
|
|
||||||
lastOrderId = undefined;
|
lastOrderId = undefined;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -184,46 +184,33 @@ async function checkNewOrder() {
|
|||||||
const currentOrderId = latestOrder.orderId;
|
const currentOrderId = latestOrder.orderId;
|
||||||
|
|
||||||
if (!initialized) {
|
if (!initialized) {
|
||||||
// 首次查询,记录基准值,不触发提醒
|
|
||||||
lastOrderId = currentOrderId;
|
lastOrderId = currentOrderId;
|
||||||
initialized = true;
|
initialized = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 比较 orderId:如果变了且新 orderId 更大,说明有新订单
|
|
||||||
if (
|
if (
|
||||||
currentOrderId !== lastOrderId &&
|
currentOrderId !== lastOrderId &&
|
||||||
(lastOrderId === undefined || (currentOrderId ?? 0) > lastOrderId)
|
(lastOrderId === undefined || (currentOrderId ?? 0) > lastOrderId)
|
||||||
) {
|
) {
|
||||||
// 有新订单!
|
triggerNotify(onNewOrderCallback);
|
||||||
triggerNotify();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
lastOrderId = currentOrderId;
|
lastOrderId = currentOrderId;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 查询失败时静默处理,不影响下次轮询
|
|
||||||
console.warn('[订单提醒] 轮询查询失败:', error);
|
console.warn('[订单提醒] 轮询查询失败:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 启动轮询
|
|
||||||
*/
|
|
||||||
function startPolling() {
|
function startPolling() {
|
||||||
if (polling) return;
|
if (polling) return;
|
||||||
polling = true;
|
polling = true;
|
||||||
initialized = false; // 重置初始化标记
|
initialized = false;
|
||||||
|
|
||||||
// 立即查询一次(记录基准值)
|
|
||||||
checkNewOrder();
|
checkNewOrder();
|
||||||
|
|
||||||
// 启动定时器
|
|
||||||
timerId = setInterval(checkNewOrder, POLL_INTERVAL);
|
timerId = setInterval(checkNewOrder, POLL_INTERVAL);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 停止轮询
|
|
||||||
*/
|
|
||||||
function stopPolling() {
|
function stopPolling() {
|
||||||
polling = false;
|
polling = false;
|
||||||
if (timerId) {
|
if (timerId) {
|
||||||
@@ -232,54 +219,23 @@ function stopPolling() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ 对外接口 ============
|
// ============ 生命周期 ============
|
||||||
|
|
||||||
/**
|
|
||||||
* 新订单提醒组合式函数
|
|
||||||
*
|
|
||||||
* 用法:
|
|
||||||
* ```ts
|
|
||||||
* const { enabled, toggle } = useOrderNotify({
|
|
||||||
* onNewOrder: () => reload() // 检测到新订单时刷新表格
|
|
||||||
* });
|
|
||||||
* // 用户点击开关时调用 toggle(true/false)
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* @param options.onNewOrder 检测到新订单时的回调(通常用于刷新页面数据)
|
|
||||||
*/
|
|
||||||
export function useOrderNotify(options?: { onNewOrder?: () => void }) {
|
|
||||||
// 缓存回调
|
|
||||||
onNewOrderCallback = options?.onNewOrder ?? null;
|
|
||||||
|
|
||||||
// 组件挂载时自动启动(默认开启)
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// 注册用户交互解锁音频监听器
|
|
||||||
registerAudioUnlock();
|
registerAudioUnlock();
|
||||||
// 自动开始轮询
|
|
||||||
startPolling();
|
startPolling();
|
||||||
});
|
});
|
||||||
|
|
||||||
// 组件卸载时自动清理
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
stopPolling();
|
stopPolling();
|
||||||
// 清理交互监听器
|
|
||||||
if (unlockHandler) {
|
|
||||||
document.removeEventListener('click', unlockHandler);
|
|
||||||
document.removeEventListener('keydown', unlockHandler);
|
|
||||||
unlockHandler = null;
|
|
||||||
}
|
|
||||||
// 清理回调引用
|
|
||||||
onNewOrderCallback = null;
|
onNewOrderCallback = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
// ============ 对外方法 ============
|
||||||
* 切换提醒开关
|
|
||||||
* @param value 是否启用
|
|
||||||
*/
|
|
||||||
const toggle = (value: boolean) => {
|
const toggle = (value: boolean) => {
|
||||||
enabled.value = value;
|
enabled.value = value;
|
||||||
if (value) {
|
if (value) {
|
||||||
// 用户交互触发,初始化音频上下文
|
|
||||||
initAudioContext();
|
initAudioContext();
|
||||||
startPolling();
|
startPolling();
|
||||||
} else {
|
} else {
|
||||||
@@ -287,12 +243,9 @@ export function useOrderNotify(options?: { onNewOrder?: () => void }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* 测试提醒(用户点击测试按钮时调用)
|
|
||||||
*/
|
|
||||||
const testNotify = () => {
|
const testNotify = () => {
|
||||||
initAudioContext();
|
initAudioContext();
|
||||||
triggerNotify();
|
triggerNotify(onNewOrderCallback);
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user