diff --git a/.workbuddy/expert-history.json b/.workbuddy/expert-history.json index dac490d..4bf7e64 100644 --- a/.workbuddy/expert-history.json +++ b/.workbuddy/expert-history.json @@ -11,7 +11,62 @@ "usedAt": 1775132175089, "industryId": "all" } + ], + "b1db410ffede4e03be74a98ac57ea478": [ + { + "expertId": "SeniorDeveloper", + "name": "Will", + "profession": "高级开发工程师", + "avatarUrl": "https://acc-1258344699.cos.accelerate.myqcloud.com/workbuddy/experts/avatars/02-Engineering/SeniorDeveloper/SeniorDeveloper.png", + "promptUrl": "https://acc-1258344699.cos.accelerate.myqcloud.com/workbuddy/experts/experts/02-Engineering/SeniorDeveloper/SeniorDeveloper_zh.md", + "usedAt": 1775753422786, + "industryId": "all" + } + ], + "3ad590301d144e91ac2d9497ad0b1a22": [ + { + "expertId": "SeniorDeveloper", + "name": "Will", + "profession": "高级开发工程师", + "avatarUrl": "https://acc-1258344699.cos.accelerate.myqcloud.com/workbuddy/experts/avatars/02-Engineering/SeniorDeveloper/SeniorDeveloper.png", + "promptUrl": "https://acc-1258344699.cos.accelerate.myqcloud.com/workbuddy/experts/experts/02-Engineering/SeniorDeveloper/SeniorDeveloper_zh.md", + "usedAt": 1775754410884, + "industryId": "all" + } + ], + "a07270b8a79e42f88a866c9e908129ad": [ + { + "expertId": "SeniorDeveloper", + "name": "Will", + "profession": "高级开发工程师", + "avatarUrl": "https://acc-1258344699.cos.accelerate.myqcloud.com/workbuddy/experts/avatars/02-Engineering/SeniorDeveloper/SeniorDeveloper.png", + "promptUrl": "https://acc-1258344699.cos.accelerate.myqcloud.com/workbuddy/experts/experts/02-Engineering/SeniorDeveloper/SeniorDeveloper_zh.md", + "usedAt": 1775755906815, + "industryId": "all" + } + ], + "c8760ea899514a1aae49666ae8a1f5e1": [ + { + "expertId": "SeniorDeveloper", + "name": "Will", + "profession": "高级开发工程师", + "avatarUrl": "https://acc-1258344699.cos.accelerate.myqcloud.com/workbuddy/experts/avatars/02-Engineering/SeniorDeveloper/SeniorDeveloper.png", + "promptUrl": "https://acc-1258344699.cos.accelerate.myqcloud.com/workbuddy/experts/experts/02-Engineering/SeniorDeveloper/SeniorDeveloper_zh.md", + "usedAt": 1775755982035, + "industryId": "all" + } + ], + "92099fa2ec454bf7b29102b6ab4b41a6": [ + { + "expertId": "SeniorDeveloper", + "name": "Will", + "profession": "高级开发工程师", + "avatarUrl": "https://acc-1258344699.cos.accelerate.myqcloud.com/workbuddy/experts/avatars/02-Engineering/SeniorDeveloper/SeniorDeveloper.png", + "promptUrl": "https://acc-1258344699.cos.accelerate.myqcloud.com/workbuddy/experts/experts/02-Engineering/SeniorDeveloper/SeniorDeveloper_zh.md", + "usedAt": 1775756293122, + "industryId": "all" + } ] }, - "lastUpdated": 1775141282813 + "lastUpdated": 1775756419382 } \ No newline at end of file diff --git a/.workbuddy/memory/2026-04-10.md b/.workbuddy/memory/2026-04-10.md new file mode 100644 index 0000000..de05a05 --- /dev/null +++ b/.workbuddy/memory/2026-04-10.md @@ -0,0 +1,90 @@ +# 2026-04-10 工作日志 + +## 完成的功能:四种用户分级标签 + +### 需求 +用户 ID 下方"管理员"标签改为四种分级: +- 0: 普通用户 +- 1: 超级管理员 +- 2: 合伙人(总店) +- 3: 合伙人(分店) + +### 修改的文件 + +#### 后端 (JAVA) +1. `ShopDealerUser.java` - 添加 `dealerLevel` 字段 +2. `ShopDealerUserMapper.xml` - 添加 dealerLevel 查询条件 + +#### 前端 (VUE) +1. `src/api/shop/shopDealerUser/model/index.ts` - 添加 dealerLevel 字段 +2. `src/utils/userLevel.ts` - 新建用户等级配置工具文件 +3. `src/pages/user/components/UserCard.tsx` - 修改角色标签显示逻辑 + +### 注意事项 +- 需要在数据库中添加 `dealer_level` 字段 +- 后端需要重启生效 +- 前端通过 dealerLevel 字段判断显示对应样式 + +## 配送员订单通知功能 + +### 需求 +客户下单后,配送员手机声音提示和红点提示功能 + +### 实现方案 +1. **红点提示**:在配送员首页「配送订单」图标上显示待配送订单数量 +2. **声音提示**:收到新订单时播放微信官方提示音 +3. **设置功能**:支持开启/关闭声音提醒 + +### 新增/修改的文件 +1. `src/api/glt/gltTicketOrder/index.ts` - 添加 `getRiderPendingCount` 接口 +2. `src/hooks/useRiderNotification.ts` - 新建配送员通知 Hook +3. `src/rider/index.tsx` - 添加 Badge 红点和设置入口 + +### 技术实现 +- 使用 30 秒轮询获取待配送订单数量 +- 使用 NutUI 的 Badge 组件显示红点 +- 使用 Taro.createInnerAudioContext 播放提示音 +- 声音设置保存在本地存储 `rider_sound_enabled` + +### 待后端配合 +- 需要后端提供 `/glt/glt-ticket-order/rider/count` 接口(可选,使用现有 page 接口也行) +- 需要配置微信订阅消息模板(可选) + +## 下单页配送方式 + 配送费功能 + +### 需求 +1. 下单页必选配送方式:电梯 / 步梯 / 一楼商铺·其他 +2. 步梯需二级选择是否送上楼,送上楼需选楼层 +3. 配送费计算:每桶每层1元,第1层不收费(即 `(楼层-1) × 数量`) +4. 自提模式隐藏配送方式选择器 + +### 修改的文件 +1. `src/api/shop/shopOrder/model/index.ts` - OrderCreateRequest 新增 deliveryMethod、deliveryFloor 字段 +2. `src/utils/payment.ts` - buildSingleGoodsOrder 透传配送方式字段 +3. `src/shop/orderConfirm/index.tsx` - 配送方式选择UI、配送费计算、楼层选择弹窗、支付校验 +4. `src/shop/orderConfirm/index.scss` - 配送方式选择器、楼层网格样式 + +### 后端需配合 +- 订单表新增 `delivery_method` 和 `delivery_floor` 字段 +- 订单创建接口接收并存储这两个字段 +- 骑手端/后台展示配送方式和楼层信息 + +## 微信订阅消息配置(补充) + +### 需要做的配置 + +#### 后端配置 +1. `GltSubscribeMessageServiceImpl.java` - 订阅消息发送服务 + - 需要配置 `SUBSCRIBE_TEMPLATE_ID` 为实际模板ID + +2. `GltTicketOrderController.java` - 订单创建时通知配送员 + - 注入 `GltSubscribeMessageService` 和 `UserMapper` + - 添加 `notifyRidersOfNewOrder` 方法 + +#### 前端配置 +1. `src/rider/index.tsx` + - `handleRequestSubscribeMessage` 函数需要配置实际的模板ID + +### 微信后台需要申请的模板 +模板名称:订单配送通知 +关键词:订单状态、订单编号、配送地址、商品数量、通知时间 diff --git a/src/api/glt/gltTicketOrder/index.ts b/src/api/glt/gltTicketOrder/index.ts index 46693fb..b181faa 100644 --- a/src/api/glt/gltTicketOrder/index.ts +++ b/src/api/glt/gltTicketOrder/index.ts @@ -16,6 +16,21 @@ export async function pageGltTicketOrder(params: GltTicketOrderParam) { return Promise.reject(new Error(res.message)); } +/** + * 获取配送员待处理订单数量 + * @param riderId 配送员ID + */ +export async function getRiderPendingCount(riderId: number) { + const res = await request.get>( + '/glt/glt-ticket-order/rider/count', + { riderId } + ); + if (res.code === 0) { + return res.data; + } + return { pendingCount: 0, totalCount: 0 }; +} + /** * 查询送水订单列表 */ diff --git a/src/api/shop/shopDealerUser/model/index.ts b/src/api/shop/shopDealerUser/model/index.ts index 2af5f3f..beda51c 100644 --- a/src/api/shop/shopDealerUser/model/index.ts +++ b/src/api/shop/shopDealerUser/model/index.ts @@ -38,6 +38,8 @@ export interface ShopDealerUser { createTime?: string; // 修改时间 updateTime?: string; + // 分销商等级:0-普通用户 1-超级管理员 2-合伙人(总店) 3-合伙人(分店) + dealerLevel?: number; } /** diff --git a/src/api/shop/shopGoods/model/index.ts b/src/api/shop/shopGoods/model/index.ts index 715172b..fe12790 100644 --- a/src/api/shop/shopGoods/model/index.ts +++ b/src/api/shop/shopGoods/model/index.ts @@ -128,6 +128,10 @@ export interface ShopGoods { expiredDay?: number; // 可购买数量 canBuyNumber?: number; + // 活动方式:0全平台 1新用户专享 + activityType?: number; + // 配送方式:0送上门 1限自提 + deliveryMode?: number; } export interface BathSet { diff --git a/src/api/shop/shopOrder/model/index.ts b/src/api/shop/shopOrder/model/index.ts index ee1320a..f6ae984 100644 --- a/src/api/shop/shopOrder/model/index.ts +++ b/src/api/shop/shopOrder/model/index.ts @@ -201,6 +201,10 @@ export interface OrderCreateRequest { selfTakeMerchantId?: number; // 订单标题(可选,后端会自动生成) title?: string; + // 配送方式:elevator(电梯) / stairs(步梯) / groundFloor(一楼商铺/其他) + deliveryMethod?: string; + // 楼层(步梯+送上楼时有值,从2开始) + deliveryFloor?: number; } /** diff --git a/src/dealer/components/FreezeMoneyModal.tsx b/src/dealer/components/FreezeMoneyModal.tsx new file mode 100644 index 0000000..aa0ec82 --- /dev/null +++ b/src/dealer/components/FreezeMoneyModal.tsx @@ -0,0 +1,98 @@ +import React from 'react' +import { View, Text } from '@tarojs/components' +import { Popup, Button } from '@nutui/nutui-react-taro' +import { Close } from '@nutui/icons-react-taro' + +interface FreezeMoneyModalProps { + visible: boolean + amount: string + onClose: () => void +} + +/** + * 待使用明细弹窗组件 + * 展示待使用(冻结中)金额和解冻规则说明 + */ +const FreezeMoneyModal: React.FC = ({ + visible, + amount, + onClose +}) => { + // 格式化金额,保留2位小数 + const formatAmount = (value: string | number): string => { + const num = typeof value === 'string' ? parseFloat(value) : value + if (isNaN(num)) return '0.00' + return num.toFixed(2) + } + + return ( + + + {/* 头部标题 */} + + + 待使用明细 + + + + + + + {/* 金额展示区域 */} + + + 待使用(冻结中) + + + ¥ + + {formatAmount(amount)} + + + + + {/* 分隔线 */} + + + {/* 温馨提示区域 */} + + + 温馨提示: + + + 需要直推用户进行下单配送后方可解冻到待提现资金组 + + + + {/* 底部按钮 */} + + + + + + ) +} + +export default FreezeMoneyModal diff --git a/src/dealer/index.tsx b/src/dealer/index.tsx index 17c6a35..068250e 100644 --- a/src/dealer/index.tsx +++ b/src/dealer/index.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { useState } from 'react' import {View, Text} from '@tarojs/components' import {ConfigProvider, Button, Grid, Avatar} from '@nutui/nutui-react-taro' import { @@ -14,6 +14,7 @@ import {useUser} from '@/hooks/useUser' import { useThemeStyles } from '@/hooks/useTheme' import {businessGradients, cardGradients, gradientUtils} from '@/styles/gradients' import {updateShopDealerUser} from '@/api/shop/shopDealerUser' +import FreezeMoneyModal from './components/FreezeMoneyModal' import Taro from '@tarojs/taro' const DealerIndex: React.FC = () => { @@ -23,6 +24,9 @@ const DealerIndex: React.FC = () => { refresh, } = useDealerUser() + // 待使用明细弹窗显示状态 + const [freezeMoneyModalVisible, setFreezeMoneyModalVisible] = useState(false) + // 获取用户角色信息 const { hasRole } = useUser() @@ -63,8 +67,21 @@ const DealerIndex: React.FC = () => { // 判断是否是配送员 const isRider = hasRole('rider') - // 点击待使用金额 - 配送员专用:将冻结金额转入可提现 - const handleFreezeMoneyClick = async () => { + // 点击待使用金额 - 显示待使用明细弹窗 + const handleFreezeMoneyClick = () => { + const freezeMoney = Number(dealerUser?.freezeMoney ?? 0) + if (freezeMoney > 0) { + setFreezeMoneyModalVisible(true) + } + } + + // 关闭待使用明细弹窗 + const handleCloseFreezeMoneyModal = () => { + setFreezeMoneyModalVisible(false) + } + + // 配送员专用:将冻结金额转入可提现 + const handleTransferFreezeMoney = async () => { // 检查是否是配送员 if (!isRider) { return @@ -76,6 +93,9 @@ const DealerIndex: React.FC = () => { return } + // 关闭弹窗 + setFreezeMoneyModalVisible(false) + // 弹出确认框 Taro.showModal({ title: '确认操作', @@ -202,15 +222,15 @@ const DealerIndex: React.FC = () => { className="text-center p-3 rounded-lg flex flex-col" style={{ background: businessGradients.money.frozen, - opacity: isRider && Number(dealerUser.freezeMoney ?? 0) > 0 ? 1 : 0.8 + opacity: Number(dealerUser.freezeMoney ?? 0) > 0 ? 1 : 0.8 }} - onClick={isRider && Number(dealerUser.freezeMoney ?? 0) > 0 ? handleFreezeMoneyClick : undefined} + onClick={Number(dealerUser.freezeMoney ?? 0) > 0 ? handleFreezeMoneyClick : undefined} > {formatMoney(dealerUser.freezeMoney)} - {isRider && Number(dealerUser.freezeMoney ?? 0) > 0 ? '待使用' : '待使用'} + 待使用 { + {/* 待使用明细弹窗 */} + + {/* 底部安全区域 */} diff --git a/src/hooks/useRiderNotification.ts b/src/hooks/useRiderNotification.ts new file mode 100644 index 0000000..31583f3 --- /dev/null +++ b/src/hooks/useRiderNotification.ts @@ -0,0 +1,277 @@ +/** + * 配送员订单通知 Hook + * 功能: + * 1. 轮询获取待配送订单数量 + * 2. 新订单时播放提示音 + * 3. 支持声音开关设置 + */ + +import { useState, useEffect, useRef, useCallback } from 'react' +import Taro from '@tarojs/taro' +import { pageGltTicketOrder } from '@/api/glt/gltTicketOrder' + +// 轮询间隔(毫秒)- 30秒 +const POLL_INTERVAL = 30000 + +// 提示音 URL(使用微信官方提示音) +const NOTIFICATION_SOUND_URL = 'https://res.wx.qq.com/wechatApp/pkg/notice/notice.mp3' + +interface RiderNotificationState { + /** 待配送订单数量 */ + pendingCount: number + /** 所有配送中订单数量 */ + totalCount: number + /** 是否正在轮询 */ + isPolling: boolean + /** 最后更新的订单ID */ + lastOrderId: number | null + /** 声音是否开启 */ + soundEnabled: boolean +} + +interface UseRiderNotificationReturn extends RiderNotificationState { + /** 开始轮询 */ + startPolling: () => void + /** 停止轮询 */ + stopPolling: () => void + /** 刷新数据 */ + refresh: () => Promise + /** 切换声音开关 */ + toggleSound: () => void + /** 手动播放提示音 */ + playSound: () => void +} + +/** + * 配送员通知 Hook + * @param autoStart 是否自动开始轮询,默认 true + */ +export const useRiderNotification = (autoStart = true): UseRiderNotificationReturn => { + const [state, setState] = useState({ + pendingCount: 0, + totalCount: 0, + isPolling: false, + lastOrderId: null, + soundEnabled: true + }) + + const pollTimerRef = useRef(null) + const audioContextRef = useRef(null) + const lastCountRef = useRef(0) + const isMountedRef = useRef(true) + + // 获取配送员ID + const getRiderId = useCallback(() => { + const riderId = Taro.getStorageSync('UserId') + return Number(riderId) || undefined + }, []) + + // 从本地存储加载声音设置 + const loadSoundSetting = useCallback(() => { + try { + const enabled = Taro.getStorageSync('rider_sound_enabled') + return enabled !== '0' // 默认开启 + } catch { + return true + } + }, []) + + // 保存声音设置到本地存储 + const saveSoundSetting = useCallback((enabled: boolean) => { + try { + Taro.setStorageSync('rider_sound_enabled', enabled ? '1' : '0') + } catch (e) { + console.error('保存声音设置失败', e) + } + }, []) + + // 播放提示音 + const playSound = useCallback(() => { + if (!state.soundEnabled) return + + try { + // 停止之前的音频 + if (audioContextRef.current) { + audioContextRef.current.stop() + audioContextRef.current.destroy() + } + + // 创建新的音频实例 + const audioContext = Taro.createInnerAudioContext() + audioContextRef.current = audioContext + + audioContext.src = NOTIFICATION_SOUND_URL + audioContext.volume = 0.8 + + audioContext.play() + audioContext.onPlay(() => { + console.log('📢 新订单提示音播放中') + }) + + audioContext.onError((err: any) => { + console.error('提示音播放失败', err) + // 播放失败时尝试使用本地提示音 + tryFallbackSound() + }) + + audioContext.onEnded(() => { + audioContext.destroy() + }) + } catch (e) { + console.error('播放提示音异常', e) + } + }, [state.soundEnabled]) + + // 备用提示音方案(使用微信震动) + const tryFallbackSound = useCallback(() => { + try { + // 震动提示 + Taro.vibrateShort({ type: 'heavy' }) + } catch (e) { + console.error('震动提示失败', e) + } + }, []) + + // 获取未读订单数量 + const fetchUnreadCount = useCallback(async () => { + const riderId = getRiderId() + if (!riderId) return + + try { + const res = await pageGltTicketOrder({ + riderId, + deliveryStatus: 10, // 待配送状态 + page: 1, + limit: 1 + } as any) + + const pendingCount = res?.count || 0 + const currentLastId = res?.list?.[0]?.id + + // 如果数量增加,说明有新订单,播放提示音 + if (pendingCount > lastCountRef.current && lastCountRef.current > 0) { + playSound() + } + + lastCountRef.current = pendingCount + + if (isMountedRef.current) { + setState(prev => ({ + ...prev, + pendingCount, + lastOrderId: currentLastId || prev.lastOrderId + })) + } + } catch (e) { + console.error('获取未读订单失败', e) + } + }, [getRiderId, playSound]) + + // 开始轮询 + const startPolling = useCallback(() => { + if (pollTimerRef.current) return + + // 立即执行一次 + fetchUnreadCount() + + // 设置定时器 + pollTimerRef.current = setInterval(() => { + fetchUnreadCount() + }, POLL_INTERVAL) + + setState(prev => ({ ...prev, isPolling: true })) + }, [fetchUnreadCount]) + + // 停止轮询 + const stopPolling = useCallback(() => { + if (pollTimerRef.current) { + clearInterval(pollTimerRef.current) + pollTimerRef.current = null + } + + // 停止音频 + if (audioContextRef.current) { + audioContextRef.current.stop() + audioContextRef.current.destroy() + audioContextRef.current = null + } + + setState(prev => ({ ...prev, isPolling: false })) + }, []) + + // 刷新数据 + const refresh = useCallback(async () => { + lastCountRef.current = 0 // 重置计数,强制刷新 + await fetchUnreadCount() + }, [fetchUnreadCount]) + + // 切换声音开关 + const toggleSound = useCallback(() => { + setState(prev => { + const newEnabled = !prev.soundEnabled + saveSoundSetting(newEnabled) + return { ...prev, soundEnabled: newEnabled } + }) + }, [saveSoundSetting]) + + // 初始化 + useEffect(() => { + isMountedRef.current = true + + // 加载声音设置 + const soundEnabled = loadSoundSetting() + setState(prev => ({ ...prev, soundEnabled })) + + // 自动开始轮询 + if (autoStart) { + startPolling() + } + + return () => { + isMountedRef.current = false + stopPolling() + } + }, [autoStart, loadSoundSetting, startPolling, stopPolling]) + + // 监听页面显示/隐藏 + useEffect(() => { + const onShow = () => { + if (!state.isPolling) { + startPolling() + } + } + + const onHide = () => { + // 页面隐藏时停止轮询节省电量 + // stopPolling() + } + + // 微信小程序监听 + if (typeof Taro.onAppShow === 'function') { + Taro.onAppShow(onShow) + } + if (typeof Taro.onAppHide === 'function') { + Taro.onAppHide(onHide) + } + + return () => { + if (typeof Taro.offAppShow === 'function') { + Taro.offAppShow(onShow) + } + if (typeof Taro.offAppHide === 'function') { + Taro.offAppHide(onHide) + } + } + }, [state.isPolling, startPolling]) + + return { + ...state, + startPolling, + stopPolling, + refresh, + toggleSound, + playSound + } +} + +export default useRiderNotification diff --git a/src/pages/user/components/UserCard.tsx b/src/pages/user/components/UserCard.tsx index 93c76b4..0734fe2 100644 --- a/src/pages/user/components/UserCard.tsx +++ b/src/pages/user/components/UserCard.tsx @@ -14,6 +14,7 @@ import {useThemeStyles} from "@/hooks/useTheme"; import {getRootDomain} from "@/utils/domain"; import { getMyGltUserTicketTotal } from '@/api/glt/gltUserTicket' import { saveStorageByLoginUser } from '@/utils/server' +import { getUserLevelName, getUserLevelConfig } from '@/utils/userLevel' const UserCard = forwardRef((_, ref) => { const {data, refresh} = useUserData() @@ -33,11 +34,26 @@ const UserCard = forwardRef((_, ref) => { return userInfo.nickname || (userInfo as any).realName || (userInfo as any).username || (IsLogin ? '用户' : '点击登录') } - // 角色名称:优先取用户 roles 数组的第一个角色名称 + // 角色名称:优先使用 dealerLevel 显示四种分级,否则取用户 roles 数组的第一个角色名称 const getRoleName = () => { + const dealerLevel = (userInfo as any)?.dealerLevel + if (dealerLevel !== undefined && dealerLevel !== null) { + return getUserLevelName(dealerLevel) + } return userInfo?.roles?.[0]?.roleName || userInfo?.roleName || '注册用户' } + // 获取用户等级配置(用于自定义样式) + const getRoleLevelConfig = () => { + const dealerLevel = (userInfo as any)?.dealerLevel + if (dealerLevel !== undefined && dealerLevel !== null) { + return getUserLevelConfig(dealerLevel) + } + return null + } + + const roleLevelConfig = getRoleLevelConfig() + // 下拉刷新 const reloadStats = async (showToast = false) => { await refresh() @@ -267,7 +283,22 @@ const UserCard = forwardRef((_, ref) => { {getDisplayName() || '点击登录'} {getRootDomain() && ( - {getRoleName()} + + {roleLevelConfig ? ( + + {getRoleName()} + + ) : ( + {getRoleName()} + )} + )} diff --git a/src/rider/index.tsx b/src/rider/index.tsx index 901bb5a..584c0ca 100644 --- a/src/rider/index.tsx +++ b/src/rider/index.tsx @@ -1,6 +1,6 @@ -import React from 'react' +import React, { useEffect } from 'react' import {View, Text} from '@tarojs/components' -import {ConfigProvider, Button, Grid, Avatar} from '@nutui/nutui-react-taro' +import {ConfigProvider, Button, Grid, Avatar, Badge} from '@nutui/nutui-react-taro' import { User, Shopping, @@ -8,11 +8,13 @@ import { ArrowRight, Purse, People, - Scan + Scan, + Setting } from '@nutui/icons-react-taro' import {useDealerUser} from '@/hooks/useDealerUser' import {useUser} from '@/hooks/useUser' import { useThemeStyles } from '@/hooks/useTheme' +import { useRiderNotification } from '@/hooks/useRiderNotification' import {businessGradients, cardGradients, gradientUtils} from '@/styles/gradients' import {updateShopDealerUser} from '@/api/shop/shopDealerUser' import Taro from '@tarojs/taro' @@ -27,6 +29,15 @@ const DealerIndex: React.FC = () => { // 获取用户角色信息 const { hasRole } = useUser() + // 配送员通知功能 + const { pendingCount, startPolling, stopPolling, soundEnabled, toggleSound } = useRiderNotification() + + // 页面生命周期管理 + useEffect(() => { + startPolling() + return () => stopPolling() + }, [startPolling, stopPolling]) + // 使用主题样式 const themeStyles = useThemeStyles() @@ -64,6 +75,55 @@ const DealerIndex: React.FC = () => { // 判断是否是配送员 const isRider = hasRole('rider') + // 请求订阅消息授权 + const handleRequestSubscribeMessage = () => { + // 微信订阅消息模板ID(需在微信公众平台配置后替换) + const templateIds = [ + 'YOUR_TEMPLATE_ID', // TODO: 替换为实际的订阅消息模板ID + ] + + // 过滤出有效的模板ID + const validTemplateIds = templateIds.filter(id => id && !id.includes('YOUR_')) + + if (validTemplateIds.length === 0) { + Taro.showModal({ + title: '提示', + content: '订阅消息功能尚未配置,请联系管理员', + showCancel: false + }) + return + } + + // 请求订阅 + Taro.requestSubscribeMessage({ + tmplIds: validTemplateIds, + success: (res) => { + console.log('订阅消息授权结果:', res) + const accepted = Object.values(res).some(v => v === 'accept') + if (accepted) { + Taro.showToast({ + title: '订阅成功', + icon: 'success' + }) + // 保存授权状态到本地 + Taro.setStorageSync('rider_subscribed', '1') + } else { + Taro.showToast({ + title: '您已拒绝订阅', + icon: 'none' + }) + } + }, + fail: (err) => { + console.error('订阅消息授权失败:', err) + Taro.showToast({ + title: '授权失败', + icon: 'none' + }) + } + }) + } + // 点击待使用金额 - 配送员专用:将冻结金额转入可提现 const handleFreezeMoneyClick = async () => { // 检查是否是配送员 @@ -284,8 +344,15 @@ const DealerIndex: React.FC = () => { > navigateToPage('/rider/orders/index')}> - + + {pendingCount > 0 && ( + 99 ? '99+' : pendingCount} + max={99} + style={{ position: 'absolute', top: '-4px', right: '-4px' }} + /> + )} @@ -323,46 +390,96 @@ const DealerIndex: React.FC = () => { - {/* 第二行功能 */} - {/**/} - {/* navigateToPage('/dealer/invite-stats/index')}>*/} - {/* */} - {/* */} - {/* */} - {/* */} - {/* */} - {/* */} + {/* 第二行功能 - 通知设置 */} + + { + const isSubscribed = Taro.getStorageSync('rider_subscribed') === '1' + Taro.showModal({ + title: '通知设置', + content: `声音提醒:${soundEnabled ? '已开启' : '已关闭'}\n订阅消息:${isSubscribed ? '已订阅' : '未订阅'}`, + confirmText: '更多设置', + cancelText: '关闭', + success: (res) => { + if (res.confirm) { + // 显示更多设置选项 + Taro.showActionSheet({ + itemList: [ + soundEnabled ? '关闭声音提醒' : '开启声音提醒', + isSubscribed ? '订阅状态正常' : '订阅消息通知', + '检查更新' + ], + success: (sheetRes) => { + if (sheetRes.tapIndex === 0) { + // 切换声音 + toggleSound() + Taro.showToast({ + title: soundEnabled ? '已关闭声音' : '已开启声音', + icon: 'none' + }) + } else if (sheetRes.tapIndex === 1) { + // 订阅消息 + if (!isSubscribed) { + handleRequestSubscribeMessage() + } else { + Taro.showToast({ + title: '已订阅消息通知', + icon: 'success' + }) + } + } else if (sheetRes.tapIndex === 2) { + Taro.showToast({ + title: '已是最新版本', + icon: 'success' + }) + } + } + }) + } + } + }) + }}> + + + + {soundEnabled ? ( + + ) : ( + + )} + + + - {/* /!* 预留其他功能位置 *!/*/} - {/* */} - {/* */} - {/* */} - {/* */} - {/* */} - {/* */} + {/* 预留功能位置 */} + + + + + + - {/* */} - {/* */} - {/* */} - {/* */} - {/* */} - {/* */} + + + + + + - {/* */} - {/* */} - {/* */} - {/* */} - {/* */} - {/* */} - {/**/} + + + + + + + diff --git a/src/shop/goodsDetail/index.tsx b/src/shop/goodsDetail/index.tsx index bdbf3d2..40c8632 100644 --- a/src/shop/goodsDetail/index.tsx +++ b/src/shop/goodsDetail/index.tsx @@ -377,6 +377,17 @@ const GoodsDetail = () => { {goods.name} + {/* 活动/配送标签 */} + {(goods.activityType === 1 || goods.deliveryMode === 1) && ( + + {goods.activityType === 1 && ( + 新用户专享 + )} + {goods.deliveryMode === 1 && ( + 仅限自提 + )} + + )} {goods.comments} diff --git a/src/shop/orderConfirm/index.scss b/src/shop/orderConfirm/index.scss index 3f80bc9..174e864 100644 --- a/src/shop/orderConfirm/index.scss +++ b/src/shop/orderConfirm/index.scss @@ -39,6 +39,189 @@ } } } + +// 配送方式选择 +.delivery-method-group { + .delivery-method-section { + display: flex; + flex-direction: column; + gap: 16px; + } + + .delivery-method-label { + display: flex; + align-items: center; + } + + .delivery-method-options { + display: flex; + gap: 12px; + } + + .delivery-method-item { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + padding: 16px 8px; + border-radius: 12px; + border: 2px solid #f0f0f0; + background: #fafafa; + transition: all 0.2s ease; + + &.active { + border-color: #07c160; + background: rgba(7, 193, 96, 0.05); + } + + &:active { + transform: scale(0.97); + } + } + + .delivery-method-icon { + font-size: 24px; + } + + .delivery-method-text { + font-size: 13px; + color: #333; + font-weight: 500; + } + + .delivery-method-item.active .delivery-method-text { + color: #07c160; + } +} + +// 是否送上楼 +.carry-upstairs-section { + display: flex; + flex-direction: column; + padding: 12px 0 0; + border-top: 1px dashed #eee; + margin-top: 4px; +} + +.carry-upstairs-options { + display: flex; + gap: 12px; +} + +.carry-upstairs-item { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 12px; + border-radius: 10px; + border: 2px solid #f0f0f0; + background: #fafafa; + font-size: 14px; + color: #666; + transition: all 0.2s ease; + + &.active { + border-color: #07c160; + background: rgba(7, 193, 96, 0.05); + color: #07c160; + font-weight: 500; + } + + &:active { + transform: scale(0.97); + } +} + +// 楼层选择 +.floor-select-section { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 0 0; + border-top: 1px dashed #eee; + margin-top: 4px; +} + +.floor-select-btn { + display: flex; + align-items: center; + gap: 4px; + padding: 8px 14px; + border-radius: 8px; + background: #f5f5f5; + font-size: 14px; + transition: background 0.2s; + + &:active { + background: #e8e8e8; + } +} + +.floor-fee-tip { + margin-left: auto; +} + +// 楼层选择弹窗 +.floor-picker-popup { + height: 100%; + display: flex; + flex-direction: column; + + &__header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px; + border-bottom: 1px solid #f0f0f0; + } + + &__content { + flex: 1; + overflow-y: auto; + padding: 16px; + } + + &__footer { + padding: 12px 16px; + border-top: 1px solid #f0f0f0; + text-align: center; + background: #fafafa; + } +} + +.floor-grid { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 12px; +} + +.floor-grid-item { + display: flex; + align-items: center; + justify-content: center; + padding: 14px 0; + border-radius: 10px; + border: 2px solid #f0f0f0; + background: #fff; + font-size: 14px; + color: #333; + transition: all 0.2s ease; + + &.active { + border-color: #07c160; + background: rgba(7, 193, 96, 0.08); + color: #07c160; + font-weight: 600; + } + + &:active { + transform: scale(0.95); + background: #f5f5f5; + } +} .address-bottom-line{ width: 100%; border-radius: 12rpx 12rpx 0 0; diff --git a/src/shop/orderConfirm/index.tsx b/src/shop/orderConfirm/index.tsx index 9616958..0f92c87 100644 --- a/src/shop/orderConfirm/index.tsx +++ b/src/shop/orderConfirm/index.tsx @@ -11,7 +11,7 @@ import { InputNumber, ConfigProvider } from '@nutui/nutui-react-taro' -import {Location, ArrowRight} from '@nutui/icons-react-taro' +import {Location, ArrowRight, Shop} from '@nutui/icons-react-taro' import Taro, {useDidShow} from '@tarojs/taro' import {ShopGoods} from "@/api/shop/shopGoods/model"; import {getShopGoods} from "@/api/shop/shopGoods"; @@ -75,12 +75,21 @@ const OrderConfirm = () => { const [availableCoupons, setAvailableCoupons] = useState([]) const [couponLoading, setCouponLoading] = useState(false) - // 门店选择:用于在下单页展示当前“已选门店”,并允许用户切换(写入 SelectedStore Storage) + // 门店选择:用于在下单页展示当前"已选门店",并允许用户切换(写入 SelectedStore Storage) const [storePopupVisible, setStorePopupVisible] = useState(false) const [stores, setStores] = useState([]) const [storeLoading, setStoreLoading] = useState(false) const [selectedStore, setSelectedStore] = useState(getSelectedStoreFromStorage()) + // 配送方式:elevator(电梯) / stairs(步梯) / groundFloor(一楼商铺/其他) + const [deliveryMethod, setDeliveryMethod] = useState('') + // 步梯是否需要送上楼(null=未选择) + const [needCarryUpstairs, setNeedCarryUpstairs] = useState(null) + // 楼层(从2开始,需要送上楼时选择) + const [deliveryFloor, setDeliveryFloor] = useState(2) + // 楼层选择弹窗 + const [floorPickerVisible, setFloorPickerVisible] = useState(false) + const router = Taro.getCurrentInstance().router; const params = router?.params || ({} as Record) const goodsIdParam = params?.goodsId @@ -213,11 +222,20 @@ const OrderConfirm = () => { return calculateCouponDiscount(selectedCoupon, total) } + // 计算配送费:每桶每层1元,第1层不收费 + const getDeliveryFee = () => { + // 仅步梯 + 需要送上楼 时才有配送费 + if (deliveryMethod !== 'stairs' || !needCarryUpstairs) return 0 + if (deliveryFloor <= 1) return 0 + return quantity * (deliveryFloor - 1) + } + // 计算实付金额 const getFinalPrice = () => { const total = getGoodsTotal() const discount = getCouponDiscount() - return Math.max(0, total - discount) + const deliveryFee = getDeliveryFee() + return Math.max(0, total - discount + deliveryFee) } @@ -481,6 +499,24 @@ const OrderConfirm = () => { return; } + // 配送方式校验(仅送货上门模式需要选择) + if (goods.deliveryMode !== 1 && !deliveryMethod) { + Taro.showToast({ + title: '请选择配送方式', + icon: 'error' + }) + return; + } + + // 步梯场景:必须选择是否送上楼 + if (deliveryMethod === 'stairs' && needCarryUpstairs === null) { + Taro.showToast({ + title: '请选择是否需要送上楼', + icon: 'error' + }) + return; + } + if (!payment) { Taro.showToast({ title: '请选择支付方式', @@ -551,11 +587,13 @@ const OrderConfirm = () => { address.id, { comments: goods.name, - deliveryType: 0, + deliveryType: goods.deliveryMode === 1 ? 1 : 0, buyerRemarks: orderRemark, couponId: parseInt(String(bestCoupon.id), 10), skuId: resolvedSkuId, - specInfo: orderDataParam?.specInfo + specInfo: orderDataParam?.specInfo, + deliveryMethod: deliveryMethod || undefined, + deliveryFloor: needCarryUpstairs ? deliveryFloor : undefined } ); @@ -603,12 +641,14 @@ const OrderConfirm = () => { address.id, { comments: '桂乐淘', - deliveryType: 0, + deliveryType: goods.deliveryMode === 1 ? 1 : 0, buyerRemarks: orderRemark, // 🔧 确保 couponId 是正确的数字类型,且不传递 undefined couponId: selectedCoupon ? parseInt(String(selectedCoupon.id), 10) : undefined, skuId: resolvedSkuId, - specInfo: orderDataParam?.specInfo + specInfo: orderDataParam?.specInfo, + deliveryMethod: deliveryMethod || undefined, + deliveryFloor: needCarryUpstairs ? deliveryFloor : undefined } ); @@ -704,7 +744,7 @@ const OrderConfirm = () => { message.includes('电子围栏') || message.includes('围栏') - // “配送范围”类错误给出更友好的解释,并提供快捷入口去更换收货地址 + // "配送范围"类错误给出更友好的解释,并提供快捷入口去更换收货地址 if (isOutOfDeliveryRange) { try { const res = await Taro.showModal({ @@ -827,7 +867,7 @@ const OrderConfirm = () => { setPayment(paymentRes[0]) } - // 加载优惠券:使用“初始数量”对应的总价做推荐,避免默认数量变化导致推荐不准 + // 加载优惠券:使用"初始数量"对应的总价做推荐,避免默认数量变化导致推荐不准 if (goodsRes) { const initQty = (() => { const n = Number(goodsRes?.canBuyNumber) @@ -893,36 +933,52 @@ const OrderConfirm = () => { return (
- - { - address && ( - Taro.navigateTo({url: '/user/address/index'})}> - - - - - 送至 - - {address.province} {address.city} {address.region} {address.address} - - - - {address.name} {address.phone} - - - - ) - } - {!address && ( - Taro.navigateTo({url: '/user/address/index'})}> + {goods.deliveryMode === 1 ? ( + // 自提模式:显示到店自提提示 + + - - 添加收货地址 + + + 到店自提 + 请到店取货或出示核销码 + - )} - + + ) : ( + // 送货上门模式:显示地址选择 + + { + address && ( + Taro.navigateTo({url: '/user/address/index'})}> + + + + + 送至 + + {address.province} {address.city} {address.region} {address.address} + + + + {address.name} {address.phone} + + + + ) + } + {!address && ( + Taro.navigateTo({url: '/user/address/index'})}> + + + 添加收货地址 + + + )} + + )} {/**/} {/* { {/* />*/} {/**/} + {/* 配送方式选择(仅送货上门模式显示) */} + {goods.deliveryMode !== 1 && ( + + + + + 配送方式 + * + + + {[ + { key: 'elevator', label: '电梯', icon: '🏛️' }, + { key: 'stairs', label: '步梯', icon: '🚶' }, + { key: 'groundFloor', label: '一楼商铺/其他', icon: '🏪' }, + ].map(item => ( + { + setDeliveryMethod(item.key) + // 切换配送方式时重置送上楼选项 + setNeedCarryUpstairs(null) + setDeliveryFloor(2) + }} + > + {item.icon} + {item.label} + + ))} + + + {/* 步梯:是否需要送上楼 */} + {deliveryMethod === 'stairs' && ( + + 是否需要送上楼? + + setNeedCarryUpstairs(true)} + > + 需要送上楼 + + { + setNeedCarryUpstairs(false) + setDeliveryFloor(2) + }} + > + 不需要 + + + + )} + + {/* 步梯+送上楼:选择楼层 */} + {deliveryMethod === 'stairs' && needCarryUpstairs === true && ( + + 送至楼层 + setFloorPickerVisible(true)} + > + 1 ? 'text-gray-900' : 'text-gray-400'}> + {deliveryFloor > 1 ? `${deliveryFloor}楼` : '请选择楼层'} + + + + {deliveryFloor > 1 && ( + + + 配送费:{quantity}桶 × {deliveryFloor - 1}层 = ¥{getDeliveryFee().toFixed(2)} + + + )} + + )} + + + + )} + @@ -1042,7 +1180,14 @@ const OrderConfirm = () => { )} onClick={() => setCouponVisible(true)} /> - + + ¥{getDeliveryFee().toFixed(2)} + {deliveryMethod === 'stairs' && needCarryUpstairs && deliveryFloor > 1 && ( + ({quantity}桶×{deliveryFloor - 1}层) + )} + + }/> 已优惠 @@ -1216,6 +1361,49 @@ const OrderConfirm = () => { + {/* 楼层选择弹窗 */} + setFloorPickerVisible(false)} + style={{height: '40vh'}} + > + + + 选择楼层 + setFloorPickerVisible(false)} + > + 关闭 + + + + + {Array.from({length: 32}, (_, i) => i + 2).map(f => ( + { + setDeliveryFloor(f) + setFloorPickerVisible(false) + }} + > + {f}楼 + + ))} + + + {deliveryFloor > 1 && ( + + + 配送费:{quantity}桶 × {deliveryFloor - 1}层 = ¥{(quantity * (deliveryFloor - 1)).toFixed(2)} + + + )} + + +
@@ -1230,6 +1418,11 @@ const OrderConfirm = () => { 已优惠 ¥{getCouponDiscount().toFixed(2)} )} + {getDeliveryFee() > 0 && ( + + 含配送费 ¥{getDeliveryFee().toFixed(2)} + + )}