Compare commits

..

32 Commits

Author SHA1 Message Date
94e1e05fdf fix(payment): 修复微信支付时openid绑定问题
- 新增确保支付前openid正确绑定的方法,解决支付账号不一致问题
- 在创建微信支付订单前强制刷新openid,防止旧微信账号导致支付失败
- 在自动登录后补充openid绑定步骤,确保支付所需的openid存在
- 设计为非阻塞流程,避免网络异常导致支付阻塞
- 仅针对微信支付触发,其他支付方式不受影响
2026-04-25 12:59:59 +08:00
4d96ca4569 fix(withdraw): 限制单笔提现金额及调整快捷金额选项
- 添加单笔提现金额上限200元的提示和限制逻辑
- 修改快捷金额选项为50、100和200元
- 保证提现金额不超过可用余额的校验逻辑正常运行
2026-04-16 01:05:31 +08:00
daafca1d5d docs(withdraw): 添加单笔提现最高金额限制提示
- 在提现说明中新增第4条提示,说明单笔提现最高金额为200
- 提醒用户注意提现金额限制,避免超额申请
2026-04-16 00:59:15 +08:00
128a566162 fix(rider): 修复订阅消息请求参数类型错误
- 在请求订阅消息时添加支付宝模板ID字段entityIds,满足类型要求
- 保持微信端代码逻辑不变
- 修正options对象类型声明为Taro.requestSubscribeMessage.Option
2026-04-12 22:03:53 +08:00
54404aa48f feat(order): 迁移和完善配送方式功能,支持全链路入库与展示
- 迁移配送方式选择功能从 orderConfirm 页至 user/ticket/use 页面
- orderConfirm 页面移除配送方式相关状态、UI与校验,取消配送费计算
- user/ticket/use 页面新增配送方式UI组件,支持配送费计算、楼层选择弹窗和提交校验
- 新增录入deliveryMethod、deliveryFloor、deliveryFee字段至订单模型与后端数据库
- 骑手端订单列表新增配送方式、楼层、配送费的详细展示
- 更新环境配置接口地址到正式API,修正测试及开发环境
- 用户页底部组件UI优化,新增版权icon并重构结构样式
- 使用配送方式字段校验下单逻辑,支持编辑模式配送信息回显与费用显示
- 移除orderConfirm中配送方式相关样式和组件,实现代码回滚清理
2026-04-12 21:57:50 +08:00
e0418df018 fix(config): 修正环境配置的接口地址
- 将开发、测试及生产环境的API和服务器地址统一更改为开发环境地址
- 修复 FreezeMoneyModal 中关闭按钮位置样式问题
- 调整 Shop 模块商品详情角标字体内边距,提高视觉舒适度
- 订单确认页调整部分字体大小,提高显示效果一致性
- 修改 Dealer 模块待使用金额弹窗逻辑,允许金额为0时也显示弹窗
2026-04-10 02:08:56 +08:00
e3181c8ade 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 中的时间戳记录
2026-04-10 01:48:22 +08:00
12917a4766 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 中的时间戳记录
2026-04-03 20:17:23 +08:00
6fb8be275a chore(config): 为环境配置添加类型注解并初始化专家历史记录
- 为 CURRENT_ENV 变量添加 'production' 类型注解
- 初始化 .workbuddy/expert-history.json 文件
- 添加 Will 专家配置信息
- 记录专家使用时间和行业信息
2026-04-02 20:21:01 +08:00
190df391c3 build(config): 更新 TypeScript 配置以包含 config 目录
- 在 include 数组中添加 ./config 路径
- 确保 TypeScript 编译器能够识别 config 目录下的文件
2026-04-02 19:43:46 +08:00
5fe881b927 feat(dealer): 添加配送员解冻资金功能
- 在dealer页面添加配送员权限判断和解冻资金功能
- 导入useUser hook和updateShopDealerUser API
- 仅配送员角色可操作冻结金额转入可提现
- 点击待使用金额弹出确认框进行资金转移
- 统一rider和dealer页面的解冻资金逻辑实现
- 修改环境配置支持SERVER_API_URL变量导出
- 更新版权信息配置结构优化代码注释
- 优化待使用金额卡片点击交互体验
2026-03-31 13:37:14 +08:00
5ff710c6a0 feat(order): 增加订单确认页面数量步长控制功能
- 在订单确认页面实现商品购买数量的步长控制机制
- 添加了商品模型中的step字段支持,用于定义购买步长
- 实现了水票套票模板的step配置和最小购买数量逻辑
- 优化了订单参数解析逻辑,支持orderData模式下的商品规格信息传递
- 更新了API基础URL配置,切换到新的测试服务器地址
- 在下单接口中增加了skuId和specInfo参数传递支持
- 完善了数量变更时的价格计算和库存限制逻辑
2026-03-16 00:20:42 +08:00
6b1e506f43 feat(order): 增加订单确认页面数量步长控制功能
- 在订单确认页面实现商品购买数量的步长控制机制
- 添加了商品模型中的step字段支持,用于定义购买步长
- 实现了水票套票模板的step配置和最小购买数量逻辑
- 优化了订单参数解析逻辑,支持orderData模式下的商品规格信息传递
- 更新了API基础URL配置,切换到新的测试服务器地址
- 在下单接口中增加了skuId和specInfo参数传递支持
- 完善了数量变更时的价格计算和库存限制逻辑
2026-03-16 00:19:30 +08:00
4a45bc5242 feat(ticket): 添加支付后自动刷新水票列表功能
- 在订单确认页面跳转到水票列表时添加时间戳参数
- 在水票列表页面添加支付后自动刷新逻辑
- 使用 ref 防止重复执行自动刷新
- 添加缓存键避免重复处理同一支付请求
- 支付后自动重试刷新水票列表三次,确保数据同步
- 实现了防抖机制防止并发刷新操作
2026-03-11 18:51:23 +08:00
0628a0f6b4 feat(ticket): 添加票券自动重试加载功能
- 引入 ticketAutoRetryCountRef 和 ticketAutoRetryTimerRef 引用计数器
- 实现购买票券后异步重试刷新逻辑,最多重试4次
- 添加延迟重试机制,间隔时间分别为800ms、1500ms、2500ms、4000ms
- 在页面显示时重置重试计数器并清除现有定时器
- 添加清理函数确保组件卸载时清除定时器
- 当检测到可用票券时不进行重试并重置计数器
2026-03-11 17:33:33 +08:00
8b902be603 fix(ticket): 修复水票相关功能显示和交互问题
- 修改订单取消后水票退回提示图标为无图标模式
- 注释掉暂无可用水票时的弹窗提示逻辑
- 调整空状态按钮点击事件,在编辑模式下关闭弹窗而非跳转购买
- 优化下单按钮显示逻辑,区分编辑模式和普通模式的不同行为
- 修复提交按钮文案显示问题,确保编辑模式下显示正确文字
2026-03-11 16:36:22 +08:00
37ab933849 fix(ticket): 修复编辑模式下按钮文本显示问题
- 在无可用票据条件判断中添加编辑模式检查
- 根据编辑模式动态显示按钮文本为"确定修改"或"确定下单"
- 确保编辑模式下购买按钮也显示正确的操作文本
2026-03-11 16:23:24 +08:00
e58a2fd915 fix(ticket): 修复编辑模式下无可用票据提示问题
- 在useEffect中添加isEditMode判断,避免编辑订单时弹出无票据提示
- 更新useEffect依赖数组,添加isEditMode依赖
- 修改按钮点击事件,确保编辑模式下不会触发无票据购买引导
2026-03-11 16:07:19 +08:00
4ffe3a8f4b refactor(ticket): 重构订单管理界面和地址修改逻辑
- 移除30天地址修改冷却限制功能
- 删除相关的历史订单查询和地址锁定逻辑
- 将订单状态检查逻辑简化为统一的待配送检查函数
- 在编辑模式下验证订单是否可修改
- 调整按钮文本从"去购买水票"改为"确定下单"
- 优化订单操作按钮的位置和显示逻辑
- 移除地址修改限制相关的UI提示和状态管理
2026-03-11 13:51:40 +08:00
e7caee08c1 fix(ticket): 修复订单取消时的票券回滚逻辑和加载状态控制
- 添加 orderCancelLoadingById 状态管理订单取消加载状态
- 实现 getTicketUsedQty 函数统一处理票券已使用数量字段
- 完善 rollbackUserTicketAfterOrderCancel 方法支持已使用数量回滚
- 添加防重复提交机制避免订单取消多次触发
- 更新订单修改和取消按钮禁用状态防止并发操作
- 优化票券可用数量和已使用数量的计算逻辑
2026-03-10 17:18:18 +08:00
cc58bd791d feat(ticket): 添加水票释放计划功能
- 在应用配置中注册新的释放计划页面路由 ticket/release/index
- 简化 API 请求参数结构,移除不必要的包装对象
- 在用户票券列表中添加释放计划详情入口和跳转逻辑
- 显示票券套票名称信息增强用户体验
- 在配送时间选择中添加日期验证防止选择过去日期
- 新增完整的释放计划详情页面实现列表展示、下拉刷新、上拉加载等功能
- 添加释放计划状态显示和数量统计信息展示
2026-03-10 15:30:38 +08:00
ac194b93eb feat(ticket): 添加水票释放计划功能
- 在应用配置中注册新的释放计划页面路由 ticket/release/index
- 简化 API 请求参数结构,移除不必要的包装对象
- 在用户票券列表中添加释放计划详情入口和跳转逻辑
- 显示票券套票名称信息增强用户体验
- 在配送时间选择中添加日期验证防止选择过去日期
- 新增完整的释放计划详情页面实现列表展示、下拉刷新、上拉加载等功能
- 添加释放计划状态显示和数量统计信息展示
2026-03-10 15:27:33 +08:00
1cdb6404ad feat(ticket): 添加水票立即送水功能
- 引入 ensureLoggedIn 工具函数用于登录验证
- 实现 goSendWater 函数处理送水逻辑
- 添加水票状态和可用次数校验
- 在水票列表项中添加立即送水按钮
- 设置按钮禁用状态根据水票可用性
- 防止卡片点击事件冒泡冲突
2026-03-10 13:56:02 +08:00
ef6a55112f fix(water-delivery): 移除送水功能中的硬编码商品ID
- 移除了立即送水按钮中的固定商品ID参数
- 修改ensureLoggedIn函数调用,不再传递商品ID
- 更新Taro.navigateTo路径,移除硬编码的goodsId参数
2026-03-10 13:43:52 +08:00
00f3954012 feat(ticket): 实现基于模板配置的动态起送数量功能
- 引入 gltTicketTemplate API 获取模板配置
- 将固定起送数量改为动态可配置的最小起送数量
- 添加基于商品ID或票据模板ID获取起送配置的功能
- 实现页面初始化时从票据模板加载起送数量配置
- 更新用户界面显示实际的动态起送数量要求
- 添加异步加载和取消请求的安全处理机制
2026-03-10 12:11:48 +08:00
0c9a03d656 feat(ticket): 添加水票可用数量计算和订单取消后水票回退功能
- 引入 getGltUserTicket 和 updateGltUserTicket API 接口
- 实现 getTicketAvailableQty 函数用于计算水票可用数量
- 添加 rollbackUserTicketAfterOrderCancel 函数处理订单取消后的水票回退逻辑
- 在订单取消时获取取消前的水票状态并进行数量对比
- 订单取消成功后自动回退相应的水票数量
- 添加水票回退失败时的错误提示和用户通知
- 更新取消订单的成功提示信息为"订单已取消,水票已退回"
2026-03-10 11:36:59 +08:00
80d4db4156 config(server): 切换到正式服务器API地址
- 将SERVER_API_URL从测试地址切换为正式地址
- 注释掉旧的服务器地址配置
- 确保使用正确的线上服务接口地址
2026-03-10 11:30:32 +08:00
a6749bcedb fix(order): 优化订单状态判断逻辑并修复页面跳转参数
- 修改送水订单跳转链接,添加tab参数支持
- 更新骑手端页面样式,添加业务渐变背景色
- 将骑手端"工资明细"改为"收入明细"
- 优化订单配送状态判断逻辑,支持配送未开始的订单修改取消
- 更新订单操作提示文案,从"待配送"改为"配送未开始"
- 实现页面tab参数解析,支持通过URL参数指定默认标签页
- 调整按钮文字顺序,将"订单修改/取消"改为"修改订单/取消订单"
- 更新服务器API地址配置,切换到新的生产环境域名
2026-03-10 11:22:34 +08:00
49c801c751 refactor(order): 重构订单状态处理逻辑并优化送水订单功能
- 将订单状态相关工具函数提取到独立的 utils 文件中
- 统一订单状态文本和颜色显示逻辑
- 移除重复的状态判断函数
- 优化送水订单列表的数据过滤逻辑
- 添加订单编辑模式支持
- 实现订单修改和取消功能
- 修复订单状态判断中的数值转换问题
- 优化送水订单的时间选择组件
- 添加订单数据加载和验证逻辑
- 重构订单详情页的条件渲染逻辑
2026-03-09 12:48:02 +08:00
3248315f6e refactor(shop): 移除水票套票商品配送时间选择功能
- 删除了配送时间相关的状态管理和日期选择器组件
- 移除了配送时间验证和格式化逻辑
- 更新了订单提交流程,不再传递配送时间参数
- 修改支付回调处理,支持自定义成功行为和跳转逻辑
- 简化了水票商品的购买流程,移除配送时间相关校验
2026-03-09 12:17:29 +08:00
58d3e884ab fix(order): 移除多余的数字标记
- 修复了订单组件中Space标签后的多余数字'1'
- 清理了不必要的代码标记,提升代码整洁性
2026-03-08 13:19:39 +08:00
b9eb64894f fix(order): 修复订单页面布局问题
- 移除了多余的空格和换行符
- 确保了 Space 组件的正确闭合
- 优化了订单页面的整体布局结构
2026-03-08 13:19:07 +08:00
41 changed files with 2907 additions and 721 deletions

View File

@@ -0,0 +1,72 @@
{
"version": 2,
"sessions": {
"bb17ec0263344400afb0ecfafefc1ab1": [
{
"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": 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": 1775972794982,
"industryId": "all"
}
]
},
"lastUpdated": 1775999935033
}

View File

@@ -0,0 +1,29 @@
# 2026-03-31 工作日志
## 新增 dealer/index.tsx 配送员解冻资金功能
`/rider/index.tsx``handleFreezeMoneyClick` 功能复制到 `/dealer/index.tsx`
- 导入 `useUser` hook 和 `updateShopDealerUser` API
- 添加 `isRider` 判断(只有 `hasRole('rider')` 才可操作)
- "待使用"卡片点击事件(仅配送员+有冻结金额时可用)
- 确认后冻结金额转入可提现
## 修改 rider/index.tsx (React Taro 项目)
修改了 `/Users/gxwebsoft/VUE/template-10584/src/rider/index.tsx`
### 修改内容:
1. **统一显示格式** - 把"桶数"改成"待使用"
2. **添加配送员权限控制** - 只有 `hasRole('rider')` 的用户才能点击操作
3. **点击解冻功能**
- 配送员点击"待使用"金额时显示蓝色高亮提示"(点击转入)"
- 弹出确认框:`确定要将 ¥xx.xx 待使用金额转入可提现吗?`
- 确认后调用 `updateShopDealerUser` API
-`freezeMoney` 清零,加到 `money`
- 成功后提示"更新成功"并刷新数据
### 关键代码:
- 使用 `useUser` hook 获取 `hasRole` 函数
- `isRider` 判断是否为配送员
- `handleFreezeMoneyClick` 处理点击解冻逻辑
- 使用 `Taro.showModal` / `Taro.showToast` 提示用户

View File

@@ -0,0 +1,107 @@
# 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` 字段
- 订单创建接口接收并存储这两个字段
- 骑手端/后台展示配送方式和楼层信息
## 配送方式功能迁移orderConfirm → user/ticket/use
### 原因
配送方式选择功能从购买下单页orderConfirm迁移到水票核销/立即送水页面user/ticket/use
### 修改的文件
1. `src/shop/orderConfirm/index.tsx` - 回滚移除配送方式状态变量、UI、校验、楼层选择弹窗、配送费计算
2. `src/shop/orderConfirm/index.scss` - 回滚:移除配送方式/楼层选择相关样式
3. `src/api/glt/gltTicketOrder/model/index.ts` - GltTicketOrder 新增 deliveryMethod、deliveryFloor、deliveryFee 字段
4. `src/user/ticket/use.tsx` - 新增配送方式选择UI、配送费计算、楼层选择弹窗、提交校验、编辑模式回显
5. `src/user/ticket/use.scss` - 新增:配送方式/楼层选择样式
### 关键差异
- orderConfirm 页面是付费购买,配送费加到实付金额中
- use 页面是水票核销(不付费),配送费以"到付"形式展示在底部栏
- use 页面同时支持新建和编辑模式,编辑时回显配送信息
## 微信订阅消息配置(补充)
### 需要做的配置
#### 后端配置
1. `GltSubscribeMessageServiceImpl.java` - 订阅消息发送服务
- 需要配置 `SUBSCRIBE_TEMPLATE_ID` 为实际模板ID
2. `GltTicketOrderController.java` - 订单创建时通知配送员
- 注入 `GltSubscribeMessageService``UserMapper`
- 添加 `notifyRidersOfNewOrder` 方法
#### 前端配置
1. `src/rider/index.tsx`
- `handleRequestSubscribeMessage` 函数需要配置实际的模板ID
### 微信后台需要申请的模板
模板名称:订单配送通知
关键词:订单状态、订单编号、配送地址、商品数量、通知时间

View File

@@ -0,0 +1,25 @@
# 2026-04-12 工作日志
## 配送方式功能完善:全链路入库+展示
### 问题
配送方式deliveryMethod、楼层deliveryFloor、配送费deliveryFee在小程序端用户下单页面已可选择并提交但后端数据库表没有对应字段数据实际没有保存。骑手端和后台管理端也没有展示这些信息。
### 修改的文件
#### 后端(/Users/gxwebsoft/JAVA/java-10584
1. `src/main/java/com/gxwebsoft/glt/entity/GltTicketOrder.java` - 新增 deliveryMethod(String)、deliveryFloor(Integer)、deliveryFee(BigDecimal) 三个数据库字段
2. `sql/glt_ticket_order_delivery_fields.sql` - 新增 ALTER TABLE SQL给 glt_ticket_order 表添加三个字段
#### 小程序端(/Users/gxwebsoft/VUE/template-10584
3. `src/rider/orders/index.tsx` - 骑手送水订单页面:新增配送方式中文映射函数,订单卡片中展示配送方式、楼层、配送费
#### 后台管理端(/Users/gxwebsoft/VUE/mp-10584
4. `src/api/glt/gltTicketOrder/model/index.ts` - GltTicketOrder 接口新增 deliveryMethod、deliveryFloor、deliveryFee 字段
5. `src/views/glt/gltTicketOrder/index.vue` - 列表页"配送信息"列中展示配送方式、楼层、配送费
6. `src/views/glt/gltTicketOrder/components/gltTicketOrderEdit.vue` - 编辑弹窗中新增配送方式只读展示
### 部署注意事项
- **必须先执行 SQL**`java-10584/sql/glt_ticket_order_delivery_fields.sql`
- MyBatis-Plus 使用驼峰自动映射,`delivery_method``deliveryMethod`,无需修改 Mapper XML
- 后端 save/updateById 自动包含新字段,无需修改 Controller/Service

View File

@@ -0,0 +1,16 @@
# 2026-04-25 工作日志
## 微信支付"下单账号与支付账号不一致"问题修复
**问题**:用户支付时微信提示「下单账号与支付账号不一致,请核实后再支付」
**根因**:下单时后端使用的 openid 与当前实际支付的微信用户 openid 不匹配。前端在创建订单前没有刷新 openid依赖登录/注册时的旧绑定。
**修改文件**
1. `src/utils/payment.ts` — 新增 `ensureOpenIdBeforePay()` 方法,在 `pay()` 方法中、微信支付创建订单前强制调用 `Taro.login()` + `getWxOpenId()` 刷新绑定当前用户的 openid
2. `src/hooks/useUser.ts` — 在 `autoLoginByOpenId()` 登录成功后补充 openid 绑定逻辑(之前只调了 loginByOpenId 没有调 getWxOpenId
**方案特点**
- 非阻塞设计openid 刷新失败不阻止支付(网络波动时兼容)
- 仅微信支付触发:余额支付/支付宝不受影响
- 兼容已有 openid 的场景:即使已有 openid 也会刷新(防止切号)

View File

View File

@@ -10,5 +10,6 @@ export const BaseUrl = API_BASE_URL;
export const Version = 'v3.0.8';
// 版权信息
export const Copyright = '桂乐淘·购享无界 乐惠万家';
// export const Copyright = '测试环境 v3.2.6';
// java -jar CertificateDownloader.jar -k 0kF5OlPr482EZwtn9zGufUcqa7ovgxRL -m 1723321338 -f ./apiclient_key.pem -s 2B933F7C35014A1C363642623E4A62364B34C4EB -o ./

View File

@@ -1,44 +1,43 @@
// 环境变量配置
// ============ 环境切换开关(修改这里即可切换环境)============
// 可选值: 'development' | 'test' | 'production'
const CURRENT_ENV = 'production'
// ===========================================================
export const ENV_CONFIG = {
// 开发环境
development: {
// API_BASE_URL: 'http://127.0.0.1:9200/api',
API_BASE_URL: 'https://glt-api.websoft.top/api',
SERVER_API_URL: 'https://glt-server.websoft.top/api',
APP_NAME: '开发环境',
DEBUG: 'true',
},
// 测试环境
test: {
API_BASE_URL: 'https://glt-api.websoft.top/api',
SERVER_API_URL: 'https://glt-server.websoft.top/api',
APP_NAME: '测试环境',
DEBUG: 'true',
},
// 生产环境
production: {
API_BASE_URL: 'https://glt-api.websoft.top/api',
SERVER_API_URL: 'https://glt-server.websoft.top/api',
APP_NAME: '桂乐淘',
DEBUG: 'false',
},
// 测试环境
test: {
// API_BASE_URL: 'http://127.0.0.1:9200/api',
API_BASE_URL: 'https://glt-api.websoft.top/api',
APP_NAME: '测试环境',
DEBUG: 'true',
}
}
// 获取当前环境配置
export function getEnvConfig() {
const env = process.env.NODE_ENV || 'development'
if (env === 'production') {
return ENV_CONFIG.production
} else { // @ts-ignore
if (env === 'test') {
return ENV_CONFIG.test
} else {
return ENV_CONFIG.development
}
}
return ENV_CONFIG[CURRENT_ENV]
}
// 导出环境变量
export const {
API_BASE_URL,
SERVER_API_URL,
APP_NAME,
DEBUG
} = getEnvConfig()

View File

@@ -2,7 +2,7 @@ import { defineConfig, type UserConfigExport } from '@tarojs/cli'
import TsconfigPathsPlugin from 'tsconfig-paths-webpack-plugin'
import devConfig from './dev'
import prodConfig from './prod'
import { getEnvConfig } from './env'
import { getEnvConfig } from './env.js'
// import vitePluginImp from 'vite-plugin-imp'
// https://taro-docs.jd.com/docs/next/config#defineconfig-辅助函数

View File

@@ -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 };
}
/**
* 查询送水订单列表
*/

View File

@@ -76,6 +76,12 @@ export interface GltTicketOrder {
createTime?: string;
// 修改时间
updateTime?: string;
// 配送方式elevator(电梯) / stairs(步梯) / groundFloor(一楼商铺/其他)
deliveryMethod?: string;
// 楼层(步梯+送上楼时有值从2开始
deliveryFloor?: number;
// 配送费(步梯+送上楼时计算:数量 × (楼层-1)
deliveryFee?: number;
}
/**

View File

@@ -16,6 +16,8 @@ export interface GltTicketTemplate {
unitName?: string;
// 最小购买数量
minBuyQty?: number;
// 购买步长5 的倍数)
step?: number;
// 起始发送数量
startSendQty?: number;
// 买赠买1送4 => gift_multiplier=4

View File

@@ -8,9 +8,7 @@ import type { GltUserTicketRelease, GltUserTicketReleaseParam } from './model';
export async function pageGltUserTicketRelease(params: GltUserTicketReleaseParam) {
const res = await request.get<ApiResult<PageResult<GltUserTicketRelease>>>(
'/glt/glt-user-ticket-release/page',
{
params
}
);
if (res.code === 0) {
return res.data;
@@ -24,9 +22,7 @@ export async function pageGltUserTicketRelease(params: GltUserTicketReleaseParam
export async function listGltUserTicketRelease(params?: GltUserTicketReleaseParam) {
const res = await request.get<ApiResult<GltUserTicketRelease[]>>(
'/glt/glt-user-ticket-release',
{
params
}
);
if (res.code === 0 && res.data) {
return res.data;

View File

@@ -38,6 +38,8 @@ export interface ShopDealerUser {
createTime?: string;
// 修改时间
updateTime?: string;
// 分销商等级0-普通用户 1-超级管理员 2-合伙人(总店) 3-合伙人(分店)
dealerLevel?: number;
}
/**

View File

@@ -81,6 +81,8 @@ export interface ShopGoods {
isNew?: number;
// 库存
stock?: number;
// 步长
step?: number;
// 商品重量
goodsWeight?: number;
// 消费赚取积分
@@ -126,6 +128,10 @@ export interface ShopGoods {
expiredDay?: number;
// 可购买数量
canBuyNumber?: number;
// 活动方式0全平台 1新用户专享
activityType?: number;
// 配送方式0送上门 1限自提
deliveryMode?: number;
}
export interface BathSet {

View File

@@ -201,6 +201,10 @@ export interface OrderCreateRequest {
selfTakeMerchantId?: number;
// 订单标题(可选,后端会自动生成)
title?: string;
// 配送方式elevator(电梯) / stairs(步梯) / groundFloor(一楼商铺/其他)
deliveryMethod?: string;
// 楼层(步梯+送上楼时有值从2开始
deliveryFloor?: number;
}
/**

View File

@@ -56,6 +56,7 @@ export default {
"points/points",
"ticket/index",
"ticket/use",
"ticket/release/index",
"ticket/orders/index",
// "gift/index",
// "gift/redeem",

View 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 -translate-y-1 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

View File

@@ -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 {
@@ -10,8 +10,11 @@ import {
People
} from '@nutui/icons-react-taro'
import {useDealerUser} from '@/hooks/useDealerUser'
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 = () => {
@@ -21,6 +24,12 @@ const DealerIndex: React.FC = () => {
refresh,
} = useDealerUser()
// 待使用明细弹窗显示状态
const [freezeMoneyModalVisible, setFreezeMoneyModalVisible] = useState(false)
// 获取用户角色信息
const { hasRole } = useUser()
// 使用主题样式
const themeStyles = useThemeStyles()
@@ -55,6 +64,75 @@ const DealerIndex: React.FC = () => {
console.log(getGradientBackground(),'getGradientBackground()')
// 判断是否是配送员
const isRider = hasRole('rider')
// 点击待使用金额 - 显示待使用明细弹窗
const handleFreezeMoneyClick = () => {
console.log('点击待使用金额', dealerUser?.freezeMoney)
const freezeMoney = Number(dealerUser?.freezeMoney ?? 0)
// 只要有金额就显示弹窗包括0元也显示让用户知道当前状态
setFreezeMoneyModalVisible(true)
}
// 关闭待使用明细弹窗
const handleCloseFreezeMoneyModal = () => {
setFreezeMoneyModalVisible(false)
}
// 配送员专用:将冻结金额转入可提现
const handleTransferFreezeMoney = async () => {
// 检查是否是配送员
if (!isRider) {
return
}
// 检查冻结金额是否为 0
const freezeMoney = Number(dealerUser?.freezeMoney ?? 0)
if (freezeMoney <= 0) {
return
}
// 关闭弹窗
setFreezeMoneyModalVisible(false)
// 弹出确认框
Taro.showModal({
title: '确认操作',
content: `确定要将 ¥${freezeMoney.toFixed(2)} 转入钱包吗?`,
confirmText: '确定',
cancelText: '取消',
success: async (res) => {
if (res.confirm) {
try {
Taro.showLoading({ title: '处理中...' })
const currentMoney = Number(dealerUser?.money ?? 0)
await updateShopDealerUser({
id: dealerUser?.id,
money: (currentMoney + freezeMoney).toFixed(2),
freezeMoney: '0.00'
})
Taro.hideLoading()
Taro.showToast({
title: '更新成功',
icon: 'success',
duration: 1500
})
// 刷新数据
refresh()
} catch (error) {
Taro.hideLoading()
Taro.showToast({
title: '更新失败',
icon: 'none',
duration: 1500
})
}
}
}
})
}
if (error) {
return (
<View className="p-4">
@@ -140,13 +218,20 @@ const DealerIndex: React.FC = () => {
</Text>
<Text className="text-xs" style={{ color: 'rgba(255, 255, 255, 0.9)' }}></Text>
</View>
<View className="text-center p-3 rounded-lg flex flex-col" style={{
background: businessGradients.money.frozen
}}>
<View
className="text-center p-3 rounded-lg flex flex-col"
style={{
background: businessGradients.money.frozen,
opacity: Number(dealerUser.freezeMoney ?? 0) > 0 ? 1 : 0.8
}}
onClick={handleFreezeMoneyClick}
>
<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)' }}>使</Text>
<Text className="text-xs" style={{ color: 'rgba(255, 255, 255, 0.9)' }}>
使
</Text>
</View>
<View className="text-center p-3 rounded-lg flex flex-col" style={{
background: businessGradients.money.total
@@ -286,6 +371,13 @@ const DealerIndex: React.FC = () => {
</View>
</View>
{/* 待使用明细弹窗 */}
<FreezeMoneyModal
visible={freezeMoneyModalVisible}
amount={dealerUser?.freezeMoney || '0'}
onClose={handleCloseFreezeMoneyModal}
/>
{/* 底部安全区域 */}
<View className="h-20"></View>
</View>

View File

@@ -308,6 +308,13 @@ const DealerWithdraw: React.FC = () => {
// return
}
if (amount > 200) {
Taro.showToast({
title: '单笔最多提现200元',
icon: 'none'
})
return
}
if (amount > available) {
Taro.showToast({
title: '提现金额超过可用余额',
@@ -413,7 +420,7 @@ const DealerWithdraw: React.FC = () => {
}
}
const quickAmounts = ['100', '300', '500', '1000']
const quickAmounts = ['50', '100', '200']
const setQuickAmount = (amount: string) => {
formRef.current?.setFieldsValue({amount})
@@ -491,7 +498,7 @@ const DealerWithdraw: React.FC = () => {
<Form.Item name="amount" label="提现金额">
<Input
placeholder="请输入提现金额"
type="number"
type="digit"
/>
</Form.Item>
@@ -525,6 +532,7 @@ const DealerWithdraw: React.FC = () => {
1.
2.
3.
4. 200
</Text>
</View>
</CellGroup>

View 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

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import Taro from '@tarojs/taro';
import { User } from '@/api/system/user/model';
import { getUserInfo, updateUserInfo, loginByOpenId } from '@/api/layout';
import { getUserInfo, updateUserInfo, loginByOpenId, getWxOpenId } from '@/api/layout';
import { TenantId } from '@/config/app';
import { handleInviteRelation } from '@/utils/invite';
@@ -27,6 +27,24 @@ export const useUser = () => {
setUser(data.user);
setIsLoggedIn(true);
// 自动登录成功后,补齐 openidJSAPI 微信支付必需)
// 防止后续支付时报"下单账号与支付账号不一致"
if (!data.user?.openid) {
try {
const freshCode = await new Promise<string | undefined>((resolve, reject) => {
Taro.login({
success: (r) => resolve(r.code as string),
fail: () => resolve(undefined),
});
});
if (freshCode) {
await getWxOpenId({ code: freshCode });
}
} catch (_e) {
console.warn('自动登录后绑定 openid 失败');
}
}
// 处理邀请关系
if (data.user?.userId) {
try {

View File

@@ -217,8 +217,8 @@ function Home() {
title: '立即送水',
icon: <Cart size={30} />,
onClick: () => {
if (!ensureLoggedIn('/user/ticket/use?goodsId=10074')) return
Taro.navigateTo({ url: '/user/ticket/use?goodsId=10074' })
if (!ensureLoggedIn('/user/ticket/use')) return
Taro.navigateTo({ url: '/user/ticket/use' })
},
},
{
@@ -226,8 +226,9 @@ function Home() {
title: '送水订单',
icon: <Agenda size={30} />,
onClick: () => {
if (!ensureLoggedIn('/user/ticket/index')) return
Taro.navigateTo({ url: '/user/ticket/index' })
const url = '/user/ticket/index?tab=order'
if (!ensureLoggedIn(url)) return
Taro.navigateTo({ url })
},
},
{

View File

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

View File

@@ -1,11 +1,12 @@
import {loginBySms} from "@/api/passport/login";
import {useState} from "react";
import Taro from '@tarojs/taro'
import {View,Text} from '@tarojs/components'
import {Popup} from '@nutui/nutui-react-taro'
import {UserParam} from "@/api/system/user/model";
import {Button} from '@nutui/nutui-react-taro'
import {Button, Image} from '@nutui/nutui-react-taro'
import {Form, Input} from '@nutui/nutui-react-taro'
import {Copyright, Version} from "@/config/app";
import {Copyright} from "@/config/app";
const UserFooter = () => {
const [openLoginByPhone, setOpenLoginByPhone] = useState(false)
const [clickNum, setClickNum] = useState<number>(0)
@@ -46,11 +47,14 @@ const UserFooter = () => {
return (
<>
<div className={'text-center py-4 w-full text-gray-300'} onClick={onLoginByPhone}>
{/*<div className={'text-xs text-gray-400 py-1'}>当前版本:{Version}</div>*/}
{/*<div className={'text-xs text-gray-400 py-1'}>Copyright © { new Date().getFullYear() } {Copyright}</div>*/}
<div className={'text-xs text-gray-400 py-1'}>{Copyright}</div>
</div>
<View className={'text-center py-4 w-full text-gray-300'} onClick={onLoginByPhone}>
{/*<View className={'text-xs text-gray-400 py-1'}>当前版本:{Version}</View>*/}
{/*<View className={'text-xs text-gray-400 py-1'}>Copyright © { new Date().getFullYear() } {Copyright}</View>*/}
<View className={'text-xs text-gray-400 py-1 flex justify-center items-center gap-2'}>
<Image src={'https://oss.wsdns.cn/20260412/7d03ec2a05964c3e926c4eac12ee5835.png'} mode={'aspectFit'} width={20} height={20} />
<Text>{Copyright}</Text>
</View>
</View>
<Popup
style={{width: '350px', padding: '10px'}}
@@ -66,7 +70,7 @@ const UserFooter = () => {
labelPosition="left"
onFinish={(values) => submitByPhone(values)}
footer={
<div
<View
style={{
display: 'flex',
justifyContent: 'center',
@@ -76,7 +80,7 @@ const UserFooter = () => {
<Button nativeType="submit" block style={{backgroundColor: '#000000',color: '#ffffff'}}>
</Button>
</div>
</View>
}
>
<Form.Item

View File

@@ -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,15 @@ 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'
const DealerIndex: React.FC = () => {
@@ -22,6 +26,18 @@ const DealerIndex: React.FC = () => {
refresh,
} = useDealerUser()
// 获取用户角色信息
const { hasRole } = useUser()
// 配送员通知功能
const { pendingCount, startPolling, stopPolling, soundEnabled, toggleSound } = useRiderNotification()
// 页面生命周期管理
useEffect(() => {
startPolling()
return () => stopPolling()
}, [startPolling, stopPolling])
// 使用主题样式
const themeStyles = useThemeStyles()
@@ -56,6 +72,109 @@ const DealerIndex: React.FC = () => {
console.log(getGradientBackground(),'getGradientBackground()')
// 判断是否是配送员
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,
entityIds: [], // 支付宝模板ID微信端不需要仅满足类型要求
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'
})
}
} as Taro.requestSubscribeMessage.Option)
}
// 点击待使用金额 - 配送员专用:将冻结金额转入可提现
const handleFreezeMoneyClick = async () => {
// 检查是否是配送员
if (!isRider) {
return
}
// 检查冻结金额是否为 0
const freezeMoney = Number(dealerUser?.freezeMoney ?? 0)
if (freezeMoney <= 0) {
return
}
// 弹出确认框
Taro.showModal({
title: '确认操作',
content: `确定要将 ¥${freezeMoney.toFixed(2)} 转入钱包吗?`,
confirmText: '确定',
cancelText: '取消',
success: async (res) => {
if (res.confirm) {
try {
Taro.showLoading({ title: '处理中...' })
const currentMoney = Number(dealerUser?.money ?? 0)
await updateShopDealerUser({
id: dealerUser?.id,
money: (currentMoney + freezeMoney).toFixed(2),
freezeMoney: '0.00'
})
Taro.hideLoading()
Taro.showToast({
title: '更新成功',
icon: 'success',
duration: 1500
})
// 刷新数据
refresh()
} catch (error) {
Taro.hideLoading()
Taro.showToast({
title: '更新失败',
icon: 'none',
duration: 1500
})
}
}
}
})
}
if (error) {
return (
<View className="p-4">
@@ -74,7 +193,14 @@ const DealerIndex: React.FC = () => {
<View>
{/*头部信息*/}
{dealerUser && (
<View className="px-4 py-6 relative overflow-hidden" style={themeStyles.primaryBackground}>
<View
className="px-4 py-6 relative overflow-hidden"
style={{
...themeStyles.primaryBackground,
background: businessGradients.order.processing,
color: '#ffffff'
}}
>
{/* 装饰性背景元素 - 小程序兼容版本 */}
<View className="absolute w-32 h-32 rounded-full" style={{
backgroundColor: 'rgba(255, 255, 255, 0.1)',
@@ -141,13 +267,20 @@ const DealerIndex: React.FC = () => {
</Text>
<Text className="text-xs" style={{ color: 'rgba(255, 255, 255, 0.9)' }}></Text>
</View>
<View className="text-center p-3 rounded-lg flex flex-col" style={{
background: businessGradients.money.frozen
}}>
<View
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
}}
onClick={isRider && 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)' }}></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={{
background: businessGradients.money.total
@@ -212,13 +345,20 @@ 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>
<Grid.Item text={'工资明细'} onClick={() => navigateToPage('/rider/withdraw/index')}>
<Grid.Item text={'收入明细'} onClick={() => navigateToPage('/rider/withdraw/index')}>
<View className="text-center">
<View className="w-12 h-12 bg-green-50 rounded-xl flex items-center justify-center mx-auto mb-2">
<Purse color="#10b981" size="20"/>
@@ -251,46 +391,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>

View File

@@ -73,6 +73,14 @@ export default function RiderOrders() {
return '待派单'
}
// 配送方式中文映射
const getDeliveryMethodText = (method?: string) => {
if (method === 'elevator') return '电梯'
if (method === 'stairs') return '步梯'
if (method === 'groundFloor') return '一楼商铺/其他'
return ''
}
const getOrderStatusColor = (order: GltTicketOrder) => {
const text = getOrderStatusText(order)
if (text === '已完成') return 'text-green-600'
@@ -383,6 +391,10 @@ export default function RiderOrders() {
const pickupName = o.warehouseName || o.storeName
const pickupAddr = o.warehouseAddress || o.storeAddress
// 配送方式信息
const deliveryMethodText = getDeliveryMethodText(o.deliveryMethod)
const hasDeliveryInfo = !!deliveryMethodText
return (
<Cell key={String(o.id)} style={{ padding: '16px' }}>
<View className="w-full">
@@ -418,6 +430,24 @@ export default function RiderOrders() {
<Text>{o.price || '-'}</Text>
</View>
{hasDeliveryInfo && (
<View className="text-sm text-gray-700 mt-1">
<Text className="text-gray-500"></Text>
<Text className={o.deliveryMethod === 'stairs' ? 'text-orange-500' : ''}>
{deliveryMethodText}
</Text>
{o.deliveryMethod === 'stairs' && o.deliveryFloor && o.deliveryFloor > 1 && (
<Text className="ml-1 text-orange-500">{o.deliveryFloor}</Text>
)}
{o.deliveryMethod === 'stairs' && !o.deliveryFloor && (
<Text className="ml-1 text-gray-400"></Text>
)}
{!!o.deliveryFee && o.deliveryFee > 0 && (
<Text className="ml-3 text-red-500"> {o.deliveryFee}</Text>
)}
</View>
)}
<View className="text-sm text-gray-700 mt-1">
<Text className="text-gray-500"></Text>
<Text>{o.sendTime ? dayjs(o.sendTime).format('YYYY-MM-DD HH:mm') : '-'}</Text>

View File

@@ -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-1 rounded-full"}></Text>
)}
{goods.deliveryMode === 1 && (
<Text className={"text-xs bg-orange-500 text-white px-2 py-1 rounded-full"}></Text>
)}
</View>
)}
<View className={"flex justify-between text-xs py-1"}>
<span className={"text-orange-500"}>
{goods.comments}

View File

@@ -39,6 +39,7 @@
}
}
}
.address-bottom-line{
width: 100%;
border-radius: 12rpx 12rpx 0 0;

View File

@@ -9,10 +9,9 @@ import {
ActionSheet,
Popup,
InputNumber,
DatePicker,
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";
@@ -39,7 +38,6 @@ import {
filterUsableCoupons,
filterUnusableCoupons
} from "@/utils/couponUtils";
import dayjs from 'dayjs'
import type {ShopStore} from "@/api/shop/shopStore/model";
import {getShopStore, listShopStore} from "@/api/shop/shopStore";
import {getSelectedStoreFromStorage, saveSelectedStoreToStorage} from "@/utils/storeSelection";
@@ -57,18 +55,6 @@ const OrderConfirm = () => {
const [loading, setLoading] = useState<boolean>(true)
const [error, setError] = useState<string>('')
const [payLoading, setPayLoading] = useState<boolean>(false)
// 配送时间(仅水票套票商品需要)
// 当日截单时间:超过该时间下单,最早配送日顺延到次日(避免 21:00 下单仍显示“当天配送”)
const DELIVERY_CUTOFF_HOUR = 21
const getMinSendDate = () => {
const now = dayjs()
const cutoff = now.hour(DELIVERY_CUTOFF_HOUR).minute(0).second(0).millisecond(0)
const startOfToday = now.startOf('day')
// >= 截单时间则最早只能选次日
return now.isSame(cutoff) || now.isAfter(cutoff) ? startOfToday.add(1, 'day') : startOfToday
}
const [sendTime, setSendTime] = useState<Date>(() => getMinSendDate().toDate())
const [sendTimePickerVisible, setSendTimePickerVisible] = useState(false)
// 水票套票活动(若存在则按规则限制最小购买量等)
const [ticketTemplate, setTicketTemplate] = useState<GltTicketTemplate | null>(null)
@@ -89,24 +75,69 @@ 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())
const router = Taro.getCurrentInstance().router;
const goodsId = router?.params?.goodsId;
const params = router?.params || ({} as Record<string, any>)
const goodsIdParam = params?.goodsId
const orderDataRaw = params?.orderData
type OrderDataParam = {
goodsId?: number | string
skuId?: number | string
quantity?: number | string
price?: number | string
specInfo?: string
}
const orderDataParam: OrderDataParam | null = useMemo(() => {
if (!orderDataRaw) return null
const rawText = String(orderDataRaw)
try {
return JSON.parse(decodeURIComponent(rawText)) as OrderDataParam
} catch (_e1) {
try {
return JSON.parse(rawText) as OrderDataParam
} catch (_e2) {
console.error('orderData 参数解析失败:', orderDataRaw)
return null
}
}
}, [orderDataRaw])
const resolvedGoodsId = (() => {
const id1 = Number(goodsIdParam)
if (Number.isFinite(id1) && id1 > 0) return id1
const id2 = Number(orderDataParam?.goodsId)
if (Number.isFinite(id2) && id2 > 0) return id2
return undefined
})()
const resolvedSkuId = (() => {
const n = Number(orderDataParam?.skuId)
return Number.isFinite(n) && n > 0 ? n : undefined
})()
const quantityFromParam = (() => {
const n = Number(orderDataParam?.quantity)
return Number.isFinite(n) && n > 0 ? Math.floor(n) : undefined
})()
// 页面级兜底:未登录直接进入下单页时,引导去注册/登录并回跳
useEffect(() => {
if (!goodsId) {
// 也可能是 orderData 模式;这里只做最小兜底
if (!ensureLoggedIn('/shop/orderConfirm/index')) return
return
}
if (!ensureLoggedIn(`/shop/orderConfirm/index?goodsId=${goodsId}`)) return
}, [goodsId])
// 兼容 goodsId / orderData 两种进入方式goodsDetail 有规格时会走 orderData
const backUrl =
orderDataRaw
? `/shop/orderConfirm/index?orderData=${orderDataRaw}`
: resolvedGoodsId
? `/shop/orderConfirm/index?goodsId=${resolvedGoodsId}`
: '/shop/orderConfirm/index'
if (!ensureLoggedIn(backUrl)) return
}, [resolvedGoodsId, orderDataRaw])
const isTicketTemplateActive =
!!ticketTemplate &&
@@ -122,10 +153,6 @@ const OrderConfirm = () => {
})()
const minBuyQty = isTicketTemplateActive ? ticketMinBuyQty : 1
const sendTimeText = useMemo(() => {
return dayjs(sendTime).format('YYYY-MM-DD')
}, [sendTime])
const getGiftTicketQty = (buyQty: number) => {
if (!isTicketTemplateActive) return 0
const multiplier = Number(ticketTemplate?.giftMultiplier || 0)
@@ -160,7 +187,9 @@ const OrderConfirm = () => {
// 计算商品总价
const getGoodsTotal = () => {
if (!goods) return 0
const price = parseFloat(goods.price || '0')
const rawPrice = String(orderDataParam?.price ?? goods.price ?? '0')
const priceNum = parseFloat(rawPrice)
const price = Number.isFinite(priceNum) ? priceNum : 0
// const total = price * quantity
// 🔍 详细日志,用于排查数值精度问题
@@ -201,12 +230,21 @@ const OrderConfirm = () => {
const handleQuantityChange = (value: string | number) => {
const fallback = isTicketTemplateActive ? minBuyQty : 1
const newQuantity = typeof value === 'string' ? parseInt(value, 10) || fallback : value
const finalQuantity = Math.max(fallback, Math.min(newQuantity, goods?.stock || 999))
const step = goods?.step || 1
const stockMax = goods?.stock ?? 999
const maxMultiple = step > 1 ? Math.floor(stockMax / step) * step : stockMax
const maxAllowed = maxMultiple > 0 ? maxMultiple : stockMax
const effectiveMin = Math.min(fallback, maxAllowed)
const clamped = Math.max(effectiveMin, Math.min(Number(newQuantity) || fallback, maxAllowed))
const snapped = step > 1 ? Math.ceil(clamped / step) * step : clamped
const finalQuantity = Math.max(effectiveMin, Math.min(snapped, maxAllowed))
setQuantity(finalQuantity)
// 数量变化时,重新排序优惠券并检查当前选中的优惠券是否还可用
if (availableCoupons.length > 0) {
const newTotal = parseFloat(goods?.price || '0') * finalQuantity
const priceNum = parseFloat(String(orderDataParam?.price ?? goods?.price ?? '0'))
const unitPrice = Number.isFinite(priceNum) ? priceNum : 0
const newTotal = unitPrice * finalQuantity
const sortedCoupons = sortCoupons(availableCoupons, newTotal)
const usableCoupons = filterUsableCoupons(sortedCoupons, newTotal)
setAvailableCoupons(sortedCoupons)
@@ -451,22 +489,7 @@ const OrderConfirm = () => {
return;
}
// 水票套票商品:保存配送时间到 ShopOrder.sendStartTime
if (hasTicketTemplate && !sendTime) {
Taro.showToast({ title: '请选择配送时间', icon: 'none' })
return
}
if (hasTicketTemplate) {
const min = getMinSendDate()
if (dayjs(sendTime).isBefore(min, 'day')) {
setSendTime(min.toDate())
Taro.showToast({
title: `已过当日${DELIVERY_CUTOFF_HOUR}点截单,最早配送:${min.format('YYYY-MM-DD')}`,
icon: 'none'
})
return
}
}
// 购买水票(囤券预付费)与水票核销(下单履约)为两个独立动作:下单页不再选择配送时间。
// 水票套票活动:最小购买量校验
if (isTicketTemplateActive && quantity < minBuyQty) {
@@ -528,19 +551,43 @@ const OrderConfirm = () => {
address.id,
{
comments: goods.name,
deliveryType: 0,
deliveryType: goods.deliveryMode === 1 ? 1 : 0,
buyerRemarks: orderRemark,
sendStartTime: hasTicketTemplate
? dayjs(sendTime).startOf('day').format('YYYY-MM-DD HH:mm:ss')
: undefined,
couponId: parseInt(String(bestCoupon.id), 10)
couponId: parseInt(String(bestCoupon.id), 10),
skuId: resolvedSkuId,
specInfo: orderDataParam?.specInfo
}
);
console.log('🎯 使用推荐优惠券的订单数据:', updatedOrderData);
// 执行支付
await PaymentHandler.pay(updatedOrderData, currentPaymentType);
await PaymentHandler.pay(updatedOrderData, currentPaymentType, hasTicketTemplate ? {
onSuccess: async () => {
const id = goods.goodsId
const ticketIndexUrl = `/user/ticket/index?fromPayAt=${Date.now()}`
try {
const res = await Taro.showModal({
title: '提示',
content: '是否立刻送水?',
confirmText: '立刻送水',
cancelText: '稍后'
})
if (res?.confirm) {
if (id) {
await Taro.redirectTo({ url: `/user/ticket/use?goodsId=${id}` })
} else {
await Taro.redirectTo({ url: ticketIndexUrl })
}
} else {
await Taro.redirectTo({ url: ticketIndexUrl })
}
} catch (_e) {
await Taro.redirectTo({ url: ticketIndexUrl })
}
return false
}
} : undefined);
return; // 提前返回,避免重复执行支付
} else {
// 用户选择不使用优惠券,继续支付
@@ -556,13 +603,12 @@ const OrderConfirm = () => {
address.id,
{
comments: '桂乐淘',
deliveryType: 0,
deliveryType: goods.deliveryMode === 1 ? 1 : 0,
buyerRemarks: orderRemark,
sendStartTime: hasTicketTemplate
? dayjs(sendTime).startOf('day').format('YYYY-MM-DD HH:mm:ss')
: undefined,
// 🔧 确保 couponId 是正确的数字类型,且不传递 undefined
couponId: selectedCoupon ? parseInt(String(selectedCoupon.id), 10) : undefined
couponId: selectedCoupon ? parseInt(String(selectedCoupon.id), 10) : undefined,
skuId: resolvedSkuId,
specInfo: orderDataParam?.specInfo
}
);
@@ -595,7 +641,32 @@ const OrderConfirm = () => {
});
// 执行支付 - 移除这里的成功提示让PaymentHandler统一处理
await PaymentHandler.pay(orderData, paymentType);
await PaymentHandler.pay(orderData, paymentType, hasTicketTemplate ? {
onSuccess: async () => {
const id = goods.goodsId
const ticketIndexUrl = `/user/ticket/index?fromPayAt=${Date.now()}`
try {
const res = await Taro.showModal({
title: '提示',
content: '是否立刻送水?',
confirmText: '立刻送水',
cancelText: '稍后'
})
if (res?.confirm) {
if (id) {
await Taro.redirectTo({ url: `/user/ticket/use?goodsId=${id}` })
} else {
await Taro.redirectTo({ url: ticketIndexUrl })
}
} else {
await Taro.redirectTo({ url: ticketIndexUrl })
}
} catch (_e) {
await Taro.redirectTo({ url: ticketIndexUrl })
}
return false
}
} : undefined);
// ✅ 移除双重成功提示 - PaymentHandler会处理成功提示
// Taro.showToast({
@@ -633,7 +704,7 @@ const OrderConfirm = () => {
message.includes('电子围栏') ||
message.includes('围栏')
// 配送范围类错误给出更友好的解释,并提供快捷入口去更换收货地址
// "配送范围"类错误给出更友好的解释,并提供快捷入口去更换收货地址
if (isOutOfDeliveryRange) {
try {
const res = await Taro.showModal({
@@ -673,8 +744,8 @@ const OrderConfirm = () => {
// 分别加载数据,避免类型推断问题
let goodsRes: ShopGoods | null = null
if (goodsId) {
goodsRes = await getShopGoods(Number(goodsId))
if (resolvedGoodsId) {
goodsRes = await getShopGoods(resolvedGoodsId)
}
const [addressRes, paymentRes] = await Promise.all([
@@ -685,9 +756,9 @@ const OrderConfirm = () => {
// 设置商品信息
// 查询当前商品是否存在水票套票活动(失败/无数据时不影响正常下单)
let tpl: GltTicketTemplate | null = null
if (goodsId) {
if (resolvedGoodsId) {
try {
tpl = await getGltTicketTemplateByGoodsId(Number(goodsId))
tpl = await getGltTicketTemplateByGoodsId(resolvedGoodsId)
} catch (e) {
tpl = null
}
@@ -703,18 +774,41 @@ const OrderConfirm = () => {
const n = Number(tpl?.minBuyQty)
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 1
})()
const tplStep = (() => {
const n = Number(tpl?.step)
return Number.isFinite(n) && n > 0 ? Math.floor(n) : undefined
})()
// 设置商品信息(若存在套票模板,则默认 canBuyNumber 使用模板最小购买量)
if (goodsRes) {
const patchedGoods: ShopGoods = { ...goodsRes }
// 兜底:确保 step 为合法正整数;若存在套票模板则优先使用模板 step
const goodsStepNum = Number((patchedGoods as any)?.step)
const goodsStep = Number.isFinite(goodsStepNum) && goodsStepNum > 0 ? Math.floor(goodsStepNum) : 1
patchedGoods.step = tplActive && tplStep ? tplStep : goodsStep
// 规格商品orderData 模式)下单时,用 sku 价格覆盖展示与计算金额
if (orderDataParam?.price !== undefined && orderDataParam?.price !== null && orderDataParam?.price !== '') {
patchedGoods.price = String(orderDataParam.price)
}
if (tplActive && ((patchedGoods.canBuyNumber ?? 0) === 0)) {
patchedGoods.canBuyNumber = tplMinBuyQty
}
setGoods(patchedGoods)
// 设置默认购买数量:优先使用 canBuyNumber否则使用 1
const initQty = (patchedGoods.canBuyNumber ?? 0) > 0 ? (patchedGoods.canBuyNumber as number) : 1
setQuantity(initQty)
// 设置默认购买数量:优先使用 canBuyNumber其次使用路由参数 quantity否则使用 1
const fixedQty = (patchedGoods.canBuyNumber ?? 0) > 0 ? Number(patchedGoods.canBuyNumber) : undefined
const rawQty = fixedQty ?? quantityFromParam ?? 1
const minQty = tplActive ? tplMinBuyQty : 1
const step = patchedGoods.step || 1
const stockMax = patchedGoods.stock ?? 999
const maxMultiple = step > 1 ? Math.floor(stockMax / step) * step : stockMax
const maxAllowed = maxMultiple > 0 ? maxMultiple : stockMax
const effectiveMin = Math.min(minQty, maxAllowed)
const clamped = Math.max(effectiveMin, Math.min(Math.floor(rawQty), maxAllowed))
const stepped = step > 1 ? Math.ceil(clamped / step) * step : clamped
setQuantity(Math.min(maxAllowed, Math.max(effectiveMin, stepped)))
}
setTicketTemplate(tpl)
@@ -733,15 +827,26 @@ const OrderConfirm = () => {
setPayment(paymentRes[0])
}
// 加载优惠券:使用初始数量对应的总价做推荐,避免默认数量变化导致推荐不准
// 加载优惠券:使用"初始数量"对应的总价做推荐,避免默认数量变化导致推荐不准
if (goodsRes) {
const initQty = (() => {
const n = Number(goodsRes?.canBuyNumber)
if (Number.isFinite(n) && n > 0) return Math.floor(n)
if (tplActive) return tplMinBuyQty
return 1
return quantityFromParam || 1
})()
const total = parseFloat(goodsRes.price || '0') * initQty
const stepForInit = tplActive && tplStep ? tplStep : (() => {
const n = Number((goodsRes as any)?.step)
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 1
})()
const stockMax = goodsRes.stock ?? 999
const maxMultiple = stepForInit > 1 ? Math.floor(stockMax / stepForInit) * stepForInit : stockMax
const maxAllowed = maxMultiple > 0 ? maxMultiple : stockMax
const initQtySnapped = stepForInit > 1 ? Math.ceil(initQty / stepForInit) * stepForInit : initQty
const effectiveMin = Math.min(tplActive ? tplMinBuyQty : 1, maxAllowed)
const safeInitQty = Math.max(effectiveMin, Math.min(initQtySnapped, maxAllowed))
const unitPrice = parseFloat(String(orderDataParam?.price ?? goodsRes.price ?? '0'))
const total = unitPrice * safeInitQty
await loadUserCoupons(total)
}
} catch (err) {
@@ -760,12 +865,9 @@ const OrderConfirm = () => {
})
useEffect(() => {
// 切换商品时重置配送时间,避免沿用上一次选择
if (!isLoggedIn()) return
setSendTime(getMinSendDate().toDate())
setSendTimePickerVisible(false)
loadAllData()
}, [goodsId]);
}, [resolvedGoodsId, orderDataRaw]);
// 重新加载数据
const handleRetry = () => {
@@ -791,6 +893,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 && (
@@ -821,27 +938,6 @@ const OrderConfirm = () => {
</Cell>
)}
</CellGroup>
{hasTicketTemplate && (
<CellGroup>
<Cell
title={'配送时间'}
extra={(
<View className={'flex items-center gap-2'}>
<View className={'text-gray-900'}>{sendTimeText}</View>
<ArrowRight className={'text-gray-400'} size={14}/>
</View>
)}
onClick={() => {
// 若页面停留跨过截单时间,打开选择器前再校正一次最早可选日期
const min = getMinSendDate()
if (dayjs(sendTime).isBefore(min, 'day')) {
setSendTime(min.toDate())
}
setSendTimePickerVisible(true)
}}
/>
</CellGroup>
)}
{/*<CellGroup>*/}
@@ -884,7 +980,7 @@ const OrderConfirm = () => {
value={quantity}
min={isTicketTemplateActive ? minBuyQty : 1}
max={goods.stock || 999}
step={minBuyQty === 1 ? 1 : 10}
step={goods.step || 1}
readOnly
disabled={((goods.canBuyNumber ?? 0) !== 0) && !isTicketTemplateActive}
onChange={handleQuantityChange}
@@ -1138,23 +1234,6 @@ const OrderConfirm = () => {
<Gap height={50}/>
<DatePicker
visible={sendTimePickerVisible}
title="选择配送时间"
type="date"
startDate={getMinSendDate().toDate()}
endDate={dayjs().add(30, 'day').toDate()}
value={sendTime}
onClose={() => setSendTimePickerVisible(false)}
onCancel={() => setSendTimePickerVisible(false)}
onConfirm={(_options, selectedValue) => {
const [y, m, d] = (selectedValue || []).map(v => Number(v))
const next = new Date(y, (m || 1) - 1, d || 1, 0, 0, 0)
setSendTime(next)
setSendTimePickerVisible(false)
}}
/>
<div className={'fixed z-50 bg-white w-full bottom-0 left-0 pt-4 pb-10 border-t border-gray-200'}>
<View className={'btn-bar flex justify-between items-center'}>
<div className={'flex flex-col justify-center items-start mx-4'}>

View File

@@ -8,6 +8,7 @@ import {listShopOrderGoods} from "@/api/shop/shopOrderGoods";
import {ShopOrderGoods} from "@/api/shop/shopOrderGoods/model";
import dayjs from "dayjs";
import PaymentCountdown from "@/components/PaymentCountdown";
import {getShopOrderStatusText} from "@/utils/shopOrderStatus";
import './index.scss'
// 申请退款:支付成功后仅允许在指定时间窗内发起(前端展示层限制,后端仍应校验)
@@ -114,37 +115,6 @@ const OrderDetail = () => {
}
}
const getOrderStatusText = (order: ShopOrder) => {
// 优先检查订单状态
if (order.orderStatus === 2) return '已取消';
if (order.orderStatus === 3) return '取消中';
if (order.orderStatus === 4) return '退款申请中';
if (order.orderStatus === 5) return '退款被拒绝';
if (order.orderStatus === 6) return '退款成功';
if (order.orderStatus === 7) return '客户端申请退款';
// 检查支付状态 (payStatus为boolean类型)
if (!order.payStatus) return '待付款';
// 已付款后检查发货状态
if (order.deliveryStatus === 10) return '待发货';
if (order.deliveryStatus === 20) {
// 若订单有配送员,则以配送员送达时间作为“可确认收货”的依据
if (order.riderId) {
if (order.sendEndTime && order.orderStatus !== 1) return '待确认收货';
return '配送中';
}
return '待收货';
}
if (order.deliveryStatus === 30) return '部分发货';
// 最后检查订单完成状态
if (order.orderStatus === 1) return '已完成';
if (order.orderStatus === 0) return '未使用';
return '未知状态';
};
const getPayTypeText = (payType?: number) => {
switch (payType) {
case 0:
@@ -194,7 +164,7 @@ const OrderDetail = () => {
order.payStatus &&
order.orderStatus !== 1 &&
order.deliveryStatus === 20 &&
(!order.riderId || !!order.sendEndTime)
(!order.riderId || Number(order.riderId) === 0 || !!order.sendEndTime)
return (
<div className={'order-detail-page'}>
@@ -232,7 +202,7 @@ const OrderDetail = () => {
<CellGroup>
<Cell title="订单编号" description={order.orderNo}/>
<Cell title="下单时间" description={dayjs(order.createTime).format('YYYY-MM-DD HH:mm:ss')}/>
<Cell title="订单状态" description={getOrderStatusText(order)}/>
<Cell title="订单状态" description={getShopOrderStatusText(order)}/>
</CellGroup>
<CellGroup>

View File

@@ -16,6 +16,7 @@ import {copyText} from "@/utils/common";
import PaymentCountdown from "@/components/PaymentCountdown";
import {PaymentType} from "@/utils/payment";
import {ErrorType, RequestError} from "@/utils/request";
import {getShopOrderStatusColor, getShopOrderStatusText, isShopOrderCompleted} from "@/utils/shopOrderStatus";
// 判断订单是否支付已过期
const isPaymentExpired = (createTime: string, timeoutHours: number = 24): boolean => {
@@ -165,68 +166,11 @@ function OrderList(props: OrderListProps) {
};
// “已完成”应以订单状态为准不要用商品ID等字段推断完成态否则会造成 Tab(待发货/待收货) 与状态文案不同步
const isOrderCompleted = (order: ShopOrder) => toNum(order.orderStatus) === 1;
const isOrderCompleted = (order: ShopOrder) => isShopOrderCompleted(order);
// 获取订单状态文本
const getOrderStatusText = (order: ShopOrder) => {
const orderStatus = toNum(order.orderStatus);
const deliveryStatus = toNum(order.deliveryStatus);
const getOrderStatusText = (order: ShopOrder) => getShopOrderStatusText(order);
// 优先检查订单状态
if (orderStatus === 2) return '已取消';
if (orderStatus === 4) return '退款申请中';
if (orderStatus === 5) return '退款被拒绝';
if (orderStatus === 6) return '退款成功';
if (orderStatus === 7) return '客户端申请退款';
if (isOrderCompleted(order)) return '已完成';
// 检查支付状态 (payStatus为boolean类型false/0表示未付款true/1表示已付款)
if (!order.payStatus) return '等待买家付款';
// 已付款后检查发货状态
if (deliveryStatus === 10) return '待发货';
if (deliveryStatus === 20) {
// 若订单没有配送员,沿用原“待收货”语义
if (!order.riderId || Number(order.riderId) === 0) return '待收货';
// 配送员确认送达后sendEndTime有值才进入“待确认收货”
if (order.sendEndTime && !isOrderCompleted(order)) return '待确认收货';
return '配送中';
}
if (deliveryStatus === 30) return '部分发货';
if (orderStatus === 0) return '未使用';
return '未知状态';
};
// 获取订单状态颜色
const getOrderStatusColor = (order: ShopOrder) => {
const orderStatus = toNum(order.orderStatus);
const deliveryStatus = toNum(order.deliveryStatus);
// 优先检查订单状态
if (orderStatus === 2) return 'text-gray-500'; // 已取消
if (orderStatus === 4) return 'text-orange-500'; // 退款申请中
if (orderStatus === 5) return 'text-red-500'; // 退款被拒绝
if (orderStatus === 6) return 'text-green-500'; // 退款成功
if (orderStatus === 7) return 'text-orange-500'; // 客户端申请退款
if (isOrderCompleted(order)) return 'text-green-600'; // 已完成
// 检查支付状态
if (!order.payStatus) return 'text-orange-500'; // 等待买家付款
// 已付款后检查发货状态
if (deliveryStatus === 10) return 'text-blue-500'; // 待发货
if (deliveryStatus === 20) {
if (!order.riderId || Number(order.riderId) === 0) return 'text-purple-500'; // 待收货
if (order.sendEndTime && !isOrderCompleted(order)) return 'text-purple-500'; // 待确认收货
return 'text-blue-500'; // 配送中
}
if (deliveryStatus === 30) return 'text-blue-500'; // 部分发货
if (orderStatus === 0) return 'text-gray-500'; // 未使用
return 'text-gray-600'; // 默认颜色
};
const getOrderStatusColor = (order: ShopOrder) => getShopOrderStatusColor(order);
// 使用后端统一的 statusFilter 进行筛选
const getOrderStatusParams = (index: string | number) => {

View File

@@ -14,15 +14,16 @@ import {
Tag
} from '@nutui/nutui-react-taro';
import { View, Text, Image } from '@tarojs/components';
import { pageGltUserTicket } from '@/api/glt/gltUserTicket';
import { getGltUserTicket, pageGltUserTicket, updateGltUserTicket } from '@/api/glt/gltUserTicket';
import type { GltUserTicket } from '@/api/glt/gltUserTicket/model';
import { pageGltTicketOrder, updateGltTicketOrder } from '@/api/glt/gltTicketOrder';
import { pageGltTicketOrder, removeGltTicketOrder, updateGltTicketOrder } from '@/api/glt/gltTicketOrder';
import type { GltTicketOrder } from '@/api/glt/gltTicketOrder/model';
import { getShopUserAddress } from '@/api/shop/shopUserAddress';
import { BaseUrl } from '@/config/app';
import dayjs from "dayjs";
import { ensureLoggedIn } from '@/utils/auth';
const PAGE_SIZE = 10;
const PAY_REFRESH_HANDLED_KEY = 'user_ticket_from_pay_at_handled';
const UserTicketList = () => {
const [ticketList, setTicketList] = useState<GltUserTicket[]>([]);
@@ -37,6 +38,7 @@ const UserTicketList = () => {
const [orderHasMore, setOrderHasMore] = useState(true);
const [orderPage, setOrderPage] = useState(1);
const [orderTotal, setOrderTotal] = useState(0);
const [orderCancelLoadingById, setOrderCancelLoadingById] = useState<Record<number, boolean>>({});
const [activeTab, setActiveTab] = useState<'ticket' | 'order'>(() => {
const tab = Taro.getCurrentInstance().router?.params?.tab
@@ -46,8 +48,25 @@ const UserTicketList = () => {
const [qrVisible, setQrVisible] = useState(false);
const [qrTicket, setQrTicket] = useState<GltUserTicket | null>(null);
const [qrImageUrl, setQrImageUrl] = useState('');
const payAutoRefreshRunningRef = useRef(false);
const addressCacheRef = useRef<Record<number, { lng: number; lat: number; fullAddress?: string } | null>>({});
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
const parsePositiveNumberParam = (v: unknown) => {
const n = Number(v);
return Number.isFinite(n) && n > 0 ? n : undefined;
};
const getFromPayAtParam = () => {
const params = Taro.getCurrentInstance().router?.params;
return parsePositiveNumberParam((params as any)?.fromPayAt);
};
const shouldAutoRefreshAfterPay = (fromPayAt?: number) => {
if (!fromPayAt) return false;
const handled = parsePositiveNumberParam(Taro.getStorageSync(PAY_REFRESH_HANDLED_KEY)) || 0;
return handled !== fromPayAt;
};
const getUserId = () => {
const raw = Taro.getStorageSync('UserId');
@@ -97,6 +116,41 @@ const UserTicketList = () => {
setQrVisible(true);
};
const goSendWater = async (ticket: GltUserTicket) => {
if (!ticket?.id) {
Taro.showToast({ title: '水票信息不完整', icon: 'none' });
return;
}
if (Number(ticket.status) === 1) {
Taro.showToast({ title: '该水票已冻结,无法下单', icon: 'none' });
return;
}
const avail = Number(ticket.availableQty ?? 0);
if (!Number.isFinite(avail) || avail <= 0) {
Taro.showToast({ title: '可用次数不足', icon: 'none' });
return;
}
const gid = Number(ticket.goodsId);
const url =
Number.isFinite(gid) && gid > 0 ? `/user/ticket/use?goodsId=${gid}` : '/user/ticket/use';
if (!ensureLoggedIn(url)) return;
await Taro.navigateTo({ url });
};
const goReleasePlanDetail = async (ticket: GltUserTicket) => {
if (!ticket?.id) {
Taro.showToast({ title: '水票信息不完整', icon: 'none' });
return;
}
const url = `/user/ticket/release/index?userTicketId=${encodeURIComponent(String(ticket.id))}&templateName=${encodeURIComponent(
String(ticket.templateName ?? '')
)}&frozenQty=${encodeURIComponent(String(ticket.frozenQty ?? 0))}&releasedQty=${encodeURIComponent(
String(ticket.releasedQty ?? 0)
)}`;
if (!ensureLoggedIn(url)) return;
await Taro.navigateTo({ url });
};
const showTicketDetail = (ticket: GltUserTicket) => {
const lines: string[] = [];
if (ticket.templateName) lines.push(`水票:${ticket.templateName}`);
@@ -188,17 +242,16 @@ const UserTicketList = () => {
});
const resList = res?.list || [];
const nextList = isRefresh ? resList : [...orderList, ...resList];
const safeList = resList.filter((o) => Number((o as any)?.deleted) !== 1);
const nextList = isRefresh ? safeList : [...orderList, ...safeList];
setOrderList(nextList);
const count = typeof res?.count === 'number' ? res.count : nextList.length;
setOrderTotal(count);
setOrderHasMore(nextList.length < count);
const serverCount = typeof res?.count === 'number' ? res.count : undefined;
const total = typeof serverCount === 'number' ? serverCount : nextList.length;
setOrderTotal(total);
setOrderHasMore(typeof serverCount === 'number' ? nextList.length < serverCount : resList.length >= PAGE_SIZE);
if (resList.length > 0) {
setOrderPage(currentPage + 1);
} else {
setOrderHasMore(false);
}
if (resList.length > 0) setOrderPage(currentPage + 1);
else setOrderHasMore(false);
} catch (error) {
console.error('获取送水订单失败:', error);
Taro.showToast({ title: '获取送水订单失败', icon: 'error' });
@@ -265,78 +318,184 @@ const UserTicketList = () => {
return d.isValid() ? d.format('YYYY年MM月DD日') : v;
};
const parseLatLng = (latRaw?: unknown, lngRaw?: unknown) => {
const lat = typeof latRaw === 'number' ? latRaw : parseFloat(String(latRaw ?? ''));
const lng = typeof lngRaw === 'number' ? lngRaw : parseFloat(String(lngRaw ?? ''));
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
if (Math.abs(lat) > 90 || Math.abs(lng) > 180) return null;
return { lat, lng };
const getTicketAvailableQty = (t?: Partial<GltUserTicket> | null) => {
if (!t) return 0;
const anyT: any = t;
const raw =
anyT.availableQty ??
anyT.availableNum ??
anyT.availableCount ??
anyT.remainQty ??
anyT.remainNum ??
anyT.remainCount;
const n = Number(raw);
if (Number.isFinite(n)) return n;
const total = Number(anyT.totalQty ?? anyT.totalNum ?? anyT.totalCount ?? 0);
const used = Number(anyT.usedQty ?? anyT.usedNum ?? anyT.usedCount ?? 0);
const frozen = Number(anyT.frozenQty ?? anyT.frozenNum ?? anyT.frozenCount ?? 0);
const computed =
(Number.isFinite(total) ? total : 0) -
(Number.isFinite(used) ? used : 0) -
(Number.isFinite(frozen) ? frozen : 0);
return Number.isFinite(computed) ? computed : 0;
};
const handleNavigateToAddress = async (order: GltTicketOrder) => {
try {
// Prefer coordinates from backend if present (non-typed fields), otherwise fetch by addressId.
const anyOrder = order as any;
const direct =
parseLatLng(anyOrder?.addressLat ?? anyOrder?.lat, anyOrder?.addressLng ?? anyOrder?.lng) ||
parseLatLng(anyOrder?.receiverLat, anyOrder?.receiverLng);
const getTicketUsedQty = (t?: Partial<GltUserTicket> | null) => {
if (!t) return 0;
const anyT: any = t;
const raw = anyT.usedQty ?? anyT.usedNum ?? anyT.usedCount;
const n = Number(raw);
return Number.isFinite(n) ? n : 0;
};
let coords = direct;
let fullAddress: string | undefined = order.address || undefined;
const rollbackUserTicketAfterOrderCancel = async (order: GltTicketOrder, before?: GltUserTicket | null) => {
const orderId = Number(order?.id);
const ticketId = Number(order?.userTicketId);
const qty = Math.max(0, Math.floor(Number((order as any)?.totalNum ?? 0)));
if (!Number.isFinite(orderId) || orderId <= 0) return;
if (!Number.isFinite(ticketId) || ticketId <= 0) return;
if (!Number.isFinite(qty) || qty <= 0) return;
if (!coords && order.addressId) {
const cached = addressCacheRef.current[order.addressId];
if (cached) {
coords = { lat: cached.lat, lng: cached.lng };
fullAddress = fullAddress || cached.fullAddress;
} else if (cached === null) {
coords = null;
} else {
const addr = await getShopUserAddress(order.addressId);
const parsed = parseLatLng(addr?.lat, addr?.lng);
if (parsed) {
coords = parsed;
fullAddress = fullAddress || addr?.fullAddress || addr?.address || undefined;
addressCacheRef.current[order.addressId] = { ...parsed, fullAddress };
} else {
addressCacheRef.current[order.addressId] = null;
const rollbackKey = `glt_ticket_order_rollback:${orderId}`;
if (Taro.getStorageSync(rollbackKey)) return;
const after = await getGltUserTicket(ticketId);
if (!after?.id) return;
const beforeAvail = before ? getTicketAvailableQty(before) : undefined;
const afterAvail = getTicketAvailableQty(after);
const beforeUsed = before ? getTicketUsedQty(before) : undefined;
const afterUsed = getTicketUsedQty(after);
let needAvail = qty;
if (typeof beforeAvail === 'number') {
const delta = afterAvail - beforeAvail;
if (delta >= qty) {
Taro.setStorageSync(rollbackKey, Date.now());
return; // backend already rolled back
}
if (delta > 0) needAvail = Math.max(0, qty - delta);
}
let needUsed = qty;
if (typeof beforeUsed === 'number') {
const delta = beforeUsed - afterUsed;
if (delta >= qty) {
needUsed = 0; // backend already rolled back used qty
} else if (delta > 0) {
needUsed = Math.max(0, qty - delta);
}
}
if (!coords) {
if (fullAddress) {
await Taro.setClipboardData({ data: fullAddress });
Taro.showToast({ title: '未配置定位,地址已复制', icon: 'none' });
} else {
Taro.showToast({ title: '暂无可导航的地址', icon: 'none' });
}
if (needAvail <= 0 && needUsed <= 0) {
Taro.setStorageSync(rollbackKey, Date.now());
return;
}
Taro.openLocation({
latitude: coords.lat,
longitude: coords.lng,
name: '收货地址',
address: fullAddress || ''
const currentAvailRaw = Number((after as any)?.availableQty);
const baseAvail = Number.isFinite(currentAvailRaw) ? currentAvailRaw : afterAvail;
const safeBaseAvail = Number.isFinite(baseAvail) ? baseAvail : 0;
const totalRaw = Number((after as any)?.totalQty ?? 0);
const total = Number.isFinite(totalRaw) ? totalRaw : undefined;
const frozenRaw = Number((after as any)?.frozenQty ?? 0);
const frozen = Number.isFinite(frozenRaw) ? frozenRaw : 0;
const currentUsedRaw = Number((after as any)?.usedQty);
const baseUsed = Number.isFinite(currentUsedRaw) ? currentUsedRaw : afterUsed;
const safeBaseUsed = Number.isFinite(baseUsed) ? baseUsed : 0;
let nextUsed = safeBaseUsed - needUsed;
if (nextUsed < 0) nextUsed = 0;
const maxAvail = typeof total === 'number' ? Math.max(0, total - frozen - nextUsed) : undefined;
let nextAvail = safeBaseAvail + needAvail;
if (typeof maxAvail === 'number' && Number.isFinite(maxAvail) && nextAvail > maxAvail) nextAvail = maxAvail;
if (nextAvail < 0) nextAvail = 0;
await updateGltUserTicket({
...after,
availableQty: nextAvail,
usedQty: nextUsed
});
} catch (e) {
console.error('一键导航失败:', e);
Taro.showToast({ title: '导航失败,请重试', icon: 'none' });
}
Taro.setStorageSync(rollbackKey, Date.now());
};
const handleOneClickCall = async (order: GltTicketOrder) => {
const phone = (order.riderPhone || order.storePhone || '').trim();
if (!phone) {
Taro.showToast({ title: '暂无可呼叫的电话', icon: 'none' });
// Allow users to modify/cancel before delivery starts (e.g. 待派单 / 待配送).
const isTicketOrderPendingDelivery = (order: GltTicketOrder) => {
if (!order?.id) return false;
if (Number(order.status) === 1) return false;
if (Number((order as any)?.deleted) === 1) return false;
if (order.receiveConfirmTime || order.sendEndTime || order.sendStartTime) return false;
const ds = Number((order as any)?.deliveryStatus);
// If backend didn't set deliveryStatus yet, treat it as pending.
if (!Number.isFinite(ds)) return true;
// 0/10: before delivery starts
return ds === 0 || ds === 10;
};
const handleOrderModify = async (order: GltTicketOrder) => {
if (!order?.id) {
Taro.showToast({ title: '订单信息不完整', icon: 'none' });
return;
}
if (!isTicketOrderPendingDelivery(order)) {
Taro.showToast({ title: '仅配送未开始的订单可修改', icon: 'none' });
return;
}
Taro.navigateTo({ url: `/user/ticket/use?orderId=${order.id}` });
};
const handleOrderCancel = async (order: GltTicketOrder) => {
if (!order?.id) {
Taro.showToast({ title: '订单信息不完整', icon: 'none' });
return;
}
if (!isTicketOrderPendingDelivery(order)) {
Taro.showToast({ title: '仅配送未开始的订单可取消', icon: 'none' });
return;
}
if (orderCancelLoadingById[order.id]) return;
const modal = await Taro.showModal({
title: '取消订单',
content: '确定要取消该订单吗?取消后无法恢复。',
confirmText: '确认取消'
});
if (!modal.confirm) return;
try {
await Taro.makePhoneCall({ phoneNumber: phone });
setOrderCancelLoadingById((prev) => ({ ...prev, [order.id as number]: true }));
Taro.showLoading({ title: '取消中...' });
let beforeTicket: GltUserTicket | null = null;
if (order.userTicketId) {
beforeTicket = await getGltUserTicket(Number(order.userTicketId)).catch(() => null);
}
try {
await updateGltTicketOrder({ id: order.id, deleted: 1 });
} catch (e) {
console.error('一键呼叫失败:', e);
Taro.showToast({ title: '呼叫失败,请手动拨打', icon: 'none' });
await removeGltTicketOrder(order.id);
}
try {
await rollbackUserTicketAfterOrderCancel(order, beforeTicket);
Taro.showToast({ title: '订单已取消,水票已退回', icon: 'none' });
} catch (e) {
console.error('取消订单后退回水票失败:', e);
await Taro.showModal({
title: '取消成功',
content: '订单已取消,但水票退回失败,请稍后刷新“我的水票”确认,或联系客服处理。',
showCancel: false
});
}
await reloadOrders(true);
} catch (e) {
console.error('取消送水订单失败:', e);
Taro.showToast({ title: '取消失败,请重试', icon: 'none' });
} finally {
Taro.hideLoading();
setOrderCancelLoadingById((prev) => ({ ...prev, [order.id as number]: false }));
}
};
@@ -391,12 +550,37 @@ const UserTicketList = () => {
}
useDidShow(() => {
if (activeTab === 'ticket') {
reloadTickets(true).then();
} else {
reloadOrders(true).then();
void (async () => {
const tabParam = Taro.getCurrentInstance().router?.params?.tab;
const nextTab = tabParam === 'ticket' || tabParam === 'order' ? tabParam : undefined;
if (nextTab && nextTab !== activeTab) {
setActiveTab(nextTab);
}
});
const tabToLoad = nextTab || activeTab;
if (tabToLoad === 'ticket') {
await reloadTickets(true);
const fromPayAt = getFromPayAtParam();
if (shouldAutoRefreshAfterPay(fromPayAt) && !payAutoRefreshRunningRef.current) {
payAutoRefreshRunningRef.current = true;
try {
Taro.setStorageSync(PAY_REFRESH_HANDLED_KEY, fromPayAt);
// 支付后水票可能异步入账:自动再刷新几次,避免用户手动下拉刷新。
for (const delayMs of [800, 1500, 2500]) {
await sleep(delayMs);
await reloadTickets(true);
}
} finally {
payAutoRefreshRunningRef.current = false;
}
}
} else {
await reloadOrders(true);
}
})();
})
return (
<ConfigProvider>
@@ -479,6 +663,9 @@ const UserTicketList = () => {
<Text className="text-base font-semibold text-gray-900">
{item.id}
</Text>
<View className="mt-1">
<Text className="text-xs text-gray-500">{item.templateName}</Text>
</View>
{item.orderNo && (
<View className="mt-1">
<Text className="text-xs text-gray-500">{item.orderNo}</Text>
@@ -490,13 +677,25 @@ const UserTicketList = () => {
</View>
)}
</View>
<View className="flex flex-col items-end gap-2 hidden">
<View className="flex flex-col items-end gap-2">
<Button
size="small"
type="primary"
disabled={(item.availableQty ?? 0) <= 0 || item.status === 1}
onClick={(e) => {
e.stopPropagation();
void goSendWater(item);
}}
>
</Button>
{/*<Tag type={item.status === 1 ? 'danger' : 'success'}>*/}
{/* {item.status === 1 ? '冻结' : '正常'}*/}
{/*</Tag>*/}
<Button
size="small"
type="primary"
style={{ display: 'none'}}
disabled={(item.availableQty ?? 0) <= 0 || item.status === 1}
onClick={(e) => {
// Avoid triggering card click.
@@ -518,7 +717,14 @@ const UserTicketList = () => {
<Text className="text-lg text-gray-900 text-center">{item.usedQty ?? 0}</Text>
<Text className="text-xs text-gray-500"></Text>
</View>
<View className="flex flex-col items-center">
<View
className="flex flex-col items-center"
hoverClass="opacity-70"
onClick={(e) => {
e.stopPropagation();
void goReleasePlanDetail(item);
}}
>
<Text className="text-lg text-gray-900 text-center">{item.frozenQty ?? 0}</Text>
<Text className="text-xs text-gray-500"></Text>
</View>
@@ -576,32 +782,6 @@ const UserTicketList = () => {
<View className="mt-1">
<Text className="text-xs text-gray-500">{formatDateTime(item.createTime)}</Text>
</View>
{(!!item.addressId || !!item.address || !!item.riderPhone || !!item.storePhone) ? (
<View className="mt-3 flex justify-end gap-2">
{(!!item.addressId || !!item.address) ? (
<Button
size="small"
onClick={(e) => {
e.stopPropagation();
void handleNavigateToAddress(item);
}}
>
</Button>
) : null}
{(!!item.riderPhone || !!item.storePhone) ? (
<Button
size="small"
onClick={(e) => {
e.stopPropagation();
void handleOneClickCall(item);
}}
>
</Button>
) : null}
</View>
) : null}
{/*{item.storeName ? (*/}
{/* <View className="mt-1 text-xs text-gray-500">*/}
{/* <Text>门店:{item.storeName}</Text>*/}
@@ -638,6 +818,38 @@ const UserTicketList = () => {
</Button>
</View>
) : null}
{item.id ? (
<View className="mt-3 flex justify-end gap-2">
<Button
size="small"
disabled={
!isTicketOrderPendingDelivery(item) ||
!!orderCancelLoadingById[item.id as number]
}
onClick={(e) => {
e.stopPropagation();
void handleOrderModify(item);
}}
>
</Button>
<Button
size="small"
type="danger"
disabled={
!isTicketOrderPendingDelivery(item) ||
!!orderCancelLoadingById[item.id as number]
}
onClick={(e) => {
e.stopPropagation();
void handleOrderCancel(item);
}}
>
</Button>
</View>
) : null}
</View>
))}
</View>

View File

@@ -0,0 +1,6 @@
export default definePageConfig({
navigationBarTitleText: '释放计划',
navigationBarTextStyle: 'black',
navigationBarBackgroundColor: '#ffffff'
})

View File

@@ -0,0 +1,245 @@
import { useState } from 'react'
import Taro, { useDidShow } from '@tarojs/taro'
import { View, Text } from '@tarojs/components'
import { ConfigProvider, Empty, InfiniteLoading, Loading, PullToRefresh, Tag } from '@nutui/nutui-react-taro'
import dayjs from 'dayjs'
import { pageGltUserTicketRelease } from '@/api/glt/gltUserTicketRelease'
import type { GltUserTicketRelease } from '@/api/glt/gltUserTicketRelease/model'
import { ensureLoggedIn } from '@/utils/auth'
const PAGE_SIZE = 10
const MAX_FETCH_ROUNDS = 10
export default function TicketReleasePlanPage() {
const [list, setList] = useState<GltUserTicketRelease[]>([])
const [loading, setLoading] = useState(false)
const [hasMore, setHasMore] = useState(true)
const [page, setPage] = useState(1)
const [total, setTotal] = useState<number | undefined>(undefined)
const router = Taro.getCurrentInstance().router
const userTicketId = String(router?.params?.userTicketId || '').trim()
const templateName = (() => {
const raw = String(router?.params?.templateName || '')
try {
return decodeURIComponent(raw)
} catch {
return raw
}
})()
const frozenQtyText = router?.params?.frozenQty !== undefined ? String(router?.params?.frozenQty) : undefined
const releasedQtyText = router?.params?.releasedQty !== undefined ? String(router?.params?.releasedQty) : undefined
const getUserId = () => {
const raw = Taro.getStorageSync('UserId')
const id = Number(raw)
return Number.isFinite(id) && id > 0 ? id : undefined
}
const getStatusMeta = (item: GltUserTicketRelease) => {
const status = Number(item.status)
if (status === 1) return { text: '已释放', type: 'success' as const }
if (status === 0) return { text: '待释放', type: 'warning' as const }
return { text: `状态${Number.isFinite(status) ? status : '-'}`, type: 'primary' as const }
}
const formatDateTime = (v?: string) => {
if (!v) return '-'
const d = dayjs(v)
return d.isValid() ? d.format('YYYY年MM月DD日 HH:mm:ss') : v
}
const reload = async (isRefresh = true) => {
if (loading) return
const uid = getUserId()
if (!uid) {
setList([])
setHasMore(false)
setTotal(0)
return
}
if (!userTicketId) {
setList([])
setHasMore(false)
setTotal(0)
return
}
if (isRefresh) {
setPage(1)
setList([])
setHasMore(true)
}
setLoading(true)
try {
const baseList = isRefresh ? [] : list
const seen = new Set(baseList.map(r => String(r.id ?? `${r.userTicketId ?? ''}:${r.periodNo ?? ''}:${r.releaseTime ?? ''}`)))
let nextPage = isRefresh ? 1 : page
let serverHasMore = true
let added = 0
let nextList = baseList.slice()
for (let round = 0; round < MAX_FETCH_ROUNDS; round++) {
if (!serverHasMore) break
// Only query by current logged-in userId; userTicketId is filtered on the client.
const res = await pageGltUserTicketRelease({
page: nextPage,
limit: PAGE_SIZE,
userId: uid
} as any)
const incoming = Array.isArray(res?.list) ? res.list : []
const safe = incoming
.filter(r => Number((r as any)?.deleted) !== 1)
.filter(r => !userTicketId || String(r.userTicketId || '') === userTicketId)
.filter(r => {
const k = String(r.id ?? `${r.userTicketId ?? ''}:${r.periodNo ?? ''}:${r.releaseTime ?? ''}`)
if (seen.has(k)) return false
seen.add(k)
return true
})
if (safe.length) {
nextList = nextList.concat(safe)
added += safe.length
}
serverHasMore = incoming.length >= PAGE_SIZE
if (!serverHasMore) break
nextPage += 1
// Stop early once we got something to render for this ticket.
if (added > 0) break
}
nextList.sort((a, b) => {
const at = dayjs(a.releaseTime || a.createTime || 0).valueOf()
const bt = dayjs(b.releaseTime || b.createTime || 0).valueOf()
return bt - at
})
setList(nextList)
setTotal(nextList.length)
setHasMore(serverHasMore)
setPage(nextPage)
} catch (e) {
console.error('加载释放计划失败:', e)
Taro.showToast({ title: '加载失败', icon: 'none' })
setHasMore(false)
} finally {
setLoading(false)
}
}
useDidShow(() => {
const redirect = userTicketId
? `/user/ticket/release/index?userTicketId=${encodeURIComponent(userTicketId)}`
: '/user/ticket/index'
if (!ensureLoggedIn(redirect)) return
void reload(true)
})
const handleRefresh = async () => {
await reload(true)
}
const loadMore = async () => {
if (!loading && hasMore) await reload(false)
}
return (
<ConfigProvider>
<PullToRefresh onRefresh={handleRefresh} headHeight={60}>
<View style={{ height: 'calc(100vh)', overflowY: 'auto' }} id="ticket-release-scroll">
<View className="px-4 py-3">
<View className="bg-white rounded-xl p-4 mb-3">
<View className="flex items-center justify-between">
<Text className="text-base font-semibold text-gray-900"></Text>
{typeof total === 'number' ? (
<Text className="text-xs text-gray-400"> {total} </Text>
) : null}
</View>
<View className="mt-2 text-xs text-gray-500">
<Text>{userTicketId || '-'}</Text>
</View>
{templateName ? (
<View className="mt-1 text-xs text-gray-500">
<Text>{templateName}</Text>
</View>
) : null}
{frozenQtyText !== undefined || releasedQtyText !== undefined ? (
<View className="mt-2 flex gap-4">
{frozenQtyText !== undefined ? (
<View>
<Text className="text-xs text-gray-500">{frozenQtyText}</Text>
</View>
) : null}
{releasedQtyText !== undefined ? (
<View>
<Text className="text-xs text-gray-500">{releasedQtyText}</Text>
</View>
) : null}
</View>
) : null}
</View>
{list.length === 0 && !loading && !hasMore ? (
<View className="flex flex-col justify-center items-center" style={{ height: 'calc(100vh - 220px)' }}>
<Empty description="暂无释放计划" style={{ backgroundColor: 'transparent' }} />
</View>
) : (
<InfiniteLoading
target="ticket-release-scroll"
hasMore={hasMore}
onLoadMore={loadMore}
loadingText={
<View className="flex justify-center items-center py-4">
<Loading />
<View className="ml-2">...</View>
</View>
}
loadMoreText={
<View className="text-center py-4 text-gray-500">
{list.length === 0 ? '暂无数据' : '没有更多了'}
</View>
}
>
<View>
{list.map((item, index) => {
const meta = getStatusMeta(item)
return (
<View
key={String(item.id ?? `${item.userTicketId ?? 't'}-${index}`)}
className="bg-white rounded-xl p-4 mb-3"
>
<View className="flex items-start justify-between">
<View className="flex-1 pr-3">
<Text className="text-sm font-semibold text-gray-900">
{item.periodNo ?? '-'}
</Text>
<View className="mt-1 text-xs text-gray-500">
<Text>{item.releaseQty ?? 0}</Text>
</View>
<View className="mt-1 text-xs text-gray-500">
<Text>{formatDateTime(item.releaseTime)}</Text>
</View>
</View>
<Tag type={meta.type}>{meta.text}</Tag>
</View>
</View>
)
})}
</View>
</InfiniteLoading>
)}
</View>
</View>
</PullToRefresh>
</ConfigProvider>
)
}

View File

@@ -58,6 +58,179 @@
}
}
// 配送方式选择
.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;
}
}
// 是否送上楼
.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: 18px;
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: 18px;
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;
}
}
// 优惠券弹窗样式
.coupon-popup {
height: 100%;

View File

@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import Taro, { useDidShow } from '@tarojs/taro'
import { View, Text } from '@tarojs/components'
import { View, Text, Picker } from '@tarojs/components'
import {
Button,
Cell,
@@ -13,7 +13,7 @@ import {
Space
} from '@nutui/nutui-react-taro'
import { ArrowRight, Location, Ticket } from '@nutui/icons-react-taro'
import dayjs from 'dayjs'
import dayjs, { type Dayjs } from 'dayjs'
import type { ShopGoods } from '@/api/shop/shopGoods/model'
import { getShopGoods } from '@/api/shop/shopGoods'
import { getShopUserAddress, listShopUserAddress } from '@/api/shop/shopUserAddress'
@@ -25,9 +25,9 @@ import type {ShopStore} from "@/api/shop/shopStore/model";
import {getShopStore, listShopStore} from "@/api/shop/shopStore";
import {getSelectedStoreFromStorage, saveSelectedStoreToStorage} from "@/utils/storeSelection";
import type { GltUserTicket } from '@/api/glt/gltUserTicket/model'
import { listGltUserTicket } from '@/api/glt/gltUserTicket'
import { addGltTicketOrder } from '@/api/glt/gltTicketOrder'
import { pageGltTicketOrder } from '@/api/glt/gltTicketOrder'
import { getGltUserTicket, listGltUserTicket } from '@/api/glt/gltUserTicket'
import { getGltTicketTemplate, getGltTicketTemplateByGoodsId } from '@/api/glt/gltTicketTemplate'
import { addGltTicketOrder, getGltTicketOrder, updateGltTicketOrder } from '@/api/glt/gltTicketOrder'
import type { GltTicketOrder } from '@/api/glt/gltTicketOrder/model'
import type { ShopStoreRider } from '@/api/shop/shopStoreRider/model'
import { listShopStoreRider } from '@/api/shop/shopStoreRider'
@@ -35,16 +35,16 @@ import type { ShopStoreFence } from '@/api/shop/shopStoreFence/model'
import { listShopStoreFence } from '@/api/shop/shopStoreFence'
import { parseFencePoints, parseLngLatFromText, pointInAnyPolygon, pointInPolygon } from '@/utils/geofence'
const MIN_START_QTY = 10
const ADDRESS_CHANGE_COOLDOWN_DAYS = 30
const DEFAULT_MIN_START_QTY = 10
const OrderConfirm = () => {
const [goods, setGoods] = useState<ShopGoods | null>(null);
const [address, setAddress] = useState<ShopUserAddress>()
const [quantity, setQuantity] = useState<number>(MIN_START_QTY)
const [minStartQty, setMinStartQty] = useState<number>(DEFAULT_MIN_START_QTY)
const [quantity, setQuantity] = useState<number>(DEFAULT_MIN_START_QTY)
const [orderRemark, setOrderRemark] = useState<string>('')
// Delivery date only (no hour/min selection).
const [sendTime] = useState<Date>(() => dayjs().startOf('day').toDate())
const [sendTime, setSendTime] = useState<Date>(() => dayjs().startOf('day').toDate())
const [loading, setLoading] = useState<boolean>(true)
const [error, setError] = useState<string>('')
const [submitLoading, setSubmitLoading] = useState<boolean>(false)
@@ -75,6 +75,8 @@ const OrderConfirm = () => {
const [ticketLoading, setTicketLoading] = useState(false)
const [ticketLoaded, setTicketLoaded] = useState(false)
const noTicketPromptedRef = useRef(false)
const ticketAutoRetryCountRef = useRef(0)
const ticketAutoRetryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Delivery range (geofence): block ordering if address/current location is outside.
const [fences, setFences] = useState<ShopStoreFence[]>([])
@@ -87,31 +89,45 @@ const OrderConfirm = () => {
// Prevent using stale `inDeliveryRange` from a previous address when user switches addresses.
const [deliveryRangeCheckedAddressId, setDeliveryRangeCheckedAddressId] = useState<number | undefined>(undefined)
// 配送方式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)
// 计算配送费每桶每层1元第1层不收费
const getDeliveryFee = () => {
if (deliveryMethod !== 'stairs' || !needCarryUpstairs) return 0
if (deliveryFloor <= 1) return 0
return displayQty * (deliveryFloor - 1)
}
const router = Taro.getCurrentInstance().router;
const goodsId = router?.params?.goodsId;
const orderId = router?.params?.orderId;
const numericGoodsId = useMemo(() => {
const n = goodsId ? Number(goodsId) : undefined
return typeof n === 'number' && Number.isFinite(n) ? n : undefined
}, [goodsId])
const numericOrderId = useMemo(() => {
const n = orderId ? Number(orderId) : undefined
return typeof n === 'number' && Number.isFinite(n) && n > 0 ? n : undefined
}, [orderId])
const isEditMode = !!numericOrderId
const [editingOrder, setEditingOrder] = useState<GltTicketOrder | null>(null)
const editingInitRef = useRef(false)
const userId = useMemo(() => {
const raw = Taro.getStorageSync('UserId')
const id = Number(raw)
return Number.isFinite(id) && id > 0 ? id : undefined
}, [])
type TicketAddressModifyLimit = {
loaded: boolean
canModify: boolean
nextAllowedText?: string
lockedAddressId?: number
}
const [ticketAddressModifyLimit, setTicketAddressModifyLimit] = useState<TicketAddressModifyLimit>({
loaded: false,
canModify: true,
})
const ticketAddressModifyLimitPromiseRef = useRef<Promise<TicketAddressModifyLimit> | null>(null)
const parseTime = (raw?: unknown) => {
if (raw === undefined || raw === null || raw === '') return null
// Compatible with seconds/milliseconds timestamps.
@@ -124,111 +140,22 @@ const OrderConfirm = () => {
return d.isValid() ? d : null
}
const getOrderTime = (o?: Partial<GltTicketOrder> | null) => {
return parseTime(o?.createTime) || parseTime(o?.updateTime)
const clampSendDateToToday = (d: Dayjs) => {
const today = dayjs().startOf('day')
if (!d.isValid()) return today
return d.isBefore(today, 'day') ? today : d.startOf('day')
}
const getOrderAddressKey = (o?: Partial<GltTicketOrder> | null) => {
const id = Number(o?.addressId)
if (Number.isFinite(id) && id > 0) return `id:${id}`
const txt = String(o?.address || '').trim()
if (txt) return `txt:${txt}`
return ''
}
const loadTicketAddressModifyLimit = async (): Promise<TicketAddressModifyLimit> => {
if (ticketAddressModifyLimitPromiseRef.current) return ticketAddressModifyLimitPromiseRef.current
ticketAddressModifyLimitPromiseRef.current = (async () => {
if (!userId) return { loaded: true, canModify: true }
const now = dayjs()
const pageSize = 20
let page = 1
const all: GltTicketOrder[] = []
let latestKey = ''
let latestAddressId: number | undefined = undefined
while (true) {
const res = await pageGltTicketOrder({ page, limit: pageSize, userId })
const list = Array.isArray(res?.list) ? res.list : []
if (page === 1) {
const first = list[0]
latestKey = getOrderAddressKey(first)
const id = Number(first?.addressId)
latestAddressId = Number.isFinite(id) && id > 0 ? id : undefined
}
if (!list.length) break
all.push(...list)
// Find the oldest order in the newest contiguous block of the latest address key.
// That order's time represents the last time user "set/changed" the ticket delivery address.
const currentKey = latestKey
if (!currentKey) {
return { loaded: true, canModify: true }
}
let lastSameIndex = 0
let foundDifferent = false
for (let i = 1; i < all.length; i++) {
const k = getOrderAddressKey(all[i])
if (!k) continue
if (k === currentKey) {
lastSameIndex = i
continue
}
foundDifferent = true
break
}
if (foundDifferent) {
const lastSetAt = getOrderTime(all[lastSameIndex])
if (!lastSetAt) return { loaded: true, canModify: true, lockedAddressId: latestAddressId }
const nextAllowed = lastSetAt.add(ADDRESS_CHANGE_COOLDOWN_DAYS, 'day')
const canModify = now.isAfter(nextAllowed)
return {
loaded: true,
canModify,
nextAllowedText: nextAllowed.format('YYYY-MM-DD'),
lockedAddressId: latestAddressId,
}
}
const oldest = getOrderTime(all[all.length - 1])
if (oldest && now.diff(oldest, 'day') >= ADDRESS_CHANGE_COOLDOWN_DAYS) {
// We have enough history beyond the cooldown window, and still no different address found.
return { loaded: true, canModify: true, lockedAddressId: latestAddressId }
}
const totalCount = typeof (res as any)?.count === 'number' ? Number((res as any).count) : undefined
if (totalCount !== undefined && all.length >= totalCount) break
if (list.length < pageSize) break
page += 1
if (page > 10) break // safety: avoid excessive paging
}
if (!all.length) return { loaded: true, canModify: true }
// If we can't prove the last-set time is older than the cooldown window, be conservative and lock.
const lastSetAt = getOrderTime(all[all.length - 1])
if (!lastSetAt) return { loaded: true, canModify: true, lockedAddressId: latestAddressId }
const nextAllowed = lastSetAt.add(ADDRESS_CHANGE_COOLDOWN_DAYS, 'day')
const canModify = now.isAfter(nextAllowed)
return {
loaded: true,
canModify,
nextAllowedText: nextAllowed.format('YYYY-MM-DD'),
lockedAddressId: latestAddressId,
}
})()
.finally(() => {
ticketAddressModifyLimitPromiseRef.current = null
})
return ticketAddressModifyLimitPromiseRef.current
const isPendingDeliveryOrder = (o?: Partial<GltTicketOrder> | null) => {
if (!o) return false
const ds = (o as any)?.deliveryStatus
const hasProgress = !!o.sendStartTime || !!o.sendEndTime || !!o.receiveConfirmTime
return (
Number((o as any)?.deleted) !== 1 &&
Number(o.status) !== 1 &&
!hasProgress &&
(ds === 10 || (typeof ds !== 'number' && !!o.riderId))
)
}
const getTicketAvailableQty = (t?: Partial<GltUserTicket> | null) => {
@@ -298,19 +225,63 @@ const OrderConfirm = () => {
return !!userId && ticketLoaded && !ticketLoading && usableTickets.length === 0
}, [ticketLoaded, ticketLoading, usableTickets.length, userId])
// After buying tickets and redirecting here, some backends may issue tickets asynchronously.
// If opened with a `goodsId`, retry a few times to refresh tickets.
useEffect(() => {
if (isEditMode) return
if (!numericGoodsId) return
if (!ticketLoaded || ticketLoading) return
if (usableTickets.length > 0) {
ticketAutoRetryCountRef.current = 0
return
}
if (ticketAutoRetryCountRef.current >= 4) return
if (ticketAutoRetryTimerRef.current) return
const delays = [800, 1500, 2500, 4000]
const delay = delays[ticketAutoRetryCountRef.current] ?? 2500
ticketAutoRetryCountRef.current += 1
ticketAutoRetryTimerRef.current = setTimeout(async () => {
ticketAutoRetryTimerRef.current = null
await loadUserTickets()
}, delay)
}, [isEditMode, numericGoodsId, ticketLoaded, ticketLoading, usableTickets.length])
useEffect(() => {
return () => {
if (ticketAutoRetryTimerRef.current) {
clearTimeout(ticketAutoRetryTimerRef.current)
ticketAutoRetryTimerRef.current = null
}
}
}, [])
const maxQuantity = useMemo(() => {
const stockMax = goods?.stock ?? 999
return Math.max(0, Math.min(stockMax, availableTicketTotal))
}, [availableTicketTotal, goods?.stock])
if (!isEditMode) return Math.max(0, Math.min(stockMax, availableTicketTotal))
const original = Number(editingOrder?.totalNum ?? 0)
const originalSafe = Number.isFinite(original) ? original : 0
const ticketId = Number(editingOrder?.userTicketId ?? 0)
const ticketIdSafe = Number.isFinite(ticketId) && ticketId > 0 ? ticketId : undefined
const rawTicket = ticketIdSafe ? (tickets || []).find(t => Number(t?.id) === ticketIdSafe) : undefined
if (!rawTicket) return Math.max(0, Math.min(stockMax, originalSafe))
const avail = getTicketAvailableQty(rawTicket)
const upper = Math.max(0, avail + originalSafe)
return Math.max(0, Math.min(stockMax, upper))
}, [availableTicketTotal, editingOrder?.totalNum, editingOrder?.userTicketId, goods?.stock, isEditMode, tickets])
const canStartOrder = useMemo(() => {
return maxQuantity >= MIN_START_QTY
}, [maxQuantity])
return maxQuantity >= minStartQty
}, [maxQuantity, minStartQty])
const displayQty = useMemo(() => {
if (!canStartOrder) return 0
return Math.max(MIN_START_QTY, Math.min(quantity, maxQuantity))
}, [quantity, maxQuantity, canStartOrder])
return Math.max(minStartQty, Math.min(quantity, maxQuantity))
}, [quantity, maxQuantity, canStartOrder, minStartQty])
const sendTimeText = useMemo(() => {
return dayjs(sendTime).format('YYYY-MM-DD')
@@ -334,18 +305,16 @@ const OrderConfirm = () => {
}
const openAddressPage = async () => {
const limit = ticketAddressModifyLimit.loaded
? ticketAddressModifyLimit
: await loadTicketAddressModifyLimit().catch(() => ({ loaded: true, canModify: true } as TicketAddressModifyLimit))
if (!ticketAddressModifyLimit.loaded) setTicketAddressModifyLimit(limit)
if (!limit.canModify) {
Taro.showToast({
title: `送水地址每${ADDRESS_CHANGE_COOLDOWN_DAYS}天可修改一次${limit.nextAllowedText ? '' + limit.nextAllowedText + ' 后可修改' : ''}`,
icon: 'none',
})
if (isEditMode) {
if (!editingOrder?.id) {
Taro.showToast({ title: '订单信息加载中,请稍后重试', icon: 'none' })
return
}
if (!isPendingDeliveryOrder(editingOrder)) {
Taro.showToast({ title: '该订单当前不可修改', icon: 'none' })
return
}
}
Taro.navigateTo({ url: '/user/address/index' })
}
@@ -579,7 +548,7 @@ const OrderConfirm = () => {
setQuantity(0)
return
}
setQuantity(Math.max(MIN_START_QTY, Math.min(newQuantity || MIN_START_QTY, upper)))
setQuantity(Math.max(minStartQty, Math.min(newQuantity || minStartQty, upper)))
}
const loadUserTickets = async () => {
@@ -623,36 +592,37 @@ const OrderConfirm = () => {
const onSubmit = async () => {
if (submitLoading) return
if (deliveryRangeCheckingRef.current) return
if (!goods?.goodsId) return
// 基础校验
if (!userId) {
Taro.showToast({ title: '请先登录', icon: 'none' })
return
}
if (isEditMode && !editingOrder?.id) {
Taro.showToast({ title: '订单信息加载中,请稍后重试', icon: 'none' })
return
}
if (isEditMode && !isPendingDeliveryOrder(editingOrder)) {
Taro.showToast({ title: '该订单当前不可修改', icon: 'none' })
return
}
if (!address?.id) {
Taro.showToast({ title: '请选择收货地址', icon: 'none' })
return
}
// Ticket delivery address is based on order snapshot. Enforce "once per 30 days" by latest ticket-order history.
const limit = ticketAddressModifyLimit.loaded
? ticketAddressModifyLimit
: await loadTicketAddressModifyLimit().catch(() => ({ loaded: true, canModify: true } as TicketAddressModifyLimit))
if (!ticketAddressModifyLimit.loaded) setTicketAddressModifyLimit(limit)
if (!limit.canModify && limit.lockedAddressId && address.id !== limit.lockedAddressId) {
Taro.showToast({
title: `送水地址每${ADDRESS_CHANGE_COOLDOWN_DAYS}天可修改一次,请使用上次下单地址${limit.nextAllowedText ? '' + limit.nextAllowedText + ' 后可修改)' : ''}`,
icon: 'none',
})
try {
const locked = await getShopUserAddress(limit.lockedAddressId)
if (locked?.id) setAddress(locked)
} catch (_e) {
// ignore: keep current address, but still block submission
}
// 配送方式校验(必选)
if (!deliveryMethod) {
Taro.showToast({ title: '请选择配送方式', icon: 'none' })
return
}
// 步梯场景:必须选择是否送上楼
if (deliveryMethod === 'stairs' && needCarryUpstairs === null) {
Taro.showToast({ title: '请选择是否需要送上楼', icon: 'none' })
return
}
if (!addressHasCoords) {
Taro.showToast({ title: '该收货地址缺少经纬度,请在地址里选择地图定位后重试', icon: 'none' })
return
@@ -672,7 +642,7 @@ const OrderConfirm = () => {
Taro.showToast({ title: '未找到可配送门店,请先在首页选择门店或联系管理员配置门店坐标', icon: 'none' })
return
}
if (availableTicketTotal <= 0) {
if (!isEditMode && availableTicketTotal <= 0) {
Taro.showToast({ title: '暂无可用水票', icon: 'none' })
return
}
@@ -682,30 +652,44 @@ const OrderConfirm = () => {
Taro.showToast({ title: '请选择送水数量', icon: 'none' })
return
}
if (finalQty > availableTicketTotal) {
if (!isEditMode && finalQty > availableTicketTotal) {
Taro.showToast({ title: '水票可用次数不足', icon: 'none' })
return
}
if (goods.stock !== undefined && finalQty > goods.stock) {
if (isEditMode && finalQty > maxQuantity) {
Taro.showToast({ title: '水票可用次数不足', icon: 'none' })
return
}
if (goods?.stock !== undefined && finalQty > goods.stock) {
Taro.showToast({ title: '商品库存不足', icon: 'none' })
return
}
if (finalQty < MIN_START_QTY) {
Taro.showToast({ title: `最低起送 ${MIN_START_QTY}`, icon: 'none' })
if (finalQty < minStartQty) {
Taro.showToast({ title: `最低起送 ${minStartQty}`, icon: 'none' })
return
}
if (!sendTime) {
Taro.showToast({ title: '请选择配送时间', icon: 'none' })
return
}
if (dayjs(sendTime).isBefore(dayjs().startOf('day'), 'day')) {
Taro.showToast({ title: '配送时间不能早于今天', icon: 'none' })
setSendTime(dayjs().startOf('day').toDate())
return
}
// 配送范围校验(电子围栏)
const ok = await ensureInDeliveryRange()
if (!ok) return
const deliveryFee = getDeliveryFee()
const confirmContent = isEditMode
? `配送时间:${sendTimeText}\n送水数量${finalQty}\n配送方式${deliveryMethod === 'elevator' ? '电梯' : deliveryMethod === 'stairs' ? '步梯' : '一楼商铺/其他'}${deliveryMethod === 'stairs' && needCarryUpstairs && deliveryFloor > 1 ? `${deliveryFloor}楼)` : deliveryMethod === 'stairs' && !needCarryUpstairs ? '(不送上楼)' : ''}\n${deliveryFee > 0 ? `配送费:¥${deliveryFee.toFixed(2)}\n` : ''}是否确认修改?`
: `配送时间:${sendTimeText}\n配送方式${deliveryMethod === 'elevator' ? '电梯' : deliveryMethod === 'stairs' ? '步梯' : '一楼商铺/其他'}${deliveryMethod === 'stairs' && needCarryUpstairs && deliveryFloor > 1 ? `${deliveryFloor}楼)` : deliveryMethod === 'stairs' && !needCarryUpstairs ? '(不送上楼)' : ''}\n${deliveryFee > 0 ? `配送费:¥${deliveryFee.toFixed(2)}\n` : ''}将使用 ${finalQty} 张水票下单(优先使用可用数量少的水票),送水 ${finalQty} 桶,是否确认?`
const confirmRes = await Taro.showModal({
title: '确认下单',
content: `配送时间:${sendTimeText}\n将使用 ${finalQty} 张水票下单(优先使用可用数量少的水票),送水 ${finalQty} 桶,是否确认?`
title: isEditMode ? '确认修改' : '确认下单',
content: confirmContent
})
if (!confirmRes.confirm) return
@@ -713,13 +697,24 @@ const OrderConfirm = () => {
setSubmitLoading(true)
Taro.showLoading({ title: '提交中...' })
if (isEditMode) {
await updateGltTicketOrder({
id: editingOrder?.id,
addressId: address.id,
totalNum: finalQty,
buyerRemarks: orderRemark,
sendTime: dayjs(sendTime).startOf('day').format('YYYY-MM-DD HH:mm:ss'),
deliveryMethod,
deliveryFloor: deliveryMethod === 'stairs' && needCarryUpstairs ? deliveryFloor : undefined,
deliveryFee: getDeliveryFee() || undefined
})
} else {
// Best-effort auto dispatch rider. If it fails, backend/manual dispatch can still handle it.
const autoRider = storeForOrder.id ? await resolveAutoRiderForStore(storeForOrder.id) : null
// Split this "delivery" into multiple ticket orders (backend order model binds to one userTicketId).
// Consume tickets with smaller available qty first.
let remain = finalQty
let created = 0
for (const t of ticketsToConsume) {
if (remain <= 0) break
const avail = getTicketAvailableQty(t)
@@ -737,27 +732,31 @@ const OrderConfirm = () => {
riderId: Number.isFinite(Number(autoRider?.userId)) ? Number(autoRider?.userId) : undefined,
riderName: autoRider?.realName,
riderPhone: autoRider?.mobile,
comments: goods.name ? `立即送水:${goods.name}` : '立即送水'
comments: goods?.name ? `立即送水:${goods.name}` : '立即送水',
// 配送方式信息
deliveryMethod,
deliveryFloor: deliveryMethod === 'stairs' && needCarryUpstairs ? deliveryFloor : undefined,
deliveryFee: getDeliveryFee() || undefined
})
remain -= useQty
created += 1
}
if (remain > 0) {
// Ticket counts might have changed between loading and submission.
throw new Error('水票可用次数不足,请刷新后重试')
}
}
await loadUserTickets()
Taro.showToast({ title: created > 1 ? '下单成功(已拆分多张水票)' : '下单成功', icon: 'success' })
Taro.showToast({ title: isEditMode ? '修改成功' : '下单成功', icon: 'success' })
setTimeout(() => {
// 跳转到“我的送水订单”
Taro.redirectTo({ url: '/user/ticket/index?tab=order' })
}, 800)
} catch (e: any) {
console.error('水票下单失败:', e)
Taro.showToast({ title: e?.message || '下单失败', icon: 'none' })
console.error(isEditMode ? '送水订单修改失败:' : '水票下单失败:', e)
Taro.showToast({ title: e?.message || (isEditMode ? '修改失败' : '下单失败'), icon: 'none' })
} finally {
Taro.hideLoading()
setSubmitLoading(false)
@@ -772,11 +771,28 @@ const OrderConfirm = () => {
if (!opts?.silent) setLoading(true)
setError('')
const [goodsRes, addressRes] = await Promise.all([
numericGoodsId ? getShopGoods(numericGoodsId) : Promise.resolve(null),
listShopUserAddress({ isDefault: true })
const [addressRes, editingOrderRes, goodsByParam] = await Promise.all([
listShopUserAddress({ isDefault: true }),
numericOrderId ? getGltTicketOrder(numericOrderId) : Promise.resolve(null),
numericGoodsId ? getShopGoods(numericGoodsId) : Promise.resolve(null)
])
let goodsRes = goodsByParam
if (!goodsRes && editingOrderRes?.userTicketId) {
const ticketId = Number(editingOrderRes.userTicketId)
if (Number.isFinite(ticketId) && ticketId > 0) {
try {
const ticket = await getGltUserTicket(ticketId)
const gid = Number((ticket as any)?.goodsId)
if (Number.isFinite(gid) && gid > 0) {
goodsRes = await getShopGoods(gid)
}
} catch (e) {
console.error('加载订单关联商品失败:', e)
}
}
}
// 设置商品信息
if (goodsRes) {
setGoods(goodsRes)
@@ -788,18 +804,51 @@ const OrderConfirm = () => {
setAddress(addressRes[0])
}
// Load ticket-order history to enforce "address can be modified once per 30 days".
// If currently locked, force using last ticket-order address (snapshot) to avoid getting stuck with a new default address.
try {
const limit = await loadTicketAddressModifyLimit()
setTicketAddressModifyLimit(limit)
if (!limit.canModify && limit.lockedAddressId) {
const locked = await getShopUserAddress(limit.lockedAddressId)
if (locked?.id) setAddress(locked)
if (numericOrderId && editingOrderRes && !editingInitRef.current) {
editingInitRef.current = true
setEditingOrder(editingOrderRes)
Taro.setNavigationBarTitle({ title: '订单确认' })
const isPending = isPendingDeliveryOrder(editingOrderRes)
if (!isPending) {
Taro.showToast({ title: '该订单当前不可修改', icon: 'none' })
setTimeout(() => {
Taro.navigateBack()
}, 600)
return
}
const initQty = Number(editingOrderRes.totalNum ?? minStartQty)
setQuantity(Number.isFinite(initQty) && initQty > 0 ? initQty : minStartQty)
setOrderRemark(String(editingOrderRes.buyerRemarks || ''))
const st = parseTime(editingOrderRes.sendTime)
if (st) setSendTime(clampSendDateToToday(st).toDate())
// 回显配送方式
if (editingOrderRes.deliveryMethod) {
setDeliveryMethod(editingOrderRes.deliveryMethod)
if (editingOrderRes.deliveryMethod === 'stairs') {
const hasFloor = editingOrderRes.deliveryFloor && editingOrderRes.deliveryFloor > 1
setNeedCarryUpstairs(hasFloor)
if (hasFloor) setDeliveryFloor(editingOrderRes.deliveryFloor)
}
}
const addrId = Number(editingOrderRes.addressId)
const addrIdSafe = Number.isFinite(addrId) && addrId > 0 ? addrId : undefined
if (addrIdSafe) {
const hit = addressRes?.find(a => Number(a?.id) === addrIdSafe)
if (hit?.id) {
setAddress(hit)
} else {
try {
const addr = await getShopUserAddress(addrIdSafe)
if (addr?.id) setAddress(addr)
} catch (e) {
console.error('加载送水地址修改限制失败:', e)
setTicketAddressModifyLimit({ loaded: true, canModify: true })
console.error('加载订单收货地址失败:', e)
}
}
}
}
// Tickets are non-blocking for first paint; load in background.
loadUserTickets()
@@ -819,6 +868,11 @@ const OrderConfirm = () => {
useDidShow(() => {
// 返回/切换到该页面时,刷新一下当前已选门店
setSelectedStore(getSelectedStoreFromStorage())
ticketAutoRetryCountRef.current = 0
if (ticketAutoRetryTimerRef.current) {
clearTimeout(ticketAutoRetryTimerRef.current)
ticketAutoRetryTimerRef.current = null
}
loadAllData({ silent: hasInitialLoadedRef.current })
})
@@ -878,10 +932,6 @@ const OrderConfirm = () => {
// When user changes the delivery address to an out-of-fence one, prompt immediately (once per address).
const outOfRangePromptedAddressIdRef = useRef<number | undefined>(undefined)
useEffect(() => {
// Only prompt when user is allowed to change the ticket delivery address.
// Otherwise this toast is noisy (they can't fix it within the cooldown window).
if (!ticketAddressModifyLimit.loaded) return
if (!ticketAddressModifyLimit.canModify) return
const id = address?.id
if (!id) return
if (deliveryRangeCheckedAddressId !== id) return
@@ -893,40 +943,83 @@ const OrderConfirm = () => {
address?.id,
addressHasCoords,
deliveryRangeCheckedAddressId,
inDeliveryRange,
ticketAddressModifyLimit.loaded,
ticketAddressModifyLimit.canModify
inDeliveryRange
])
// When tickets/stock change, clamp quantity into [0..maxQuantity].
useEffect(() => {
setQuantity(prev => {
if (maxQuantity <= 0) return 0
if (maxQuantity < MIN_START_QTY) return 0
if (!prev || prev < MIN_START_QTY) return MIN_START_QTY
if (maxQuantity < minStartQty) return 0
if (!prev || prev < minStartQty) return minStartQty
return Math.min(prev, maxQuantity)
})
}, [maxQuantity])
}, [maxQuantity, minStartQty])
const minStartQtyKey = useMemo(() => {
const gid = Number(goods?.goodsId)
if (Number.isFinite(gid) && gid > 0) return `g:${gid}`
// If there is exactly one ticket template available, infer min start qty from it (covers "稍后再送" without goodsId).
const ids = Array.from(
new Set(
(usableTickets || [])
.map(t => Number(t?.templateId))
.filter(id => Number.isFinite(id) && id > 0)
)
)
if (ids.length === 1) return `t:${ids[0]}`
return ''
}, [goods?.goodsId, usableTickets])
// Use configured min start-send qty from ticket template (by goodsId or by user's unique templateId).
useEffect(() => {
let cancelled = false
;(async () => {
try {
if (!minStartQtyKey) {
setMinStartQty(DEFAULT_MIN_START_QTY)
return
}
const [kind, rawId] = minStartQtyKey.split(':')
const id = Number(rawId)
const tpl =
kind === 'g'
? await getGltTicketTemplateByGoodsId(id)
: await getGltTicketTemplate(id)
const n = Number(tpl?.startSendQty)
const safe = Number.isFinite(n) && n > 0 ? n : DEFAULT_MIN_START_QTY
if (!cancelled) setMinStartQty(safe)
} catch (_e) {
if (!cancelled) setMinStartQty(DEFAULT_MIN_START_QTY)
}
})()
return () => {
cancelled = true
}
}, [minStartQtyKey])
// If user has no usable tickets, proactively guide them to purchase (only once per page lifecycle).
useEffect(() => {
if (!noUsableTickets) return
// Editing an existing order: don't interrupt with "no tickets" prompt.
if (isEditMode) return
if (noTicketPromptedRef.current) return
noTicketPromptedRef.current = true
;(async () => {
const r = await Taro.showModal({
title: '暂无可用水票',
content: '您当前没有可用水票,购买后再来下单更方便。',
confirmText: '去购买',
cancelText: '暂不'
})
if (r.confirm) {
await goBuyTickets()
}
})()
// ;(async () => {
// const r = await Taro.showModal({
// title: '暂无可用水票',
// content: '您当前没有可用水票,购买后再来下单更方便。',
// confirmText: '去购买',
// cancelText: '暂不'
// })
// if (r.confirm) {
// await goBuyTickets()
// }
// })()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [noUsableTickets])
}, [noUsableTickets, isEditMode])
// 重新加载数据
const handleRetry = () => {
@@ -946,7 +1039,7 @@ const OrderConfirm = () => {
}
// 加载状态
if (loading || !goods) {
if (loading) {
return <OrderConfirmSkeleton/>
}
@@ -991,12 +1084,6 @@ const OrderConfirm = () => {
</Space>
<View className={'pt-1 pb-3'}>
<View className={'text-gray-500'}>{address.name} {address.phone}</View>
{ticketAddressModifyLimit.loaded && !ticketAddressModifyLimit.canModify && (
<View className={'pt-1 text-xs text-orange-500 hidden'}>
{ADDRESS_CHANGE_COOLDOWN_DAYS}
{ticketAddressModifyLimit.nextAllowedText ? `${ticketAddressModifyLimit.nextAllowedText} 后可修改` : ''}
</View>
)}
</View>
</View>
</Space>
@@ -1013,13 +1100,110 @@ const OrderConfirm = () => {
)}
</CellGroup>
{/* 配送方式选择(必选) */}
<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={'text-sm'}>{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'}>
{displayQty} x {deliveryFloor - 1} = {getDeliveryFee().toFixed(2)}
</Text>
</View>
)}
</View>
)}
</View>
</Cell>
</CellGroup>
<CellGroup>
<Cell
title={'配送时间'}
extra={(
<Picker
mode="date"
start={dayjs().format('YYYY-MM-DD')}
value={dayjs(sendTime).format('YYYY-MM-DD')}
onChange={(e) => {
const v = (e as any)?.detail?.value
const d = dayjs(v)
if (!d.isValid()) return
if (d.isBefore(dayjs().startOf('day'), 'day')) {
Taro.showToast({ title: '配送时间不能早于今天', icon: 'none' })
setSendTime(dayjs().startOf('day').toDate())
return
}
setSendTime(d.startOf('day').toDate())
}}
>
<View className={'flex items-center gap-2'}>
<View className={'text-gray-900'}>{sendTimeText}</View>
<ArrowRight className={'text-gray-400'} size={14} />
</View>
</Picker>
)}
/>
</CellGroup>
@@ -1029,16 +1213,16 @@ const OrderConfirm = () => {
title={'送水数量'}
description={
canStartOrder
? `最低起送 ${MIN_START_QTY}`
: `最低起送 ${MIN_START_QTY} 桶(当前最多 ${maxQuantity} 桶)`
? `最低起送 ${minStartQty}`
: `最低起送 ${minStartQty} 桶(当前最多 ${maxQuantity} 桶)`
}
extra={(
<ConfigProvider theme={customTheme}>
<InputNumber
value={displayQty}
min={canStartOrder ? MIN_START_QTY : 0}
min={canStartOrder ? minStartQty : 0}
max={canStartOrder ? maxQuantity : 0}
step={10}
step={minStartQty >= 10 ? 10 : 1}
readOnly
disabled={!canStartOrder}
onChange={handleQuantityChange}
@@ -1080,7 +1264,7 @@ const OrderConfirm = () => {
await loadUserTickets()
return
}
if (noUsableTickets) {
if (noUsableTickets && !isEditMode) {
const r = await Taro.showModal({
title: '暂无可用水票',
content: '您还没有可用水票,是否前往购买?',
@@ -1093,7 +1277,7 @@ const OrderConfirm = () => {
setTicketPopupVisible(true)
}}
/>
{noUsableTickets && (
{(noUsableTickets && !isEditMode) && (
<Cell
title={<Text className="text-gray-500"></Text>}
description="购买水票后即可在这里直接下单送水"
@@ -1169,8 +1353,11 @@ const OrderConfirm = () => {
<View className="py-10 text-center">
<Empty description="暂无可用水票" />
<View className="mt-4 flex justify-center">
<Button type="primary" onClick={goBuyTickets}>
<Button
type="primary"
onClick={isEditMode ? () => setTicketPopupVisible(false) : goBuyTickets}
>
{isEditMode ? '确定修改' : '确定下单'}
</Button>
</View>
</View>
@@ -1238,6 +1425,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'}>
{displayQty} x {deliveryFloor - 1} = <Text className={'text-red-500 font-bold'}>{(displayQty * (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'}>
@@ -1250,11 +1480,16 @@ const OrderConfirm = () => {
</span>
<span className={'text-sm text-gray-500'}></span>
</View>
{getDeliveryFee() > 0 && (
<View className={'text-xs text-orange-500'}>
{getDeliveryFee().toFixed(2)}
</View>
)}
</div>
<div className={'buy-btn mx-4'}>
{noUsableTickets ? (
{noUsableTickets && !isEditMode ? (
<Button type="primary" size="large" onClick={goBuyTickets}>
{isEditMode ? '确定修改' : '确定下单'}
</Button>
) : (
<Button
@@ -1266,8 +1501,10 @@ const OrderConfirm = () => {
!address?.id ||
!addressHasCoords ||
(deliveryRangeCheckedAddressId === address?.id && inDeliveryRange === false) ||
availableTicketTotal <= 0 ||
!canStartOrder
(!isEditMode && availableTicketTotal <= 0) ||
!canStartOrder ||
!deliveryMethod ||
(deliveryMethod === 'stairs' && needCarryUpstairs === null)
}
onClick={onSubmit}
>
@@ -1279,7 +1516,13 @@ const OrderConfirm = () => {
? '地址缺少定位'
: ((deliveryRangeCheckedAddressId === address?.id && inDeliveryRange === false)
? '不在配送范围'
: (submitLoading ? '提交中...' : '立即提交')
: (!deliveryMethod
? '请选配送方式'
: (deliveryMethod === 'stairs' && needCarryUpstairs === null
? '请选是否送上楼'
: (submitLoading ? '提交中...' : (isEditMode ? '确定修改' : '立即提交'))
)
)
)
)
)

View File

@@ -5,6 +5,7 @@ import { getSelectedStoreFromStorage, getSelectedStoreIdFromStorage } from '@/ut
import type { ShopStoreRider } from '@/api/shop/shopStoreRider/model';
import type { ShopStoreWarehouse } from '@/api/shop/shopStoreWarehouse/model';
import request from '@/utils/request';
import { getWxOpenId, getUserInfo } from '@/api/layout';
/**
* 支付类型枚举
@@ -19,7 +20,8 @@ export enum PaymentType {
* 支付结果回调
*/
export interface PaymentCallback {
onSuccess?: () => void;
// Return `false` to skip default "支付成功" toast + redirect.
onSuccess?: () => void | boolean | Promise<void | boolean>;
onError?: (error: string) => void;
onComplete?: () => void;
}
@@ -32,6 +34,74 @@ export class PaymentHandler {
private static storeRidersCache = new Map<number, ShopStoreRider[]>();
private static warehousesCache: ShopStoreWarehouse[] | null = null;
/**
* 【关键修复】支付前确保当前微信用户的 openid 已正确绑定到后端
*
* 问题场景:
* 1. 用户通过手机号注册 → openid 未绑定 → 后端用空 openid 创建预支付单 → 支付时报"账号不一致"
* 2. 用户切换了微信账号 → 本地缓存的用户信息是旧账号的 → openid 与当前支付人不匹配
* 3. 自动登录(loginByOpenId)未触发 getWxOpenId 绑定流程
*
* 解决方案:每次微信支付前,重新获取 wx.login code 并调用 getWxOpenId 绑定,
* 确保后端记录的 openid = 当前实际支付人的 openid
*/
private static async ensureOpenIdBeforePay(): Promise<void> {
try {
// 非微信环境跳过(如 H5 开发调试)
let isWeapp = false;
try {
isWeapp = Taro.getEnv() === Taro.ENV_TYPE.WEAPP;
} catch (_e) {
isWeapp = process.env.TARO_ENV === 'weapp';
}
if (!isWeapp) return;
// 获取当前登录用户的最新信息(检查是否已有 openid
let currentUser = null;
try {
currentUser = await getUserInfo();
} catch (_e) {
// getUserInfo 失败时不阻塞支付(可能是 token 过期等),让后续接口自行报错
console.warn('[ensureOpenId] 获取用户信息失败,跳过 openid 校验');
return;
}
// 如果用户已有 openid仍然需要刷新因为用户可能切换了微信账号
// 每次都重新获取 code + 绑定,确保 openid 是当前微信会话的
const code = await new Promise<string | undefined>((resolve, reject) => {
Taro.login({
success: (res) => resolve(res.code as string),
fail: (e) => reject(e),
timeout: 5000, // 5秒超时
});
});
if (!code) {
console.warn('[ensureOpenId] wx.login 未返回 code');
return;
}
// 调用后端绑定/更新 openid
await getWxOpenId({ code });
console.log('[ensureOpenId] openid 刷新/绑定成功');
// 同步本地 User 缓存,确保后续逻辑能读到最新 openid
try {
const freshUser = await getUserInfo();
if (freshUser) {
Taro.setStorageSync('User', freshUser);
}
} catch (_e) {
// ignore: 服务端已更新 openid本地缓存不同步也不影响本次支付
}
} catch (error) {
// openid 刷新失败不阻塞支付流程(可能网络波动),
// 但记录日志方便排查;若确实不一致,微信支付侧会报错
console.warn('[ensureOpenId] openid 刷新失败(非阻塞):', error);
}
}
/**
* 执行支付
* @param orderData 订单数据
@@ -79,6 +149,12 @@ export class PaymentHandler {
// 设置支付类型
orderData.payType = paymentType;
// 【关键修复】微信支付前,强制刷新/绑定当前微信用户的 openid
// 防止"下单账号与支付账号不一致"错误
if (paymentType === PaymentType.WECHAT) {
await this.ensureOpenIdBeforePay();
}
console.log('创建订单请求:', orderData);
// 创建订单
@@ -118,17 +194,27 @@ export class PaymentHandler {
if (paymentSuccess) {
console.log('支付成功,订单号:', result.orderNo);
// 先收起 loading避免遮挡 modal/toast
try {
Taro.hideLoading();
} catch (_e) {
// ignore
}
const onSuccessResult = await callback?.onSuccess?.();
const skipDefaultSuccessBehavior = onSuccessResult === false;
if (!skipDefaultSuccessBehavior) {
Taro.showToast({
title: '支付成功',
icon: 'success'
});
callback?.onSuccess?.();
// 跳转到订单页面
setTimeout(() => {
Taro.navigateTo({ url: '/user/order/order' });
}, 2000);
}
} else {
throw new Error('支付未完成');
}
@@ -465,6 +551,8 @@ export function buildSingleGoodsOrder(
specInfo?: string;
buyerRemarks?: string;
sendStartTime?: string;
deliveryMethod?: string;
deliveryFloor?: number;
}
): OrderCreateRequest {
return {
@@ -482,7 +570,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
};
}

View File

@@ -3,9 +3,11 @@ import {User} from "@/api/system/user/model";
// 模版套餐ID - 请根据实际情况修改
export const TEMPLATE_ID = '10584';
// 服务接口 - 请根据实际情况修改
export const SERVER_API_URL = 'https://glt-server.websoft.top/api';
// export const SERVER_API_URL = 'http://127.0.0.1:8000/api';
// 服务接口 - 从环境配置读取
// @ts-ignore
export const SERVER_API_URL = process.env.TARO_ENV === 'production'
? 'https://glt-server.websoft.top/api'
: 'https://glt-server.websoft.top/api';
/**
* 保存用户信息到本地存储
* @param token

View File

@@ -0,0 +1,65 @@
import type { ShopOrder } from '@/api/shop/shopOrder/model';
const toNum = (value: unknown): number | undefined => {
if (value === null || value === undefined || value === '') return undefined;
const n = Number(value);
return Number.isFinite(n) ? n : undefined;
};
export const isShopOrderCompleted = (order: Pick<ShopOrder, 'orderStatus'>): boolean =>
toNum(order?.orderStatus) === 1;
export const getShopOrderStatusText = (order: ShopOrder): string => {
const orderStatus = toNum(order?.orderStatus);
const deliveryStatus = toNum(order?.deliveryStatus);
const riderId = toNum(order?.riderId);
if (orderStatus === 2) return '已取消';
if (orderStatus === 3) return '取消中';
if (orderStatus === 4) return '退款申请中';
if (orderStatus === 5) return '退款被拒绝';
if (orderStatus === 6) return '退款成功';
if (orderStatus === 7) return '客户端申请退款';
if (orderStatus === 1) return '已完成';
if (!order?.payStatus) return '等待买家付款';
if (deliveryStatus === 10) return '待发货';
if (deliveryStatus === 20) {
if (!riderId || riderId === 0) return '待收货';
if (order?.sendEndTime) return '待确认收货';
return '配送中';
}
if (deliveryStatus === 30) return '部分发货';
if (orderStatus === 0) return '未使用';
return '未知状态';
};
export const getShopOrderStatusColor = (order: ShopOrder): string => {
const orderStatus = toNum(order?.orderStatus);
const deliveryStatus = toNum(order?.deliveryStatus);
const riderId = toNum(order?.riderId);
if (orderStatus === 2) return 'text-gray-500'; // 已取消
if (orderStatus === 3) return 'text-orange-500'; // 取消中
if (orderStatus === 4) return 'text-orange-500'; // 退款申请中
if (orderStatus === 5) return 'text-red-500'; // 退款被拒绝
if (orderStatus === 6) return 'text-green-500'; // 退款成功
if (orderStatus === 7) return 'text-orange-500'; // 客户端申请退款
if (orderStatus === 1) return 'text-green-600'; // 已完成
if (!order?.payStatus) return 'text-orange-500'; // 等待买家付款
if (deliveryStatus === 10) return 'text-blue-500'; // 待发货
if (deliveryStatus === 20) {
if (!riderId || riderId === 0) return 'text-purple-500'; // 待收货
if (order?.sendEndTime) return 'text-purple-500'; // 待确认收货
return 'text-blue-500'; // 配送中
}
if (deliveryStatus === 30) return 'text-blue-500'; // 部分发货
if (orderStatus === 0) return 'text-gray-500'; // 未使用
return 'text-gray-600';
};

115
src/utils/userLevel.ts Normal file
View 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;