forked from gxwebsoft/mp-10550
refactor(config): 将环境配置文件从 TypeScript 转换为 JavaScript
- 移除 config/env.ts 文件并将环境配置转换为 config/env.js - 更新 config/index.ts 中的导入路径以匹配新的 JavaScript 文件扩展名 - 修改 src/utils/server.ts 中的开发服务器 URL 配置 - 更新 tsconfig.json 的 include 配置移除 config 目录 - 调整环境配置中的 API 地址设置统一使用生产环境地址 - 更新 .workbuddy/expert-history.json 中的时间戳记录
This commit is contained in:
@@ -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
|
||||
}
|
||||
90
.workbuddy/memory/2026-04-10.md
Normal file
90
.workbuddy/memory/2026-04-10.md
Normal file
@@ -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
|
||||
|
||||
### 微信后台需要申请的模板
|
||||
模板名称:订单配送通知
|
||||
关键词:订单状态、订单编号、配送地址、商品数量、通知时间
|
||||
@@ -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<ApiResult<{ pendingCount: number; totalCount: number }>>(
|
||||
'/glt/glt-ticket-order/rider/count',
|
||||
{ riderId }
|
||||
);
|
||||
if (res.code === 0) {
|
||||
return res.data;
|
||||
}
|
||||
return { pendingCount: 0, totalCount: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询送水订单列表
|
||||
*/
|
||||
|
||||
@@ -38,6 +38,8 @@ export interface ShopDealerUser {
|
||||
createTime?: string;
|
||||
// 修改时间
|
||||
updateTime?: string;
|
||||
// 分销商等级:0-普通用户 1-超级管理员 2-合伙人(总店) 3-合伙人(分店)
|
||||
dealerLevel?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -128,6 +128,10 @@ export interface ShopGoods {
|
||||
expiredDay?: number;
|
||||
// 可购买数量
|
||||
canBuyNumber?: number;
|
||||
// 活动方式:0全平台 1新用户专享
|
||||
activityType?: number;
|
||||
// 配送方式:0送上门 1限自提
|
||||
deliveryMode?: number;
|
||||
}
|
||||
|
||||
export interface BathSet {
|
||||
|
||||
@@ -201,6 +201,10 @@ export interface OrderCreateRequest {
|
||||
selfTakeMerchantId?: number;
|
||||
// 订单标题(可选,后端会自动生成)
|
||||
title?: string;
|
||||
// 配送方式:elevator(电梯) / stairs(步梯) / groundFloor(一楼商铺/其他)
|
||||
deliveryMethod?: string;
|
||||
// 楼层(步梯+送上楼时有值,从2开始)
|
||||
deliveryFloor?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
98
src/dealer/components/FreezeMoneyModal.tsx
Normal file
98
src/dealer/components/FreezeMoneyModal.tsx
Normal file
@@ -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<FreezeMoneyModalProps> = ({
|
||||
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 (
|
||||
<Popup
|
||||
visible={visible}
|
||||
style={{ padding: '0', borderRadius: '16px', overflow: 'hidden' }}
|
||||
onClose={onClose}
|
||||
closeOnOverlayClick={true}
|
||||
position="center"
|
||||
>
|
||||
<View className="w-72 bg-white">
|
||||
{/* 头部标题 */}
|
||||
<View className="relative px-4 py-4 border-b border-gray-100">
|
||||
<Text className="text-lg font-semibold text-gray-800 text-center block">
|
||||
待使用明细
|
||||
</Text>
|
||||
<View
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1"
|
||||
onClick={onClose}
|
||||
>
|
||||
<Close size={20} className="text-gray-400" />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 金额展示区域 */}
|
||||
<View className="px-6 py-8 text-center">
|
||||
<Text className="text-sm text-gray-500 mb-2 block">
|
||||
待使用(冻结中)
|
||||
</Text>
|
||||
<View className="flex items-baseline justify-center">
|
||||
<Text className="text-2xl font-bold text-gray-800">¥</Text>
|
||||
<Text className="text-4xl font-bold text-gray-800 ml-1">
|
||||
{formatAmount(amount)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 分隔线 */}
|
||||
<View className="mx-6 h-px bg-gray-100" />
|
||||
|
||||
{/* 温馨提示区域 */}
|
||||
<View className="px-6 py-6">
|
||||
<Text className="text-sm font-medium text-gray-700 mb-3 block">
|
||||
温馨提示:
|
||||
</Text>
|
||||
<Text className="text-sm text-gray-500 leading-relaxed block">
|
||||
需要直推用户进行下单配送后方可解冻到待提现资金组
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<View className="px-6 pb-6">
|
||||
<Button
|
||||
type="primary"
|
||||
block
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
height: '44px',
|
||||
lineHeight: '44px'
|
||||
}}
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</Popup>
|
||||
)
|
||||
}
|
||||
|
||||
export default FreezeMoneyModal
|
||||
@@ -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}
|
||||
>
|
||||
<Text className="text-lg font-bold mb-1 text-white">
|
||||
{formatMoney(dealerUser.freezeMoney)}
|
||||
</Text>
|
||||
<Text className="text-xs" style={{ color: 'rgba(255, 255, 255, 0.9)' }}>
|
||||
{isRider && Number(dealerUser.freezeMoney ?? 0) > 0 ? '待使用' : '待使用'}
|
||||
待使用
|
||||
</Text>
|
||||
</View>
|
||||
<View className="text-center p-3 rounded-lg flex flex-col" style={{
|
||||
@@ -351,6 +371,13 @@ const DealerIndex: React.FC = () => {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 待使用明细弹窗 */}
|
||||
<FreezeMoneyModal
|
||||
visible={freezeMoneyModalVisible}
|
||||
amount={dealerUser?.freezeMoney || '0'}
|
||||
onClose={handleCloseFreezeMoneyModal}
|
||||
/>
|
||||
|
||||
{/* 底部安全区域 */}
|
||||
<View className="h-20"></View>
|
||||
</View>
|
||||
|
||||
277
src/hooks/useRiderNotification.ts
Normal file
277
src/hooks/useRiderNotification.ts
Normal file
@@ -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<void>
|
||||
/** 切换声音开关 */
|
||||
toggleSound: () => void
|
||||
/** 手动播放提示音 */
|
||||
playSound: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 配送员通知 Hook
|
||||
* @param autoStart 是否自动开始轮询,默认 true
|
||||
*/
|
||||
export const useRiderNotification = (autoStart = true): UseRiderNotificationReturn => {
|
||||
const [state, setState] = useState<RiderNotificationState>({
|
||||
pendingCount: 0,
|
||||
totalCount: 0,
|
||||
isPolling: false,
|
||||
lastOrderId: null,
|
||||
soundEnabled: true
|
||||
})
|
||||
|
||||
const pollTimerRef = useRef<number | null>(null)
|
||||
const audioContextRef = useRef<any>(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
|
||||
@@ -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<any, any>((_, ref) => {
|
||||
const {data, refresh} = useUserData()
|
||||
@@ -33,11 +34,26 @@ const UserCard = forwardRef<any, any>((_, 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<any, any>((_, ref) => {
|
||||
<View className={'flex flex-col'}>
|
||||
<Text style={{color: '#ffffff'}}>{getDisplayName() || '点击登录'}</Text>
|
||||
{getRootDomain() && (
|
||||
<View><Tag type="success">{getRoleName()}</Tag></View>
|
||||
<View>
|
||||
{roleLevelConfig ? (
|
||||
<Tag
|
||||
type={roleLevelConfig.tagType as any}
|
||||
style={{
|
||||
backgroundColor: roleLevelConfig.bgColor,
|
||||
color: roleLevelConfig.textColor,
|
||||
borderColor: roleLevelConfig.borderColor,
|
||||
}}
|
||||
>
|
||||
{getRoleName()}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag type="success">{getRoleName()}</Tag>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -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 = () => {
|
||||
>
|
||||
<Grid.Item text="配送订单" onClick={() => navigateToPage('/rider/orders/index')}>
|
||||
<View className="text-center">
|
||||
<View className="w-12 h-12 bg-blue-50 rounded-xl flex items-center justify-center mx-auto mb-2">
|
||||
<View className="w-12 h-12 bg-blue-50 rounded-xl flex items-center justify-center mx-auto mb-2 relative">
|
||||
<Shopping color="#3b82f6" size="20"/>
|
||||
{pendingCount > 0 && (
|
||||
<Badge
|
||||
value={pendingCount > 99 ? '99+' : pendingCount}
|
||||
max={99}
|
||||
style={{ position: 'absolute', top: '-4px', right: '-4px' }}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</Grid.Item>
|
||||
@@ -323,46 +390,96 @@ const DealerIndex: React.FC = () => {
|
||||
</Grid.Item>
|
||||
</Grid>
|
||||
|
||||
{/* 第二行功能 */}
|
||||
{/*<Grid*/}
|
||||
{/* columns={4}*/}
|
||||
{/* className="no-border-grid mt-4"*/}
|
||||
{/* style={{*/}
|
||||
{/* '--nutui-grid-border-color': 'transparent',*/}
|
||||
{/* '--nutui-grid-item-border-width': '0px',*/}
|
||||
{/* border: 'none'*/}
|
||||
{/* } as React.CSSProperties}*/}
|
||||
{/*>*/}
|
||||
{/* <Grid.Item text={'邀请统计'} onClick={() => navigateToPage('/dealer/invite-stats/index')}>*/}
|
||||
{/* <View className="text-center">*/}
|
||||
{/* <View className="w-12 h-12 bg-indigo-50 rounded-xl flex items-center justify-center mx-auto mb-2">*/}
|
||||
{/* <Presentation color="#6366f1" size="20"/>*/}
|
||||
{/* </View>*/}
|
||||
{/* </View>*/}
|
||||
{/* </Grid.Item>*/}
|
||||
{/* 第二行功能 - 通知设置 */}
|
||||
<Grid
|
||||
columns={4}
|
||||
className="no-border-grid mt-4"
|
||||
style={{
|
||||
'--nutui-grid-border-color': 'transparent',
|
||||
'--nutui-grid-item-border-width': '0px',
|
||||
border: 'none'
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<Grid.Item text={'通知设置'} onClick={() => {
|
||||
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'
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}}>
|
||||
<View className="text-center">
|
||||
<View className="w-12 h-12 bg-indigo-50 rounded-xl flex items-center justify-center mx-auto mb-2 relative">
|
||||
<Setting color={soundEnabled ? '#6366f1' : '#9ca3af'} size="20"/>
|
||||
{soundEnabled ? (
|
||||
<View className="absolute -bottom-1 -right-1 w-3 h-3 bg-green-500 rounded-full border-2 border-white"></View>
|
||||
) : (
|
||||
<View className="absolute -bottom-1 -right-1 w-3 h-3 bg-gray-400 rounded-full border-2 border-white"></View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</Grid.Item>
|
||||
|
||||
{/* /!* 预留其他功能位置 *!/*/}
|
||||
{/* <Grid.Item text={''}>*/}
|
||||
{/* <View className="text-center">*/}
|
||||
{/* <View className="w-12 h-12 bg-gray-50 rounded-xl flex items-center justify-center mx-auto mb-2">*/}
|
||||
{/* </View>*/}
|
||||
{/* </View>*/}
|
||||
{/* </Grid.Item>*/}
|
||||
{/* 预留功能位置 */}
|
||||
<Grid.Item text={''}>
|
||||
<View className="text-center">
|
||||
<View className="w-12 h-12 bg-gray-50 rounded-xl flex items-center justify-center mx-auto mb-2">
|
||||
</View>
|
||||
</View>
|
||||
</Grid.Item>
|
||||
|
||||
{/* <Grid.Item text={''}>*/}
|
||||
{/* <View className="text-center">*/}
|
||||
{/* <View className="w-12 h-12 bg-gray-50 rounded-xl flex items-center justify-center mx-auto mb-2">*/}
|
||||
{/* </View>*/}
|
||||
{/* </View>*/}
|
||||
{/* </Grid.Item>*/}
|
||||
<Grid.Item text={''}>
|
||||
<View className="text-center">
|
||||
<View className="w-12 h-12 bg-gray-50 rounded-xl flex items-center justify-center mx-auto mb-2">
|
||||
</View>
|
||||
</View>
|
||||
</Grid.Item>
|
||||
|
||||
{/* <Grid.Item text={''}>*/}
|
||||
{/* <View className="text-center">*/}
|
||||
{/* <View className="w-12 h-12 bg-gray-50 rounded-xl flex items-center justify-center mx-auto mb-2">*/}
|
||||
{/* </View>*/}
|
||||
{/* </View>*/}
|
||||
{/* </Grid.Item>*/}
|
||||
{/*</Grid>*/}
|
||||
<Grid.Item text={''}>
|
||||
<View className="text-center">
|
||||
<View className="w-12 h-12 bg-gray-50 rounded-xl flex items-center justify-center mx-auto mb-2">
|
||||
</View>
|
||||
</View>
|
||||
</Grid.Item>
|
||||
</Grid>
|
||||
</ConfigProvider>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -377,6 +377,17 @@ const GoodsDetail = () => {
|
||||
<View className={"car-no text-lg"}>
|
||||
{goods.name}
|
||||
</View>
|
||||
{/* 活动/配送标签 */}
|
||||
{(goods.activityType === 1 || goods.deliveryMode === 1) && (
|
||||
<View className={"flex gap-1 py-1"}>
|
||||
{goods.activityType === 1 && (
|
||||
<Text className={"text-xs bg-red-500 text-white px-2 py-0.5 rounded-full"}>新用户专享</Text>
|
||||
)}
|
||||
{goods.deliveryMode === 1 && (
|
||||
<Text className={"text-xs bg-orange-500 text-white px-2 py-0.5 rounded-full"}>仅限自提</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
<View className={"flex justify-between text-xs py-1"}>
|
||||
<span className={"text-orange-500"}>
|
||||
{goods.comments}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<CouponCardProps[]>([])
|
||||
const [couponLoading, setCouponLoading] = useState<boolean>(false)
|
||||
|
||||
// 门店选择:用于在下单页展示当前“已选门店”,并允许用户切换(写入 SelectedStore Storage)
|
||||
// 门店选择:用于在下单页展示当前"已选门店",并允许用户切换(写入 SelectedStore Storage)
|
||||
const [storePopupVisible, setStorePopupVisible] = useState(false)
|
||||
const [stores, setStores] = useState<ShopStore[]>([])
|
||||
const [storeLoading, setStoreLoading] = useState(false)
|
||||
const [selectedStore, setSelectedStore] = useState<ShopStore | null>(getSelectedStoreFromStorage())
|
||||
|
||||
// 配送方式:elevator(电梯) / stairs(步梯) / groundFloor(一楼商铺/其他)
|
||||
const [deliveryMethod, setDeliveryMethod] = useState<string>('')
|
||||
// 步梯是否需要送上楼(null=未选择)
|
||||
const [needCarryUpstairs, setNeedCarryUpstairs] = useState<boolean | null>(null)
|
||||
// 楼层(从2开始,需要送上楼时选择)
|
||||
const [deliveryFloor, setDeliveryFloor] = useState<number>(2)
|
||||
// 楼层选择弹窗
|
||||
const [floorPickerVisible, setFloorPickerVisible] = useState(false)
|
||||
|
||||
const router = Taro.getCurrentInstance().router;
|
||||
const params = router?.params || ({} as Record<string, any>)
|
||||
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,6 +933,21 @@ const OrderConfirm = () => {
|
||||
|
||||
return (
|
||||
<div className={'order-confirm-page'}>
|
||||
{goods.deliveryMode === 1 ? (
|
||||
// 自提模式:显示到店自提提示
|
||||
<CellGroup>
|
||||
<Cell>
|
||||
<Space>
|
||||
<Shop className={'text-orange-500'}/>
|
||||
<View className={'flex flex-col w-full'}>
|
||||
<Text className={'font-medium text-orange-600'}>到店自提</Text>
|
||||
<Text className={'text-gray-500 text-sm mt-1'}>请到店取货或出示核销码</Text>
|
||||
</View>
|
||||
</Space>
|
||||
</Cell>
|
||||
</CellGroup>
|
||||
) : (
|
||||
// 送货上门模式:显示地址选择
|
||||
<CellGroup>
|
||||
{
|
||||
address && (
|
||||
@@ -923,6 +978,7 @@ const OrderConfirm = () => {
|
||||
</Cell>
|
||||
)}
|
||||
</CellGroup>
|
||||
)}
|
||||
|
||||
{/*<CellGroup>*/}
|
||||
{/* <Cell*/}
|
||||
@@ -944,6 +1000,88 @@ const OrderConfirm = () => {
|
||||
{/* />*/}
|
||||
{/*</CellGroup>*/}
|
||||
|
||||
{/* 配送方式选择(仅送货上门模式显示) */}
|
||||
{goods.deliveryMode !== 1 && (
|
||||
<CellGroup className={'delivery-method-group'}>
|
||||
<Cell>
|
||||
<View className={'delivery-method-section'}>
|
||||
<View className={'delivery-method-label'}>
|
||||
<Text className={'font-medium text-sm'}>配送方式</Text>
|
||||
<Text className={'text-red-500 text-xs ml-1'}>*</Text>
|
||||
</View>
|
||||
<View className={'delivery-method-options'}>
|
||||
{[
|
||||
{ key: 'elevator', label: '电梯', icon: '🏛️' },
|
||||
{ key: 'stairs', label: '步梯', icon: '🚶' },
|
||||
{ key: 'groundFloor', label: '一楼商铺/其他', icon: '🏪' },
|
||||
].map(item => (
|
||||
<View
|
||||
key={item.key}
|
||||
className={`delivery-method-item ${deliveryMethod === item.key ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setDeliveryMethod(item.key)
|
||||
// 切换配送方式时重置送上楼选项
|
||||
setNeedCarryUpstairs(null)
|
||||
setDeliveryFloor(2)
|
||||
}}
|
||||
>
|
||||
<Text className={'delivery-method-icon'}>{item.icon}</Text>
|
||||
<Text className={'delivery-method-text'}>{item.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* 步梯:是否需要送上楼 */}
|
||||
{deliveryMethod === 'stairs' && (
|
||||
<View className={'carry-upstairs-section'}>
|
||||
<Text className={'text-sm text-gray-600 mb-2'}>是否需要送上楼?</Text>
|
||||
<View className={'carry-upstairs-options'}>
|
||||
<View
|
||||
className={`carry-upstairs-item ${needCarryUpstairs === true ? 'active' : ''}`}
|
||||
onClick={() => setNeedCarryUpstairs(true)}
|
||||
>
|
||||
<Text>需要送上楼</Text>
|
||||
</View>
|
||||
<View
|
||||
className={`carry-upstairs-item ${needCarryUpstairs === false ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setNeedCarryUpstairs(false)
|
||||
setDeliveryFloor(2)
|
||||
}}
|
||||
>
|
||||
<Text>不需要</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 步梯+送上楼:选择楼层 */}
|
||||
{deliveryMethod === 'stairs' && needCarryUpstairs === true && (
|
||||
<View className={'floor-select-section'}>
|
||||
<Text className={'text-sm text-gray-600'}>送至楼层</Text>
|
||||
<View
|
||||
className={'floor-select-btn'}
|
||||
onClick={() => setFloorPickerVisible(true)}
|
||||
>
|
||||
<Text className={deliveryFloor > 1 ? 'text-gray-900' : 'text-gray-400'}>
|
||||
{deliveryFloor > 1 ? `${deliveryFloor}楼` : '请选择楼层'}
|
||||
</Text>
|
||||
<ArrowRight className={'text-gray-400'} size={14}/>
|
||||
</View>
|
||||
{deliveryFloor > 1 && (
|
||||
<View className={'floor-fee-tip'}>
|
||||
<Text className={'text-xs text-orange-500'}>
|
||||
配送费:{quantity}桶 × {deliveryFloor - 1}层 = ¥{getDeliveryFee().toFixed(2)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Cell>
|
||||
</CellGroup>
|
||||
)}
|
||||
|
||||
<CellGroup>
|
||||
<Cell key={goods.goodsId}>
|
||||
<View className={'flex w-full justify-between gap-3'}>
|
||||
@@ -1042,7 +1180,14 @@ const OrderConfirm = () => {
|
||||
)}
|
||||
onClick={() => setCouponVisible(true)}
|
||||
/>
|
||||
<Cell title={'配送费'} extra={'¥0.00'}/>
|
||||
<Cell title={'配送费'} extra={
|
||||
<View className={'flex items-center gap-1'}>
|
||||
<Text>¥{getDeliveryFee().toFixed(2)}</Text>
|
||||
{deliveryMethod === 'stairs' && needCarryUpstairs && deliveryFloor > 1 && (
|
||||
<Text className={'text-xs text-gray-400'}>({quantity}桶×{deliveryFloor - 1}层)</Text>
|
||||
)}
|
||||
</View>
|
||||
}/>
|
||||
<Cell extra={(
|
||||
<View className={'flex items-end gap-2'}>
|
||||
<Text>已优惠</Text>
|
||||
@@ -1216,6 +1361,49 @@ const OrderConfirm = () => {
|
||||
</View>
|
||||
</Popup>
|
||||
|
||||
{/* 楼层选择弹窗 */}
|
||||
<Popup
|
||||
visible={floorPickerVisible}
|
||||
position="bottom"
|
||||
onClose={() => setFloorPickerVisible(false)}
|
||||
style={{height: '40vh'}}
|
||||
>
|
||||
<View className="floor-picker-popup">
|
||||
<View className="floor-picker-popup__header">
|
||||
<Text className="text-base font-medium">选择楼层</Text>
|
||||
<Text
|
||||
className="text-sm text-gray-500"
|
||||
onClick={() => setFloorPickerVisible(false)}
|
||||
>
|
||||
关闭
|
||||
</Text>
|
||||
</View>
|
||||
<View className="floor-picker-popup__content">
|
||||
<View className="floor-grid">
|
||||
{Array.from({length: 32}, (_, i) => i + 2).map(f => (
|
||||
<View
|
||||
key={f}
|
||||
className={`floor-grid-item ${deliveryFloor === f ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setDeliveryFloor(f)
|
||||
setFloorPickerVisible(false)
|
||||
}}
|
||||
>
|
||||
<Text>{f}楼</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
{deliveryFloor > 1 && (
|
||||
<View className="floor-picker-popup__footer">
|
||||
<Text className={'text-sm text-gray-600'}>
|
||||
配送费:{quantity}桶 × {deliveryFloor - 1}层 = <Text className={'text-red-500 font-bold'}>¥{(quantity * (deliveryFloor - 1)).toFixed(2)}</Text>
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Popup>
|
||||
|
||||
<Gap height={50}/>
|
||||
|
||||
<div className={'fixed z-50 bg-white w-full bottom-0 left-0 pt-4 pb-10 border-t border-gray-200'}>
|
||||
@@ -1230,6 +1418,11 @@ const OrderConfirm = () => {
|
||||
已优惠 ¥{getCouponDiscount().toFixed(2)}
|
||||
</View>
|
||||
)}
|
||||
{getDeliveryFee() > 0 && (
|
||||
<View className={'text-xs text-orange-500'}>
|
||||
含配送费 ¥{getDeliveryFee().toFixed(2)}
|
||||
</View>
|
||||
)}
|
||||
</div>
|
||||
<div className={'buy-btn mx-4'}>
|
||||
<Button
|
||||
|
||||
@@ -476,6 +476,8 @@ export function buildSingleGoodsOrder(
|
||||
specInfo?: string;
|
||||
buyerRemarks?: string;
|
||||
sendStartTime?: string;
|
||||
deliveryMethod?: string;
|
||||
deliveryFloor?: number;
|
||||
}
|
||||
): OrderCreateRequest {
|
||||
return {
|
||||
@@ -493,7 +495,9 @@ export function buildSingleGoodsOrder(
|
||||
sendStartTime: options?.sendStartTime,
|
||||
deliveryType: options?.deliveryType || 0,
|
||||
couponId: options?.couponId,
|
||||
selfTakeMerchantId: options?.selfTakeMerchantId
|
||||
selfTakeMerchantId: options?.selfTakeMerchantId,
|
||||
deliveryMethod: options?.deliveryMethod,
|
||||
deliveryFloor: options?.deliveryFloor
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
115
src/utils/userLevel.ts
Normal file
115
src/utils/userLevel.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* 用户等级配置
|
||||
* 用于分销商角色的显示和样式管理
|
||||
*/
|
||||
|
||||
/** 用户等级枚举 */
|
||||
export enum UserLevel {
|
||||
/** 普通用户 */
|
||||
NORMAL = 0,
|
||||
/** 超级管理员 */
|
||||
SUPER_ADMIN = 1,
|
||||
/** 合伙人(总店) */
|
||||
PARTNER_HEAD = 2,
|
||||
/** 合伙人(分店) */
|
||||
PARTNER_BRANCH = 3,
|
||||
}
|
||||
|
||||
/** 用户等级配置接口 */
|
||||
export interface UserLevelConfig {
|
||||
level: UserLevel;
|
||||
name: string;
|
||||
/** Tag 组件的 type 属性 */
|
||||
tagType: 'default' | 'success' | 'warning' | 'danger';
|
||||
/** 背景色 */
|
||||
bgColor: string;
|
||||
/** 文字颜色 */
|
||||
textColor: string;
|
||||
/** 边框颜色 */
|
||||
borderColor: string;
|
||||
}
|
||||
|
||||
/** 用户等级配置表 */
|
||||
export const USER_LEVEL_CONFIG: Record<UserLevel, UserLevelConfig> = {
|
||||
[UserLevel.NORMAL]: {
|
||||
level: UserLevel.NORMAL,
|
||||
name: '普通用户',
|
||||
tagType: 'default',
|
||||
bgColor: '#f5f5f5',
|
||||
textColor: '#666666',
|
||||
borderColor: '#e5e5e5',
|
||||
},
|
||||
[UserLevel.SUPER_ADMIN]: {
|
||||
level: UserLevel.SUPER_ADMIN,
|
||||
name: '超级管理员',
|
||||
tagType: 'danger',
|
||||
bgColor: '#fff2f0',
|
||||
textColor: '#cf1322',
|
||||
borderColor: '#ffccc7',
|
||||
},
|
||||
[UserLevel.PARTNER_HEAD]: {
|
||||
level: UserLevel.PARTNER_HEAD,
|
||||
name: '合伙人',
|
||||
tagType: 'warning',
|
||||
bgColor: '#fff7e6',
|
||||
textColor: '#d46b08',
|
||||
borderColor: '#ffd8bf',
|
||||
},
|
||||
[UserLevel.PARTNER_BRANCH]: {
|
||||
level: UserLevel.PARTNER_BRANCH,
|
||||
name: '合伙人',
|
||||
tagType: 'success',
|
||||
bgColor: '#f6ffed',
|
||||
textColor: '#389e0d',
|
||||
borderColor: '#b7eb8f',
|
||||
},
|
||||
};
|
||||
|
||||
/** 显示名称(带后缀区分总店和分店) */
|
||||
export const USER_LEVEL_DISPLAY_NAMES: Record<UserLevel, string> = {
|
||||
[UserLevel.NORMAL]: '普通用户',
|
||||
[UserLevel.SUPER_ADMIN]: '超级管理员',
|
||||
[UserLevel.PARTNER_HEAD]: '合伙人(总店)',
|
||||
[UserLevel.PARTNER_BRANCH]: '合伙人(分店)',
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据等级值获取配置
|
||||
* @param level 等级值
|
||||
* @returns 用户等级配置
|
||||
*/
|
||||
export function getUserLevelConfig(level?: number): UserLevelConfig {
|
||||
const validLevel = level ?? 0;
|
||||
return USER_LEVEL_CONFIG[validLevel as UserLevel] || USER_LEVEL_CONFIG[UserLevel.NORMAL];
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据等级值获取显示名称
|
||||
* @param level 等级值
|
||||
* @returns 显示名称
|
||||
*/
|
||||
export function getUserLevelName(level?: number): string {
|
||||
const validLevel = level ?? 0;
|
||||
return USER_LEVEL_DISPLAY_NAMES[validLevel as UserLevel] || USER_LEVEL_DISPLAY_NAMES[UserLevel.NORMAL];
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否是合伙人(包括总店和分店)
|
||||
* @param level 等级值
|
||||
* @returns 是否是合伙人
|
||||
*/
|
||||
export function isPartner(level?: number): boolean {
|
||||
return level === UserLevel.PARTNER_HEAD || level === UserLevel.PARTNER_BRANCH;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否是管理员(包括超级管理员)
|
||||
* @param level 等级值
|
||||
* @returns 是否是管理员
|
||||
*/
|
||||
export function isAdmin(level?: number): boolean {
|
||||
return level === UserLevel.SUPER_ADMIN || level === UserLevel.ADMIN;
|
||||
}
|
||||
|
||||
// 导出默认配置
|
||||
export default USER_LEVEL_CONFIG;
|
||||
Reference in New Issue
Block a user