Compare commits

...

53 Commits

Author SHA1 Message Date
24d28d0aaa fix(order): 优化收货地址及收货人信息处理逻辑
- 切换开发环境,方便本地调试
- 在订单模型中新增收货人姓名和手机号码字段
- 订单列表中优先显示收货人信息,fallback为客户昵称和电话
- 订单编辑与新建时优化收货地址选择逻辑
- 编辑模式优先使用默认地址,新建模式使用订单关联地址
- 异步获取地址失败时添加错误日志方便排查
2026-05-06 17:46:30 +08:00
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
81c63e0e65 feat(order): 添加订单自动取消过期功能
- 在OrderList组件中新增autoCancelExpired和paymentTimeoutHours属性
- 实现支付过期订单的自动检测和取消逻辑
- 添加parseTime和isOrderPaymentExpiredSafe辅助函数
- 使用useRef管理自动取消状态避免重复执行
- 支持单次最多处理20笔过期订单避免接口风暴
- 区分resetPage和loadMore场景下的列表状态同步
- 更新useEffect依赖数组包含新的属性参数
2026-03-08 13:08:35 +08:00
86f7506422 fix(order): 解决支付取消后加载状态未正确重置问题
- 在支付流程中添加 skipFinallyResetPayLoading 标志来控制加载状态
- 检测用户取消支付情况并跳转到待付款订单列表页面
- 优化支付取消后的页面导航逻辑,支持 redirectTo 和 navigateTo 两种方式
- 修改订单列表中的按钮文案"取消订单"为"取消"
- 修改订单列表中的按钮文案"立即支付"为"继续支付"
2026-03-08 10:41:00 +08:00
fae144549e style(goods): 商品列表卡片样式优化
- 为商品图片添加固定宽高和圆角样式
- 在政企采购专区卡片上添加点击跳转功能
- 在桂乐淘福利区卡片上添加点击跳转功能
- 为两个专区卡片添加箭头图标指示跳转
- 从 NutUI 图标库导入箭头图标组件
2026-03-07 16:13:52 +08:00
718eddff63 refactor(home): 移除未使用的导航功能
- 删除未使用的 navTo 导入语句
- 注释掉政企采购专区的点击跳转功能
- 注释掉桂乐淘福利惊爆区的点击跳转功能
- 保留卡片展示样式但移除交互逻辑
2026-03-07 15:41:49 +08:00
a4a0a922fc refactor(home): 移除未使用的导航功能
- 删除未使用的 navTo 导入语句
- 注释掉政企采购专区的点击跳转功能
- 注释掉桂乐淘福利惊爆区的点击跳转功能
- 保留卡片展示样式但移除交互逻辑
2026-03-07 15:41:00 +08:00
ca2436a2e8 feat(shop): 商品列表组件重构并优化页面展示
- 新增 GoodsList.scss 样式文件,实现网格布局和商品卡片样式
- 重构 GoodsList.tsx 组件,使用新的样式结构和 ShopGoods 类型
- 移除 Share 图标依赖,简化购买按钮设计
- 注释掉首页的桶装水和水票套餐分类入口
- 更新政企采购专区跳转链接至正确分类ID
- 在商品列表页面添加空状态显示组件
- 修改商品列表请求参数,增加状态过滤条件
2026-03-07 15:39:31 +08:00
83ba49d860 fix(home): 更新首页商品分类配置并优化跳转功能
- 修改开发环境API_BASE_URL配置
- 引入通用导航工具函数navTo
- 更新商品分类标签名称为桶装水和水票套餐
- 添加政企采购专区卡片组件
- 集成桂乐淘福利惊爆区跳转功能
- 修复页面布局结构中的多余空行问题
2026-03-07 15:20:16 +08:00
7375a3b1ce fix(order): 修复订单退款功能并调整开发环境配置
- 将开发环境API地址切换回本地服务
- 移除订单详情页面中的退款接口导入
- 将退款操作改为更新订单状态方式实现
- 注释掉用户页面底部版本号显示
2026-03-07 13:13:44 +08:00
756b548bf9 fix(dealer): 修复提现页面跳转和提示图标问题
- 将提现金额超限提示的图标从 'error' 改为 'none'
- 修复经销商首页可提现金额区域的点击跳转功能
- 移除重复的 onClick 事件绑定,统一在父容器上处理点击事件
2026-03-07 02:08:42 +08:00
76e76c62ef fix(withdraw): 修复经销商提现页面类型定义和渲染逻辑
- 将 activeTab 状态类型从 string | number 限定为 string
- 统一 Tab 切换处理中的值转换逻辑,避免类型不一致问题
- 修复条件判断中的字符串比较,确保类型安全
- 调整组件渲染方式,改为按需渲染当前选中的标签页内容
- 更新骑手页面工资统计标题为配送提成以匹配实际业务逻辑
2026-03-07 02:01:10 +08:00
546d90cc28 feat(app): 更新版权信息和分类标题
- 修改版权信息从公司名称变更为品牌宣传语
- 更新首页分类标签名称为政企采购专区和桂乐淘福利惊爆区
- 调整用户页脚版权显示格式,移除年份和Copyright标识
2026-03-07 01:44:12 +08:00
d4fd61376c fix(dealer): 更新页面标题和文案内容
- 将导航栏标题从"桂乐淘分享中心"改为"账户管理中心"
- 移除提现金额表单项的必填标识
- 修复提现注意事项中的错别字"再"改为"在"
- 统一更新用户组件中的页面标题显示
- 将用户网格组件中的"我的服务"改为"桂乐淘服务中心"
2026-03-07 01:38:27 +08:00
b27421fd6e fix(dealer): 更新佣金状态文案并修复商品价格显示
- 将佣金统计中的"冻结中"改为"待使用"
- 为可提现金额添加点击跳转到提现页面的功能
- 更新商品详情页价格字段从price改为buyingPrice
- 注释掉首页商品卡片中的买水票优惠按钮
- 在商品详情页价格后添加单位显示
2026-03-07 01:13:51 +08:00
b929b8d35e feat(user): 添加地址编辑时的地区锁定功能
- 新增 regionLocked 状态管理地区锁定状态
- 编辑模式下有经纬度时自动锁定地区,防止被识别覆盖
- 地图选点后锁定地区并验证省市区完整性
- 锁定状态下点击地区选择器显示提示信息
- 表单提交前验证必填的省市区字段
- 使用 View 组件替换 div 优化 Taro 兼容性
- 识别成功时根据锁定状态显示不同提示文案
2026-03-06 11:39:47 +08:00
23af704c68 fix(shop): 修复订单确认页面数量输入组件逻辑
- 调整步长设置逻辑,根据最小购买数量动态设置步长值
- 移除票据模板激活时的禁用条件限制
- 简化数量输入框的禁用状态判断逻辑
2026-03-01 12:22:23 +08:00
ab61aa9ee0 fix(user): 修复用户页面组件状态刷新和水票余额显示问题
- 在订单确认页面添加水票模板购买数量限制逻辑
- 为用户页面添加dealerViewKey状态确保子组件正确刷新
- 交换用户卡片中水票和余额的位置显示正确数据
- 移除自动跳转邀请注册页面逻辑改用显式跳转
- 添加预期失败场景的日志过滤避免不必要的错误输出
2026-02-28 20:28:18 +08:00
64d30e1b62 fix(user): 修复用户自动登录和输入组件配置问题
- 修改导航栏标题从"成为经销商"为"注册成为会员"
- 隐藏团队页面中的手机号显示功能
- 为订单确认页面的数量输入组件添加步长和只读属性
- 为用户票券页面的数量输入组件添加步长和只读属性
- 移除未使用的getStoredInviteParams导入
- 优化自动登录失败处理逻辑,避免不必要的页面跳转
- 添加错误消息过滤,避免预期失败情况下的控制台刷屏
2026-02-28 20:10:33 +08:00
a8eb9e11be feat(dealer): 添加订单解冻状态和订单状态显示功能
- 在订单模型中新增佣金解冻字段和订单状态字段
- 扩展订单状态判断逻辑支持解冻状态和订单取消状态
- 更新订单状态颜色映射适配新的状态类型
- 修改订单组件中的状态显示以支持新字段
- 优化订单状态文本和颜色渲染逻辑
2026-02-28 19:42:56 +08:00
338dc421db fix(ticket): 修复配送地址超出范围提示问题
- 仅在用户允许修改门票配送地址时显示提示
- 避免在冷却窗口期间显示冗余提示
- 添加对地址修改限制状态的检查
- 更新 useEffect 依赖数组以包含地址修改限制状态
2026-02-28 00:40:38 +08:00
6f1e0a6a2b feat(ticket): 添加送水地址修改限制功能
- 引入 ADDRESS_CHANGE_COOLDOWN_DAYS 常量设置30天修改间隔
- 新增 ticketAddressModifyLimit 状态管理地址修改权限
- 实现 loadTicketAddressModifyLimit 函数查询订单历史判断修改限制
- 添加 openAddressPage 函数控制地址页面跳转逻辑
- 在提交订单时验证地址修改限制并显示提示信息
- 初始化时加载地址修改限制并强制使用锁定地址
- 更新地址单元格点击事件为 openAddressPage 函数
- 添加地址修改限制状态显示到界面
2026-02-28 00:38:04 +08:00
58 changed files with 3451 additions and 733 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

@@ -9,6 +9,7 @@ export const BaseUrl = API_BASE_URL;
// 当前版本 // 当前版本
export const Version = 'v3.0.8'; export const Version = 'v3.0.8';
// 版权信息 // 版权信息
export const Copyright = 'WebSoft Inc.'; export const Copyright = '桂乐淘·购享无界 乐惠万家';
// export const Copyright = '测试环境 v3.2.6';
// java -jar CertificateDownloader.jar -k 0kF5OlPr482EZwtn9zGufUcqa7ovgxRL -m 1723321338 -f ./apiclient_key.pem -s 2B933F7C35014A1C363642623E4A62364B34C4EB -o ./ // 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 = 'development'
// ===========================================================
export const ENV_CONFIG = { export const ENV_CONFIG = {
// 开发环境 // 开发环境
development: { development: {
// API_BASE_URL: 'http://127.0.0.1:9200/api',
API_BASE_URL: 'https://glt-api.websoft.top/api', API_BASE_URL: 'https://glt-api.websoft.top/api',
SERVER_API_URL: 'https://glt-server.websoft.top/api',
APP_NAME: '开发环境', APP_NAME: '开发环境',
DEBUG: 'true', 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: { production: {
API_BASE_URL: 'https://glt-api.websoft.top/api', API_BASE_URL: 'https://glt-api.websoft.top/api',
SERVER_API_URL: 'https://glt-server.websoft.top/api',
APP_NAME: '桂乐淘', APP_NAME: '桂乐淘',
DEBUG: 'false', 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() { export function getEnvConfig() {
const env = process.env.NODE_ENV || 'development' return ENV_CONFIG[CURRENT_ENV]
if (env === 'production') {
return ENV_CONFIG.production
} else { // @ts-ignore
if (env === 'test') {
return ENV_CONFIG.test
} else {
return ENV_CONFIG.development
}
}
} }
// 导出环境变量 // 导出环境变量
export const { export const {
API_BASE_URL, API_BASE_URL,
SERVER_API_URL,
APP_NAME, APP_NAME,
DEBUG DEBUG
} = getEnvConfig() } = getEnvConfig()

View File

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

BIN
dist.zip

Binary file not shown.

View File

@@ -20,7 +20,7 @@
#### 新增功能 #### 新增功能
- 用户头像和基本信息展示 - 用户头像和基本信息展示
- 佣金统计(可提现、冻结中、累计收益) - 佣金统计(可提现、待使用、累计收益)
- 团队统计(一级、二级、三级成员) - 团队统计(一级、二级、三级成员)
- 功能导航网格(分销订单、提现申请、我的团队、推广二维码) - 功能导航网格(分销订单、提现申请、我的团队、推广二维码)

View File

@@ -35,7 +35,7 @@ dealer: {
// 金额相关 // 金额相关
money: { money: {
available: 'linear-gradient(135deg, #10b981 0%, #059669 100%)', // 可提现 - 绿色 available: 'linear-gradient(135deg, #10b981 0%, #059669 100%)', // 可提现 - 绿色
frozen: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)', // 冻结中 - 蓝色 frozen: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)', // 待使用 - 蓝色
total: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)' // 累计 - 橙色 total: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)' // 累计 - 橙色
} }
``` ```

View File

@@ -16,6 +16,21 @@ export async function pageGltTicketOrder(params: GltTicketOrderParam) {
return Promise.reject(new Error(res.message)); 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

@@ -62,6 +62,10 @@ export interface GltTicketOrder {
avatar?: string; avatar?: string;
// 手机号码 // 手机号码
phone?: string; phone?: string;
// 收货人姓名
receiverName?: string;
// 收货人手机号码
receiverPhone?: string;
// 排序(数字越小越靠前) // 排序(数字越小越靠前)
sortNumber?: number; sortNumber?: number;
// 备注 // 备注
@@ -76,6 +80,12 @@ export interface GltTicketOrder {
createTime?: string; createTime?: string;
// 修改时间 // 修改时间
updateTime?: 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; unitName?: string;
// 最小购买数量 // 最小购买数量
minBuyQty?: number; minBuyQty?: number;
// 购买步长5 的倍数)
step?: number;
// 起始发送数量 // 起始发送数量
startSendQty?: number; startSendQty?: number;
// 买赠买1送4 => gift_multiplier=4 // 买赠买1送4 => gift_multiplier=4

View File

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

View File

@@ -31,6 +31,10 @@ export interface ShopDealerOrder {
isInvalid?: number; isInvalid?: number;
// 佣金结算(0未结算 1已结算) // 佣金结算(0未结算 1已结算)
isSettled?: number; isSettled?: number;
// 佣金解冻(0未解冻 1已解冻)
isUnfreeze?: number;
// 订单状态
orderStatus?: number;
// 结算时间 // 结算时间
settleTime?: number; settleTime?: number;
// 商城ID // 商城ID

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
export default definePageConfig({ export default definePageConfig({
navigationBarTitleText: '成为经销商', navigationBarTitleText: '注册成为会员',
navigationBarTextStyle: 'black' navigationBarTextStyle: 'black'
}) })

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,3 +1,3 @@
export default definePageConfig({ export default definePageConfig({
navigationBarTitleText: '桂乐淘分享中心' navigationBarTitleText: '账户管理中心'
}) })

View File

@@ -1,4 +1,4 @@
import React from 'react' import React, { useState } from 'react'
import {View, Text} from '@tarojs/components' import {View, Text} from '@tarojs/components'
import {ConfigProvider, Button, Grid, Avatar} from '@nutui/nutui-react-taro' import {ConfigProvider, Button, Grid, Avatar} from '@nutui/nutui-react-taro'
import { import {
@@ -10,8 +10,11 @@ import {
People People
} from '@nutui/icons-react-taro' } from '@nutui/icons-react-taro'
import {useDealerUser} from '@/hooks/useDealerUser' import {useDealerUser} from '@/hooks/useDealerUser'
import {useUser} from '@/hooks/useUser'
import { useThemeStyles } from '@/hooks/useTheme' import { useThemeStyles } from '@/hooks/useTheme'
import {businessGradients, cardGradients, gradientUtils} from '@/styles/gradients' import {businessGradients, cardGradients, gradientUtils} from '@/styles/gradients'
import {updateShopDealerUser} from '@/api/shop/shopDealerUser'
import FreezeMoneyModal from './components/FreezeMoneyModal'
import Taro from '@tarojs/taro' import Taro from '@tarojs/taro'
const DealerIndex: React.FC = () => { const DealerIndex: React.FC = () => {
@@ -21,6 +24,12 @@ const DealerIndex: React.FC = () => {
refresh, refresh,
} = useDealerUser() } = useDealerUser()
// 待使用明细弹窗显示状态
const [freezeMoneyModalVisible, setFreezeMoneyModalVisible] = useState(false)
// 获取用户角色信息
const { hasRole } = useUser()
// 使用主题样式 // 使用主题样式
const themeStyles = useThemeStyles() const themeStyles = useThemeStyles()
@@ -55,6 +64,75 @@ const DealerIndex: React.FC = () => {
console.log(getGradientBackground(),'getGradientBackground()') 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) { if (error) {
return ( return (
<View className="p-4"> <View className="p-4">
@@ -134,19 +212,26 @@ const DealerIndex: React.FC = () => {
<View className="grid grid-cols-3 gap-3"> <View className="grid grid-cols-3 gap-3">
<View className="text-center p-3 rounded-lg flex flex-col" style={{ <View className="text-center p-3 rounded-lg flex flex-col" style={{
background: businessGradients.money.available background: businessGradients.money.available
}}> }} onClick={() => navigateToPage('/dealer/withdraw/index')}>
<Text className="text-lg font-bold mb-1 text-white"> <Text className="text-lg font-bold mb-1 text-white">
{formatMoney(dealerUser.money)} {formatMoney(dealerUser.money)}
</Text> </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>
<View className="text-center p-3 rounded-lg flex flex-col" style={{ <View
background: businessGradients.money.frozen 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"> <Text className="text-lg font-bold mb-1 text-white">
{formatMoney(dealerUser.freezeMoney)} {formatMoney(dealerUser.freezeMoney)}
</Text> </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>
<View className="text-center p-3 rounded-lg flex flex-col" style={{ <View className="text-center p-3 rounded-lg flex flex-col" style={{
background: businessGradients.money.total background: businessGradients.money.total
@@ -286,6 +371,13 @@ const DealerIndex: React.FC = () => {
</View> </View>
</View> </View>
{/* 待使用明细弹窗 */}
<FreezeMoneyModal
visible={freezeMoneyModalVisible}
amount={dealerUser?.freezeMoney || '0'}
onClose={handleCloseFreezeMoneyModal}
/>
{/* 底部安全区域 */} {/* 底部安全区域 */}
<View className="h-20"></View> <View className="h-20"></View>
</View> </View>

View File

@@ -94,15 +94,19 @@ const DealerOrders: React.FC = () => {
} }
}, [fetchOrders]) }, [fetchOrders])
const getStatusText = (isSettled?: number, isInvalid?: number) => { const getStatusText = (isSettled?: number, isInvalid?: number, isUnfreeze?: number, orderStatus?: number) => {
if (orderStatus === 2 || orderStatus === 5 || orderStatus === 6) return '已取消'
if (isInvalid === 1) return '已失效' if (isInvalid === 1) return '已失效'
if (isUnfreeze === 1) return '已解冻'
if (isSettled === 1) return '已结算' if (isSettled === 1) return '已结算'
return '待结算' return '待结算'
} }
const getStatusColor = (isSettled?: number, isInvalid?: number) => { const getStatusColor = (isSettled?: number, isInvalid?: number, isUnfreeze?: number, orderStatus?: number) => {
if (orderStatus === 2 || orderStatus === 5 || orderStatus === 6) return 'default'
if (isInvalid === 1) return 'danger' if (isInvalid === 1) return 'danger'
if (isSettled === 1) return 'success' if (isUnfreeze === 1) return 'success'
if (isSettled === 1) return 'info'
return 'warning' return 'warning'
} }
@@ -120,8 +124,8 @@ const DealerOrders: React.FC = () => {
<Text className="font-semibold text-gray-800"> <Text className="font-semibold text-gray-800">
{order.orderNo || '-'} {order.orderNo || '-'}
</Text> </Text>
<Tag type={getStatusColor(order.isSettled, order.isInvalid)}> <Tag type={getStatusColor(order.isSettled, order.isInvalid, order.isUnfreeze,order.orderStatus)}>
{getStatusText(order.isSettled, order.isInvalid)} {getStatusText(order.isSettled, order.isInvalid, order.isUnfreeze,order.orderStatus)}
</Tag> </Tag>
</View> </View>

View File

@@ -1,5 +1,5 @@
export default definePageConfig({ export default definePageConfig({
navigationBarTitleText: '桂乐淘分享中心', navigationBarTitleText: '账户管理中心',
// Enable "Share to friends" and "Share to Moments" (timeline) for this page. // Enable "Share to friends" and "Share to Moments" (timeline) for this page.
enableShareAppMessage: true, enableShareAppMessage: true,
enableShareTimeline: true enableShareTimeline: true

View File

@@ -325,7 +325,7 @@ const DealerTeam: React.FC = () => {
</View> </View>
{/* 显示手机号(仅本级可见) */} {/* 显示手机号(仅本级可见) */}
{showPhone && member.phone && ( {showPhone && member.phone && (
<Text className="text-sm text-gray-500" onClick={(e) => { <Text className="text-sm text-gray-500 hidden" onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
makePhoneCall(member.phone || ''); makePhoneCall(member.phone || '');
}}> }}>

View File

@@ -98,7 +98,7 @@ const normalizeMoneyString = (money: unknown) => {
} }
const DealerWithdraw: React.FC = () => { const DealerWithdraw: React.FC = () => {
const [activeTab, setActiveTab] = useState<string | number>('0') const [activeTab, setActiveTab] = useState<string>('0')
const [loading, setLoading] = useState<boolean>(false) const [loading, setLoading] = useState<boolean>(false)
const [refreshing, setRefreshing] = useState<boolean>(false) const [refreshing, setRefreshing] = useState<boolean>(false)
const [submitting, setSubmitting] = useState<boolean>(false) const [submitting, setSubmitting] = useState<boolean>(false)
@@ -114,10 +114,11 @@ const DealerWithdraw: React.FC = () => {
// Tab 切换处理函数 // Tab 切换处理函数
const handleTabChange = (value: string | number) => { const handleTabChange = (value: string | number) => {
console.log('Tab切换到:', value) console.log('Tab切换到:', value)
setActiveTab(value) const next = String(value)
setActiveTab(next)
// 如果切换到提现记录页面,刷新数据 // 如果切换到提现记录页面,刷新数据
if (String(value) === '1') { if (next === '1') {
fetchWithdrawRecords() fetchWithdrawRecords()
} }
} }
@@ -307,10 +308,17 @@ const DealerWithdraw: React.FC = () => {
// return // return
} }
if (amount > 200) {
Taro.showToast({
title: '单笔最多提现200元',
icon: 'none'
})
return
}
if (amount > available) { if (amount > available) {
Taro.showToast({ Taro.showToast({
title: '提现金额超过可用余额', title: '提现金额超过可用余额',
icon: 'error' icon: 'none'
}) })
return return
} }
@@ -412,7 +420,7 @@ const DealerWithdraw: React.FC = () => {
} }
} }
const quickAmounts = ['100', '300', '500', '1000'] const quickAmounts = ['50', '100', '200']
const setQuickAmount = (amount: string) => { const setQuickAmount = (amount: string) => {
formRef.current?.setFieldsValue({amount}) formRef.current?.setFieldsValue({amount})
@@ -487,10 +495,10 @@ const DealerWithdraw: React.FC = () => {
labelPosition="top" labelPosition="top"
> >
<CellGroup> <CellGroup>
<Form.Item name="amount" label="提现金额" required> <Form.Item name="amount" label="提现金额">
<Input <Input
placeholder="请输入提现金额" placeholder="请输入提现金额"
type="number" type="digit"
/> />
</Form.Item> </Form.Item>
@@ -522,8 +530,9 @@ const DealerWithdraw: React.FC = () => {
<Text className="text-sm text-red-500"> <Text className="text-sm text-red-500">
1. 1.
2. 2.
3. 3.
4. 200
</Text> </Text>
</View> </View>
</CellGroup> </CellGroup>
@@ -628,13 +637,12 @@ const DealerWithdraw: React.FC = () => {
<View className="bg-gray-50 min-h-screen"> <View className="bg-gray-50 min-h-screen">
<Tabs value={activeTab} onChange={handleTabChange}> <Tabs value={activeTab} onChange={handleTabChange}>
<Tabs.TabPane title="申请提现" value="0"> <Tabs.TabPane title="申请提现" value="0">
{renderWithdrawForm()}
</Tabs.TabPane> </Tabs.TabPane>
<Tabs.TabPane title="提现记录" value="1"> <Tabs.TabPane title="提现记录" value="1">
{renderWithdrawRecords()}
</Tabs.TabPane> </Tabs.TabPane>
</Tabs> </Tabs>
{activeTab === '0' ? renderWithdrawForm() : renderWithdrawRecords()}
</View> </View>
) )
} }

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,9 +1,9 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import Taro from '@tarojs/taro'; import Taro from '@tarojs/taro';
import { User } from '@/api/system/user/model'; 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 { TenantId } from '@/config/app';
import {getStoredInviteParams, handleInviteRelation} from '@/utils/invite'; import { handleInviteRelation } from '@/utils/invite';
// 用户Hook // 用户Hook
export const useUser = () => { export const useUser = () => {
@@ -27,6 +27,24 @@ export const useUser = () => {
setUser(data.user); setUser(data.user);
setIsLoggedIn(true); 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) { if (data.user?.userId) {
try { try {
@@ -44,15 +62,10 @@ export const useUser = () => {
reject(new Error('自动登录失败')); reject(new Error('自动登录失败'));
} }
}).catch(_ => { }).catch(_ => {
// 首次注册,跳转到邀请注册页面 // 登录失败(通常是新用户尚未注册/未绑定手机号等)。
const pages = Taro.getCurrentPages(); // 这里不做任何“自动跳转”,避免用户点击「我的」时被强制带到分销/申请页,体验割裂。
const currentPage = pages[pages.length - 1]; // 需要登录的页面请使用 utils/auth 的 ensureLoggedIn / goToRegister 做显式跳转。
const inviteParams = getStoredInviteParams() reject(new Error('autoLoginByOpenId failed'));
if (currentPage?.route !== 'dealer/apply/add' && inviteParams?.inviter) {
return Taro.navigateTo({
url: '/dealer/apply/add'
});
}
}); });
}, },
fail: reject fail: reject
@@ -60,7 +73,11 @@ export const useUser = () => {
}); });
return res; return res;
} catch (error) { } catch (error) {
const msg = error instanceof Error ? error.message : String(error);
// 新用户首次进入、未绑定手机号等场景属于“预期失败”,避免刷屏报错。
if (msg !== 'autoLoginByOpenId failed') {
console.error('自动登录失败:', error); console.error('自动登录失败:', error);
}
return null; return null;
} }
}; };

View File

@@ -3,7 +3,7 @@ import Banner from './Banner'
import Taro, { useDidShow, useShareAppMessage } from '@tarojs/taro' import Taro, { useDidShow, useShareAppMessage } from '@tarojs/taro'
import { View, Text, Image, ScrollView } from '@tarojs/components' import { View, Text, Image, ScrollView } from '@tarojs/components'
import { useEffect, useMemo, useState, type ReactNode } from 'react' import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { Cart, Gift, Ticket, Agenda } from '@nutui/icons-react-taro' import { Cart, Gift, Ticket, Agenda, ArrowRight } from '@nutui/icons-react-taro'
import { getShopInfo } from '@/api/layout' import { getShopInfo } from '@/api/layout'
import { checkAndHandleInviteRelation, hasPendingInvite } from '@/utils/invite' import { checkAndHandleInviteRelation, hasPendingInvite } from '@/utils/invite'
import { pageShopGoods } from '@/api/shop/shopGoods' import { pageShopGoods } from '@/api/shop/shopGoods'
@@ -11,6 +11,7 @@ import type { ShopGoods, ShopGoodsParam } from '@/api/shop/shopGoods/model'
import { getMyGltUserTicketTotal } from '@/api/glt/gltUserTicket' import { getMyGltUserTicketTotal } from '@/api/glt/gltUserTicket'
import { ensureLoggedIn } from '@/utils/auth' import { ensureLoggedIn } from '@/utils/auth'
import './index.scss' import './index.scss'
// import navTo from "@/utils/common";
function Home() { function Home() {
const [activeTabKey, setActiveTabKey] = useState('recommend') const [activeTabKey, setActiveTabKey] = useState('recommend')
@@ -216,8 +217,8 @@ function Home() {
title: '立即送水', title: '立即送水',
icon: <Cart size={30} />, icon: <Cart size={30} />,
onClick: () => { onClick: () => {
if (!ensureLoggedIn('/user/ticket/use?goodsId=10074')) return if (!ensureLoggedIn('/user/ticket/use')) return
Taro.navigateTo({ url: '/user/ticket/use?goodsId=10074' }) Taro.navigateTo({ url: '/user/ticket/use' })
}, },
}, },
{ {
@@ -225,8 +226,9 @@ function Home() {
title: '送水订单', title: '送水订单',
icon: <Agenda size={30} />, icon: <Agenda size={30} />,
onClick: () => { onClick: () => {
if (!ensureLoggedIn('/user/ticket/index')) return const url = '/user/ticket/index?tab=order'
Taro.navigateTo({ url: '/user/ticket/index' }) if (!ensureLoggedIn(url)) return
Taro.navigateTo({ url })
}, },
}, },
{ {
@@ -289,6 +291,20 @@ function Home() {
</View> </View>
</View> </View>
<View className="ticket-card" onClick={() => Taro.navigateTo({ url: `/shop/category/index?id=4560` })}>
<View className="ticket-card__head">
<Text className="ticket-card__title"></Text>
<ArrowRight className={'text-gray-50'} size={16} />
</View>
</View>
<View className="ticket-card" onClick={() => Taro.navigateTo({ url: `/shop/category/index?id=4556` })}>
<View className="ticket-card__head">
<Text className="ticket-card__title">·</Text>
<ArrowRight className={'text-gray-50'} size={16} />
</View>
</View>
{/*分类Tabs*/} {/*分类Tabs*/}
<ScrollView className="home-tabs" scrollX enableFlex> <ScrollView className="home-tabs" scrollX enableFlex>
<View className="home-tabs__inner"> <View className="home-tabs__inner">
@@ -306,7 +322,6 @@ function Home() {
})} })}
</View> </View>
</ScrollView> </ScrollView>
{/* 商品列表 */} {/* 商品列表 */}
<View className="goods-grid"> <View className="goods-grid">
{visibleGoods.map((item) => ( {visibleGoods.map((item) => (
@@ -329,20 +344,20 @@ function Home() {
<Text className="goods-card__sold">:{item.sales || 0}</Text> <Text className="goods-card__sold">:{item.sales || 0}</Text>
<View className="goods-card__price"> <View className="goods-card__price">
<Text className="goods-card__priceUnit"></Text> <Text className="goods-card__priceUnit"></Text>
<Text className="goods-card__priceValue">{item.price}</Text> <Text className="goods-card__priceValue">{item.buyingPrice}</Text>
</View> </View>
</View> </View>
<View className="goods-card__actions"> <View className="goods-card__actions">
<View {/*<View*/}
className="goods-card__btn goods-card__btn--ghost" {/* className="goods-card__btn goods-card__btn--ghost"*/}
onClick={() => { {/* onClick={() => {*/}
if (!ensureLoggedIn('/shop/orderConfirm/index?goodsId=10074')) return {/* if (!ensureLoggedIn('/shop/orderConfirm/index?goodsId=10074')) return*/}
Taro.navigateTo({ url: '/shop/orderConfirm/index?goodsId=10074' }) {/* Taro.navigateTo({ url: '/shop/orderConfirm/index?goodsId=10074' })*/}
}} {/* }}*/}
> {/*>*/}
<Text className="goods-card__btnText"></Text> {/* <Text className="goods-card__btnText">买水票更优惠</Text>*/}
</View> {/*</View>*/}
<View <View
className="goods-card__btn goods-card__btn--primary" className="goods-card__btn goods-card__btn--primary"
onClick={() => onClick={() =>
@@ -356,6 +371,7 @@ function Home() {
</View> </View>
))} ))}
</View> </View>
</View> </View>
</> </>
) )

View File

@@ -52,7 +52,7 @@ const IsDealer = () => {
<View style={{display: 'inline-flex', alignItems: 'center'}}> <View style={{display: 'inline-flex', alignItems: 'center'}}>
<Reward className={'text-orange-100 '} size={16}/> <Reward className={'text-orange-100 '} size={16}/>
<Text style={{fontSize: '16px'}} <Text style={{fontSize: '16px'}}
className={'pl-3 text-orange-100 font-medium'}>{config?.vipText || '桂乐淘分享中心'}</Text> className={'pl-3 text-orange-100 font-medium'}>{config?.vipText || '账户管理中心'}</Text>
{/*<Text className={'text-white opacity-80 pl-3'}>门店核销</Text>*/} {/*<Text className={'text-white opacity-80 pl-3'}>门店核销</Text>*/}
</View> </View>
} }
@@ -76,7 +76,7 @@ const IsDealer = () => {
title={ title={
<View style={{display: 'inline-flex', alignItems: 'center'}}> <View style={{display: 'inline-flex', alignItems: 'center'}}>
<Reward className={'text-orange-100 '} size={16}/> <Reward className={'text-orange-100 '} size={16}/>
<Text style={{fontSize: '16px'}} className={'pl-3 text-orange-100 font-medium'}>{config?.vipText || '桂乐淘分享中心'}</Text> <Text style={{fontSize: '16px'}} className={'pl-3 text-orange-100 font-medium'}>{config?.vipText || '账户管理中心'}</Text>
<Text className={'text-white opacity-80 pl-3'}>{config?.vipComments || ''}</Text> <Text className={'text-white opacity-80 pl-3'}>{config?.vipComments || ''}</Text>
</View> </View>
} }

View File

@@ -14,6 +14,7 @@ import {useThemeStyles} from "@/hooks/useTheme";
import {getRootDomain} from "@/utils/domain"; import {getRootDomain} from "@/utils/domain";
import { getMyGltUserTicketTotal } from '@/api/glt/gltUserTicket' import { getMyGltUserTicketTotal } from '@/api/glt/gltUserTicket'
import { saveStorageByLoginUser } from '@/utils/server' import { saveStorageByLoginUser } from '@/utils/server'
import { getUserLevelName, getUserLevelConfig } from '@/utils/userLevel'
const UserCard = forwardRef<any, any>((_, ref) => { const UserCard = forwardRef<any, any>((_, ref) => {
const {data, refresh} = useUserData() 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 ? '用户' : '点击登录') return userInfo.nickname || (userInfo as any).realName || (userInfo as any).username || (IsLogin ? '用户' : '点击登录')
} }
// 角色名称:优先取用户 roles 数组的第一个角色名称 // 角色名称:优先使用 dealerLevel 显示四种分级,否则取用户 roles 数组的第一个角色名称
const getRoleName = () => { const getRoleName = () => {
const dealerLevel = (userInfo as any)?.dealerLevel
if (dealerLevel !== undefined && dealerLevel !== null) {
return getUserLevelName(dealerLevel)
}
return userInfo?.roles?.[0]?.roleName || userInfo?.roleName || '注册用户' 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) => { const reloadStats = async (showToast = false) => {
await refresh() await refresh()
@@ -267,7 +283,22 @@ const UserCard = forwardRef<any, any>((_, ref) => {
<View className={'flex flex-col'}> <View className={'flex flex-col'}>
<Text style={{color: '#ffffff'}}>{getDisplayName() || '点击登录'}</Text> <Text style={{color: '#ffffff'}}>{getDisplayName() || '点击登录'}</Text>
{getRootDomain() && ( {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> </View>
@@ -335,13 +366,9 @@ const UserCard = forwardRef<any, any>((_, ref) => {
<View className={'py-2'}> <View className={'py-2'}>
<View className={'flex justify-around mt-1'}> <View className={'flex justify-around mt-1'}>
<View className={'item flex justify-center flex-col items-center'} <View className={'item flex justify-center flex-col items-center'}
onClick={() => navTo('/user/wallet/wallet', true)}> onClick={() => navTo('/user/ticket/index', true)}>
<Text className={'text-xs text-gray-200'} style={themeStyles.textColor}></Text> <Text className={'text-xs text-gray-200'} style={themeStyles.textColor}></Text>
<Text className={'text-xl text-white'} style={themeStyles.textColor}>{data?.balance || '0.00'}</Text> <Text className={'text-xl text-white'} style={themeStyles.textColor}>{ticketTotal}</Text>
</View>
<View className={'item flex justify-center flex-col items-center'}>
<Text className={'text-xs text-gray-200'} style={themeStyles.textColor}></Text>
<Text className={'text-xl text-white'} style={themeStyles.textColor}>{data?.points || 0}</Text>
</View> </View>
<View className={'item flex justify-center flex-col items-center'} <View className={'item flex justify-center flex-col items-center'}
onClick={() => navTo('/user/coupon/index', true)}> onClick={() => navTo('/user/coupon/index', true)}>
@@ -349,9 +376,13 @@ const UserCard = forwardRef<any, any>((_, ref) => {
<Text className={'text-xl text-white'} style={themeStyles.textColor}>{data?.coupons || 0}</Text> <Text className={'text-xl text-white'} style={themeStyles.textColor}>{data?.coupons || 0}</Text>
</View> </View>
<View className={'item flex justify-center flex-col items-center'} <View className={'item flex justify-center flex-col items-center'}
onClick={() => navTo('/user/ticket/index', true)}> onClick={() => navTo('/user/wallet/wallet', true)}>
<Text className={'text-xs text-gray-200'} style={themeStyles.textColor}></Text> <Text className={'text-xs text-gray-200'} style={themeStyles.textColor}></Text>
<Text className={'text-xl text-white'} style={themeStyles.textColor}>{ticketTotal}</Text> <Text className={'text-xl text-white'} style={themeStyles.textColor}>{data?.balance || '0.00'}</Text>
</View>
<View className={'item flex justify-center flex-col items-center'}>
<Text className={'text-xs text-gray-200'} style={themeStyles.textColor}></Text>
<Text className={'text-xl text-white'} style={themeStyles.textColor}>{data?.points || 0}</Text>
</View> </View>
</View> </View>
</View> </View>

View File

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

View File

@@ -39,7 +39,7 @@ const UserCell = () => {
return ( return (
<> <>
<View className="bg-white mx-4 mt-4 rounded-xl"> <View className="bg-white mx-4 mt-4 rounded-xl">
<View className="font-semibold text-gray-800 pt-4 pl-4"></View> <View className="font-semibold text-gray-800 pt-4 pl-4"></View>
<ConfigProvider> <ConfigProvider>
<Grid <Grid
columns={4} columns={4}

View File

@@ -1,4 +1,4 @@
import {useEffect, useRef} from 'react' import {useEffect, useRef, useState} from 'react'
import {PullToRefresh} from '@nutui/nutui-react-taro' import {PullToRefresh} from '@nutui/nutui-react-taro'
import UserCard from "./components/UserCard"; import UserCard from "./components/UserCard";
import UserOrder from "./components/UserOrder"; import UserOrder from "./components/UserOrder";
@@ -14,12 +14,15 @@ function User() {
const userCardRef = useRef<any>() const userCardRef = useRef<any>()
const themeStyles = useThemeStyles(); const themeStyles = useThemeStyles();
// TabBar 页在小程序里通常不会销毁;从“注册/申请”页返回时需要触发子组件重新初始化/拉取最新状态。
const [dealerViewKey, setDealerViewKey] = useState(0)
// 下拉刷新处理 // 下拉刷新处理
const handleRefresh = async () => { const handleRefresh = async () => {
if (userCardRef.current?.handleRefresh) { if (userCardRef.current?.handleRefresh) {
await userCardRef.current.handleRefresh() await userCardRef.current.handleRefresh()
} }
setDealerViewKey(v => v + 1)
} }
useEffect(() => { useEffect(() => {
@@ -30,6 +33,7 @@ function User() {
userCardRef.current?.reloadStats?.() userCardRef.current?.reloadStats?.()
// 个人资料(头像/昵称)可能在其它页面被修改,这里确保返回时立刻刷新 // 个人资料(头像/昵称)可能在其它页面被修改,这里确保返回时立刻刷新
userCardRef.current?.reloadUserInfo?.() userCardRef.current?.reloadUserInfo?.()
setDealerViewKey(v => v + 1)
}) })
return ( return (
@@ -58,7 +62,7 @@ function User() {
</View> </View>
<UserCard ref={userCardRef}/> <UserCard ref={userCardRef}/>
<UserOrder/> <UserOrder/>
<IsDealer/> <IsDealer key={dealerViewKey}/>
<UserGrid/> <UserGrid/>
<UserFooter/> <UserFooter/>
</PullToRefresh> </PullToRefresh>

View File

@@ -1,6 +1,6 @@
import React from 'react' import React, { useEffect } from 'react'
import {View, Text} from '@tarojs/components' 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 { import {
User, User,
Shopping, Shopping,
@@ -8,11 +8,15 @@ import {
ArrowRight, ArrowRight,
Purse, Purse,
People, People,
Scan Scan,
Setting
} from '@nutui/icons-react-taro' } from '@nutui/icons-react-taro'
import {useDealerUser} from '@/hooks/useDealerUser' import {useDealerUser} from '@/hooks/useDealerUser'
import {useUser} from '@/hooks/useUser'
import { useThemeStyles } from '@/hooks/useTheme' import { useThemeStyles } from '@/hooks/useTheme'
import { useRiderNotification } from '@/hooks/useRiderNotification'
import {businessGradients, cardGradients, gradientUtils} from '@/styles/gradients' import {businessGradients, cardGradients, gradientUtils} from '@/styles/gradients'
import {updateShopDealerUser} from '@/api/shop/shopDealerUser'
import Taro from '@tarojs/taro' import Taro from '@tarojs/taro'
const DealerIndex: React.FC = () => { const DealerIndex: React.FC = () => {
@@ -22,6 +26,18 @@ const DealerIndex: React.FC = () => {
refresh, refresh,
} = useDealerUser() } = useDealerUser()
// 获取用户角色信息
const { hasRole } = useUser()
// 配送员通知功能
const { pendingCount, startPolling, stopPolling, soundEnabled, toggleSound } = useRiderNotification()
// 页面生命周期管理
useEffect(() => {
startPolling()
return () => stopPolling()
}, [startPolling, stopPolling])
// 使用主题样式 // 使用主题样式
const themeStyles = useThemeStyles() const themeStyles = useThemeStyles()
@@ -56,6 +72,109 @@ const DealerIndex: React.FC = () => {
console.log(getGradientBackground(),'getGradientBackground()') 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) { if (error) {
return ( return (
<View className="p-4"> <View className="p-4">
@@ -74,7 +193,14 @@ const DealerIndex: React.FC = () => {
<View> <View>
{/*头部信息*/} {/*头部信息*/}
{dealerUser && ( {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={{ <View className="absolute w-32 h-32 rounded-full" style={{
backgroundColor: 'rgba(255, 255, 255, 0.1)', backgroundColor: 'rgba(255, 255, 255, 0.1)',
@@ -130,7 +256,7 @@ const DealerIndex: React.FC = () => {
{dealerUser && ( {dealerUser && (
<View className="mx-4 -mt-6 rounded-xl p-4 relative z-10" style={cardGradients.elevated}> <View className="mx-4 -mt-6 rounded-xl p-4 relative z-10" style={cardGradients.elevated}>
<View className="mb-4"> <View className="mb-4">
<Text className="font-semibold text-gray-800"></Text> <Text className="font-semibold text-gray-800"></Text>
</View> </View>
<View className="grid grid-cols-3 gap-3"> <View className="grid grid-cols-3 gap-3">
<View className="text-center p-3 rounded-lg flex flex-col" style={{ <View className="text-center p-3 rounded-lg flex flex-col" style={{
@@ -141,13 +267,20 @@ const DealerIndex: React.FC = () => {
</Text> </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>
<View className="text-center p-3 rounded-lg flex flex-col" style={{ <View
background: businessGradients.money.frozen 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"> <Text className="text-lg font-bold mb-1 text-white">
{formatMoney(dealerUser.freezeMoney)} {formatMoney(dealerUser.freezeMoney)}
</Text> </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>
<View className="text-center p-3 rounded-lg flex flex-col" style={{ <View className="text-center p-3 rounded-lg flex flex-col" style={{
background: businessGradients.money.total background: businessGradients.money.total
@@ -212,13 +345,20 @@ const DealerIndex: React.FC = () => {
> >
<Grid.Item text="配送订单" onClick={() => navigateToPage('/rider/orders/index')}> <Grid.Item text="配送订单" onClick={() => navigateToPage('/rider/orders/index')}>
<View className="text-center"> <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"/> <Shopping color="#3b82f6" size="20"/>
{pendingCount > 0 && (
<Badge
value={pendingCount > 99 ? '99+' : pendingCount}
max={99}
style={{ position: 'absolute', top: '-4px', right: '-4px' }}
/>
)}
</View> </View>
</View> </View>
</Grid.Item> </Grid.Item>
<Grid.Item text={'工资明细'} onClick={() => navigateToPage('/rider/withdraw/index')}> <Grid.Item text={'收入明细'} onClick={() => navigateToPage('/rider/withdraw/index')}>
<View className="text-center"> <View className="text-center">
<View className="w-12 h-12 bg-green-50 rounded-xl flex items-center justify-center mx-auto mb-2"> <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"/> <Purse color="#10b981" size="20"/>
@@ -251,46 +391,96 @@ const DealerIndex: React.FC = () => {
</Grid.Item> </Grid.Item>
</Grid> </Grid>
{/* 第二行功能 */} {/* 第二行功能 - 通知设置 */}
{/*<Grid*/} <Grid
{/* columns={4}*/} columns={4}
{/* className="no-border-grid mt-4"*/} className="no-border-grid mt-4"
{/* style={{*/} style={{
{/* '--nutui-grid-border-color': 'transparent',*/} '--nutui-grid-border-color': 'transparent',
{/* '--nutui-grid-item-border-width': '0px',*/} '--nutui-grid-item-border-width': '0px',
{/* border: 'none'*/} border: 'none'
{/* } as React.CSSProperties}*/} } as React.CSSProperties}
{/*>*/} >
{/* <Grid.Item text={'邀请统计'} onClick={() => navigateToPage('/dealer/invite-stats/index')}>*/} <Grid.Item text={'通知设置'} onClick={() => {
{/* <View className="text-center">*/} const isSubscribed = Taro.getStorageSync('rider_subscribed') === '1'
{/* <View className="w-12 h-12 bg-indigo-50 rounded-xl flex items-center justify-center mx-auto mb-2">*/} Taro.showModal({
{/* <Presentation color="#6366f1" size="20"/>*/} title: '通知设置',
{/* </View>*/} content: `声音提醒:${soundEnabled ? '已开启' : '已关闭'}\n订阅消息${isSubscribed ? '已订阅' : '未订阅'}`,
{/* </View>*/} confirmText: '更多设置',
{/* </Grid.Item>*/} 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={''}>*/} <Grid.Item text={''}>
{/* <View className="text-center">*/} <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 className="w-12 h-12 bg-gray-50 rounded-xl flex items-center justify-center mx-auto mb-2">
{/* </View>*/} </View>
{/* </View>*/} </View>
{/* </Grid.Item>*/} </Grid.Item>
{/* <Grid.Item text={''}>*/} <Grid.Item text={''}>
{/* <View className="text-center">*/} <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 className="w-12 h-12 bg-gray-50 rounded-xl flex items-center justify-center mx-auto mb-2">
{/* </View>*/} </View>
{/* </View>*/} </View>
{/* </Grid.Item>*/} </Grid.Item>
{/* <Grid.Item text={''}>*/} <Grid.Item text={''}>
{/* <View className="text-center">*/} <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 className="w-12 h-12 bg-gray-50 rounded-xl flex items-center justify-center mx-auto mb-2">
{/* </View>*/} </View>
{/* </View>*/} </View>
{/* </Grid.Item>*/} </Grid.Item>
{/*</Grid>*/} </Grid>
</ConfigProvider> </ConfigProvider>
</View> </View>
</View> </View>

View File

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

View File

@@ -0,0 +1,122 @@
.goods-grid {
margin-top: 18rpx;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18rpx;
}
.goods-card {
border-radius: 22rpx;
overflow: hidden;
background: #ffffff;
box-shadow: 0 18rpx 36rpx rgba(0, 0, 0, 0.06);
}
.goods-card__imgWrap {
padding: 18rpx 18rpx 0;
}
.goods-card__img {
width: 100%;
height: 280rpx;
border-radius: 18rpx;
background: #f4f4f4;
}
.goods-card__body {
padding: 18rpx 18rpx 20rpx;
}
.goods-card__title {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
font-size: 26rpx;
font-weight: 700;
color: #1c1c1c;
min-height: 72rpx;
}
.goods-card__meta {
margin-top: 10rpx;
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 10rpx;
}
.goods-card__sold {
font-size: 22rpx;
color: #9a9a9a;
white-space: nowrap;
}
.goods-card__price {
display: flex;
align-items: baseline;
gap: 4rpx;
color: #27c86b;
white-space: nowrap;
}
.goods-card__priceUnit {
font-size: 22rpx;
font-weight: 800;
}
.goods-card__priceValue {
font-size: 36rpx;
font-weight: 900;
}
.goods-card__actions {
margin-top: 16rpx;
display: flex;
gap: 14rpx;
}
.goods-card__btn {
flex: 1;
height: 64rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
justify-content: center;
}
.goods-card__btn--ghost {
border: 2rpx solid rgba(32, 194, 106, 0.7);
background: #ffffff;
}
.goods-card__btn--primary {
background: linear-gradient(90deg, #24d34c 0%, #6df09a 100%);
}
.goods-card__btnText {
font-size: 24rpx;
font-weight: 700;
color: #18b85a;
white-space: nowrap;
}
.goods-card__btnText--primary {
color: #ffffff;
}
.buy-btn{
height: 70px;
background: linear-gradient(to bottom, #1cd98a, #24ca94);
border-radius: 100px;
color: #ffffff;
display: flex;
align-items: center;
justify-content: space-around;
.cart-icon{
background: linear-gradient(to bottom, #bbe094, #4ee265);
border-radius: 100px 0 0 100px;
height: 70px;
}
}

View File

@@ -1,51 +1,57 @@
import {Image} from '@nutui/nutui-react-taro' import {Image} from '@nutui/nutui-react-taro'
import {Share} from '@nutui/icons-react-taro'
import {View, Text} from '@tarojs/components' import {View, Text} from '@tarojs/components'
import Taro from '@tarojs/taro' import Taro from '@tarojs/taro'
import './GoodsList.scss' import './GoodsList.scss'
import {ShopGoods} from "@/api/shop/shopGoods/model";
const GoodsList = (props: any) => { const GoodsList = (props: any) => {
return ( return (
<> <>
<View className={'py-3'}> <View className={'p-3'}>
<View className={'flex flex-col justify-between items-center rounded-lg px-2'}>
{props.data?.map((item: any, index: number) => { <View className="goods-grid">
return ( {props.data?.map((item: ShopGoods) => (
<View key={index} className={'flex flex-col rounded-lg bg-white shadow-sm w-full mb-5'}> <View key={item.goodsId} className="goods-card">
<Image src={item.image} mode={'aspectFit'} lazyLoad={false} <View className="goods-card__imgWrap">
radius="10px 10px 0 0" height="180" <Image
onClick={() => Taro.navigateTo({url: '/shop/goodsDetail/index?id=' + item.goodsId})}/> className="goods-card__img"
<View className={'flex flex-col p-2 rounded-lg'}> src={item.image || ''}
<View> mode="aspectFill"
<View className={'car-no text-sm'}>{item.name}</View> width="100%"
<View className={'flex justify-between text-xs py-1'}> height="280rpx"
<Text className={'text-orange-500'}>{item.comments}</Text> radius="18rpx"
<Text className={'text-gray-400'}> {item.sales}</Text> lazyLoad={false}
onClick={() =>
Taro.navigateTo({ url: `/shop/goodsDetail/index?id=${item.goodsId}` })
}
/>
</View> </View>
<View className={'flex justify-between items-center py-2'}>
<View className={'flex text-red-500 text-xl items-baseline'}> <View className="goods-card__body">
<Text className={'text-xs'}></Text> <Text className="goods-card__title">{item.name}</Text>
<Text className={'font-bold text-2xl'}>{item.price}</Text> <View className="goods-card__meta">
<Text className={'text-xs px-1'}></Text> <Text className="goods-card__sold">:{item.sales || 0}</Text>
<Text className={'text-xs text-gray-400 line-through'}>{item.salePrice}</Text> <View className="goods-card__price">
<Text className="goods-card__priceUnit"></Text>
<Text className="goods-card__priceValue">{item.buyingPrice}</Text>
</View> </View>
<View className={'buy-btn'}>
<View className={'cart-icon'}>
<Share size={20} className={'mx-4 mt-2'}
onClick={() => Taro.navigateTo({url: '/shop/goodsDetail/index?id=' + item.goodsId})}/>
</View> </View>
<View className={'text-white pl-4 pr-5'}
onClick={() => Taro.navigateTo({url: '/shop/goodsDetail/index?id=' + item.goodsId})}> <View className="goods-card__actions">
<View
className="goods-card__btn goods-card__btn--primary"
onClick={() =>
Taro.navigateTo({ url: `/shop/goodsDetail/index?id=${item.goodsId}` })
}
>
<Text className="goods-card__btnText goods-card__btnText--primary"></Text>
</View> </View>
</View> </View>
</View> </View>
</View> </View>
</View> ))}
</View>
)
})}
</View> </View>
</View> </View>
</> </>

View File

@@ -1,7 +1,7 @@
import Taro from '@tarojs/taro' import Taro from '@tarojs/taro'
import GoodsList from './components/GoodsList' import GoodsList from './components/GoodsList'
import {useShareAppMessage} from "@tarojs/taro" import {useShareAppMessage} from "@tarojs/taro"
import {Loading} from '@nutui/nutui-react-taro' import {Loading,Empty} from '@nutui/nutui-react-taro'
import {useEffect, useState} from "react" import {useEffect, useState} from "react"
import {useRouter} from '@tarojs/taro' import {useRouter} from '@tarojs/taro'
import './index.scss' import './index.scss'
@@ -21,7 +21,7 @@ function Category() {
// 1.加载远程数据 // 1.加载远程数据
const id = Number(params.id) const id = Number(params.id)
const nav = await getCmsNavigation(id) const nav = await getCmsNavigation(id)
const shopGoods = await pageShopGoods({categoryId: id}) const shopGoods = await pageShopGoods({categoryId: id, status: 0})
// 2.处理业务逻辑 // 2.处理业务逻辑
setCategoryId(id) setCategoryId(id)
@@ -59,6 +59,12 @@ function Category() {
) )
} }
if(list.length == 0){
return (
<Empty description="暂无数据"/>
)
}
return ( return (
<> <>
<div className={'flex flex-col'}> <div className={'flex flex-col'}>

View File

@@ -366,9 +366,9 @@ const GoodsDetail = () => {
<View className={'flex justify-between'}> <View className={'flex justify-between'}>
<View className={'flex text-red-500 text-xl items-baseline'}> <View className={'flex text-red-500 text-xl items-baseline'}>
<Text className={'text-xs'}></Text> <Text className={'text-xs'}></Text>
<Text className={'font-bold text-2xl'}>{goods.price}</Text> <Text className={'font-bold text-2xl'}>{goods.buyingPrice}</Text>
<Text className={'text-xs px-1'}></Text> <Text className={'text-xs px-1'}></Text>
<Text className={'text-xs text-gray-400 line-through'}>{goods.salePrice}</Text> <Text className={'text-xs text-gray-400 line-through'}>{goods.salePrice}/{goods.unitName}</Text>
</View> </View>
<span className={"text-gray-400 text-xs"}> {goods.sales}</span> <span className={"text-gray-400 text-xs"}> {goods.sales}</span>
</View> </View>
@@ -377,6 +377,17 @@ const GoodsDetail = () => {
<View className={"car-no text-lg"}> <View className={"car-no text-lg"}>
{goods.name} {goods.name}
</View> </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"}> <View className={"flex justify-between text-xs py-1"}>
<span className={"text-orange-500"}> <span className={"text-orange-500"}>
{goods.comments} {goods.comments}

View File

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

View File

@@ -9,10 +9,9 @@ import {
ActionSheet, ActionSheet,
Popup, Popup,
InputNumber, InputNumber,
DatePicker,
ConfigProvider ConfigProvider
} from '@nutui/nutui-react-taro' } 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 Taro, {useDidShow} from '@tarojs/taro'
import {ShopGoods} from "@/api/shop/shopGoods/model"; import {ShopGoods} from "@/api/shop/shopGoods/model";
import {getShopGoods} from "@/api/shop/shopGoods"; import {getShopGoods} from "@/api/shop/shopGoods";
@@ -39,7 +38,6 @@ import {
filterUsableCoupons, filterUsableCoupons,
filterUnusableCoupons filterUnusableCoupons
} from "@/utils/couponUtils"; } from "@/utils/couponUtils";
import dayjs from 'dayjs'
import type {ShopStore} from "@/api/shop/shopStore/model"; import type {ShopStore} from "@/api/shop/shopStore/model";
import {getShopStore, listShopStore} from "@/api/shop/shopStore"; import {getShopStore, listShopStore} from "@/api/shop/shopStore";
import {getSelectedStoreFromStorage, saveSelectedStoreToStorage} from "@/utils/storeSelection"; import {getSelectedStoreFromStorage, saveSelectedStoreToStorage} from "@/utils/storeSelection";
@@ -57,18 +55,6 @@ const OrderConfirm = () => {
const [loading, setLoading] = useState<boolean>(true) const [loading, setLoading] = useState<boolean>(true)
const [error, setError] = useState<string>('') const [error, setError] = useState<string>('')
const [payLoading, setPayLoading] = useState<boolean>(false) 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) const [ticketTemplate, setTicketTemplate] = useState<GltTicketTemplate | null>(null)
@@ -89,24 +75,69 @@ const OrderConfirm = () => {
const [availableCoupons, setAvailableCoupons] = useState<CouponCardProps[]>([]) const [availableCoupons, setAvailableCoupons] = useState<CouponCardProps[]>([])
const [couponLoading, setCouponLoading] = useState<boolean>(false) const [couponLoading, setCouponLoading] = useState<boolean>(false)
// 门店选择:用于在下单页展示当前已选门店,并允许用户切换(写入 SelectedStore Storage // 门店选择:用于在下单页展示当前"已选门店",并允许用户切换(写入 SelectedStore Storage
const [storePopupVisible, setStorePopupVisible] = useState(false) const [storePopupVisible, setStorePopupVisible] = useState(false)
const [stores, setStores] = useState<ShopStore[]>([]) const [stores, setStores] = useState<ShopStore[]>([])
const [storeLoading, setStoreLoading] = useState(false) const [storeLoading, setStoreLoading] = useState(false)
const [selectedStore, setSelectedStore] = useState<ShopStore | null>(getSelectedStoreFromStorage()) const [selectedStore, setSelectedStore] = useState<ShopStore | null>(getSelectedStoreFromStorage())
const router = Taro.getCurrentInstance().router; 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(() => { useEffect(() => {
if (!goodsId) { // 兼容 goodsId / orderData 两种进入方式goodsDetail 有规格时会走 orderData
// 也可能是 orderData 模式;这里只做最小兜底 const backUrl =
if (!ensureLoggedIn('/shop/orderConfirm/index')) return orderDataRaw
return ? `/shop/orderConfirm/index?orderData=${orderDataRaw}`
} : resolvedGoodsId
if (!ensureLoggedIn(`/shop/orderConfirm/index?goodsId=${goodsId}`)) return ? `/shop/orderConfirm/index?goodsId=${resolvedGoodsId}`
}, [goodsId]) : '/shop/orderConfirm/index'
if (!ensureLoggedIn(backUrl)) return
}, [resolvedGoodsId, orderDataRaw])
const isTicketTemplateActive = const isTicketTemplateActive =
!!ticketTemplate && !!ticketTemplate &&
@@ -122,10 +153,6 @@ const OrderConfirm = () => {
})() })()
const minBuyQty = isTicketTemplateActive ? ticketMinBuyQty : 1 const minBuyQty = isTicketTemplateActive ? ticketMinBuyQty : 1
const sendTimeText = useMemo(() => {
return dayjs(sendTime).format('YYYY-MM-DD')
}, [sendTime])
const getGiftTicketQty = (buyQty: number) => { const getGiftTicketQty = (buyQty: number) => {
if (!isTicketTemplateActive) return 0 if (!isTicketTemplateActive) return 0
const multiplier = Number(ticketTemplate?.giftMultiplier || 0) const multiplier = Number(ticketTemplate?.giftMultiplier || 0)
@@ -160,7 +187,9 @@ const OrderConfirm = () => {
// 计算商品总价 // 计算商品总价
const getGoodsTotal = () => { const getGoodsTotal = () => {
if (!goods) return 0 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 // const total = price * quantity
// 🔍 详细日志,用于排查数值精度问题 // 🔍 详细日志,用于排查数值精度问题
@@ -201,12 +230,21 @@ const OrderConfirm = () => {
const handleQuantityChange = (value: string | number) => { const handleQuantityChange = (value: string | number) => {
const fallback = isTicketTemplateActive ? minBuyQty : 1 const fallback = isTicketTemplateActive ? minBuyQty : 1
const newQuantity = typeof value === 'string' ? parseInt(value, 10) || fallback : value 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) setQuantity(finalQuantity)
// 数量变化时,重新排序优惠券并检查当前选中的优惠券是否还可用 // 数量变化时,重新排序优惠券并检查当前选中的优惠券是否还可用
if (availableCoupons.length > 0) { 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 sortedCoupons = sortCoupons(availableCoupons, newTotal)
const usableCoupons = filterUsableCoupons(sortedCoupons, newTotal) const usableCoupons = filterUsableCoupons(sortedCoupons, newTotal)
setAvailableCoupons(sortedCoupons) setAvailableCoupons(sortedCoupons)
@@ -430,6 +468,7 @@ const OrderConfirm = () => {
* 统一支付入口 * 统一支付入口
*/ */
const onPay = async (goods: ShopGoods) => { const onPay = async (goods: ShopGoods) => {
let skipFinallyResetPayLoading = false
try { try {
setPayLoading(true) setPayLoading(true)
@@ -450,22 +489,7 @@ const OrderConfirm = () => {
return; 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) { if (isTicketTemplateActive && quantity < minBuyQty) {
@@ -527,19 +551,43 @@ const OrderConfirm = () => {
address.id, address.id,
{ {
comments: goods.name, comments: goods.name,
deliveryType: 0, deliveryType: goods.deliveryMode === 1 ? 1 : 0,
buyerRemarks: orderRemark, buyerRemarks: orderRemark,
sendStartTime: hasTicketTemplate couponId: parseInt(String(bestCoupon.id), 10),
? dayjs(sendTime).startOf('day').format('YYYY-MM-DD HH:mm:ss') skuId: resolvedSkuId,
: undefined, specInfo: orderDataParam?.specInfo
couponId: parseInt(String(bestCoupon.id), 10)
} }
); );
console.log('🎯 使用推荐优惠券的订单数据:', updatedOrderData); 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; // 提前返回,避免重复执行支付 return; // 提前返回,避免重复执行支付
} else { } else {
// 用户选择不使用优惠券,继续支付 // 用户选择不使用优惠券,继续支付
@@ -555,13 +603,12 @@ const OrderConfirm = () => {
address.id, address.id,
{ {
comments: '桂乐淘', comments: '桂乐淘',
deliveryType: 0, deliveryType: goods.deliveryMode === 1 ? 1 : 0,
buyerRemarks: orderRemark, buyerRemarks: orderRemark,
sendStartTime: hasTicketTemplate
? dayjs(sendTime).startOf('day').format('YYYY-MM-DD HH:mm:ss')
: undefined,
// 🔧 确保 couponId 是正确的数字类型,且不传递 undefined // 🔧 确保 couponId 是正确的数字类型,且不传递 undefined
couponId: selectedCoupon ? parseInt(String(selectedCoupon.id), 10) : undefined couponId: selectedCoupon ? parseInt(String(selectedCoupon.id), 10) : undefined,
skuId: resolvedSkuId,
specInfo: orderDataParam?.specInfo
} }
); );
@@ -594,7 +641,32 @@ const OrderConfirm = () => {
}); });
// 执行支付 - 移除这里的成功提示让PaymentHandler统一处理 // 执行支付 - 移除这里的成功提示让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会处理成功提示 // ✅ 移除双重成功提示 - PaymentHandler会处理成功提示
// Taro.showToast({ // Taro.showToast({
@@ -603,13 +675,36 @@ const OrderConfirm = () => {
// }) // })
} catch (error: any) { } catch (error: any) {
const message = String(error?.message || '') const message = String(error?.message || '')
const isUserCancelPay =
message.includes('用户取消支付') ||
message.includes('取消支付') ||
message.toLowerCase().includes('requestpayment:fail cancel') ||
message.toLowerCase().includes('cancel')
// 用户取消支付:跳转到待付款列表,方便继续支付
if (isUserCancelPay) {
skipFinallyResetPayLoading = true
setPayLoading(false)
const url = '/user/order/order?statusFilter=0'
try {
await Taro.redirectTo({ url })
} catch (_e) {
try {
await Taro.navigateTo({ url })
} catch (_e2) {
// ignore
}
}
return
}
const isOutOfDeliveryRange = const isOutOfDeliveryRange =
message.includes('不在配送范围') || message.includes('不在配送范围') ||
message.includes('配送范围') || message.includes('配送范围') ||
message.includes('电子围栏') || message.includes('电子围栏') ||
message.includes('围栏') message.includes('围栏')
// 配送范围类错误给出更友好的解释,并提供快捷入口去更换收货地址 // "配送范围"类错误给出更友好的解释,并提供快捷入口去更换收货地址
if (isOutOfDeliveryRange) { if (isOutOfDeliveryRange) {
try { try {
const res = await Taro.showModal({ const res = await Taro.showModal({
@@ -632,8 +727,10 @@ const OrderConfirm = () => {
Taro.showToast({ title: message || '支付失败,请重试', icon: 'none' }) Taro.showToast({ title: message || '支付失败,请重试', icon: 'none' })
} }
} finally { } finally {
if (!skipFinallyResetPayLoading) {
setPayLoading(false) setPayLoading(false)
} }
}
}; };
// 统一的数据加载函数 // 统一的数据加载函数
@@ -647,8 +744,8 @@ const OrderConfirm = () => {
// 分别加载数据,避免类型推断问题 // 分别加载数据,避免类型推断问题
let goodsRes: ShopGoods | null = null let goodsRes: ShopGoods | null = null
if (goodsId) { if (resolvedGoodsId) {
goodsRes = await getShopGoods(Number(goodsId)) goodsRes = await getShopGoods(resolvedGoodsId)
} }
const [addressRes, paymentRes] = await Promise.all([ const [addressRes, paymentRes] = await Promise.all([
@@ -659,9 +756,9 @@ const OrderConfirm = () => {
// 设置商品信息 // 设置商品信息
// 查询当前商品是否存在水票套票活动(失败/无数据时不影响正常下单) // 查询当前商品是否存在水票套票活动(失败/无数据时不影响正常下单)
let tpl: GltTicketTemplate | null = null let tpl: GltTicketTemplate | null = null
if (goodsId) { if (resolvedGoodsId) {
try { try {
tpl = await getGltTicketTemplateByGoodsId(Number(goodsId)) tpl = await getGltTicketTemplateByGoodsId(resolvedGoodsId)
} catch (e) { } catch (e) {
tpl = null tpl = null
} }
@@ -677,18 +774,41 @@ const OrderConfirm = () => {
const n = Number(tpl?.minBuyQty) const n = Number(tpl?.minBuyQty)
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 1 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 使用模板最小购买量) // 设置商品信息(若存在套票模板,则默认 canBuyNumber 使用模板最小购买量)
if (goodsRes) { if (goodsRes) {
const patchedGoods: ShopGoods = { ...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)) { if (tplActive && ((patchedGoods.canBuyNumber ?? 0) === 0)) {
patchedGoods.canBuyNumber = tplMinBuyQty patchedGoods.canBuyNumber = tplMinBuyQty
} }
setGoods(patchedGoods) setGoods(patchedGoods)
// 设置默认购买数量:优先使用 canBuyNumber否则使用 1 // 设置默认购买数量:优先使用 canBuyNumber其次使用路由参数 quantity否则使用 1
const initQty = (patchedGoods.canBuyNumber ?? 0) > 0 ? (patchedGoods.canBuyNumber as number) : 1 const fixedQty = (patchedGoods.canBuyNumber ?? 0) > 0 ? Number(patchedGoods.canBuyNumber) : undefined
setQuantity(initQty) 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) setTicketTemplate(tpl)
@@ -707,15 +827,26 @@ const OrderConfirm = () => {
setPayment(paymentRes[0]) setPayment(paymentRes[0])
} }
// 加载优惠券:使用初始数量对应的总价做推荐,避免默认数量变化导致推荐不准 // 加载优惠券:使用"初始数量"对应的总价做推荐,避免默认数量变化导致推荐不准
if (goodsRes) { if (goodsRes) {
const initQty = (() => { const initQty = (() => {
const n = Number(goodsRes?.canBuyNumber) const n = Number(goodsRes?.canBuyNumber)
if (Number.isFinite(n) && n > 0) return Math.floor(n) if (Number.isFinite(n) && n > 0) return Math.floor(n)
if (tplActive) return tplMinBuyQty 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) await loadUserCoupons(total)
} }
} catch (err) { } catch (err) {
@@ -734,12 +865,9 @@ const OrderConfirm = () => {
}) })
useEffect(() => { useEffect(() => {
// 切换商品时重置配送时间,避免沿用上一次选择
if (!isLoggedIn()) return if (!isLoggedIn()) return
setSendTime(getMinSendDate().toDate())
setSendTimePickerVisible(false)
loadAllData() loadAllData()
}, [goodsId]); }, [resolvedGoodsId, orderDataRaw]);
// 重新加载数据 // 重新加载数据
const handleRetry = () => { const handleRetry = () => {
@@ -765,6 +893,21 @@ const OrderConfirm = () => {
return ( return (
<div className={'order-confirm-page'}> <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> <CellGroup>
{ {
address && ( address && (
@@ -795,27 +938,6 @@ const OrderConfirm = () => {
</Cell> </Cell>
)} )}
</CellGroup> </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>*/} {/*<CellGroup>*/}
@@ -858,6 +980,8 @@ const OrderConfirm = () => {
value={quantity} value={quantity}
min={isTicketTemplateActive ? minBuyQty : 1} min={isTicketTemplateActive ? minBuyQty : 1}
max={goods.stock || 999} max={goods.stock || 999}
step={goods.step || 1}
readOnly
disabled={((goods.canBuyNumber ?? 0) !== 0) && !isTicketTemplateActive} disabled={((goods.canBuyNumber ?? 0) !== 0) && !isTicketTemplateActive}
onChange={handleQuantityChange} onChange={handleQuantityChange}
/> />
@@ -1110,23 +1234,6 @@ const OrderConfirm = () => {
<Gap height={50}/> <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'}> <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'}> <View className={'btn-bar flex justify-between items-center'}>
<div className={'flex flex-col justify-center items-start mx-4'}> <div className={'flex flex-col justify-center items-start mx-4'}>

View File

@@ -3,11 +3,12 @@ import {Cell, CellGroup, Image, Space, Button, Dialog} from '@nutui/nutui-react-
import Taro from '@tarojs/taro' import Taro from '@tarojs/taro'
import {View} from '@tarojs/components' import {View} from '@tarojs/components'
import {ShopOrder} from "@/api/shop/shopOrder/model"; import {ShopOrder} from "@/api/shop/shopOrder/model";
import {getShopOrder, updateShopOrder, refundShopOrder} from "@/api/shop/shopOrder"; import {getShopOrder, updateShopOrder} from "@/api/shop/shopOrder";
import {listShopOrderGoods} from "@/api/shop/shopOrderGoods"; import {listShopOrderGoods} from "@/api/shop/shopOrderGoods";
import {ShopOrderGoods} from "@/api/shop/shopOrderGoods/model"; import {ShopOrderGoods} from "@/api/shop/shopOrderGoods/model";
import dayjs from "dayjs"; import dayjs from "dayjs";
import PaymentCountdown from "@/components/PaymentCountdown"; import PaymentCountdown from "@/components/PaymentCountdown";
import {getShopOrderStatusText} from "@/utils/shopOrderStatus";
import './index.scss' import './index.scss'
// 申请退款:支付成功后仅允许在指定时间窗内发起(前端展示层限制,后端仍应校验) // 申请退款:支付成功后仅允许在指定时间窗内发起(前端展示层限制,后端仍应校验)
@@ -69,7 +70,7 @@ const OrderDetail = () => {
Taro.showLoading({ title: '提交中...' }) Taro.showLoading({ title: '提交中...' })
// 退款相关操作使用退款接口PUT /api/shop/shop-order/refund // 退款相关操作使用退款接口PUT /api/shop/shop-order/refund
await refundShopOrder({ await updateShopOrder({
orderId: order.orderId, orderId: order.orderId,
refundMoney: order.payPrice || order.totalPrice, refundMoney: order.payPrice || order.totalPrice,
orderStatus: 7 orderStatus: 7
@@ -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) => { const getPayTypeText = (payType?: number) => {
switch (payType) { switch (payType) {
case 0: case 0:
@@ -194,7 +164,7 @@ const OrderDetail = () => {
order.payStatus && order.payStatus &&
order.orderStatus !== 1 && order.orderStatus !== 1 &&
order.deliveryStatus === 20 && order.deliveryStatus === 20 &&
(!order.riderId || !!order.sendEndTime) (!order.riderId || Number(order.riderId) === 0 || !!order.sendEndTime)
return ( return (
<div className={'order-detail-page'}> <div className={'order-detail-page'}>
@@ -232,7 +202,7 @@ const OrderDetail = () => {
<CellGroup> <CellGroup>
<Cell title="订单编号" description={order.orderNo}/> <Cell title="订单编号" description={order.orderNo}/>
<Cell title="下单时间" description={dayjs(order.createTime).format('YYYY-MM-DD HH:mm:ss')}/> <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>
<CellGroup> <CellGroup>

View File

@@ -47,6 +47,7 @@ const AddUserAddress = () => {
const [FormData, setFormData] = useState<ShopUserAddress>({}) const [FormData, setFormData] = useState<ShopUserAddress>({})
const [inputText, setInputText] = useState<string>('') const [inputText, setInputText] = useState<string>('')
const [selectedLocation, setSelectedLocation] = useState<SelectedLocation | null>(null) const [selectedLocation, setSelectedLocation] = useState<SelectedLocation | null>(null)
const [regionLocked, setRegionLocked] = useState(false)
const formRef = useRef<any>(null) const formRef = useRef<any>(null)
const wxDraftRef = useRef<Partial<ShopUserAddress> | null>(null) const wxDraftRef = useRef<Partial<ShopUserAddress> | null>(null)
const wxDraftPatchedRef = useRef(false) const wxDraftPatchedRef = useRef(false)
@@ -120,7 +121,12 @@ const AddUserAddress = () => {
// 设置所在地区 // 设置所在地区
setText(`${address.province} ${address.city} ${address.region}`) setText(`${address.province} ${address.city} ${address.region}`)
// 回显已保存的经纬度(编辑模式) // 回显已保存的经纬度(编辑模式)
if (hasValidLngLat(address)) setSelectedLocation({ lng: String(address.lng), lat: String(address.lat) }) if (hasValidLngLat(address)) {
setSelectedLocation({ lng: String(address.lng), lat: String(address.lat) })
setRegionLocked(true)
} else {
setRegionLocked(false)
}
} catch (error) { } catch (error) {
console.error('加载地址失败:', error) console.error('加载地址失败:', error)
Taro.showToast({ Taro.showToast({
@@ -172,30 +178,39 @@ const AddUserAddress = () => {
const result = parseAddressText(inputText); const result = parseAddressText(inputText);
// 更新表单数据 // 更新表单数据
const newFormData = { const newFormData: any = {
...FormData, ...FormData,
name: result.name || FormData.name, name: result.name || FormData.name,
phone: result.phone || FormData.phone, phone: result.phone || FormData.phone,
address: result.address || FormData.address, address: result.address || FormData.address
province: result.province || FormData.province,
city: result.city || FormData.city,
region: result.region || FormData.region
}; };
if (!regionLocked) {
newFormData.province = result.province || FormData.province
newFormData.city = result.city || FormData.city
newFormData.region = result.region || FormData.region
}
setFormData(newFormData); setFormData(newFormData);
// 更新地区显示文本 // 更新地区显示文本
if (result.province && result.city && result.region) { if (!regionLocked && result.province && result.city && result.region) {
setText(`${result.province} ${result.city} ${result.region}`); setText(`${result.province} ${result.city} ${result.region}`);
} }
// 更新表单字段值 // 更新表单字段值
if (formRef.current) { if (formRef.current) {
formRef.current.setFieldsValue(newFormData); const patch: any = {
name: newFormData.name,
phone: newFormData.phone,
address: newFormData.address
}
if (!regionLocked && newFormData.region) patch.region = newFormData.region
formRef.current.setFieldsValue(patch);
} }
Taro.showToast({ Taro.showToast({
title: '识别成功', title: regionLocked ? '识别成功(所在地区以定位为准)' : '识别成功',
icon: 'success' icon: 'success'
}); });
@@ -311,7 +326,6 @@ const AddUserAddress = () => {
name: res.name, name: res.name,
address: res.address address: res.address
} }
setSelectedLocation(next)
// 尝试从地图返回的 address 文本解析省市区best-effort // 尝试从地图返回的 address 文本解析省市区best-effort
const regionResult = res?.provinceName || res?.cityName || res?.adName const regionResult = res?.provinceName || res?.cityName || res?.adName
@@ -322,15 +336,22 @@ const AddUserAddress = () => {
} }
: parseRegion(String(res.address || '')) : parseRegion(String(res.address || ''))
const province = String(regionResult?.province || '').trim()
const city = String(regionResult?.city || '').trim()
const region = String(regionResult?.region || '').trim()
if (!province || !city || !region) {
Taro.showToast({ title: '定位未识别到所在地区,请重新选择定位', icon: 'none' })
return
}
setSelectedLocation(next)
setRegionLocked(true)
// 将地图选点的地址同步到“收货地址”(不额外拼接省市区字段,省市区由独立字段保存) // 将地图选点的地址同步到“收货地址”(不额外拼接省市区字段,省市区由独立字段保存)
const nextDetailAddress = (() => { const nextDetailAddress = (() => {
const rawAddr = String(res.address || '').trim() const rawAddr = String(res.address || '').trim()
const name = String(res.name || '').trim() const name = String(res.name || '').trim()
const province = String(regionResult?.province || '').trim()
const city = String(regionResult?.city || '').trim()
const region = String(regionResult?.region || '').trim()
// 选择定位返回的 address 往往包含省市区,这里尽量剥离掉,避免和表单的省市区字段重复 // 选择定位返回的 address 往往包含省市区,这里尽量剥离掉,避免和表单的省市区字段重复
let detail = rawAddr let detail = rawAddr
for (const part of [province, city, region]) { for (const part of [province, city, region]) {
@@ -350,20 +371,18 @@ const AddUserAddress = () => {
lng: next.lng, lng: next.lng,
lat: next.lat, lat: next.lat,
address: nextDetailAddress || prev.address, address: nextDetailAddress || prev.address,
province: regionResult?.province || prev.province, province,
city: regionResult?.city || prev.city, city,
region: regionResult?.region || prev.region region
})) }))
if (regionResult?.province && regionResult?.city && regionResult?.region) { setText(`${province} ${city} ${region}`)
setText(`${regionResult.province} ${regionResult.city} ${regionResult.region}`)
}
// 更新表单展示值Form initialValues 不会跟随 FormData 变化) // 更新表单展示值Form initialValues 不会跟随 FormData 变化)
if (formRef.current) { if (formRef.current) {
const patch: any = {} const patch: any = {}
if (nextDetailAddress) patch.address = nextDetailAddress if (nextDetailAddress) patch.address = nextDetailAddress
if (regionResult?.region) patch.region = regionResult.region patch.region = region
formRef.current.setFieldsValue(patch) formRef.current.setFieldsValue(patch)
} }
} }
@@ -407,6 +426,14 @@ const AddUserAddress = () => {
} }
} }
const openRegionPicker = () => {
if (regionLocked) {
Taro.showToast({ title: '所在地区已由定位确定,修改请重新选择定位', icon: 'none' })
return
}
setVisible(true)
}
// 提交表单 // 提交表单
const submitSucceed = async (values: any) => { const submitSucceed = async (values: any) => {
const loc = const loc =
@@ -416,6 +443,10 @@ const AddUserAddress = () => {
Taro.showToast({ title: '请选择定位', icon: 'none' }) Taro.showToast({ title: '请选择定位', icon: 'none' })
return return
} }
if (!FormData.province || !FormData.city || !FormData.region) {
Taro.showToast({ title: '请先选择定位以自动填写所在地区', icon: 'none' })
return
}
try { try {
// 准备提交的数据 // 准备提交的数据
@@ -487,6 +518,12 @@ const AddUserAddress = () => {
}) })
}, [fromWx, isEditMode]); }, [fromWx, isEditMode]);
useEffect(() => {
if (!regionLocked) return
if (!visible) return
setVisible(false)
}, [regionLocked, visible])
// NutUI Form 的 initialValues 在首次渲染后不再响应更新;微信导入时做一次 setFieldsValue 兜底回填。 // NutUI Form 的 initialValues 在首次渲染后不再响应更新;微信导入时做一次 setFieldsValue 兜底回填。
useEffect(() => { useEffect(() => {
if (loading) return if (loading) return
@@ -523,7 +560,7 @@ const AddUserAddress = () => {
onFinishFailed={(errors) => submitFailed(errors)} onFinishFailed={(errors) => submitFailed(errors)}
> >
<CellGroup className={'px-3'}> <CellGroup className={'px-3'}>
<div <View
style={{ style={{
border: '1px dashed #22c55e', border: '1px dashed #22c55e',
display: 'flex', display: 'flex',
@@ -549,7 +586,7 @@ const AddUserAddress = () => {
> >
</Button> </Button>
</div> </View>
</CellGroup> </CellGroup>
<View className={'bg-gray-100 h-3'}></View> <View className={'bg-gray-100 h-3'}></View>
<CellGroup style={{padding: '4px 0'}}> <CellGroup style={{padding: '4px 0'}}>
@@ -581,10 +618,10 @@ const AddUserAddress = () => {
rules={[{message: '请输入您的所在地区'}]} rules={[{message: '请输入您的所在地区'}]}
required required
> >
<div className={'flex justify-between items-center'} onClick={() => setVisible(true)}> <View className={'flex justify-between items-center'} onClick={openRegionPicker}>
<Input placeholder="选择所在地区" value={text} disabled/> <Input placeholder="选择所在地区" value={text} disabled/>
<ArrowRight className={'text-gray-400'}/> <ArrowRight className={'text-gray-400'}/>
</div> </View>
</Form.Item> </Form.Item>
<Form.Item name="address" label="收货地址" initialValue={FormData.address} required> <Form.Item name="address" label="收货地址" initialValue={FormData.address} required>
<TextArea maxLength={50} placeholder="请输入详细收货地址"/> <TextArea maxLength={50} placeholder="请输入详细收货地址"/>
@@ -598,15 +635,15 @@ const AddUserAddress = () => {
(selectedLocation ? `经纬度:${selectedLocation.lng}, ${selectedLocation.lat}` : '用于计算是否超出配送范围') (selectedLocation ? `经纬度:${selectedLocation.lng}, ${selectedLocation.lat}` : '用于计算是否超出配送范围')
} }
extra={( extra={(
<div className={'flex items-center gap-2'}> <View className={'flex items-center gap-2'}>
<div <View
className={'text-gray-900 text-sm'} className={'text-gray-900 text-sm'}
style={{maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap'}} style={{maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap'}}
> >
{selectedLocation?.name || (selectedLocation ? '已选择' : '请选择')} {selectedLocation?.name || (selectedLocation ? '已选择' : '请选择')}
</div> </View>
<ArrowRight className={'text-gray-400'}/> <ArrowRight className={'text-gray-400'}/>
</div> </View>
)} )}
onClick={chooseGeoLocation} onClick={chooseGeoLocation}
/> />
@@ -618,6 +655,10 @@ const AddUserAddress = () => {
options={optionsDemo1} options={optionsDemo1}
title="选择地址" title="选择地址"
onChange={(value, _) => { onChange={(value, _) => {
if (regionLocked) {
Taro.showToast({ title: '所在地区已由定位确定,修改请重新选择定位', icon: 'none' })
return
}
setFormData({ setFormData({
...FormData, ...FormData,
province: `${value[0]}`, province: `${value[0]}`,

View File

@@ -16,6 +16,7 @@ import {copyText} from "@/utils/common";
import PaymentCountdown from "@/components/PaymentCountdown"; import PaymentCountdown from "@/components/PaymentCountdown";
import {PaymentType} from "@/utils/payment"; import {PaymentType} from "@/utils/payment";
import {ErrorType, RequestError} from "@/utils/request"; import {ErrorType, RequestError} from "@/utils/request";
import {getShopOrderStatusColor, getShopOrderStatusText, isShopOrderCompleted} from "@/utils/shopOrderStatus";
// 判断订单是否支付已过期 // 判断订单是否支付已过期
const isPaymentExpired = (createTime: string, timeoutHours: number = 24): boolean => { const isPaymentExpired = (createTime: string, timeoutHours: number = 24): boolean => {
@@ -104,6 +105,10 @@ interface OrderListProps {
baseParams?: ShopOrderParam; baseParams?: ShopOrderParam;
// 只读模式:隐藏“支付/取消/确认收货/退款”等用户操作按钮 // 只读模式:隐藏“支付/取消/确认收货/退款”等用户操作按钮
readOnly?: boolean; readOnly?: boolean;
// 是否自动取消“支付已过期”的待支付订单(仅 user 模式生效)
autoCancelExpired?: boolean;
// 支付超时时间(小时),默认 24 小时
paymentTimeoutHours?: number;
} }
function OrderList(props: OrderListProps) { function OrderList(props: OrderListProps) {
@@ -111,6 +116,8 @@ function OrderList(props: OrderListProps) {
const pageRef = useRef(1) const pageRef = useRef(1)
const [hasMore, setHasMore] = useState(true) const [hasMore, setHasMore] = useState(true)
const [payingOrderId, setPayingOrderId] = useState<number | null>(null) const [payingOrderId, setPayingOrderId] = useState<number | null>(null)
const autoCanceledOrderIdsRef = useRef<Set<number>>(new Set())
const autoCancelRunningRef = useRef(false)
// 根据传入的statusFilter设置初始tab索引 // 根据传入的statusFilter设置初始tab索引
const getInitialTabIndex = () => { const getInitialTabIndex = () => {
if (props.searchParams?.statusFilter !== undefined) { if (props.searchParams?.statusFilter !== undefined) {
@@ -138,69 +145,32 @@ function OrderList(props: OrderListProps) {
return Number.isFinite(n) ? n : undefined; return Number.isFinite(n) ? n : undefined;
}; };
const parseTime = (raw: any): dayjs.Dayjs | null => {
const text = String(raw ?? '').trim();
if (!text) return null;
const t = /^\d+$/.test(text)
? dayjs(Number(text) < 1e12 ? Number(text) * 1000 : Number(text))
: dayjs(text);
return t.isValid() ? t : null;
};
const isOrderPaymentExpiredSafe = (order: ShopOrder, timeoutHours: number) => {
if (order.payStatus) return false;
if (toNum(order.orderStatus) === 2) return false;
const expiration = parseTime(order.expirationTime);
if (expiration) return dayjs().isAfter(expiration);
if (order.createTime) return isPaymentExpired(order.createTime, timeoutHours);
return false;
};
// “已完成”应以订单状态为准不要用商品ID等字段推断完成态否则会造成 Tab(待发货/待收货) 与状态文案不同步 // “已完成”应以订单状态为准不要用商品ID等字段推断完成态否则会造成 Tab(待发货/待收货) 与状态文案不同步
const isOrderCompleted = (order: ShopOrder) => toNum(order.orderStatus) === 1; const isOrderCompleted = (order: ShopOrder) => isShopOrderCompleted(order);
// 获取订单状态文本 const getOrderStatusText = (order: ShopOrder) => getShopOrderStatusText(order);
const getOrderStatusText = (order: ShopOrder) => {
const orderStatus = toNum(order.orderStatus);
const deliveryStatus = toNum(order.deliveryStatus);
// 优先检查订单状态 const getOrderStatusColor = (order: ShopOrder) => getShopOrderStatusColor(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'; // 默认颜色
};
// 使用后端统一的 statusFilter 进行筛选 // 使用后端统一的 statusFilter 进行筛选
const getOrderStatusParams = (index: string | number) => { const getOrderStatusParams = (index: string | number) => {
@@ -249,23 +219,81 @@ function OrderList(props: OrderListProps) {
}); });
try { try {
const res = await pageShopOrder(searchConditions); const timeoutHours = typeof props.paymentTimeoutHours === 'number' ? props.paymentTimeoutHours : 24;
const canAutoCancelExpired =
!!props.autoCancelExpired &&
(!props.mode || props.mode === 'user') &&
!props.readOnly;
const isPendingPayList = statusParams.statusFilter === 0;
if (res?.list && res?.list.length > 0) { const fetchOrders = async () => pageShopOrder(searchConditions);
let res = await fetchOrders();
let incoming = (res?.list || []) as ShopOrder[];
let rawIncomingLength = incoming.length;
// 自动取消“支付已过期”的待支付订单(避免用户看到一堆不可支付的过期单)
if (canAutoCancelExpired && incoming.length && !autoCancelRunningRef.current) {
const expiredToCancel = incoming
.filter(o => !!o?.orderId)
.filter(o => !autoCanceledOrderIdsRef.current.has(o.orderId as number))
.filter(o => isOrderPaymentExpiredSafe(o, timeoutHours));
if (expiredToCancel.length) {
autoCancelRunningRef.current = true;
const justCanceled = new Set<number>();
try {
// 单次最多处理 20 笔,避免接口风暴
for (const order of expiredToCancel.slice(0, 20)) {
try {
await updateShopOrder({ orderId: order.orderId, orderStatus: 2 });
autoCanceledOrderIdsRef.current.add(order.orderId as number);
justCanceled.add(order.orderId as number);
} catch (e) {
console.warn('自动取消过期订单失败:', order?.orderId, e);
}
}
} finally {
autoCancelRunningRef.current = false;
}
if (justCanceled.size > 0) {
if (resetPage) {
// resetPage 时重新拉取一次,确保列表状态与服务端一致
res = await fetchOrders();
incoming = (res?.list || []) as ShopOrder[];
rawIncomingLength = incoming.length;
Taro.showToast({ title: '已自动取消过期订单', icon: 'none' });
} else {
// loadMore 时不重新拉取,避免破坏滚动;仅在本地列表中做最小同步
if (isPendingPayList) {
incoming = incoming.filter(o => !justCanceled.has(o.orderId as number));
} else {
incoming = incoming.map(o => (
justCanceled.has(o.orderId as number) ? { ...o, orderStatus: 2 } : o
));
}
}
}
}
}
if (rawIncomingLength > 0) {
// 订单分页接口已返回 orderGoods列表直接使用该字段 // 订单分页接口已返回 orderGoods列表直接使用该字段
const incoming = res.list as ShopOrder[];
// 使用函数式更新避免依赖 list // 使用函数式更新避免依赖 list
setList(prevList => { if (incoming.length > 0) {
const newList = resetPage ? incoming : (prevList || []).concat(incoming); setList(prevList => (resetPage ? incoming : (prevList || []).concat(incoming)));
return newList; } else {
}); // 本页数据全部被自动取消过滤掉:不清空历史列表,仅保持现状
setList(prevList => (resetPage ? [] : prevList));
}
// 正确判断是否还有更多数据 // 正确判断是否还有更多数据(以服务端返回条数为准)
const hasMoreData = incoming.length >= 10; // 假设每页10条数据 const hasMoreData = rawIncomingLength >= 10; // 假设每页10条数据
setHasMore(hasMoreData); setHasMore(hasMoreData);
} else { } else {
setList(prevList => resetPage ? [] : prevList); // 服务端已无更多数据
setList(prevList => (resetPage ? [] : prevList));
setHasMore(false); setHasMore(false);
} }
@@ -281,7 +309,7 @@ function OrderList(props: OrderListProps) {
icon: 'none' icon: 'none'
}); });
} }
}, [tapIndex, props.searchParams]); // 移除 list/page 依赖避免useEffect触发循环 }, [tapIndex, props.searchParams, props.baseParams, props.mode, props.readOnly, props.autoCancelExpired, props.paymentTimeoutHours]); // 移除 list/page 依赖避免useEffect触发循环
const reloadMore = useCallback(async () => { const reloadMore = useCallback(async () => {
if (loading || !hasMore) return; // 防止重复加载 if (loading || !hasMore) return; // 防止重复加载
@@ -820,12 +848,12 @@ function OrderList(props: OrderListProps) {
<Button size={'small'} onClick={(e) => { <Button size={'small'} onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
void cancelOrder(item); void cancelOrder(item);
}}></Button> }}></Button>
{(!item.createTime || !isPaymentExpired(item.createTime, 24)) && ( {(!item.createTime || !isPaymentExpired(item.createTime, 24)) && (
<Button size={'small'} type="primary" onClick={(e) => { <Button size={'small'} type="primary" onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
void payOrder(item); void payOrder(item);
}}></Button> }}></Button>
)} )}
</Space> </Space>
)} )}

View File

@@ -164,6 +164,7 @@ function Order() {
onReload={() => reload(searchParams)} onReload={() => reload(searchParams)}
searchParams={searchParams} searchParams={searchParams}
showSearch={showSearch} showSearch={showSearch}
autoCancelExpired
onSearchParamsChange={(newParams) => { onSearchParamsChange={(newParams) => {
console.log('父组件接收到searchParams变化:', newParams); console.log('父组件接收到searchParams变化:', newParams);
setSearchParams(newParams); setSearchParams(newParams);

View File

@@ -14,15 +14,16 @@ import {
Tag Tag
} from '@nutui/nutui-react-taro'; } from '@nutui/nutui-react-taro';
import { View, Text, Image } from '@tarojs/components'; 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 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 type { GltTicketOrder } from '@/api/glt/gltTicketOrder/model';
import { getShopUserAddress } from '@/api/shop/shopUserAddress';
import { BaseUrl } from '@/config/app'; import { BaseUrl } from '@/config/app';
import dayjs from "dayjs"; import dayjs from "dayjs";
import { ensureLoggedIn } from '@/utils/auth';
const PAGE_SIZE = 10; const PAGE_SIZE = 10;
const PAY_REFRESH_HANDLED_KEY = 'user_ticket_from_pay_at_handled';
const UserTicketList = () => { const UserTicketList = () => {
const [ticketList, setTicketList] = useState<GltUserTicket[]>([]); const [ticketList, setTicketList] = useState<GltUserTicket[]>([]);
@@ -37,6 +38,7 @@ const UserTicketList = () => {
const [orderHasMore, setOrderHasMore] = useState(true); const [orderHasMore, setOrderHasMore] = useState(true);
const [orderPage, setOrderPage] = useState(1); const [orderPage, setOrderPage] = useState(1);
const [orderTotal, setOrderTotal] = useState(0); const [orderTotal, setOrderTotal] = useState(0);
const [orderCancelLoadingById, setOrderCancelLoadingById] = useState<Record<number, boolean>>({});
const [activeTab, setActiveTab] = useState<'ticket' | 'order'>(() => { const [activeTab, setActiveTab] = useState<'ticket' | 'order'>(() => {
const tab = Taro.getCurrentInstance().router?.params?.tab const tab = Taro.getCurrentInstance().router?.params?.tab
@@ -46,8 +48,25 @@ const UserTicketList = () => {
const [qrVisible, setQrVisible] = useState(false); const [qrVisible, setQrVisible] = useState(false);
const [qrTicket, setQrTicket] = useState<GltUserTicket | null>(null); const [qrTicket, setQrTicket] = useState<GltUserTicket | null>(null);
const [qrImageUrl, setQrImageUrl] = useState(''); 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 getUserId = () => {
const raw = Taro.getStorageSync('UserId'); const raw = Taro.getStorageSync('UserId');
@@ -97,6 +116,41 @@ const UserTicketList = () => {
setQrVisible(true); 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 showTicketDetail = (ticket: GltUserTicket) => {
const lines: string[] = []; const lines: string[] = [];
if (ticket.templateName) lines.push(`水票:${ticket.templateName}`); if (ticket.templateName) lines.push(`水票:${ticket.templateName}`);
@@ -188,17 +242,16 @@ const UserTicketList = () => {
}); });
const resList = res?.list || []; 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); setOrderList(nextList);
const count = typeof res?.count === 'number' ? res.count : nextList.length; const serverCount = typeof res?.count === 'number' ? res.count : undefined;
setOrderTotal(count); const total = typeof serverCount === 'number' ? serverCount : nextList.length;
setOrderHasMore(nextList.length < count); setOrderTotal(total);
setOrderHasMore(typeof serverCount === 'number' ? nextList.length < serverCount : resList.length >= PAGE_SIZE);
if (resList.length > 0) { if (resList.length > 0) setOrderPage(currentPage + 1);
setOrderPage(currentPage + 1); else setOrderHasMore(false);
} else {
setOrderHasMore(false);
}
} catch (error) { } catch (error) {
console.error('获取送水订单失败:', error); console.error('获取送水订单失败:', error);
Taro.showToast({ title: '获取送水订单失败', icon: 'error' }); Taro.showToast({ title: '获取送水订单失败', icon: 'error' });
@@ -265,78 +318,184 @@ const UserTicketList = () => {
return d.isValid() ? d.format('YYYY年MM月DD日') : v; return d.isValid() ? d.format('YYYY年MM月DD日') : v;
}; };
const parseLatLng = (latRaw?: unknown, lngRaw?: unknown) => { const getTicketAvailableQty = (t?: Partial<GltUserTicket> | null) => {
const lat = typeof latRaw === 'number' ? latRaw : parseFloat(String(latRaw ?? '')); if (!t) return 0;
const lng = typeof lngRaw === 'number' ? lngRaw : parseFloat(String(lngRaw ?? '')); const anyT: any = t;
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null; const raw =
if (Math.abs(lat) > 90 || Math.abs(lng) > 180) return null; anyT.availableQty ??
return { lat, lng }; 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) => { const getTicketUsedQty = (t?: Partial<GltUserTicket> | null) => {
try { if (!t) return 0;
// Prefer coordinates from backend if present (non-typed fields), otherwise fetch by addressId. const anyT: any = t;
const anyOrder = order as any; const raw = anyT.usedQty ?? anyT.usedNum ?? anyT.usedCount;
const direct = const n = Number(raw);
parseLatLng(anyOrder?.addressLat ?? anyOrder?.lat, anyOrder?.addressLng ?? anyOrder?.lng) || return Number.isFinite(n) ? n : 0;
parseLatLng(anyOrder?.receiverLat, anyOrder?.receiverLng); };
let coords = direct; const rollbackUserTicketAfterOrderCancel = async (order: GltTicketOrder, before?: GltUserTicket | null) => {
let fullAddress: string | undefined = order.address || undefined; 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 rollbackKey = `glt_ticket_order_rollback:${orderId}`;
const cached = addressCacheRef.current[order.addressId]; if (Taro.getStorageSync(rollbackKey)) return;
if (cached) {
coords = { lat: cached.lat, lng: cached.lng }; const after = await getGltUserTicket(ticketId);
fullAddress = fullAddress || cached.fullAddress; if (!after?.id) return;
} else if (cached === null) {
coords = null; const beforeAvail = before ? getTicketAvailableQty(before) : undefined;
} else { const afterAvail = getTicketAvailableQty(after);
const addr = await getShopUserAddress(order.addressId); const beforeUsed = before ? getTicketUsedQty(before) : undefined;
const parsed = parseLatLng(addr?.lat, addr?.lng); const afterUsed = getTicketUsedQty(after);
if (parsed) {
coords = parsed; let needAvail = qty;
fullAddress = fullAddress || addr?.fullAddress || addr?.address || undefined; if (typeof beforeAvail === 'number') {
addressCacheRef.current[order.addressId] = { ...parsed, fullAddress }; const delta = afterAvail - beforeAvail;
} else { if (delta >= qty) {
addressCacheRef.current[order.addressId] = null; 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 (needAvail <= 0 && needUsed <= 0) {
if (fullAddress) { Taro.setStorageSync(rollbackKey, Date.now());
await Taro.setClipboardData({ data: fullAddress });
Taro.showToast({ title: '未配置定位,地址已复制', icon: 'none' });
} else {
Taro.showToast({ title: '暂无可导航的地址', icon: 'none' });
}
return; return;
} }
Taro.openLocation({ const currentAvailRaw = Number((after as any)?.availableQty);
latitude: coords.lat, const baseAvail = Number.isFinite(currentAvailRaw) ? currentAvailRaw : afterAvail;
longitude: coords.lng, const safeBaseAvail = Number.isFinite(baseAvail) ? baseAvail : 0;
name: '收货地址',
address: fullAddress || '' 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.setStorageSync(rollbackKey, Date.now());
Taro.showToast({ title: '导航失败,请重试', icon: 'none' });
}
}; };
const handleOneClickCall = async (order: GltTicketOrder) => { // Allow users to modify/cancel before delivery starts (e.g. 待派单 / 待配送).
const phone = (order.riderPhone || order.storePhone || '').trim(); const isTicketOrderPendingDelivery = (order: GltTicketOrder) => {
if (!phone) { if (!order?.id) return false;
Taro.showToast({ title: '暂无可呼叫的电话', icon: 'none' }); 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; 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 { 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) { } catch (e) {
console.error('一键呼叫失败:', e); await removeGltTicketOrder(order.id);
Taro.showToast({ title: '呼叫失败,请手动拨打', icon: 'none' }); }
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(() => { useDidShow(() => {
if (activeTab === 'ticket') { void (async () => {
reloadTickets(true).then(); const tabParam = Taro.getCurrentInstance().router?.params?.tab;
} else { const nextTab = tabParam === 'ticket' || tabParam === 'order' ? tabParam : undefined;
reloadOrders(true).then();
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 ( return (
<ConfigProvider> <ConfigProvider>
@@ -479,6 +663,9 @@ const UserTicketList = () => {
<Text className="text-base font-semibold text-gray-900"> <Text className="text-base font-semibold text-gray-900">
{item.id} {item.id}
</Text> </Text>
<View className="mt-1">
<Text className="text-xs text-gray-500">{item.templateName}</Text>
</View>
{item.orderNo && ( {item.orderNo && (
<View className="mt-1"> <View className="mt-1">
<Text className="text-xs text-gray-500">{item.orderNo}</Text> <Text className="text-xs text-gray-500">{item.orderNo}</Text>
@@ -490,13 +677,25 @@ const UserTicketList = () => {
</View> </View>
)} )}
</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'}>*/} {/*<Tag type={item.status === 1 ? 'danger' : 'success'}>*/}
{/* {item.status === 1 ? '冻结' : '正常'}*/} {/* {item.status === 1 ? '冻结' : '正常'}*/}
{/*</Tag>*/} {/*</Tag>*/}
<Button <Button
size="small" size="small"
type="primary" type="primary"
style={{ display: 'none'}}
disabled={(item.availableQty ?? 0) <= 0 || item.status === 1} disabled={(item.availableQty ?? 0) <= 0 || item.status === 1}
onClick={(e) => { onClick={(e) => {
// Avoid triggering card click. // 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-lg text-gray-900 text-center">{item.usedQty ?? 0}</Text>
<Text className="text-xs text-gray-500"></Text> <Text className="text-xs text-gray-500"></Text>
</View> </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-lg text-gray-900 text-center">{item.frozenQty ?? 0}</Text>
<Text className="text-xs text-gray-500"></Text> <Text className="text-xs text-gray-500"></Text>
</View> </View>
@@ -576,32 +782,6 @@ const UserTicketList = () => {
<View className="mt-1"> <View className="mt-1">
<Text className="text-xs text-gray-500">{formatDateTime(item.createTime)}</Text> <Text className="text-xs text-gray-500">{formatDateTime(item.createTime)}</Text>
</View> </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 ? (*/} {/*{item.storeName ? (*/}
{/* <View className="mt-1 text-xs text-gray-500">*/} {/* <View className="mt-1 text-xs text-gray-500">*/}
{/* <Text>门店:{item.storeName}</Text>*/} {/* <Text>门店:{item.storeName}</Text>*/}
@@ -638,6 +818,38 @@ const UserTicketList = () => {
</Button> </Button>
</View> </View>
) : null} ) : 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> </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 { .coupon-popup {
height: 100%; height: 100%;

View File

@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import Taro, { useDidShow } from '@tarojs/taro' import Taro, { useDidShow } from '@tarojs/taro'
import { View, Text } from '@tarojs/components' import { View, Text, Picker } from '@tarojs/components'
import { import {
Button, Button,
Cell, Cell,
@@ -13,10 +13,10 @@ import {
Space Space
} from '@nutui/nutui-react-taro' } from '@nutui/nutui-react-taro'
import { ArrowRight, Location, Ticket } from '@nutui/icons-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 type { ShopGoods } from '@/api/shop/shopGoods/model'
import { getShopGoods } from '@/api/shop/shopGoods' import { getShopGoods } from '@/api/shop/shopGoods'
import { listShopUserAddress } from '@/api/shop/shopUserAddress' import { getShopUserAddress, listShopUserAddress } from '@/api/shop/shopUserAddress'
import type { ShopUserAddress } from '@/api/shop/shopUserAddress/model' import type { ShopUserAddress } from '@/api/shop/shopUserAddress/model'
import './use.scss' import './use.scss'
import Gap from "@/components/Gap"; import Gap from "@/components/Gap";
@@ -25,23 +25,26 @@ import type {ShopStore} from "@/api/shop/shopStore/model";
import {getShopStore, listShopStore} from "@/api/shop/shopStore"; import {getShopStore, listShopStore} from "@/api/shop/shopStore";
import {getSelectedStoreFromStorage, saveSelectedStoreToStorage} from "@/utils/storeSelection"; import {getSelectedStoreFromStorage, saveSelectedStoreToStorage} from "@/utils/storeSelection";
import type { GltUserTicket } from '@/api/glt/gltUserTicket/model' import type { GltUserTicket } from '@/api/glt/gltUserTicket/model'
import { listGltUserTicket } from '@/api/glt/gltUserTicket' import { getGltUserTicket, listGltUserTicket } from '@/api/glt/gltUserTicket'
import { addGltTicketOrder } from '@/api/glt/gltTicketOrder' 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 type { ShopStoreRider } from '@/api/shop/shopStoreRider/model'
import { listShopStoreRider } from '@/api/shop/shopStoreRider' import { listShopStoreRider } from '@/api/shop/shopStoreRider'
import type { ShopStoreFence } from '@/api/shop/shopStoreFence/model' import type { ShopStoreFence } from '@/api/shop/shopStoreFence/model'
import { listShopStoreFence } from '@/api/shop/shopStoreFence' import { listShopStoreFence } from '@/api/shop/shopStoreFence'
import { parseFencePoints, parseLngLatFromText, pointInAnyPolygon, pointInPolygon } from '@/utils/geofence' import { parseFencePoints, parseLngLatFromText, pointInAnyPolygon, pointInPolygon } from '@/utils/geofence'
const MIN_START_QTY = 10 const DEFAULT_MIN_START_QTY = 10
const OrderConfirm = () => { const OrderConfirm = () => {
const [goods, setGoods] = useState<ShopGoods | null>(null); const [goods, setGoods] = useState<ShopGoods | null>(null);
const [address, setAddress] = useState<ShopUserAddress>() 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>('') const [orderRemark, setOrderRemark] = useState<string>('')
// Delivery date only (no hour/min selection). // 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 [loading, setLoading] = useState<boolean>(true)
const [error, setError] = useState<string>('') const [error, setError] = useState<string>('')
const [submitLoading, setSubmitLoading] = useState<boolean>(false) const [submitLoading, setSubmitLoading] = useState<boolean>(false)
@@ -72,6 +75,8 @@ const OrderConfirm = () => {
const [ticketLoading, setTicketLoading] = useState(false) const [ticketLoading, setTicketLoading] = useState(false)
const [ticketLoaded, setTicketLoaded] = useState(false) const [ticketLoaded, setTicketLoaded] = useState(false)
const noTicketPromptedRef = useRef(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. // Delivery range (geofence): block ordering if address/current location is outside.
const [fences, setFences] = useState<ShopStoreFence[]>([]) const [fences, setFences] = useState<ShopStoreFence[]>([])
@@ -84,19 +89,75 @@ const OrderConfirm = () => {
// Prevent using stale `inDeliveryRange` from a previous address when user switches addresses. // Prevent using stale `inDeliveryRange` from a previous address when user switches addresses.
const [deliveryRangeCheckedAddressId, setDeliveryRangeCheckedAddressId] = useState<number | undefined>(undefined) 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 router = Taro.getCurrentInstance().router;
const goodsId = router?.params?.goodsId; const goodsId = router?.params?.goodsId;
const orderId = router?.params?.orderId;
const numericGoodsId = useMemo(() => { const numericGoodsId = useMemo(() => {
const n = goodsId ? Number(goodsId) : undefined const n = goodsId ? Number(goodsId) : undefined
return typeof n === 'number' && Number.isFinite(n) ? n : undefined return typeof n === 'number' && Number.isFinite(n) ? n : undefined
}, [goodsId]) }, [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 userId = useMemo(() => {
const raw = Taro.getStorageSync('UserId') const raw = Taro.getStorageSync('UserId')
const id = Number(raw) const id = Number(raw)
return Number.isFinite(id) && id > 0 ? id : undefined return Number.isFinite(id) && id > 0 ? id : undefined
}, []) }, [])
const parseTime = (raw?: unknown) => {
if (raw === undefined || raw === null || raw === '') return null
// Compatible with seconds/milliseconds timestamps.
if (typeof raw === 'number' || (typeof raw === 'string' && /^\d+$/.test(raw))) {
const n = Number(raw)
if (!Number.isFinite(n)) return null
return dayjs(n < 1e12 ? n * 1000 : n)
}
const d = dayjs(raw as any)
return d.isValid() ? d : null
}
const clampSendDateToToday = (d: Dayjs) => {
const today = dayjs().startOf('day')
if (!d.isValid()) return today
return d.isBefore(today, 'day') ? today : d.startOf('day')
}
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) => { const getTicketAvailableQty = (t?: Partial<GltUserTicket> | null) => {
if (!t) return 0 if (!t) return 0
const anyT: any = t const anyT: any = t
@@ -164,19 +225,63 @@ const OrderConfirm = () => {
return !!userId && ticketLoaded && !ticketLoading && usableTickets.length === 0 return !!userId && ticketLoaded && !ticketLoading && usableTickets.length === 0
}, [ticketLoaded, ticketLoading, usableTickets.length, userId]) }, [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 maxQuantity = useMemo(() => {
const stockMax = goods?.stock ?? 999 const stockMax = goods?.stock ?? 999
return Math.max(0, Math.min(stockMax, availableTicketTotal)) if (!isEditMode) return Math.max(0, Math.min(stockMax, availableTicketTotal))
}, [availableTicketTotal, goods?.stock])
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(() => { const canStartOrder = useMemo(() => {
return maxQuantity >= MIN_START_QTY return maxQuantity >= minStartQty
}, [maxQuantity]) }, [maxQuantity, minStartQty])
const displayQty = useMemo(() => { const displayQty = useMemo(() => {
if (!canStartOrder) return 0 if (!canStartOrder) return 0
return Math.max(MIN_START_QTY, Math.min(quantity, maxQuantity)) return Math.max(minStartQty, Math.min(quantity, maxQuantity))
}, [quantity, maxQuantity, canStartOrder]) }, [quantity, maxQuantity, canStartOrder, minStartQty])
const sendTimeText = useMemo(() => { const sendTimeText = useMemo(() => {
return dayjs(sendTime).format('YYYY-MM-DD') return dayjs(sendTime).format('YYYY-MM-DD')
@@ -199,6 +304,20 @@ const OrderConfirm = () => {
return parseLngLatFromText((s.lngAndLat || s.location || '').trim()) return parseLngLatFromText((s.lngAndLat || s.location || '').trim())
} }
const openAddressPage = async () => {
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' })
}
const loadFences = async (): Promise<ShopStoreFence[]> => { const loadFences = async (): Promise<ShopStoreFence[]> => {
if (fencesLoadedRef.current) return fences if (fencesLoadedRef.current) return fences
if (fencesPromiseRef.current) return fencesPromiseRef.current if (fencesPromiseRef.current) return fencesPromiseRef.current
@@ -429,7 +548,7 @@ const OrderConfirm = () => {
setQuantity(0) setQuantity(0)
return 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 () => { const loadUserTickets = async () => {
@@ -473,17 +592,37 @@ const OrderConfirm = () => {
const onSubmit = async () => { const onSubmit = async () => {
if (submitLoading) return if (submitLoading) return
if (deliveryRangeCheckingRef.current) return if (deliveryRangeCheckingRef.current) return
if (!goods?.goodsId) return
// 基础校验 // 基础校验
if (!userId) { if (!userId) {
Taro.showToast({ title: '请先登录', icon: 'none' }) Taro.showToast({ title: '请先登录', icon: 'none' })
return return
} }
if (isEditMode && !editingOrder?.id) {
Taro.showToast({ title: '订单信息加载中,请稍后重试', icon: 'none' })
return
}
if (isEditMode && !isPendingDeliveryOrder(editingOrder)) {
Taro.showToast({ title: '该订单当前不可修改', icon: 'none' })
return
}
if (!address?.id) { if (!address?.id) {
Taro.showToast({ title: '请选择收货地址', icon: 'none' }) Taro.showToast({ title: '请选择收货地址', icon: 'none' })
return return
} }
// 配送方式校验(必选)
if (!deliveryMethod) {
Taro.showToast({ title: '请选择配送方式', icon: 'none' })
return
}
// 步梯场景:必须选择是否送上楼
if (deliveryMethod === 'stairs' && needCarryUpstairs === null) {
Taro.showToast({ title: '请选择是否需要送上楼', icon: 'none' })
return
}
if (!addressHasCoords) { if (!addressHasCoords) {
Taro.showToast({ title: '该收货地址缺少经纬度,请在地址里选择地图定位后重试', icon: 'none' }) Taro.showToast({ title: '该收货地址缺少经纬度,请在地址里选择地图定位后重试', icon: 'none' })
return return
@@ -503,7 +642,7 @@ const OrderConfirm = () => {
Taro.showToast({ title: '未找到可配送门店,请先在首页选择门店或联系管理员配置门店坐标', icon: 'none' }) Taro.showToast({ title: '未找到可配送门店,请先在首页选择门店或联系管理员配置门店坐标', icon: 'none' })
return return
} }
if (availableTicketTotal <= 0) { if (!isEditMode && availableTicketTotal <= 0) {
Taro.showToast({ title: '暂无可用水票', icon: 'none' }) Taro.showToast({ title: '暂无可用水票', icon: 'none' })
return return
} }
@@ -513,30 +652,44 @@ const OrderConfirm = () => {
Taro.showToast({ title: '请选择送水数量', icon: 'none' }) Taro.showToast({ title: '请选择送水数量', icon: 'none' })
return return
} }
if (finalQty > availableTicketTotal) { if (!isEditMode && finalQty > availableTicketTotal) {
Taro.showToast({ title: '水票可用次数不足', icon: 'none' }) Taro.showToast({ title: '水票可用次数不足', icon: 'none' })
return 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' }) Taro.showToast({ title: '商品库存不足', icon: 'none' })
return return
} }
if (finalQty < MIN_START_QTY) { if (finalQty < minStartQty) {
Taro.showToast({ title: `最低起送 ${MIN_START_QTY}`, icon: 'none' }) Taro.showToast({ title: `最低起送 ${minStartQty}`, icon: 'none' })
return return
} }
if (!sendTime) { if (!sendTime) {
Taro.showToast({ title: '请选择配送时间', icon: 'none' }) Taro.showToast({ title: '请选择配送时间', icon: 'none' })
return return
} }
if (dayjs(sendTime).isBefore(dayjs().startOf('day'), 'day')) {
Taro.showToast({ title: '配送时间不能早于今天', icon: 'none' })
setSendTime(dayjs().startOf('day').toDate())
return
}
// 配送范围校验(电子围栏) // 配送范围校验(电子围栏)
const ok = await ensureInDeliveryRange() const ok = await ensureInDeliveryRange()
if (!ok) return 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({ const confirmRes = await Taro.showModal({
title: '确认下单', title: isEditMode ? '确认修改' : '确认下单',
content: `配送时间:${sendTimeText}\n将使用 ${finalQty} 张水票下单(优先使用可用数量少的水票),送水 ${finalQty} 桶,是否确认?` content: confirmContent
}) })
if (!confirmRes.confirm) return if (!confirmRes.confirm) return
@@ -544,13 +697,24 @@ const OrderConfirm = () => {
setSubmitLoading(true) setSubmitLoading(true)
Taro.showLoading({ title: '提交中...' }) 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. // Best-effort auto dispatch rider. If it fails, backend/manual dispatch can still handle it.
const autoRider = storeForOrder.id ? await resolveAutoRiderForStore(storeForOrder.id) : null const autoRider = storeForOrder.id ? await resolveAutoRiderForStore(storeForOrder.id) : null
// Split this "delivery" into multiple ticket orders (backend order model binds to one userTicketId). // Split this "delivery" into multiple ticket orders (backend order model binds to one userTicketId).
// Consume tickets with smaller available qty first. // Consume tickets with smaller available qty first.
let remain = finalQty let remain = finalQty
let created = 0
for (const t of ticketsToConsume) { for (const t of ticketsToConsume) {
if (remain <= 0) break if (remain <= 0) break
const avail = getTicketAvailableQty(t) const avail = getTicketAvailableQty(t)
@@ -568,27 +732,31 @@ const OrderConfirm = () => {
riderId: Number.isFinite(Number(autoRider?.userId)) ? Number(autoRider?.userId) : undefined, riderId: Number.isFinite(Number(autoRider?.userId)) ? Number(autoRider?.userId) : undefined,
riderName: autoRider?.realName, riderName: autoRider?.realName,
riderPhone: autoRider?.mobile, 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 remain -= useQty
created += 1
} }
if (remain > 0) { if (remain > 0) {
// Ticket counts might have changed between loading and submission. // Ticket counts might have changed between loading and submission.
throw new Error('水票可用次数不足,请刷新后重试') throw new Error('水票可用次数不足,请刷新后重试')
} }
}
await loadUserTickets() await loadUserTickets()
Taro.showToast({ title: created > 1 ? '下单成功(已拆分多张水票)' : '下单成功', icon: 'success' }) Taro.showToast({ title: isEditMode ? '修改成功' : '下单成功', icon: 'success' })
setTimeout(() => { setTimeout(() => {
// 跳转到“我的送水订单” // 跳转到“我的送水订单”
Taro.redirectTo({ url: '/user/ticket/index?tab=order' }) Taro.redirectTo({ url: '/user/ticket/index?tab=order' })
}, 800) }, 800)
} catch (e: any) { } catch (e: any) {
console.error('水票下单失败:', e) console.error(isEditMode ? '送水订单修改失败:' : '水票下单失败:', e)
Taro.showToast({ title: e?.message || '下单失败', icon: 'none' }) Taro.showToast({ title: e?.message || (isEditMode ? '修改失败' : '下单失败'), icon: 'none' })
} finally { } finally {
Taro.hideLoading() Taro.hideLoading()
setSubmitLoading(false) setSubmitLoading(false)
@@ -603,11 +771,28 @@ const OrderConfirm = () => {
if (!opts?.silent) setLoading(true) if (!opts?.silent) setLoading(true)
setError('') setError('')
const [goodsRes, addressRes] = await Promise.all([ const [addressRes, editingOrderRes, goodsByParam] = await Promise.all([
numericGoodsId ? getShopGoods(numericGoodsId) : Promise.resolve(null), listShopUserAddress({ isDefault: true }),
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) { if (goodsRes) {
setGoods(goodsRes) setGoods(goodsRes)
@@ -618,6 +803,62 @@ const OrderConfirm = () => {
if (addressRes && addressRes.length > 0) { if (addressRes && addressRes.length > 0) {
setAddress(addressRes[0]) setAddress(addressRes[0])
} }
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)
}
}
// 编辑模式下优先使用默认地址(用户刚从地址列表选择的)
// 新下单模式使用订单关联的地址
let targetAddr: ShopUserAddress | undefined = undefined
if (isEditMode) {
// 编辑模式:优先使用默认地址
targetAddr = addressRes?.find(a => a.isDefault) || addressRes?.[0]
} else {
// 新下单模式:使用订单关联的地址
const addrId = Number(editingOrderRes?.addressId)
const addrIdSafe = Number.isFinite(addrId) && addrId > 0 ? addrId : undefined
if (addrIdSafe) {
targetAddr = addressRes?.find(a => Number(a?.id) === addrIdSafe)
if (!targetAddr?.id) {
try {
targetAddr = await getShopUserAddress(addrIdSafe)
} catch (e) {
console.error('加载订单收货地址失败:', e)
}
}
}
}
if (targetAddr?.id) {
setAddress(targetAddr)
}
}
// Tickets are non-blocking for first paint; load in background. // Tickets are non-blocking for first paint; load in background.
loadUserTickets() loadUserTickets()
} catch (err) { } catch (err) {
@@ -636,6 +877,11 @@ const OrderConfirm = () => {
useDidShow(() => { useDidShow(() => {
// 返回/切换到该页面时,刷新一下当前已选门店 // 返回/切换到该页面时,刷新一下当前已选门店
setSelectedStore(getSelectedStoreFromStorage()) setSelectedStore(getSelectedStoreFromStorage())
ticketAutoRetryCountRef.current = 0
if (ticketAutoRetryTimerRef.current) {
clearTimeout(ticketAutoRetryTimerRef.current)
ticketAutoRetryTimerRef.current = null
}
loadAllData({ silent: hasInitialLoadedRef.current }) loadAllData({ silent: hasInitialLoadedRef.current })
}) })
@@ -702,37 +948,87 @@ const OrderConfirm = () => {
if (outOfRangePromptedAddressIdRef.current === id) return if (outOfRangePromptedAddressIdRef.current === id) return
outOfRangePromptedAddressIdRef.current = id outOfRangePromptedAddressIdRef.current = id
Taro.showToast({ title: addressHasCoords ? '该地址不在配送范围,请更换围栏内地址' : '该地址缺少定位,请在地址里选择地图定位后重试', icon: 'none' }) Taro.showToast({ title: addressHasCoords ? '该地址不在配送范围,请更换围栏内地址' : '该地址缺少定位,请在地址里选择地图定位后重试', icon: 'none' })
}, [address?.id, addressHasCoords, deliveryRangeCheckedAddressId, inDeliveryRange]) }, [
address?.id,
addressHasCoords,
deliveryRangeCheckedAddressId,
inDeliveryRange
])
// When tickets/stock change, clamp quantity into [0..maxQuantity]. // When tickets/stock change, clamp quantity into [0..maxQuantity].
useEffect(() => { useEffect(() => {
setQuantity(prev => { setQuantity(prev => {
if (maxQuantity <= 0) return 0 if (maxQuantity <= 0) return 0
if (maxQuantity < MIN_START_QTY) return 0 if (maxQuantity < minStartQty) return 0
if (!prev || prev < MIN_START_QTY) return MIN_START_QTY if (!prev || prev < minStartQty) return minStartQty
return Math.min(prev, maxQuantity) 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). // If user has no usable tickets, proactively guide them to purchase (only once per page lifecycle).
useEffect(() => { useEffect(() => {
if (!noUsableTickets) return if (!noUsableTickets) return
// Editing an existing order: don't interrupt with "no tickets" prompt.
if (isEditMode) return
if (noTicketPromptedRef.current) return if (noTicketPromptedRef.current) return
noTicketPromptedRef.current = true noTicketPromptedRef.current = true
;(async () => { // ;(async () => {
const r = await Taro.showModal({ // const r = await Taro.showModal({
title: '暂无可用水票', // title: '暂无可用水票',
content: '您当前没有可用水票,购买后再来下单更方便。', // content: '您当前没有可用水票,购买后再来下单更方便。',
confirmText: '去购买', // confirmText: '去购买',
cancelText: '暂不' // cancelText: '暂不'
}) // })
if (r.confirm) { // if (r.confirm) {
await goBuyTickets() // await goBuyTickets()
} // }
})() // })()
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [noUsableTickets]) }, [noUsableTickets, isEditMode])
// 重新加载数据 // 重新加载数据
const handleRetry = () => { const handleRetry = () => {
@@ -752,7 +1048,7 @@ const OrderConfirm = () => {
} }
// 加载状态 // 加载状态
if (loading || !goods) { if (loading) {
return <OrderConfirmSkeleton/> return <OrderConfirmSkeleton/>
} }
@@ -782,7 +1078,7 @@ const OrderConfirm = () => {
address && ( address && (
<Cell <Cell
className={'address-bottom-line'} className={'address-bottom-line'}
onClick={() => Taro.navigateTo({ url: '/user/address/index' })} onClick={openAddressPage}
> >
<Space> <Space>
<Location className={'text-gray-500'}/> <Location className={'text-gray-500'}/>
@@ -795,14 +1091,16 @@ const OrderConfirm = () => {
<ArrowRight className={'text-gray-400'} size={14}/> <ArrowRight className={'text-gray-400'} size={14}/>
</View> </View>
</Space> </Space>
<View className={'pt-1 pb-3 text-gray-500'}>{address.name} {address.phone}</View> <View className={'pt-1 pb-3'}>
<View className={'text-gray-500'}>{address.name} {address.phone}</View>
</View>
</View> </View>
</Space> </Space>
</Cell> </Cell>
) )
} }
{!address && ( {!address && (
<Cell className={''} onClick={() => Taro.navigateTo({url: '/user/address/index'})}> <Cell className={''} onClick={openAddressPage}>
<Space> <Space>
<Location/> <Location/>
@@ -811,13 +1109,110 @@ const OrderConfirm = () => {
)} )}
</CellGroup> </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> <CellGroup>
<Cell <Cell
title={'配送时间'} title={'配送时间'}
extra={( 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={'flex items-center gap-2'}>
<View className={'text-gray-900'}>{sendTimeText}</View> <View className={'text-gray-900'}>{sendTimeText}</View>
<ArrowRight className={'text-gray-400'} size={14} />
</View> </View>
</Picker>
)} )}
/> />
</CellGroup> </CellGroup>
@@ -827,15 +1222,17 @@ const OrderConfirm = () => {
title={'送水数量'} title={'送水数量'}
description={ description={
canStartOrder canStartOrder
? `最低起送 ${MIN_START_QTY}` ? `最低起送 ${minStartQty}`
: `最低起送 ${MIN_START_QTY} 桶(当前最多 ${maxQuantity} 桶)` : `最低起送 ${minStartQty} 桶(当前最多 ${maxQuantity} 桶)`
} }
extra={( extra={(
<ConfigProvider theme={customTheme}> <ConfigProvider theme={customTheme}>
<InputNumber <InputNumber
value={displayQty} value={displayQty}
min={canStartOrder ? MIN_START_QTY : 0} min={canStartOrder ? minStartQty : 0}
max={canStartOrder ? maxQuantity : 0} max={canStartOrder ? maxQuantity : 0}
step={minStartQty >= 10 ? 10 : 1}
readOnly
disabled={!canStartOrder} disabled={!canStartOrder}
onChange={handleQuantityChange} onChange={handleQuantityChange}
/> />
@@ -876,7 +1273,7 @@ const OrderConfirm = () => {
await loadUserTickets() await loadUserTickets()
return return
} }
if (noUsableTickets) { if (noUsableTickets && !isEditMode) {
const r = await Taro.showModal({ const r = await Taro.showModal({
title: '暂无可用水票', title: '暂无可用水票',
content: '您还没有可用水票,是否前往购买?', content: '您还没有可用水票,是否前往购买?',
@@ -889,7 +1286,7 @@ const OrderConfirm = () => {
setTicketPopupVisible(true) setTicketPopupVisible(true)
}} }}
/> />
{noUsableTickets && ( {(noUsableTickets && !isEditMode) && (
<Cell <Cell
title={<Text className="text-gray-500"></Text>} title={<Text className="text-gray-500"></Text>}
description="购买水票后即可在这里直接下单送水" description="购买水票后即可在这里直接下单送水"
@@ -965,8 +1362,11 @@ const OrderConfirm = () => {
<View className="py-10 text-center"> <View className="py-10 text-center">
<Empty description="暂无可用水票" /> <Empty description="暂无可用水票" />
<View className="mt-4 flex justify-center"> <View className="mt-4 flex justify-center">
<Button type="primary" onClick={goBuyTickets}> <Button
type="primary"
onClick={isEditMode ? () => setTicketPopupVisible(false) : goBuyTickets}
>
{isEditMode ? '确定修改' : '确定下单'}
</Button> </Button>
</View> </View>
</View> </View>
@@ -1034,6 +1434,49 @@ const OrderConfirm = () => {
</View> </View>
</Popup> </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}/> <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'}> <div className={'fixed z-50 bg-white w-full bottom-0 left-0 pt-4 pb-10 border-t border-gray-200'}>
@@ -1046,11 +1489,16 @@ const OrderConfirm = () => {
</span> </span>
<span className={'text-sm text-gray-500'}></span> <span className={'text-sm text-gray-500'}></span>
</View> </View>
{getDeliveryFee() > 0 && (
<View className={'text-xs text-orange-500'}>
{getDeliveryFee().toFixed(2)}
</View>
)}
</div> </div>
<div className={'buy-btn mx-4'}> <div className={'buy-btn mx-4'}>
{noUsableTickets ? ( {noUsableTickets && !isEditMode ? (
<Button type="primary" size="large" onClick={goBuyTickets}> <Button type="primary" size="large" onClick={goBuyTickets}>
{isEditMode ? '确定修改' : '确定下单'}
</Button> </Button>
) : ( ) : (
<Button <Button
@@ -1062,8 +1510,10 @@ const OrderConfirm = () => {
!address?.id || !address?.id ||
!addressHasCoords || !addressHasCoords ||
(deliveryRangeCheckedAddressId === address?.id && inDeliveryRange === false) || (deliveryRangeCheckedAddressId === address?.id && inDeliveryRange === false) ||
availableTicketTotal <= 0 || (!isEditMode && availableTicketTotal <= 0) ||
!canStartOrder !canStartOrder ||
!deliveryMethod ||
(deliveryMethod === 'stairs' && needCarryUpstairs === null)
} }
onClick={onSubmit} onClick={onSubmit}
> >
@@ -1075,7 +1525,13 @@ const OrderConfirm = () => {
? '地址缺少定位' ? '地址缺少定位'
: ((deliveryRangeCheckedAddressId === address?.id && inDeliveryRange === false) : ((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 { ShopStoreRider } from '@/api/shop/shopStoreRider/model';
import type { ShopStoreWarehouse } from '@/api/shop/shopStoreWarehouse/model'; import type { ShopStoreWarehouse } from '@/api/shop/shopStoreWarehouse/model';
import request from '@/utils/request'; import request from '@/utils/request';
import { getWxOpenId, getUserInfo } from '@/api/layout';
/** /**
* 支付类型枚举 * 支付类型枚举
@@ -19,7 +20,8 @@ export enum PaymentType {
* 支付结果回调 * 支付结果回调
*/ */
export interface PaymentCallback { export interface PaymentCallback {
onSuccess?: () => void; // Return `false` to skip default "支付成功" toast + redirect.
onSuccess?: () => void | boolean | Promise<void | boolean>;
onError?: (error: string) => void; onError?: (error: string) => void;
onComplete?: () => void; onComplete?: () => void;
} }
@@ -32,6 +34,74 @@ export class PaymentHandler {
private static storeRidersCache = new Map<number, ShopStoreRider[]>(); private static storeRidersCache = new Map<number, ShopStoreRider[]>();
private static warehousesCache: ShopStoreWarehouse[] | null = null; 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 订单数据 * @param orderData 订单数据
@@ -79,6 +149,12 @@ export class PaymentHandler {
// 设置支付类型 // 设置支付类型
orderData.payType = paymentType; orderData.payType = paymentType;
// 【关键修复】微信支付前,强制刷新/绑定当前微信用户的 openid
// 防止"下单账号与支付账号不一致"错误
if (paymentType === PaymentType.WECHAT) {
await this.ensureOpenIdBeforePay();
}
console.log('创建订单请求:', orderData); console.log('创建订单请求:', orderData);
// 创建订单 // 创建订单
@@ -118,17 +194,27 @@ export class PaymentHandler {
if (paymentSuccess) { if (paymentSuccess) {
console.log('支付成功,订单号:', result.orderNo); 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({ Taro.showToast({
title: '支付成功', title: '支付成功',
icon: 'success' icon: 'success'
}); });
callback?.onSuccess?.();
// 跳转到订单页面 // 跳转到订单页面
setTimeout(() => { setTimeout(() => {
Taro.navigateTo({ url: '/user/order/order' }); Taro.navigateTo({ url: '/user/order/order' });
}, 2000); }, 2000);
}
} else { } else {
throw new Error('支付未完成'); throw new Error('支付未完成');
} }
@@ -465,6 +551,8 @@ export function buildSingleGoodsOrder(
specInfo?: string; specInfo?: string;
buyerRemarks?: string; buyerRemarks?: string;
sendStartTime?: string; sendStartTime?: string;
deliveryMethod?: string;
deliveryFloor?: number;
} }
): OrderCreateRequest { ): OrderCreateRequest {
return { return {
@@ -482,7 +570,9 @@ export function buildSingleGoodsOrder(
sendStartTime: options?.sendStartTime, sendStartTime: options?.sendStartTime,
deliveryType: options?.deliveryType || 0, deliveryType: options?.deliveryType || 0,
couponId: options?.couponId, 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 - 请根据实际情况修改 // 模版套餐ID - 请根据实际情况修改
export const TEMPLATE_ID = '10584'; export const TEMPLATE_ID = '10584';
// 服务接口 - 请根据实际情况修改 // 服务接口 - 从环境配置读取
export const SERVER_API_URL = 'https://glt-server.websoft.top/api'; // @ts-ignore
// export const SERVER_API_URL = 'http://127.0.0.1:8000/api'; export const SERVER_API_URL = process.env.TARO_ENV === 'production'
? 'https://glt-server.websoft.top/api'
: 'https://glt-server.websoft.top/api';
/** /**
* 保存用户信息到本地存储 * 保存用户信息到本地存储
* @param token * @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;