Compare commits

108 Commits

Author SHA1 Message Date
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
5840bea66b feat(order): 添加配送员位置追踪功能
- 在 shopStoreRider 模型中新增 longitude 和 latitude 字段
- 在首页添加送水订单入口并引入 Agenda 图标
- 在送水订单页面实现配送员当前位置获取和更新逻辑
- 添加位置权限检查和经纬度数据同步到后台
- 实现按用户ID和门店ID精确匹配配送员记录
- 添加兜底机制按门店筛选后匹配配送员
- 送达操作时自动记录配送员当前位置信息
2026-02-25 13:40:45 +08:00
929f173b95 feat(share): 更新分享标题显示用户昵称
- 修改分享功能使用用户昵称替代用户ID作为标题前缀
- 添加微信昵称存储逻辑到本地缓存
- 实现用户昵称、真实姓名或用户名的优先级取值
- 添加注释说明微信显示名称的存储用途
2026-02-25 13:14:48 +08:00
049b2396c3 refactor(address): 移除地理位置获取逻辑并优化默认地址处理
- 删除 getCurrentLngLat 工具函数的导入和使用
- 简化 onDefault 函数中的默认地址检查逻辑
- 移除地址更新时的经纬度参数传递
- 优化 selectAddress 函数中的导航回退逻辑
- 使用 safeNavigateBack 替代直接的 navigateBack 操作
- 添加回退失败时的页面重载机制
2026-02-25 12:42:18 +08:00
fb06816e37 Merge remote-tracking branch 'origin/master'
# Conflicts:
#	src/user/order/components/OrderList.tsx
#	src/user/order/refund/index.tsx
2026-02-24 16:28:27 +08:00
939d7b3ec2 ```
refactor(order): 优化退款流程和订单状态管理

- 移除Uploader组件并隐藏上传凭证功能
- 添加getShopOrder和listShopOrderGoods API导入
- 新增toMoneyNumber和formatMoney工具函数处理金额格式
- 实现markOrderClientRefund函数标记客户端退款状态
- 使用真实订单数据替代模拟数据加载订单商品信息
- 修复订单金额显示格式化问题
- 优化订单状态判断逻辑和颜色配置
- 简化申请退款流程,移除预更新订单状态步骤
- 修复商品数量变更处理逻辑
- 更新订单状态码:退款申请中改为客户端申请退款
```
2026-02-24 16:27:21 +08:00
d6eb6d5e05 feat(order): 添加订单退款功能并优化退款流程
- 在 shopOrder API 中新增 refundShopOrder 接口导入
- 实现订单详情页的退款申请功能,添加确认弹窗和加载提示
- 将退款逻辑从 afterSale 接口迁移到统一的 refundShopOrder 接口
- 优化退款页面数据获取,支持从订单详情或商品明细接口获取商品信息
- 添加退款金额验证和订单ID格式校验
- 实现乐观更新本地状态以提升用户体验
- 统一各页面的退款流程和状态码处理
- 添加退款按钮状态控制,避免重复提交
2026-02-24 12:30:11 +08:00
694b5e1e7a ```
fix(address): 修复地址管理中的经纬度验证和导航逻辑

- 添加 parseLngLatFromText 工具函数用于解析经纬度坐标
- 实现 hasValidLngLat 函数验证地址是否包含有效经纬度信息
- 在新增地址时检查默认地址是否缺少经纬度并引导用户完善定位
- 使用 hasValidLngLat 替换原有的经纬度验证逻辑
- 添加安全返回功能避免地址列表页导航异常
- 设置默认地址后自动返回上一页提升用户体验
```
2026-02-22 11:36:18 +08:00
00d3ffaeee refactor(cart): 移除购物车页面中的返回箭头图标
- 从 @nutui/icons-react-taro 导入中移除 ArrowLeft 图标
- 在固定导航栏中移除左侧的返回箭头组件
- 简化导航栏结构,只保留右侧按钮功能
2026-02-13 21:53:05 +08:00
e22cfe4646 feat(auth): 添加统一认证工具和优化登录流程
- 新增 auth 工具模块,包含 isLoggedIn、goToRegister、ensureLoggedIn 方法
- 将硬编码的服务器URL更新为 glt-server 域名
- 重构多个页面的登录检查逻辑,使用统一的认证工具
- 在用户注册/登录流程中集成邀请关系处理
- 更新注册页面配置和实现,支持跳转参数传递
- 优化分销商二维码页面的加载状态和错误处理
- 在水票使用页面添加无票时的购买引导
- 统一文件上传和API请求的服务器地址
- 添加加密库类型定义文件
2026-02-13 21:30:58 +08:00
52ef8d4199 chore(config): 更新服务器API基础URL地址
- 将微信手机号登录接口地址从 server.websoft.top 更改为 glt-server.websoft.top
- 将API基础URL从 mp-api.websoft.top 更改为 glt-api.websoft.top
- 更新二维码生成接口地址为新的API域名
- 统一所有环境配置中的API基础URL地址
- 修改SERVER_API_URL常量指向新服务器地址
2026-02-13 17:18:28 +08:00
93418912dc feat(gltTicketOrder): 添加订单编号字段
- 在模型中新增orderNo字段用于存储订单编号
- 为订单系统提供唯一的订单标识支持
2026-02-11 13:54:14 +08:00
3535cf3a92 fix(order): 修正订单确认页面起送量显示错误
- 将最低起送量字段从 minBuyQty 更新为 startSendQty
- 确保订单确认页面显示正确的起送量要求
2026-02-10 16:15:06 +08:00
b22ff991f0 feat(order): 添加下拉刷新功能到订单列表
- 引入 PullToRefresh 组件实现下拉刷新功能
- 将原有滚动容器包装到 PullToRefresh 组件内部
- 实现下拉刷新逻辑,重置分页数据并重新加载
- 设置刷新头部高度为 60
- 保留原有的无限滚动和上拉加载功能
2026-02-10 13:00:24 +08:00
fc3b32215e fix(ui): 更新订单页面标题文本
- 将配送订单标题更改为送水订单
- 订单详情中显示票号而非订单ID
- 我的订单标题更新为商城订单
2026-02-10 12:06:44 +08:00
4951c3202d feat(dealer): 更新经销商申请功能
- 将页面标题从"注册会员"改为"成为经销商"
- 在经销商注册成功后触发dealerUser:changed事件以通知其他页面刷新
- 优化API响应处理,当用户未注册为分销商时返回null而不是抛出错误
- 使用useDidShow钩子在页面显示时自动刷新经销商数据
- 添加事件监听器支持通过dealerUser:changed事件主动触发数据刷新
2026-02-09 22:00:34 +08:00
50ac79d055 feat(user): 优化地址添加页面的地图定位功能
- 移除地图选点地址与收货地址的自动同步逻辑
- 改进地图选点地址解析,避免省市区字段重复显示
- 添加经纬度有效性校验,防止无效数值传递给位置选择器
- 将选择定位组件移至表单下方并重新排列布局
- 为收货人和手机号字段添加更严格的验证规则
- 保留地图定位功能的完整交互界面和错误处理
2026-02-09 17:28:00 +08:00
8d2188b928 ```
feat(address): 添加用户地址管理页面的定位功能

- 集成 Taro 地图选点功能,支持用户手动选择精确位置
- 新增地理位置权限处理逻辑,包括授权失败和用户取消的情况
- 实现经纬度信息在地址表单中的存储和回显功能
- 添加定位选择界面,展示已选择的位置信息
- 移除原有的实时定位功能,改为手动选择模式
- 更新地址表单提交逻辑以支持新的定位数据结构
```
2026-02-09 17:15:18 +08:00
a1e1487d42 feat(order): 添加配送时间选择功能
- 在订单模型中新增 sendStartTime 字段用于预约配送时间
- 为水票套票商品添加配送时间选择器组件和日期选择逻辑
- 实现配送时间验证确保水票套票商品必须选择配送时间
- 优化支付错误处理增加配送范围提示和地址更换引导
- 调整套票购买注意事项显示动态最低起送量信息
- 移除用户票据页面重复的时间选择相关代码以保持一致性
2026-02-09 16:48:34 +08:00
37c2f030f2 feat(shop): 添加套票活动功能并优化购买数量控制
- 在仓库模型中添加状态字段
- 实现套票活动最低购买量的灵活配置,优先取模板配置值
- 优化数量输入逻辑,支持套票活动下的默认数量设置
- 改进优惠券加载逻辑,使用初始数量对应总价进行推荐
- 修复商品信息加载顺序,确保套票模板数据正确应用
- 更新支付工具类中的仓库类型引用
- 调整数量输入组件的最小值和禁用状态逻辑
2026-02-09 16:02:33 +08:00
231723e960 feat(address): 添加地理位置获取功能并优化门店自动分配逻辑
- 集成 getCurrentLngLat 工具函数用于获取用户当前位置
- 在添加地址时自动获取并存储经纬度信息
- 在设置默认地址时更新位置信息
- 实现基于地理位置的门店自动分配算法
- 添加距离计算和多边形区域判断功能
- 优化送水订单的门店和配送员自动匹配逻辑
- 在微信地址导入时集成位置信息获取
- 添加位置权限处理和用户引导机制
2026-02-09 15:09:27 +08:00
94ed969d2d feat(ticket): 添加配送范围校验功能
- 集成电子围栏API,实现配送范围检查
- 添加地理围栏解析工具函数
- 实现坐标点在多边形内检测算法
- 添加位置权限检查和用户引导
- 优化订单提交流程,增加范围校验步骤
- 更新UI显示配送范围校验状态和结果
2026-02-09 11:16:23 +08:00
1ce6381248 feat(api): 添加电子围栏功能并重构仓库模块
- 新增 shopStoreFence 模块,包含完整的CRUD接口和数据模型
- 将 shopWarehouse 重命名为 shopStoreWarehouse 并更新相关接口
- 配置文件中切换API_BASE_URL到生产环境地址
- 地址管理页面标题从"地址管理"改为"配送管理"
- 配送员页面收益描述从"工资收入"改为"本月配送佣金"
- 用户地址列表增加每月修改次数限制逻辑
- 更新地址数据模型增加updateTime字段
- 页面组件中的收货地址文案统一改为配送地址
- 移除用户优惠券页面中不必要的导航链接
2026-02-07 18:52:35 +08:00
7fb74e9977 fix(goods-card): 修复商品卡片跳转路径问题
- 修正了商品卡片中买水票按钮的跳转路径,添加了路由前缀斜杠以确保正确的页面导航
2026-02-07 17:37:58 +08:00
f8672dec34 fix(order): 修复订单退款时间窗口限制逻辑
- 在开发环境中切换回本地API接口配置
- 修正首页商品卡片按钮链接到正确的购买页面
- 添加退款时间窗口检查函数来限制退款申请时机
- 更新订单详情页退款按钮显示条件,确保仅在有效期内显示
- 在用户订单列表中实现相同的退款时间窗口验证逻辑
- 确保退款功能仅在支付后60分钟内可访问
2026-02-07 15:44:00 +08:00
6c83f6c082 feat(withdraw): 添加实名认证验证功能
- 在提现页面集成实名认证状态检查
- 添加 fetchVerifyStatus 函数用于获取认证状态
- 实现认证状态包括未知、已认证、未认证、审核中、已驳回
- 在提交提现前验证用户是否已完成实名认证
- 添加去认证按钮跳转到认证页面
- 优化订单详情和订单列表中的取消订单逻辑
- 修复用户认证页面的表单验证逻辑
- 添加真实姓名和身份证号输入字段到企业认证表单
2026-02-07 15:35:23 +08:00
5581493772 update(dealer): 更新提现页面提示信息和加载文案
- 修改提现页面提示文字为红色并更新内容,包含实名认证、手动领取和税务提醒
- 简化用户页面经销商身份加载时的提示文案
2026-02-07 13:34:24 +08:00
80653f7ac2 feat(payment): 更新支付倒计时组件以支持过期时间
- 添加 expirationTime 属性作为首选时间源
- 当 expirationTime 缺失时回退到 createTime + timeoutHours 方式
- 更新订单详情页和订单列表页组件以传递 expirationTime
- 修改 usePaymentCountdown Hook 以支持新的参数结构
- 更新组件文档以反映新的 API 和使用方式
- 增强时间计算逻辑以处理无效时间情况
2026-02-07 13:16:31 +08:00
6e0a5aa1fe feat(shop): 添加水票套票活动功能支持
- 移除未使用的 Shop 图标导入和 navTo 工具函数
- 新增水票套票模板查询接口和类型定义
- 实现套票活动状态判断逻辑和最小购买量校验
- 添加购买数量变更时的赠送水票计算功能
- 在商品详情区域显示最低购买量和赠送水票信息
- 为套票活动商品添加注意事项展示
- 禁用不符合最低购买量要求的支付按钮
- 注释掉门店选择相关UI组件以优化界面
2026-02-07 13:12:46 +08:00
50ffd2c9da feat(api): 添加根据商品ID查询水票模板接口
- 新增 getGltTicketTemplateByGoodsId 函数用于查询水票模板
- 移除最低提现金额显示,保留手续费信息
- 隐藏订单确认页面中的配送范围设置区域
- 添加订单注意事项说明文本
2026-02-07 12:40:53 +08:00
9e780e369c fix(dealer): 优化经销商模块文字显示和加载逻辑
- 将"佣金统计"改为"资金统计"
- 将"提现申请"改为"申请提现"
- 将"我的邀请"改为"我的团队"
- 将"我的邀请码"改为"实名认证"并调整跳转路径
- 在经销商用户钩子中添加加载状态控制
- 防止快速点击导致的路由错误
- 优化用户登录状态检测逻辑
- 改进初始化加载时的数据处理流程
2026-02-07 12:26:06 +08:00
8751be5fb4 feat(dealer): 更新分销中心为桂乐淘分享中心
- 将导航栏标题从"分销中心"和"推广二维码"统一改为"桂乐淘分享中心"
- 修改分享页面文案从"我的邀请小程序码"为"我的分享码"
- 更新分享描述文案为"与好友共享福利 一起省、一起赚"
- 将团队邀请文案改为"桂乐淘伙伴计划"
- 自购省 | 分享赚 | 好友惠
- 在用户票据页面添加日期格式化函数
- 调整票据详情显示顺序和字段内容
- 移除门店名称显示并注释相关代码
- 统一用户组件中的中心名称为"桂乐淘分享中心"
- 更新水票列表标题显示格式
2026-02-07 12:22:43 +08:00
f15933fc82 feat(home): 更新首页配置和界面展示
- 修改开发环境API基础URL地址
- 移除门店选择相关功能组件和逻辑
- 调整页面背景渐变色和字体大小
- 优化轮播图触摸操作支持
- 更新分享标题为用户专属推荐
- 调整商品分类显示,隐藏部分分类入口
- 移除领券中心入口
- 简化配送时间选择,只选择日期不选择具体时间
- 移除门店选择界面元素
- 调整时间选择器为日期模式并修正时间格式化逻辑
2026-02-07 11:59:06 +08:00
f20c8b0961 feat(rider): 更新配送订单功能以支持新的送水订单系统
- 将 ShopOrder 替换为 GltTicketOrder 类型
- 更新 API 调用从 pageShopOrder/updateShopOrder 到 pageGltTicketOrder/updateGltTicketOrder
- 重构配送状态管理逻辑,使用 deliveryStatus 字段替代原有状态判断
- 添加新的配送流程按钮:开始配送、补传照片完成等功能
- 实现配送确认模式选择(拍照完成或等待客户确认)
- 集成 PullToRefresh 下拉刷新组件提升用户体验
- 添加 Loading 组件优化加载状态显示
- 重构订单列表展示界面适配新订单类型和字段结构
- 实现订单状态颜色标识和流程进度显示
- 添加地址复制和联系门店功能按钮
2026-02-06 23:08:31 +08:00
25177d724e feat(ticket): 完善水票配送订单功能
- 优化导入路径,修复 PageParam 类型引用
- 新增 DeliverConfirmMode 类型定义,支持拍照完成和等待客户确认两种模式
- 实现配送确认的双模式功能,支持直接完成和等待确认流程
- 重构订单状态判断逻辑,完善配送流程状态管理
- 新增用户端确认收货功能,支持手动确认收货操作
- 优化订单列表展示,增加票号、取货点、门店电话等详细信息
- 添加地址复制和联系门店功能按钮
- 实现补传照片完成订单功能
- 更新订单流程状态显示,提供更准确的状态标识
- 添加配送确认模式切换的单选框界面
- 优化下单成功后的页面跳转逻辑
- 新增水票配送订单后端接口设计文档
2026-02-06 20:33:56 +08:00
661e7574ef feat(ticket): 将送水订单功能重构为配送订单系统
- 修改页面标题从"送水订单"为"配送订单"
- 扩展订单模型增加配送相关字段:配送开始时间、结束时间、送达照片、配送状态、客户确认时间等
- 新增配送员角色相关字段和筛选参数
- 实现完整的配送流程管理:待配送、配送中、待确认、已完成状态流转
- 添加配送订单标签页切换功能,支持按状态分类查看
- 集成配送操作界面,支持开始配送和确认送达功能
- 实现配送照片上传和展示功能
- 优化订单列表显示,增加配送流程进度展示
- 添加配送相关的业务逻辑验证和状态判断方法
2026-02-06 20:00:36 +08:00
56d933ddf8 feat(ticket): 将核销记录替换为送水订单功能并优化用户体验
- 替换核销记录为送水订单展示功能
- 在订单模型中新增门店、配送员、仓库的名称和联系方式字段
- 添加用户昵称、头像、手机号等个人信息字段
- 实现配送时间选择器功能
- 设置最低起送数量限制为10桶
- 优化订单列表展示界面和交互逻辑
- 添加订单状态显示功能
- 实现订单数据分页加载和搜索功能
- 优化页面数据加载性能,支持静默刷新
2026-02-06 19:41:31 +08:00
c0954564a6 feat(user): 优化用户权限管理与扫码功能
- 添加 isAdmin 状态检查逻辑支持多种数据类型 (true/1/'1')
- 实现统一扫码按钮的管理员权限控制,仅管理员可查看
- 集成 saveStorageByLoginUser 工具函数统一处理登录用户信息存储
- 优化扫码取消操作的错误处理,区分用户主动取消与实际错误
- 同步本地存储中的用户信息以便其他钩子读取管理员标识
2026-02-06 02:29:02 +08:00
5bddf6e438 fix(order): 修复订单列表筛选逻辑和支付倒计时配置
- 将开发环境API_BASE_URL切换回本地地址
- 修复Tabs状态筛选器逻辑,全部状态时删除筛选参数
- 更新待收货和退货/售后标签页的订单过滤规则
- 将支付倒计时超时时间从1小时调整为24小时
- 修复立即支付按钮显示逻辑,避免已过期订单仍显示支付按钮
2026-02-06 00:17:43 +08:00
6197dbdb84 refactor(user): 移除多余的状态栏高度获取逻辑
- 删除了状态栏高度相关的状态变量定义
- 移除了系统信息获取的副作用逻辑
- 简化了页面顶部的安全区域处理
- 更新了导航栏配置以使用默认样式
- 优化了页面布局结构减少了不必要的视图组件
2026-02-05 19:22:42 +08:00
96d1bb959e feat(ticket): 添加送水订单功能和页面
- 新增 ticket/orders/index 页面用于展示送水订单
- 添加 GltTicketOrder 相关数据模型定义
- 实现送水订单的增删改查 API 接口
- 在水票使用页面集成订单功能
- 添加水票选择逻辑优化
- 实现送水订单列表分页加载
- 集成下拉刷新和上拉加载更多功能
2026-02-05 19:17:40 +08:00
a1e5bf1c05 feat(ticket): 实现水票下单功能,支持用水票抵扣送水订单
- 移除原有优惠券和支付方式选择逻辑
- 添加水票相关的API调用和数据管理
- 实现水票消费计划算法,按FIFO原则使用水票
- 添加水票明细弹窗展示用户持有的水票
- 实现下单时自动扣除对应数量水票的功能
- 添加水票核销记录日志功能
- 修改数量选择器以限制在水票可用范围内
- 实现水票下单的完整业务流程验证
2026-02-05 19:01:08 +08:00
2a3b661478 feat(ticket): 移除礼品卡相关页面并调整路由配置
- 删除 ticket/add.config.ts 和 ticket/add.tsx 页面文件
- 删除 ticket/detail.config.ts 和 ticket/detail.tsx 页面文件
- 删除 ticket/receive.config.ts 和 ticket/receive.tsx 页面文件
- 删除 ticket/redeem.config.ts 和 ticket/redeem.tsx 页面文件
- 将 app.config.ts 中的 ticket/detail 路由改为 ticket/use
- 修改首页订单按钮跳转链接从 goodsDetail 到 ticket/use
- 修改首页商品卡片按钮跳转从 coupon/index 到 ticket/index
- 新增 ticket/use.config.ts 配置文件并设置页面标题为立即送水
2026-02-05 18:35:17 +08:00
6d33708601 refactor(order): 重构订单模块并移除再次购买功能
- 移除订单列表中的再次购买功能及相关代码
- 更新API导入语句格式以提高可读性
- 添加退款订单API方法
- 清理未使用的导航函数导入
- 简化订单列表组件的按钮渲染逻辑
2026-02-05 17:54:41 +08:00
8c7698a198 fix(ui): 修正页面标题文字
- 将'立即订水'修改为'立即送水'
- 更新首页导航标题
- 更新商品详情页跳转标题
2026-02-05 17:37:40 +08:00
24354a38c5 feat(tickets): 优化水票页面显示和订单状态逻辑
- 集成 dayjs 库用于时间格式化
- 将下单时间格式化为 YYYY年MM月DD日 HH:mm:ss 格式
- 重新设计水票数量展示布局,突出显示可用、已用和剩余赠票
- 更新核销记录标题为票号显示
- 修改二维码扫描页面显示票号而非模板名称
- 添加订单完成状态判断函数
- 在待发货和待收货状态中排除已完成订单
- 移除已完成订单状态下的操作按钮
2026-02-05 12:03:56 +08:00
5dc70a1c3c fix(config): 更新开发和测试环境API基础URL配置
- 将开发环境API_BASE_URL从本地地址切换到线上地址
- 将测试环境API_BASE_URL从本地地址切换到线上地址
- 移除SERVER_API_URL常量引用
- 简化gltUserTicket接口请求路径配置
- 修改用户票券列表显示票号ID替代模板名称
- 注释掉票券状态标签显示逻辑
2026-02-05 11:14:36 +08:00
5e90c48b8b feat(glt): 完善水票总数获取逻辑并优化用户体验
- 新增 normalizeTotal 函数处理多种数据格式的总数解析
- 支持通过 userId 参数查询指定用户的水票总数
- 添加多服务器地址尝试机制提高接口可用性
- 优化首页和用户中心页面的水票总数加载逻辑
- 修复水票页面滚动区域高度计算问题
- 移除自动跳转登录页面的定时器逻辑
- 个人中心页面支持下拉刷新统计数据
- 统一请求参数传递方式简化代码结构
2026-02-05 01:35:11 +08:00
526c821a67 feat(rider): 实现水票核销页面自动扫描功能
- 在水票核销页面添加自动扫描模式支持
- 添加路由参数检测实现自动开启扫码功能
- 添加首次展示时自动触发扫码逻辑
- 修改用户卡片组件显示实际水票总数而非礼品卡数量
- 添加独立的水票总数刷新机制
- 在用户登录和信息刷新时同步更新水票总数
2026-02-05 01:19:33 +08:00
8679b26f74 feat(rider): 新增水票核销功能
- 添加水票核销扫码页面,支持扫描加密和明文二维码
- 实现水票验证逻辑,包括余额检查和核销确认
- 添加核销记录展示,最多保留最近10条记录
- 在骑手端界面增加水票核销入口
- 新增获取用户水票总数的API接口
- 优化首页轮播图加载,增加缓存和懒加载机制
- 添加门店选择功能,支持订单确认页切换门店
- 修复物流信息类型安全问题
- 更新用户中心门店相关文案显示
2026-02-05 01:08:37 +08:00
fcbaa970d0 feat(home): 添加商品上下架状态管理功能
- 在商品模型中新增status字段用于标识商品上下架状态
- 首页请求商品列表时默认传入status为0参数
- 商品列表数据过滤仅显示上架状态的商品
- 添加商品状态注释说明0为上架1为下架
2026-02-04 15:40:27 +08:00
5e36f243ef feat(order): 添加订单重新发起支付功能并优化支付流程
- 新增 prepayShopOrder 接口用于对未支付订单生成新预支付参数
- 实现多路径兼容探测机制,支持不同后端版本的支付接口
- 优化订单支付逻辑,优先使用服务端最新状态避免重复支付
- 添加 fallback 机制,在重新支付失败时降级为重新创建订单
- 实现支付成功后自动取消旧待支付订单,避免列表堆积
- 修复订单列表中key值重复的问题
- 在商品列表中添加数量标识符x提升UI显示效果
2026-02-04 15:32:27 +08:00
afe8f93c32 fix(config): 恢复开发和测试环境API配置
- 将开发环境API_BASE_URL从线上地址改回本地地址
- 在开发环境注释掉线上API地址配置
- 将测试环境API_BASE_URL从线上地址改回本地地址
- 在测试环境注释掉线上API地址配置

fix(order): 修复待收货状态下订单操作权限控制

- 在待收货状态判断条件中增加orderStatus !== 6的限制
- 防止已完成订单在待收货状态下显示查看物流和确认收货按钮
2026-02-04 15:10:31 +08:00
174f9458e2 fix(order): 修复订单列表中申请退款按钮的事件冒泡问题
- 在申请退款按钮点击事件中添加了 stopPropagation 防止事件冒泡
- 确保退款申请操作不会触发父级元素的点击事件
- 保持了原有的订单项数据传递逻辑不变
2026-02-04 14:45:34 +08:00
f96918bf86 feat(ticket): 添加水票功能支持
- 在订单模型中增加formId字段用于标识商品ID
- 更新统一扫码组件以支持水票和礼品卡核销
- 实现水票列表页面,包含我的水票和核销记录两个标签页
- 添加水票核销二维码生成功能
- 支持水票的分页加载和搜索功能
- 实现水票核销记录的展示
- 添加水票状态变更历史追踪
- 更新订单状态判断逻辑以支持特定商品完成状态
- 扩展扫码验证功能以处理水票业务类型
2026-02-04 11:00:54 +08:00
a3c952d092 feat(ticket): 添加水票功能模块
- 新增水票相关API接口,包括水票模板、用户水票、消费日志和水票释放功能
- 添加水票管理页面,实现水票的增删改查和详情展示功能
- 实现水票的分页查询和列表展示界面
- 替换原有的礼品卡功能为水票功能,在首页导航中更新路由链接
- 添加水票详情页面,支持二维码展示和兑换码复制功能
- 实现水票的状态管理和使用流程控制
2026-02-04 10:02:26 +08:00
cb17e48b03 feat(home): 优化首页商品展示功能
- 添加 recommend 字段到商品模型定义
- 重构首页标签页逻辑,支持分类参数传递
- 实现动态商品列表加载,按标签分类获取数据
- 更新订水跳转链接指向新商品ID
- 优化标签页切换逻辑,使用键值对映射
- 添加错误处理机制,防止商品列表加载失败
2026-02-03 20:06:50 +08:00
945bf9af8d refactor(order): 优化订单商品数据显示逻辑
- 将订单模型中的 orderGoods 类型从 OrderGoods 改为 ShopOrderGoods
- 移除 OrderWithGoods 接口定义和 normalizeOrderGoodsList 函数
- 直接使用订单分页接口返回的 orderGoods 字段渲染商品信息
- 添加 utils/orderGoods.ts 工具函数处理订单商品数据标准化
- 在骑手端订单页面实现商品名称汇总显示功能
- 优化再次购买和支付功能中的商品数据获取逻辑
2026-02-01 12:21:55 +08:00
dea40268fe refactor(order): 优化订单列表性能并移除冗余推荐人信息
- 移除经销商页面中的推荐人显示信息
- 将订单商品详情从单独接口请求改为直接从分页接口获取,避免N+1查询问题
- 添加normalizeOrderGoodsList函数实现订单商品数据结构标准化
- 统一门店名称文字颜色样式为灰色
- 简化支付工具类中的重复API端点调用
2026-02-01 11:51:28 +08:00
a2e34466d5 feat(store): 添加门店管理功能和订单配送功能
- 在app.config.ts中添加门店相关路由配置
- 在config/app.ts中添加租户名称常量
- 在Header.tsx中实现门店选择功能,包括定位、距离计算和门店切换
- 更新ShopOrder模型,添加门店ID、门店名称、配送员ID和仓库ID字段
- 新增ShopStore相关API和服务,支持门店的增删改查
- 新增ShopStoreRider相关API和服务,支持配送员管理
- 新增ShopStoreUser相关API和服务,支持店员管理
- 新增ShopWarehouse相关API和服务,支持仓库管理
- 添加配送订单页面,支持订单状态管理和送达确认功能
- 优化经销商页面的样式布局
2026-02-01 02:42:20 +08:00
3d82a0f194 feat(store): 添加门店管理功能和订单配送功能
- 在app.config.ts中添加门店相关路由配置
- 在config/app.ts中添加租户名称常量
- 在Header.tsx中实现门店选择功能,包括定位、距离计算和门店切换
- 更新ShopOrder模型,添加门店ID、门店名称、配送员ID和仓库ID字段
- 新增ShopStore相关API和服务,支持门店的增删改查
- 新增ShopStoreRider相关API和服务,支持配送员管理
- 新增ShopStoreUser相关API和服务,支持店员管理
- 新增ShopWarehouse相关API和服务,支持仓库管理
- 添加配送订单页面,支持订单状态管理和送达确认功能
- 优化经销商页面的样式布局
2026-02-01 01:39:49 +08:00
f8e689e250 feat(header): 替换网站名称为租户名称显示
- 引入User模型类型定义
- 添加userInfo状态管理
- 实现getTenantName方法获取租户名称
- 将Header组件中的getWebsiteName替换为getTenantName
- 在用户卡片组件中根据域名条件渲染角色标签
2026-01-31 22:47:02 +08:00
e07fd4091e refactor(withdraw): 移除快速提现金额中的无效选项
- 从 quickAmounts 数组中移除 '0.2' 选项
- 防止用户选择低于最低限额的快速金额

refactor(user): 优化用户角色名称获取逻辑

- 移除对 useUser hook 中 getRoleName 的依赖
- 在组件内部实现角色名称获取逻辑
- 优先取用户 roles 数组的第一个角色名称
- 添加默认角色名称为'注册用户'的回退机制
2026-01-31 22:30:51 +08:00
47d2eee486 feat(withdraw): 添加分销商提现领取功能
- 新增 receiveShopDealerWithdraw 接口用于用户领取提现
- 新增 receiveSuccessShopDealerWithdraw 接口用于领取成功回调
- 添加 ShopDealerWithdrawReceiveResult 类型定义
- 实现提取 package_info 的 extractPackageInfo 函数
- 更新提现列表页面的领取按钮样式
- 完善领取流程的状态处理和错误提示机制
2026-01-31 22:24:51 +08:00
3b98dfa150 feat(dealer): 更新提现流程为审核后领取模式
- 添加新的API接口getShopDealerWithdraw和updateShopDealerWithdraw
- 新增package_info相关字段用于微信确认收款流程
- 添加claimingId状态管理用于控制领取按钮
- 修改状态显示逻辑,将"审核通过"改为"待领取",颜色从success改为info
- 移除直接调用微信收款确认的逻辑,改为先提交审核再领取
- 新增handleClaim函数处理提现领取流程
- 在提现记录中添加"立即领取"按钮,仅在待领取状态下显示
- 更新提现说明文案,明确审核后领取流程
- 调整记录列表界面布局,优化时间显示和按钮位置
2026-01-31 22:04:59 +08:00
3a68955f1c refactor(dealer): 简化提现功能只支持微信钱包
- 移除支付宝和银行卡提现方式的选择
- 删除相关账户信息输入字段验证逻辑
- 简化提现表单只保留微信钱包选项
- 更新快速金额按钮配置
- 移除多余的状态管理变量
- 删除不再使用的 Radio 和 Cell 组件导入
- 移除提现
2026-01-31 21:14:10 +08:00
b9c03be394 feat(withdraw): 实现微信商家转账收款确认功能
- 配置文件中更新测试环境API基础URL
- 添加ShopDealerWithdrawCreateResult类型定义以支持微信转账返回的package_info
- 修改addShopDealerWithdraw函数以处理微信转账流程的特殊返回值
- 实现extractPackageInfo、canRequestMerchantTransferConfirm和requestMerchantTransferConfirm辅助函数
- 在微信钱包提现流程中集成商户转账确认页面调用
- 添加对微信小程序环境的检测和错误处理
- 更新快速金额选项,增加1元选项
- 修改微信提现提示文字,说明需要确认收款的流程
2026-01-31 18:57:32 +08:00
3a42eaf853 feat(gift): 将礼品卡功能重命名为水票并添加新增页面路由
- 将所有"礼品卡"文本替换为"水票",包括页面标题、组件文案、注释等
- 修改首页导航,将充值水票按钮指向我的水票页面
- 调整订水按钮链接直接跳转到商品详情页
- 移除帮助按钮相关代码
- 更新数据转换函数中的面值规格文案
- 修改核销成功提示中的商品类型文案
- 调整空状态提示文案为水票相关内容
- 在应用配置中添加新的水票添加页面路由
- 更新类型定义中的注释说明
2026-01-31 13:39:10 +08:00
f5c6d52b78 feat(rider): 添加配送员模块和订单图片保存功能
- 新增配送员首页界面,包含订单管理、工资明细、配送小区、仓库地址等功能入口
- 实现小程序码保存到相册功能,支持权限检查和错误处理
- 添加相册写入权限配置和图片下载临时路径处理
- 修复订单列表商品信息显示问题,优化支付流程
- 更新首页轮播图广告代码,调整用户中心网格布局
- 增加订单页面返回时的数据刷新机制,提升用户体验
2026-01-31 02:52:28 +08:00
7227ec6d84 fix(dealer): 修复经销商提现功能中的金额处理问题
- 添加 normalizeMoneyString 函数统一处理后端返回的金额数据类型
- 使用 normalizeMoneyString 替代直接访问 dealerUser.money 确保金额始终为字符串
- 修改金额验证逻辑确保数值转换的准确性
- 更新格式化金额函数支持未知类型输入并添加数值有效性检查
- 修复 Radio.Group 控件值更新时表单字段同步问题
2026-01-28 14:30:07 +08:00
ed5ef3fb19 feat(register): 移除注册页面并调整经销商申请流程
- 删除 passport/register.tsx 和 passport/register.config.ts 注册相关文件
- 从 app.config.ts 中移除 register 页面配置
- 将经销商申请页面标题从"邀请注册"改为"注册会员"
- 注释掉经销商申请表单中的邀请人ID字段
- 更新经销商申请页面导航栏标题文本
2026-01-28 10:22:14 +08:00
ed02db5a8d fix(dealer): 解决经销商申请注册流程中的角色分配问题
- 添加了对用户ID存在性的检查,避免注册时用户信息缺失导致的错误
- 实现了更健壮的角色查询逻辑,当查询不到角色时使用默认角色ID进行兜底
- 新增了addUserRole API方法用于在用户无角色时创建默认角色
- 优化了角色分配逻辑,支持upsert操作以处理不同后端实现方式
- 将页面跳转从navigateBack改为switchTab,确保注册后正确返回到"我的"页面
- 更新了API基础URL配置,统一指向新的mp-api域名
- 修复了二维码组件中的API地址引用,保持与新域名的一致性
2026-01-27 17:50:16 +08:00
a4938fbe31 fix(config): 更新API基础URL配置
- 将开发环境API_BASE_URL从mp-api.websoft.top更改为cms-api.websoft.top
- 将生产环境API_BASE_URL从mp-api.websoft.top更改为cms-api.websoft.top
- 将测试环境API_BASE_URL从mp-api.s209.websoft.top更改为cms-api.s209.websoft.top
- 更新SimpleQRCodeModal组件中的二维码生成API地址为cms-api.websoft.top
2026-01-27 16:53:13 +08:00
aff888794f feat(dealer): 添加分销商收益明细页面并优化订单管理功能
- 新增收益明细页面,支持下拉刷新和上拉加载更多
- 在app.config.ts中注册收益明细页面路由
- 更新API基础URL配置,统一使用mp-api域名
- 优化提交表单逻辑,确保refereeId参数为数字类型
- 修改订单页面,添加resourceId参数以正确过滤分销订单
- 修复订单号显示逻辑,优先使用接口返回的订单号
- 优化订单列表项点击事件,跳转到收益明细页面
- 更新客户名称显示格式,包含昵称和用户ID
- 调整订单详情展示布局和信息内容
2026-01-25 13:32:49 +08:00
0d6eb331c8 feat(shop): 添加商品分享邀请功能
- 切换API基础URL到生产环境地址
- 在商品详情页添加邀请参数解析和存储逻辑
- 实现分享链接携带邀请者ID和来源信息
- 新增商品分享来源类型标识
- 在短信登录成功后处理待绑定的邀请关系
- 添加邀请关系跟踪和统计功能
2026-01-20 15:18:48 +08:00
415e05cc4e feat(user): 添加用户卡片统计数据接口和优化性能
- 新增 UserCardStats 接口定义余额/积分/优惠券/礼品卡数据结构
- 实现 getUserCardStats 函数聚合返回用户卡片统计数据
- 替换原有多个独立请求为单一聚合接口提升性能
- 修改 useUserData Hook 使用新聚合接口并调整数据类型
- 移除废弃的 pageShopOrder 和相关 API 导入
- 优化用户登录后自动刷新卡片统计数据逻辑
2026-01-20 12:47:22 +08:00
0542b93dc7 feat(home): 重构首页轮播图组件并优化广告数据处理
- 修改首页轮播图组件,替换为新的 Banner 组件实现
- 新增广告图片数据标准化处理函数,支持多种字段格式兼容
- 优化首页广告数据加载逻辑,改用 Promise.allSettled 并行请求
- 修复轮播图高度计算,添加数字转换安全处理
- 调整经销商申请页面文本,将"入驻申请"改为"门店入驻"
- 修复商品卡片图片显示,添加空值处理防止报错
- 临时隐藏搜索栏组件,设置为隐藏状态
- 恢复开发环境 API 地址配置,便于本地调试
- 移除经销商申请表单中邀请人 ID 的禁用状态
2026-01-20 11:12:31 +08:00
0770eb1699 feat(home): 重构首页界面并更新API配置
- 移除底部导航栏中的"基地生活"选项卡
- 切换开发环境API地址为线上测试接口
- 添加完整的首页样式定义,包括英雄区域、商品卡片、快捷入口等
- 重构首页组件结构,集成商品列表、分类标签页和交互功能
- 更新主题管理逻辑,支持多种主题模式和用户ID兼容处理
- 添加商品数据获取和展示功能,实现首页内容动态加载
2026-01-15 10:12:49 +08:00
039af32fc3 config(app): 更新应用配置以适配新项目名称
- 将租户ID从10550更新为10584
- 将应用名称从"时里院子市集"更新为"桂乐淘"
- 更新package.json中的项目名称
- 更新project.config.json中的项目描述和APPID
- 更新Vercel项目配置名称
- 更新头条小程序项目描述
- 更新服务器模板ID配置
- 更新各页面分享标题中的应用名称
- 更新订单确认页面的评论字段值
2026-01-14 17:20:45 +08:00
154 changed files with 11563 additions and 1797 deletions

View File

@@ -1 +1 @@
{"projectName":"trae_template-10550_mhk8"}
{"projectName":"trae_template-10584_mhk8"}

View File

@@ -1,12 +1,14 @@
import { API_BASE_URL } from './env'
// 租户ID - 请根据实际情况修改
export const TenantId = '10550';
export const TenantId = '10584';
// 租户名称
export const TenantName = '桂乐淘';
// 接口地址 - 请根据实际情况修改
export const BaseUrl = API_BASE_URL;
// 当前版本
export const Version = 'v3.0.8';
// 版权信息
export const Copyright = 'WebSoft Inc.';
export const Copyright = '桂乐淘·购享无界 乐惠万家';
// java -jar CertificateDownloader.jar -k 0kF5OlPr482EZwtn9zGufUcqa7ovgxRL -m 1723321338 -f ./apiclient_key.pem -s 2B933F7C35014A1C363642623E4A62364B34C4EB -o ./

View File

@@ -2,20 +2,21 @@
export const ENV_CONFIG = {
// 开发环境
development: {
API_BASE_URL: 'http://127.0.0.1:9200/api',
// API_BASE_URL: 'https://cms-api.websoft.top/api',
// API_BASE_URL: 'http://127.0.0.1:9200/api',
API_BASE_URL: 'https://glt-api.websoft.top/api',
APP_NAME: '开发环境',
DEBUG: 'true',
},
// 生产环境
production: {
API_BASE_URL: 'https://cms-api.websoft.top/api',
APP_NAME: '时里院子市集',
API_BASE_URL: 'https://glt-api.websoft.top/api',
APP_NAME: '桂乐淘',
DEBUG: 'false',
},
// 测试环境
test: {
API_BASE_URL: 'https://cms-api.s209.websoft.top/api',
// API_BASE_URL: 'http://127.0.0.1:9200/api',
API_BASE_URL: 'https://glt-api.websoft.top/api',
APP_NAME: '测试环境',
DEBUG: 'true',
}

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

@@ -90,7 +90,7 @@ const handleGetPhoneNumber = ({detail}: {detail: {code?: string, encryptedData?:
success: function () {
if (code) {
Taro.request({
url: 'https://server.websoft.top/api/wx-login/loginByMpWxPhone',
url: 'https://glt-server.websoft.top/api/wx-login/loginByMpWxPhone',
method: 'POST',
data: {
code,

View File

@@ -0,0 +1,41 @@
# 水票配送订单:后端提示词(可直接发给后端)
## 1) 订单查询(配送员端)
请在 `GET /glt/glt-ticket-order/page` 支持以下筛选,并保证权限隔离:
- `riderId`:只返回该配送员的订单(必要)
- `deliveryStatus`10待配送、20配送中、30待客户确认、40已完成必要
- 排序:建议 `sendTime asc` + `createTime desc`(或给前端一个可控排序字段)
## 2) 配送流程字段(建议后端落库并回传)
订单表建议确保有以下字段(当前前端已按这些字段做流程判断/展示):
- `riderId/riderName/riderPhone`:配送员信息
- `deliveryStatus`10/20/30/40
- `sendStartTime`:配送员点击“开始配送”的时间
- `sendEndTime`:配送员点击“确认送达”的时间
- `sendEndImg`:送达拍照留档图片 URL可选/必填由后端策略决定)
- `receiveConfirmTime`:客户确认收货时间
- `receiveConfirmType`10客户手动确认、20配送照片自动确认、30超时自动确认
## 3) 状态流转与校验(强烈建议在后端做)
请在更新订单时做状态机校验,避免前端绕过流程:
- `10 -> 20`:仅允许订单属于当前配送员,且未开始/未送达
- `20 -> 30`:配送员确认送达(可带 `sendEndImg`
- `20/30 -> 40`:完成;来源可能是
- 客户手动确认(写 `receiveConfirmTime` + `receiveConfirmType=10`
- 配送照片直接完成(写 `receiveConfirmTime` + `receiveConfirmType=20`,并要求 `sendEndImg`
- 超时自动确认(写 `receiveConfirmTime` + `receiveConfirmType=30`,建议由定时任务执行)
## 4) 建议新增/明确的接口能力
为了避免并发抢单/越权更新,建议新增更语义化的接口(或在 update 内做等价校验):
- 接单(抢单/派单):`POST /glt/glt-ticket-order/{id}/accept`
- 后端原子校验:仅当 `riderId is null` 才能写入当前 rider 信息
- 开始配送:`POST /glt/glt-ticket-order/{id}/start`(写 `sendStartTime` + `deliveryStatus=20`
- 确认送达:`POST /glt/glt-ticket-order/{id}/delivered`(写 `sendEndTime` + `deliveryStatus=30` + 可选 `sendEndImg`
- 客户确认收货:`POST /glt/glt-ticket-order/{id}/confirm-receive`
- 校验:只能本人 `userId` 操作,且必须已送达
## 5) 为了“导航到收货地址/取货点”的字段补充(建议)
当前仅有 `address` 字符串,无法在小程序内 `openLocation` 精准导航;建议补充:
- 收货地址:`receiverName``receiverPhone``province/city/district/detail``latitude/longitude`
- 取货点(门店/仓库):`storeLatitude/storeLongitude``warehouseLatitude/warehouseLongitude`

View File

@@ -1,5 +1,5 @@
{
"name": "template-10550",
"name": "template-10584",
"version": "1.0.0",
"private": true,
"description": "WebSoft Inc.",

View File

@@ -1,8 +1,8 @@
{
"miniprogramRoot": "dist/",
"projectname": "template-10550",
"description": "时里院子市集",
"appid": "wx5170f9f17a813877",
"projectname": "template-10584",
"description": "桂乐淘",
"appid": "wxad831ba00ad6a026",
"setting": {
"urlCheck": true,
"es6": false,

View File

@@ -1,7 +1,7 @@
{
"miniprogramRoot": "./",
"projectname": "mp-react",
"description": "时里院子市集",
"description": "桂乐淘",
"appid": "touristappid",
"setting": {
"urlCheck": true,

View File

@@ -144,7 +144,7 @@ function UserCard() {
success: function () {
if (code) {
Taro.request({
url: 'https://server.websoft.top/api/wx-login/loginByMpWxPhone',
url: 'https://glt-server.websoft.top/api/wx-login/loginByMpWxPhone',
method: 'POST',
data: {
code,
@@ -237,7 +237,7 @@ function UserCard() {
</div>
<div className={'item flex justify-center flex-col items-center'}
onClick={() => navTo('/user/gift/index', true)}>
<span className={'text-sm text-gray-500'}></span>
<span className={'text-sm text-gray-500'}></span>
<span className={'text-xl'}>{giftCount}</span>
</div>
{/*<div className={'item flex justify-center flex-col items-center'}>*/}

View File

@@ -0,0 +1,101 @@
import request from '@/utils/request';
import type { ApiResult, PageResult } from '@/api/index';
import type { GltTicketOrder, GltTicketOrderParam } from './model';
/**
* 分页查询送水订单
*/
export async function pageGltTicketOrder(params: GltTicketOrderParam) {
const res = await request.get<ApiResult<PageResult<GltTicketOrder>>>(
'/glt/glt-ticket-order/page',
params
);
if (res.code === 0) {
return res.data;
}
return Promise.reject(new Error(res.message));
}
/**
* 查询送水订单列表
*/
export async function listGltTicketOrder(params?: GltTicketOrderParam) {
const res = await request.get<ApiResult<GltTicketOrder[]>>(
'/glt/glt-ticket-order',
params
);
if (res.code === 0 && res.data) {
return res.data;
}
return Promise.reject(new Error(res.message));
}
/**
* 添加送水订单
*/
export async function addGltTicketOrder(data: GltTicketOrder) {
const res = await request.post<ApiResult<unknown>>(
'/glt/glt-ticket-order',
data
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 修改送水订单
*/
export async function updateGltTicketOrder(data: GltTicketOrder) {
const res = await request.put<ApiResult<unknown>>(
'/glt/glt-ticket-order',
data
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 删除送水订单
*/
export async function removeGltTicketOrder(id?: number) {
const res = await request.del<ApiResult<unknown>>(
'/glt/glt-ticket-order/' + id
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 批量删除送水订单
*/
export async function removeBatchGltTicketOrder(data: (number | undefined)[]) {
const res = await request.del<ApiResult<unknown>>(
'/glt/glt-ticket-order/batch',
{
data
}
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 根据id查询送水订单
*/
export async function getGltTicketOrder(id: number) {
const res = await request.get<ApiResult<GltTicketOrder>>(
'/glt/glt-ticket-order/' + id
);
if (res.code === 0 && res.data) {
return res.data;
}
return Promise.reject(new Error(res.message));
}

View File

@@ -0,0 +1,94 @@
import type { PageParam } from '@/api';
/**
* 送水订单
*/
export interface GltTicketOrder {
//
id?: number;
// 用户水票ID
userTicketId?: number;
// 订单编号
orderNo?: string;
// 门店ID
storeId?: number;
// 门店名称
storeName?: string;
// 门店地址
storeAddress?: string;
// 门店电话
storePhone?: string;
// 配送员
riderId?: number;
// 配送员名称
riderName?: string;
// 配送员电话
riderPhone?: string;
// 仓库ID
warehouseId?: number;
// 仓库名称
warehouseName?: string;
// 仓库地址
warehouseAddress?: string;
// 关联收货地址
addressId?: number;
// 收货地址
address?: string;
// 配送时间
sendTime?: string;
// 配送开始时间(配送员点击“开始配送”)
sendStartTime?: string;
// 配送结束时间(配送员确认送达)
sendEndTime?: string;
// 配送员送达拍照(选填/必填由后端策略决定)
sendEndImg?: string;
// 发货/配送状态建议10待配送 20配送中 30待客户确认 40已完成
deliveryStatus?: number;
// 客户确认收货时间(客户点击确认收货)
receiveConfirmTime?: string;
// 客户确认方式建议10客户手动确认 20配送照片自动确认 30后台超时自动确认
receiveConfirmType?: number;
// 买家留言
buyerRemarks?: string;
// 用于统计
price?: string;
// 购买数量
totalNum?: number;
// 用户ID
userId?: number;
// 昵称
nickname?: string;
// 头像
avatar?: string;
// 手机号码
phone?: string;
// 排序(数字越小越靠前)
sortNumber?: number;
// 备注
comments?: string;
// 状态, 0正常, 1冻结
status?: number;
// 是否删除, 0否, 1是
deleted?: number;
// 租户id
tenantId?: number;
// 创建时间
createTime?: string;
// 修改时间
updateTime?: string;
}
/**
* 送水订单搜索条件
*/
export interface GltTicketOrderParam extends PageParam {
id?: number;
keywords?: string;
userId?: number;
// 配送员用户ID用于配送员端查询
riderId?: number;
// 发货/配送状态(建议与 GltTicketOrder.deliveryStatus 对齐)
deliveryStatus?: number;
// 兼容 ShopOrderParam 的筛选字段(如后端已实现可直接复用)
statusFilter?: number;
}

View File

@@ -0,0 +1,118 @@
import request from '@/utils/request';
import type { ApiResult, PageResult } from '@/api';
import type { GltTicketTemplate, GltTicketTemplateParam } from './model';
/**
* 分页查询水票
*/
export async function pageGltTicketTemplate(params: GltTicketTemplateParam) {
const res = await request.get<ApiResult<PageResult<GltTicketTemplate>>>(
'/glt/glt-ticket-template/page',
{
params
}
);
if (res.code === 0) {
return res.data;
}
return Promise.reject(new Error(res.message));
}
/**
* 查询水票列表
*/
export async function listGltTicketTemplate(params?: GltTicketTemplateParam) {
const res = await request.get<ApiResult<GltTicketTemplate[]>>(
'/glt/glt-ticket-template',
{
params
}
);
if (res.code === 0 && res.data) {
return res.data;
}
return Promise.reject(new Error(res.message));
}
/**
* 添加水票
*/
export async function addGltTicketTemplate(data: GltTicketTemplate) {
const res = await request.post<ApiResult<unknown>>(
'/glt/glt-ticket-template',
data
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 修改水票
*/
export async function updateGltTicketTemplate(data: GltTicketTemplate) {
const res = await request.put<ApiResult<unknown>>(
'/glt/glt-ticket-template',
data
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 删除水票
*/
export async function removeGltTicketTemplate(id?: number) {
const res = await request.del<ApiResult<unknown>>(
'/glt/glt-ticket-template/' + id
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 批量删除水票
*/
export async function removeBatchGltTicketTemplate(data: (number | undefined)[]) {
const res = await request.del<ApiResult<unknown>>(
'/glt/glt-ticket-template/batch',
{
data
}
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 根据id查询水票
*/
export async function getGltTicketTemplate(id: number) {
const res = await request.get<ApiResult<GltTicketTemplate>>(
'/glt/glt-ticket-template/' + id
);
if (res.code === 0 && res.data) {
return res.data;
}
return Promise.reject(new Error(res.message));
}
/**
* 根据商品ID查询水票模板
*/
export async function getGltTicketTemplateByGoodsId(id: number) {
const res = await request.get<ApiResult<GltTicketTemplate>>(
'/glt/glt-ticket-template/getByGoodsId/' + id
);
if (res.code === 0 && res.data) {
return res.data;
}
return Promise.reject(new Error(res.message));
}

View File

@@ -0,0 +1,55 @@
import type { PageParam } from '@/api';
/**
* 水票
*/
export interface GltTicketTemplate {
//
id?: number;
// 关联商品ID
goodsId?: number;
// 名称
name?: string;
// 启用
enabled?: boolean;
// 单位名称
unitName?: string;
// 最小购买数量
minBuyQty?: number;
// 起始发送数量
startSendQty?: number;
// 买赠买1送4 => gift_multiplier=4
giftMultiplier?: number;
// 是否把购买量也计入套票总量(默认仅计入赠送量)
includeBuyQty?: boolean;
// 每期释放数量默认每月释放10
monthlyReleaseQty?: number;
// 总共释放多少期(若配置>0则按期数平均分摊
releasePeriods?: number;
// 首期释放时机0=支付成功当刻1=下个月同日
firstReleaseMode?: number;
// 用户ID
userId?: number;
// 排序(数字越小越靠前)
sortNumber?: number;
// 备注
comments?: string;
// 状态, 0正常, 1冻结
status?: number;
// 是否删除, 0否, 1是
deleted?: number;
// 租户id
tenantId?: number;
// 创建时间
createTime?: string;
// 修改时间
updateTime?: string;
}
/**
* 水票搜索条件
*/
export interface GltTicketTemplateParam extends PageParam {
id?: number;
keywords?: string;
}

View File

@@ -0,0 +1,170 @@
import request from '@/utils/request';
import type { ApiResult, PageResult } from '@/api';
import type { GltUserTicket, GltUserTicketParam } from './model';
function normalizeTotal(input: unknown): number {
if (typeof input === 'number' && Number.isFinite(input)) return input;
if (typeof input === 'string') {
const n = Number(input);
if (Number.isFinite(n)) return n;
}
if (input && typeof input === 'object') {
const obj: any = input;
// Common shapes from different backends.
for (const key of ['total', 'count', 'value', 'num', 'ticketTotal', 'totalQty']) {
const v = obj?.[key];
const n = normalizeTotal(v);
if (n) return n;
}
// Sometimes nested: { data: { total: ... } } / { data: 12 }
if ('data' in obj) {
const n = normalizeTotal(obj.data);
if (n) return n;
}
}
return 0;
}
/**
* 分页查询我的水票
*/
export async function pageGltUserTicket(params: GltUserTicketParam) {
const res = await request.get<ApiResult<PageResult<GltUserTicket>>>(
'/glt/glt-user-ticket/page',
params
);
if (res.code === 0 && res.data) {
return res.data;
}
return Promise.reject(new Error(res.message));
}
/**
* 查询我的水票列表
*/
export async function listGltUserTicket(params?: GltUserTicketParam) {
const res = await request.get<ApiResult<GltUserTicket[]>>(
'/glt/glt-user-ticket',
params
);
if (res.code === 0 && res.data) {
return res.data;
}
return Promise.reject(new Error(res.message));
}
/**
* 添加我的水票
*/
export async function addGltUserTicket(data: GltUserTicket) {
const res = await request.post<ApiResult<unknown>>(
'/glt/glt-user-ticket',
data
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 修改我的水票
*/
export async function updateGltUserTicket(data: GltUserTicket) {
const res = await request.put<ApiResult<unknown>>(
'/glt/glt-user-ticket',
data
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 删除我的水票
*/
export async function removeGltUserTicket(id?: number) {
const res = await request.del<ApiResult<unknown>>(
'/glt/glt-user-ticket/' + id
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 批量删除我的水票
*/
export async function removeBatchGltUserTicket(data: (number | undefined)[]) {
const res = await request.del<ApiResult<unknown>>(
'/glt/glt-user-ticket/batch',
{
data
}
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 根据id查询我的水票
*/
export async function getGltUserTicket(id: number) {
const res = await request.get<ApiResult<GltUserTicket>>(
'/glt/glt-user-ticket/' + id
);
if (res.code === 0 && res.data) {
return res.data;
}
return Promise.reject(new Error(res.message));
}
/**
* 获取我的水票总数
*/
export async function getMyGltUserTicketTotal(userId?: number) {
const params = userId ? { userId } : undefined
const extract = (res: any) => {
// Some backends may return a raw number instead of ApiResult.
if (typeof res === 'number' || typeof res === 'string') return normalizeTotal(res)
if (res && typeof res === 'object' && 'code' in res) {
const apiRes = res as ApiResult<unknown>
if (apiRes.code === 0) return normalizeTotal(apiRes.data)
throw new Error(apiRes.message)
}
return normalizeTotal(res)
}
// Try both the configured BaseUrl host and the auth-server host.
// If the first one returns 0, keep trying; some tenants deploy GLT on a different host.
const urls = [
'/glt/glt-user-ticket/my-total'
]
let lastError: unknown
let firstTotal: number | undefined
for (const url of urls) {
try {
const res = await request.get<any>(url, params)
if (process.env.NODE_ENV === 'development') {
console.log('[getMyGltUserTicketTotal] response:', { url, res })
}
const total = extract(res)
if (firstTotal === undefined) firstTotal = total
if (total) return total
} catch (e) {
if (process.env.NODE_ENV === 'development') {
console.warn('[getMyGltUserTicketTotal] failed:', { url, error: e })
}
lastError = e
}
}
if (firstTotal !== undefined) return firstTotal
return Promise.reject(lastError instanceof Error ? lastError : new Error('获取水票总数失败'))
}

View File

@@ -0,0 +1,66 @@
import type { PageParam } from '@/api';
/**
* 我的水票
*/
export interface GltUserTicket {
//
id?: number;
// 模板ID
templateId?: number;
// 模板名称
templateName?: string;
// 商品ID
goodsId?: number;
// 订单ID
orderId?: number;
// 订单编号
orderNo?: string;
// 订单商品ID
orderGoodsId?: number;
// 总数量
totalQty?: number;
// 可用数量
availableQty?: number;
// 冻结数量
frozenQty?: number;
// 已使用数量
usedQty?: number;
// 已释放数量
releasedQty?: number;
// 用户ID
userId?: number;
// 用户昵称
nickname?: string;
// 用户头像
avatar?: string;
// 用户手机号
phone?: string;
// 排序(数字越小越靠前)
sortNumber?: number;
// 备注
comments?: string;
// 状态, 0正常, 1冻结
status?: number;
// 是否删除, 0否, 1是
deleted?: number;
// 租户id
tenantId?: number;
// 创建时间
createTime?: string;
// 修改时间
updateTime?: string;
}
/**
* 我的水票搜索条件
*/
export interface GltUserTicketParam extends PageParam {
id?: number;
templateId?: number;
userId?: number;
phone?: string;
keywords?: string;
// 状态过滤0正常1冻结
status?: number;
}

View File

@@ -0,0 +1,101 @@
import request from '@/utils/request';
import type { ApiResult, PageResult } from '@/api';
import type { GltUserTicketLog, GltUserTicketLogParam } from './model';
/**
* 分页查询消费日志
*/
export async function pageGltUserTicketLog(params: GltUserTicketLogParam) {
const res = await request.get<ApiResult<PageResult<GltUserTicketLog>>>(
'/glt/glt-user-ticket-log/page',
params
);
if (res.code === 0) {
return res.data;
}
return Promise.reject(new Error(res.message));
}
/**
* 查询消费日志列表
*/
export async function listGltUserTicketLog(params?: GltUserTicketLogParam) {
const res = await request.get<ApiResult<GltUserTicketLog[]>>(
'/glt/glt-user-ticket-log',
params
);
if (res.code === 0 && res.data) {
return res.data;
}
return Promise.reject(new Error(res.message));
}
/**
* 添加消费日志
*/
export async function addGltUserTicketLog(data: GltUserTicketLog) {
const res = await request.post<ApiResult<unknown>>(
'/glt/glt-user-ticket-log',
data
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 修改消费日志
*/
export async function updateGltUserTicketLog(data: GltUserTicketLog) {
const res = await request.put<ApiResult<unknown>>(
'/glt/glt-user-ticket-log',
data
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 删除消费日志
*/
export async function removeGltUserTicketLog(id?: number) {
const res = await request.del<ApiResult<unknown>>(
'/glt/glt-user-ticket-log/' + id
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 批量删除消费日志
*/
export async function removeBatchGltUserTicketLog(data: (number | undefined)[]) {
const res = await request.del<ApiResult<unknown>>(
'/glt/glt-user-ticket-log/batch',
{
data
}
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 根据id查询消费日志
*/
export async function getGltUserTicketLog(id: number) {
const res = await request.get<ApiResult<GltUserTicketLog>>(
'/glt/glt-user-ticket-log/' + id
);
if (res.code === 0 && res.data) {
return res.data;
}
return Promise.reject(new Error(res.message));
}

View File

@@ -0,0 +1,54 @@
import type { PageParam } from '@/api';
/**
* 消费日志
*/
export interface GltUserTicketLog {
//
id?: number;
// 用户水票ID
userTicketId?: number;
// 变更类型
changeType?: number;
// 可更改
changeAvailable?: number;
// 更改冻结状态
changeFrozen?: number;
// 已使用更改
changeUsed?: number;
// 可用后
availableAfter?: number;
// 冻结后
frozenAfter?: number;
// 使用后
usedAfter?: number;
// 订单ID
orderId?: number;
// 订单编号
orderNo?: string;
// 用户ID
userId?: number;
// 排序(数字越小越靠前)
sortNumber?: number;
// 备注
comments?: string;
// 状态, 0正常, 1冻结
status?: number;
// 是否删除, 0否, 1是
deleted?: number;
// 租户id
tenantId?: number;
// 创建时间
createTime?: string;
// 修改时间
updateTime?: string;
}
/**
* 消费日志搜索条件
*/
export interface GltUserTicketLogParam extends PageParam {
id?: number;
keywords?: string;
userId?: number;
}

View File

@@ -0,0 +1,105 @@
import request from '@/utils/request';
import type { ApiResult, PageResult } from '@/api';
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;
}
return Promise.reject(new Error(res.message));
}
/**
* 查询水票释放列表
*/
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;
}
return Promise.reject(new Error(res.message));
}
/**
* 添加水票释放
*/
export async function addGltUserTicketRelease(data: GltUserTicketRelease) {
const res = await request.post<ApiResult<unknown>>(
'/glt/glt-user-ticket-release',
data
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 修改水票释放
*/
export async function updateGltUserTicketRelease(data: GltUserTicketRelease) {
const res = await request.put<ApiResult<unknown>>(
'/glt/glt-user-ticket-release',
data
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 删除水票释放
*/
export async function removeGltUserTicketRelease(id?: number) {
const res = await request.del<ApiResult<unknown>>(
'/glt/glt-user-ticket-release/' + id
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 批量删除水票释放
*/
export async function removeBatchGltUserTicketRelease(data: (number | undefined)[]) {
const res = await request.del<ApiResult<unknown>>(
'/glt/glt-user-ticket-release/batch',
{
data
}
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 根据id查询水票释放
*/
export async function getGltUserTicketRelease(id: number) {
const res = await request.get<ApiResult<GltUserTicketRelease>>(
'/glt/glt-user-ticket-release/' + id
);
if (res.code === 0 && res.data) {
return res.data;
}
return Promise.reject(new Error(res.message));
}

View File

@@ -0,0 +1,38 @@
import type { PageParam } from '@/api';
/**
* 水票释放
*/
export interface GltUserTicketRelease {
//
id?: string;
// 水票ID
userTicketId?: string;
// 用户ID
userId?: number;
// 周期编号
periodNo?: number;
// 释放数量
releaseQty?: number;
// 释放时间
releaseTime?: string;
// 状态
status?: number;
// 是否删除, 0否, 1是
deleted?: number;
// 租户id
tenantId?: number;
// 创建时间
createTime?: string;
// 修改时间
updateTime?: string;
}
/**
* 水票释放搜索条件
*/
export interface GltUserTicketReleaseParam extends PageParam {
id?: number;
userId?: number;
keywords?: string;
}

View File

@@ -31,5 +31,11 @@ export interface ShopDealerCapital {
*/
export interface ShopDealerCapitalParam extends PageParam {
id?: number;
// 仅查询当前分销商的收益/资金明细
userId?: number;
// 可选:按订单过滤
orderId?: number;
// 可选:资金流动类型过滤
flowType?: number;
keywords?: string;
}

View File

@@ -8,6 +8,9 @@ export interface ShopDealerOrder {
id?: number;
// 买家用户ID
userId?: number;
nickname?: string;
// 订单编号(部分接口会直接返回订单号字符串)
orderNo?: string;
// 订单ID
orderId?: number;
// 订单总金额(不含运费)
@@ -28,6 +31,10 @@ export interface ShopDealerOrder {
isInvalid?: number;
// 佣金结算(0未结算 1已结算)
isSettled?: number;
// 佣金解冻(0未解冻 1已解冻)
isUnfreeze?: number;
// 订单状态
orderStatus?: number;
// 结算时间
settleTime?: number;
// 商城ID
@@ -47,5 +54,7 @@ export interface ShopDealerOrderParam extends PageParam {
secondUserId?: number;
thirdUserId?: number;
userId?: number;
// 数据权限/资源ID通常传当前登录用户ID
resourceId?: number;
keywords?: string;
}

View File

@@ -95,8 +95,9 @@ export async function getShopDealerUser(userId: number) {
const res = await request.get<ApiResult<ShopDealerUser>>(
'/shop/shop-dealer-user/' + userId
);
if (res.code === 0 && res.data) {
return res.data;
if (res.code === 0) {
// 未注册为分销商时,后端可能返回 data=null这里用 null 表示“没有分销商信息”
return res.data || null;
}
return Promise.reject(new Error(res.message));
}

View File

@@ -2,6 +2,21 @@ import request from '@/utils/request';
import type { ApiResult, PageResult } from '@/api';
import type { ShopDealerWithdraw, ShopDealerWithdrawParam } from './model';
// WeChat transfer v3: backend may return `package_info` for MiniProgram to open the
// "confirm receipt" page via `wx.requestMerchantTransfer`.
export type ShopDealerWithdrawCreateResult =
| string
| {
package_info?: string;
packageInfo?: string;
[k: string]: any;
}
| null
| undefined;
// When applyStatus=20, user can "receive" (WeChat confirm receipt flow).
export type ShopDealerWithdrawReceiveResult = ShopDealerWithdrawCreateResult;
/**
* 分页查询分销商提现明细表
*/
@@ -33,11 +48,40 @@ export async function listShopDealerWithdraw(params?: ShopDealerWithdrawParam) {
/**
* 添加分销商提现明细表
*/
export async function addShopDealerWithdraw(data: ShopDealerWithdraw) {
const res = await request.post<ApiResult<unknown>>(
export async function addShopDealerWithdraw(data: ShopDealerWithdraw): Promise<ShopDealerWithdrawCreateResult> {
const res = await request.post<ApiResult<any>>(
'/shop/shop-dealer-withdraw',
data
);
if (res.code === 0) {
// Some backends return `message`, while WeChat transfer flow returns `data.package_info`.
return res.data ?? res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 用户领取(仅当 applyStatus=20 时)- 后台返回 package_info 供小程序调起确认收款页
*/
export async function receiveShopDealerWithdraw(id: number): Promise<ShopDealerWithdrawReceiveResult> {
const res = await request.post<ApiResult<any>>(
'/shop/shop-dealer-withdraw/receive/' + id,
{}
);
if (res.code === 0) {
return res.data ?? res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 领取成功回调:前端确认收款后通知后台把状态置为 applyStatus=40
*/
export async function receiveSuccessShopDealerWithdraw(id: number) {
const res = await request.post<ApiResult<unknown>>(
'/shop/shop-dealer-withdraw/receive-success/' + id,
{}
);
if (res.code === 0) {
return res.message;
}

View File

@@ -1,7 +1,7 @@
import type { PageParam } from '@/api';
/**
* 礼品卡
* 水票
*/
export interface ShopGift {
// 礼品卡ID

View File

@@ -146,4 +146,7 @@ export interface ShopGoodsParam extends PageParam {
isShow?: number;
stock?: number;
keywords?: string;
recommend?: number;
// 0上架 1下架以实际后端约定为准
status?: number;
}

View File

@@ -1,4 +1,4 @@
import request from '@/utils/request';
import request, { ErrorType, RequestError } from '@/utils/request';
import type { ApiResult, PageResult } from '@/api';
import type { ShopOrder, ShopOrderParam, OrderCreateRequest } from './model';
@@ -113,6 +113,44 @@ export interface WxPayResult {
paySign: string;
}
/**
* 订单重新发起支付(对“已创建但未支付”的订单生成新的预支付参数,不应重复创建订单)
*
* 说明:不同后端版本可能暴露不同路径,这里做兼容探测;若全部失败,调用方可自行降级处理。
*/
export interface OrderPrepayRequest {
orderId: number;
payType: number;
}
export async function prepayShopOrder(data: OrderPrepayRequest) {
const urls = [
'/shop/shop-order/pay',
'/shop/shop-order/prepay',
'/shop/shop-order/repay'
];
let lastError: unknown;
let businessError: unknown;
for (const url of urls) {
try {
const res = await request.post<ApiResult<WxPayResult>>(url, data, { showError: false });
// request.ts 在 code!=0 时会直接 throw走到这里通常都是 code===0
if (res.code === 0) return res.data;
} catch (e) {
// 若已命中“业务错误”(例如订单已取消/已支付),优先保留该错误用于向上提示;
// 不要被后续的 404/网络错误覆盖掉,避免调用方误判为“不支持该接口”而降级走创建订单。
if (!businessError && e instanceof RequestError && e.type === ErrorType.BUSINESS_ERROR) {
businessError = e;
} else {
lastError = e;
}
}
}
return Promise.reject(businessError || lastError || new Error('发起支付失败'));
}
/**
* 创建订单
*/
@@ -140,3 +178,18 @@ export async function repairOrder(data: ShopOrder) {
}
return Promise.reject(new Error(res.message));
}
/**
* 申请|同意退款
*/
export async function refundShopOrder(data: ShopOrder) {
const res = await request.put<ApiResult<unknown>>(
'/shop/shop-order/refund',
data
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}

View File

@@ -1,5 +1,5 @@
import type { PageParam } from '@/api/index';
import {OrderGoods} from "@/api/system/orderGoods/model";
import type { ShopOrderGoods } from '@/api/shop/shopOrderGoods/model';
/**
* 订单
@@ -27,6 +27,14 @@ export interface ShopOrder {
merchantName?: string;
// 商户编号
merchantCode?: string;
// 归属门店IDshop_store.id
storeId?: number;
// 归属门店名称
storeName?: string;
// 配送员用户ID优先级派单
riderId?: number;
// 发货仓库ID
warehouseId?: number;
// 使用的优惠券id
couponId?: number;
// 使用的会员卡id
@@ -61,6 +69,8 @@ export interface ShopOrder {
sendStartTime?: string;
// 配送结束时间
sendEndTime?: string;
// 配送员送达拍照(选填)
sendEndImg?: string;
// 发货店铺id
expressMerchantId?: number;
// 发货店铺
@@ -83,6 +93,8 @@ export interface ShopOrder {
totalNum?: number;
// 教练id
coachId?: number;
// 商品ID
formId?: number;
// 支付的用户id
payUserId?: number;
// 0余额支付, 1微信支付102微信Native2会员卡支付3支付宝4现金5POS机6VIP月卡7VIP年卡8VIP次卡9IC月卡10IC年卡11IC次卡12免费13VIP充值卡14IC充值卡15积分支付16VIP季卡17IC季卡18代付
@@ -146,7 +158,7 @@ export interface ShopOrder {
// 是否已收到赠品
hasTakeGift?: string;
// 订单商品项
orderGoods?: OrderGoods[];
orderGoods?: ShopOrderGoods[];
}
/**
@@ -165,6 +177,14 @@ export interface OrderGoodsItem {
export interface OrderCreateRequest {
// 商品信息列表
goodsItems: OrderGoodsItem[];
// 归属门店IDshop_store.id
storeId?: number;
// 归属门店名称(可选)
storeName?: string;
// 配送员用户ID优先级派单
riderId?: number;
// 发货仓库ID
warehouseId?: number;
// 收货地址ID
addressId?: number;
// 支付方式
@@ -173,6 +193,8 @@ export interface OrderCreateRequest {
couponId?: number;
// 备注
comments?: string;
// 配送开始时间(用于预约/配送时间)
sendStartTime?: string;
// 配送方式 0快递 1自提
deliveryType?: number;
// 自提店铺ID
@@ -197,6 +219,14 @@ export interface OrderGoodsItem {
export interface OrderCreateRequest {
// 商品信息列表
goodsItems: OrderGoodsItem[];
// 归属门店IDshop_store.id
storeId?: number;
// 归属门店名称(可选)
storeName?: string;
// 配送员用户ID优先级派单
riderId?: number;
// 发货仓库ID
warehouseId?: number;
// 收货地址ID
addressId?: number;
// 支付方式
@@ -205,6 +235,8 @@ export interface OrderCreateRequest {
couponId?: number;
// 备注
comments?: string;
// 配送开始时间(用于预约/配送时间)
sendStartTime?: string;
// 配送方式 0快递 1自提
deliveryType?: number;
// 自提店铺ID
@@ -223,6 +255,12 @@ export interface ShopOrderParam extends PageParam {
payType?: number;
isInvoice?: boolean;
userId?: number;
// 归属门店IDshop_store.id
storeId?: number;
// 配送员用户ID
riderId?: number;
// 发货仓库ID
warehouseId?: number;
keywords?: string;
deliveryStatus?: number;
statusFilter?: number;

View File

@@ -0,0 +1,101 @@
import request from '@/utils/request';
import type { ApiResult, PageResult } from '@/api';
import type { ShopStore, ShopStoreParam } from './model';
/**
* 分页查询门店
*/
export async function pageShopStore(params: ShopStoreParam) {
const res = await request.get<ApiResult<PageResult<ShopStore>>>(
'/shop/shop-store/page',
params
);
if (res.code === 0) {
return res.data;
}
return Promise.reject(new Error(res.message));
}
/**
* 查询门店列表
*/
export async function listShopStore(params?: ShopStoreParam) {
const res = await request.get<ApiResult<ShopStore[]>>(
'/shop/shop-store',
params
);
if (res.code === 0 && res.data) {
return res.data;
}
return Promise.reject(new Error(res.message));
}
/**
* 添加门店
*/
export async function addShopStore(data: ShopStore) {
const res = await request.post<ApiResult<unknown>>(
'/shop/shop-store',
data
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 修改门店
*/
export async function updateShopStore(data: ShopStore) {
const res = await request.put<ApiResult<unknown>>(
'/shop/shop-store',
data
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 删除门店
*/
export async function removeShopStore(id?: number) {
const res = await request.del<ApiResult<unknown>>(
'/shop/shop-store/' + id
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 批量删除门店
*/
export async function removeBatchShopStore(data: (number | undefined)[]) {
const res = await request.del<ApiResult<unknown>>(
'/shop/shop-store/batch',
{
data
}
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 根据id查询门店
*/
export async function getShopStore(id: number) {
const res = await request.get<ApiResult<ShopStore>>(
'/shop/shop-store/' + id
);
if (res.code === 0 && res.data) {
return res.data;
}
return Promise.reject(new Error(res.message));
}

View File

@@ -0,0 +1,63 @@
import type { PageParam } from '@/api';
/**
* 门店
*/
export interface ShopStore {
// 自增ID
id?: number;
// 店铺名称
name?: string;
// 门店地址
address?: string;
// 手机号码
phone?: string;
// 邮箱
email?: string;
// 门店经理
managerName?: string;
// 门店banner
shopBanner?: string;
// 所在省份
province?: string;
// 所在城市
city?: string;
// 所在辖区
region?: string;
// 经度和纬度
lngAndLat?: string;
// 位置
location?:string;
// 区域
district?: string;
// 轮廓
points?: string;
// 用户ID
userId?: number;
// 默认仓库IDshop_warehouse.id
warehouseId?: number;
// 默认仓库名称(可选)
warehouseName?: string;
// 状态
status?: number;
// 备注
comments?: string;
// 排序号
sortNumber?: number;
// 是否删除
isDelete?: number;
// 租户id
tenantId?: number;
// 创建时间
createTime?: string;
// 修改时间
updateTime?: string;
}
/**
* 门店搜索条件
*/
export interface ShopStoreParam extends PageParam {
id?: number;
keywords?: string;
}

View File

@@ -0,0 +1,101 @@
import request from '@/utils/request';
import type { ApiResult, PageResult } from '@/api/index';
import type { ShopStoreFence, ShopStoreFenceParam } from './model';
/**
* 分页查询黄家明_电子围栏
*/
export async function pageShopStoreFence(params: ShopStoreFenceParam) {
const res = await request.get<ApiResult<PageResult<ShopStoreFence>>>(
'/shop/shop-store-fence/page',
params
);
if (res.code === 0) {
return res.data;
}
return Promise.reject(new Error(res.message));
}
/**
* 查询黄家明_电子围栏列表
*/
export async function listShopStoreFence(params?: ShopStoreFenceParam) {
const res = await request.get<ApiResult<ShopStoreFence[]>>(
'/shop/shop-store-fence',
params
);
if (res.code === 0 && res.data) {
return res.data;
}
return Promise.reject(new Error(res.message));
}
/**
* 添加黄家明_电子围栏
*/
export async function addShopStoreFence(data: ShopStoreFence) {
const res = await request.post<ApiResult<unknown>>(
'/shop/shop-store-fence',
data
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 修改黄家明_电子围栏
*/
export async function updateShopStoreFence(data: ShopStoreFence) {
const res = await request.put<ApiResult<unknown>>(
'/shop/shop-store-fence',
data
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 删除黄家明_电子围栏
*/
export async function removeShopStoreFence(id?: number) {
const res = await request.del<ApiResult<unknown>>(
'/shop/shop-store-fence/' + id
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 批量删除黄家明_电子围栏
*/
export async function removeBatchShopStoreFence(data: (number | undefined)[]) {
const res = await request.del<ApiResult<unknown>>(
'/shop/shop-store-fence/batch',
{
data
}
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 根据id查询黄家明_电子围栏
*/
export async function getShopStoreFence(id: number) {
const res = await request.get<ApiResult<ShopStoreFence>>(
'/shop/shop-store-fence/' + id
);
if (res.code === 0 && res.data) {
return res.data;
}
return Promise.reject(new Error(res.message));
}

View File

@@ -0,0 +1,43 @@
import type { PageParam } from '@/api/index';
/**
* 黄家明_电子围栏
*/
export interface ShopStoreFence {
// 自增ID
id?: number;
// 围栏名称
name?: string;
// 类型 0圆形 1方形
type?: number;
// 定位
location?: string;
// 经度
longitude?: string;
// 纬度
latitude?: string;
// 区域
district?: string;
// 电子围栏轮廓
points?: string;
// 排序(数字越小越靠前)
sortNumber?: number;
// 备注
comments?: string;
// 状态, 0正常, 1冻结
status?: number;
// 租户id
tenantId?: number;
// 创建时间
createTime?: string;
// 修改时间
updateTime?: string;
}
/**
* 黄家明_电子围栏搜索条件
*/
export interface ShopStoreFenceParam extends PageParam {
id?: number;
keywords?: string;
}

View File

@@ -0,0 +1,101 @@
import request from '@/utils/request';
import type { ApiResult, PageResult } from '@/api';
import type { ShopStoreRider, ShopStoreRiderParam } from './model';
/**
* 分页查询配送员
*/
export async function pageShopStoreRider(params: ShopStoreRiderParam) {
const res = await request.get<ApiResult<PageResult<ShopStoreRider>>>(
'/shop/shop-store-rider/page',
params
);
if (res.code === 0) {
return res.data;
}
return Promise.reject(new Error(res.message));
}
/**
* 查询配送员列表
*/
export async function listShopStoreRider(params?: ShopStoreRiderParam) {
const res = await request.get<ApiResult<ShopStoreRider[]>>(
'/shop/shop-store-rider',
params
);
if (res.code === 0 && res.data) {
return res.data;
}
return Promise.reject(new Error(res.message));
}
/**
* 添加配送员
*/
export async function addShopStoreRider(data: ShopStoreRider) {
const res = await request.post<ApiResult<unknown>>(
'/shop/shop-store-rider',
data
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 修改配送员
*/
export async function updateShopStoreRider(data: ShopStoreRider) {
const res = await request.put<ApiResult<unknown>>(
'/shop/shop-store-rider',
data
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 删除配送员
*/
export async function removeShopStoreRider(id?: number) {
const res = await request.del<ApiResult<unknown>>(
'/shop/shop-store-rider/' + id
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 批量删除配送员
*/
export async function removeBatchShopStoreRider(data: (number | undefined)[]) {
const res = await request.del<ApiResult<unknown>>(
'/shop/shop-store-rider/batch',
{
data
}
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 根据id查询配送员
*/
export async function getShopStoreRider(id: number) {
const res = await request.get<ApiResult<ShopStoreRider>>(
'/shop/shop-store-rider/' + id
);
if (res.code === 0 && res.data) {
return res.data;
}
return Promise.reject(new Error(res.message));
}

View File

@@ -0,0 +1,71 @@
import type { PageParam } from '@/api';
/**
* 配送员
*/
export interface ShopStoreRider {
// 主键ID
id?: string;
// 配送点IDshop_dealer.id
dealerId?: number;
// 骑手编号(可选)
riderNo?: string;
// 姓名
realName?: string;
// 手机号
mobile?: string;
// 头像
avatar?: string;
// 身份证号(可选)
idCardNo?: string;
// 状态1启用0禁用
status?: number;
// 接单状态0休息/下线1在线2忙碌
workStatus?: number;
// 是否开启自动派单1是0否
autoDispatchEnabled?: number;
// 派单优先级(同小区多骑手时可用,值越大越优先)
dispatchPriority?: number;
// 最大同时配送单数0表示不限制
maxOnhandOrders?: number;
// 是否计算工资(提成)1计算0不计算如三方配送点可设0
commissionCalcEnabled?: number;
// 水每桶提成金额(元/桶)
waterBucketUnitFee?: string;
// 其他商品提成方式1按订单固定金额2按订单金额比例3按商品规则(另表)
otherGoodsCommissionType?: number;
// 其他商品提成值:固定金额(元)或比例(%)
otherGoodsCommissionValue?: string;
// 用户ID
userId?: number;
// 经度(配送员当前位置)
longitude?: string;
// 纬度(配送员当前位置)
latitude?: string;
// 备注
comments?: string;
// 排序号
sortNumber?: number;
// 是否删除
isDelete?: number;
// 租户id
tenantId?: number;
// 创建时间
createTime?: string;
// 修改时间
updateTime?: string;
}
/**
* 配送员搜索条件
*/
export interface ShopStoreRiderParam extends PageParam {
id?: number;
keywords?: string;
// 配送点/门店ID后端可能用 dealerId 或 storeId
dealerId?: number;
storeId?: number;
status?: number;
workStatus?: number;
autoDispatchEnabled?: number;
}

View File

@@ -0,0 +1,101 @@
import request from '@/utils/request';
import type { ApiResult, PageResult } from '@/api';
import type { ShopStoreUser, ShopStoreUserParam } from './model';
/**
* 分页查询店员
*/
export async function pageShopStoreUser(params: ShopStoreUserParam) {
const res = await request.get<ApiResult<PageResult<ShopStoreUser>>>(
'/shop/shop-store-user/page',
params
);
if (res.code === 0) {
return res.data;
}
return Promise.reject(new Error(res.message));
}
/**
* 查询店员列表
*/
export async function listShopStoreUser(params?: ShopStoreUserParam) {
const res = await request.get<ApiResult<ShopStoreUser[]>>(
'/shop/shop-store-user',
params
);
if (res.code === 0 && res.data) {
return res.data;
}
return Promise.reject(new Error(res.message));
}
/**
* 添加店员
*/
export async function addShopStoreUser(data: ShopStoreUser) {
const res = await request.post<ApiResult<unknown>>(
'/shop/shop-store-user',
data
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 修改店员
*/
export async function updateShopStoreUser(data: ShopStoreUser) {
const res = await request.put<ApiResult<unknown>>(
'/shop/shop-store-user',
data
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 删除店员
*/
export async function removeShopStoreUser(id?: number) {
const res = await request.del<ApiResult<unknown>>(
'/shop/shop-store-user/' + id
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 批量删除店员
*/
export async function removeBatchShopStoreUser(data: (number | undefined)[]) {
const res = await request.del<ApiResult<unknown>>(
'/shop/shop-store-user/batch',
{
data
}
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 根据id查询店员
*/
export async function getShopStoreUser(id: number) {
const res = await request.get<ApiResult<ShopStoreUser>>(
'/shop/shop-store-user/' + id
);
if (res.code === 0 && res.data) {
return res.data;
}
return Promise.reject(new Error(res.message));
}

View File

@@ -0,0 +1,36 @@
import type { PageParam } from '@/api';
/**
* 店员
*/
export interface ShopStoreUser {
// 主键ID
id?: number;
// 配送点IDshop_dealer.id
storeId?: number;
// 用户ID
userId?: number;
// 备注
comments?: string;
// 排序号
sortNumber?: number;
// 是否删除
isDelete?: number;
// 租户id
tenantId?: number;
// 创建时间
createTime?: string;
// 修改时间
updateTime?: string;
}
/**
* 店员搜索条件
*/
export interface ShopStoreUserParam extends PageParam {
id?: number;
keywords?: string;
storeId?: number;
userId?: number;
isDelete?: number;
}

View File

@@ -0,0 +1,101 @@
import request from '@/utils/request';
import type { ApiResult, PageResult } from '@/api/index';
import type { ShopStoreWarehouse, ShopStoreWarehouseParam } from './model';
/**
* 分页查询仓库
*/
export async function pageShopStoreWarehouse(params: ShopStoreWarehouseParam) {
const res = await request.get<ApiResult<PageResult<ShopStoreWarehouse>>>(
'/shop/shop-store-warehouse/page',
params
);
if (res.code === 0) {
return res.data;
}
return Promise.reject(new Error(res.message));
}
/**
* 查询仓库列表
*/
export async function listShopStoreWarehouse(params?: ShopStoreWarehouseParam) {
const res = await request.get<ApiResult<ShopStoreWarehouse[]>>(
'/shop/shop-store-warehouse',
params
);
if (res.code === 0 && res.data) {
return res.data;
}
return Promise.reject(new Error(res.message));
}
/**
* 添加仓库
*/
export async function addShopStoreWarehouse(data: ShopStoreWarehouse) {
const res = await request.post<ApiResult<unknown>>(
'/shop/shop-store-warehouse',
data
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 修改仓库
*/
export async function updateShopStoreWarehouse(data: ShopStoreWarehouse) {
const res = await request.put<ApiResult<unknown>>(
'/shop/shop-store-warehouse',
data
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 删除仓库
*/
export async function removeShopStoreWarehouse(id?: number) {
const res = await request.del<ApiResult<unknown>>(
'/shop/shop-store-warehouse/' + id
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 批量删除仓库
*/
export async function removeBatchShopStoreWarehouse(data: (number | undefined)[]) {
const res = await request.del<ApiResult<unknown>>(
'/shop/shop-store-warehouse/batch',
{
data
}
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 根据id查询仓库
*/
export async function getShopStoreWarehouse(id: number) {
const res = await request.get<ApiResult<ShopStoreWarehouse>>(
'/shop/shop-store-warehouse/' + id
);
if (res.code === 0 && res.data) {
return res.data;
}
return Promise.reject(new Error(res.message));
}

View File

@@ -0,0 +1,53 @@
import type { PageParam } from '@/api/index';
/**
* 仓库
*/
export interface ShopStoreWarehouse {
// 自增ID
id?: number;
// 仓库名称
name?: string;
// 唯一标识
code?: string;
// 类型 中心仓,区域仓,门店仓
type?: string;
// 仓库地址
address?: string;
// 真实姓名
realName?: string;
// 联系电话
phone?: string;
// 所在省份
province?: string;
// 所在城市
city?: string;
// 所在辖区
region?: string;
// 经纬度
lngAndLat?: string;
// 用户ID
userId?: number;
// 状态
status?: number;
// 备注
comments?: string;
// 排序号
sortNumber?: number;
// 是否删除
isDelete?: number;
// 租户id
tenantId?: number;
// 创建时间
createTime?: string;
// 修改时间
updateTime?: string;
}
/**
* 仓库搜索条件
*/
export interface ShopStoreWarehouseParam extends PageParam {
id?: number;
keywords?: string;
}

View File

@@ -38,6 +38,8 @@ export interface ShopUserAddress {
tenantId?: number;
// 注册时间
createTime?: string;
// 更新时间
updateTime?: string;
}
/**

View File

@@ -21,7 +21,7 @@ export async function uploadOssByPath(filePath: string) {
let stsExpired = Taro.getStorageSync('stsExpiredAt');
if (!sts || (stsExpired && dayjs().isBefore(dayjs(stsExpired)))) {
// @ts-ignore
const {data: {data: {credentials}}} = await request.get(`https://server.websoft.top/api/oss/getSTSToken`)
const {data: {data: {credentials}}} = await request.get(`https://gle-server.websoft.top/api/oss/getSTSToken`)
Taro.setStorageSync('sts', credentials)
Taro.setStorageSync('stsExpiredAt', credentials.expiration)
sts = credentials
@@ -49,7 +49,7 @@ export async function uploadOssByPath(filePath: string) {
})
}
const computeSignature = (accessKeySecret, canonicalString) => {
const computeSignature = (accessKeySecret: string, canonicalString: string): string => {
return crypto.enc.Base64.stringify(crypto.HmacSHA1(canonicalString, accessKeySecret));
}
@@ -66,7 +66,7 @@ export async function uploadFile() {
const tempFilePath = res.tempFilePaths[0];
// 上传图片到OSS
Taro.uploadFile({
url: 'https://server.websoft.top/api/oss/upload',
url: 'https://glt-server.websoft.top/api/oss/upload',
filePath: tempFilePath,
name: 'file',
header: {

View File

@@ -30,3 +30,18 @@ export async function updateUserRole(data: UserRole) {
}
return Promise.reject(new Error(res.message));
}
/**
* 新增用户角色
* 说明:部分后端实现为 POST 新增、PUT 修改;这里补齐 API 以便新用户无角色时可以创建默认角色。
*/
export async function addUserRole(data: UserRole) {
const res = await request.post<ApiResult<unknown>>(
SERVER_API_URL + '/system/user-role',
data
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}

View File

@@ -43,6 +43,15 @@ export interface UserOrderStats {
total: number
}
// 用户卡片统计(个人中心头部:余额/积分/优惠券/水票)
export interface UserCardStats {
balance: string
points: number
coupons: number
giftCards: number
lastUpdateTime?: string
}
// 用户完整数据
export interface UserDashboard {
balance: UserBalance
@@ -108,6 +117,17 @@ export async function getUserOrderStats() {
return Promise.reject(new Error(res.message))
}
/**
* 获取用户卡片统计(一次性返回余额/积分/可用优惠券/未使用礼品卡数量)
*/
export async function getUserCardStats() {
const res = await request.get<ApiResult<UserCardStats>>('/user/card/stats')
if (res.code === 0 && res.data) {
return res.data
}
return Promise.reject(new Error(res.message))
}
/**
* 获取用户完整仪表板数据(一次性获取所有数据)
*/

View File

@@ -54,10 +54,15 @@ export default {
"wallet/wallet",
"coupon/index",
"points/points",
"gift/index",
"gift/redeem",
"gift/detail",
"ticket/index",
"ticket/use",
"ticket/orders/index",
// "gift/index",
// "gift/redeem",
// "gift/detail",
// "gift/add",
"store/verification",
"store/orders/index",
"theme/index",
"poster/poster",
"chat/conversation/index",
@@ -73,6 +78,7 @@ export default {
"apply/add",
"withdraw/index",
"orders/index",
"capital/index",
"team/index",
"qrcode/index",
"invite-stats/index",
@@ -90,6 +96,21 @@ export default {
'comments/index',
'search/index']
},
{
"root": "store",
"pages": [
"index",
"orders/index"
]
},
{
"root": "rider",
"pages": [
"index",
"orders/index",
"ticket/verification/index"
]
},
{
"root": "admin",
"pages": [
@@ -116,12 +137,6 @@ export default {
selectedIconPath: "assets/tabbar/home-active.png",
text: "首页",
},
{
pagePath: "pages/category/index",
iconPath: "assets/tabbar/category.png",
selectedIconPath: "assets/tabbar/category-active.png",
text: "基地生活",
},
{
pagePath: "pages/cart/cart",
iconPath: "assets/tabbar/cart.png",
@@ -144,6 +159,9 @@ export default {
permission: {
"scope.userLocation": {
"desc": "你的位置信息将用于小程序位置接口的效果展示"
},
"scope.writePhotosAlbum": {
"desc": "用于保存小程序码到相册,方便分享给好友"
}
}
}

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

@@ -44,7 +44,7 @@ function Category() {
useShareAppMessage(() => {
return {
title: `${nav?.categoryName}_时里院子市集`,
title: `${nav?.categoryName}_桂乐淘`,
path: `/shop/category/index?id=${categoryId}`,
success: function () {
console.log('分享成功');

View File

@@ -5,6 +5,7 @@ import {getUserInfo} from "@/api/layout";
import {useEffect, useState} from "react";
import {getCmsArticle} from "@/api/cms/cmsArticle";
import {CmsArticle} from "@/api/cms/cmsArticle/model";
import { goToRegister } from '@/utils/auth'
function AddCartBar() {
const { router } = getCurrentInstance();
@@ -13,13 +14,8 @@ function AddCartBar() {
const [IsLogin, setIsLogin] = useState<boolean>(false)
const onPay = () => {
if (!IsLogin) {
Taro.showToast({title: `请先登录`, icon: 'error'})
setTimeout(() => {
Taro.switchTab(
{
url: '/pages/user/user',
},
)
goToRegister({ redirect: '/pages/user/user' })
}, 1000)
return false;
}

View File

@@ -24,7 +24,7 @@ export interface GiftCardProps {
faceValue?: string
/** 商品原价 */
originalPrice?: string
/** 礼品卡类型10-实物礼品 20-虚拟礼品卡 30-服务礼品卡 */
/** 礼品卡类型10-礼品 20-虚拟礼品卡 30-服务礼品卡 */
type?: number
/** 状态0-未使用 1-已使用 2-失效 */
status?: number
@@ -112,10 +112,10 @@ const GiftCard: React.FC<GiftCardProps> = ({
// 获取礼品卡类型文本
const getTypeText = () => {
switch (type) {
case 10: return '实物礼品'
case 10: return '礼品'
case 20: return '虚拟礼品卡'
case 30: return '服务礼品卡'
default: return '礼品卡'
default: return '水票'
}
}

View File

@@ -51,7 +51,7 @@ const GiftCardGuide: React.FC<GiftCardGuideProps> = ({
title: '礼品卡类型说明',
icon: <Gift size="24" className="text-purple-500" />,
content: [
'🎁 实物礼品:需到指定地址领取商品',
'🎁 礼品:需到指定地址领取商品',
'💻 虚拟礼品卡:自动发放到账户余额',
'🛎️ 服务礼品卡:联系客服预约服务',
'⏰ 注意查看有效期,过期无法使用'

View File

@@ -28,10 +28,10 @@ const GiftCardShare: React.FC<GiftCardShareProps> = ({
// 获取礼品卡类型文本
const getTypeText = () => {
switch (giftCard.type) {
case 10: return '实物礼品'
case 10: return '礼品'
case 20: return '虚拟礼品卡'
case 30: return '服务礼品卡'
default: return '礼品卡'
default: return '水票'
}
}

View File

@@ -1,6 +1,6 @@
# PaymentCountdown 支付倒计时组件
基于订单创建时间的支付倒计时组件,支持静态显示和实时更新两种模式。
基于订单过期时间(`expirationTime`的支付倒计时组件,支持静态显示和实时更新两种模式。
## 功能特性
@@ -19,7 +19,7 @@ import PaymentCountdown from '@/components/PaymentCountdown';
// 订单列表页 - 静态显示
<PaymentCountdown
createTime={order.createTime}
expirationTime={order.expirationTime}
payStatus={order.payStatus}
realTime={false}
mode="badge"
@@ -27,7 +27,7 @@ import PaymentCountdown from '@/components/PaymentCountdown';
// 订单详情页 - 实时更新
<PaymentCountdown
createTime={order.createTime}
expirationTime={order.expirationTime}
payStatus={order.payStatus}
realTime={true}
showSeconds={true}
@@ -43,7 +43,7 @@ import PaymentCountdown from '@/components/PaymentCountdown';
```tsx
// 自定义超时时间12小时
<PaymentCountdown
createTime={order.createTime}
expirationTime={order.expirationTime}
payStatus={order.payStatus}
realTime={true}
timeoutHours={12}
@@ -55,7 +55,7 @@ import PaymentCountdown from '@/components/PaymentCountdown';
// 纯文本模式
<PaymentCountdown
createTime={order.createTime}
expirationTime={order.expirationTime}
payStatus={order.payStatus}
realTime={false}
mode="text"
@@ -67,6 +67,7 @@ import PaymentCountdown from '@/components/PaymentCountdown';
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| createTime | string | - | 订单创建时间 |
| expirationTime | string | - | 订单过期时间(推荐) |
| payStatus | boolean | false | 支付状态 |
| realTime | boolean | false | 是否实时更新 |
| timeoutHours | number | 24 | 超时小时数 |
@@ -102,12 +103,13 @@ import PaymentCountdown from '@/components/PaymentCountdown';
import { usePaymentCountdown, formatCountdownText } from '@/hooks/usePaymentCountdown';
const MyComponent = ({ order }) => {
const timeLeft = usePaymentCountdown(
order.createTime,
order.payStatus,
true, // 实时更新
24 // 24小时超时
);
const timeLeft = usePaymentCountdown({
expirationTime: order.expirationTime,
createTime: order.createTime, // expirationTime 缺失时回退
payStatus: order.payStatus,
realTime: true,
timeoutHours: 24
});
const countdownText = formatCountdownText(timeLeft, true);

View File

@@ -11,6 +11,8 @@ import './PaymentCountdown.scss';
export interface PaymentCountdownProps {
/** 订单创建时间 */
createTime?: string;
/** 订单过期时间(推荐直接传后端返回的 expirationTime */
expirationTime?: string;
/** 支付状态 */
payStatus?: boolean;
/** 是否实时更新详情页用true列表页用false */
@@ -29,18 +31,25 @@ export interface PaymentCountdownProps {
const PaymentCountdown: React.FC<PaymentCountdownProps> = ({
createTime,
expirationTime,
payStatus = false,
realTime = false,
timeoutHours = 1,
timeoutHours = 24,
showSeconds = false,
className = '',
onExpired,
mode = 'badge'
}) => {
const timeLeft = usePaymentCountdown(createTime, payStatus, realTime, timeoutHours);
const timeLeft = usePaymentCountdown({
createTime,
expirationTime,
payStatus,
realTime,
timeoutHours
});
// 如果已支付或没有创建时间,不显示倒计时
if (payStatus || !createTime) {
// 如果已支付或没有可计算的截止时间,不显示倒计时
if (payStatus || (!expirationTime && !createTime)) {
return null;
}

View File

@@ -81,7 +81,7 @@ const SimpleQRCodeModal: React.FC<SimpleQRCodeModalProps> = ({
{qrContent ? (
<View className={'flex flex-col justify-center'}>
<img
src={`https://mp-api.websoft.top/api/qr-code/create-encrypted-qr-image?size=300x300&expireMinutes=60&businessType=gift&data=${encodeURIComponent(qrContent)}`}
src={`https://glt-api.websoft.top/api/qr-code/create-encrypted-qr-image?size=300x300&expireMinutes=60&businessType=gift&data=${encodeURIComponent(qrContent)}`}
alt="二维码"
style={{width: '200px', height: '200px'}}
className="mx-auto"

View File

@@ -68,7 +68,7 @@ const UnifiedQRButton: React.FC<UnifiedQRButtonProps> = ({
setTimeout(() => {
Taro.showModal({
title: '核销成功',
content: '是否继续扫码核销其他礼品卡?',
content: '是否继续扫码核销其他水票/礼品卡?',
success: (res) => {
if (res.confirm) {
handleClick(); // 递归调用继续扫码

View File

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

View File

@@ -10,7 +10,9 @@ import {updateUser} from "@/api/system/user";
import {User} from "@/api/system/user/model";
import {getStoredInviteParams, handleInviteRelation} from "@/utils/invite";
import {addShopDealerUser} from "@/api/shop/shopDealerUser";
import {listUserRole, updateUserRole} from "@/api/system/userRole";
import {addUserRole, listUserRole, updateUserRole} from "@/api/system/userRole";
import { listRoles } from "@/api/system/role";
import type { UserRole } from "@/api/system/userRole/model";
// 类型定义
interface ChooseAvatarEvent {
@@ -26,7 +28,7 @@ interface InputEvent {
}
const AddUserAddress = () => {
const {user, loginUser} = useUser()
const {user, loginUser, fetchUserInfo} = useUser()
const [loading, setLoading] = useState<boolean>(true)
const [FormData, setFormData] = useState<User>()
const formRef = useRef<any>(null)
@@ -59,7 +61,7 @@ const AddUserAddress = () => {
setFormData(tempFormData)
Taro.uploadFile({
url: 'https://server.websoft.top/api/oss/upload',
url: 'https://glt-server.websoft.top/api/oss/upload',
filePath: detail.avatarUrl,
name: 'file',
header: {
@@ -127,7 +129,7 @@ const AddUserAddress = () => {
}
// 提交表单
const submitSucceed = async (values: any) => {
const submitSucceed = async (values: User) => {
try {
// 验证必填字段
if (!values.phone && !FormData?.phone) {
@@ -142,8 +144,8 @@ const AddUserAddress = () => {
const nickname = values.realName || FormData?.nickname || '';
if (!nickname || nickname.trim() === '') {
Taro.showToast({
title: '请填写昵称',
icon: 'error'
title: '请上传头像和填写昵称',
icon: 'none'
});
return;
}
@@ -176,12 +178,27 @@ const AddUserAddress = () => {
}
console.log(values,FormData)
const roles = await listUserRole({userId: user?.userId})
console.log(roles, 'roles...')
if (!user?.userId) {
Taro.showToast({
title: '用户信息缺失,请先登录',
icon: 'error'
});
return;
}
let roles: UserRole[] = [];
try {
roles = await listUserRole({userId: user.userId})
console.log(roles, 'roles...')
} catch (e) {
// 新用户/权限限制时可能查不到角色列表,不影响基础注册流程
console.warn('查询用户角色失败,将尝试直接写入默认角色:', e)
roles = []
}
// 准备提交的数据
await updateUser({
userId: user?.userId,
userId: user.userId,
nickname: values.realName || FormData?.nickname,
phone: values.phone || FormData?.phone,
avatar: values.avatar || FormData?.avatar,
@@ -189,17 +206,55 @@ const AddUserAddress = () => {
});
await addShopDealerUser({
userId: user?.userId,
userId: user.userId,
realName: values.realName || FormData?.nickname,
mobile: values.phone || FormData?.phone,
refereeId: values.refereeId || FormData?.refereeId
refereeId: Number(values.refereeId) || Number(FormData?.refereeId)
})
if (roles.length > 0) {
await updateUserRole({
...roles[0],
roleId: 1848
})
// 通知其他页面(如“我的”页、分销中心页)刷新经销商信息
Taro.eventCenter.trigger('dealerUser:changed')
// 角色为空时这里会导致“注册成功但没有角色”,这里做一次兜底写入默认 user 角色
try {
// 1) 先尝试通过 roleCode=user 查询角色ID避免硬编码
// 2) 取不到就回退到旧的默认ID1848
let userRoleId: number | undefined;
try {
// 注意:当前 request.get 的封装不支持 axios 风格的 { params: ... }
// 某些自动生成的 API 可能无法按参数过滤;这里直接取全量再本地查找更稳。
const roleList = await listRoles();
userRoleId = roleList?.find(r => r.roleCode === 'user')?.roleId;
} catch (_) {
// ignore
}
if (!userRoleId) userRoleId = 1848;
const baseRolePayload = {
userId: user.userId,
tenantId: Number(TenantId),
roleId: userRoleId
};
// 后端若已创建 user-role 记录则更新否则尝试“无id更新”触发创建多数实现会 upsert
if (roles.length > 0) {
await updateUserRole({
...roles[0],
roleId: userRoleId
});
} else {
try {
await addUserRole(baseRolePayload);
} catch (_) {
// 兼容后端仅支持 PUT upsert 的情况
await updateUserRole(baseRolePayload);
}
}
// 刷新一次用户信息,确保 roles 写回本地缓存,避免“我的”页显示为空/不一致
await fetchUserInfo();
} catch (e) {
console.warn('写入默认角色失败(不影响注册成功):', e)
}
@@ -209,7 +264,8 @@ const AddUserAddress = () => {
});
setTimeout(() => {
Taro.navigateBack();
// “我的”是 tabBar 页面,注册完成后直接切到“我的”
Taro.switchTab({ url: '/pages/user/user' });
}, 1000);
} catch (error) {
@@ -241,7 +297,7 @@ const AddUserAddress = () => {
success: (loginRes) => {
if (code) {
Taro.request({
url: 'https://server.websoft.top/api/wx-login/loginByMpWxPhone',
url: 'https://glt-server.websoft.top/api/wx-login/loginByMpWxPhone',
method: 'POST',
data: {
authCode: loginRes.code,
@@ -382,9 +438,9 @@ const AddUserAddress = () => {
>
<View className={'bg-gray-100 h-3'}></View>
<CellGroup style={{padding: '4px 0'}}>
<Form.Item name="refereeId" label="邀请人ID" initialValue={FormData?.refereeId} required>
<Input placeholder="邀请人ID" disabled={true}/>
</Form.Item>
{/*<Form.Item name="refereeId" label="邀请人ID" initialValue={FormData?.refereeId} required>*/}
{/* <Input placeholder="邀请人ID" disabled={false}/>*/}
{/*</Form.Item>*/}
<Form.Item name="phone" label="手机号" initialValue={FormData?.phone} required>
<View className="flex items-center justify-between">
<Input

View File

@@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '收益明细'
})

View File

@@ -0,0 +1,2 @@
/* Intentionally empty: styling is done via utility classes. */

View File

@@ -0,0 +1,199 @@
import React, {useCallback, useEffect, useState} from 'react'
import {View, Text, ScrollView} from '@tarojs/components'
import {Empty, Tag, PullToRefresh, Loading} from '@nutui/nutui-react-taro'
import Taro from '@tarojs/taro'
import {pageShopDealerCapital} from '@/api/shop/shopDealerCapital'
import {useDealerUser} from '@/hooks/useDealerUser'
import type {ShopDealerCapital} from '@/api/shop/shopDealerCapital/model'
const PAGE_SIZE = 10
const DealerCapital: React.FC = () => {
const {dealerUser} = useDealerUser()
const [loading, setLoading] = useState(false)
const [refreshing, setRefreshing] = useState(false)
const [loadingMore, setLoadingMore] = useState(false)
const [currentPage, setCurrentPage] = useState(1)
const [hasMore, setHasMore] = useState(true)
const [records, setRecords] = useState<ShopDealerCapital[]>([])
const getFlowTypeText = (flowType?: number) => {
switch (flowType) {
case 10:
return '佣金收入'
case 20:
return '提现支出'
case 30:
return '转账支出'
case 40:
return '转账收入'
default:
return '资金变动'
}
}
const getFlowTypeTag = (flowType?: number) => {
// 收入success支出danger其它default
if (flowType === 10 || flowType === 40) return 'success'
if (flowType === 20 || flowType === 30) return 'danger'
return 'default'
}
const formatMoney = (flowType?: number, money?: string) => {
const isIncome = flowType === 10 || flowType === 40
const isExpense = flowType === 20 || flowType === 30
const sign = isIncome ? '+' : isExpense ? '-' : ''
return `${sign}${money || '0.00'}`
}
const fetchRecords = useCallback(async (page: number = 1, isRefresh: boolean = false) => {
if (!dealerUser?.userId) return
try {
if (isRefresh) {
setRefreshing(true)
} else if (page === 1) {
setLoading(true)
} else {
setLoadingMore(true)
}
const result = await pageShopDealerCapital({
page,
limit: PAGE_SIZE,
// 只显示与当前登录用户相关的收益明细
userId: dealerUser.userId
})
const list = result?.list || []
if (page === 1) {
setRecords(list)
} else {
setRecords(prev => [...prev, ...list])
}
setHasMore(list.length === PAGE_SIZE)
setCurrentPage(page)
} catch (error) {
console.error('获取收益明细失败:', error)
Taro.showToast({
title: '获取收益明细失败',
icon: 'error'
})
} finally {
setLoading(false)
setRefreshing(false)
setLoadingMore(false)
}
}, [dealerUser?.userId])
const handleRefresh = async () => {
await fetchRecords(1, true)
}
const handleLoadMore = async () => {
if (!loadingMore && hasMore) {
await fetchRecords(currentPage + 1)
}
}
useEffect(() => {
if (dealerUser?.userId) {
fetchRecords(1)
}
}, [fetchRecords, dealerUser?.userId])
if (!dealerUser) {
return (
<View className="bg-gray-50 min-h-screen flex items-center justify-center">
<Loading/>
<Text className="text-gray-500 mt-2">...</Text>
</View>
)
}
return (
<View className="min-h-screen bg-gray-50">
<PullToRefresh
onRefresh={handleRefresh}
disabled={refreshing}
pullingText="下拉刷新"
canReleaseText="释放刷新"
refreshingText="刷新中..."
completeText="刷新完成"
>
<ScrollView
scrollY
className="h-screen"
onScrollToLower={handleLoadMore}
lowerThreshold={50}
>
<View className="p-4">
{loading && records.length === 0 ? (
<View className="text-center py-8">
<Loading/>
<Text className="text-gray-500 mt-2">...</Text>
</View>
) : records.length > 0 ? (
<>
{records.map((item) => (
<View key={item.id} className="bg-white rounded-lg p-4 mb-3 shadow-sm">
<View className="flex justify-between items-start mb-1">
<Text className="font-semibold text-gray-800">
{item.describe || '收益明细'}
</Text>
<Tag type={getFlowTypeTag(item.flowType)}>
{getFlowTypeText(item.flowType)}
</Tag>
</View>
<View className="flex justify-between items-center mb-1">
<Text className="text-sm text-gray-400">
</Text>
<Text
className={`text-sm font-semibold ${
item.flowType === 10 || item.flowType === 40 ? 'text-green-600' :
item.flowType === 20 || item.flowType === 30 ? 'text-red-500' :
'text-gray-700'
}`}
>
{formatMoney(item.flowType, item.money)}
</Text>
</View>
<View className="flex justify-between items-center">
<Text className="text-sm text-gray-400">
{/*用户:{item.userId ?? '-'}*/}
</Text>
<Text className="text-sm text-gray-400">
{item.createTime || '-'}
</Text>
</View>
</View>
))}
{loadingMore && (
<View className="text-center py-4">
<Loading/>
<Text className="text-gray-500 mt-1 text-sm">...</Text>
</View>
)}
{!hasMore && records.length > 0 && (
<View className="text-center py-4">
<Text className="text-gray-400 text-sm"></Text>
</View>
)}
</>
) : (
<Empty description="暂无收益明细"/>
)}
</View>
</ScrollView>
</PullToRefresh>
</View>
)
}
export default DealerCapital

View File

@@ -1,3 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '分销中心'
navigationBarTitleText: '账户管理中心'
})

View File

@@ -108,7 +108,7 @@ const DealerIndex: React.FC = () => {
<View className="text-sm" style={{
color: 'rgba(255, 255, 255, 0.8)'
}}>
ID: {dealerUser.userId} | : {dealerUser.refereeId || '无'}
ID: {dealerUser.userId}
</View>
</View>
<View className="text-right hidden">
@@ -129,26 +129,26 @@ 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" style={{
<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" style={{
<View className="text-center p-3 rounded-lg flex flex-col" style={{
background: businessGradients.money.frozen
}}>
<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" style={{
<View className="text-center p-3 rounded-lg flex flex-col" style={{
background: businessGradients.money.total
}}>
<Text className="text-lg font-bold mb-1 text-white">
@@ -217,7 +217,7 @@ const DealerIndex: React.FC = () => {
</View>
</Grid.Item>
<Grid.Item text={'提现申请'} onClick={() => navigateToPage('/dealer/withdraw/index')}>
<Grid.Item text={'申请提现'} onClick={() => navigateToPage('/dealer/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"/>
@@ -225,7 +225,7 @@ const DealerIndex: React.FC = () => {
</View>
</Grid.Item>
<Grid.Item text={'我的邀请'} onClick={() => navigateToPage('/dealer/team/index')}>
<Grid.Item text={'我的团队'} onClick={() => navigateToPage('/dealer/team/index')}>
<View className="text-center">
<View className="w-12 h-12 bg-purple-50 rounded-xl flex items-center justify-center mx-auto mb-2">
<People color="#8b5cf6" size="20"/>
@@ -233,7 +233,7 @@ const DealerIndex: React.FC = () => {
</View>
</Grid.Item>
<Grid.Item text={'我的邀请码'} onClick={() => navigateToPage('/dealer/qrcode/index')}>
<Grid.Item text={'实名认证'} onClick={() => navigateToPage('/user/userVerify/index')}>
<View className="text-center">
<View className="w-12 h-12 bg-orange-50 rounded-xl flex items-center justify-center mx-auto mb-2">
<Dongdong color="#f59e0b" size="20"/>

View File

@@ -24,7 +24,8 @@ const DealerOrders: React.FC = () => {
// 获取订单数据
const fetchOrders = useCallback(async (page: number = 1, isRefresh: boolean = false) => {
if (!dealerUser?.userId) return
// 需要当前登录用户ID用于 resourceId 参数)
if (!dealerUser || !dealerUser.userId) return
try {
if (isRefresh) {
@@ -37,14 +38,17 @@ const DealerOrders: React.FC = () => {
const result = await pageShopDealerOrder({
page,
limit: 10
limit: 10,
// 后端需要 resourceId=当前登录用户ID 才能正确过滤分销订单
resourceId: dealerUser.userId
})
if (result?.list) {
const newOrders = result.list.map(order => ({
...order,
orderNo: `${order.orderId}`,
customerName: `用户${order.userId}`,
// 优先使用接口返回的订单号;没有则降级展示 orderId
orderNo: order.orderNo ?? (order.orderId != null ? String(order.orderId) : undefined),
customerName: `${order.nickname}${order.userId}`,
userCommission: order.firstMoney || '0.00'
}))
@@ -90,44 +94,53 @@ 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'
}
const handleGoCapital = () => {
Taro.navigateTo({url: '/dealer/capital/index'})
}
const renderOrderItem = (order: OrderWithDetails) => (
<View key={order.id} className="bg-white rounded-lg p-4 mb-3 shadow-sm">
<View
key={order.id}
className="bg-white rounded-lg p-4 mb-3 shadow-sm"
onClick={handleGoCapital}
>
<View className="flex justify-between items-start mb-1">
<Text className="font-semibold text-gray-800">
{order.orderNo}
{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 className="flex justify-between items-center mb-1">
<Text className="text-sm text-gray-400">
¥{order.orderPrice || '0.00'}
</Text>
<Text className="text-sm text-orange-500 font-semibold">
¥{order.userCommission}
</Text>
</View>
{/*<View className="flex justify-between items-center mb-1">*/}
{/* <Text className="text-sm text-gray-400">*/}
{/* 订单金额:¥{order.orderPrice || '0.00'}*/}
{/* </Text>*/}
{/*</View>*/}
<View className="flex justify-between items-center">
<Text className="text-sm text-gray-400">
{order.customerName}
{order.createTime}
</Text>
<Text className="text-sm text-gray-400">
{order.createTime}
¥{order.orderPrice || '0.00'}
</Text>
</View>
</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'
@@ -10,10 +10,44 @@ import {businessGradients} from '@/styles/gradients'
const DealerQrcode: React.FC = () => {
const [miniProgramCodeUrl, setMiniProgramCodeUrl] = useState<string>('')
const [loading, setLoading] = useState<boolean>(false)
const [codeLoading, setCodeLoading] = useState<boolean>(false)
const [saving, setSaving] = useState<boolean>(false)
// const [inviteStats, setInviteStats] = useState<InviteStats | null>(null)
// const [statsLoading, setStatsLoading] = useState<boolean>(false)
const {dealerUser} = useDealerUser()
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 () => {
@@ -22,7 +56,7 @@ const DealerQrcode: React.FC = () => {
}
try {
setLoading(true)
setCodeLoading(true)
// 生成邀请小程序码
const codeUrl = await generateInviteCode(dealerUser.userId)
@@ -40,7 +74,7 @@ const DealerQrcode: React.FC = () => {
// 清空之前的二维码
setMiniProgramCodeUrl('')
} finally {
setLoading(false)
setCodeLoading(false)
}
}
@@ -67,6 +101,66 @@ const DealerQrcode: React.FC = () => {
}
}, [dealerUser?.userId])
const isAlbumAuthError = (errMsg?: string) => {
if (!errMsg) return false
// WeChat uses variants like: "saveImageToPhotosAlbum:fail auth deny",
// "saveImageToPhotosAlbum:fail auth denied", "authorize:fail auth deny"
return (
errMsg.includes('auth deny') ||
errMsg.includes('auth denied') ||
errMsg.includes('authorize') ||
errMsg.includes('scope.writePhotosAlbum')
)
}
const ensureWriteAlbumPermission = async (): Promise<boolean> => {
try {
const setting = await Taro.getSetting()
if (setting?.authSetting?.['scope.writePhotosAlbum']) return true
await Taro.authorize({scope: 'scope.writePhotosAlbum'})
return true
} catch (error: any) {
const modal = await Taro.showModal({
title: '提示',
content: '需要您授权保存图片到相册,请在设置中开启相册权限',
confirmText: '去设置'
})
if (modal.confirm) {
await Taro.openSetting()
}
return false
}
}
const downloadImageToLocalPath = async (url: string): Promise<string> => {
// saveImageToPhotosAlbum must receive a local temp path (e.g. `http://tmp/...` or `wxfile://...`).
// Some environments may return a non-existing temp path from getImageInfo, so we verify.
if (url.startsWith('http://tmp/') || url.startsWith('wxfile://')) {
return url
}
const token = Taro.getStorageSync('access_token')
const tenantId = Taro.getStorageSync('TenantId')
const header: Record<string, string> = {}
if (token) header.Authorization = token
if (tenantId) header.TenantId = tenantId
// 先下载到本地临时文件再保存到相册
const res = await Taro.downloadFile({url, header})
if (res.statusCode !== 200 || !res.tempFilePath) {
throw new Error(`图片下载失败(${res.statusCode || 'unknown'})`)
}
// Double-check file exists to avoid: saveImageToPhotosAlbum:fail no such file or directory
try {
await Taro.getFileInfo({filePath: res.tempFilePath})
} catch (_) {
throw new Error('图片临时文件不存在,请重试')
}
return res.tempFilePath
}
// 保存小程序码到相册
const saveMiniProgramCode = async () => {
if (!miniProgramCodeUrl) {
@@ -78,39 +172,64 @@ const DealerQrcode: React.FC = () => {
}
try {
// 先下载图片到本地
const res = await Taro.downloadFile({
url: miniProgramCodeUrl
})
if (saving) return
setSaving(true)
Taro.showLoading({title: '保存中...'})
if (res.statusCode === 200) {
// 保存到相册
await Taro.saveImageToPhotosAlbum({
filePath: res.tempFilePath
})
const hasPermission = await ensureWriteAlbumPermission()
if (!hasPermission) return
Taro.showToast({
title: '保存成功',
icon: 'success'
})
let filePath = await downloadImageToLocalPath(miniProgramCodeUrl)
try {
await Taro.saveImageToPhotosAlbum({filePath})
} catch (e: any) {
const msg = e?.errMsg || e?.message || ''
// Fallback: some devices/clients may fail to save directly from a temp path.
if (
msg.includes('no such file or directory') &&
(filePath.startsWith('http://tmp/') || filePath.startsWith('wxfile://'))
) {
const saved = (await Taro.saveFile({tempFilePath: filePath})) as unknown as { savedFilePath?: string }
if (saved?.savedFilePath) {
filePath = saved.savedFilePath
}
await Taro.saveImageToPhotosAlbum({filePath})
} else {
throw e
}
}
Taro.showToast({
title: '保存成功',
icon: 'success'
})
} catch (error: any) {
if (error.errMsg?.includes('auth deny')) {
Taro.showModal({
const errMsg = error?.errMsg || error?.message
if (errMsg?.includes('cancel')) {
Taro.showToast({title: '已取消', icon: 'none'})
return
}
if (isAlbumAuthError(errMsg)) {
const modal = await Taro.showModal({
title: '提示',
content: '需要您授权保存图片到相册',
success: (res) => {
if (res.confirm) {
Taro.openSetting()
}
}
confirmText: '去设置'
})
if (modal.confirm) {
await Taro.openSetting()
}
} else {
Taro.showToast({
// Prefer a modal so we can show the real reason (e.g. domain whitelist / network error).
await Taro.showModal({
title: '保存失败',
icon: 'error'
content: errMsg || '保存失败,请稍后重试',
showCancel: false
})
}
} finally {
Taro.hideLoading()
setSaving(false)
}
}
@@ -126,7 +245,7 @@ const DealerQrcode: React.FC = () => {
//
// const inviteText = `🎉 邀请您加入我的团队!
//
// 扫描小程序码或搜索"时里院子市集"小程序,即可享受优质商品和服务!
// 扫描小程序码或搜索"桂乐淘"小程序,即可享受优质商品和服务!
//
// 💰 成为我的团队成员,一起赚取丰厚佣金
// 🎁 新用户专享优惠等你来拿
@@ -162,7 +281,7 @@ const DealerQrcode: React.FC = () => {
// })
// }
if (!dealerUser) {
if (dealerLoading) {
return (
<View className="bg-gray-50 min-h-screen flex items-center justify-center">
<Loading/>
@@ -171,6 +290,33 @@ const DealerQrcode: React.FC = () => {
)
}
if (error) {
return (
<View className="bg-gray-50 min-h-screen flex flex-col items-center justify-center p-6">
<Text className="text-gray-800 font-semibold"></Text>
<Text className="text-gray-500 text-sm mt-2">{error}</Text>
<Button className="mt-6" type="primary" onClick={refresh}></Button>
</View>
)
}
// 未成为分销商时给出明确引导,避免一直停留在“加载中”
if (!dealerUser) {
return (
<View className="bg-gray-50 min-h-screen flex flex-col items-center justify-center p-6">
<Text className="text-gray-800 font-semibold"></Text>
<Text className="text-gray-500 text-sm mt-2 text-center"></Text>
<Button
className="mt-6"
type="primary"
onClick={() => Taro.navigateTo({url: '/dealer/apply/add'})}
>
</Button>
</View>
)
}
return (
<View className="bg-gray-50 min-h-screen">
{/* 头部卡片 */}
@@ -185,9 +331,9 @@ const DealerQrcode: React.FC = () => {
}}></View>
<View className="relative z-10 flex flex-col">
<Text className="text-2xl font-bold mb-2 text-white"></Text>
<Text className="text-2xl font-bold mb-2 text-white"></Text>
<Text className="text-white text-opacity-80">
</Text>
</View>
</View>
@@ -196,7 +342,7 @@ const DealerQrcode: React.FC = () => {
{/* 小程序码展示区 */}
<View className="bg-white rounded-2xl p-6 mb-6 shadow-sm">
<View className="text-center">
{loading ? (
{codeLoading ? (
<View className="w-48 h-48 mx-auto mb-4 flex items-center justify-center bg-gray-50 rounded-xl">
<Loading/>
<Text className="text-gray-500 mt-2">...</Text>
@@ -239,10 +385,10 @@ const DealerQrcode: React.FC = () => {
)}
<View className="text-lg font-semibold text-gray-800 mb-2">
</View>
<View className="text-sm text-gray-500 mb-4">
| |
</View>
@@ -258,34 +404,12 @@ const DealerQrcode: React.FC = () => {
block
icon={<Download/>}
onClick={saveMiniProgramCode}
disabled={!miniProgramCodeUrl || loading}
disabled={!miniProgramCodeUrl || codeLoading || saving}
>
</Button>
</View>
{/*<View className={'my-2 bg-white'}>*/}
{/* <Button*/}
{/* size="large"*/}
{/* block*/}
{/* icon={<Copy/>}*/}
{/* onClick={copyInviteInfo}*/}
{/* disabled={!dealerUser?.userId || loading}*/}
{/* >*/}
{/* 复制邀请信息*/}
{/* </Button>*/}
{/*</View>*/}
{/*<View className={'my-2 bg-white'}>*/}
{/* <Button*/}
{/* size="large"*/}
{/* block*/}
{/* fill="outline"*/}
{/* icon={<Share/>}*/}
{/* onClick={shareMiniProgramCode}*/}
{/* disabled={!dealerUser?.userId || loading}*/}
{/* >*/}
{/* 分享给好友*/}
{/* </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

@@ -1,184 +0,0 @@
import React from 'react'
import { render, fireEvent, waitFor } from '@testing-library/react'
import DealerWithdraw from '../index'
import { useDealerUser } from '@/hooks/useDealerUser'
import * as withdrawAPI from '@/api/shop/shopDealerWithdraw'
// Mock dependencies
jest.mock('@/hooks/useDealerUser')
jest.mock('@/api/shop/shopDealerWithdraw')
jest.mock('@tarojs/taro', () => ({
showToast: jest.fn(),
getStorageSync: jest.fn(() => 123),
}))
const mockUseDealerUser = useDealerUser as jest.MockedFunction<typeof useDealerUser>
const mockAddShopDealerWithdraw = withdrawAPI.addShopDealerWithdraw as jest.MockedFunction<typeof withdrawAPI.addShopDealerWithdraw>
const mockPageShopDealerWithdraw = withdrawAPI.pageShopDealerWithdraw as jest.MockedFunction<typeof withdrawAPI.pageShopDealerWithdraw>
describe('DealerWithdraw', () => {
const mockDealerUser = {
userId: 123,
money: '10000.00',
realName: '测试用户',
mobile: '13800138000'
}
beforeEach(() => {
mockUseDealerUser.mockReturnValue({
dealerUser: mockDealerUser,
loading: false,
error: null,
refresh: jest.fn()
})
mockPageShopDealerWithdraw.mockResolvedValue({
list: [],
count: 0
})
})
afterEach(() => {
jest.clearAllMocks()
})
test('应该正确显示可提现余额', () => {
const { getByText } = render(<DealerWithdraw />)
expect(getByText('10000.00')).toBeInTheDocument()
expect(getByText('可提现余额')).toBeInTheDocument()
})
test('应该验证最低提现金额', async () => {
mockAddShopDealerWithdraw.mockResolvedValue('success')
const { getByPlaceholderText, getByText } = render(<DealerWithdraw />)
// 输入低于最低金额的数值
const amountInput = getByPlaceholderText('请输入提现金额')
fireEvent.change(amountInput, { target: { value: '50' } })
// 选择提现方式
const wechatRadio = getByText('微信钱包')
fireEvent.click(wechatRadio)
// 提交表单
const submitButton = getByText('申请提现')
fireEvent.click(submitButton)
await waitFor(() => {
expect(require('@tarojs/taro').showToast).toHaveBeenCalledWith({
title: '最低提现金额为100元',
icon: 'error'
})
})
})
test('应该验证提现金额不超过可用余额', async () => {
const { getByPlaceholderText, getByText } = render(<DealerWithdraw />)
// 输入超过可用余额的金额
const amountInput = getByPlaceholderText('请输入提现金额')
fireEvent.change(amountInput, { target: { value: '20000' } })
// 选择提现方式
const wechatRadio = getByText('微信钱包')
fireEvent.click(wechatRadio)
// 提交表单
const submitButton = getByText('申请提现')
fireEvent.click(submitButton)
await waitFor(() => {
expect(require('@tarojs/taro').showToast).toHaveBeenCalledWith({
title: '提现金额超过可用余额',
icon: 'error'
})
})
})
test('应该验证支付宝账户信息完整性', async () => {
const { getByPlaceholderText, getByText } = render(<DealerWithdraw />)
// 输入有效金额
const amountInput = getByPlaceholderText('请输入提现金额')
fireEvent.change(amountInput, { target: { value: '1000' } })
// 选择支付宝提现
const alipayRadio = getByText('支付宝')
fireEvent.click(alipayRadio)
// 只填写账号,不填写姓名
const accountInput = getByPlaceholderText('请输入支付宝账号')
fireEvent.change(accountInput, { target: { value: 'test@alipay.com' } })
// 提交表单
const submitButton = getByText('申请提现')
fireEvent.click(submitButton)
await waitFor(() => {
expect(require('@tarojs/taro').showToast).toHaveBeenCalledWith({
title: '请填写完整的支付宝信息',
icon: 'error'
})
})
})
test('应该成功提交微信提现申请', async () => {
mockAddShopDealerWithdraw.mockResolvedValue('success')
const { getByPlaceholderText, getByText } = render(<DealerWithdraw />)
// 输入有效金额
const amountInput = getByPlaceholderText('请输入提现金额')
fireEvent.change(amountInput, { target: { value: '1000' } })
// 选择微信提现
const wechatRadio = getByText('微信钱包')
fireEvent.click(wechatRadio)
// 提交表单
const submitButton = getByText('申请提现')
fireEvent.click(submitButton)
await waitFor(() => {
expect(mockAddShopDealerWithdraw).toHaveBeenCalledWith({
userId: 123,
money: '1000',
payType: 10,
applyStatus: 10,
platform: 'MiniProgram'
})
})
await waitFor(() => {
expect(require('@tarojs/taro').showToast).toHaveBeenCalledWith({
title: '提现申请已提交',
icon: 'success'
})
})
})
test('快捷金额按钮应该正常工作', () => {
const { getByText, getByPlaceholderText } = render(<DealerWithdraw />)
// 点击快捷金额按钮
const quickAmountButton = getByText('500')
fireEvent.click(quickAmountButton)
// 验证金额输入框的值
const amountInput = getByPlaceholderText('请输入提现金额') as HTMLInputElement
expect(amountInput.value).toBe('500')
})
test('全部按钮应该设置为可用余额', () => {
const { getByText, getByPlaceholderText } = render(<DealerWithdraw />)
// 点击全部按钮
const allButton = getByText('全部')
fireEvent.click(allButton)
// 验证金额输入框的值
const amountInput = getByPlaceholderText('请输入提现金额') as HTMLInputElement
expect(amountInput.value).toBe('10000.00')
})
})

View File

@@ -1,13 +1,11 @@
import React, {useState, useRef, useEffect, useCallback} from 'react'
import {View, Text} from '@tarojs/components'
import {
Cell,
Space,
Button,
Form,
Input,
CellGroup,
Radio,
Tabs,
Tag,
Empty,
@@ -18,32 +16,109 @@ import {Wallet} from '@nutui/icons-react-taro'
import {businessGradients} from '@/styles/gradients'
import Taro from '@tarojs/taro'
import {useDealerUser} from '@/hooks/useDealerUser'
import {pageShopDealerWithdraw, addShopDealerWithdraw} from '@/api/shop/shopDealerWithdraw'
import {myUserVerify} from '@/api/system/userVerify'
import {goTo} from '@/utils/navigation'
import {
pageShopDealerWithdraw,
addShopDealerWithdraw,
receiveShopDealerWithdraw,
receiveSuccessShopDealerWithdraw
} from '@/api/shop/shopDealerWithdraw'
import type {ShopDealerWithdraw} from '@/api/shop/shopDealerWithdraw/model'
interface WithdrawRecordWithDetails extends ShopDealerWithdraw {
accountDisplay?: string
// Backend may include these fields for WeChat "confirm receipt" flow after approval.
package_info?: string
packageInfo?: string
package?: string
}
const extractPackageInfo = (result: unknown): string | null => {
if (typeof result === 'string') return result
if (!result || typeof result !== 'object') return null
const r = result as any
return (
r.package_info ??
r.packageInfo ??
r.package ??
null
)
}
const canRequestMerchantTransferConfirm = (): boolean => {
try {
if (typeof (Taro as any).getEnv === 'function' && (Taro as any).ENV_TYPE) {
const env = (Taro as any).getEnv()
if (env !== (Taro as any).ENV_TYPE.WEAPP) return false
}
const api =
(globalThis as any).wx?.requestMerchantTransfer ||
(Taro as any).requestMerchantTransfer
return typeof api === 'function'
} catch {
return false
}
}
const requestMerchantTransferConfirm = (packageInfo: string): Promise<any> => {
if (!canRequestMerchantTransferConfirm()) {
return Promise.reject(new Error('请在微信小程序内完成收款确认'))
}
// Backend may wrap/format base64 with newlines; WeChat API requires a clean string.
const cleanPackageInfo = String(packageInfo).replace(/\s+/g, '')
const api =
(globalThis as any).wx?.requestMerchantTransfer ||
(Taro as any).requestMerchantTransfer
if (typeof api !== 'function') {
return Promise.reject(new Error('当前环境不支持商家转账收款确认(缺少 requestMerchantTransfer'))
}
return new Promise((resolve, reject) => {
api({
// WeChat API uses `package`, backend returns `package_info`.
package: cleanPackageInfo,
mchId: '1737910695',
appId: 'wxad831ba00ad6a026',
success: (res: any) => resolve(res),
fail: (err: any) => reject(err)
})
})
}
// Some backends may return money fields as number; keep internal usage always as string.
const normalizeMoneyString = (money: unknown) => {
if (money === null || money === undefined || money === '') return '0.00'
return typeof money === 'string' ? money : String(money)
}
const DealerWithdraw: React.FC = () => {
const [activeTab, setActiveTab] = useState<string | number>('0')
const [selectedAccount, setSelectedAccount] = useState('')
const [activeTab, setActiveTab] = useState<string>('0')
const [loading, setLoading] = useState<boolean>(false)
const [refreshing, setRefreshing] = useState<boolean>(false)
const [submitting, setSubmitting] = useState<boolean>(false)
const [claimingId, setClaimingId] = useState<number | null>(null)
const [availableAmount, setAvailableAmount] = useState<string>('0.00')
const [withdrawRecords, setWithdrawRecords] = useState<WithdrawRecordWithDetails[]>([])
const formRef = useRef<any>(null)
const {dealerUser} = useDealerUser()
const [verifyStatus, setVerifyStatus] = useState<'unknown' | 'verified' | 'unverified' | 'pending' | 'rejected'>('unknown')
const [verifyStatusText, setVerifyStatusText] = useState<string>('')
// 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()
}
}
@@ -52,7 +127,7 @@ const DealerWithdraw: React.FC = () => {
const fetchBalance = useCallback(async () => {
console.log(dealerUser, 'dealerUser...')
try {
setAvailableAmount(dealerUser?.money || '0.00')
setAvailableAmount(normalizeMoneyString(dealerUser?.money))
} catch (error) {
console.error('获取余额失败:', error)
}
@@ -115,12 +190,63 @@ const DealerWithdraw: React.FC = () => {
}
}, [fetchBalance, fetchWithdrawRecords])
// 判断实名认证状态:提现前必须完成实名认证(已通过)
const fetchVerifyStatus = useCallback(async () => {
// Fast path: some pages store this flag after login.
if (String(Taro.getStorageSync('Certification')) === '1') {
setVerifyStatus('verified')
setVerifyStatusText('已实名认证')
return
}
try {
const r = await myUserVerify({})
if (!r) {
setVerifyStatus('unverified')
setVerifyStatusText('未实名认证')
return
}
const s = Number((r as any).status)
const st = String((r as any).statusText || '')
// Common convention in this project: 0审核中/待审核, 1已通过, 2已驳回
if (s === 1) {
setVerifyStatus('verified')
setVerifyStatusText(st || '已实名认证')
return
}
if (s === 0) {
setVerifyStatus('pending')
setVerifyStatusText(st || '审核中')
return
}
if (s === 2) {
setVerifyStatus('rejected')
setVerifyStatusText(st || '已驳回')
return
}
setVerifyStatus('unverified')
setVerifyStatusText(st || '未实名认证')
} catch (e) {
console.warn('获取实名认证状态失败,将按未认证处理:', e)
setVerifyStatus('unverified')
setVerifyStatusText('未实名认证')
}
}, [])
useEffect(() => {
if (!dealerUser?.userId) return
fetchVerifyStatus().then()
}, [dealerUser?.userId, fetchVerifyStatus])
const getStatusText = (status?: number) => {
switch (status) {
case 40:
return '已到账'
case 20:
return '审核通过'
return '待领取'
case 10:
return '待审核'
case 30:
@@ -135,7 +261,7 @@ const DealerWithdraw: React.FC = () => {
case 40:
return 'success'
case 20:
return 'success'
return 'info'
case 10:
return 'warning'
case 30:
@@ -154,17 +280,17 @@ const DealerWithdraw: React.FC = () => {
return
}
if (!values.accountType) {
if (verifyStatus !== 'verified') {
Taro.showToast({
title: '请选择提现方式',
icon: 'error'
title: '请先完成实名认证',
icon: 'none'
})
return
}
// 验证提现金额
const amount = parseFloat(values.amount)
const available = parseFloat(availableAmount.replace(/,/g, ''))
const amount = parseFloat(String(values.amount))
const available = parseFloat(normalizeMoneyString(availableAmount).replace(/,/g, ''))
if (isNaN(amount) || amount <= 0) {
Taro.showToast({
@@ -175,72 +301,41 @@ const DealerWithdraw: React.FC = () => {
}
if (amount < 100) {
Taro.showToast({
title: '最低提现金额为100元',
icon: 'error'
})
return
// Taro.showToast({
// title: '最低提现金额为100元',
// icon: 'error'
// })
// return
}
if (amount > available) {
Taro.showToast({
title: '提现金额超过可用余额',
icon: 'error'
icon: 'none'
})
return
}
// 验证账户信息
if (values.accountType === 'alipay') {
if (!values.account || !values.accountName) {
Taro.showToast({
title: '请填写完整的支付宝信息',
icon: 'error'
})
return
}
} else if (values.accountType === 'bank') {
if (!values.account || !values.accountName || !values.bankName) {
Taro.showToast({
title: '请填写完整的银行卡信息',
icon: 'error'
})
return
}
}
try {
setSubmitting(true)
const withdrawData: ShopDealerWithdraw = {
userId: dealerUser.userId,
money: values.amount,
payType: values.accountType === 'wechat' ? 10 :
values.accountType === 'alipay' ? 20 : 30,
applyStatus: 10, // 待审核
// Only support WeChat wallet withdrawals.
payType: 10,
platform: 'MiniProgram'
}
// 根据提现方式设置账户信息
if (values.accountType === 'alipay') {
withdrawData.alipayAccount = values.account
withdrawData.alipayName = values.accountName
} else if (values.accountType === 'bank') {
withdrawData.bankCard = values.account
withdrawData.bankAccount = values.accountName
withdrawData.bankName = values.bankName || '银行卡'
}
// Security flow:
// 1) user submits => applyStatus=10 (待审核)
// 2) backend审核通过 => applyStatus=20 (待领取)
// 3) user goes to records to "领取" => applyStatus=40 (已到账)
await addShopDealerWithdraw(withdrawData)
Taro.showToast({
title: '提现申请已提交',
icon: 'success'
})
Taro.showToast({title: '提现申请已提交,等待审核', icon: 'success'})
// 重置表单
formRef.current?.resetFields()
setSelectedAccount('')
// 刷新数据
await handleRefresh()
@@ -259,6 +354,65 @@ const DealerWithdraw: React.FC = () => {
}
}
const handleClaim = async (record: WithdrawRecordWithDetails) => {
if (!record?.id) {
Taro.showToast({title: '记录不存在', icon: 'error'})
return
}
if (record.applyStatus !== 20) {
Taro.showToast({title: '当前状态不可领取', icon: 'none'})
return
}
if (record.payType !== 10) {
Taro.showToast({title: '仅支持微信提现领取', icon: 'none'})
return
}
if (claimingId !== null) return
try {
setClaimingId(record.id)
if (!canRequestMerchantTransferConfirm()) {
throw new Error('当前环境不支持微信收款确认,请在微信小程序内操作')
}
const receiveResult = await receiveShopDealerWithdraw(record.id)
const packageInfo = extractPackageInfo(receiveResult)
if (!packageInfo) {
throw new Error('后台未返回 package_info无法领取请联系管理员')
}
try {
await requestMerchantTransferConfirm(packageInfo)
} catch (e: any) {
const msg = String(e?.errMsg || e?.message || '')
if (/cancel/i.test(msg)) {
Taro.showToast({title: '已取消领取', icon: 'none'})
return
}
throw new Error(msg || '领取失败,请稍后重试')
}
try {
await receiveSuccessShopDealerWithdraw(record.id)
Taro.showToast({title: '领取成功', icon: 'success'})
} catch (e: any) {
console.warn('领取成功,但状态同步失败:', e)
Taro.showToast({title: '已收款,状态更新失败,请稍后刷新', icon: 'none'})
} finally {
await handleRefresh()
}
} catch (e: any) {
console.error('领取失败:', e)
Taro.showToast({title: e?.message || '领取失败', icon: 'error'})
} finally {
setClaimingId(null)
}
}
const quickAmounts = ['100', '300', '500', '1000']
const setQuickAmount = (amount: string) => {
@@ -266,17 +420,37 @@ const DealerWithdraw: React.FC = () => {
}
const setAllAmount = () => {
formRef.current?.setFieldsValue({amount: availableAmount.replace(/,/g, '')})
formRef.current?.setFieldsValue({amount: normalizeMoneyString(availableAmount).replace(/,/g, '')})
}
// 格式化金额
const formatMoney = (money?: string) => {
if (!money) return '0.00'
return parseFloat(money).toFixed(2)
const formatMoney = (money?: unknown) => {
const n = parseFloat(normalizeMoneyString(money).replace(/,/g, ''))
return Number.isFinite(n) ? n.toFixed(2) : '0.00'
}
const goVerify = () => {
goTo('/user/userVerify/index')
}
const renderWithdrawForm = () => (
<View>
{(verifyStatus === 'unverified' || verifyStatus === 'pending' || verifyStatus === 'rejected') && (
<View className="rounded-lg bg-white px-4 py-3 mb-4 mx-4">
<View className="flex items-center justify-between">
<View className="flex flex-col">
<Text className="text-sm text-red-500"></Text>
{verifyStatusText ? (
<Text className="text-xs text-gray-500 mt-1">{verifyStatusText}</Text>
) : null}
</View>
<Text className="text-sm text-blue-600" onClick={goVerify}>
</Text>
</View>
</View>
)}
{/* 余额卡片 */}
<View className="rounded-xl p-6 mb-6 text-white relative overflow-hidden" style={{
background: businessGradients.dealer.header
@@ -303,7 +477,7 @@ const DealerWithdraw: React.FC = () => {
borderTop: '1px solid rgba(255, 255, 255, 0.3)'
}}>
<Text className="text-white text-opacity-80 text-xs">
¥100 |
</Text>
</View>
</View>
@@ -314,18 +488,10 @@ const DealerWithdraw: React.FC = () => {
labelPosition="top"
>
<CellGroup>
<Form.Item name="amount" label="提现金额" required>
<Form.Item name="amount" label="提现金额">
<Input
placeholder="请输入提现金额"
type="number"
onChange={(value) => {
// 实时验证提现金额
const amount = parseFloat(value)
const available = parseFloat(availableAmount.replace(/,/g, ''))
if (!isNaN(amount) && amount > available) {
// 可以在这里添加实时提示,但不阻止输入
}
}}
/>
</Form.Item>
@@ -353,54 +519,14 @@ const DealerWithdraw: React.FC = () => {
</View>
</View>
<Form.Item name="accountType" label="提现方式" required>
<Radio.Group value={selectedAccount} onChange={() => setSelectedAccount}>
<Cell.Group>
<Cell>
<Radio value="wechat"></Radio>
</Cell>
<Cell>
<Radio value="alipay"></Radio>
</Cell>
<Cell>
<Radio value="bank"></Radio>
</Cell>
</Cell.Group>
</Radio.Group>
</Form.Item>
{selectedAccount === 'alipay' && (
<>
<Form.Item name="account" label="支付宝账号" required>
<Input placeholder="请输入支付宝账号"/>
</Form.Item>
<Form.Item name="accountName" label="支付宝姓名" required>
<Input placeholder="请输入支付宝实名姓名"/>
</Form.Item>
</>
)}
{selectedAccount === 'bank' && (
<>
<Form.Item name="bankName" label="开户银行" required>
<Input placeholder="请输入开户银行名称"/>
</Form.Item>
<Form.Item name="account" label="银行卡号" required>
<Input placeholder="请输入银行卡号"/>
</Form.Item>
<Form.Item name="accountName" label="开户姓名" required>
<Input placeholder="请输入银行卡开户姓名"/>
</Form.Item>
</>
)}
{selectedAccount === 'wechat' && (
<View className="px-4 py-2">
<Text className="text-sm text-gray-500">
</Text>
</View>
)}
<View className="px-4 py-2">
<Text className="text-sm text-red-500">
1.
2.
3.
</Text>
</View>
</CellGroup>
<View className="mt-6 px-4">
@@ -409,7 +535,7 @@ const DealerWithdraw: React.FC = () => {
type="primary"
nativeType="submit"
loading={submitting}
disabled={submitting || !selectedAccount}
disabled={submitting || verifyStatus !== 'verified'}
>
{submitting ? '提交中...' : '申请提现'}
</Button>
@@ -433,35 +559,53 @@ const DealerWithdraw: React.FC = () => {
<Text className="text-gray-500 mt-2">...</Text>
</View>
) : withdrawRecords.length > 0 ? (
withdrawRecords.map(record => (
<View key={record.id} className="rounded-lg bg-gray-50 p-4 mb-3 shadow-sm">
<View className="flex justify-between items-start mb-3">
<Space>
<Text className="font-semibold text-gray-800 mb-1">
¥{record.money}
</Text>
<Text className="text-sm text-gray-500">
{record.accountDisplay}
</Text>
</Space>
<Tag type={getStatusColor(record.applyStatus)}>
{getStatusText(record.applyStatus)}
</Tag>
</View>
withdrawRecords.map(record => (
<View key={record.id} className="rounded-lg bg-gray-50 p-4 mb-3 shadow-sm">
<View className="flex justify-between items-start mb-3">
<Space>
<Text className="font-semibold text-gray-800 mb-1">
¥{record.money}
</Text>
{/*<Text className="text-sm text-gray-500">*/}
{/* 提现账户:{record.accountDisplay}*/}
{/*</Text>*/}
</Space>
<Tag background="#999999" type={getStatusColor(record.applyStatus)} plain>
{getStatusText(record.applyStatus)}
</Tag>
</View>
<View className="text-xs text-gray-400">
<Text>{record.createTime}</Text>
{record.auditTime && (
<Text className="block mt-1">
{new Date(record.auditTime).toLocaleString()}
</Text>
{record.applyStatus === 20 && record.payType === 10 && (
<View className="flex mb-5 justify-center">
<Button
size="small"
type="primary"
loading={claimingId === record.id}
disabled={claimingId !== null}
onClick={() => handleClaim(record)}
>
</Button>
</View>
)}
{record.rejectReason && (
<Text className="block mt-1 text-red-500">
{record.rejectReason}
</Text>
)}
</View>
<View className="flex justify-between items-center">
<View className="text-xs text-gray-400">
<Text>{record.createTime}</Text>
{record.auditTime && (
<Text className="block mt-1">
{record.auditTime}
</Text>
)}
{record.rejectReason && (
<Text className="block mt-1 text-red-500">
{record.rejectReason}
</Text>
)}
</View>
</View>
</View>
))
) : (
@@ -485,13 +629,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

@@ -1,5 +1,5 @@
import {useState, useEffect, useCallback} from 'react'
import Taro from '@tarojs/taro'
import Taro, { useDidShow } from '@tarojs/taro'
import {getShopDealerUser} from '@/api/shop/shopDealerUser'
import type {ShopDealerUser} from '@/api/shop/shopDealerUser/model'
@@ -22,17 +22,20 @@ export interface UseDealerUserReturn {
*/
export const useDealerUser = (): UseDealerUserReturn => {
const [dealerUser, setDealerUser] = useState<ShopDealerUser | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const rawUserId = Taro.getStorageSync('UserId')
const userId = Number(rawUserId)
const hasUser = Number.isFinite(userId) && userId > 0
const userId = Taro.getStorageSync('UserId');
// If user is logged in, start in loading state to avoid "click too fast" mis-routing.
const [loading, setLoading] = useState<boolean>(hasUser)
const [error, setError] = useState<string | null>(null)
// 获取经销商用户数据
const fetchDealerData = useCallback(async () => {
if (!userId) {
console.log('🔍 用户未登录,提前返回')
if (!hasUser) {
setDealerUser(null)
setLoading(false)
return
}
@@ -55,7 +58,7 @@ export const useDealerUser = (): UseDealerUserReturn => {
} finally {
setLoading(false)
}
}, [userId])
}, [hasUser, userId])
// 刷新数据
const refresh = useCallback(async () => {
@@ -64,13 +67,31 @@ export const useDealerUser = (): UseDealerUserReturn => {
// 初始化加载数据
useEffect(() => {
if (userId) {
console.log('🔍 调用 fetchDealerData')
if (hasUser) {
fetchDealerData()
} else {
console.log('🔍 用户ID不存在不调用 fetchDealerData')
setDealerUser(null)
setError(null)
setLoading(false)
}
}, [fetchDealerData, userId])
}, [fetchDealerData, hasUser])
// 页面返回/切换到前台时刷新一次,避免“注册成为经销商后,页面不更新”
useDidShow(() => {
fetchDealerData()
})
// 允许业务侧通过事件主动触发刷新(例如:注册成功后触发)
useEffect(() => {
const handler = () => {
fetchDealerData()
}
// 事件名尽量语义化;后续可在注册成功处 trigger
Taro.eventCenter.on('dealerUser:changed', handler)
return () => {
Taro.eventCenter.off('dealerUser:changed', handler)
}
}, [fetchDealerData])
return {
dealerUser,

View File

@@ -1,7 +1,6 @@
import { useState, useEffect, useCallback } from 'react';
import { UserOrderStats } from '@/api/user';
import { getUserOrderStats, UserOrderStats } from '@/api/user';
import Taro from '@tarojs/taro';
import {pageShopOrder} from "@/api/shop/shopOrder";
/**
* 订单统计Hook
@@ -31,20 +30,17 @@ export const useOrderStats = () => {
if(!Taro.getStorageSync('UserId')){
return false;
}
// TODO 读取订单数量
const pending = await pageShopOrder({ page: 1, limit: 1, userId: Taro.getStorageSync('UserId'), statusFilter: 0})
const paid = await pageShopOrder({ page: 1, limit: 1, userId: Taro.getStorageSync('UserId'), statusFilter: 1})
const shipped = await pageShopOrder({ page: 1, limit: 1, userId: Taro.getStorageSync('UserId'), statusFilter: 3})
const completed = await pageShopOrder({ page: 1, limit: 1, userId: Taro.getStorageSync('UserId'), statusFilter: 5})
const refund = await pageShopOrder({ page: 1, limit: 1, userId: Taro.getStorageSync('UserId'), statusFilter: 6})
const total = await pageShopOrder({ page: 1, limit: 1, userId: Taro.getStorageSync('UserId')})
// 聚合接口:一次请求返回各状态数量(后台按用户做了缓存)
const stats = await getUserOrderStats();
setOrderStats({
pending: pending?.count || 0,
paid: paid?.count || 0,
shipped: shipped?.count || 0,
completed: completed?.count || 0,
refund: refund?.count || 0,
total: total?.count || 0
pending: stats?.pending || 0,
paid: stats?.paid || 0,
shipped: stats?.shipped || 0,
completed: stats?.completed || 0,
refund: stats?.refund || 0,
total: stats?.total || 0
})
if (showToast) {

View File

@@ -13,19 +13,30 @@ export interface CountdownTime {
totalMinutes: number; // 总剩余分钟数
}
export interface UsePaymentCountdownParams {
/** 订单创建时间(用于兼容:当 expirationTime 缺失时按 createTime + timeoutHours 计算) */
createTime?: string;
/** 订单过期时间(推荐直接传后端返回的 expirationTime */
expirationTime?: string;
/** 支付状态 */
payStatus?: boolean;
/** 是否实时更新详情页用true列表页用false */
realTime?: boolean;
/** 超时小时数默认24小时仅在 expirationTime 缺失时生效) */
timeoutHours?: number;
}
/**
* 支付倒计时Hook
* @param createTime 订单创建时间
* @param payStatus 支付状态
* @param realTime 是否实时更新详情页用true列表页用false
* @param timeoutHours 超时小时数默认24小时
* 优先使用 expirationTime当 expirationTime 缺失时回退到 createTime + timeoutHours。
*/
export const usePaymentCountdown = (
createTime?: string,
payStatus?: boolean,
realTime: boolean = false,
timeoutHours: number = 24
): CountdownTime => {
export const usePaymentCountdown = ({
createTime,
expirationTime,
payStatus,
realTime = false,
timeoutHours = 24
}: UsePaymentCountdownParams): CountdownTime => {
const [timeLeft, setTimeLeft] = useState<CountdownTime>({
hours: 0,
minutes: 0,
@@ -37,7 +48,7 @@ export const usePaymentCountdown = (
// 计算剩余时间的函数
const calculateTimeLeft = useMemo(() => {
return (): CountdownTime => {
if (!createTime || payStatus) {
if (payStatus || (!expirationTime && !createTime)) {
return {
hours: 0,
minutes: 0,
@@ -47,8 +58,27 @@ export const usePaymentCountdown = (
};
}
const createTimeObj = dayjs(createTime);
const expireTime = createTimeObj.add(timeoutHours, 'hour');
// 优先使用后端过期时间;如果无法解析,再回退到 createTime + timeoutHours
const expireTimeFromExpiration = expirationTime ? dayjs(expirationTime) : null;
const expireTimeFromCreate =
createTime ? dayjs(createTime).add(timeoutHours, 'hour') : null;
const expireTime =
expireTimeFromExpiration?.isValid()
? expireTimeFromExpiration
: expireTimeFromCreate?.isValid()
? expireTimeFromCreate
: null;
if (!expireTime) {
return {
hours: 0,
minutes: 0,
seconds: 0,
isExpired: true,
totalMinutes: 0
};
}
const now = dayjs();
const diff = expireTime.diff(now);
@@ -76,10 +106,10 @@ export const usePaymentCountdown = (
totalMinutes
};
};
}, [createTime, payStatus, timeoutHours]);
}, [createTime, expirationTime, payStatus, timeoutHours]);
useEffect(() => {
if (!createTime || payStatus) {
if (payStatus || (!expirationTime && !createTime)) {
setTimeLeft({
hours: 0,
minutes: 0,
@@ -111,7 +141,7 @@ export const usePaymentCountdown = (
}, 1000);
return () => clearInterval(timer);
}, [createTime, payStatus, realTime, calculateTimeLeft]);
}, [createTime, expirationTime, payStatus, realTime, calculateTimeLeft]);
return timeLeft;
};

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'
import { gradientThemes, GradientTheme, gradientUtils } from '@/styles/gradients'
import { useState, useEffect, useCallback } from 'react'
import { gradientThemes, type GradientTheme, gradientUtils } from '@/styles/gradients'
import Taro from '@tarojs/taro'
export interface UseThemeReturn {
@@ -14,28 +14,42 @@ export interface UseThemeReturn {
* 提供主题切换和状态管理功能
*/
export const useTheme = (): UseThemeReturn => {
const [currentTheme, setCurrentTheme] = useState<GradientTheme>(gradientThemes[0])
const [isAutoTheme, setIsAutoTheme] = useState<boolean>(true)
// 获取当前主题
const getCurrentTheme = (): GradientTheme => {
const savedTheme = Taro.getStorageSync('user_theme') || 'auto'
if (savedTheme === 'auto') {
// 自动主题根据用户ID生成
const userId = Taro.getStorageSync('userId') || '1'
return gradientUtils.getThemeByUserId(userId)
} else {
// 手动选择的主题
return gradientThemes.find(t => t.name === savedTheme) || gradientThemes[0]
const getSavedThemeName = useCallback((): string => {
try {
return Taro.getStorageSync('user_theme') || 'nature'
} catch {
return 'nature'
}
}
}, [])
const getStoredUserId = useCallback((): number => {
try {
const raw = Taro.getStorageSync('UserId') ?? Taro.getStorageSync('userId')
const asNumber = typeof raw === 'number' ? raw : parseInt(String(raw || '1'), 10)
return Number.isFinite(asNumber) ? asNumber : 1
} catch {
return 1
}
}, [])
const resolveTheme = useCallback(
(themeName: string): GradientTheme => {
if (themeName === 'auto') {
return gradientUtils.getThemeByUserId(getStoredUserId())
}
return gradientThemes.find(t => t.name === themeName) || gradientUtils.getThemeByName('nature') || gradientThemes[0]
},
[getStoredUserId]
)
const [isAutoTheme, setIsAutoTheme] = useState<boolean>(() => getSavedThemeName() === 'auto')
const [currentTheme, setCurrentTheme] = useState<GradientTheme>(() => resolveTheme(getSavedThemeName()))
// 初始化主题
useEffect(() => {
const savedTheme = Taro.getStorageSync('user_theme') || 'auto'
const savedTheme = getSavedThemeName()
setIsAutoTheme(savedTheme === 'auto')
setCurrentTheme(getCurrentTheme())
setCurrentTheme(resolveTheme(savedTheme))
}, [])
// 设置主题
@@ -43,7 +57,7 @@ export const useTheme = (): UseThemeReturn => {
try {
Taro.setStorageSync('user_theme', themeName)
setIsAutoTheme(themeName === 'auto')
setCurrentTheme(getCurrentTheme())
setCurrentTheme(resolveTheme(themeName))
} catch (error) {
console.error('保存主题失败:', error)
}
@@ -51,7 +65,7 @@ export const useTheme = (): UseThemeReturn => {
// 刷新主题(用于自动主题模式下用户信息变更时)
const refreshTheme = () => {
setCurrentTheme(getCurrentTheme())
setCurrentTheme(resolveTheme(getSavedThemeName()))
}
return {

View File

@@ -5,6 +5,7 @@ import {
parseQRContent
} from '@/api/passport/qr-login';
import { getShopGiftByCode, updateShopGift, decryptQrData } from "@/api/shop/shopGift";
import { getGltUserTicket, updateGltUserTicket } from '@/api/glt/gltUserTicket';
import { useUser } from "@/hooks/useUser";
import { isValidJSON } from "@/utils/jsonUtils";
import dayjs from 'dayjs';
@@ -29,6 +30,15 @@ export enum ScanType {
UNKNOWN = 'unknown' // 未知类型
}
type VerificationBusinessType = 'gift' | 'ticket';
interface TicketVerificationPayload {
userTicketId: number;
qty?: number;
userId?: number;
t?: number;
}
/**
* 统一扫码结果
*/
@@ -73,7 +83,11 @@ export function useUnifiedQRScan() {
// 1. 检查是否为JSON格式核销二维码
if (isValidJSON(scanResult)) {
const json = JSON.parse(scanResult);
if (json.businessType === 'gift' && json.token && json.data) {
if ((json.businessType === 'gift' || json.businessType === 'ticket') && json.token && json.data) {
return ScanType.VERIFICATION;
}
// Allow plaintext (non-encrypted) ticket verification payload for debugging/internal use.
if (json.userTicketId) {
return ScanType.VERIFICATION;
}
}
@@ -130,35 +144,79 @@ export function useUnifiedQRScan() {
throw new Error('您没有核销权限');
}
let code = '';
let businessType: VerificationBusinessType = 'gift';
let decryptedOrRaw = '';
// 判断是否为加密的JSON格式
if (isValidJSON(scanResult)) {
const json = JSON.parse(scanResult);
if (json.businessType === 'gift' && json.token && json.data) {
// 解密获取核销码
if ((json.businessType === 'gift' || json.businessType === 'ticket') && json.token && json.data) {
businessType = json.businessType;
// 解密获取核销内容
const decryptedData = await decryptQrData({
token: json.token,
encryptedData: json.data
});
if (decryptedData) {
code = decryptedData.toString();
decryptedOrRaw = decryptedData.toString();
} else {
throw new Error('解密失败');
}
} else if (json.userTicketId) {
businessType = 'ticket';
decryptedOrRaw = scanResult.trim();
}
} else {
// 直接使用扫码结果作为核销
code = scanResult.trim();
// 直接使用扫码结果作为核销内容
decryptedOrRaw = scanResult.trim();
}
if (!code) {
if (!decryptedOrRaw) {
throw new Error('无法获取有效的核销码');
}
// 验证核销码
const gift = await getShopGiftByCode(code);
if (businessType === 'ticket') {
if (!isValidJSON(decryptedOrRaw)) {
throw new Error('水票核销信息格式错误');
}
const payload = JSON.parse(decryptedOrRaw) as TicketVerificationPayload;
const userTicketId = Number(payload.userTicketId);
const qty = Math.max(1, Number(payload.qty || 1));
if (!Number.isFinite(userTicketId) || userTicketId <= 0) {
throw new Error('水票核销信息无效');
}
const ticket = await getGltUserTicket(userTicketId);
if (!ticket) throw new Error('水票不存在');
if (ticket.status === 1) throw new Error('该水票已冻结');
const available = Number(ticket.availableQty || 0);
const used = Number(ticket.usedQty || 0);
if (available < qty) throw new Error('水票可用次数不足');
await updateGltUserTicket({
...ticket,
availableQty: available - qty,
usedQty: used + qty
});
return {
type: ScanType.VERIFICATION,
data: {
businessType: 'ticket',
ticket: {
...ticket,
availableQty: available - qty,
usedQty: used + qty
},
qty
},
message: `核销成功(已使用${qty}次)`
};
}
// 验证礼品卡核销码
const gift = await getShopGiftByCode(decryptedOrRaw);
if (!gift) {
throw new Error('核销码无效');
@@ -187,7 +245,7 @@ export function useUnifiedQRScan() {
return {
type: ScanType.VERIFICATION,
data: gift,
data: { businessType: 'gift', gift },
message: '核销成功'
};
}, [isAdmin]);
@@ -213,7 +271,14 @@ export function useUnifiedQRScan() {
}
},
fail: (err) => {
reject(new Error(err.errMsg || '扫码失败'));
const msg = (err as any)?.errMsg || '';
// `scanCode:fail cancel` is a user-driven cancel; don't treat it as an error toast.
if (typeof msg === 'string' && msg.toLowerCase().includes('cancel')) {
cancelRef.current = true;
reject(new Error('取消扫码'));
return;
}
reject(new Error(msg || '扫码失败'));
}
});
});
@@ -265,6 +330,11 @@ export function useUnifiedQRScan() {
return result;
} catch (err: any) {
// User cancelled scanning (e.g. `scanCode:fail cancel`).
if (cancelRef.current) {
reset();
return null;
}
if (!cancelRef.current) {
setState(UnifiedScanState.ERROR);
const errorMessage = err.message || '处理失败';

View File

@@ -3,7 +3,7 @@ import Taro from '@tarojs/taro';
import { User } from '@/api/system/user/model';
import { getUserInfo, updateUserInfo, loginByOpenId } from '@/api/layout';
import { TenantId } from '@/config/app';
import {getStoredInviteParams, handleInviteRelation} from '@/utils/invite';
import { handleInviteRelation } from '@/utils/invite';
// 用户Hook
export const useUser = () => {
@@ -44,15 +44,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 +55,11 @@ export const useUser = () => {
});
return res;
} catch (error) {
console.error('自动登录失败:', error);
const msg = error instanceof Error ? error.message : String(error);
// 新用户首次进入、未绑定手机号等场景属于“预期失败”,避免刷屏报错。
if (msg !== 'autoLoginByOpenId failed') {
console.error('自动登录失败:', error);
}
return null;
}
};
@@ -280,11 +279,14 @@ export const useUser = () => {
// 检查用户是否是管理员
const isAdmin = () => {
return user?.isAdmin === true;
// Some backends use `1/0` (or `1/2`) instead of boolean.
const v: any = (user as any)?.isAdmin;
return v === true || v === 1 || v === '1';
};
const isSuperAdmin = () => {
return user?.isSuperAdmin === true;
const v: any = (user as any)?.isSuperAdmin;
return v === true || v === 1 || v === '1';
};
// 获取用户余额

View File

@@ -1,12 +1,10 @@
import { useState, useEffect, useCallback } from 'react'
import {pageShopUserCoupon} from "@/api/shop/shopUserCoupon";
import {pageShopGift} from "@/api/shop/shopGift";
import {useUser} from "@/hooks/useUser";
import Taro from '@tarojs/taro'
import {getUserInfo} from "@/api/layout";
import { getUserCardStats } from '@/api/user'
interface UserData {
balance: number
balance: string
points: number
coupons: number
giftCards: number
@@ -24,7 +22,7 @@ interface UseUserDataReturn {
loading: boolean
error: string | null
refresh: () => Promise<void>
updateBalance: (newBalance: number) => void
updateBalance: (newBalance: string) => void
updatePoints: (newPoints: number) => void
}
@@ -43,18 +41,14 @@ export const useUserData = (): UseUserDataReturn => {
return;
}
// 并发请求所有数据
const [userDataRes, couponsRes, giftCardsRes] = await Promise.all([
getUserInfo(),
pageShopUserCoupon({ page: 1, limit: 1, userId: Taro.getStorageSync('UserId'), status: 0}),
pageShopGift({ page: 1, limit: 1, userId: Taro.getStorageSync('UserId'), status: 0})
])
// 聚合接口:一次请求返回余额/积分/优惠券/礼品卡统计(后端可按用户做缓存)
const stats = await getUserCardStats()
const newData: UserData = {
balance: userDataRes?.balance || 0.00,
points: userDataRes?.points || 0,
coupons: couponsRes?.count || 0,
giftCards: giftCardsRes?.count || 0,
balance: stats?.balance || '0.00',
points: stats?.points || 0,
coupons: stats?.coupons || 0,
giftCards: stats?.giftCards || 0,
orders: {
pending: 0,
paid: 0,
@@ -78,7 +72,7 @@ export const useUserData = (): UseUserDataReturn => {
}, [fetchUserData])
// 更新余额(本地更新,避免频繁请求)
const updateBalance = useCallback((newBalance: number) => {
const updateBalance = useCallback((newBalance: string) => {
setData(prev => prev ? { ...prev, balance: newBalance } : null)
}, [])

View File

@@ -10,10 +10,11 @@ import {
Divider,
ConfigProvider
} from '@nutui/nutui-react-taro';
import {ArrowLeft, Del} from '@nutui/icons-react-taro';
import {Del} from '@nutui/icons-react-taro';
import {View} from '@tarojs/components';
import {CartItem, useCart} from "@/hooks/useCart";
import './cart.scss';
import { ensureLoggedIn } from '@/utils/auth'
function Cart() {
const [statusBarHeight, setStatusBarHeight] = useState<number>(0);
@@ -41,7 +42,7 @@ function Cart() {
useShareAppMessage(() => {
return {
title: '购物车 - 时里院子市集',
title: '购物车 - 桂乐淘',
success: function () {
console.log('分享成功');
},
@@ -150,6 +151,9 @@ function Cart() {
// 将选中的商品信息存储到本地,供结算页面使用
Taro.setStorageSync('checkout_items', JSON.stringify(selectedCartItems));
// 未登录则引导去注册/登录;登录后回到购物车结算页
if (!ensureLoggedIn('/shop/orderConfirmCart/index')) return
// 跳转到购物车结算页面
Taro.navigateTo({
url: '/shop/orderConfirmCart/index'
@@ -171,7 +175,6 @@ function Cart() {
<NavBar
fixed={true}
style={{marginTop: `${statusBarHeight}px`}}
left={<ArrowLeft onClick={() => Taro.navigateBack()}/>}
right={
cartItems.length > 0 && (
<Button
@@ -227,7 +230,6 @@ function Cart() {
<NavBar
fixed={true}
style={{marginTop: `${statusBarHeight}px`}}
left={<ArrowLeft onClick={() => Taro.navigateBack()}/>}
right={
cartItems.length > 0 && (
<Button

View File

@@ -49,7 +49,7 @@ function Category() {
useShareAppMessage(() => {
return {
title: `${nav?.categoryName}_时里院子市集`,
title: `${nav?.categoryName}_桂乐淘`,
path: `/shop/category/index?id=${categoryId}`,
success: function () {
console.log('分享成功');

View File

@@ -6,52 +6,81 @@ import {Image} from '@nutui/nutui-react-taro'
import {getCmsAdByCode} from "@/api/cms/cmsAd";
import navTo from "@/utils/common";
import {pageCmsArticle} from "@/api/cms/cmsArticle";
import {CmsArticle} from "@/api/cms/cmsArticle/model";
import Taro from '@tarojs/taro'
type AdImage = {
url?: string
path?: string
title?: string
// Compatible keys (some backends use different fields)
src?: string
imageUrl?: string
}
function normalizeAdImages(ad?: CmsAd): AdImage[] {
const list = ad?.imageList
if (Array.isArray(list) && list.length) return list as AdImage[]
// Some APIs only return `images` as a JSON string.
const raw = ad?.images
if (!raw) return []
try {
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? (parsed as AdImage[]) : []
} catch {
return []
}
}
function toNumberPx(input: unknown, fallback: number) {
const n = typeof input === 'number' ? input : Number.parseInt(String(input ?? ''), 10)
return Number.isFinite(n) ? n : fallback
}
const MyPage = () => {
const [carouselData, setCarouselData] = useState<CmsAd>()
const [hotToday, setHotToday] = useState<CmsAd>()
const [item, setItem] = useState<CmsArticle>()
const [loading, setLoading] = useState(true)
// const [disableSwiper, setDisableSwiper] = useState(false)
const CACHE_KEY = 'home_banner_mp-ad'
// 用于记录触摸开始位置
// const touchStartRef = useRef({x: 0, y: 0})
// 加载数据
const loadData = async () => {
const loadData = async (opts?: {silent?: boolean}) => {
if (!opts?.silent) setLoading(true)
try {
setLoading(true)
// 轮播图
const flash = await getCmsAdByCode('flash')
// 今日热卖
const hotToday = await getCmsAdByCode('hot_today')
// 时里动态
const news = await pageCmsArticle({limit:1,recommend:1})
// 赋值
if(flash){
setCarouselData(flash)
}
if(hotToday){
setHotToday(hotToday)
}
if(news && news.list.length > 0){
setItem(news.list[0])
}
// 只阻塞 banner 自己的数据;其他数据预热不应影响首屏展示速度
const flash = await getCmsAdByCode('mp-ad')
setCarouselData(flash)
void Taro.setStorage({ key: CACHE_KEY, data: flash }).catch(() => {})
} catch (error) {
console.error('Banner数据加载失败:', error)
} finally {
setLoading(false)
if (!opts?.silent) setLoading(false)
}
// 后台预热(不阻塞 banner 渲染)
void getCmsAdByCode('hot_today').catch(() => {})
void pageCmsArticle({ limit: 1, recommend: 1 }).catch(() => {})
}
useEffect(() => {
loadData()
const cached = Taro.getStorageSync(CACHE_KEY) as CmsAd | undefined
// 有缓存则先渲染缓存,避免首屏等待;再静默刷新
if (cached && normalizeAdImages(cached).length) {
setCarouselData(cached)
setLoading(false)
void loadData({ silent: true })
return
}
void loadData()
}, [])
// 轮播图高度默认300px
const carouselHeight = carouselData?.height || 300;
const carouselHeight = toNumberPx(carouselData?.height, 300)
const carouselImages = normalizeAdImages(carouselData)
// 骨架屏组件
const BannerSkeleton = () => (
@@ -100,94 +129,43 @@ const MyPage = () => {
}
return (
<View className="flex p-2 justify-between" style={{height: `${carouselHeight}px`}}>
{/* 左侧轮播图区域 */}
<View
style={{width: '50%', height: '100%'}}
className="banner-swiper-container"
>
<Swiper
defaultValue={0}
height={carouselHeight}
indicator
autoPlay
duration={3000}
style={{
height: `${carouselHeight}px`,
touchAction: 'pan-y' // 关键修改:允许垂直滑动
}}
disableTouch={false}
direction="horizontal"
className="custom-swiper"
>
{carouselData && carouselData?.imageList?.map((img, index) => (
<Swiper.Item key={index} style={{ touchAction: 'pan-x pan-y' }}>
<Image
width="100%"
height="100%"
src={img.url}
mode={'scaleToFill'}
onClick={() => navTo(`${img.path}`)}
lazyLoad={false}
style={{
height: `${carouselHeight}px`,
borderRadius: '4px',
touchAction: 'manipulation' // 关键修改:优化触摸操作
}}
/>
</Swiper.Item>
))}
</Swiper>
</View>
{/* 右侧上下图片区域 - 从API获取数据 */}
<View className="flex flex-col" style={{width: '50%', height: '100%'}}>
{/* 上层图片 - 使用今日热卖素材 */}
<View className={'ml-2 bg-white rounded-lg shadow-sm'}>
<View className={'px-3 my-2 font-bold text-sm'}></View>
<View className={'px-3 flex'} style={{
height: '110px'
}}>
{
hotToday?.imageList?.map(item => (
<View className={'item flex flex-col mr-1'} key={item.url}>
<Image
width={70}
height={70}
src={item.url}
mode={'scaleToFill'}
lazyLoad={false}
style={{
borderRadius: '4px'
}}
onClick={() => navTo('/shop/category/index?id=4424')}
/>
<View className={'text-xs py-2 text-orange-600 whitespace-nowrap'}>{item.title || '到手价¥9.9'}</View>
</View>
))
}
</View>
</View>
{/* 下层图片 - 使用社区拼团素材 */}
<View className={'ml-2 bg-white rounded-lg mt-3 shadow-sm'}>
<View className={'px-3 my-2 font-bold text-sm'}></View>
<View className={'rounded-lg px-3 pb-3'}>
<Swiper
defaultValue={0}
height={carouselHeight}
indicator
autoPlay
duration={3000}
style={{
height: `${carouselHeight}px`,
touchAction: 'pan-y' // 关键修改:允许垂直滑动
}}
disableTouch={false}
direction="horizontal"
className="custom-swiper"
>
{carouselImages.map((img, index) => {
const src = img.url || img.src || img.imageUrl
if (!src) return null
return (
<Swiper.Item key={index} style={{ touchAction: 'pan-x pan-y' }}>
<Image
width={'100%'}
height={94}
src={item?.image}
width="100%"
height="100%"
src={src}
mode={'scaleToFill'}
lazyLoad={false}
onClick={() => (img.path ? navTo(`${img.path}`) : undefined)}
// 首张图优先加载,其余按需懒加载,避免并发图片请求拖慢首屏可见
lazyLoad={index !== 0}
style={{
borderRadius: '4px'
height: `${carouselHeight}px`,
borderRadius: '4px',
touchAction: 'manipulation' // 关键修改:优化触摸操作
}}
onClick={() => navTo('cms/detail/index?id=' + item?.articleId)}
/>
</View>
</View>
</View>
</View>
</Swiper.Item>
)
})}
</Swiper>
)
}

View File

@@ -14,10 +14,3 @@
position: absolute;
z-index: 0;
}
/* 吸顶状态下的样式 */
.nutui-sticky--fixed {
.header-bg {
height: 100%;
}
}

View File

@@ -1,28 +1,151 @@
import {useEffect, useState} from "react";
import Taro from '@tarojs/taro';
import {Button, Space, Sticky} from '@nutui/nutui-react-taro'
import {TriangleDown} from '@nutui/icons-react-taro'
import {Avatar, NavBar} from '@nutui/nutui-react-taro'
import {Button, Popup, Cell, CellGroup} from '@nutui/nutui-react-taro'
// import {TriangleDown} from '@nutui/icons-react-taro'
import { NavBar} from '@nutui/nutui-react-taro'
import {getUserInfo, getWxOpenId} from "@/api/layout";
import {TenantId} from "@/config/app";
import {TenantId, TenantName} from "@/config/app";
import {getOrganization} from "@/api/system/organization";
import {myUserVerify} from "@/api/system/userVerify";
import { useShopInfo } from '@/hooks/useShopInfo';
// import { useShopInfo } from '@/hooks/useShopInfo';
import {handleInviteRelation, getStoredInviteParams} from "@/utils/invite";
import {View,Text} from '@tarojs/components'
import MySearch from "./MySearch";
import './Header.scss';
import {User} from "@/api/system/user/model";
import {getShopStore, listShopStore} from "@/api/shop/shopStore";
import type {ShopStore} from "@/api/shop/shopStore/model";
import {getSelectedStoreFromStorage, saveSelectedStoreToStorage} from "@/utils/storeSelection";
const Header = (_: any) => {
// 使用新的useShopInfo Hook
const {
getWebsiteName,
getWebsiteLogo
} = useShopInfo();
// const {
// getWebsiteLogo
// } = useShopInfo();
const [IsLogin, setIsLogin] = useState<boolean>(true)
const [statusBarHeight, setStatusBarHeight] = useState<number>()
const [stickyStatus, setStickyStatus] = useState<boolean>(false)
const [userInfo] = useState<User>()
// 门店选择:用于首页展示“最近门店”,并在下单时写入订单 storeId
const [storePopupVisible, setStorePopupVisible] = useState(false)
const [stores, setStores] = useState<ShopStore[]>([])
const [selectedStore, setSelectedStore] = useState<ShopStore | null>(getSelectedStoreFromStorage())
const [userLocation, setUserLocation] = useState<{lng: number; lat: number} | null>(null)
const getTenantName = () => {
return userInfo?.tenantName || TenantName
}
const parseStoreCoords = (s: ShopStore): {lng: number; lat: number} | null => {
const raw = (s.lngAndLat || s.location || '').trim()
if (!raw) return null
const parts = raw.split(/[,\s]+/).filter(Boolean)
if (parts.length < 2) return null
const a = parseFloat(parts[0])
const b = parseFloat(parts[1])
if (Number.isNaN(a) || Number.isNaN(b)) return null
// 常见格式是 "lng,lat";这里做一个简单兜底(经度范围更宽)
const looksLikeLngLat = Math.abs(a) <= 180 && Math.abs(b) <= 90
const looksLikeLatLng = Math.abs(a) <= 90 && Math.abs(b) <= 180
if (looksLikeLngLat) return {lng: a, lat: b}
if (looksLikeLatLng) return {lng: b, lat: a}
return null
}
const distanceMeters = (a: {lng: number; lat: number}, b: {lng: number; lat: number}) => {
const toRad = (x: number) => (x * Math.PI) / 180
const R = 6371000 // meters
const dLat = toRad(b.lat - a.lat)
const dLng = toRad(b.lng - a.lng)
const lat1 = toRad(a.lat)
const lat2 = toRad(b.lat)
const sin1 = Math.sin(dLat / 2)
const sin2 = Math.sin(dLng / 2)
const h = sin1 * sin1 + Math.cos(lat1) * Math.cos(lat2) * sin2 * sin2
return 2 * R * Math.asin(Math.min(1, Math.sqrt(h)))
}
const formatDistance = (meters?: number) => {
if (meters === undefined || Number.isNaN(meters)) return ''
if (meters < 1000) return `${Math.round(meters)}m`
return `${(meters / 1000).toFixed(1)}km`
}
const getStoreDistance = (s: ShopStore) => {
if (!userLocation) return undefined
const coords = parseStoreCoords(s)
if (!coords) return undefined
return distanceMeters(userLocation, coords)
}
const initStoreSelection = async () => {
// 先读取本地已选门店,避免页面首屏抖动
const stored = getSelectedStoreFromStorage()
if (stored?.id) {
setSelectedStore(stored)
}
// 拉取门店列表(失败时允许用户手动重试/继续使用本地门店)
let list: ShopStore[] = []
try {
list = await listShopStore()
} catch (e) {
console.error('获取门店列表失败:', e)
list = []
}
const usable = (list || []).filter(s => s?.isDelete !== 1)
setStores(usable)
// 尝试获取定位,用于计算最近门店
let loc: {lng: number; lat: number} | null = null
try {
const r = await Taro.getLocation({type: 'gcj02'})
loc = {lng: r.longitude, lat: r.latitude}
} catch (e) {
// 不强制定位授权;无定位时仍允许用户手动选择
console.warn('获取定位失败,将不显示最近门店距离:', e)
}
setUserLocation(loc)
const ensureStoreDetail = async (s: ShopStore) => {
if (!s?.id) return s
// 如果后端已经返回默认仓库等字段,就不额外请求
if (s.warehouseId) return s
try {
const full = await getShopStore(s.id)
return full || s
} catch (_e) {
return s
}
}
// 若用户没有选过门店,则自动选择最近门店(或第一个)
const alreadySelected = stored?.id
if (alreadySelected || usable.length === 0) return
let autoPick: ShopStore | undefined
if (loc) {
autoPick = [...usable]
.map(s => {
const coords = parseStoreCoords(s)
const d = coords ? distanceMeters(loc, coords) : undefined
return {s, d}
})
.sort((x, y) => (x.d ?? Number.POSITIVE_INFINITY) - (y.d ?? Number.POSITIVE_INFINITY))[0]?.s
} else {
autoPick = usable[0]
}
if (autoPick?.id) {
const full = await ensureStoreDetail(autoPick)
setSelectedStore(full)
saveSelectedStoreToStorage(full)
}
}
const reload = async () => {
Taro.getSystemInfo({
@@ -109,7 +232,7 @@ const Header = (_: any) => {
success: function () {
if (code) {
Taro.request({
url: 'https://server.websoft.top/api/wx-login/loginByMpWxPhone',
url: 'https://glt-server.websoft.top/api/wx-login/loginByMpWxPhone',
method: 'POST',
data: {
code,
@@ -167,12 +290,6 @@ const Header = (_: any) => {
})
}
// 处理粘性布局状态变化
const onStickyChange = (isSticky: boolean) => {
setStickyStatus(isSticky)
console.log('Header 粘性状态:', isSticky ? '已固定' : '取消固定')
}
// 获取小程序系统信息
// const getSystemInfo = () => {
// const systemInfo = Taro.getSystemInfoSync()
@@ -182,61 +299,119 @@ const Header = (_: any) => {
useEffect(() => {
reload().then()
initStoreSelection().then()
}, [])
return (
<>
<Sticky
threshold={0}
onChange={onStickyChange}
<View
className={'header-bg'}
style={{
zIndex: 1000,
backgroundColor: stickyStatus ? '#03605c' : 'transparent',
transition: 'background-color 0.3s ease',
height: '180px',
paddingBottom: '12px',
}}
>
<View className={'header-bg'} style={{
height: !stickyStatus ? '180px' : `${(statusBarHeight || 0) + 44}px`,
paddingBottom: !stickyStatus ? '12px' : '0px'
}}>
{/* 只在非吸顶状态下显示搜索框 */}
{!stickyStatus && <MySearch statusBarHeight={statusBarHeight} />}
<MySearch statusBarHeight={statusBarHeight} />
</View>
<NavBar
style={{
marginTop: `${statusBarHeight}px`,
marginBottom: '0px',
backgroundColor: 'transparent'
}}
onBackClick={() => {
}}
// left={
// <View
// style={{display: 'flex', alignItems: 'center', gap: '8px'}}
// onClick={() => setStorePopupVisible(true)}
// >
// <Avatar
// size="22"
// src={getWebsiteLogo()}
// />
// <Text className={'text-white'}>
// {selectedStore?.name || '请选择门店'}
// </Text>
// <TriangleDown className={'text-white'} size={9}/>
// </View>
// }
right={
!IsLogin ? (
<Button
size="small"
fill="none"
style={{color: '#ffffff'}}
open-type="getPhoneNumber"
onGetPhoneNumber={handleGetPhoneNumber}
>
</Button>
) : null
}
>
<Text className={'text-white'}>{getTenantName()}</Text>
</NavBar>
<Popup
visible={storePopupVisible}
position="bottom"
style={{height: '70vh'}}
onClose={() => setStorePopupVisible(false)}
>
<View className="p-4">
<View className="flex justify-between items-center mb-3">
<Text className="text-base font-medium"></Text>
<Text
className="text-sm text-gray-500"
onClick={() => setStorePopupVisible(false)}
>
</Text>
</View>
<View className="text-xs text-gray-500 mb-2">
{userLocation ? '已获取定位,按距离排序' : '未获取定位,可手动选择门店'}
</View>
<CellGroup>
{[...stores]
.sort((a, b) => (getStoreDistance(a) ?? Number.POSITIVE_INFINITY) - (getStoreDistance(b) ?? Number.POSITIVE_INFINITY))
.map((s) => {
const d = getStoreDistance(s)
const isActive = !!selectedStore?.id && selectedStore.id === s.id
return (
<Cell
key={s.id}
title={
<View className="flex items-center justify-between">
<Text className={isActive ? 'text-green-600' : ''}>{s.name || `门店${s.id}`}</Text>
{d !== undefined && <Text className="text-xs text-gray-500">{formatDistance(d)}</Text>}
</View>
}
description={s.address || ''}
onClick={async () => {
let storeToSave = s
if (s?.id) {
try {
const full = await getShopStore(s.id)
if (full) storeToSave = full
} catch (_e) {
// keep base item
}
}
setSelectedStore(storeToSave)
saveSelectedStoreToStorage(storeToSave)
setStorePopupVisible(false)
Taro.showToast({title: '门店已切换', icon: 'success'})
}}
/>
)
})}
</CellGroup>
</View>
<NavBar
style={{
marginTop: `${statusBarHeight}px`,
marginBottom: '0px',
backgroundColor: 'transparent'
}}
onBackClick={() => {
}}
left={
!IsLogin ? (
<View style={{display: 'flex', alignItems: 'center'}}>
<Button style={{color: '#ffffff'}} open-type="getPhoneNumber" onGetPhoneNumber={handleGetPhoneNumber}>
<Space>
<Avatar
size="22"
src={getWebsiteLogo()}
/>
<Text style={{color: '#ffffff'}}>{getWebsiteName()}</Text>
<TriangleDown size={9} className={'text-white'}/>
</Space>
</Button>
</View>
) : (
<View style={{display: 'flex', alignItems: 'center', gap: '8px'}}>
<Avatar
size="22"
src={getWebsiteLogo()}
/>
<Text className={'text-white'}>{getWebsiteName()}</Text>
<TriangleDown className={'text-white'} size={9}/>
</View>
)}>
{/*<QRLoginButton />*/}
</NavBar>
</Sticky>
</Popup>
</>
)
}

View File

@@ -49,7 +49,7 @@ const Login = (props: LoginProps) => {
success: function () {
if (code) {
Taro.request({
url: 'https://server.websoft.top/api/wx-login/loginByMpWxPhone',
url: 'https://glt-server.websoft.top/api/wx-login/loginByMpWxPhone',
method: 'POST',
data: {
code,

View File

@@ -30,7 +30,7 @@ function MySearch(props: any) {
return (
<div className={'z-50 left-0 w-full'}>
<div className={'z-50 left-0 w-full hidden'}>
<div className={'px-2'}>
<div
style={{

View File

@@ -4,6 +4,376 @@ page {
background: linear-gradient(to bottom, #e9fff2, #ffffff);
}
.home-page {
padding: 24rpx 24rpx calc(32rpx + env(safe-area-inset-bottom));
}
.home-hero {
position: relative;
overflow: hidden;
border-radius: 28rpx;
background: linear-gradient(180deg, #bfefff 0%, #eafaff 40%, #fff7ec 100%);
box-shadow: 0 18rpx 36rpx rgba(0, 0, 0, 0.06);
}
.home-hero__bg {
position: absolute;
inset: 0;
background:
radial-gradient(360rpx 240rpx at 18% 16%, rgba(255, 255, 255, 0.8), rgba(255, 255, 255, 0)),
radial-gradient(320rpx 220rpx at 84% 18%, rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0)),
linear-gradient(180deg, rgba(0, 207, 255, 0.12), rgba(0, 0, 0, 0));
pointer-events: none;
}
.home-hero__content {
position: relative;
display: flex;
justify-content: space-between;
gap: 18rpx;
padding: 26rpx 24rpx 28rpx;
min-height: 320rpx;
}
.home-hero__left {
flex: 1;
min-width: 0;
}
.home-hero__topRow {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 16rpx;
}
.home-hero__brand {
flex: none;
display: inline-flex;
align-items: center;
padding: 8rpx 14rpx;
border-radius: 999rpx;
background: rgba(255, 214, 84, 0.92);
color: #2a2a2a;
font-weight: 700;
font-size: 24rpx;
line-height: 1;
}
.home-hero__brandText {
line-height: 1;
}
.home-hero__tag {
flex: none;
display: inline-flex;
align-items: center;
padding: 10rpx 18rpx;
border-radius: 18rpx;
background: linear-gradient(90deg, #22d64a 0%, #7df4b0 100%);
box-shadow: 0 14rpx 24rpx rgba(36, 202, 148, 0.22);
}
.home-hero__tagText {
font-size: 56rpx;
font-weight: 900;
color: #ffffff;
line-height: 1;
}
.home-hero__date {
flex: 1;
min-width: 0;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 10rpx 14rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.75);
}
.home-hero__dateText {
font-size: 26rpx;
font-weight: 700;
color: #1a1a1a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.home-hero__headline {
margin-top: 22rpx;
}
.home-hero__headlineText {
display: block;
font-size: 42rpx;
font-weight: 900;
color: #0b0b0b;
letter-spacing: 0.5px;
line-height: 1.15;
}
.home-hero__right {
width: 200rpx;
display: flex;
justify-content: flex-end;
align-items: flex-end;
}
.home-hero__bottle {
position: relative;
width: 190rpx;
height: 250rpx;
border-radius: 28rpx;
background:
radial-gradient(240rpx 360rpx at 60% 30%, rgba(255, 255, 255, 0.95), rgba(255, 255, 255, 0.18)),
linear-gradient(180deg, rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0.1));
border: 2rpx solid rgba(255, 255, 255, 0.65);
box-shadow: 0 18rpx 36rpx rgba(0, 0, 0, 0.12);
}
.home-hero__bottleCap {
position: absolute;
top: 14rpx;
left: 50%;
transform: translateX(-50%);
width: 88rpx;
height: 26rpx;
border-radius: 999rpx;
background: linear-gradient(180deg, #d7e6f3, #b0cadd);
box-shadow: 0 10rpx 20rpx rgba(0, 0, 0, 0.12);
}
.home-hero__bottleLabel {
position: absolute;
left: 18rpx;
right: 18rpx;
bottom: 30rpx;
padding: 12rpx 12rpx;
border-radius: 18rpx;
background: linear-gradient(90deg, rgba(0, 150, 255, 0.18), rgba(0, 255, 210, 0.18));
border: 2rpx solid rgba(255, 255, 255, 0.45);
}
.home-hero__bottleLabelText {
font-size: 30rpx;
font-weight: 800;
color: rgba(0, 80, 140, 0.95);
text-align: center;
display: block;
}
.ticket-card {
margin-top: 18rpx;
border-radius: 22rpx;
overflow: hidden;
background: #ffffff;
box-shadow: 0 18rpx 36rpx rgba(0, 0, 0, 0.06);
}
.ticket-card__head {
display: flex;
justify-content: space-between;
align-items: center;
padding: 18rpx 20rpx;
background: linear-gradient(90deg, #22d64a 0%, #2fa560 100%);
}
.ticket-card__title {
color: #ffffff;
font-weight: 800;
font-size: 28rpx;
}
.ticket-card__count {
color: rgba(255, 255, 255, 0.92);
font-size: 28rpx;
}
.ticket-card__countNum {
color: #ffffff;
font-weight: 900;
}
.ticket-card__body {
padding: 20rpx 10rpx 22rpx;
}
.shortcut-grid {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12rpx;
}
.shortcut-grid__item {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 12rpx;
}
.shortcut-grid__icon {
width: 88rpx;
height: 88rpx;
border-radius: 18rpx;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
color: #20c26a;
border: 2rpx solid rgba(32, 194, 106, 0.35);
}
.shortcut-grid__text {
font-size: 24rpx;
color: #333333;
}
.home-tabs {
margin-top: 18rpx;
}
.home-tabs__inner {
display: flex;
gap: 18rpx;
padding: 0 4rpx;
}
.home-tabs__item {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 10rpx 18rpx;
border-radius: 999rpx;
background: transparent;
}
.home-tabs__item--active {
background: rgba(32, 194, 106, 0.16);
}
.home-tabs__itemText {
font-size: 28rpx;
color: #2a2a2a;
white-space: nowrap;
}
.home-tabs__item--active .home-tabs__itemText {
color: #16b65a;
font-weight: 800;
}
.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);
@@ -22,20 +392,20 @@ page {
/* 轮播图容器样式,确保支持两种滑动操作 */
.banner-swiper-container {
touch-action: pan-y !important; /* 允许垂直滑动 */
.nut-swiper {
touch-action: pan-y !important; /* 允许垂直滑动 */
.nut-swiper-item {
touch-action: pan-x pan-y !important; /* 允许水平和垂直滑动 */
image {
pointer-events: auto; /* 确保图片点击事件正常 */
touch-action: manipulation; /* 优化触摸操作 */
}
}
}
/* 为Swiper容器添加特殊处理 */
.nut-swiper--horizontal {
touch-action: pan-y !important; /* 允许垂直滑动 */
@@ -52,7 +422,7 @@ page {
/* 为Swiper添加更精确的触摸控制 */
.nut-swiper {
touch-action: pan-y !important;
.nut-swiper-inner {
touch-action: pan-x pan-y !important;
}
@@ -61,7 +431,7 @@ page {
/* 自定义Swiper样式 */
.custom-swiper {
touch-action: pan-y !important;
.nut-swiper-item {
touch-action: pan-x pan-y !important;
}
@@ -73,4 +443,4 @@ page {
.nut-swiper,
.nut-swiper-item {
-webkit-overflow-scrolling: touch; /* iOS平台启用硬件加速滚动 */
}
}

View File

@@ -1,26 +1,32 @@
import Header from './Header';
import BestSellers from './BestSellers';
import Taro from '@tarojs/taro';
import {useShareAppMessage} from "@tarojs/taro"
import {useEffect, useState} from "react";
import {getShopInfo} from "@/api/layout";
import Menu from "./Menu";
import Banner from "./Banner";
import {checkAndHandleInviteRelation, hasPendingInvite} from "@/utils/invite";
// import Header from './Header'
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, ArrowRight } from '@nutui/icons-react-taro'
import { getShopInfo } from '@/api/layout'
import { checkAndHandleInviteRelation, hasPendingInvite } from '@/utils/invite'
import { pageShopGoods } from '@/api/shop/shopGoods'
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 [stickyStatus, setStickyStatus] = useState<boolean>(false)
// Tabs粘性状态
const [_, setTabsStickyStatus] = useState<boolean>(false)
const [activeTabKey, setActiveTabKey] = useState('recommend')
const [goodsList, setGoodsList] = useState<ShopGoods[]>([])
const [ticketTotal, setTicketTotal] = useState(0)
useShareAppMessage(() => {
// 获取当前用户ID用于生成邀请链接
const userId = Taro.getStorageSync('UserId');
const user = Taro.getStorageSync('User') || {};
const nickname =
(user && (user.nickname || user.realName || user.username)) || '';
return {
title: '🏠 首页 🏠',
title: (nickname || '') + '超值推荐',
path: userId ? `/pages/index/index?inviter=${userId}&source=share&t=${Date.now()}` : `/pages/index/index`,
success: function () {
console.log('首页分享成功');
@@ -85,14 +91,30 @@ function Home() {
// }
// 处理Tabs粘性状态变化
const handleTabsStickyChange = (isSticky: boolean) => {
setTabsStickyStatus(isSticky)
}
// const handleTabsStickyChange = (isSticky: boolean) => {}
const reload = () => {
const token = Taro.getStorageSync('access_token')
const userIdRaw = Taro.getStorageSync('UserId')
const userId = Number(userIdRaw)
const hasUserId = Number.isFinite(userId) && userId > 0
if (!token && !hasUserId) {
setTicketTotal(0)
return
}
getMyGltUserTicketTotal(hasUserId ? userId : undefined)
.then((total) => setTicketTotal(typeof total === 'number' ? total : 0))
.catch((err) => {
console.error('首页水票总数加载失败:', err)
setTicketTotal(0)
})
};
// 回到首页/首次进入时都刷新一次(避免依赖 scope.userInfo 导致不触发 reload
useDidShow(() => {
reload()
})
useEffect(() => {
// 获取站点信息
getShopInfo().then(() => {
@@ -135,7 +157,6 @@ function Home() {
if (res.authSetting['scope.userInfo']) {
// 用户已经授权过,可以直接获取用户信息
console.log('用户已经授权过,可以直接获取用户信息')
reload();
} else {
// 用户未授权,需要弹出授权窗口
console.log('用户未授权,需要弹出授权窗口')
@@ -147,21 +168,210 @@ function Home() {
Taro.getUserInfo({
success: (res) => {
const avatar = res.userInfo.avatarUrl;
// Keep WeChat display name in storage so share title can use it.
console.log(avatar, 'avatarUrl')
}
});
}, []);
const tabs = useMemo<
Array<{ key: string; title: string; params: Partial<ShopGoodsParam> }>
>(
() => [
{ key: 'recommend', title: '推荐', params: { recommend: 1 } },
{ key: '4476', title: '桶装水', params: { categoryId: 4476 } },
{ key: '4556', title: '水票套餐', params: { categoryId: 4556 } },
// { key: '4557', title: '购机套餐', params: { categoryId: 4557 } },
// { key: '4477', title: '饮水设备', params: { categoryId: 4477 } },
],
[]
)
useEffect(() => {
const tab = tabs.find((t) => t.key === activeTabKey) || tabs[0]
if (!tab) return
pageShopGoods({ ...tab.params, status: 0 })
.then((res) => setGoodsList((res?.list || []).filter((g) => g?.status === 0)))
.catch((err) => {
console.error('首页商品列表加载失败:', err)
setGoodsList([])
})
}, [activeTabKey, tabs])
const shortcuts = useMemo<
Array<{ key: string; title: string; icon: ReactNode; onClick: () => void }>
>(
() => [
{
key: 'ticket',
title: '我的水票',
icon: <Ticket size={30} />,
onClick: () => {
if (!ensureLoggedIn('/user/ticket/index')) return
Taro.navigateTo({ url: '/user/ticket/index' })
},
},
{
key: 'order',
title: '立即送水',
icon: <Cart size={30} />,
onClick: () => {
if (!ensureLoggedIn('/user/ticket/use?goodsId=10074')) return
Taro.navigateTo({ url: '/user/ticket/use?goodsId=10074' })
},
},
{
key: 'order',
title: '送水订单',
icon: <Agenda size={30} />,
onClick: () => {
if (!ensureLoggedIn('/user/ticket/index')) return
Taro.navigateTo({ url: '/user/ticket/index' })
},
},
{
key: 'invite',
title: '邀请有礼',
icon: <Gift size={30} />,
onClick: () => {
if (!ensureLoggedIn('/dealer/qrcode/index')) return
Taro.navigateTo({ url: '/dealer/qrcode/index' })
},
},
// {
// key: 'coupon',
// title: '领券中心',
// icon: <Coupon size={30} />,
// onClick: () => Taro.navigateTo({ url: '/coupon/index' }),
// },
],
[]
)
const visibleGoods = useMemo(() => {
// 先按效果图展示两列卡片,数据不够时也保持布局稳定
const list = goodsList || []
if (list.length <= 6) return list
return list.slice(0, 6)
}, [goodsList])
return (
<>
{/* Header区域 - 现在由Header组件内部处理吸顶逻辑 */}
<Header />
{/* Header区域 */}
{/*<Header />*/}
<div className={'flex flex-col mt-12'}>
<Menu/>
<Banner/>
<BestSellers onStickyChange={handleTabsStickyChange}/>
</div>
<View className="home-page">
{/* 顶部活动主视觉:使用 Banner 组件 */}
<Banner />
{/* 电子水票 */}
<View className="ticket-card">
<View className="ticket-card__head">
<Text className="ticket-card__title"></Text>
<Text className="ticket-card__count">
<Text className="ticket-card__countNum">{ticketTotal}</Text>
</Text>
</View>
<View className="ticket-card__body">
<View className="shortcut-grid">
{shortcuts.map((item) => (
<View
key={item.key}
className="shortcut-grid__item"
onClick={item.onClick}
>
<View className="shortcut-grid__icon">{item.icon}</View>
<Text className="shortcut-grid__text">{item.title}</Text>
</View>
))}
</View>
</View>
</View>
<View className="ticket-card" onClick={() => Taro.navigateTo({ url: `/shop/category/index?id=4560` })}>
<View className="ticket-card__head">
<Text className="ticket-card__title"></Text>
<ArrowRight className={'text-gray-50'} size={16} />
</View>
</View>
<View className="ticket-card" onClick={() => Taro.navigateTo({ url: `/shop/category/index?id=4556` })}>
<View className="ticket-card__head">
<Text className="ticket-card__title">·</Text>
<ArrowRight className={'text-gray-50'} size={16} />
</View>
</View>
{/*分类Tabs*/}
<ScrollView className="home-tabs" scrollX enableFlex>
<View className="home-tabs__inner">
{tabs.map((tab) => {
const active = tab.key === activeTabKey
return (
<View
key={tab.key}
className={`home-tabs__item ${active ? 'home-tabs__item--active' : ''}`}
onClick={() => setActiveTabKey(tab.key)}
>
<Text className="home-tabs__itemText">{tab.title}</Text>
</View>
)
})}
</View>
</ScrollView>
{/* 商品列表 */}
<View className="goods-grid">
{visibleGoods.map((item) => (
<View key={item.goodsId} className="goods-card">
<View className="goods-card__imgWrap">
<Image
className="goods-card__img"
src={item.image || ''}
mode="aspectFill"
lazyLoad={false}
onClick={() =>
Taro.navigateTo({ url: `/shop/goodsDetail/index?id=${item.goodsId}` })
}
/>
</View>
<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>
<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--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 File

@@ -6,12 +6,13 @@ import {useUser} from '@/hooks/useUser'
import {useDealerUser} from "@/hooks/useDealerUser";
import {useThemeStyles} from "@/hooks/useTheme";
import { useConfig } from "@/hooks/useConfig"; // 使用新的自定义Hook
import Taro from '@tarojs/taro'
const IsDealer = () => {
const themeStyles = useThemeStyles();
const { config } = useConfig(); // 使用新的Hook
const {isSuperAdmin} = useUser();
const {dealerUser} = useDealerUser()
const {dealerUser, loading: dealerLoading} = useDealerUser()
/**
* 管理中心
@@ -51,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>
}
@@ -75,12 +76,18 @@ 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 || '开通VIP'}</Text>
<Text className={'text-white opacity-80 pl-3'}>{config?.vipComments || '享优惠'}</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>
}
extra={<ArrowRight color="#cccccc" size={18}/>}
onClick={() => navTo('/dealer/apply/add', true)}
onClick={() => {
if (dealerLoading) {
Taro.showToast({ title: '正在加载信息,请稍等...', icon: 'none' })
return
}
navTo('/dealer/apply/add', true)
}}
/>
</View>
</>

View File

@@ -8,33 +8,94 @@ import navTo from "@/utils/common";
import {TenantId} from "@/config/app";
import {useUser} from "@/hooks/useUser";
import {useUserData} from "@/hooks/useUserData";
import {getStoredInviteParams} from "@/utils/invite";
import {checkAndHandleInviteRelation, getStoredInviteParams, hasPendingInvite} from "@/utils/invite";
import UnifiedQRButton from "@/components/UnifiedQRButton";
import {useThemeStyles} from "@/hooks/useTheme";
import {getRootDomain} from "@/utils/domain";
import { getMyGltUserTicketTotal } from '@/api/glt/gltUserTicket'
import { saveStorageByLoginUser } from '@/utils/server'
const UserCard = forwardRef<any, any>((_, ref) => {
const {data, refresh} = useUserData()
const {getDisplayName, getRoleName} = useUser();
const {loadUserFromStorage} = useUser();
const [IsLogin, setIsLogin] = useState<boolean>(false)
const [userInfo, setUserInfo] = useState<User>()
const [ticketTotal, setTicketTotal] = useState<number>(0)
const themeStyles = useThemeStyles();
const canShowScanButton = (() => {
const v: any = (userInfo as any)?.isAdmin
return v === true || v === 1 || v === '1'
})()
const getDisplayName = () => {
if (!userInfo) return IsLogin ? '用户' : '点击登录'
return userInfo.nickname || (userInfo as any).realName || (userInfo as any).username || (IsLogin ? '用户' : '点击登录')
}
// 角色名称:优先取用户 roles 数组的第一个角色名称
const getRoleName = () => {
return userInfo?.roles?.[0]?.roleName || userInfo?.roleName || '注册用户'
}
// 下拉刷新
const handleRefresh = async () => {
const reloadStats = async (showToast = false) => {
await refresh()
Taro.showToast({
title: '刷新成功',
icon: 'success'
})
reloadTicketTotal()
if (showToast) {
Taro.showToast({
title: '刷新成功',
icon: 'success'
})
}
}
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
handleRefresh: async () => {
await reloadUserInfo()
await reloadStats(true)
},
reloadStats,
reloadUserInfo
}))
useEffect(() => {
// 独立于用户信息授权:只要有登录 token就可以拉取水票总数
reloadTicketTotal()
// Taro.getSetting获取用户的当前设置。返回值中只会出现小程序已经向用户请求过的权限。
Taro.getSetting({
success: (res) => {
@@ -51,6 +112,23 @@ const UserCard = forwardRef<any, any>((_, ref) => {
});
}, []);
const reloadTicketTotal = () => {
const token = Taro.getStorageSync('access_token')
const userIdRaw = Taro.getStorageSync('UserId')
const userId = Number(userIdRaw)
const hasUserId = Number.isFinite(userId) && userId > 0
if (!token && !hasUserId) {
setTicketTotal(0)
return
}
getMyGltUserTicketTotal(hasUserId ? userId : undefined)
.then((total) => setTicketTotal(typeof total === 'number' ? total : 0))
.catch((err) => {
console.error('个人中心水票总数加载失败:', err)
setTicketTotal(0)
})
}
const reload = () => {
Taro.getUserInfo({
success: (res) => {
@@ -60,25 +138,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);
Taro.setStorageSync('UserId', data.userId)
// 获取openId
if (!data.openid) {
Taro.login({
success: (res) => {
getWxOpenId({code: res.code}).then(() => {
})
}
})
}
}
}).catch(() => {
console.log('未登录')
});
reloadUserInfo()
.then(() => {
// 登录态已就绪后刷新卡片统计(余额/积分/券/水票)
refresh().then()
reloadTicketTotal()
})
.catch(() => {
console.log('未登录')
})
}
});
};
@@ -133,7 +201,7 @@ const UserCard = forwardRef<any, any>((_, ref) => {
success: function () {
if (code) {
Taro.request({
url: 'https://server.websoft.top/api/wx-login/loginByMpWxPhone',
url: 'https://glt-server.websoft.top/api/wx-login/loginByMpWxPhone',
method: 'POST',
data: {
code,
@@ -158,10 +226,19 @@ const UserCard = forwardRef<any, any>((_, ref) => {
return false;
}
// 登录成功
Taro.setStorageSync('access_token', res.data.data.access_token)
Taro.setStorageSync('UserId', res.data.data.user.userId)
saveStorageByLoginUser(res.data.data.access_token, res.data.data.user)
setUserInfo(res.data.data.user)
setIsLogin(true)
// 登录态已就绪后刷新卡片统计(余额/积分/券/水票)
refresh().then()
reloadTicketTotal()
// 登录成功后(可能是新注册用户),检查是否存在待处理的邀请关系并尝试绑定
if (hasPendingInvite()) {
checkAndHandleInviteRelation().catch((e) => {
console.error('个人中心登录后处理邀请关系失败:', e)
})
}
}
})
} else {
@@ -189,7 +266,9 @@ const UserCard = forwardRef<any, any>((_, ref) => {
/>
<View className={'flex flex-col'}>
<Text style={{color: '#ffffff'}}>{getDisplayName() || '点击登录'}</Text>
<View><Tag type="success">{getRoleName()}</Tag></View>
{getRootDomain() && (
<View><Tag type="success">{getRoleName()}</Tag></View>
)}
</View>
</View>
</View>
@@ -209,33 +288,62 @@ const UserCard = forwardRef<any, any>((_, ref) => {
</Button>
)}
</View>
<Space style={{
marginTop: '30px',
marginRight: '10px'
}}>
{/*统一扫码入口 - 支持登录和核销*/}
<UnifiedQRButton
text="扫一扫"
size="small"
onSuccess={(result) => {
console.log('统一扫码成功:', result);
// 根据扫码类型给出不同的提示
if (result.type === 'verification') {
// 核销成功,可以显示更多信息或跳转到详情页
Taro.showModal({
title: '核销成功',
content: `已成功核销的品类:${result.data.goodsName || '礼品卡'},面值¥${result.data.faceValue}`
});
}
}}
onError={(error) => {
console.error('统一扫码失败:', error);
}}
/>
</Space>
{/*统一扫码入口 - 仅管理员可见*/}
{canShowScanButton && (
<Space style={{
marginTop: '30px',
marginRight: '10px'
}}>
<UnifiedQRButton
text="扫一扫"
size="small"
onSuccess={(result) => {
console.log('统一扫码成功:', result);
// 根据扫码类型给出不同的提示
if (result.type === 'verification') {
const businessType = result?.data?.businessType;
if (businessType === 'gift' && result?.data?.gift) {
const gift = result.data.gift;
Taro.showModal({
title: '核销成功',
content: `已成功核销:${gift.goodsName || gift.name || '礼品'},面值¥${gift.faceValue}`
});
return;
}
if (businessType === 'ticket' && result?.data?.ticket) {
const ticket = result.data.ticket;
const qty = result.data.qty || 1;
Taro.showModal({
title: '核销成功',
content: `已成功核销:${ticket.templateName || '水票'},本次使用${qty}次,剩余可用${ticket.availableQty ?? 0}`
});
return;
}
Taro.showModal({
title: '核销成功',
content: '已成功核销'
});
}
}}
onError={(error) => {
console.error('统一扫码失败:', error);
}}
/>
</Space>
)}
</View>
<View className={'py-2'}>
<View className={'flex justify-around mt-1'}>
<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>
</View>
<View className={'item flex justify-center flex-col items-center'}
onClick={() => navTo('/user/coupon/index', true)}>
<Text className={'text-xs text-gray-200'} style={themeStyles.textColor}></Text>
<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/wallet/wallet', true)}>
<Text className={'text-xs text-gray-200'} style={themeStyles.textColor}></Text>
@@ -245,16 +353,6 @@ const UserCard = forwardRef<any, any>((_, ref) => {
<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 className={'item flex justify-center flex-col items-center'}
onClick={() => navTo('/user/coupon/index', true)}>
<Text className={'text-xs text-gray-200'} style={themeStyles.textColor}></Text>
<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/gift/index', true)}>
<Text className={'text-xs text-gray-200'} style={themeStyles.textColor}></Text>
<Text className={'text-xl text-white'} style={themeStyles.textColor}>{data?.giftCards || 0}</Text>
</View>
</View>
</View>
</View>

View File

@@ -55,7 +55,7 @@ const UserCell = () => {
title={
<View style={{display: 'inline-flex', alignItems: 'center'}}>
<Location size={16}/>
<Text className={'pl-3 text-sm'}></Text>
<Text className={'pl-3 text-sm'}></Text>
</View>
}
align="center"

View File

@@ -47,8 +47,9 @@ const UserFooter = () => {
return (
<>
<div className={'text-center py-4 w-full text-gray-300'} onClick={onLoginByPhone}>
<div className={'text-xs text-gray-400 py-1'}>{Version}</div>
<div className={'text-xs text-gray-400 py-1'}>Copyright © { new Date().getFullYear() } {Copyright}</div>
{/*<div className={'text-xs text-gray-400 py-1'}>当前版本:{Version}</div>*/}
{/*<div className={'text-xs text-gray-400 py-1'}>Copyright © { new Date().getFullYear() } {Copyright}</div>*/}
<div className={'text-xs text-gray-400 py-1'}>{Copyright}</div>
</div>
<Popup

View File

@@ -11,13 +11,14 @@ import {
People,
// AfterSaleService,
Logout,
ShoppingAdd,
Shop,
Jdl,
Service
} from '@nutui/icons-react-taro'
import {useUser} from "@/hooks/useUser";
const UserCell = () => {
const {logoutUser} = useUser();
const {logoutUser, hasRole} = useUser();
const onLogout = () => {
Taro.showModal({
@@ -38,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}
@@ -49,10 +50,49 @@ const UserCell = () => {
border: 'none'
} as React.CSSProperties}
>
<Grid.Item text="企业采购" onClick={() => navTo('/user/poster/poster', true)}>
{hasRole('store') && (
<Grid.Item text="门店中心" onClick={() => navTo('/store/index', true)}>
<View className="text-center">
<View className="w-12 h-12 bg-blue-50 rounded-xl flex items-center justify-center mx-auto mb-2">
<Shop color="#3b82f6" size="20"/>
</View>
</View>
</Grid.Item>
)}
{hasRole('rider') && (
<Grid.Item text="配送中心" onClick={() => navTo('/rider/index', true)}>
<View className="text-center">
<View className="w-12 h-12 bg-blue-50 rounded-xl flex items-center justify-center mx-auto mb-2">
<Jdl color="#3b82f6" size="20"/>
</View>
</View>
</Grid.Item>
)}
{(hasRole('staff') || hasRole('admin')) && (
<Grid.Item text="门店订单" onClick={() => navTo('/user/store/orders/index', true)}>
<View className="text-center">
<View className="w-12 h-12 bg-orange-50 rounded-xl flex items-center justify-center mx-auto mb-2">
<Shop color="#f59e0b" size="20"/>
</View>
</View>
</Grid.Item>
)}
<Grid.Item text="配送地址" onClick={() => navTo('/user/address/index', true)}>
<View className="text-center">
<View className="w-12 h-12 bg-blue-50 rounded-xl flex items-center justify-center mx-auto mb-2">
<ShoppingAdd color="#3b82f6" size="20"/>
<View className="w-12 h-12 bg-emerald-50 rounded-xl flex items-center justify-center mx-auto mb-2">
<Location color="#3b82f6" size="20"/>
</View>
</View>
</Grid.Item>
<Grid.Item text={'常见问题'} onClick={() => navTo('/user/help/index')}>
<View className="text-center">
<View className="w-12 h-12 bg-cyan-50 rounded-xl flex items-center justify-center mx-auto mb-2">
<Ask className={'text-cyan-500'} size="20"/>
</View>
</View>
</Grid.Item>
@@ -71,14 +111,6 @@ const UserCell = () => {
</Button>
</Grid.Item>
<Grid.Item text="收货地址" onClick={() => navTo('/user/address/index', true)}>
<View className="text-center">
<View className="w-12 h-12 bg-emerald-50 rounded-xl flex items-center justify-center mx-auto mb-2">
<Location color="#3b82f6" size="20"/>
</View>
</View>
</Grid.Item>
<Grid.Item text={'实名认证'} onClick={() => navTo('/user/userVerify/index', true)}>
<View className="text-center">
<View className="w-12 h-12 bg-green-50 rounded-xl flex items-center justify-center mx-auto mb-2">
@@ -111,13 +143,6 @@ const UserCell = () => {
{/* </View>*/}
{/*</Grid.Item>*/}
<Grid.Item text={'常见问题'} onClick={() => navTo('/user/help/index')}>
<View className="text-center">
<View className="w-12 h-12 bg-cyan-50 rounded-xl flex items-center justify-center mx-auto mb-2">
<Ask className={'text-cyan-500'} size="20"/>
</View>
</View>
</Grid.Item>
<Grid.Item text={'关于我们'} onClick={() => navTo('/user/about/index')}>
<View className="text-center">
@@ -189,4 +214,3 @@ const UserCell = () => {
)
}
export default UserCell

View File

@@ -26,7 +26,7 @@ function UserOrder() {
}}
>
<View className={'title-bar flex justify-between pt-2'}>
<Text className={'title font-medium px-4'}></Text>
<Text className={'title font-medium px-4'}></Text>
<View
className={'more flex items-center px-2'}
onClick={() => navTo('/user/order/order', true)}

View File

@@ -1,33 +1,41 @@
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";
import UserFooter from "./components/UserFooter";
import {useUserData} from "@/hooks/useUserData";
import {View} from '@tarojs/components';
import './user.scss'
import IsDealer from "./components/IsDealer";
import {useThemeStyles} from "@/hooks/useTheme";
import UserGrid from "@/pages/user/components/UserGrid";
import { useDidShow } from '@tarojs/taro'
function User() {
const {refresh} = useUserData()
const userCardRef = useRef<any>()
const themeStyles = useThemeStyles();
// TabBar 页在小程序里通常不会销毁;从“注册/申请”页返回时需要触发子组件重新初始化/拉取最新状态。
const [dealerViewKey, setDealerViewKey] = useState(0)
// 下拉刷新处理
const handleRefresh = async () => {
await refresh()
// 如果 UserCard 组件有自己的刷新方法,也可以调用
if (userCardRef.current?.handleRefresh) {
await userCardRef.current.handleRefresh()
}
setDealerViewKey(v => v + 1)
}
useEffect(() => {
}, []);
// 每次进入/切回个人中心都刷新一次统计(包含水票数量)
useDidShow(() => {
userCardRef.current?.reloadStats?.()
// 个人资料(头像/昵称)可能在其它页面被修改,这里确保返回时立刻刷新
userCardRef.current?.reloadUserInfo?.()
setDealerViewKey(v => v + 1)
})
return (
<PullToRefresh
onRefresh={handleRefresh}
@@ -54,7 +62,7 @@ function User() {
</View>
<UserCard ref={userCardRef}/>
<UserOrder/>
<IsDealer/>
<IsDealer key={dealerViewKey}/>
<UserGrid/>
<UserFooter/>
</PullToRefresh>

View File

@@ -1,28 +1,47 @@
import {useEffect, useState} from "react";
import { useEffect, useState } from 'react'
import Taro from '@tarojs/taro'
import {View, RichText} from '@tarojs/components'
import { Loading } from '@nutui/nutui-react-taro'
import { RichText, View } from '@tarojs/components'
import { getByCode } from '@/api/cms/cmsArticle'
import { wxParse } from '@/utils/common'
const Agreement = () => {
const [loading, setLoading] = useState(true)
const [content, setContent] = useState<string>('')
const [content, setContent] = useState<any>('')
const reload = () => {
Taro.hideTabBar()
setContent('<p>' +
'<span style="font-size: 14px;">欢迎使用</span>' +
'<span style="font-size: 14px;">&nbsp;</span>' +
'<span style="font-size: 14px;"><strong><span style="color: rgb(255, 0, 0);">【WebSoft】</span></strong></span>' +
'<span style="font-size: 14px;">服务协议&nbsp;</span>' +
'</p>')
const reload = async () => {
try {
Taro.hideTabBar()
} catch (_) {
// ignore (e.g. H5 / unsupported env)
}
try {
const article = await getByCode('xieyi')
setContent(article?.content ? wxParse(article.content) : '<p>暂无协议内容</p>')
} catch (e) {
// Keep UI usable even if CMS/API fails.
// eslint-disable-next-line no-console
console.error('load agreement failed', e)
setContent('<p>协议内容加载失败</p>')
Taro.showToast({ title: '协议加载失败', icon: 'none' })
} finally {
setLoading(false)
}
}
useEffect(() => {
reload()
}, [])
if (loading) {
return <Loading className={'px-2'}></Loading>
}
return (
<>
<View className={'content text-gray-700 text-sm p-4'}>
<RichText nodes={content}/>
<RichText nodes={content} />
</View>
</>
)

View File

@@ -1,4 +1,5 @@
export default definePageConfig({
navigationBarTitleText: '注册账号',
navigationBarTitleText: '注册/登录',
navigationBarTextStyle: 'black'
})

View File

@@ -1,47 +1,295 @@
import {useEffect, useState} from "react";
import { useEffect, useMemo, useState } from 'react'
import Taro from '@tarojs/taro'
import {Input, Radio, Button} from '@nutui/nutui-react-taro'
import { Button, Radio } from '@nutui/nutui-react-taro'
import { TenantId } from '@/config/app'
import { getUserInfo, getWxOpenId } from '@/api/layout'
import { saveStorageByLoginUser } from '@/utils/server'
import {
getStoredInviteParams,
parseInviteParams,
saveInviteParams,
trackInviteSource,
checkAndHandleInviteRelation,
} from '@/utils/invite'
interface GetPhoneNumberDetail {
code?: string
encryptedData?: string
iv?: string
errMsg?: string
}
interface GetPhoneNumberEvent {
detail: GetPhoneNumberDetail
}
interface LoginResponse {
data: {
code?: number
message?: string
data?: {
access_token: string
user: any
}
}
}
async function getWeappLoginCode(): Promise<string | undefined> {
try {
const res = await new Promise<{ code?: string }>((resolve, reject) => {
Taro.login({
success: (r) => resolve(r as any),
fail: (e) => reject(e),
})
})
return res?.code
} catch (_e) {
return undefined
}
}
async function ensureWxOpenIdSaved(opts: { user?: any; wxLoginCode?: string }) {
// JSAPI 微信支付必须有 openid注册/登录后立刻补齐,避免后续创建支付单失败。
try {
if (Taro.getEnv() !== Taro.ENV_TYPE.WEAPP) return
} catch (_e) {
if (process.env.TARO_ENV !== 'weapp') return
}
if (opts.user?.openid) return
const code = opts.wxLoginCode || (await getWeappLoginCode())
if (!code) return
// 该接口一般会在服务端把 openid 绑定到当前登录用户;返回值并不一定包含 openid。
await getWxOpenId({ code })
// 同步本地 User让后续页面/逻辑能直接读到 openid
try {
const fresh = await getUserInfo()
if (fresh) Taro.setStorageSync('User', fresh)
} catch (_e) {
// ignore: openid 已在服务端绑定,本地不同步也不影响后端创建支付订单
}
}
function safeDecodeMaybeEncoded(input?: string): string {
if (!input) return ''
try {
// Taro 路由参数通常是 URL 编码过的字符串
return decodeURIComponent(input)
} catch (_e) {
return input
}
}
function isTabBarUrl(url: string) {
const pure = url.split('?')[0]
return (
pure === '/pages/index/index' ||
pure === '/pages/cart/cart' ||
pure === '/pages/user/user' ||
pure === '/pages/category/index'
)
}
const Register = () => {
const [isAgree, setIsAgree] = useState(false)
const reload = () => {
Taro.hideTabBar()
}
const [loading, setLoading] = useState(false)
// 短信验证码登录仅在非微信小程序端展示
const isWeapp = useMemo(() => {
try {
return Taro.getEnv() === Taro.ENV_TYPE.WEAPP
} catch (_e) {
return process.env.TARO_ENV === 'weapp'
}
}, [])
const router = Taro.getCurrentInstance().router
useEffect(() => {
reload()
// 注册/登录页不需要展示 tabBar
Taro.hideTabBar()
}, [])
const redirectUrl = useMemo(() => {
const raw = (router?.params as any)?.redirect as string | undefined
const decoded = safeDecodeMaybeEncoded(raw)
if (!decoded) return ''
return decoded.startsWith('/') ? decoded : `/${decoded}`
}, [router?.params])
// 如果从分享/二维码直接进入注册页(携带 inviter/source/t先暂存邀请信息
useEffect(() => {
try {
const inviteParams = parseInviteParams({ query: router?.params })
if (inviteParams?.inviter) {
saveInviteParams(inviteParams)
trackInviteSource(inviteParams.source || 'qrcode', parseInt(inviteParams.inviter, 10))
}
} catch (e) {
console.error('注册页处理邀请参数失败:', e)
}
}, [router?.params])
const navigateAfterLogin = async () => {
if (!redirectUrl) {
await Taro.reLaunch({ url: '/pages/index/index' })
return
}
if (isTabBarUrl(redirectUrl)) {
// switchTab 不支持携带 query这里按纯路径跳转
await Taro.switchTab({ url: redirectUrl.split('?')[0] })
return
}
// 替换当前注册页,避免返回栈里再回到注册页
await Taro.redirectTo({ url: redirectUrl })
}
const handleGetPhoneNumber = async ({ detail }: GetPhoneNumberEvent) => {
if (!isAgree) {
Taro.showToast({ title: '请先勾选同意协议', icon: 'none' })
return
}
if (loading) return
const { code: phoneCode, encryptedData, iv, errMsg } = detail || {}
if (!phoneCode || (errMsg && errMsg.includes('fail'))) {
Taro.showToast({ title: '未授权手机号', icon: 'none' })
return
}
try {
setLoading(true)
// 获取存储的邀请参数推荐人ID
const inviteParams = getStoredInviteParams()
const refereeId = inviteParams?.inviter ? parseInt(inviteParams.inviter, 10) : 0
// 获取小程序登录 code用于后续绑定 openid
const wxLoginCode = await getWeappLoginCode()
const res = (await Taro.request({
url: 'https://glt-server.websoft.top/api/wx-login/loginByMpWxPhone',
method: 'POST',
data: {
code: phoneCode,
encryptedData,
iv,
notVerifyPhone: true,
refereeId: refereeId,
sceneType: 'save_referee',
tenantId: TenantId,
},
header: {
'content-type': 'application/json',
TenantId,
},
})) as unknown as LoginResponse
if ((res as any)?.data?.code === 1) {
Taro.showToast({ title: res.data.message || '登录失败', icon: 'none' })
return
}
const token = res?.data?.data?.access_token
const user = res?.data?.data?.user
if (!token || !user?.userId) {
Taro.showToast({ title: '登录失败,请重试', icon: 'none' })
return
}
saveStorageByLoginUser(token, user)
// 注册/登录成功后,立即补齐 openidJSAPI 支付必需)
try {
await ensureWxOpenIdSaved({ user, wxLoginCode })
} catch (e) {
console.error('注册页绑定 openid 失败:', e)
}
// 登录成功后尝试绑定推荐关系(如果有待处理 inviter会自动处理并清理参数
try {
await checkAndHandleInviteRelation()
} catch (e) {
console.error('注册页登录后处理邀请关系失败:', e)
}
Taro.showToast({ title: '登录成功', icon: 'success' })
setTimeout(() => {
navigateAfterLogin().catch((e) => console.error('登录后跳转失败:', e))
}, 800)
} catch (e: any) {
console.error('注册/登录失败:', e)
Taro.showToast({ title: e?.message || '登录失败', icon: 'none' })
} finally {
setLoading(false)
}
}
const goSmsLogin = () => {
const inviteParams = getStoredInviteParams()
const inviter = inviteParams?.inviter
const source = inviteParams?.source
const t = inviteParams?.t
const params: Record<string, any> = {}
if (redirectUrl) params.redirect = redirectUrl
// 兜底:把 inviter 带过去,避免“先点注册再进入”时丢失
if (inviter) params.inviter = inviter
if (source) params.source = source
if (t) params.t = t
const qs = Object.entries(params)
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
.join('&')
Taro.navigateTo({ url: `/passport/sms-login${qs ? `?${qs}` : ''}` })
}
return (
<>
<div className={'flex flex-col justify-center px-5 pt-3'}>
<div className={'text-xl font-bold py-2'}>14</div>
<div className={'text-sm py-1 font-normal text-gray-500'}></div>
<div className={'text-sm pb-4 font-normal text-gray-500'}>
WebSoft为您提供独立站的解决方案
<div className={'flex flex-col justify-center px-5 pt-5'}>
<div className={'text-3xl text-center py-5 font-normal my-6'}>/</div>
<div className={'flex flex-col gap-3 bg-green-600 py-1'} style={{ borderRadius: '100px' }}>
<Button
type="primary"
fill="solid"
color="#07c160"
block
loading={loading}
disabled={!isAgree || loading}
open-type="getPhoneNumber"
onGetPhoneNumber={handleGetPhoneNumber}
>
/
</Button>
{!isWeapp && (
<Button type="default" block disabled={!isAgree || loading} onClick={goSmsLogin}>
/
</Button>
)}
</div>
<div className={'flex flex-col justify-between items-center my-2'}>
<Input type="text" placeholder="手机号" maxLength={11} style={{backgroundColor: '#ffffff', borderRadius: '8px'}}/>
<div className={'mt-6 flex text-sm items-center px-1'}>
<Radio style={{ color: '#333333' }} checked={isAgree} onClick={() => setIsAgree(!isAgree)} />
<span className={'text-gray-400'} onClick={() => setIsAgree(!isAgree)}>
</span>
<a
onClick={() => Taro.navigateTo({ url: '/passport/agreement' })}
className={'text-blue-600'}
>
</a>
</div>
<div className={'flex flex-col justify-between items-center my-2'}>
<Input type="password" placeholder="密码" style={{backgroundColor: '#ffffff', borderRadius: '8px'}}/>
</div>
<div className={'flex flex-col justify-between items-center my-2'}>
<Input type="password" placeholder="再次输入密码" style={{backgroundColor: '#ffffff', borderRadius: '8px'}}/>
</div>
<div className={'flex justify-center my-5'}>
<Button type="info" size={'large'} className={'w-full rounded-lg p-2'} disabled={!isAgree}></Button>
</div>
<div className={'my-2 flex text-sm items-center px-1'}>
<Radio style={{color: '#333333'}} checked={isAgree} onClick={() => setIsAgree(!isAgree)}></Radio>
<span className={'text-gray-400'} onClick={() => setIsAgree(!isAgree)}></span>
<a onClick={() => Taro.navigateTo({url: '/passport/agreement'})} className={'text-blue-600'}></a>
</div>
</div>
<div className={'w-full fixed bottom-20 my-2 flex justify-center text-sm items-center text-center'}>
<a className={'text-blue-600'} onClick={() => Taro.navigateBack()}></a>
</div>
</>
)
}
export default Register

View File

@@ -3,6 +3,7 @@ import Taro from '@tarojs/taro'
import {Input, Button} from '@nutui/nutui-react-taro'
import {loginBySms, sendSmsCaptcha} from "@/api/passport/login";
import {LoginParam} from "@/api/passport/login/model";
import {checkAndHandleInviteRelation, hasPendingInvite, parseInviteParams, saveInviteParams, trackInviteSource} from "@/utils/invite";
const SmsLogin = () => {
const [loading, setLoading] = useState<boolean>(false)
@@ -13,6 +14,46 @@ const SmsLogin = () => {
code: ''
})
const router = Taro.getCurrentInstance().router
const redirectParam = (router?.params as any)?.redirect as string | undefined
const safeDecodeMaybeEncoded = (input?: string) => {
if (!input) return ''
try {
return decodeURIComponent(input)
} catch (_e) {
return input
}
}
const redirectUrl = (() => {
const decoded = safeDecodeMaybeEncoded(redirectParam)
if (!decoded) return ''
return decoded.startsWith('/') ? decoded : `/${decoded}`
})()
const isTabBarUrl = (url: string) => {
const pure = url.split('?')[0]
return (
pure === '/pages/index/index' ||
pure === '/pages/cart/cart' ||
pure === '/pages/user/user' ||
pure === '/pages/category/index'
)
}
const navigateAfterLogin = async () => {
if (!redirectUrl) {
await Taro.reLaunch({ url: '/pages/index/index' })
return
}
if (isTabBarUrl(redirectUrl)) {
await Taro.switchTab({ url: redirectUrl.split('?')[0] })
return
}
await Taro.redirectTo({ url: redirectUrl })
}
const reload = () => {
Taro.hideTabBar()
}
@@ -21,6 +62,19 @@ const SmsLogin = () => {
reload()
}, [])
// 如果从分享/二维码链接进入短信登录页,先暂存邀请信息
useEffect(() => {
try {
const inviteParams = parseInviteParams({ query: router?.params })
if (inviteParams?.inviter) {
saveInviteParams(inviteParams)
trackInviteSource(inviteParams.source || 'share', parseInt(inviteParams.inviter, 10))
}
} catch (e) {
console.error('短信登录页处理邀请参数失败:', e)
}
}, [router?.params])
// 倒计时效果
useEffect(() => {
let timer: NodeJS.Timeout
@@ -131,6 +185,15 @@ const SmsLogin = () => {
code: formData.code
})
// 登录成功后(可能是新注册用户),检查是否存在待处理的邀请关系并尝试绑定
if (hasPendingInvite()) {
try {
await checkAndHandleInviteRelation()
} catch (e) {
console.error('短信登录后处理邀请关系失败:', e)
}
}
Taro.showToast({
title: '登录成功',
icon: 'success'
@@ -138,8 +201,9 @@ const SmsLogin = () => {
// 延迟跳转到首页
setTimeout(() => {
Taro.reLaunch({
url: '/pages/index/index'
navigateAfterLogin().catch((e) => {
console.error('短信登录后跳转失败:', e)
Taro.reLaunch({ url: '/pages/index/index' })
})
}, 1500)

View File

@@ -43,7 +43,7 @@ const UnifiedQRPage: React.FC = () => {
setTimeout(() => {
Taro.showModal({
title: '核销成功',
content: '是否继续扫码核销其他礼品卡?',
content: '是否继续扫码核销其他水票/礼品卡?',
success: (res) => {
if (res.confirm) {
handleStartScan();
@@ -179,7 +179,7 @@ const UnifiedQRPage: React.FC = () => {
</Text>
<Text className="text-gray-600 mb-6 block">
{scanType === ScanType.LOGIN ? '正在确认登录' :
scanType === ScanType.VERIFICATION ? '正在核销礼品卡' : '正在处理'}
scanType === ScanType.VERIFICATION ? '正在核销' : '正在处理'}
</Text>
</>
)}
@@ -192,12 +192,29 @@ const UnifiedQRPage: React.FC = () => {
</Text>
{result.type === ScanType.VERIFICATION && result.data && (
<View className="bg-green-50 rounded-lg p-3 mb-4">
<Text className="text-sm text-green-800 block">
{result.data.goodsName || '未知商品'}
</Text>
<Text className="text-sm text-green-800 block">
¥{result.data.faceValue}
</Text>
{result.data.businessType === 'gift' && result.data.gift && (
<>
<Text className="text-sm text-green-800 block">
{result.data.gift.goodsName || result.data.gift.name || '未知'}
</Text>
<Text className="text-sm text-green-800 block">
¥{result.data.gift.faceValue}
</Text>
</>
)}
{result.data.businessType === 'ticket' && result.data.ticket && (
<>
<Text className="text-sm text-green-800 block">
{result.data.ticket.templateName || '水票'}
</Text>
<Text className="text-sm text-green-800 block">
{result.data.qty || 1}
</Text>
<Text className="text-sm text-green-800 block">
{result.data.ticket.availableQty ?? 0}
</Text>
</>
)}
</View>
)}
<View className="mt-2">
@@ -278,9 +295,14 @@ const UnifiedQRPage: React.FC = () => {
<Text className="text-sm text-gray-800">
{record.success ? record.message : record.error}
</Text>
{record.success && record.type === ScanType.VERIFICATION && record.data && (
{record.success && record.type === ScanType.VERIFICATION && record.data?.businessType === 'gift' && record.data?.gift && (
<Text className="text-xs text-gray-500">
{record.data.goodsName} - ¥{record.data.faceValue}
{record.data.gift.goodsName || record.data.gift.name} - ¥{record.data.gift.faceValue}
</Text>
)}
{record.success && record.type === ScanType.VERIFICATION && record.data?.businessType === 'ticket' && record.data?.ticket && (
<Text className="text-xs text-gray-500">
{record.data.ticket.templateName || '水票'} - {record.data.qty || 1}
</Text>
)}
</View>
@@ -304,7 +326,7 @@ const UnifiedQRPage: React.FC = () => {
</Text>
<Text className="text-xs text-blue-700 block mb-1">
/
</Text>
<Text className="text-xs text-blue-700 block">

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '配送中心'
})

0
src/rider/index.scss Normal file
View File

304
src/rider/index.tsx Normal file
View File

@@ -0,0 +1,304 @@
import React from 'react'
import {View, Text} from '@tarojs/components'
import {ConfigProvider, Button, Grid, Avatar} from '@nutui/nutui-react-taro'
import {
User,
Shopping,
Dongdong,
ArrowRight,
Purse,
People,
Scan
} from '@nutui/icons-react-taro'
import {useDealerUser} from '@/hooks/useDealerUser'
import { useThemeStyles } from '@/hooks/useTheme'
import {businessGradients, cardGradients, gradientUtils} from '@/styles/gradients'
import Taro from '@tarojs/taro'
const DealerIndex: React.FC = () => {
const {
dealerUser,
error,
refresh,
} = useDealerUser()
// 使用主题样式
const themeStyles = useThemeStyles()
// 导航到各个功能页面
const navigateToPage = (url: string) => {
Taro.navigateTo({url})
}
// 格式化金额
const formatMoney = (money?: string) => {
if (!money) return '0.00'
return parseFloat(money).toFixed(2)
}
// 格式化时间
const formatTime = (time?: string) => {
if (!time) return '-'
return new Date(time).toLocaleDateString()
}
// 获取用户主题
const userTheme = gradientUtils.getThemeByUserId(dealerUser?.userId)
// 获取渐变背景
const getGradientBackground = (themeColor?: string) => {
if (themeColor) {
const darkerColor = gradientUtils.adjustColorBrightness(themeColor, -30)
return gradientUtils.createGradient(themeColor, darkerColor)
}
return userTheme.background
}
console.log(getGradientBackground(),'getGradientBackground()')
if (error) {
return (
<View className="p-4">
<View className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
<Text className="text-red-600">{error}</Text>
</View>
<Button type="primary" onClick={refresh}>
</Button>
</View>
)
}
return (
<View className="bg-gray-100 min-h-screen">
<View>
{/*头部信息*/}
{dealerUser && (
<View className="px-4 py-6 relative overflow-hidden" style={themeStyles.primaryBackground}>
{/* 装饰性背景元素 - 小程序兼容版本 */}
<View className="absolute w-32 h-32 rounded-full" style={{
backgroundColor: 'rgba(255, 255, 255, 0.1)',
top: '-16px',
right: '-16px'
}}></View>
<View className="absolute w-24 h-24 rounded-full" style={{
backgroundColor: 'rgba(255, 255, 255, 0.08)',
bottom: '-12px',
left: '-12px'
}}></View>
<View className="absolute w-16 h-16 rounded-full" style={{
backgroundColor: 'rgba(255, 255, 255, 0.05)',
top: '60px',
left: '120px'
}}></View>
<View className="flex items-center justify-between relative z-10 mb-4">
<Avatar
size="50"
src={dealerUser?.qrcode}
icon={<User/>}
className="mr-4"
style={{
border: '2px solid rgba(255, 255, 255, 0.3)'
}}
/>
<View className="flex-1 flex-col">
<View className="text-white text-lg font-bold mb-1" style={{
}}>
{dealerUser?.realName || '分销商'}
</View>
<View className="text-sm" style={{
color: 'rgba(255, 255, 255, 0.8)'
}}>
ID: {dealerUser.userId}
</View>
</View>
<View className="text-right hidden">
<Text className="text-xs" style={{
color: 'rgba(255, 255, 255, 0.9)'
}}></Text>
<Text className="text-xs" style={{
color: 'rgba(255, 255, 255, 0.7)'
}}>
{formatTime(dealerUser.createTime)}
</Text>
</View>
</View>
</View>
)}
{/* 佣金统计卡片 */}
{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>
</View>
<View className="grid grid-cols-3 gap-3">
<View className="text-center p-3 rounded-lg flex flex-col" style={{
background: businessGradients.money.available
}}>
<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
}}>
<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>
</View>
<View className="text-center p-3 rounded-lg flex flex-col" style={{
background: businessGradients.money.total
}}>
<Text className="text-lg font-bold mb-1 text-white">
{formatMoney(dealerUser.totalMoney)}
</Text>
<Text className="text-xs" style={{ color: 'rgba(255, 255, 255, 0.9)' }}></Text>
</View>
</View>
</View>
)}
{/* 团队统计 */}
{dealerUser && (
<View className="bg-white mx-4 mt-4 rounded-xl p-4 hidden">
<View className="flex items-center justify-between mb-4">
<Text className="font-semibold text-gray-800"></Text>
<View
className="text-gray-400 text-sm flex items-center"
onClick={() => navigateToPage('/dealer/team/index')}
>
<Text></Text>
<ArrowRight size="12"/>
</View>
</View>
<View className="grid grid-cols-3 gap-4">
<View className="text-center grid">
<Text className="text-xl font-bold text-purple-500 mb-1">
{dealerUser.firstNum || 0}
</Text>
<Text className="text-xs text-gray-500"></Text>
</View>
<View className="text-center grid">
<Text className="text-xl font-bold text-indigo-500 mb-1">
{dealerUser.secondNum || 0}
</Text>
<Text className="text-xs text-gray-500"></Text>
</View>
<View className="text-center grid">
<Text className="text-xl font-bold text-pink-500 mb-1">
{dealerUser.thirdNum || 0}
</Text>
<Text className="text-xs text-gray-500"></Text>
</View>
</View>
</View>
)}
{/* 功能导航 */}
<View className="bg-white mx-4 mt-4 rounded-xl p-4">
<View className="font-semibold mb-4 text-gray-800"></View>
<ConfigProvider>
<Grid
columns={4}
className="no-border-grid"
style={{
'--nutui-grid-border-color': 'transparent',
'--nutui-grid-item-border-width': '0px',
border: 'none'
} as React.CSSProperties}
>
<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">
<Shopping color="#3b82f6" size="20"/>
</View>
</View>
</Grid.Item>
<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"/>
</View>
</View>
</Grid.Item>
<Grid.Item text={'配送小区'} onClick={() => navigateToPage('/rider/team/index')}>
<View className="text-center">
<View className="w-12 h-12 bg-purple-50 rounded-xl flex items-center justify-center mx-auto mb-2">
<People color="#8b5cf6" size="20"/>
</View>
</View>
</Grid.Item>
<Grid.Item text={'仓库地址'} onClick={() => navigateToPage('/rider/qrcode/index')}>
<View className="text-center">
<View className="w-12 h-12 bg-orange-50 rounded-xl flex items-center justify-center mx-auto mb-2">
<Dongdong color="#f59e0b" size="20"/>
</View>
</View>
</Grid.Item>
<Grid.Item text={'水票核销'} onClick={() => navigateToPage('/rider/ticket/verification/index?auto=1')}>
<View className="text-center">
<View className="w-12 h-12 bg-cyan-50 rounded-xl flex items-center justify-center mx-auto mb-2">
<Scan color="#06b6d4" size="20" />
</View>
</View>
</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.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>*/}
</ConfigProvider>
</View>
</View>
{/* 底部安全区域 */}
<View className="h-20"></View>
</View>
)
}
export default DealerIndex

View File

@@ -0,0 +1,4 @@
export default {
navigationBarTitleText: '送水订单',
navigationBarTextStyle: 'black'
}

610
src/rider/orders/index.tsx Normal file
View File

@@ -0,0 +1,610 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import Taro, { useDidShow } from '@tarojs/taro'
import {
Tabs,
TabPane,
Cell,
Space,
Button,
Dialog,
Radio,
RadioGroup,
Image,
Empty,
InfiniteLoading,
PullToRefresh,
Loading
} from '@nutui/nutui-react-taro'
import { View, Text } from '@tarojs/components'
import dayjs from 'dayjs'
import { pageGltTicketOrder, updateGltTicketOrder } from '@/api/glt/gltTicketOrder'
import type { GltTicketOrder, GltTicketOrderParam } from '@/api/glt/gltTicketOrder/model'
import { uploadFile } from '@/api/system/file'
export default function RiderOrders() {
const PAGE_SIZE = 10
const riderId = useMemo(() => {
const v = Number(Taro.getStorageSync('UserId'))
return Number.isFinite(v) && v > 0 ? v : undefined
}, [])
const pageRef = useRef(1)
const listRef = useRef<GltTicketOrder[]>([])
const [tabIndex, setTabIndex] = useState(0)
const [list, setList] = useState<GltTicketOrder[]>([])
const [hasMore, setHasMore] = useState(true)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [deliverDialogVisible, setDeliverDialogVisible] = useState(false)
const [deliverSubmitting, setDeliverSubmitting] = useState(false)
const [deliverOrder, setDeliverOrder] = useState<GltTicketOrder | null>(null)
const [deliverImg, setDeliverImg] = useState<string | undefined>(undefined)
type DeliverConfirmMode = 'photoComplete' | 'waitCustomerConfirm'
const [deliverConfirmMode, setDeliverConfirmMode] = useState<DeliverConfirmMode>('photoComplete')
const riderTabs = useMemo(
() => [
{ index: 0, title: '全部' },
{ index: 1, title: '待配送', deliveryStatus: 10 },
{ index: 2, title: '配送中', deliveryStatus: 20 },
{ index: 3, title: '待确认', deliveryStatus: 30 },
{ index: 4, title: '已完成', deliveryStatus: 40 }
],
[]
)
const getOrderStatusText = (order: GltTicketOrder) => {
if (order.status === 1) return '已冻结'
const deliveryStatus = order.deliveryStatus
if (deliveryStatus === 40) return '已完成'
if (deliveryStatus === 30) return '待客户确认'
if (deliveryStatus === 20) return '配送中'
if (deliveryStatus === 10) return '待配送'
// 兼容:如果后端暂未下发 deliveryStatus就用时间字段推断
if (order.receiveConfirmTime) return '已完成'
if (order.sendEndTime) return '待客户确认'
if (order.sendStartTime) return '配送中'
if (order.riderId) return '待配送'
return '待派单'
}
const getOrderStatusColor = (order: GltTicketOrder) => {
const text = getOrderStatusText(order)
if (text === '已完成') return 'text-green-600'
if (text === '待客户确认') return 'text-purple-600'
if (text === '配送中') return 'text-blue-600'
if (text === '待配送') return 'text-amber-600'
if (text === '已冻结') return 'text-orange-600'
return 'text-gray-500'
}
const canStartDeliver = (order: GltTicketOrder) => {
if (!order.id) return false
if (order.status === 1) return false
if (!riderId || order.riderId !== riderId) return false
if (order.deliveryStatus && order.deliveryStatus !== 10) return false
return !order.sendStartTime && !order.sendEndTime
}
const canConfirmDelivered = (order: GltTicketOrder) => {
if (!order.id) return false
if (order.status === 1) return false
if (!riderId || order.riderId !== riderId) return false
if (order.receiveConfirmTime) return false
if (order.deliveryStatus === 40) return false
if (order.sendEndTime) return false
// 只允许在“配送中”阶段确认送达
if (typeof order.deliveryStatus === 'number') return order.deliveryStatus === 20
return !!order.sendStartTime
}
const canCompleteByPhoto = (order: GltTicketOrder) => {
if (!order.id) return false
if (order.status === 1) return false
if (!riderId || order.riderId !== riderId) return false
if (order.receiveConfirmTime) return false
if (order.deliveryStatus === 40) return false
// 已送达但未完成:允许补传照片并直接完成
return !!order.sendEndTime
}
const filterByTab = useCallback(
(orders: GltTicketOrder[]) => {
if (tabIndex === 0) return orders
const current = riderTabs.find(t => t.index === tabIndex)
const status = current?.deliveryStatus
if (!status) return orders
// 如果后端已实现 deliveryStatus 筛选,这里基本不会再过滤;否则用兼容逻辑兜底。
return orders.filter(o => {
const ds = o.deliveryStatus
if (typeof ds === 'number') return ds === status
if (status === 10) return !!o.riderId && !o.sendStartTime && !o.sendEndTime
if (status === 20) return !!o.sendStartTime && !o.sendEndTime
if (status === 30) return !!o.sendEndTime && !o.receiveConfirmTime
if (status === 40) return !!o.receiveConfirmTime
return true
})
},
[riderTabs, tabIndex]
)
const reload = useCallback(
async (resetPage = false) => {
if (!riderId) return
if (loading) return
setLoading(true)
setError(null)
const currentPage = resetPage ? 1 : pageRef.current
const currentTab = riderTabs.find(t => t.index === tabIndex)
const params: GltTicketOrderParam = {
page: currentPage,
limit: PAGE_SIZE,
riderId,
deliveryStatus: currentTab?.deliveryStatus
}
try {
const res = await pageGltTicketOrder(params as any)
const incomingAll = (res?.list || []) as GltTicketOrder[]
// 兼容:后端若暂未实现 riderId 过滤,前端兜底过滤掉非本人的订单
const incoming = incomingAll.filter(o => o?.deleted !== 1 && o?.riderId === riderId)
const prev = resetPage ? [] : listRef.current
const next = resetPage ? incoming : prev.concat(incoming)
listRef.current = next
setList(next)
const total = typeof res?.count === 'number' ? res.count : undefined
const filteredOut = incomingAll.length - incoming.length
if (typeof total === 'number' && filteredOut === 0) {
setHasMore(next.length < total)
} else {
setHasMore(incomingAll.length >= PAGE_SIZE)
}
pageRef.current = currentPage + 1
} catch (e) {
console.error('加载配送订单失败:', e)
setError('加载失败,请重试')
setHasMore(false)
} finally {
setLoading(false)
}
},
[PAGE_SIZE, loading, riderId, riderTabs, tabIndex]
)
const reloadMore = useCallback(async () => {
if (loading || !hasMore) return
await reload(false)
}, [hasMore, loading, reload])
const openDeliverDialog = (order: GltTicketOrder, opts?: { mode?: DeliverConfirmMode }) => {
setDeliverOrder(order)
setDeliverImg(order.sendEndImg)
setDeliverConfirmMode(opts?.mode || (order.sendEndImg ? 'photoComplete' : 'waitCustomerConfirm'))
setDeliverDialogVisible(true)
}
const handleChooseDeliverImg = async () => {
try {
const file = await uploadFile()
setDeliverImg(file?.url)
} catch (e) {
console.error('上传送达照片失败:', e)
Taro.showToast({ title: '上传失败,请重试', icon: 'none' })
}
}
const handleStartDeliver = async (order: GltTicketOrder) => {
if (!order?.id) return
if (!canStartDeliver(order)) return
try {
await updateGltTicketOrder({
id: order.id,
deliveryStatus: 20,
sendStartTime: dayjs().format('YYYY-MM-DD HH:mm:ss')
})
Taro.showToast({ title: '已开始配送', icon: 'success' })
pageRef.current = 1
listRef.current = []
setList([])
setHasMore(true)
await reload(true)
} catch (e) {
console.error('开始配送失败:', e)
Taro.showToast({ title: '开始配送失败', icon: 'none' })
}
}
const handleConfirmDelivered = async () => {
if (!deliverOrder?.id) return
if (deliverSubmitting) return
if (deliverConfirmMode === 'photoComplete' && !deliverImg) {
Taro.showToast({ title: '请先拍照/上传送达照片', icon: 'none' })
return
}
setDeliverSubmitting(true)
try {
const now = dayjs().format('YYYY-MM-DD HH:mm:ss')
// 送达时间:首次“确认送达”写入;补传照片时不要覆盖原送达时间
const deliveredAt = deliverOrder.sendEndTime || now
// - waitCustomerConfirm只标记“已送达”进入待客户确认
// - photoComplete拍照留档后可直接完成是否允许由后端策略决定
const payload: GltTicketOrder =
deliverConfirmMode === 'photoComplete'
? {
id: deliverOrder.id,
deliveryStatus: 40,
sendEndTime: deliveredAt,
sendEndImg: deliverImg,
receiveConfirmTime: now,
receiveConfirmType: 20
}
: {
id: deliverOrder.id,
deliveryStatus: 30,
sendEndTime: deliveredAt,
sendEndImg: deliverImg
}
await updateGltTicketOrder(payload)
Taro.showToast({ title: '已确认送达', icon: 'success' })
setDeliverDialogVisible(false)
setDeliverOrder(null)
setDeliverImg(undefined)
setDeliverConfirmMode('photoComplete')
pageRef.current = 1
listRef.current = []
setList([])
setHasMore(true)
await reload(true)
} catch (e) {
console.error('确认送达失败:', e)
Taro.showToast({ title: '确认送达失败', icon: 'none' })
} finally {
setDeliverSubmitting(false)
}
}
useEffect(() => {
listRef.current = list
}, [list])
useDidShow(() => {
pageRef.current = 1
listRef.current = []
setList([])
setHasMore(true)
void reload(true)
// eslint-disable-next-line react-hooks/exhaustive-deps
})
useEffect(() => {
pageRef.current = 1
listRef.current = []
setList([])
setHasMore(true)
void reload(true)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tabIndex, riderId])
if (!riderId) {
return (
<View className="bg-gray-50 min-h-screen p-4">
<Text></Text>
</View>
)
}
const displayList = filterByTab(list)
return (
<View className="bg-gray-50 min-h-screen">
<View>
<Tabs
align="left"
className="fixed left-0"
style={{zIndex: 998, borderBottom: '1px solid #e5e5e5'}}
tabStyle={{backgroundColor: '#ffffff'}}
value={tabIndex}
onChange={(paneKey) => setTabIndex(Number(paneKey))}
>
{riderTabs.map(t => (
<TabPane key={t.index} title={loading && tabIndex === t.index ? `${t.title}...` : t.title}></TabPane>
))}
</Tabs>
<PullToRefresh
onRefresh={async () => {
pageRef.current = 1
listRef.current = []
setList([])
setHasMore(true)
await reload(true)
}}
headHeight={60}
>
<View
style={{ height: '84vh', width: '100%', padding: '0', overflowY: 'auto', overflowX: 'hidden' }}
id="rider-order-scroll"
>
{error ? (
<View className="flex flex-col items-center justify-center h-64">
<Text className="text-gray-500 mb-4">{error}</Text>
<Button size="small" type="primary" onClick={() => reload(true)}>
</Button>
</View>
) : (
<InfiniteLoading
target="rider-order-scroll"
hasMore={hasMore}
onLoadMore={reloadMore}
loadingText={
<View className="flex justify-center items-center py-4">
<Loading />
<View className="ml-2">...</View>
</View>
}
loadMoreText={
displayList.length === 0 ? (
<Empty style={{ backgroundColor: 'transparent' }} description="暂无配送订单" />
) : (
<View className="h-24 text-center text-gray-500"></View>
)
}
>
{displayList.map(o => {
const timeText = o.createTime ? dayjs(o.createTime).format('YYYY-MM-DD HH:mm') : '-'
const addr = o.address || (o.addressId ? `地址ID${o.addressId}` : '-')
const remark = o.buyerRemarks || o.comments || ''
const qty = Number(o.totalNum || 0)
const flow1Done = !!o.riderId
const flow2Done = !!o.sendStartTime || (typeof o.deliveryStatus === 'number' && o.deliveryStatus >= 20)
const flow3Done = !!o.sendEndTime || (typeof o.deliveryStatus === 'number' && o.deliveryStatus >= 30)
const flow4Done = !!o.receiveConfirmTime || o.deliveryStatus === 40
const phoneToCall = o.phone
const storePhone = o.storePhone
const pickupName = o.warehouseName || o.storeName
const pickupAddr = o.warehouseAddress || o.storeAddress
return (
<Cell key={String(o.id)} style={{ padding: '16px' }}>
<View className="w-full">
<View className="flex justify-between items-center">
<Text className="text-gray-800 font-bold text-sm">
{o.userTicketId ? `票号#${o.userTicketId}` : '送水订单'}
</Text>
<Text className={`${getOrderStatusColor(o)} text-sm font-medium`}>{getOrderStatusText(o)}</Text>
</View>
<View className="text-gray-400 text-xs mt-1">{timeText}</View>
<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>
</View>
<View className="text-sm text-gray-700 mt-1">
<Text className="text-gray-500"></Text>
<Text>{addr}</Text>
</View>
{!!remark && (
<View className="text-sm text-gray-700 mt-1">
<Text className="text-gray-500"></Text>
<Text>{remark}</Text>
</View>
)}
<View className="text-sm text-gray-700 mt-1">
<Text className="text-gray-500"></Text>
<Text>{Number.isFinite(qty) ? qty : '-'}</Text>
<Text className="text-gray-500 ml-3"></Text>
<Text>{o.price || '-'}</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>
<View className="text-sm text-gray-700 mt-1">
<Text className="text-gray-500"></Text>
<Text>{pickupName || '-'}</Text>
</View>
<View className="text-sm text-gray-700 mt-1">
<Text className="text-gray-500"></Text>
<Text>{pickupAddr || '-'}</Text>
</View>
{!!o.sendStartTime && (
<View className="text-sm text-gray-700 mt-1">
<Text className="text-gray-500"></Text>
<Text>{dayjs(o.sendStartTime).format('YYYY-MM-DD HH:mm')}</Text>
</View>
)}
{!!o.sendEndTime && (
<View className="text-sm text-gray-700 mt-1">
<Text className="text-gray-500"></Text>
<Text>{dayjs(o.sendEndTime).format('YYYY-MM-DD HH:mm')}</Text>
</View>
)}
{!!o.receiveConfirmTime && (
<View className="text-sm text-gray-700 mt-1">
<Text className="text-gray-500"></Text>
<Text>{dayjs(o.receiveConfirmTime).format('YYYY-MM-DD HH:mm')}</Text>
</View>
)}
{o.sendEndImg ? (
<View className="text-sm text-gray-700 mt-2">
<Text className="text-gray-500"></Text>
<View className="mt-2">
<Image src={o.sendEndImg} width="100%" height="120" />
</View>
</View>
) : null}
</View>
{/* 配送流程 */}
<View className="mt-3 bg-gray-50 rounded-lg p-2 text-xs">
<Text className="text-gray-600"></Text>
<Text className={flow1Done ? 'text-green-600 font-medium' : 'text-gray-400'}>1 </Text>
<Text className="mx-1 text-gray-400">{'>'}</Text>
<Text className={flow2Done ? 'text-blue-600 font-medium' : 'text-gray-400'}>2 </Text>
<Text className="mx-1 text-gray-400">{'>'}</Text>
<Text className={flow3Done ? 'text-purple-600 font-medium' : 'text-gray-400'}>3 </Text>
<Text className="mx-1 text-gray-400">{'>'}</Text>
<Text className={flow4Done ? 'text-green-600 font-medium' : 'text-gray-400'}>4 </Text>
</View>
<View className="mt-3 flex justify-end">
<Space>
{!!phoneToCall && (
<Button
size="small"
onClick={e => {
e.stopPropagation()
Taro.makePhoneCall({ phoneNumber: phoneToCall })
}}
>
</Button>
)}
{!!addr && addr !== '-' && (
<Button
size="small"
onClick={e => {
e.stopPropagation()
void Taro.setClipboardData({ data: addr })
Taro.showToast({ title: '地址已复制', icon: 'none' })
}}
>
</Button>
)}
{!!storePhone && (
<Button
size="small"
onClick={e => {
e.stopPropagation()
Taro.makePhoneCall({ phoneNumber: storePhone })
}}
>
</Button>
)}
{canStartDeliver(o) && (
<Button
size="small"
onClick={e => {
e.stopPropagation()
void handleStartDeliver(o)
}}
>
</Button>
)}
{canConfirmDelivered(o) && (
<Button
size="small"
type="primary"
onClick={e => {
e.stopPropagation()
openDeliverDialog(o, { mode: 'waitCustomerConfirm' })
}}
>
</Button>
)}
{canCompleteByPhoto(o) && (
<Button
size="small"
type="primary"
onClick={e => {
e.stopPropagation()
openDeliverDialog(o, { mode: 'photoComplete' })
}}
>
</Button>
)}
</Space>
</View>
</View>
</Cell>
)
})}
</InfiniteLoading>
)}
</View>
</PullToRefresh>
</View>
<Dialog
title="确认送达"
visible={deliverDialogVisible}
confirmText={
deliverSubmitting
? '提交中...'
: deliverConfirmMode === 'photoComplete'
? '拍照完成'
: '确认送达'
}
cancelText="取消"
onConfirm={handleConfirmDelivered}
onCancel={() => {
if (deliverSubmitting) return
setDeliverDialogVisible(false)
setDeliverOrder(null)
setDeliverImg(undefined)
setDeliverConfirmMode('photoComplete')
}}
>
<View className="text-sm text-gray-700">
<View></View>
<View className="mt-3">
<RadioGroup value={deliverConfirmMode} onChange={v => setDeliverConfirmMode(v as DeliverConfirmMode)}>
<Radio value="photoComplete"></Radio>
<Radio value="waitCustomerConfirm"></Radio>
</RadioGroup>
</View>
<View className="mt-3">
<Button size="small" onClick={handleChooseDeliverImg}>
{deliverImg ? '重新拍照/上传' : '拍照/上传'}
</Button>
</View>
{deliverImg && (
<View className="mt-3">
<Image src={deliverImg} width="100%" height="120" />
<View className="mt-2 flex justify-end">
<Button size="small" onClick={() => setDeliverImg(undefined)}>
</Button>
</View>
</View>
)}
<View className="mt-3 text-xs text-gray-500">
</View>
</View>
</Dialog>
</View>
)
}

Some files were not shown because too many files have changed in this diff Show More