Compare commits

...

63 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
8b5609255a fix(ticket): 解决配送范围检查中的地址状态问题
- 添加 deliveryRangeCheckedAddressId 状态防止使用旧地址的配送范围结果
- 在配送范围检查成功时更新已检查地址ID
- 在配送范围检查失败时清除已检查地址ID
- 在地址变更预检查中添加已检查地址验证
- 更新依赖数组包含 deliveryRangeCheckedAddressId
- 修正提交按钮禁用条件确保只在当前地址已检查且超出范围时禁用
- 完善按钮文字显示逻辑确保只在当前地址已检查且超出范围时显示配送范围提示
2026-02-27 15:57:48 +08:00
31d47f0a0b ```
fix(order): 修复订单状态判断逻辑和配送范围验证

- 修复订单状态数值转换逻辑,统一使用 toNum 函数处理状态值
- 移除基于 formId 推断订单完成状态的逻辑,改用 orderStatus 字段
- 更新订单列表中各状态的条件判断,确保标签页与状态文案同步
- 修改配送范围验证逻辑,移除GPS定位回退,仅使用地址坐标验证
- 添加地址坐标缺失的错误提示和表单验证
- 更新配送范围检查的UI状态管理和错误处理流程
- 优化按钮状态控制,增加地址坐标验证检查
```
2026-02-27 15:49:21 +08:00
68d5848d3d style(shop): 移除订单确认页面中的规格信息显示
- 注释掉订单确认页面商品规格信息的展示
- 注释掉购物车订单确认页面商品规格信息的展示
2026-02-26 14:55:08 +08:00
e40120138b refactor(ticket): 优化水票功能实现逻辑
- 移除手动选择水票功能,改为自动按数量少优先消耗
- 新增 ticketLoaded 状态跟踪水票加载完成情况
- 实现 getTicketAvailableQty 函数统一处理不同租户的可用数量字段差异
- 修改水票过滤逻辑,支持多种状态字段格式并改进商品ID匹配
- 更新下单流程,将单个订单拆分为多个水票订单以支持批量消耗
- 优化水票弹窗界面显示可用总数和消耗顺序说明
- 移除选中水票的相关状态管理和UI组件
- 更新下单确认提示显示优先使用数量少的水票策略
2026-02-26 13:23:17 +08:00
ef26a207b0 fix(goods): 修复水票套票商品的购买流程控制
- 添加 ticketTemplateChecked 状态用于跟踪套票模板检查状态
- 在获取套票模板后正确设置 checked 状态避免重复检查
- 修复立即购买按钮样式在无购物车按钮时的显示问题
- 隐藏用户票券页面中的操作标签元素
2026-02-26 12:30:24 +08:00
78ac461ef9 feat(share): 添加分享功能并限制水票商品加入购物车
- 在二维码页面启用分享给朋友和分享到朋友圈功能
- 实现分享菜单显示和分享内容自定义逻辑
- 移除原有的复制邀请信息和分享给好友按钮
- 新增水票套票模板查询接口和类型定义
- 阻止水票套票商品加入购物车并提示用户立即购买
- 添加组件卸载时的清理逻辑防止内存泄漏
- 优化商品详情页异步操作的状态管理
2026-02-26 12:11:30 +08:00
f9dcaa9ce9 fix(order): 修复配送时间截单逻辑并移除微信地址功能
- 添加当日21点截单时间控制,超过时间下单最早配送日顺延到次日
- 在订单确认页面实现截单时间校验和自动调整配送时间
- 移除用户地址管理中的获取微信地址功能相关代码
- 修复地址表单中CellGroup组件嵌套结构问题
- 更新配送时间选择器的起始日期计算逻辑
2026-02-26 01:25:35 +08:00
d86cdad470 feat(address): 添加微信地址导入功能和一键导航呼叫功能
- 新增微信地址导入流程,支持从微信原生地址选择后跳转到编辑页面完善定位
- 添加WxAddressDraft缓存机制用于存储微信返回的地址草稿数据
- 实现一键导航功能,支持通过订单地址ID或地址信息进行地图导航
- 添加一键呼叫功能,支持直接拨打电话联系骑手或门店
- 优化地址编辑页面支持微信导入模式和默认地址检查
2026-02-25 18:06:45 +08:00
3d94125c5e feat(user): 添加用户信息实时同步和刷新功能
- 在个人中心页面添加用户信息实时刷新机制
- 实现用户头像和昵称修改后的同步更新
- 新增 reloadUserInfo 方法用于重新加载用户数据
- 添加本地存储同步机制保持用户信息一致性
- 优化登录状态管理和用户数据显示逻辑
- 整合微信 OpenID 获取流程到用户信息刷新过程
2026-02-25 17:19:20 +08:00
63d0d64a1f fix(dealer): 移除提款申请状态字段
- 移除了提款请求中的 applyStatus 字段,该字段不再需要
- 修复了微信钱包提款流程中的冗余状态设置
- 简化了提款数据结构,提高代码可维护性
2026-02-25 14:59:11 +08:00
63 changed files with 3959 additions and 850 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 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 ./

View File

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

View File

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

BIN
dist.zip

Binary file not shown.

View File

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

View File

@@ -35,7 +35,7 @@ dealer: {
// 金额相关
money: {
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%)' // 累计 - 橙色
}
```

View File

@@ -16,6 +16,21 @@ export async function pageGltTicketOrder(params: GltTicketOrderParam) {
return Promise.reject(new Error(res.message));
}
/**
* 获取配送员待处理订单数量
* @param riderId 配送员ID
*/
export async function getRiderPendingCount(riderId: number) {
const res = await request.get<ApiResult<{ pendingCount: number; totalCount: number }>>(
'/glt/glt-ticket-order/rider/count',
{ riderId }
);
if (res.code === 0) {
return res.data;
}
return { pendingCount: 0, totalCount: 0 };
}
/**
* 查询送水订单列表
*/

View File

@@ -62,6 +62,10 @@ export interface GltTicketOrder {
avatar?: string;
// 手机号码
phone?: string;
// 收货人姓名
receiverName?: string;
// 收货人手机号码
receiverPhone?: string;
// 排序(数字越小越靠前)
sortNumber?: number;
// 备注
@@ -76,6 +80,12 @@ export interface GltTicketOrder {
createTime?: string;
// 修改时间
updateTime?: string;
// 配送方式elevator(电梯) / stairs(步梯) / groundFloor(一楼商铺/其他)
deliveryMethod?: string;
// 楼层(步梯+送上楼时有值从2开始
deliveryFloor?: number;
// 配送费(步梯+送上楼时计算:数量 × (楼层-1)
deliveryFee?: number;
}
/**

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -87,6 +87,10 @@ button[open-type="chooseAvatar"] {
justify-content: center;
height: 80px;
}
.cart-buy-only{
border-radius: 20px;
flex: 1;
}
}
image {

View File

@@ -1,4 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '成为经销商',
navigationBarTitleText: '注册成为会员',
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({
navigationBarTitleText: '桂乐淘分享中心'
navigationBarTitleText: '账户管理中心'
})

View File

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

View File

@@ -94,15 +94,19 @@ const DealerOrders: React.FC = () => {
}
}, [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 (isUnfreeze === 1) return '已解冻'
if (isSettled === 1) 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 (isSettled === 1) return 'success'
if (isUnfreeze === 1) return 'success'
if (isSettled === 1) return 'info'
return 'warning'
}
@@ -120,8 +124,8 @@ const DealerOrders: React.FC = () => {
<Text className="font-semibold text-gray-800">
{order.orderNo || '-'}
</Text>
<Tag type={getStatusColor(order.isSettled, order.isInvalid)}>
{getStatusText(order.isSettled, order.isInvalid)}
<Tag type={getStatusColor(order.isSettled, order.isInvalid, order.isUnfreeze,order.orderStatus)}>
{getStatusText(order.isSettled, order.isInvalid, order.isUnfreeze,order.orderStatus)}
</Tag>
</View>

View File

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

View File

@@ -2,7 +2,7 @@ import React, {useState, useEffect} from 'react'
import {View, Text, Image} from '@tarojs/components'
import {Button, Loading} from '@nutui/nutui-react-taro'
import {Download, QrCode} from '@nutui/icons-react-taro'
import Taro from '@tarojs/taro'
import Taro, {useShareAppMessage} from '@tarojs/taro'
import {useDealerUser} from '@/hooks/useDealerUser'
import {generateInviteCode} from '@/api/invite'
// import type {InviteStats} from '@/api/invite'
@@ -16,6 +16,39 @@ const DealerQrcode: React.FC = () => {
// const [statsLoading, setStatsLoading] = useState<boolean>(false)
const {dealerUser, loading: dealerLoading, error, refresh} = useDealerUser()
// Enable "转发给朋友" + "分享到朋友圈" items in the share panel/menu.
useEffect(() => {
// Some clients require explicit call to show both share entries.
Taro.showShareMenu({
withShareTicket: true,
showShareItems: ['shareAppMessage', 'shareTimeline']
}).catch(() => {})
}, [])
// 转发给朋友(分享小程序链接)
useShareAppMessage(() => {
const inviterRaw = dealerUser?.userId ?? Taro.getStorageSync('UserId')
const inviter = Number(inviterRaw)
const hasInviter = Number.isFinite(inviter) && inviter > 0
const user = Taro.getStorageSync('User') || {}
const nickname = (user && (user.nickname || user.realName || user.username)) || ''
const title = hasInviter ? `${nickname || '我'}邀请你加入桂乐淘伙伴计划` : '桂乐淘伙伴计划'
return {
title,
path: hasInviter
? `/pages/index/index?inviter=${inviter}&source=dealer_qrcode&t=${Date.now()}`
: `/pages/index/index`,
success: function () {
Taro.showToast({title: '分享成功', icon: 'success', duration: 2000})
},
fail: function () {
Taro.showToast({title: '分享失败', icon: 'none', duration: 2000})
}
}
})
// 生成小程序码
const generateMiniProgramCode = async () => {
if (!dealerUser?.userId) {
@@ -376,29 +409,7 @@ const DealerQrcode: React.FC = () => {
</Button>
</View>
{/*<View className={'my-2 bg-white'}>*/}
{/* <Button*/}
{/* size="large"*/}
{/* block*/}
{/* icon={<Copy/>}*/}
{/* onClick={copyInviteInfo}*/}
{/* disabled={!dealerUser?.userId || codeLoading}*/}
{/* >*/}
{/* 复制邀请信息*/}
{/* </Button>*/}
{/*</View>*/}
{/*<View className={'my-2 bg-white'}>*/}
{/* <Button*/}
{/* size="large"*/}
{/* block*/}
{/* fill="outline"*/}
{/* icon={<Share/>}*/}
{/* onClick={shareMiniProgramCode}*/}
{/* disabled={!dealerUser?.userId || codeLoading}*/}
{/* >*/}
{/* 分享给好友*/}
{/* </Button>*/}
{/*</View>*/}
</View>
{/* 推广说明 */}

View File

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

View File

@@ -98,7 +98,7 @@ const normalizeMoneyString = (money: unknown) => {
}
const DealerWithdraw: React.FC = () => {
const [activeTab, setActiveTab] = useState<string | number>('0')
const [activeTab, setActiveTab] = useState<string>('0')
const [loading, setLoading] = useState<boolean>(false)
const [refreshing, setRefreshing] = useState<boolean>(false)
const [submitting, setSubmitting] = useState<boolean>(false)
@@ -114,10 +114,11 @@ const DealerWithdraw: React.FC = () => {
// Tab 切换处理函数
const handleTabChange = (value: string | number) => {
console.log('Tab切换到:', value)
setActiveTab(value)
const next = String(value)
setActiveTab(next)
// 如果切换到提现记录页面,刷新数据
if (String(value) === '1') {
if (next === '1') {
fetchWithdrawRecords()
}
}
@@ -307,10 +308,17 @@ const DealerWithdraw: React.FC = () => {
// return
}
if (amount > 200) {
Taro.showToast({
title: '单笔最多提现200元',
icon: 'none'
})
return
}
if (amount > available) {
Taro.showToast({
title: '提现金额超过可用余额',
icon: 'error'
icon: 'none'
})
return
}
@@ -323,7 +331,6 @@ const DealerWithdraw: React.FC = () => {
money: values.amount,
// Only support WeChat wallet withdrawals.
payType: 10,
applyStatus: 10, // 待审核
platform: 'MiniProgram'
}
@@ -413,7 +420,7 @@ const DealerWithdraw: React.FC = () => {
}
}
const quickAmounts = ['100', '300', '500', '1000']
const quickAmounts = ['50', '100', '200']
const setQuickAmount = (amount: string) => {
formRef.current?.setFieldsValue({amount})
@@ -488,10 +495,10 @@ const DealerWithdraw: React.FC = () => {
labelPosition="top"
>
<CellGroup>
<Form.Item name="amount" label="提现金额" required>
<Form.Item name="amount" label="提现金额">
<Input
placeholder="请输入提现金额"
type="number"
type="digit"
/>
</Form.Item>
@@ -523,8 +530,9 @@ const DealerWithdraw: React.FC = () => {
<Text className="text-sm text-red-500">
1.
2.
2.
3.
4. 200
</Text>
</View>
</CellGroup>
@@ -629,13 +637,12 @@ const DealerWithdraw: React.FC = () => {
<View className="bg-gray-50 min-h-screen">
<Tabs value={activeTab} onChange={handleTabChange}>
<Tabs.TabPane title="申请提现" value="0">
{renderWithdrawForm()}
</Tabs.TabPane>
<Tabs.TabPane title="提现记录" value="1">
{renderWithdrawRecords()}
</Tabs.TabPane>
</Tabs>
{activeTab === '0' ? renderWithdrawForm() : renderWithdrawRecords()}
</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 Taro from '@tarojs/taro';
import { User } from '@/api/system/user/model';
import { getUserInfo, updateUserInfo, loginByOpenId } from '@/api/layout';
import { getUserInfo, updateUserInfo, loginByOpenId, getWxOpenId } from '@/api/layout';
import { TenantId } from '@/config/app';
import {getStoredInviteParams, handleInviteRelation} from '@/utils/invite';
import { handleInviteRelation } from '@/utils/invite';
// 用户Hook
export const useUser = () => {
@@ -27,6 +27,24 @@ export const useUser = () => {
setUser(data.user);
setIsLoggedIn(true);
// 自动登录成功后,补齐 openidJSAPI 微信支付必需)
// 防止后续支付时报"下单账号与支付账号不一致"
if (!data.user?.openid) {
try {
const freshCode = await new Promise<string | undefined>((resolve, reject) => {
Taro.login({
success: (r) => resolve(r.code as string),
fail: () => resolve(undefined),
});
});
if (freshCode) {
await getWxOpenId({ code: freshCode });
}
} catch (_e) {
console.warn('自动登录后绑定 openid 失败');
}
}
// 处理邀请关系
if (data.user?.userId) {
try {
@@ -44,15 +62,10 @@ export const useUser = () => {
reject(new Error('自动登录失败'));
}
}).catch(_ => {
// 首次注册,跳转到邀请注册页面
const pages = Taro.getCurrentPages();
const currentPage = pages[pages.length - 1];
const inviteParams = getStoredInviteParams()
if (currentPage?.route !== 'dealer/apply/add' && inviteParams?.inviter) {
return Taro.navigateTo({
url: '/dealer/apply/add'
});
}
// 登录失败(通常是新用户尚未注册/未绑定手机号等)。
// 这里不做任何“自动跳转”,避免用户点击「我的」时被强制带到分销/申请页,体验割裂。
// 需要登录的页面请使用 utils/auth 的 ensureLoggedIn / goToRegister 做显式跳转。
reject(new Error('autoLoginByOpenId failed'));
});
},
fail: reject
@@ -60,7 +73,11 @@ export const useUser = () => {
});
return res;
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
// 新用户首次进入、未绑定手机号等场景属于“预期失败”,避免刷屏报错。
if (msg !== 'autoLoginByOpenId failed') {
console.error('自动登录失败:', error);
}
return null;
}
};

View File

@@ -3,7 +3,7 @@ import Banner from './Banner'
import Taro, { useDidShow, useShareAppMessage } from '@tarojs/taro'
import { View, Text, Image, ScrollView } from '@tarojs/components'
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 { checkAndHandleInviteRelation, hasPendingInvite } from '@/utils/invite'
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 { ensureLoggedIn } from '@/utils/auth'
import './index.scss'
// import navTo from "@/utils/common";
function Home() {
const [activeTabKey, setActiveTabKey] = useState('recommend')
@@ -216,8 +217,8 @@ function Home() {
title: '立即送水',
icon: <Cart size={30} />,
onClick: () => {
if (!ensureLoggedIn('/user/ticket/use?goodsId=10074')) return
Taro.navigateTo({ url: '/user/ticket/use?goodsId=10074' })
if (!ensureLoggedIn('/user/ticket/use')) return
Taro.navigateTo({ url: '/user/ticket/use' })
},
},
{
@@ -225,8 +226,9 @@ function Home() {
title: '送水订单',
icon: <Agenda size={30} />,
onClick: () => {
if (!ensureLoggedIn('/user/ticket/index')) return
Taro.navigateTo({ url: '/user/ticket/index' })
const url = '/user/ticket/index?tab=order'
if (!ensureLoggedIn(url)) return
Taro.navigateTo({ url })
},
},
{
@@ -289,6 +291,20 @@ function Home() {
</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*/}
<ScrollView className="home-tabs" scrollX enableFlex>
<View className="home-tabs__inner">
@@ -306,7 +322,6 @@ function Home() {
})}
</View>
</ScrollView>
{/* 商品列表 */}
<View className="goods-grid">
{visibleGoods.map((item) => (
@@ -329,20 +344,20 @@ function Home() {
<Text className="goods-card__sold">:{item.sales || 0}</Text>
<View className="goods-card__price">
<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 className="goods-card__actions">
<View
className="goods-card__btn goods-card__btn--ghost"
onClick={() => {
if (!ensureLoggedIn('/shop/orderConfirm/index?goodsId=10074')) return
Taro.navigateTo({ url: '/shop/orderConfirm/index?goodsId=10074' })
}}
>
<Text className="goods-card__btnText"></Text>
</View>
{/*<View*/}
{/* className="goods-card__btn goods-card__btn--ghost"*/}
{/* onClick={() => {*/}
{/* if (!ensureLoggedIn('/shop/orderConfirm/index?goodsId=10074')) return*/}
{/* Taro.navigateTo({ url: '/shop/orderConfirm/index?goodsId=10074' })*/}
{/* }}*/}
{/*>*/}
{/* <Text className="goods-card__btnText">买水票更优惠</Text>*/}
{/*</View>*/}
<View
className="goods-card__btn goods-card__btn--primary"
onClick={() =>
@@ -356,6 +371,7 @@ function Home() {
</View>
))}
</View>
</View>
</>
)

View File

@@ -52,7 +52,7 @@ const IsDealer = () => {
<View style={{display: 'inline-flex', alignItems: 'center'}}>
<Reward className={'text-orange-100 '} size={16}/>
<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>*/}
</View>
}
@@ -76,7 +76,7 @@ const IsDealer = () => {
title={
<View style={{display: 'inline-flex', alignItems: 'center'}}>
<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>
</View>
}

View File

@@ -14,10 +14,11 @@ import {useThemeStyles} from "@/hooks/useTheme";
import {getRootDomain} from "@/utils/domain";
import { getMyGltUserTicketTotal } from '@/api/glt/gltUserTicket'
import { saveStorageByLoginUser } from '@/utils/server'
import { getUserLevelName, getUserLevelConfig } from '@/utils/userLevel'
const UserCard = forwardRef<any, any>((_, ref) => {
const {data, refresh} = useUserData()
const {getDisplayName, isAdmin} = useUser();
const {loadUserFromStorage} = useUser();
const [IsLogin, setIsLogin] = useState<boolean>(false)
const [userInfo, setUserInfo] = useState<User>()
const [ticketTotal, setTicketTotal] = useState<number>(0)
@@ -25,14 +26,34 @@ const UserCard = forwardRef<any, any>((_, ref) => {
const themeStyles = useThemeStyles();
const canShowScanButton = (() => {
const v: any = (userInfo as any)?.isAdmin
return isAdmin() || v === true || v === 1 || v === '1'
return v === true || v === 1 || v === '1'
})()
// 角色名称:优先取用户 roles 数组的第一个角色名称
const getDisplayName = () => {
if (!userInfo) return IsLogin ? '用户' : '点击登录'
return userInfo.nickname || (userInfo as any).realName || (userInfo as any).username || (IsLogin ? '用户' : '点击登录')
}
// 角色名称:优先使用 dealerLevel 显示四种分级,否则取用户 roles 数组的第一个角色名称
const getRoleName = () => {
const dealerLevel = (userInfo as any)?.dealerLevel
if (dealerLevel !== undefined && dealerLevel !== null) {
return getUserLevelName(dealerLevel)
}
return userInfo?.roles?.[0]?.roleName || userInfo?.roleName || '注册用户'
}
// 获取用户等级配置(用于自定义样式)
const getRoleLevelConfig = () => {
const dealerLevel = (userInfo as any)?.dealerLevel
if (dealerLevel !== undefined && dealerLevel !== null) {
return getUserLevelConfig(dealerLevel)
}
return null
}
const roleLevelConfig = getRoleLevelConfig()
// 下拉刷新
const reloadStats = async (showToast = false) => {
await refresh()
@@ -45,10 +66,46 @@ const UserCard = forwardRef<any, any>((_, ref) => {
}
}
const syncUserToStorage = (u: User) => {
// Keep storage up-to-date for other places that read user info synchronously.
Taro.setStorageSync('User', u)
if (u?.userId) Taro.setStorageSync('UserId', u.userId)
if (u?.nickname) Taro.setStorageSync('WxNickName', u.nickname)
}
const reloadUserInfo = async () => {
try {
const u = await getUserInfo()
if (u) {
setUserInfo(u)
setIsLogin(true)
syncUserToStorage(u)
// Refresh this hook instance's state from storage (defensive).
await loadUserFromStorage()
// 获取openId不阻塞 UI 刷新)
if (!u.openid) {
Taro.login({
success: (res) => {
getWxOpenId({code: res.code}).catch(() => {})
}
})
}
}
} catch (e) {
// Not logged in / token expired: keep UI in "not login" state.
// Other error handling is done in request interceptor / callers.
}
}
// 暴露方法给父组件
useImperativeHandle(ref, () => ({
handleRefresh: () => reloadStats(true),
reloadStats
handleRefresh: async () => {
await reloadUserInfo()
await reloadStats(true)
},
reloadStats,
reloadUserInfo
}))
useEffect(() => {
@@ -97,30 +154,15 @@ const UserCard = forwardRef<any, any>((_, ref) => {
nickname: res.userInfo.nickName,
sexName: res.userInfo.gender == 1 ? '男' : '女'
})
getUserInfo().then((data) => {
if (data) {
setUserInfo(data)
setIsLogin(true);
// Keep local storage user info in sync so other hooks (e.g. unified scan) can read admin flags.
Taro.setStorageSync('User', data)
Taro.setStorageSync('UserId', data.userId)
reloadUserInfo()
.then(() => {
// 登录态已就绪后刷新卡片统计(余额/积分/券/水票)
refresh().then()
reloadTicketTotal()
// 获取openId
if (!data.openid) {
Taro.login({
success: (res) => {
getWxOpenId({code: res.code}).then(() => {
})
}
})
}
}
}).catch(() => {
.catch(() => {
console.log('未登录')
});
})
}
});
};
@@ -241,7 +283,22 @@ const UserCard = forwardRef<any, any>((_, ref) => {
<View className={'flex flex-col'}>
<Text style={{color: '#ffffff'}}>{getDisplayName() || '点击登录'}</Text>
{getRootDomain() && (
<View><Tag type="success">{getRoleName()}</Tag></View>
<View>
{roleLevelConfig ? (
<Tag
type={roleLevelConfig.tagType as any}
style={{
backgroundColor: roleLevelConfig.bgColor,
color: roleLevelConfig.textColor,
borderColor: roleLevelConfig.borderColor,
}}
>
{getRoleName()}
</Tag>
) : (
<Tag type="success">{getRoleName()}</Tag>
)}
</View>
)}
</View>
</View>
@@ -309,13 +366,9 @@ const UserCard = forwardRef<any, any>((_, ref) => {
<View className={'py-2'}>
<View className={'flex justify-around mt-1'}>
<View className={'item flex justify-center flex-col items-center'}
onClick={() => navTo('/user/wallet/wallet', true)}>
<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>
</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>
onClick={() => navTo('/user/ticket/index', true)}>
<Text className={'text-xs text-gray-200'} style={themeStyles.textColor}></Text>
<Text className={'text-xl text-white'} style={themeStyles.textColor}>{ticketTotal}</Text>
</View>
<View className={'item flex justify-center flex-col items-center'}
onClick={() => navTo('/user/coupon/index', true)}>
@@ -323,9 +376,13 @@ const UserCard = forwardRef<any, any>((_, ref) => {
<Text className={'text-xl text-white'} style={themeStyles.textColor}>{data?.coupons || 0}</Text>
</View>
<View className={'item flex justify-center flex-col items-center'}
onClick={() => navTo('/user/ticket/index', true)}>
<Text className={'text-xs text-gray-200'} style={themeStyles.textColor}></Text>
<Text className={'text-xl text-white'} style={themeStyles.textColor}>{ticketTotal}</Text>
onClick={() => navTo('/user/wallet/wallet', true)}>
<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>
</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 File

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

View File

@@ -39,7 +39,7 @@ const UserCell = () => {
return (
<>
<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>
<Grid
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 UserCard from "./components/UserCard";
import UserOrder from "./components/UserOrder";
@@ -14,12 +14,15 @@ function User() {
const userCardRef = useRef<any>()
const themeStyles = useThemeStyles();
// TabBar 页在小程序里通常不会销毁;从“注册/申请”页返回时需要触发子组件重新初始化/拉取最新状态。
const [dealerViewKey, setDealerViewKey] = useState(0)
// 下拉刷新处理
const handleRefresh = async () => {
if (userCardRef.current?.handleRefresh) {
await userCardRef.current.handleRefresh()
}
setDealerViewKey(v => v + 1)
}
useEffect(() => {
@@ -28,6 +31,9 @@ function User() {
// 每次进入/切回个人中心都刷新一次统计(包含水票数量)
useDidShow(() => {
userCardRef.current?.reloadStats?.()
// 个人资料(头像/昵称)可能在其它页面被修改,这里确保返回时立刻刷新
userCardRef.current?.reloadUserInfo?.()
setDealerViewKey(v => v + 1)
})
return (
@@ -56,7 +62,7 @@ function User() {
</View>
<UserCard ref={userCardRef}/>
<UserOrder/>
<IsDealer/>
<IsDealer key={dealerViewKey}/>
<UserGrid/>
<UserFooter/>
</PullToRefresh>

View File

@@ -1,6 +1,6 @@
import React from 'react'
import React, { useEffect } from 'react'
import {View, Text} from '@tarojs/components'
import {ConfigProvider, Button, Grid, Avatar} from '@nutui/nutui-react-taro'
import {ConfigProvider, Button, Grid, Avatar, Badge} from '@nutui/nutui-react-taro'
import {
User,
Shopping,
@@ -8,11 +8,15 @@ import {
ArrowRight,
Purse,
People,
Scan
Scan,
Setting
} from '@nutui/icons-react-taro'
import {useDealerUser} from '@/hooks/useDealerUser'
import {useUser} from '@/hooks/useUser'
import { useThemeStyles } from '@/hooks/useTheme'
import { useRiderNotification } from '@/hooks/useRiderNotification'
import {businessGradients, cardGradients, gradientUtils} from '@/styles/gradients'
import {updateShopDealerUser} from '@/api/shop/shopDealerUser'
import Taro from '@tarojs/taro'
const DealerIndex: React.FC = () => {
@@ -22,6 +26,18 @@ const DealerIndex: React.FC = () => {
refresh,
} = useDealerUser()
// 获取用户角色信息
const { hasRole } = useUser()
// 配送员通知功能
const { pendingCount, startPolling, stopPolling, soundEnabled, toggleSound } = useRiderNotification()
// 页面生命周期管理
useEffect(() => {
startPolling()
return () => stopPolling()
}, [startPolling, stopPolling])
// 使用主题样式
const themeStyles = useThemeStyles()
@@ -56,6 +72,109 @@ const DealerIndex: React.FC = () => {
console.log(getGradientBackground(),'getGradientBackground()')
// 判断是否是配送员
const isRider = hasRole('rider')
// 请求订阅消息授权
const handleRequestSubscribeMessage = () => {
// 微信订阅消息模板ID需在微信公众平台配置后替换
const templateIds = [
'YOUR_TEMPLATE_ID', // TODO: 替换为实际的订阅消息模板ID
]
// 过滤出有效的模板ID
const validTemplateIds = templateIds.filter(id => id && !id.includes('YOUR_'))
if (validTemplateIds.length === 0) {
Taro.showModal({
title: '提示',
content: '订阅消息功能尚未配置,请联系管理员',
showCancel: false
})
return
}
// 请求订阅
Taro.requestSubscribeMessage({
tmplIds: validTemplateIds,
entityIds: [], // 支付宝模板ID微信端不需要仅满足类型要求
success: (res) => {
console.log('订阅消息授权结果:', res)
const accepted = Object.values(res).some(v => v === 'accept')
if (accepted) {
Taro.showToast({
title: '订阅成功',
icon: 'success'
})
// 保存授权状态到本地
Taro.setStorageSync('rider_subscribed', '1')
} else {
Taro.showToast({
title: '您已拒绝订阅',
icon: 'none'
})
}
},
fail: (err) => {
console.error('订阅消息授权失败:', err)
Taro.showToast({
title: '授权失败',
icon: 'none'
})
}
} as Taro.requestSubscribeMessage.Option)
}
// 点击待使用金额 - 配送员专用:将冻结金额转入可提现
const handleFreezeMoneyClick = async () => {
// 检查是否是配送员
if (!isRider) {
return
}
// 检查冻结金额是否为 0
const freezeMoney = Number(dealerUser?.freezeMoney ?? 0)
if (freezeMoney <= 0) {
return
}
// 弹出确认框
Taro.showModal({
title: '确认操作',
content: `确定要将 ¥${freezeMoney.toFixed(2)} 转入钱包吗?`,
confirmText: '确定',
cancelText: '取消',
success: async (res) => {
if (res.confirm) {
try {
Taro.showLoading({ title: '处理中...' })
const currentMoney = Number(dealerUser?.money ?? 0)
await updateShopDealerUser({
id: dealerUser?.id,
money: (currentMoney + freezeMoney).toFixed(2),
freezeMoney: '0.00'
})
Taro.hideLoading()
Taro.showToast({
title: '更新成功',
icon: 'success',
duration: 1500
})
// 刷新数据
refresh()
} catch (error) {
Taro.hideLoading()
Taro.showToast({
title: '更新失败',
icon: 'none',
duration: 1500
})
}
}
}
})
}
if (error) {
return (
<View className="p-4">
@@ -74,7 +193,14 @@ const DealerIndex: React.FC = () => {
<View>
{/*头部信息*/}
{dealerUser && (
<View className="px-4 py-6 relative overflow-hidden" style={themeStyles.primaryBackground}>
<View
className="px-4 py-6 relative overflow-hidden"
style={{
...themeStyles.primaryBackground,
background: businessGradients.order.processing,
color: '#ffffff'
}}
>
{/* 装饰性背景元素 - 小程序兼容版本 */}
<View className="absolute w-32 h-32 rounded-full" style={{
backgroundColor: 'rgba(255, 255, 255, 0.1)',
@@ -130,7 +256,7 @@ const DealerIndex: React.FC = () => {
{dealerUser && (
<View className="mx-4 -mt-6 rounded-xl p-4 relative z-10" style={cardGradients.elevated}>
<View className="mb-4">
<Text className="font-semibold text-gray-800"></Text>
<Text className="font-semibold text-gray-800"></Text>
</View>
<View className="grid grid-cols-3 gap-3">
<View className="text-center p-3 rounded-lg flex flex-col" style={{
@@ -141,13 +267,20 @@ const DealerIndex: React.FC = () => {
</Text>
<Text className="text-xs" style={{ color: 'rgba(255, 255, 255, 0.9)' }}></Text>
</View>
<View className="text-center p-3 rounded-lg flex flex-col" style={{
background: businessGradients.money.frozen
}}>
<View
className="text-center p-3 rounded-lg flex flex-col"
style={{
background: businessGradients.money.frozen,
opacity: isRider && Number(dealerUser.freezeMoney ?? 0) > 0 ? 1 : 0.8
}}
onClick={isRider && Number(dealerUser.freezeMoney ?? 0) > 0 ? handleFreezeMoneyClick : undefined}
>
<Text className="text-lg font-bold mb-1 text-white">
{formatMoney(dealerUser.freezeMoney)}
</Text>
<Text className="text-xs" style={{ color: 'rgba(255, 255, 255, 0.9)' }}></Text>
<Text className="text-xs" style={{ color: 'rgba(255, 255, 255, 0.9)' }}>
{isRider && Number(dealerUser.freezeMoney ?? 0) > 0 ? '待使用' : '待使用'}
</Text>
</View>
<View className="text-center p-3 rounded-lg flex flex-col" style={{
background: businessGradients.money.total
@@ -212,13 +345,20 @@ const DealerIndex: React.FC = () => {
>
<Grid.Item text="配送订单" onClick={() => navigateToPage('/rider/orders/index')}>
<View className="text-center">
<View className="w-12 h-12 bg-blue-50 rounded-xl flex items-center justify-center mx-auto mb-2">
<View className="w-12 h-12 bg-blue-50 rounded-xl flex items-center justify-center mx-auto mb-2 relative">
<Shopping color="#3b82f6" size="20"/>
{pendingCount > 0 && (
<Badge
value={pendingCount > 99 ? '99+' : pendingCount}
max={99}
style={{ position: 'absolute', top: '-4px', right: '-4px' }}
/>
)}
</View>
</View>
</Grid.Item>
<Grid.Item text={'工资明细'} onClick={() => navigateToPage('/rider/withdraw/index')}>
<Grid.Item text={'收入明细'} onClick={() => navigateToPage('/rider/withdraw/index')}>
<View className="text-center">
<View className="w-12 h-12 bg-green-50 rounded-xl flex items-center justify-center mx-auto mb-2">
<Purse color="#10b981" size="20"/>
@@ -251,46 +391,96 @@ const DealerIndex: React.FC = () => {
</Grid.Item>
</Grid>
{/* 第二行功能 */}
{/*<Grid*/}
{/* columns={4}*/}
{/* className="no-border-grid mt-4"*/}
{/* style={{*/}
{/* '--nutui-grid-border-color': 'transparent',*/}
{/* '--nutui-grid-item-border-width': '0px',*/}
{/* border: 'none'*/}
{/* } as React.CSSProperties}*/}
{/*>*/}
{/* <Grid.Item text={'邀请统计'} onClick={() => navigateToPage('/dealer/invite-stats/index')}>*/}
{/* <View className="text-center">*/}
{/* <View className="w-12 h-12 bg-indigo-50 rounded-xl flex items-center justify-center mx-auto mb-2">*/}
{/* <Presentation color="#6366f1" size="20"/>*/}
{/* </View>*/}
{/* </View>*/}
{/* </Grid.Item>*/}
{/* 第二行功能 - 通知设置 */}
<Grid
columns={4}
className="no-border-grid mt-4"
style={{
'--nutui-grid-border-color': 'transparent',
'--nutui-grid-item-border-width': '0px',
border: 'none'
} as React.CSSProperties}
>
<Grid.Item text={'通知设置'} onClick={() => {
const isSubscribed = Taro.getStorageSync('rider_subscribed') === '1'
Taro.showModal({
title: '通知设置',
content: `声音提醒:${soundEnabled ? '已开启' : '已关闭'}\n订阅消息${isSubscribed ? '已订阅' : '未订阅'}`,
confirmText: '更多设置',
cancelText: '关闭',
success: (res) => {
if (res.confirm) {
// 显示更多设置选项
Taro.showActionSheet({
itemList: [
soundEnabled ? '关闭声音提醒' : '开启声音提醒',
isSubscribed ? '订阅状态正常' : '订阅消息通知',
'检查更新'
],
success: (sheetRes) => {
if (sheetRes.tapIndex === 0) {
// 切换声音
toggleSound()
Taro.showToast({
title: soundEnabled ? '已关闭声音' : '已开启声音',
icon: 'none'
})
} else if (sheetRes.tapIndex === 1) {
// 订阅消息
if (!isSubscribed) {
handleRequestSubscribeMessage()
} else {
Taro.showToast({
title: '已订阅消息通知',
icon: 'success'
})
}
} else if (sheetRes.tapIndex === 2) {
Taro.showToast({
title: '已是最新版本',
icon: 'success'
})
}
}
})
}
}
})
}}>
<View className="text-center">
<View className="w-12 h-12 bg-indigo-50 rounded-xl flex items-center justify-center mx-auto mb-2 relative">
<Setting color={soundEnabled ? '#6366f1' : '#9ca3af'} size="20"/>
{soundEnabled ? (
<View className="absolute -bottom-1 -right-1 w-3 h-3 bg-green-500 rounded-full border-2 border-white"></View>
) : (
<View className="absolute -bottom-1 -right-1 w-3 h-3 bg-gray-400 rounded-full border-2 border-white"></View>
)}
</View>
</View>
</Grid.Item>
{/* /!* 预留其他功能位置 *!/*/}
{/* <Grid.Item text={''}>*/}
{/* <View className="text-center">*/}
{/* <View className="w-12 h-12 bg-gray-50 rounded-xl flex items-center justify-center mx-auto mb-2">*/}
{/* </View>*/}
{/* </View>*/}
{/* </Grid.Item>*/}
{/* 预留功能位置 */}
<Grid.Item text={''}>
<View className="text-center">
<View className="w-12 h-12 bg-gray-50 rounded-xl flex items-center justify-center mx-auto mb-2">
</View>
</View>
</Grid.Item>
{/* <Grid.Item text={''}>*/}
{/* <View className="text-center">*/}
{/* <View className="w-12 h-12 bg-gray-50 rounded-xl flex items-center justify-center mx-auto mb-2">*/}
{/* </View>*/}
{/* </View>*/}
{/* </Grid.Item>*/}
<Grid.Item text={''}>
<View className="text-center">
<View className="w-12 h-12 bg-gray-50 rounded-xl flex items-center justify-center mx-auto mb-2">
</View>
</View>
</Grid.Item>
{/* <Grid.Item text={''}>*/}
{/* <View className="text-center">*/}
{/* <View className="w-12 h-12 bg-gray-50 rounded-xl flex items-center justify-center mx-auto mb-2">*/}
{/* </View>*/}
{/* </View>*/}
{/* </Grid.Item>*/}
{/*</Grid>*/}
<Grid.Item text={''}>
<View className="text-center">
<View className="w-12 h-12 bg-gray-50 rounded-xl flex items-center justify-center mx-auto mb-2">
</View>
</View>
</Grid.Item>
</Grid>
</ConfigProvider>
</View>
</View>

View File

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

View File

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

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

View File

@@ -17,6 +17,8 @@ import {useCart} from "@/hooks/useCart";
import {useConfig} from "@/hooks/useConfig";
import {parseInviteParams, saveInviteParams, trackInviteSource} from "@/utils/invite";
import { ensureLoggedIn } from '@/utils/auth'
import {getGltTicketTemplateByGoodsId} from "@/api/glt/gltTicketTemplate";
import type {GltTicketTemplate} from "@/api/glt/gltTicketTemplate/model";
const GoodsDetail = () => {
const [statusBarHeight, setStatusBarHeight] = useState<number>(44);
@@ -32,6 +34,9 @@ const GoodsDetail = () => {
title: '',
content: ''
})
// 水票套票模板:存在时该商品不允许加入购物车(购物车无法支付此类商品)
const [ticketTemplate, setTicketTemplate] = useState<GltTicketTemplate | null>(null)
const [ticketTemplateChecked, setTicketTemplateChecked] = useState(false)
// const [selectedSku, setSelectedSku] = useState<ShopGoodsSku | null>(null);
const [loading, setLoading] = useState(false);
const router = Taro.getCurrentInstance().router;
@@ -60,9 +65,29 @@ const GoodsDetail = () => {
}, [goodsId])
// 处理加入购物车
const handleAddToCart = () => {
const handleAddToCart = async () => {
if (!goods) return;
// 水票套票商品:不允许加入购物车(购物车无法支付)
// 优先使用已加载的 ticketTemplate若尚未加载则补一次查询
let tpl = ticketTemplate
let checked = ticketTemplateChecked
if (!tpl && goods?.goodsId) {
try {
tpl = await getGltTicketTemplateByGoodsId(Number(goods.goodsId))
setTicketTemplate(tpl)
setTicketTemplateChecked(true)
checked = true
} catch (_e) {
tpl = null
setTicketTemplateChecked(true)
checked = true
}
}
if (!checked || tpl) {
return
}
if (!ensureLoggedIn(`/shop/goodsDetail/index?id=${goods.goodsId}`)) return
// 如果有规格,显示规格选择器
@@ -99,11 +124,30 @@ const GoodsDetail = () => {
};
// 规格选择确认回调
const handleSpecConfirm = (sku: ShopGoodsSku, quantity: number, action?: 'cart' | 'buy') => {
const handleSpecConfirm = async (sku: ShopGoodsSku, quantity: number, action?: 'cart' | 'buy') => {
// setSelectedSku(sku);
setShowSpecSelector(false);
if (action === 'cart') {
// 水票套票商品:不允许加入购物车(购物车无法支付)
let tpl = ticketTemplate
let checked = ticketTemplateChecked
if (!tpl && goods?.goodsId) {
try {
tpl = await getGltTicketTemplateByGoodsId(Number(goods.goodsId))
setTicketTemplate(tpl)
setTicketTemplateChecked(true)
checked = true
} catch (_e) {
tpl = null
setTicketTemplateChecked(true)
checked = true
}
}
if (!checked || tpl) {
return
}
// 加入购物车
addToCart({
goodsId: goods!.goodsId!,
@@ -143,14 +187,19 @@ const GoodsDetail = () => {
}
useEffect(() => {
let alive = true
Taro.getSystemInfo({
success: (res) => {
if (!alive) return
setWindowWidth(res.windowWidth)
setStatusBarHeight(Number(res.statusBarHeight) + 5)
},
});
if (goodsId) {
setLoading(true);
// 切换商品时先重置套票模板,避免复用上一个商品状态
setTicketTemplate(null)
setTicketTemplateChecked(false)
// 加载商品详情
getShopGoods(Number(goodsId))
@@ -159,6 +208,7 @@ const GoodsDetail = () => {
if (res.content) {
res.content = wxParse(res.content);
}
if (!alive) return
setGoods(res);
if (res.files) {
const arr = JSON.parse(res.files);
@@ -169,12 +219,27 @@ const GoodsDetail = () => {
console.error("Failed to fetch goods detail:", error);
})
.finally(() => {
if (!alive) return
setLoading(false);
});
// 查询商品是否绑定水票模板(失败/无数据时不影响正常浏览)
getGltTicketTemplateByGoodsId(Number(goodsId))
.then((tpl) => {
if (!alive) return
setTicketTemplate(tpl)
setTicketTemplateChecked(true)
})
.catch((_e) => {
if (!alive) return
setTicketTemplate(null)
setTicketTemplateChecked(true)
})
// 加载商品规格
listShopGoodsSpec({goodsId: Number(goodsId)} as any)
.then((data) => {
if (!alive) return
setSpecs(data || []);
})
.catch((error) => {
@@ -184,12 +249,16 @@ const GoodsDetail = () => {
// 加载商品SKU
listShopGoodsSku({goodsId: Number(goodsId)} as any)
.then((data) => {
if (!alive) return
setSkus(data || []);
})
.catch((error) => {
console.error("Failed to fetch goods skus:", error);
});
}
return () => {
alive = false
}
}, [goodsId]);
// 分享给好友
@@ -227,6 +296,8 @@ const GoodsDetail = () => {
return <View>...</View>;
}
const showAddToCart = ticketTemplateChecked && !ticketTemplate
return (
<View className={"py-0"}>
<View
@@ -295,9 +366,9 @@ const GoodsDetail = () => {
<View className={'flex justify-between'}>
<View className={'flex text-red-500 text-xl items-baseline'}>
<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 text-gray-400 line-through'}>{goods.salePrice}</Text>
<Text className={'text-xs text-gray-400 line-through'}>{goods.salePrice}/{goods.unitName}</Text>
</View>
<span className={"text-gray-400 text-xs"}> {goods.sales}</span>
</View>
@@ -306,6 +377,17 @@ const GoodsDetail = () => {
<View className={"car-no text-lg"}>
{goods.name}
</View>
{/* 活动/配送标签 */}
{(goods.activityType === 1 || goods.deliveryMode === 1) && (
<View className={"flex gap-1 py-1"}>
{goods.activityType === 1 && (
<Text className={"text-xs bg-red-500 text-white px-2 py-1 rounded-full"}></Text>
)}
{goods.deliveryMode === 1 && (
<Text className={"text-xs bg-orange-500 text-white px-2 py-1 rounded-full"}></Text>
)}
</View>
)}
<View className={"flex justify-between text-xs py-1"}>
<span className={"text-orange-500"}>
{goods.comments}
@@ -385,10 +467,12 @@ const GoodsDetail = () => {
</button>
</View>
<View className={'buy-btn mx-4'}>
{showAddToCart && (
<View className={'cart-add px-4 text-sm'}
onClick={() => handleAddToCart()}>
</View>
<View className={'cart-buy pl-4 pr-5 text-sm'}
)}
<View className={`cart-buy text-sm ${showAddToCart ? 'pl-4 pr-5' : 'cart-buy-only px-4'}`}
onClick={() => handleBuyNow()}>
</View>
</View>

View File

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

View File

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

View File

@@ -164,7 +164,7 @@ const OrderConfirm = () => {
}} lazyLoad={false}/>
<View className={'flex flex-col'}>
<View className={'font-medium w-full'}>{item.name}</View>
<View className={'number text-gray-400 text-sm py-2'}>80g/</View>
{/*<View className={'number text-gray-400 text-sm py-2'}>80g/袋</View>*/}
<Space className={'flex justify-start items-center'}>
<View className={'text-red-500'}>{item.price}</View>
<View className={'text-gray-500 text-sm'}>x {item.quantity}</View>

View File

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

View File

@@ -47,11 +47,17 @@ const AddUserAddress = () => {
const [FormData, setFormData] = useState<ShopUserAddress>({})
const [inputText, setInputText] = useState<string>('')
const [selectedLocation, setSelectedLocation] = useState<SelectedLocation | null>(null)
const [regionLocked, setRegionLocked] = useState(false)
const formRef = useRef<any>(null)
const wxDraftRef = useRef<Partial<ShopUserAddress> | null>(null)
const wxDraftPatchedRef = useRef(false)
// 判断是编辑还是新增模式
const isEditMode = !!params.id
const addressId = params.id ? Number(params.id) : undefined
const fromWx = params.fromWx === '1' || params.fromWx === 'true'
const skipDefaultCheck =
fromWx || params.skipDefaultCheck === '1' || params.skipDefaultCheck === 'true'
const reload = async () => {
// 整理地区数据
@@ -59,7 +65,7 @@ const AddUserAddress = () => {
// 新增模式:若存在“默认地址”但缺少经纬度,则强制引导到编辑页完善定位
// 目的:确保业务依赖默认地址坐标(选店/配送范围)时不会因为缺失坐标而失败
if (!isEditMode) {
if (!isEditMode && !skipDefaultCheck) {
try {
const defaultList = await listShopUserAddress({ isDefault: true })
const defaultAddr = defaultList?.[0]
@@ -82,6 +88,31 @@ const AddUserAddress = () => {
}
}
// 微信地址导入:先用微信返回的字段预填表单,让用户手动选择定位后再保存
if (!isEditMode && fromWx && !wxDraftPatchedRef.current) {
try {
const draft = Taro.getStorageSync('WxAddressDraft')
if (draft) {
wxDraftPatchedRef.current = true
wxDraftRef.current = draft as any
Taro.removeStorageSync('WxAddressDraft')
setFormData(prev => ({
...prev,
...(draft as any)
}))
const p = String((draft as any)?.province || '').trim()
const c = String((draft as any)?.city || '').trim()
const r = String((draft as any)?.region || '').trim()
const regionText = [p, c, r].filter(Boolean).join(' ')
if (regionText) setText(regionText)
}
} catch (_e) {
// ignore
}
}
// 如果是编辑模式,加载地址数据
if (isEditMode && addressId) {
try {
@@ -90,7 +121,12 @@ const AddUserAddress = () => {
// 设置所在地区
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) {
console.error('加载地址失败:', error)
Taro.showToast({
@@ -142,30 +178,39 @@ const AddUserAddress = () => {
const result = parseAddressText(inputText);
// 更新表单数据
const newFormData = {
const newFormData: any = {
...FormData,
name: result.name || FormData.name,
phone: result.phone || FormData.phone,
address: result.address || FormData.address,
province: result.province || FormData.province,
city: result.city || FormData.city,
region: result.region || FormData.region
address: result.address || FormData.address
};
if (!regionLocked) {
newFormData.province = result.province || FormData.province
newFormData.city = result.city || FormData.city
newFormData.region = result.region || FormData.region
}
setFormData(newFormData);
// 更新地区显示文本
if (result.province && result.city && result.region) {
if (!regionLocked && result.province && result.city && result.region) {
setText(`${result.province} ${result.city} ${result.region}`);
}
// 更新表单字段值
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({
title: '识别成功',
title: regionLocked ? '识别成功(所在地区以定位为准)' : '识别成功',
icon: 'success'
});
@@ -281,7 +326,6 @@ const AddUserAddress = () => {
name: res.name,
address: res.address
}
setSelectedLocation(next)
// 尝试从地图返回的 address 文本解析省市区best-effort
const regionResult = res?.provinceName || res?.cityName || res?.adName
@@ -292,15 +336,22 @@ const AddUserAddress = () => {
}
: 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 rawAddr = String(res.address || '').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 往往包含省市区,这里尽量剥离掉,避免和表单的省市区字段重复
let detail = rawAddr
for (const part of [province, city, region]) {
@@ -320,20 +371,18 @@ const AddUserAddress = () => {
lng: next.lng,
lat: next.lat,
address: nextDetailAddress || prev.address,
province: regionResult?.province || prev.province,
city: regionResult?.city || prev.city,
region: regionResult?.region || prev.region
province,
city,
region
}))
if (regionResult?.province && regionResult?.city && regionResult?.region) {
setText(`${regionResult.province} ${regionResult.city} ${regionResult.region}`)
}
setText(`${province} ${city} ${region}`)
// 更新表单展示值Form initialValues 不会跟随 FormData 变化)
if (formRef.current) {
const patch: any = {}
if (nextDetailAddress) patch.address = nextDetailAddress
if (regionResult?.region) patch.region = regionResult.region
patch.region = region
formRef.current.setFieldsValue(patch)
}
}
@@ -377,6 +426,14 @@ const AddUserAddress = () => {
}
}
const openRegionPicker = () => {
if (regionLocked) {
Taro.showToast({ title: '所在地区已由定位确定,修改请重新选择定位', icon: 'none' })
return
}
setVisible(true)
}
// 提交表单
const submitSucceed = async (values: any) => {
const loc =
@@ -386,11 +443,16 @@ const AddUserAddress = () => {
Taro.showToast({ title: '请选择定位', icon: 'none' })
return
}
if (!FormData.province || !FormData.city || !FormData.region) {
Taro.showToast({ title: '请先选择定位以自动填写所在地区', icon: 'none' })
return
}
try {
// 准备提交的数据
const submitData = {
...values,
country: FormData.country,
province: FormData.province,
city: FormData.city,
region: FormData.region,
@@ -448,13 +510,40 @@ const AddUserAddress = () => {
useEffect(() => {
// 动态设置页面标题
Taro.setNavigationBarTitle({
title: isEditMode ? '编辑收货地址' : '新增收货地址'
title: isEditMode ? '编辑收货地址' : (fromWx ? '完善收货地址' : '新增收货地址')
});
reload().then(() => {
setLoading(false)
})
}, [isEditMode]);
}, [fromWx, isEditMode]);
useEffect(() => {
if (!regionLocked) return
if (!visible) return
setVisible(false)
}, [regionLocked, visible])
// NutUI Form 的 initialValues 在首次渲染后不再响应更新;微信导入时做一次 setFieldsValue 兜底回填。
useEffect(() => {
if (loading) return
if (isEditMode) return
const draft = wxDraftRef.current
if (!draft) return
if (!formRef.current?.setFieldsValue) return
try {
formRef.current.setFieldsValue({
name: (draft as any)?.name,
phone: (draft as any)?.phone,
address: (draft as any)?.address,
region: (draft as any)?.region
})
} catch (_e) {
// ignore
} finally {
wxDraftRef.current = null
}
}, [fromWx, isEditMode, loading])
if (loading) {
return <Loading className={'px-2'}></Loading>
@@ -471,7 +560,7 @@ const AddUserAddress = () => {
onFinishFailed={(errors) => submitFailed(errors)}
>
<CellGroup className={'px-3'}>
<div
<View
style={{
border: '1px dashed #22c55e',
display: 'flex',
@@ -497,7 +586,7 @@ const AddUserAddress = () => {
>
</Button>
</div>
</View>
</CellGroup>
<View className={'bg-gray-100 h-3'}></View>
<CellGroup style={{padding: '4px 0'}}>
@@ -529,30 +618,32 @@ const AddUserAddress = () => {
rules={[{message: '请输入您的所在地区'}]}
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/>
<ArrowRight className={'text-gray-400'}/>
</div>
</View>
</Form.Item>
<Form.Item name="address" label="收货地址" initialValue={FormData.address} required>
<TextArea maxLength={50} placeholder="请输入详细收货地址"/>
</Form.Item>
</CellGroup>
<CellGroup>
<Cell
title="选择定位"
description={
selectedLocation?.address ||
(selectedLocation ? `经纬度:${selectedLocation.lng}, ${selectedLocation.lat}` : '')
(selectedLocation ? `经纬度:${selectedLocation.lng}, ${selectedLocation.lat}` : '用于计算是否超出配送范围')
}
extra={(
<div className={'flex items-center gap-2'}>
<div
<View className={'flex items-center gap-2'}>
<View
className={'text-gray-900 text-sm'}
style={{maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap'}}
>
{selectedLocation?.name || (selectedLocation ? '已选择' : '请选择')}
</div>
</View>
<ArrowRight className={'text-gray-400'}/>
</div>
</View>
)}
onClick={chooseGeoLocation}
/>
@@ -564,6 +655,10 @@ const AddUserAddress = () => {
options={optionsDemo1}
title="选择地址"
onChange={(value, _) => {
if (regionLocked) {
Taro.showToast({ title: '所在地区已由定位确定,修改请重新选择定位', icon: 'none' })
return
}
setFormData({
...FormData,
province: `${value[0]}`,

View File

@@ -1,7 +1,7 @@
import {useState} from "react";
import Taro, {useDidShow} from '@tarojs/taro'
import {Button, Cell, CellGroup, Space, Empty, ConfigProvider, Divider} from '@nutui/nutui-react-taro'
import {Dongdong, ArrowRight, CheckNormal, Checked} from '@nutui/icons-react-taro'
import {Button, Cell, Space, Empty, ConfigProvider, Divider} from '@nutui/nutui-react-taro'
import {CheckNormal, Checked} from '@nutui/icons-react-taro'
import {View} from '@tarojs/components'
import {ShopUserAddress} from "@/api/shop/shopUserAddress/model";
import {listShopUserAddress, removeShopUserAddress, updateShopUserAddress} from "@/api/shop/shopUserAddress";
@@ -144,8 +144,8 @@ const Address = () => {
/>
<Space>
<Button onClick={() => Taro.navigateTo({url: '/user/address/add'})}></Button>
<Button type="success" fill="dashed"
onClick={() => Taro.navigateTo({url: '/user/address/wxAddress'})}></Button>
{/*<Button type="success" fill="dashed"*/}
{/* onClick={() => Taro.navigateTo({url: '/user/address/wxAddress'})}>获取微信地址</Button>*/}
</Space>
</div>
</ConfigProvider>
@@ -154,19 +154,19 @@ const Address = () => {
return (
<>
<CellGroup>
<Cell
onClick={() => Taro.navigateTo({url: '/user/address/wxAddress'})}
>
<div className={'flex justify-between items-center w-full'}>
<div className={'flex items-center gap-3'}>
<Dongdong className={'text-green-600'}/>
<div></div>
</div>
<ArrowRight className={'text-gray-400'}/>
</div>
</Cell>
</CellGroup>
{/*<CellGroup>*/}
{/* <Cell*/}
{/* onClick={() => Taro.navigateTo({url: '/user/address/wxAddress'})}*/}
{/* >*/}
{/* <div className={'flex justify-between items-center w-full'}>*/}
{/* <div className={'flex items-center gap-3'}>*/}
{/* <Dongdong className={'text-green-600'}/>*/}
{/* <div>获取微信地址</div>*/}
{/* </div>*/}
{/* <ArrowRight className={'text-gray-400'}/>*/}
{/* </div>*/}
{/* </Cell>*/}
{/*</CellGroup>*/}
{list.map((item, _) => (
<Cell.Group>
<Cell className={'flex flex-col gap-1'} onClick={() => selectAddress(item)}>

View File

@@ -1,25 +1,17 @@
import {useEffect} from "react";
import Taro from '@tarojs/taro'
import {addShopUserAddress} from "@/api/shop/shopUserAddress";
import { getCurrentLngLat } from "@/utils/location";
const WxAddress = () => {
/**
* 从微信API获取用户收货地址
* 调用微信原生地址选择界面,获取成功后保存到服务器并刷新列表
* 调用微信原生地址选择界面,获取成功后跳转到“新增收货地址”页面,让用户选择定位后再保存
*/
const getWeChatAddress = () => {
Taro.chooseAddress()
.then(async res => {
const loc = await getCurrentLngLat()
if (!loc) {
// Avoid leaving the user on an empty page.
setTimeout(() => Taro.navigateBack(), 300)
return
}
// 格式化微信返回的地址数据为后端所需格式
const addressData = {
// 仅填充微信地址信息,不要用“当前定位”覆盖经纬度(会造成经纬度与地址不匹配)。
// 选择后跳转到“新增/编辑收货地址”页面,让用户手动选择地图定位后再保存。
const addressDraft = {
name: res.userName,
phone: res.telNumber,
country: res.nationalCode || '中国',
@@ -27,40 +19,32 @@ const WxAddress = () => {
city: res.cityName,
region: res.countyName,
address: res.detailInfo,
postalCode: res.postalCode,
lng: loc.lng,
lat: loc.lat,
isDefault: false
isDefault: false,
}
console.log(res, 'addrs..')
// 调用保存地址的API假设存在该接口
addShopUserAddress(addressData)
.then((msg) => {
console.log(msg)
Taro.showToast({
title: `${msg}`,
icon: 'none'
})
setTimeout(() => {
// 保存成功后返回
Taro.navigateBack()
}, 1000)
})
.catch(error => {
console.error('保存地址失败:', error)
Taro.showToast({title: '保存地址失败', icon: 'error'})
})
Taro.setStorageSync('WxAddressDraft', addressDraft)
// 用 redirectTo 替换当前页面,避免保存后 navigateBack 回到空白的 wxAddress 页面。
await Taro.redirectTo({ url: '/user/address/add?fromWx=1&skipDefaultCheck=1' })
})
.catch(err => {
console.error('获取微信地址失败:', err)
// 用户取消选择地址:直接返回上一页
if (String(err?.errMsg || '').includes('cancel')) {
setTimeout(() => Taro.navigateBack(), 200)
return
}
// 处理用户拒绝授权的情况
if (err.errMsg.includes('auth deny')) {
if (String(err?.errMsg || '').includes('auth deny')) {
Taro.showModal({
title: '授权失败',
content: '请在设置中允许获取地址权限',
showCancel: false
})
setTimeout(() => Taro.navigateBack(), 300)
return
}
Taro.showToast({ title: '获取微信地址失败', icon: 'none' })
setTimeout(() => Taro.navigateBack(), 300)
})
}

View File

@@ -16,6 +16,7 @@ import {copyText} from "@/utils/common";
import PaymentCountdown from "@/components/PaymentCountdown";
import {PaymentType} from "@/utils/payment";
import {ErrorType, RequestError} from "@/utils/request";
import {getShopOrderStatusColor, getShopOrderStatusText, isShopOrderCompleted} from "@/utils/shopOrderStatus";
// 判断订单是否支付已过期
const isPaymentExpired = (createTime: string, timeoutHours: number = 24): boolean => {
@@ -104,6 +105,10 @@ interface OrderListProps {
baseParams?: ShopOrderParam;
// 只读模式:隐藏“支付/取消/确认收货/退款”等用户操作按钮
readOnly?: boolean;
// 是否自动取消“支付已过期”的待支付订单(仅 user 模式生效)
autoCancelExpired?: boolean;
// 支付超时时间(小时),默认 24 小时
paymentTimeoutHours?: number;
}
function OrderList(props: OrderListProps) {
@@ -111,6 +116,8 @@ function OrderList(props: OrderListProps) {
const pageRef = useRef(1)
const [hasMore, setHasMore] = useState(true)
const [payingOrderId, setPayingOrderId] = useState<number | null>(null)
const autoCanceledOrderIdsRef = useRef<Set<number>>(new Set())
const autoCancelRunningRef = useRef(false)
// 根据传入的statusFilter设置初始tab索引
const getInitialTabIndex = () => {
if (props.searchParams?.statusFilter !== undefined) {
@@ -132,65 +139,39 @@ function OrderList(props: OrderListProps) {
const [orderToConfirmReceive, setOrderToConfirmReceive] = useState<ShopOrder | null>(null)
const isReadOnly = props.readOnly || props.mode === 'store' || props.mode === 'rider'
const isOrderCompleted = (order: ShopOrder) => Number(order.orderStatus) === 1 || order.formId === 10074;
// 获取订单状态文本
const getOrderStatusText = (order: ShopOrder) => {
// 优先检查订单状态
if (order.orderStatus === 2) return '已取消';
if (order.orderStatus === 4) return '退款申请中';
if (order.orderStatus === 5) return '退款被拒绝';
if (order.orderStatus === 6) return '退款成功';
if (order.orderStatus === 7) return '客户端申请退款';
if (isOrderCompleted(order)) return '已完成';
// 检查支付状态 (payStatus为boolean类型false/0表示未付款true/1表示已付款)
if (!order.payStatus) return '等待买家付款';
// 已付款后检查发货状态
if (order.deliveryStatus === 10) return '待发货';
if (order.deliveryStatus === 20) {
// 若订单没有配送员,沿用原“待收货”语义
if (!order.riderId || Number(order.riderId) === 0) return '待收货';
// 配送员确认送达后sendEndTime有值才进入“待确认收货”
if (order.sendEndTime && !isOrderCompleted(order)) return '待确认收货';
return '配送中';
}
if (order.deliveryStatus === 30) return '部分发货';
if (order.orderStatus === 0) return '未使用';
return '未知状态';
const toNum = (v: any): number | undefined => {
if (v === null || v === undefined || v === '') return undefined;
const n = Number(v);
return Number.isFinite(n) ? n : undefined;
};
// 获取订单状态颜色
const getOrderStatusColor = (order: ShopOrder) => {
// 优先检查订单状态
if (order.orderStatus === 2) return 'text-gray-500'; // 已取消
if (order.orderStatus === 4) return 'text-orange-500'; // 退款申请中
if (order.orderStatus === 5) return 'text-red-500'; // 退款被拒绝
if (order.orderStatus === 6) return 'text-green-500'; // 退款成功
if (order.orderStatus === 7) return 'text-orange-500'; // 客户端申请退款
if (isOrderCompleted(order)) return 'text-green-600'; // 已完成
// 检查支付状态
if (!order.payStatus) return 'text-orange-500'; // 等待买家付款
// 已付款后检查发货状态
if (order.deliveryStatus === 10) return 'text-blue-500'; // 待发货
if (order.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 (order.deliveryStatus === 30) return 'text-blue-500'; // 部分发货
if (order.orderStatus === 0) return 'text-gray-500'; // 未使用
return 'text-gray-600'; // 默认颜色
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(待发货/待收货) 与状态文案不同步
const isOrderCompleted = (order: ShopOrder) => isShopOrderCompleted(order);
const getOrderStatusText = (order: ShopOrder) => getShopOrderStatusText(order);
const getOrderStatusColor = (order: ShopOrder) => getShopOrderStatusColor(order);
// 使用后端统一的 statusFilter 进行筛选
const getOrderStatusParams = (index: string | number) => {
let params: ShopOrderParam = {
@@ -238,23 +219,81 @@ function OrderList(props: OrderListProps) {
});
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列表直接使用该字段
const incoming = res.list as ShopOrder[];
// 使用函数式更新避免依赖 list
setList(prevList => {
const newList = resetPage ? incoming : (prevList || []).concat(incoming);
return newList;
});
if (incoming.length > 0) {
setList(prevList => (resetPage ? incoming : (prevList || []).concat(incoming)));
} else {
// 本页数据全部被自动取消过滤掉:不清空历史列表,仅保持现状
setList(prevList => (resetPage ? [] : prevList));
}
// 正确判断是否还有更多数据
const hasMoreData = incoming.length >= 10; // 假设每页10条数据
// 正确判断是否还有更多数据(以服务端返回条数为准)
const hasMoreData = rawIncomingLength >= 10; // 假设每页10条数据
setHasMore(hasMoreData);
} else {
setList(prevList => resetPage ? [] : prevList);
// 服务端已无更多数据
setList(prevList => (resetPage ? [] : prevList));
setHasMore(false);
}
@@ -270,7 +309,7 @@ function OrderList(props: OrderListProps) {
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 () => {
if (loading || !hasMore) return; // 防止重复加载
@@ -712,17 +751,20 @@ function OrderList(props: OrderListProps) {
{/* 订单列表 */}
{list.length > 0 && list
?.filter((item) => {
const orderStatus = toNum(item.orderStatus);
// “待收货”不展示退款中的/已退款订单,这些订单统一放到“退货/售后”
if (tapIndex === 3 && (item.orderStatus === 4 || item.orderStatus === 6)) {
if (tapIndex === 3 && (orderStatus === 4 || orderStatus === 6)) {
return false;
}
// “退货/售后”只展示售后相关状态
if (tapIndex === 5) {
return item.orderStatus === 4 || item.orderStatus === 5 || item.orderStatus === 6 || item.orderStatus === 7;
return orderStatus === 4 || orderStatus === 5 || orderStatus === 6 || orderStatus === 7;
}
return true;
})
?.map((item, index) => {
const orderStatus = toNum(item.orderStatus);
const deliveryStatus = toNum(item.deliveryStatus);
return (
<Cell key={item.orderId ?? item.orderNo ?? index} style={{padding: '16px'}}
onClick={() => Taro.navigateTo({url: `/shop/orderDetail/index?orderId=${item.orderId}`})}>
@@ -737,7 +779,7 @@ function OrderList(props: OrderListProps) {
</View>
{/* 右侧显示合并的状态和倒计时 */}
<View className={`${getOrderStatusColor(item)} font-medium`}>
{!item.payStatus && item.orderStatus !== 2 ? (
{!item.payStatus && orderStatus !== 2 ? (
<PaymentCountdown
expirationTime={item.expirationTime}
createTime={item.createTime}
@@ -801,23 +843,23 @@ function OrderList(props: OrderListProps) {
{!isReadOnly && (
<Space className={'btn flex justify-end'}>
{/* 待付款状态:显示取消订单和立即支付 */}
{(!item.payStatus) && item.orderStatus !== 2 && (
{(!item.payStatus) && orderStatus !== 2 && (
<Space>
<Button size={'small'} onClick={(e) => {
e.stopPropagation();
void cancelOrder(item);
}}></Button>
}}></Button>
{(!item.createTime || !isPaymentExpired(item.createTime, 24)) && (
<Button size={'small'} type="primary" onClick={(e) => {
e.stopPropagation();
void payOrder(item);
}}></Button>
}}></Button>
)}
</Space>
)}
{/* 待发货状态:显示申请退款 */}
{item.payStatus && isWithinRefundWindow(item.payTime, 60) && item.deliveryStatus === 10 && item.orderStatus !== 2 && item.orderStatus !== 4 && item.orderStatus !== 6 && item.orderStatus !== 7 && !isOrderCompleted(item) && (
{item.payStatus && isWithinRefundWindow(item.payTime, 60) && deliveryStatus === 10 && orderStatus !== 2 && orderStatus !== 4 && orderStatus !== 6 && orderStatus !== 7 && !isOrderCompleted(item) && (
<Button size={'small'} onClick={(e) => {
e.stopPropagation();
applyRefund(item);
@@ -825,7 +867,7 @@ function OrderList(props: OrderListProps) {
)}
{/* 待收货状态:显示查看物流和确认收货 */}
{item.deliveryStatus === 20 && (!item.riderId || Number(item.riderId) === 0 || !!item.sendEndTime) && item.orderStatus !== 2 && item.orderStatus !== 6 && !isOrderCompleted(item) && (
{deliveryStatus === 20 && (!item.riderId || Number(item.riderId) === 0 || !!item.sendEndTime) && orderStatus !== 2 && orderStatus !== 6 && !isOrderCompleted(item) && (
<Space>
{/*<Button size={'small'} onClick={(e) => {*/}
{/* e.stopPropagation();*/}
@@ -839,7 +881,7 @@ function OrderList(props: OrderListProps) {
)}
{/* 退款/售后状态:显示查看进度和撤销申请 */}
{(item.orderStatus === 4 || item.orderStatus === 7) && (
{(orderStatus === 4 || orderStatus === 7) && (
<Space>
{/*<Button size={'small'} onClick={(e) => {*/}
{/* e.stopPropagation();*/}

View File

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

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useRef, useState } from 'react';
import Taro, { useDidShow } from '@tarojs/taro';
import {
Button,
@@ -14,14 +14,16 @@ import {
Tag
} from '@nutui/nutui-react-taro';
import { View, Text, Image } from '@tarojs/components';
import { pageGltUserTicket } from '@/api/glt/gltUserTicket';
import { getGltUserTicket, pageGltUserTicket, updateGltUserTicket } from '@/api/glt/gltUserTicket';
import type { GltUserTicket } from '@/api/glt/gltUserTicket/model';
import { pageGltTicketOrder, updateGltTicketOrder } from '@/api/glt/gltTicketOrder';
import { pageGltTicketOrder, removeGltTicketOrder, updateGltTicketOrder } from '@/api/glt/gltTicketOrder';
import type { GltTicketOrder } from '@/api/glt/gltTicketOrder/model';
import { BaseUrl } from '@/config/app';
import dayjs from "dayjs";
import { ensureLoggedIn } from '@/utils/auth';
const PAGE_SIZE = 10;
const PAY_REFRESH_HANDLED_KEY = 'user_ticket_from_pay_at_handled';
const UserTicketList = () => {
const [ticketList, setTicketList] = useState<GltUserTicket[]>([]);
@@ -36,6 +38,7 @@ const UserTicketList = () => {
const [orderHasMore, setOrderHasMore] = useState(true);
const [orderPage, setOrderPage] = useState(1);
const [orderTotal, setOrderTotal] = useState(0);
const [orderCancelLoadingById, setOrderCancelLoadingById] = useState<Record<number, boolean>>({});
const [activeTab, setActiveTab] = useState<'ticket' | 'order'>(() => {
const tab = Taro.getCurrentInstance().router?.params?.tab
@@ -45,6 +48,25 @@ const UserTicketList = () => {
const [qrVisible, setQrVisible] = useState(false);
const [qrTicket, setQrTicket] = useState<GltUserTicket | null>(null);
const [qrImageUrl, setQrImageUrl] = useState('');
const payAutoRefreshRunningRef = useRef(false);
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
const parsePositiveNumberParam = (v: unknown) => {
const n = Number(v);
return Number.isFinite(n) && n > 0 ? n : undefined;
};
const getFromPayAtParam = () => {
const params = Taro.getCurrentInstance().router?.params;
return parsePositiveNumberParam((params as any)?.fromPayAt);
};
const shouldAutoRefreshAfterPay = (fromPayAt?: number) => {
if (!fromPayAt) return false;
const handled = parsePositiveNumberParam(Taro.getStorageSync(PAY_REFRESH_HANDLED_KEY)) || 0;
return handled !== fromPayAt;
};
const getUserId = () => {
const raw = Taro.getStorageSync('UserId');
@@ -94,6 +116,41 @@ const UserTicketList = () => {
setQrVisible(true);
};
const goSendWater = async (ticket: GltUserTicket) => {
if (!ticket?.id) {
Taro.showToast({ title: '水票信息不完整', icon: 'none' });
return;
}
if (Number(ticket.status) === 1) {
Taro.showToast({ title: '该水票已冻结,无法下单', icon: 'none' });
return;
}
const avail = Number(ticket.availableQty ?? 0);
if (!Number.isFinite(avail) || avail <= 0) {
Taro.showToast({ title: '可用次数不足', icon: 'none' });
return;
}
const gid = Number(ticket.goodsId);
const url =
Number.isFinite(gid) && gid > 0 ? `/user/ticket/use?goodsId=${gid}` : '/user/ticket/use';
if (!ensureLoggedIn(url)) return;
await Taro.navigateTo({ url });
};
const goReleasePlanDetail = async (ticket: GltUserTicket) => {
if (!ticket?.id) {
Taro.showToast({ title: '水票信息不完整', icon: 'none' });
return;
}
const url = `/user/ticket/release/index?userTicketId=${encodeURIComponent(String(ticket.id))}&templateName=${encodeURIComponent(
String(ticket.templateName ?? '')
)}&frozenQty=${encodeURIComponent(String(ticket.frozenQty ?? 0))}&releasedQty=${encodeURIComponent(
String(ticket.releasedQty ?? 0)
)}`;
if (!ensureLoggedIn(url)) return;
await Taro.navigateTo({ url });
};
const showTicketDetail = (ticket: GltUserTicket) => {
const lines: string[] = [];
if (ticket.templateName) lines.push(`水票:${ticket.templateName}`);
@@ -185,17 +242,16 @@ const UserTicketList = () => {
});
const resList = res?.list || [];
const nextList = isRefresh ? resList : [...orderList, ...resList];
const safeList = resList.filter((o) => Number((o as any)?.deleted) !== 1);
const nextList = isRefresh ? safeList : [...orderList, ...safeList];
setOrderList(nextList);
const count = typeof res?.count === 'number' ? res.count : nextList.length;
setOrderTotal(count);
setOrderHasMore(nextList.length < count);
const serverCount = typeof res?.count === 'number' ? res.count : undefined;
const total = typeof serverCount === 'number' ? serverCount : nextList.length;
setOrderTotal(total);
setOrderHasMore(typeof serverCount === 'number' ? nextList.length < serverCount : resList.length >= PAGE_SIZE);
if (resList.length > 0) {
setOrderPage(currentPage + 1);
} else {
setOrderHasMore(false);
}
if (resList.length > 0) setOrderPage(currentPage + 1);
else setOrderHasMore(false);
} catch (error) {
console.error('获取送水订单失败:', error);
Taro.showToast({ title: '获取送水订单失败', icon: 'error' });
@@ -262,6 +318,187 @@ const UserTicketList = () => {
return d.isValid() ? d.format('YYYY年MM月DD日') : v;
};
const getTicketAvailableQty = (t?: Partial<GltUserTicket> | null) => {
if (!t) return 0;
const anyT: any = t;
const raw =
anyT.availableQty ??
anyT.availableNum ??
anyT.availableCount ??
anyT.remainQty ??
anyT.remainNum ??
anyT.remainCount;
const n = Number(raw);
if (Number.isFinite(n)) return n;
const total = Number(anyT.totalQty ?? anyT.totalNum ?? anyT.totalCount ?? 0);
const used = Number(anyT.usedQty ?? anyT.usedNum ?? anyT.usedCount ?? 0);
const frozen = Number(anyT.frozenQty ?? anyT.frozenNum ?? anyT.frozenCount ?? 0);
const computed =
(Number.isFinite(total) ? total : 0) -
(Number.isFinite(used) ? used : 0) -
(Number.isFinite(frozen) ? frozen : 0);
return Number.isFinite(computed) ? computed : 0;
};
const getTicketUsedQty = (t?: Partial<GltUserTicket> | null) => {
if (!t) return 0;
const anyT: any = t;
const raw = anyT.usedQty ?? anyT.usedNum ?? anyT.usedCount;
const n = Number(raw);
return Number.isFinite(n) ? n : 0;
};
const rollbackUserTicketAfterOrderCancel = async (order: GltTicketOrder, before?: GltUserTicket | null) => {
const orderId = Number(order?.id);
const ticketId = Number(order?.userTicketId);
const qty = Math.max(0, Math.floor(Number((order as any)?.totalNum ?? 0)));
if (!Number.isFinite(orderId) || orderId <= 0) return;
if (!Number.isFinite(ticketId) || ticketId <= 0) return;
if (!Number.isFinite(qty) || qty <= 0) return;
const rollbackKey = `glt_ticket_order_rollback:${orderId}`;
if (Taro.getStorageSync(rollbackKey)) return;
const after = await getGltUserTicket(ticketId);
if (!after?.id) return;
const beforeAvail = before ? getTicketAvailableQty(before) : undefined;
const afterAvail = getTicketAvailableQty(after);
const beforeUsed = before ? getTicketUsedQty(before) : undefined;
const afterUsed = getTicketUsedQty(after);
let needAvail = qty;
if (typeof beforeAvail === 'number') {
const delta = afterAvail - beforeAvail;
if (delta >= qty) {
Taro.setStorageSync(rollbackKey, Date.now());
return; // backend already rolled back
}
if (delta > 0) needAvail = Math.max(0, qty - delta);
}
let needUsed = qty;
if (typeof beforeUsed === 'number') {
const delta = beforeUsed - afterUsed;
if (delta >= qty) {
needUsed = 0; // backend already rolled back used qty
} else if (delta > 0) {
needUsed = Math.max(0, qty - delta);
}
}
if (needAvail <= 0 && needUsed <= 0) {
Taro.setStorageSync(rollbackKey, Date.now());
return;
}
const currentAvailRaw = Number((after as any)?.availableQty);
const baseAvail = Number.isFinite(currentAvailRaw) ? currentAvailRaw : afterAvail;
const safeBaseAvail = Number.isFinite(baseAvail) ? baseAvail : 0;
const totalRaw = Number((after as any)?.totalQty ?? 0);
const total = Number.isFinite(totalRaw) ? totalRaw : undefined;
const frozenRaw = Number((after as any)?.frozenQty ?? 0);
const frozen = Number.isFinite(frozenRaw) ? frozenRaw : 0;
const currentUsedRaw = Number((after as any)?.usedQty);
const baseUsed = Number.isFinite(currentUsedRaw) ? currentUsedRaw : afterUsed;
const safeBaseUsed = Number.isFinite(baseUsed) ? baseUsed : 0;
let nextUsed = safeBaseUsed - needUsed;
if (nextUsed < 0) nextUsed = 0;
const maxAvail = typeof total === 'number' ? Math.max(0, total - frozen - nextUsed) : undefined;
let nextAvail = safeBaseAvail + needAvail;
if (typeof maxAvail === 'number' && Number.isFinite(maxAvail) && nextAvail > maxAvail) nextAvail = maxAvail;
if (nextAvail < 0) nextAvail = 0;
await updateGltUserTicket({
...after,
availableQty: nextAvail,
usedQty: nextUsed
});
Taro.setStorageSync(rollbackKey, Date.now());
};
// Allow users to modify/cancel before delivery starts (e.g. 待派单 / 待配送).
const isTicketOrderPendingDelivery = (order: GltTicketOrder) => {
if (!order?.id) return false;
if (Number(order.status) === 1) return false;
if (Number((order as any)?.deleted) === 1) return false;
if (order.receiveConfirmTime || order.sendEndTime || order.sendStartTime) return false;
const ds = Number((order as any)?.deliveryStatus);
// If backend didn't set deliveryStatus yet, treat it as pending.
if (!Number.isFinite(ds)) return true;
// 0/10: before delivery starts
return ds === 0 || ds === 10;
};
const handleOrderModify = async (order: GltTicketOrder) => {
if (!order?.id) {
Taro.showToast({ title: '订单信息不完整', icon: 'none' });
return;
}
if (!isTicketOrderPendingDelivery(order)) {
Taro.showToast({ title: '仅配送未开始的订单可修改', icon: 'none' });
return;
}
Taro.navigateTo({ url: `/user/ticket/use?orderId=${order.id}` });
};
const handleOrderCancel = async (order: GltTicketOrder) => {
if (!order?.id) {
Taro.showToast({ title: '订单信息不完整', icon: 'none' });
return;
}
if (!isTicketOrderPendingDelivery(order)) {
Taro.showToast({ title: '仅配送未开始的订单可取消', icon: 'none' });
return;
}
if (orderCancelLoadingById[order.id]) return;
const modal = await Taro.showModal({
title: '取消订单',
content: '确定要取消该订单吗?取消后无法恢复。',
confirmText: '确认取消'
});
if (!modal.confirm) return;
try {
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) {
await removeGltTicketOrder(order.id);
}
try {
await rollbackUserTicketAfterOrderCancel(order, beforeTicket);
Taro.showToast({ title: '订单已取消,水票已退回', icon: 'none' });
} catch (e) {
console.error('取消订单后退回水票失败:', e);
await Taro.showModal({
title: '取消成功',
content: '订单已取消,但水票退回失败,请稍后刷新“我的水票”确认,或联系客服处理。',
showCancel: false
});
}
await reloadOrders(true);
} catch (e) {
console.error('取消送水订单失败:', e);
Taro.showToast({ title: '取消失败,请重试', icon: 'none' });
} finally {
Taro.hideLoading();
setOrderCancelLoadingById((prev) => ({ ...prev, [order.id as number]: false }));
}
};
const getTicketOrderStatusMeta = (order: GltTicketOrder) => {
if (order.status === 1) return { text: '已冻结', type: 'warning' as const };
@@ -313,12 +550,37 @@ const UserTicketList = () => {
}
useDidShow(() => {
if (activeTab === 'ticket') {
reloadTickets(true).then();
} else {
reloadOrders(true).then();
void (async () => {
const tabParam = Taro.getCurrentInstance().router?.params?.tab;
const nextTab = tabParam === 'ticket' || tabParam === 'order' ? tabParam : undefined;
if (nextTab && nextTab !== activeTab) {
setActiveTab(nextTab);
}
});
const tabToLoad = nextTab || activeTab;
if (tabToLoad === 'ticket') {
await reloadTickets(true);
const fromPayAt = getFromPayAtParam();
if (shouldAutoRefreshAfterPay(fromPayAt) && !payAutoRefreshRunningRef.current) {
payAutoRefreshRunningRef.current = true;
try {
Taro.setStorageSync(PAY_REFRESH_HANDLED_KEY, fromPayAt);
// 支付后水票可能异步入账:自动再刷新几次,避免用户手动下拉刷新。
for (const delayMs of [800, 1500, 2500]) {
await sleep(delayMs);
await reloadTickets(true);
}
} finally {
payAutoRefreshRunningRef.current = false;
}
}
} else {
await reloadOrders(true);
}
})();
})
return (
<ConfigProvider>
@@ -401,6 +663,9 @@ const UserTicketList = () => {
<Text className="text-base font-semibold text-gray-900">
{item.id}
</Text>
<View className="mt-1">
<Text className="text-xs text-gray-500">{item.templateName}</Text>
</View>
{item.orderNo && (
<View className="mt-1">
<Text className="text-xs text-gray-500">{item.orderNo}</Text>
@@ -413,12 +678,24 @@ const UserTicketList = () => {
)}
</View>
<View className="flex flex-col items-end gap-2">
<Button
size="small"
type="primary"
disabled={(item.availableQty ?? 0) <= 0 || item.status === 1}
onClick={(e) => {
e.stopPropagation();
void goSendWater(item);
}}
>
</Button>
{/*<Tag type={item.status === 1 ? 'danger' : 'success'}>*/}
{/* {item.status === 1 ? '冻结' : '正常'}*/}
{/*</Tag>*/}
<Button
size="small"
type="primary"
style={{ display: 'none'}}
disabled={(item.availableQty ?? 0) <= 0 || item.status === 1}
onClick={(e) => {
// Avoid triggering card click.
@@ -440,7 +717,14 @@ const UserTicketList = () => {
<Text className="text-lg text-gray-900 text-center">{item.usedQty ?? 0}</Text>
<Text className="text-xs text-gray-500"></Text>
</View>
<View className="flex flex-col items-center">
<View
className="flex flex-col items-center"
hoverClass="opacity-70"
onClick={(e) => {
e.stopPropagation();
void goReleasePlanDetail(item);
}}
>
<Text className="text-lg text-gray-900 text-center">{item.frozenQty ?? 0}</Text>
<Text className="text-xs text-gray-500"></Text>
</View>
@@ -534,6 +818,38 @@ const UserTicketList = () => {
</Button>
</View>
) : null}
{item.id ? (
<View className="mt-3 flex justify-end gap-2">
<Button
size="small"
disabled={
!isTicketOrderPendingDelivery(item) ||
!!orderCancelLoadingById[item.id as number]
}
onClick={(e) => {
e.stopPropagation();
void handleOrderModify(item);
}}
>
</Button>
<Button
size="small"
type="danger"
disabled={
!isTicketOrderPendingDelivery(item) ||
!!orderCancelLoadingById[item.id as number]
}
onClick={(e) => {
e.stopPropagation();
void handleOrderCancel(item);
}}
>
</Button>
</View>
) : null}
</View>
))}
</View>

View File

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

View File

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

View File

@@ -58,6 +58,179 @@
}
}
// 配送方式选择
.delivery-method-group {
.delivery-method-section {
display: flex;
flex-direction: column;
gap: 16px;
}
.delivery-method-label {
display: flex;
align-items: center;
}
.delivery-method-options {
display: flex;
gap: 12px;
}
.delivery-method-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
padding: 16px 8px;
border-radius: 12px;
border: 2px solid #f0f0f0;
background: #fafafa;
transition: all 0.2s ease;
&.active {
border-color: #07c160;
background: rgba(7, 193, 96, 0.05);
}
&:active {
transform: scale(0.97);
}
}
.delivery-method-icon {
font-size: 24px;
}
}
// 是否送上楼
.carry-upstairs-section {
display: flex;
flex-direction: column;
padding: 12px 0 0;
border-top: 1px dashed #eee;
margin-top: 4px;
}
.carry-upstairs-options {
display: flex;
gap: 12px;
}
.carry-upstairs-item {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
border-radius: 10px;
border: 2px solid #f0f0f0;
background: #fafafa;
font-size: 18px;
color: #666;
transition: all 0.2s ease;
&.active {
border-color: #07c160;
background: rgba(7, 193, 96, 0.05);
color: #07c160;
font-weight: 500;
}
&:active {
transform: scale(0.97);
}
}
// 楼层选择
.floor-select-section {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 0 0;
border-top: 1px dashed #eee;
margin-top: 4px;
}
.floor-select-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 14px;
border-radius: 8px;
background: #f5f5f5;
font-size: 18px;
transition: background 0.2s;
&:active {
background: #e8e8e8;
}
}
.floor-fee-tip {
margin-left: auto;
}
// 楼层选择弹窗
.floor-picker-popup {
height: 100%;
display: flex;
flex-direction: column;
&__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid #f0f0f0;
}
&__content {
flex: 1;
overflow-y: auto;
padding: 16px;
}
&__footer {
padding: 12px 16px;
border-top: 1px solid #f0f0f0;
text-align: center;
background: #fafafa;
}
}
.floor-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
}
.floor-grid-item {
display: flex;
align-items: center;
justify-content: center;
padding: 14px 0;
border-radius: 10px;
border: 2px solid #f0f0f0;
background: #fff;
font-size: 14px;
color: #333;
transition: all 0.2s ease;
&.active {
border-color: #07c160;
background: rgba(7, 193, 96, 0.08);
color: #07c160;
font-weight: 600;
}
&:active {
transform: scale(0.95);
background: #f5f5f5;
}
}
// 优惠券弹窗样式
.coupon-popup {
height: 100%;

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

115
src/utils/userLevel.ts Normal file
View File

@@ -0,0 +1,115 @@
/**
* 用户等级配置
* 用于分销商角色的显示和样式管理
*/
/** 用户等级枚举 */
export enum UserLevel {
/** 普通用户 */
NORMAL = 0,
/** 超级管理员 */
SUPER_ADMIN = 1,
/** 合伙人(总店) */
PARTNER_HEAD = 2,
/** 合伙人(分店) */
PARTNER_BRANCH = 3,
}
/** 用户等级配置接口 */
export interface UserLevelConfig {
level: UserLevel;
name: string;
/** Tag 组件的 type 属性 */
tagType: 'default' | 'success' | 'warning' | 'danger';
/** 背景色 */
bgColor: string;
/** 文字颜色 */
textColor: string;
/** 边框颜色 */
borderColor: string;
}
/** 用户等级配置表 */
export const USER_LEVEL_CONFIG: Record<UserLevel, UserLevelConfig> = {
[UserLevel.NORMAL]: {
level: UserLevel.NORMAL,
name: '普通用户',
tagType: 'default',
bgColor: '#f5f5f5',
textColor: '#666666',
borderColor: '#e5e5e5',
},
[UserLevel.SUPER_ADMIN]: {
level: UserLevel.SUPER_ADMIN,
name: '超级管理员',
tagType: 'danger',
bgColor: '#fff2f0',
textColor: '#cf1322',
borderColor: '#ffccc7',
},
[UserLevel.PARTNER_HEAD]: {
level: UserLevel.PARTNER_HEAD,
name: '合伙人',
tagType: 'warning',
bgColor: '#fff7e6',
textColor: '#d46b08',
borderColor: '#ffd8bf',
},
[UserLevel.PARTNER_BRANCH]: {
level: UserLevel.PARTNER_BRANCH,
name: '合伙人',
tagType: 'success',
bgColor: '#f6ffed',
textColor: '#389e0d',
borderColor: '#b7eb8f',
},
};
/** 显示名称(带后缀区分总店和分店) */
export const USER_LEVEL_DISPLAY_NAMES: Record<UserLevel, string> = {
[UserLevel.NORMAL]: '普通用户',
[UserLevel.SUPER_ADMIN]: '超级管理员',
[UserLevel.PARTNER_HEAD]: '合伙人(总店)',
[UserLevel.PARTNER_BRANCH]: '合伙人(分店)',
};
/**
* 根据等级值获取配置
* @param level 等级值
* @returns 用户等级配置
*/
export function getUserLevelConfig(level?: number): UserLevelConfig {
const validLevel = level ?? 0;
return USER_LEVEL_CONFIG[validLevel as UserLevel] || USER_LEVEL_CONFIG[UserLevel.NORMAL];
}
/**
* 根据等级值获取显示名称
* @param level 等级值
* @returns 显示名称
*/
export function getUserLevelName(level?: number): string {
const validLevel = level ?? 0;
return USER_LEVEL_DISPLAY_NAMES[validLevel as UserLevel] || USER_LEVEL_DISPLAY_NAMES[UserLevel.NORMAL];
}
/**
* 判断是否是合伙人(包括总店和分店)
* @param level 等级值
* @returns 是否是合伙人
*/
export function isPartner(level?: number): boolean {
return level === UserLevel.PARTNER_HEAD || level === UserLevel.PARTNER_BRANCH;
}
/**
* 判断是否是管理员(包括超级管理员)
* @param level 等级值
* @returns 是否是管理员
*/
export function isAdmin(level?: number): boolean {
return level === UserLevel.SUPER_ADMIN || level === UserLevel.ADMIN;
}
// 导出默认配置
export default USER_LEVEL_CONFIG;