Compare commits

...

41 Commits

Author SHA1 Message Date
8d46016d8b feat(passport): 优化手机号授权流程及超时处理机制
- 新增手机号授权等待状态及定时器,防止重复触发授权操作
- 增加手机号授权超时弹窗提示,支持切换短信验证码登录
- 修改手机号授权冷却时间由3000ms调整为8000ms,减少频繁操作
- 控制手机号授权按钮状态,确保用户协议同意且无冷却或等待中才能操作
- 清理手机号授权等待定时器,防止内存泄露
- 调整加载状态时按钮UI表现,提高用户交互体验
2026-07-16 21:33:13 +08:00
46298c011f fix(login): 恢复手机号快捷登录功能,避免带旧Token导致失败
- 将 login.tsx 和 register.tsx 中登录请求从封装的 request 工具改回 Taro.request
- 取消登录请求中自动注入 Authorization 头,避免携带过期/旧 token 导致认证失败
- 调整登录接口响应数据解析,适配 Taro.request 的数据结构
- fetchUserInfo 改回使用 Taro.request,手动携带 Authorization 头部
- 添加全局 getPhoneNumber 调用冷却机制,防止频繁调用导致微信报错
- 登录页新增手机授权冷却
2026-07-16 17:01:17 +08:00
ce1e2bd4dd refactor(privacy): 调整隐私授权触发逻辑,登录页取消主动预检
- 移除登录页和注册页 `useDidShow` 中主动调用 `ensurePrivacyAuthorized()` 预检的逻辑
- 登录页不再主动弹出隐私协议弹窗,由微信在用户点击手机号登录按钮时强制触发授权
- 门店订单管理页面调用 `chooseProofImage` 前新增 `ensurePrivacyAuthorized()` 预检,确保上传凭证时授权
- 修改隐私弹窗文案为更通用描述,避免误导用户以为是相册授权
- 保留全局 `onNeedPrivacyAuthorization` 通过 `PrivacyModal` 处理,适配微信基础库强制机制
- 统一门店商品管理上传使用已内置预检的 `uploadFile()` 接口调用流程
2026-07-16 01:40:16 +08:00
1b72ae37a5 feat(privacy): 修复微信隐私协议授权流程
- 新增全局隐私协议授权管理器 privacyManager,统一管理弹窗显示和授权结果
- 用 PrivacyModal 组件替代原先 Taro.showModal 实现,使用 open-type="agreePrivacyAuthorization" 的 Button 触发微信认可的隐私授权
- 在 app.tsx 中注册 onNeedPrivacyAuthorization 回调,通过 privacyManager.show 弹出隐私弹窗
- 登录和注册页面 useDidShow 钩子调用 ensurePrivacyAuthorized 函数预检隐私授权状态,避免授权失败
- 修复登录/注册接口调用,统一使用 request.post + SERVER_API_URL,修正错误接口地址及导入问题
- 新增隐私弹窗样式和逻辑,实现用户同意或拒绝后的正确流程处理
- 解决微信基础库 3.16.1+ 强制隐私协议授权导致敏感 API 调用失败问题
2026-07-16 01:11:54 +08:00
af90bd7a55 refactor(pay): 优化登录及获取 openid 逻辑
- 替换 getOpenId 为 getWxOpenId,调整参数和返回值处理
- 修正 openid 变量赋值及异常判断逻辑
- loginByOpenId 调用改为更健壮的空值判断
- 捕获 loginByOpenId 异常,优化异常处理及用户引导
- 保证登录异常不阻断支付流程,并提示用户先注册/登录
2026-07-16 00:45:01 +08:00
ca78e73f51 feat(pay): 新增小程序支付页面及相关隐私协议处理
- 新增支付页面组件,实现JSAPI支付流程和多状态页面展示
- 配置支付页面导航条样式和标题
- app.config.ts中添加支付页面路径
- app.tsx中新增微信隐私协议授权弹窗,拦截敏感API授权流程
- api/system/file中导出ensurePrivacyAuthorized用于隐私授权预检
- 登录页和注册页新增useDidShow钩子调用ensurePrivacyAuthorized预检隐私协议
- 登录和注册接口请求迁移至统一request封装,修正响应解构
- 支付流程中新增获取openid和自动登录逻辑,支持未注册用户跳转登录
- 支付成功后通知后端确认并自动跳转到已购产品页面
- 优化支付状态判断逻辑,正确识别支付和订单状态
- 添加错误处理和用户提示,提升支付体验安全性与稳定性
2026-07-16 00:26:42 +08:00
549e8258b6 fix(user): 修复订单入口tab错位并统一购物车角标样式
- 将订单快捷入口和我的订单tab索引改为tabIndex,修正页面跳转参数使用`?tab=`传递
- 修复因状态值与tab索引不匹配导致的订单状态页错位问题
- 更新`user.tsx`和`index.tsx`中相关订单tab配置,确保键名和逻辑一致
- 购物车入口数量角标由手写样式改为Badge组件,去除白色边框样式
- 购物车角标与用户订单图标角标样式保持一致,使用绝对定位调整位置
- `navigateTo`跳转及未登录重定向均支持传递正确的tab索引参数
2026-07-15 23:51:37 +08:00
fbaa5c32bc fix(tabBar): 修正“订单”页改为非tabBar页面的导航链接
- 将原来 switchTab 跳转“订单”页改为 navigateTo,以适配非 tabBar 页面
- 统一更新多个文件中关于订单页的跳转方式共计 6 处
- 更新 tabBar 配置,从包含“订单”页改为实际的四个 tabBar 页面
- 修正 isTabBarUrl 相关判断,去除不存在或非 tabBar 路径
- 调整多个 tabBar 判断列表,保证逻辑一致性与正确性
- 修改购物车页图片压缩宽度加入限制,优化资源加载
- 更新订单列表页注释,去除“tabBar 页”描述
- 备注遗留问题,部分积分页跳转仍用错误方式,待进一步修复
2026-07-15 23:42:27 +08:00
c12bba66eb feat(common): 抽取通用 Badge 和 ArrowRight 组件并统一替换内联角标
- 新增 Badge 组件,替代项目内多处重复数字角标代码,支持 count、max、颜色、尺寸等配置
- 新增 ArrowRight 组件,替代项目内多处 '>' 文本箭头,采用 CSS 绘制,支持颜色和尺寸配置
- user、index 和 store页面相关角标已改用 Badge 组件,简化代码和统一样式
- user 页面箭头符号替换为 ArrowRight 组件,提升图标一致性和样式可控性
- app.config.ts 调整 tabbar 配置,将“订单”页替换为“购物车”页
2026-07-15 23:29:20 +08:00
b8fca7bed2 fix(user): 修正数量角标为数字时的显示逻辑
- 去除对 item.count 存在性的判断,改为直接将 count 转换为数字后判断
- 确保当 count 为字符串数字且大于0时角标正常显示
- 优化数量角标的渲染条件,提高代码健壮性
2026-07-15 23:10:21 +08:00
909e0fd43f fix(user): 优化我的订单角标样式和0值显示
- 角标位置从 top-0 right-2 调整为 -top-1 -right-1,使小红点更贴近右上角边缘
- 角标尺寸改为 min-w-[16px] h-4 并固定字体大小,保证单数字圆形,多数字椭圆形
- 0 的判断改用 Number(item.count) > 0,防止后端字符串形式的 "0" 被误判显示
- 0 数量时角标不再渲染,避免显示多余的“0”数字
2026-07-15 23:02:15 +08:00
ad616943c8 fix(user): 修复「我的」页订单数量统计显示为 0 的问题
- 修正 UserOrderStats 接口字段与后端 DTO 完全对齐
- getUserOrderStats() 读取后端真实字段,数值使用 Number(x) 兜底
- 「我的订单」快捷入口 count 和 status 与 OrderListStatus 枚举对齐
- localStorage 缓存 key 升级至 v3,避免旧缓存干扰冷启动渲染
- 保证所有跳转订单列表的入口传递正确 status 参数,防止跳转错误的 tab
2026-07-15 22:52:08 +08:00
501e79eed7 fix(index): 修复首页轮播图显示与高度自适应问题
- 解决首页轮播图仅显示第一张图片的问题,支持广告位多图展开显示
- 通过 flatMap 展开广告位所有图片为独立轮播项,兼容单图老数据
- 轮播图点击跳转逻辑按广告位路径保持不变
- 获取轮播容器实际宽度,计算轮播高度自适应,替代固定 160px 高度
- 根据后台广告位宽高比计算轮播图展示高度,无效数据时回退到默认高度
- 轮播容器新增类名便于 DOM 选择和尺寸测量
- 轮播图及占位区高度均采用动态计算结果,适配不同设备屏幕宽度
2026-07-15 22:30:02 +08:00
4d646b3fd6 feat(passport): 重构短信登录页并提供手机号授权降级入口
- 修复购物车数据库缺失 spec_info 字段问题,新增迁移脚本
- 登录页处理手机号授权失败,按错误类型弹窗引导用户改用短信登录
- 登录页新增长期显示的「使用短信验证码登录」入口,提升用户降级体验
- 注册页同步改造,去除微信小程序环境下短信按钮隐藏问题
- 短信登录页整体 UI 重构,弃用 NutUI 组件,采用原生组件实现
- 短信登录页新增渐变背景、浮动光圈、圆形 logo、卡片输入区设计
- 实现手机号输入、验证码发送、倒计时及登录校验逻辑全覆盖
- 短信登录页新增返回微信快捷登录链接,避免用户被困登录流程
- 新增配套样式文件,实现多重动效及分阶段入场动画
- 保证类型检查和编译无误,增加设计稿 HTML 预览文件
2026-07-15 22:19:22 +08:00
b47ea6bd90 feat(passport): 重构短信登录页并提供手机号授权降级入口
- 修复购物车数据库缺失 spec_info 字段问题,新增迁移脚本
- 登录页处理手机号授权失败,按错误类型弹窗引导用户改用短信登录
- 登录页新增长期显示的「使用短信验证码登录」入口,提升用户降级体验
- 注册页同步改造,去除微信小程序环境下短信按钮隐藏问题
- 短信登录页整体 UI 重构,弃用 NutUI 组件,采用原生组件实现
- 短信登录页新增渐变背景、浮动光圈、圆形 logo、卡片输入区设计
- 实现手机号输入、验证码发送、倒计时及登录校验逻辑全覆盖
- 短信登录页新增返回微信快捷登录链接,避免用户被困登录流程
- 新增配套样式文件,实现多重动效及分阶段入场动画
- 保证类型检查和编译无误,增加设计稿 HTML 预览文件
2026-07-15 18:55:12 +08:00
fad7c5f58e fix(cart): 修复购物车同一商品重复显示问题
- 后端新增 ShopCartService.addToCart 方法,按 user_id+goods_id+sku_id 判重合并数量
- 修改 ShopCartController.save 调用为 addToCart,避免重复插入购物车记录
- sku_id 为 null 或 0 时,判重逻辑支持 null-safe 匹配
- 提供历史数据清理脚本 sql/shop_cart_merge_duplicates.sql 合并重复购物车数据
- 更新文档记录购物车合并逻辑及相关接口说明
- 编译通过,后端服务需重启生效
2026-07-15 17:07:42 +08:00
b45a5722eb feat(order-logistics): 改造订单详情物流查看功能
- 后端 ShopOrderServiceImpl.getByIdRel 填充发货单 shopOrderDelivery 信息
- 前端新增 ShopOrderDelivery 类型,ShopOrder 接口添加 shopOrderDelivery 字段
- 物流页支持仅传 orderId 即可加载订单及发货单,无需必须传 expressNo 和 expressCompany
- 物流轨迹查询条件调整,失败时展示发货单信息,不阻塞页面
- 新增发货信息卡展示发货人、联系方式、地址、物流单号、发货时间及发货方式
- 新增收货信息卡,展示订单收货人名称、电话、地址
- 订单列表“待付款”Tab修复状态显示为“待付款”,兼容线下付款订单逻辑
- 组件OrderCard中去除isOffline判断,修正订单状态文案一致性
- 物流详情页复制功能优化,支持复制正确的物流单号
- 优化快递状态提示、时间格式化显示及页面空状态处理
- 电话联系客服按钮事件名称修正和调用逻辑优化
2026-07-15 16:38:24 +08:00
af645c058e fix(shop): 修复首页VIP价格显示及后端价格字段问题
- 首页index.tsx中接入useVipStatus,修复热销推荐价格未显示dealerPrice的问题
- 新增getHotDisplayPrice函数,VIP时显示dealerPrice及划线原价,并添加VIP金色角标
- 后端OrderCreateRequest.OrderGoodsItem增加price字段及校验,解决后端DTO缺失price字段问题
- 重构OrderBusinessService中价格校验逻辑,确保正确优先级及SKU库存校验
- 确认价格字段约定,前端传dealerPrice给后端下单
- Maven编译通过,后端需重启服务生效
2026-07-15 14:50:06 +08:00
ca6ba52993 feat(user): 修改帮助中心联系客服入口为直连微信客服
- 替换原跳转页面逻辑,使用微信 Button 组件直接调起微信客服
- 设置 Button 关键属性 openType、sessionFrom、showMessageCard、sendMessageTitle 和 onContact 回调
- 重置 Button 样式保持原视觉,调整高度、内边距和圆角
- 从 @tarojs/components 中引入 Button 组件
- 提醒微信客服 Button 在开发者工具无效,需真机预览测试
2026-07-15 14:27:15 +08:00
6dbe128ddd feat(admin): 新增后台管理端商品浏览记录页面和删除接口
- 新增后端 ShopGoodsBrowseController 的 /admin/{id} 删除接口,支持管理端删除
- 原删除接口 /{id} 继续限定当前用户权限
- 新增前端管理端页面及相关类型和接口文件,实现浏览记录的列表展示和搜索功能
- 管理后台菜单通过 sys_menu 表动态配置,新增商品浏览记录菜单项
- 前端请求后端返回数据结构规范调整,使用 res.data.code 和 res.data.data
- 后端编译通过,前端类型校验无错误
- 需后端重新部署并配置后台菜单实现功能

fix(privacy): 简化微信隐私协议弹窗逻辑

- 当用户已在登录页勾选同意隐私协议时,不再弹出授权确认窗口
- 在 wxAny.onNeedPrivacyAuthorization 中直接 resolve 同意,提升用户体验
2026-07-15 13:32:24 +08:00
3f1931e420 feat(address): 修复默认地址唯一性并新增用户浏览记录功能
- 后端新增 clearDefault 方法,保存默认地址前清除其他默认状态,避免多默认地址
- 前端地址编辑页保存成功后若为默认,调用 setDefaultAddress 触发后端清理逻辑
- useAddress hook 加入 ensureSingleDefault,前端保证地址数据中只有一个默认地址
- 新增 shop_goods_browse 表及相关实体、Mapper、服务和控制器,实现用户商品浏览记录功能
- 商品详情页接入浏览记录上报,支持登录用户浏览记录服务端同步
- 浏览历史页支持登录用户服务端分页查询,未登录用户使用本地存储兜底
- 浏览历史页支持浏览记录单条删除、清空、上拉加载和下拉刷新功能
- 调整用户中心入口,恢复浏览历史菜单项显示
- 文档和内存文件更新,包括数据库建表 SQL、接口定义和项目编译说明
2026-07-15 12:52:25 +08:00
3955e64844 fix(address-edit): 修复地址编辑页粘贴文本智能识别问题
- 优化姓名匹配优先查找标签“收件人”等后面的名字,提升识别准确率
- 清理详细地址中标签文本,去除“收件人”“手机号码”“所在地区”“详细地址”等冗余字段
- 保留街道及详细地址完整信息,避免误删有效内容
- 类型定义中新增 country,调整 isDefault 类型为 boolean,增加 address 字段
- 使用 Python 测试验证解析效果,确保姓名、电话、省市区和详细地址准确提取
- 通过类型检查无误,提升代码健壮性和维护便利性
2026-07-15 11:10:29 +08:00
81324cbb82 fix(index): 修复首页铃铛权限漏洞和非门店用户新订单提醒
- 针对铃铛红点和计数,改为仅门店角色可见
- 新订单提醒回调增加角色校验,非门店不弹Toast和震动
- 订单管理页增加访问控制,非门店自动跳回防止越权访问
- 保留消息点击事件中对角色的原有区分逻辑
- 弹窗宽度调整及登录同步逻辑未变动
2026-07-15 03:24:53 +08:00
ad3c209fe5 feat(order): 优化订单备注结构及线下付款状态显示
- 门店订单修改金额时,修改原因从追加 comments 改为保存 merchantRemarks 字段
- ShopOrder 和 OrderCreateRequest 接口新增 buyerRemarks 和 merchantRemarks 字段,实现订单备注字段分离
- 结算页由 comments 注入变更为 buyerRemarks,门店订单列表新增买家备注展示块
- 修复线下付款订单状态显示:统一线下付款(payType=9)与货到付款(payType=8)的待发货状态显示
- 取消按钮逻辑优化,已付款订单不显示取消按钮,文案由"关闭订单"改为"取消订单"
- 订单列表和详情页弹窗标题及提示文案更新,确保状态描述准确,提升用户体验
2026-07-15 03:07:35 +08:00
e47a0d3d3e feat(shop): 优化VIP价格支持及线下付款兼容
- 结算页新增线下付款异常友好提示,避免支付配置中appid缺失错误
- 后端修复支付类型判断,防止线下付款仍试图创建微信支付订单
- 结算页默认支付方式调整为线下付款,货到付款选项注释
- 新增门店订单“修改金额”功能,支持弹窗输入新金额和修改原因
- 订单修改支持追加操作记录,接口复用updateShopOrder,无需新接口
- 重构多处页面和组件,使用响应式useVipStatus替代同步isVipMember缓存
- 新增OrderGoodsItem.price字段,下单时传VIP dealerPrice确保价格准确
- 购物车、商品详情、分类页、SkuSelector、商品卡片等支持动态VIP价格展示
- 修改购物车上下文calcPrice函数,响应VIP状态变更自动更新价格显示
- 提升前端VIP状态管理一致性,防止过期缓存导致VIP特权价格显示异常
- 验证构建通过,后端服务重启后生效线下付款修复和订单修改功能
2026-07-15 02:45:19 +08:00
747a2a1d3a feat(auth): 优化隐藏登录弹窗点击逻辑和样式
- 将点击步数提示从第4次调整到第6次触发,增强用户体验
- 弹窗宽度由320px调整为340px,更加合理显示内容
- 弹窗布局对齐sms-login.tsx,验证码输入和按钮布局调整,修复小程序Button显示问题
- 登录按钮样式改用className设置宽度和圆角,取消block和shape属性
- 点击版权信息累计11次弹出登录弹窗,未达次数显示剩余步数提示
- 登录成功后同步React登录态,保持状态一致
2026-07-15 01:45:45 +08:00
38a7b4320d refactor(user): 优化开发者登录弹窗及点击交互逻辑
- 移除点击步数提示和震动反馈,改为静默计数,点击11次后弹出登录弹窗
- 将弹窗宽度从300px调整为320px,增加内边距提升视觉效果
- 登录按钮由block属性改为style设置宽度100%,确保按钮样式一致
- 优化弹窗标题和关闭按钮样式,提高界面层次感
- 调整手机号输入框和布局间距,提升整体排版协调性
2026-07-15 01:40:36 +08:00
19ca568d60 feat(user): 添加关于页隐藏开发者登录功能
- 在底部版权信息连续点击11次时弹出开发者登录弹窗
- 登录方式采用手机号加短信验证码,复用现有登录API
- 点击计数器使用useRef实现,3秒无操作自动重置
- 第4次及以后点击显示剩余步数提示,前3次触发轻震动反馈
- 登录成功后同步React登录态,关闭弹窗并重置表单
- 弹窗中使用NutUI组件,实现验证码发送和倒计时功能
2026-07-15 01:25:04 +08:00
1e4549095a fix(shop): 调整结算页默认支付方式为线下付款
- 注释掉了货到付款(id=8)支付方式选项
- 将默认支付方式由货到付款改为线下付款(id=9)
- 更新支付方式相关状态默认值为线下付款
- 确保支付方式选项列表仅保留线下付款项
2026-07-15 00:50:24 +08:00
6dafbf47c9 fix(privacy): 修复微信隐私协议chooseImage报错问题
- 调整requiredPrivateInfos字段,仅保留位置类API白名单
- 在uploadFile中添加ensurePrivacyAuthorized预检逻辑,避免权限缺失导致失败
- 在app.tsx的useLaunch生命周期中注册Taro.onNeedPrivacyAuthorization回调,实现自定义协议弹窗交互
- 提示需在小程序管理后台配置“开发者收集你的相册/摄像头”声明,避免首次调用弹窗
- 警告requiredPrivateInfos不可添加非位置类API,防止app.json解析失败
- 说明直接调用Taro.chooseImage的页面需单独添加预检措施
2026-07-14 23:46:29 +08:00
a353bc9ea8 fix(privacy): 修复微信隐私协议导致的chooseImage错误
- 在 app.config.ts 的requiredPrivateInfos中添加chooseImage和chooseMedia权限声明
- 在上传文件接口uploadFile中调用ensurePrivacyAuthorized函数,预先检查用户隐私授权
- 在 app.tsx 的 useLaunch 钩子中注册 onNeedPrivacyAuthorization 回调,自定义弹窗提示用户协议
- ensurePrivacyAuthorized兼容旧版本微信基础库,未提供隐私授权函数时默认放行
- 更新文档说明隐私协议错误码errno:112的原因及修复方案
- 提醒绕过uploadFile调用chooseImage的页面需单独添加隐私授权预检机制
2026-07-14 23:26:49 +08:00
1fcfcdec73 feat(auth): 实现禁用用户拦截机制防止禁用用户访问
- 新增工具函数 isUserDisabled 判断用户是否被禁用
- 新增 blockDisabledUser 函数拦截禁用用户,清除登录态弹窗提示并跳转登录页
- UserContext 启动时异步校验用户状态,禁用则拦截并退出
- UserContext 的 refreshUser、syncFromStorage、loginUser 接口均增加禁用检查
- 登录相关 API 登录前校验用户状态,禁用时拒绝登录
- 登录页面保存登录态前校验,禁用用户弹窗提示阻断登录流程
- 用户状态定义:status 0 为正常,1 为禁用
- 新增用户管理页支持查询和管理用户状态
- 文档中补充用户状态与禁用机制说明及拦截层级介绍
2026-07-14 23:16:49 +08:00
beabbaefc0 feat(auth): 实现禁用用户拦截机制防止禁用用户访问
- 新增工具函数 isUserDisabled 判断用户是否被禁用
- 新增 blockDisabledUser 函数拦截禁用用户,清除登录态弹窗提示并跳转登录页
- UserContext 启动时异步校验用户状态,禁用则拦截并退出
- UserContext 的 refreshUser、syncFromStorage、loginUser 接口均增加禁用检查
- 登录相关 API 登录前校验用户状态,禁用时拒绝登录
- 登录页面保存登录态前校验,禁用用户弹窗提示阻断登录流程
- 用户状态定义:status 0 为正常,1 为禁用
- 新增用户管理页支持查询和管理用户状态
- 文档中补充用户状态与禁用机制说明及拦截层级介绍
2026-07-14 23:16:23 +08:00
c870e75b3b feat(auth): 实现禁用用户拦截机制防止禁用用户访问
- 新增工具函数 isUserDisabled 判断用户是否被禁用
- 新增 blockDisabledUser 函数拦截禁用用户,清除登录态弹窗提示并跳转登录页
- UserContext 启动时异步校验用户状态,禁用则拦截并退出
- UserContext 的 refreshUser、syncFromStorage、loginUser 接口均增加禁用检查
- 登录相关 API 登录前校验用户状态,禁用时拒绝登录
- 登录页面保存登录态前校验,禁用用户弹窗提示阻断登录流程
- 用户状态定义:status 0 为正常,1 为禁用
- 新增用户管理页支持查询和管理用户状态
- 文档中补充用户状态与禁用机制说明及拦截层级介绍
2026-07-14 23:11:29 +08:00
dc123d79a2 fix(about): 更新公司简介及相关显示内容
- 将公司名称由“玉林市玉州区鑫龙家电经营部”更新为“鑫龙商贸电器”
- 修改头部品牌显示文字对应新公司名称
- 更新公司简介内容,体现新的品牌及业务范围
- 更改底部版权信息,匹配新公司名称
2026-07-14 23:02:12 +08:00
4c059522a2 feat(store): 新增门店中心用户管理页面
- 创建用户管理新页面,支持分页查询用户列表
- 实现昵称/手机号搜索及全部/正常/已禁用Tab筛选
- 增加用户详情弹窗展示详细信息
- 添加禁用/启用用户功能及确认弹窗
- 路由注册新增用户管理路径
- 门店中心首页添加用户管理入口卡片,蓝色主题带👥图标
- 头像支持图片压缩展示,支持无头像显示默认图标
- 优化用户状态显示,颜色区分正常与已禁用状态
- 详情弹窗覆盖手机号、真实姓名、性别、会员等级、余额、积分、邮箱等信息
- 支持列表和详情弹窗中用户状态同步更新
2026-07-14 23:00:09 +08:00
0ca26801b1 refactor(store): 重命名上传轮播图函数防止命名冲突
- 将 handleAddBanner 函数重命名为 handleAddUploadBanner
- 更新相关点击事件绑定以使用新的函数名
- 保持上传状态逻辑不变,提升代码可读性和维护性
2026-07-14 22:53:38 +08:00
711e588cfe feat(store): 新增门店商品管理及订单图片修复
- 门店商品页新增商品新增功能,包括名称、分类、图片、价格、库存等信息录入
- 支持新增商品轮播图上传、删除及表单校验
- 获取当前登录店员门店ID并绑定新增商品,实现门店商品的创建
- 门店订单页修复商品图片字段,调整字段名为模型匹配的image、spec和totalNum
- 订单页商品图片添加压缩处理,优化图片展示尺寸与加载速度
2026-07-14 22:50:12 +08:00
8d6c0fda69 feat(order): 在待发货订单列表增加关闭订单功能
- 在 OrderCard 组件中新增 onCloseOrder 可选回调属性
- 待发货且未取消的订单显示“关闭订单”按钮
- 点击按钮阻止事件冒泡,避免触发卡片跳转
- 列表页实现 handleCloseOrder,弹窗确认后调用 updateShopOrder
- 关闭订单后显示成功提示并刷新当前列表数据
- 复用订单详情页取消订单的状态更新逻辑,保持一致性
2026-07-14 22:30:04 +08:00
f878ea146e feat(order): 在待发货订单列表增加关闭订单功能
- 在 OrderCard 组件中新增 onCloseOrder 可选回调属性
- 待发货且未取消的订单显示“关闭订单”按钮
- 点击按钮阻止事件冒泡,避免触发卡片跳转
- 列表页实现 handleCloseOrder,弹窗确认后调用 updateShopOrder
- 关闭订单后显示成功提示并刷新当前列表数据
- 复用订单详情页取消订单的状态更新逻辑,保持一致性
2026-07-14 22:25:33 +08:00
dfbcbcf615 feat(payment): 新增线下付款方式及相关订单状态支持
- 支付页面新增线下付款选项,支持微信转账付款方式
- 订单流程新增线下付款待确认(offline_pending)阶段
- 订单详情与订单卡片显示线下付款状态及提示信息
- 支付接口和模型增加线下付款支付方式(payType=9)支持
- 订单列表页区分线下付款待确认与待收款状态
- 地址编辑页修复智能识别按钮无法点击问题,调整布局避免被Textarea遮挡
2026-07-14 22:15:31 +08:00
67 changed files with 5183 additions and 785 deletions

View File

@@ -0,0 +1,20 @@
# 2025-07-15 工作日志
## 关于页面隐藏开发者登录
-`src/pages/user/about/index.tsx` 底部版权信息上添加了点击 11 次弹出隐藏登录弹窗的功能
- 登录方式:手机号 + 短信验证码,复用 `loginBySms` / `sendSmsCaptcha` APIfrom `@/api/passport/login`
- 点击计数使用 `useRef` 保证快速点击时的同步性,超过 3 秒未点击自动重置
- 步数提示从**第 6 次**点击开始显示Android 开发者模式风格)
- **(已更新)** 弹窗布局完全对齐 `sms-login.tsx` 写法:验证码行取消 `gap-2`/`flex-1` 包裹,改用 `flex items-center` 直接放 Input + Button登录按钮取消 `block`/`shape`/`style`,改用 `className='w-full rounded-lg p-2'` + 外层 `flex justify-center`,解决小程序中 Button 不显示的问题
- 弹窗宽度从 300px 加宽到 **340px**
- 登录成功后调用 `useUserContext().syncFromStorage()` 同步 React 登录态(`loginBySms` 内部已写入 storage 但不返回数据)
- 弹窗 UI 使用 NutUI `Input` + `Button`,带 60 秒倒计时
## 修复首页铃铛权限漏洞和非门店用户新订单提醒
- **问题1**:非门店角色用户也能通过铃铛进入订单管理页
- **问题2**新订单提醒红点badge + Toast对非门店用户也显示
- **修复**
1. `src/pages/index/index.tsx`:铃铛 badge 显示条件从 `isLoggedIn` 改为 `isClerk`(红点和计数仅门店角色可见)
2. `src/pages/index/index.tsx``onNewOrders` 回调增加 `isClerkRef.current` 检查,非门店角色不弹 Toast/震动
3. `src/pages/store/orders/index.tsx`:订单管理页增加访问控制,`getMyClerk()` 校验非门店角色自动跳回(防御性拦截)
- `handleMessageClick` 中已有 `isClerk` 判断(店员→订单管理,普通用户→消息通知页),无需修改

View File

@@ -1,4 +1,4 @@
- 修改 `src/pages/user/about/index.tsx`:根据截图将“广西金炜建设工程有限公司”的简介更新为“玉林市玉州区鑫龙家电经营部”相关内容,包括头部品牌、公司简介、核心业务、联系信息及版权。
- 修改 `src/pages/user/about/index.tsx`:根据截图将“广西金炜建设工程有限公司”的简介更新为“鑫龙商贸电器”相关内容,包括头部品牌、公司简介、核心业务、联系信息及版权。
## 门店中心功能实现

View File

@@ -1,294 +1,36 @@
## 购物车功能修复(2026-07-14
# 2026-07-14 工作日志
## 门店订单页商品图片修复
- 文件:`src/pages/store/orders/index.tsx`
- 问题:订单商品图片不显示,字段名与模型不匹配
- 修复:`coverImage``image``specInfo``spec``quantity``totalNum`(按 `ShopOrderGoods` 模型定义)
- 图片压缩:`getCompressedImageUrl(goods.image, { width: 80 })`,展示尺寸 w-16 h-1664px压缩宽度 80 够用
## 门店中心新增用户管理功能
- 新页面:`src/pages/store/users/index.tsx`
- 功能:分页查询用户列表、搜索(昵称/手机号、Tab 筛选(全部/正常/已禁用)、查看用户详情弹窗、禁用/启用用户
- 使用的 API`pageUsers``/system/user/page`)、`updateUserStatus``/system/user/status`,参数 userId + status0=正常 1=禁用)
- 入口:`src/pages/store/center/index.tsx` FEATURE_CARDS 中,在 VIP会员审核上方加了"用户管理"卡片(蓝色主题 👥 图标)
- 路由注册:`src/app.config.ts` 新增 `pages/store/users/index`
## 禁用用户拦截(防止被禁用用户仍可浏览小程序)
- 新增工具函数:`src/utils/auth.ts``isUserDisabled(user)``blockDisabledUser(user)`(清登录态+弹窗+跳转登录页)
- 拦截层级:
1. **UserContext 启动校验**`src/contexts/UserContext.tsx`init 时先用缓存快速显示,再异步调 `getUserInfo()` 校验 status若被禁用则 `blockDisabledUser` 拦截
2. **UserContext refreshUser / syncFromStorage / loginUser**:均加了 `isUserDisabled` 检查
3. **登录 API**`src/api/passport/login/index.ts``login()``loginBySms()``saveStorageByLoginUser` 前检查 status被禁用则 reject
4. **登录页面**`passport/login.tsx``passport/register.tsx``pages/store/login/index.tsx` 在保存登录态前检查 status被禁用则弹窗拦截
- 用户状态约定:`status === 0` 正常,`status === 1` 禁用(通过 `updateUserStatus(userId, status)` 接口控制)
## 微信隐私协议chooseImage errno:112修复
- 报错:`MiniProgramError {errMsg:"chooseImage:fail api scope is not declared in the privacy agreement", errno:112}`(基础库 3.16.1+ 强制)
- 影响范围:所有调用 `Taro.chooseImage` 的地方,本轮修复统一入口 `uploadFile`(商品管理页、订单评价、售后申请、门店订单都走这个)
- 修复:
1. **`src/api/system/file/index.ts`** — `uploadFile``ensurePrivacyAuthorized()` 预检(`getPrivacySetting` + `requirePrivacyAuthorize`
2. **`src/app.tsx`** — `useLaunch` 里注册 `Taro.onNeedPrivacyAuthorization` 回调,弹自定义 showModal 协议弹窗,同意 resolve({event:'agree'}) 拒绝 resolve({event:'disagree'})
3. **小程序管理后台**(必须手动去配)→ 设置 → 第三方设置 → 用户隐私保护指引 → 添加「开发者收集你的相册/摄像头」声明,否则首次调用仍会触发协议弹窗
- 旧基础库兼容:`typeof wxAny.requirePrivacyAuthorize !== 'function'` 时直接放行
- ⚠️ **易错点:`requiredPrivateInfos` 字段只接受位置类 API 白名单**chooseAddress/chooseLocation/choosePoi/getFuzzyLocation/getLocation/onLocationChange/startLocationUpdate/startLocationUpdateBackground不要加 `chooseImage`/`chooseMedia` 等非位置类,会导致 app.json 解析失败
- 后续若别的页面直接调 `Taro.chooseImage`(如 `evaluate/index.tsx``after-sale/apply/index.tsx``store/orders/index.tsx`),仍要单独加 `ensurePrivacyAuthorized` 预检
### 问题
购物车页面pages/shop/cart商品显示异常
- 商品名称/图片/价格信息缺失
- 加入购物车后无法正确显示
### 根因
`CartContext.refresh()` 方法在将后端 API 返回的购物车项映射为前端 `CartItem` 时,**遗漏了 `product`(商品详情)和 `sku`SKU 详情)嵌套对象的映射**。导致页面中 `item.product?.name/image/price``item.sku?.price/image` 均为 `undefined`UI 只能显示硬编码的默认值。
### 修改
1. **`src/contexts/CartContext.tsx`**(核心修复)
- `refresh()``mappedItems` 映射中补充 `product``sku` 字段
- 使用 `(item as any).product` / `(item as any).sku` 获取后端返回的嵌套对象
2. **`src/utils/image.ts`**(增强健壮性)
- 新增 `ensureFullUrl()` 辅助函数:相对路径自动补全 OSS 域名 `https://oss.wsdns.cn`
- `getCompressedImageUrl()` 内部调用 `ensureFullUrl` 确保 URL 完整性
3. **`src/pages/shop/cart.tsx`**(补充图片回退源)
- 图片 `src` 回退链增加 `(item.sku as any)?.image`
- 完整回退链:`goodsImage -> product.image -> sku.image -> ''`
## 收藏列表图片/价格不显示修复2026-07-14
### 问题
`pages/user/favorite-list/index` 收藏列表:商品图片、价格、名称均空白。
### 根因
后端 `/api/shop/goods/favorite/list`(前端 `/shop/goods/favorite/list`)走 `ShopGoodsFavoriteServiceImpl.listRel``selectListRel`SQL 只 `SELECT a.*` 未关联商品表,返回字段仅 `id/userId/goodsId/merchantId/tenantId/createTime`,不含商品信息。前端依赖 `goodsImage/goodsName/price/salePrice` 均为 undefined。
### 修改方案1后端返回商品信息
1. **后端** `guilixu-java`
- `ShopGoodsFavorite.java`:新增 4 个 `@TableField(exist=false)` 非持久化字段 `goodsName(String)``goodsImage(String)``price(BigDecimal)``salePrice(BigDecimal)`,并补 `BigDecimal`/`TableField` import。
- `ShopGoodsFavoriteMapper.xml``selectSql``SELECT a.*, g.name AS goods_name, g.image AS goods_image, g.price AS price, g.sale_price AS sale_price``LEFT JOIN shop_goods g ON a.goods_id = g.goods_id`(已确认 `map-underscore-to-camel-case: true`,别名可映射)。
- 注意:本机无 maven 无法编译,改动为常规 MyBatis/MP 写法,需用户自行编译部署。
2. **前端**
- `src/api/shop/shopGoodsFavorite/model.ts``favoriteId` 改为 `id`(后端实际返回 `id`);新增 `price?: string|number``salePrice` 改为 `string|number`
- `pages/user/favorite-list/index.tsx`:图片用 `item.goodsImage`;价格改为优先 `item.price`(到手价),`salePrice` 作为划线价(`salePrice > price` 时显示);`key` 改用 `item.id ?? item.goodsId`(原 `favoriteId` 始终 undefined 会导致 key 重复)。
- 注:`src/pages/favorite-list.tsx` 为未注册路由的重复文件,未改动。
### 遗留(非本次范围,供参考)
adapter `/list` 接口忽略分页,一次返回全部收藏;前端 `listShopGoodsFavorite({page,limit:20})` 的无限滚动在收藏>20条时会重复追加。如需彻底解决建议改前端走 `/page` 接口或后端 `/list` 支持分页。
## 购物车更新 API 请求方式修复2026-07-14
### 问题
`PUT /api/shop/shop-cart/{id}` 返回 `HttpRequestMethodNotSupportedException: Request method 'PUT' not supported`
### 根因
`src/api/shop/shopCart/index.ts` 中 3 个函数用了非标准 URL 模式 `PUT /shop/shop-cart/{id}`,而项目所有其他 APIshopOrder、shopArticle 等)的更新操作统一走 `PUT /shop/shop-cart`(基础 URLid 放 body
### 修改
1. `updateCartNum``PUT /shop/shop-cart/{id}` + `{quantity}``PUT /shop/shop-cart` + `{id, num}`
2. `updateCartChecked``PUT /shop/shop-cart/{id}` + `{selected}``PUT /shop/shop-cart` + `{id, checked}`(同时修正字段名 `selected``checked` 与 model 一致)
3. `updateCartAllChecked``PUT /shop/shop-cart/selected` + `{selected}``POST /shop/shop-cart/selected` + `{checked}`(改用 POST自定义批量操作更可能支持 POSTCartContext 已有静默 catch 兜底)
## 购物车后端 SQL 关联查询修复2026-07-14
### 问题
`GET /api/shop/shop-cart` 返回的数据没有商品图片、价格等信息。前端 `CartContext.refresh()` 已映射 `goodsName/goodsImage/skuPrice/skuSpec/stock` 等字段,但后端根本没返回这些字段。
### 根因
后端 `ShopCartMapper.xml` 的 SQL 只做 `SELECT a.* FROM shop_cart a`,没有 JOIN 商品表和 SKU 表。
### 后端修改(`guilixu-java` 项目)
1. **`ShopCart.java`**:新增 7 个 `@TableField(exist = false)` 非持久化字段:`goodsName``goodsImage``skuPrice``skuSpec``stock``salePrice``dealerPrice`;补充 `TableField` import
2. **`ShopCartMapper.xml`**SQL 改为 `LEFT JOIN shop_goods g ON a.goods_id = g.goods_id` + `LEFT JOIN shop_goods_sku s ON a.sku_id = s.id`SELECT 别名映射到新字段(`COALESCE(s.price, g.price) AS sku_price``COALESCE(s.stock, g.stock) AS stock`);同时修复 `userId/goodsId/merchantId``LIKE` 改为 `=`(原 LIKE 会导致 userId=1 匹配到 10/11/21 等)
3. **`ShopCartController.java`**`list()` 方法自动按当前登录用户过滤(`param.setUserId(loginUser.getUserId().longValue())`
### 前端修改
4. **`src/api/shop/shopCart/model/index.ts`**`ShopCart` 接口新增 `salePrice``dealerPrice` 字段
5. **`src/contexts/CartContext.tsx`**`CartItem` 新增 `salePrice``dealerPrice``refresh()` 映射这两个字段;`calcPrice()` VIP 优先用扁平 `i.dealerPrice`(之前只看 `i.product?.dealerPrice`
6. **`src/pages/shop/cart.tsx`** + **`src/pages/shop/checkout.tsx`**:价格显示逻辑同步更新,优先使用扁平 `item.dealerPrice` / `item.salePrice`
## 购物车前后端字段名对齐修复2026-07-14
### 问题
修改购物车数量时后端报 `UPDATE shop_cart WHERE id=?`SET 子句为空MyBatis-Plus 找不到要更新的字段。
### 根因
前后端字段名不匹配:
- 前端发送 `num`,后端实体字段是 `cartNum` → Jackson 反序列化时 `cartNum` 为 null → 空 UPDATE
- 前端发送 `checked`,后端实体字段是 `selected` → 同样不匹配
- 列表返回 `cartNum`,前端读 `item.num` → 数量永远为默认值 1
### 修改文件
1. **`src/api/shop/shopCart/model/index.ts`**`ShopCart.num``cartNum`;新增 `selected` 字段;`AddToCartParam.num``cartNum``UpdateCartNumParam.num``cartNum`
2. **`src/api/shop/shopCart/index.ts`**`updateCartNum``{id, cartNum}``updateCartChecked``{id, selected}`
3. **`src/contexts/CartContext.tsx`**`refresh()``item.cartNum``item.selected``addItem()``cartNum``updateQuantity` 调用 `{id, cartNum}`
4. **`src/pages/shop/index.tsx`** + **`src/pages/shop/category.tsx`**`addToCart` 参数 `num``cartNum`
## 禁用 loginByOpenId 静默登录2026-07-14
### 改动
- **`src/contexts/UserContext.tsx`**:移除"情况3"静默登录块(`Taro.login` + `loginByOpenId`),无 token 时直接保持未登录状态;清理 `loginByOpenId``TenantId` 无用导入
- **`src/hooks/useUser.ts`**:移除降级方案中的 `loginByOpenId` 调用,仅从 storage 读取;清理 `loginByOpenId``TenantId` 无用导入
- `src/api/layout/index.ts``loginByOpenId` 函数定义保留,仅不再被调用
## 商城分类页价格登录可见2026-07-14
### 改动
- **`src/pages/shop/index.tsx`**:价格显示从手写 `Text` 改为 `Price` 组件 + `loginMask` 属性(与首页一致),游客看到"登录后查看价格"VIP 划线价逻辑保留在 `original` prop 中;单位 `unitName` 仅登录后显示
## 订单列表页未登录可查看订单 Bug 修复2026-07-14
### 问题
`pages/order/list` 未登录用户也能看到订单数据。
### 根因
页面缺少登录校验——`useEffect` 直接调用 `loadOrders`,未登录时 `user?.userId``undefined`,后端未按用户过滤直接返回了订单。
### 修改
- **`src/pages/order/list.tsx`**
1.`useUser()` 获取 `isLoggedIn`
2. `useEffect` 中未登录时调用 `requireLogin({ action: 'viewOrder', redirect: '/pages/order/list', content: '登录后才能查看订单,是否前往登录?' })` 弹窗引导,而不是直接跳转
3. render 中 `if (!isLoggedIn) return null` 兜底,避免未登录时渲染订单内容
- **`src/passport/login.tsx`**:登录成功后的 `tabBarUrls` 白名单补全 `pages/order/list`,确保从订单入口登录后能 `switchTab` 回订单页
## 我的页面未登录可点击需登录功能 Bug 修复2026-07-14
### 问题
`pages/user/user` 未登录时,点击「我的订单」下的按钮(待付款/待发货/待收货/已完成、「全部订单」、以及菜单中的「升级VIP会员」「我的钱包」「收货地址」「我的收藏」都能直接跳转页面。
### 修改
- **`src/pages/user/user.tsx`**
1. 导入 `requireLogin` from `@/utils/login-guard`
2. 给需要登录的菜单项加 `requireAuth: true` 标记升级VIP会员/门店中心/我的钱包/收货地址/我的收藏)
3. 新增 `handleMenuClick(item)``requireAuth` 项调用 `requireLogin` 弹窗拦截,已登录才 `navigateTo`
4. 新增 `handleOrderTabClick(status)``handleViewAllOrders()`:订单入口 `requireLogin` 拦截,并加 `redirect: '/pages/order/list'` 让登录后回跳订单页
5. 未登录时需登录的菜单项和订单按钮加 `opacity-50` 视觉降级
6. 「帮助中心」「设置」不需要登录,保持原样
## 订单列表页登录后仍看到别人订单 Bug 修复2026-07-14
### 问题
`pages/order/list` 刚登录成功后仍能看到别人的订单,刷新重进后才正常。
### 根因
`loadOrders``useCallback` 依赖数组为 `[updateTabState]`,未包含 `user`,导致**闭包过期**——登录后 `isLoggedIn``true` 触发 effect 调用 `loadOrders`,但闭包中的 `user` 仍是初始渲染时的 `null``userId: user?.userId` 传了 `undefined`,后端未按用户过滤返回了全部订单。
### 修改
- **`src/pages/order/list.tsx`**
1. 新增 `userIdRef``useRef(user?.userId)`),每渲染同步 `userIdRef.current = user?.userId`,避免闭包过期
2. `loadOrders` 开头从 `userIdRef.current` 取 userId`if (!userId) return` 硬拦截——没有当前登录用户 userId 就禁止加载
3. `useEffect` 中增加 `if (!user?.userId) return` 判断,已登录但 userId 还没拿到时等待下次渲染
4. `useEffect` 依赖数组加 `user?.userId`userId 变化时重新触发加载
## 商城列表页浮动购物车按钮2026-07-14
### 需求
`pages/shop/index` 页面右下角添加浮动购物车按钮,显示购物车商品数量角标。
### 实现
- **`src/pages/shop/index.tsx`**
1.`useCartContext()` 额外解构 `totalCount`
2. 页面末尾添加 fixed 定位浮动按钮绿色圆形48px`bottom:80px right:16px`(避开 tabBar
3. `totalCount > 0` 时右上角显示红色角标,超过 99 显示 `99+`
4. 点击 `Taro.navigateTo` 跳转购物车页cart 非 tabBar 页)
5. 数量实时更新:`addItem` 内部调 `refresh()``totalCount` 派生值自动重渲染
## 购物车页面进入不刷新 Bug 修复2026-07-14
### 问题
从商品列表页/分类页加入购物车后,进入购物车页面,新加的商品不显示,只有之前已在购物车里的商品。
### 根因
1. **购物车页面缺少 `useDidShow`**`cart.tsx` 仅在 `useEffect([isLoggedIn])` 中调用 `refresh()`,登录状态不变时不会重新拉取数据。从其他页面返回购物车时,页面不刷新。
2. **商品列表页/分类页直接调 API 加购**`shop/index.tsx``shop/category.tsx` 直接调用 `addToCart` API未通过 `CartContext.addItem`,导致 CartContext 状态不同步(`addItem` 内部会调 `refresh()`)。
### 修改
1. **`src/pages/shop/cart.tsx`**:导入 `useDidShow`,添加 `useDidShow(() => { if (isLoggedIn) refresh() })`,页面每次显示时从服务端拉取最新购物车数据
2. **`src/pages/shop/index.tsx`**:导入 `useCartContext``handleAddToCart` 改用 `addItem(product, undefined, product.step || 1)` 代替直接调 `addToCart` API移除 `addToCart` 导入
3. **`src/pages/shop/category.tsx`**:同上,改用 `useCartContext``addItem`
### 需求
首页顶部搜索栏右侧铃铛图标,门店店员有新订单时能收到提醒。
### 改动
- **`src/pages/index/index.tsx`**
1. 导入 `useNewOrderDetector``getMyClerk``pageShopOrder``useDidShow``useCallback``useRef`
2. 登录后调 `getMyClerk()` 检测店员身份,存 `isClerk` state + `isClerkRef` refref 用于轮询回调中读取最新值,避免闭包过期)
3. `useDidShow` 时也刷新店员身份
4. 接入 `useNewOrderDetector`30秒轮询 `pageShopOrder({page:1,limit:5})``fetchLatestOrders` 内用 `isClerkRef.current` 网关——非店员直接返回空数组(不建 baseline无副作用
5. 新订单回调:`Taro.vibrateShort` 震动 + `Taro.showToast` 提示"您有 N 条新订单"
6. 铃铛角标从静态红点改为动态:`newOrderCount > 0` 显示数字角标(>99 显示 99+`=== 0` 时显示小圆点
7. 点击处理:店员 → `navigateTo` 门店订单页(有新订单时先 `clearUnread()`);普通用户 → 通知页
### 设计要点
- `isClerkRef` 解决 hooks 闭包过期问题——`fetchLatestOrders``useCallback` 依赖空数组,但通过 ref 读取最新店员状态
- 非店员时 `fetchLatestOrders` 返回 `[]`hook 内 `doCheck``list.length === 0` 时 return`knownOrderIdsRef` 保持 `null` 不建 baseline店员身份确认后首次真实请求才建 baseline不会误报
- `useNewOrderDetector` 内部已处理 `useDidShow`/`useDidHide` 生命周期tabBar 页切走自动停轮询
## 商品分类页浮动购物车图标优化2026-07-14
### 问题
商品分类页pages/shop/index右下角绿色圆形浮动购物车按钮使用 emoji `🛒` 作为图标,在绿色背景上对比度不足、线条不清晰。
### 修改
1. 新增白色线条购物车 SVG 图标:`src/assets/icons/cart.svg`
2. 导出 base64 数据 URI 常量:`src/assets/icons/index.ts``CART_ICON_WHITE`
3. `src/pages/shop/index.tsx` 浮动购物车按钮改用 `Image` 组件加载 `CART_ICON_WHITE`28×28px`mode='aspectFit'`),图标更清晰
## 商品详情页出现孤立 "0" 排查修复2026-07-14
### 问题
`pages/shop/product-detail` 价格区域下方出现一个不归属任何标签的孤立 `0`
### 根因
`src/pages/shop/product-detail.tsx` 中赚取积分行的条件渲染写成:
```tsx
{product.gainIntegral && Number(product.gainIntegral) > 0 && (...)}
```
当后端返回 `gainIntegral` 为数字 `0`JSX 短路与运算直接返回 `0`React 会把它渲染成一个纯文本节点。这就是页面上那个孤立的 `0`
### 修改
- **`src/pages/shop/product-detail.tsx`**
- 去掉条件里的 `product.gainIntegral &&`,改成 `{Number(product.gainIntegral) > 0 && (...)}`,避免 `0` 泄漏。
- 顺手把配送区域 `goodsWeight` 的同类写法也改成 `{Number(product.goodsWeight) > 0 && (...)}`,防止重量为 0 时同样泄漏。
- 另按用户要求「是 0 就不显示」,把销量行也改为 `{Number(product.sales) > 0 && (...)}`,销量为 0 时整条「销量: 0」不渲染。
## 订单列表页下单后不自动刷新修复2026-07-14
### 问题
`pages/order/list` 下单后从 checkout 页 `Taro.switchTab` 跳过来,订单列表不刷新,看不到新订单。
### 根因
订单列表页是 tabBar 页,`switchTab` 不会重新挂载组件。`useEffect` 依赖 `[tabIndex, isLoggedIn, user?.userId]`,这些值不变就不会重新触发;且 effect 内有 `if (!tabStates[tabIndex].initialized)` 守卫,页面已初始化过就直接跳过。
### 修改
- **`src/pages/order/list.tsx`**:新增 `Taro.useDidShow` 生命周期钩子
-`isFirstShow` ref 跳过首次显示(由 `useEffect` 处理初始加载)
- 后续每次显示时:标记所有 tab 为 `initialized: false`(切 tab 时会重新加载),并立即 `loadOrders(tabIndex, 1)` 刷新当前 tab
- 与之前购物车页 `useDidShow` 刷新是同一模式
## 商品详情页底部购物车角标2026-07-14
### 需求
`pages/shop/product-detail` 底部操作栏的购物车入口需要显示当前购物车商品数量角标。
### 修改
- **`src/pages/shop/product-detail.tsx`**
1.`useCartContext` 额外解构 `totalCount``refresh`
2. 引入 `useDidShow`,页面显示时(已登录)调 `refresh()` 拉取最新购物车,保证角标准确
3. 购物车图标外包一层 `relative` 容器,`totalCount > 0` 时右上角显示红色圆角角标(>99 显示 `99+`),样式与商城列表页浮动购物车角标一致
## 在线客服功能修复2026-07-14
### 问题
`pages/user/customer-service/index` 在线客服功能使用不了,用户已在微信后台添加客服人员。
### 根因(多个问题叠加)
1. **`openType="contact"` 按钮缺少关键属性**:缺少 `sessionFrom`(会话来源标识)、`onContact`(客服回调)、`showMessageCard`/`sendMessageTitle`(客服消息卡片),且按钮被 Tailwind `border-0` 类可能干扰原生渲染
2. **开发者工具限制**`open-type="contact"` 在微信开发者工具中不生效,只能在真机上使用,页面缺少提示
3. **自定义聊天系统消息发送缺失 `conversationId`**`addShopChatMessage` 调用未传 `conversationId`,消息无法关联到会话;`ShopChatMessage` 模型本身也缺少该字段
4. **消息发送缺失 `formUserId`**:发送人 ID 未设置
5. **`conv.lastMessage` 字段不存在**`ShopChatConversation` 模型只有 `content` 字段,代码引用了不存在的 `lastMessage`
6. **`useReachBottom` 用错场景**:页面使用 `ScrollView` 组件,`useReachBottom` 是页面级滚动钩子,对 ScrollView 无效,应改用 `onScrollToLower`
7. **`scrollToBottom` ref 设了但从未使用**:死代码
### 修改
1. **`src/api/shop/shopChatMessage/model/index.ts`**
- `ShopChatMessage` 接口新增 `conversationId?: number`
- `ShopChatMessageParam` 接口新增 `conversationId?: number`
2. **`src/pages/user/customer-service/index.tsx`**(全面修复):
- 微信客服按钮补全 `sessionFrom="customer_service"``onContact``showMessageCard``sendMessageTitle`
- 按钮样式从 Tailwind 类改为 inline style避免 `border-0` 干扰原生按钮),显式设置 `border: 'none'`
- 添加"需真机预览测试"提示文字
- `addShopChatMessage` 调用补全 `conversationId``formUserId`(从 `Taro.getStorageSync('UserId')` 获取)
- `conv.lastMessage` 全部改为 `conv.content`
- 会话列表更新 `lastMessage: text` 改为 `content: text`
- 移除 `useReachBottom`,改用 ScrollView `onScrollToLower` + `lowerThreshold={50}`
- 移除未使用的 `scrollToBottom` ref
- `pageShopChatMessage` 参数去掉 `as any` 断言(模型已支持 `conversationId`
- 新增 `getCurrentUserId()` 辅助函数
- `handleScrollToLower` 增加 `showHistory` 条件守卫(仅展开历史时才加载更多)
3. **`src/components/NavBar/index.tsx`**(关键修复,导致白屏):
- Taro 4.1.11 没有 `useNavigate` hook原代码 `import { useNavigate } from '@tarojs/taro'` 运行时为 `undefined`
- 改为 `import Taro from '@tarojs/taro'`,回退逻辑用 `Taro.navigateBack({ delta: 1 })`
4. **删除未注册的死代码**
- `src/pages/customer-service.tsx`(重复且仍引用 `useReachBottom`,干扰编译/热更新)
- `src/pages/customer-service.config.ts`
### 验证
- 全局搜索确认无其他 `useNavigate``useReachBottom` 引用
- TypeScript 检查修改文件无错误
### 客服功能简化21:00
- 用户反馈客服功能太复杂用不了,改为直接用微信原生 `open-type="contact"` 按钮
- `src/pages/shop/product-detail.tsx`:客服图标从 `View + onClick navigateTo` 改为 `Button openType="contact" sessionFrom="product_detail"`
- 删除了 `handleContactService` 函数和跳转到 `/pages/user/customer-service/index` 的逻辑
- **注意:`open-type="contact"` 必须真机预览测试,开发者工具中点击无反应**
- `pages/user/customer-service/index` 页面保留但不再从商品详情页跳转

View File

@@ -0,0 +1,363 @@
## 线下付款下单失败排查
- 文件:`src/pages/shop/checkout.tsx``src/api/shop/shopOrder/index.ts`
- 现象结算页选择线下付款payType=9下单时报错`创建支付订单失败创建微信支付订单失败支付配置中应用ID为null或空`
- 原因:前端传参正确,但后端 `POST /shop/shop-order` 在 payType=9 时仍尝试创建微信支付订单,因未配置 appId 失败
- 前端处理:
- `checkout.tsx` 增加线下付款支付配置类错误的友好提示
- `createOrder` 返回类型放宽为 `WxPayResult | null`,兼容非微信支付场景
- 后端修复(`/Users/gxwebsoft/JAVA/guilixu-java`3 个文件):
1. `OrderBusinessService.java``createOrder()` 中 Integer 比较从 `==` 改为 `.equals()`,避免装箱陷阱导致 payType=8/9 拦截失效;微信支付分支前加日志
2. `ShopOrderServiceImpl.java``createWxOrder()` 方法开头增加 payType 守卫,非微信支付(1/102)直接抛异常,不走 JSAPI 分支
3. `ShopOrderController.java``saveLegacy()` 方法增加 payType 检查,货到付款/线下付款/余额支付跳过 createWxOrder
- 验证Maven compile 成功,无编译错误
- 注意:后端服务需重启才能生效
## 结算页支付方式调整
- 文件:`src/pages/shop/checkout.tsx`
- 注释掉货到付款(id=8)选项,只保留线下付款(id=9)
- 默认支付方式从 8(货到付款) 改为 9(线下付款)
## 门店订单修改金额功能
- 文件:`src/pages/store/orders/index.tsx`
- 新增 OpType `'editPrice'`,在订单卡片操作区增加橙色「修改金额」按钮
- 仅对可操作订单(非已完成/非已关闭)显示
- 弹窗包含当前实付展示、新金额输入digit、修改原因必填 textarea
- 提交调用 `updateShopOrder({ orderId, payPrice, comments })`,金额变更记录追加到 comments
- 复用现有 `updateShopOrder` APIPUT `/shop/shop-order`),无需新增接口
## 修复 VIP 会员价格显示和下单问题
- **根因**`useVipStatus` hook 已定义但从未被任何页面使用,所有页面直接调用 `isVipMember()` 读取同步缓存。该缓存仅在「个人中心」页刷新,导致 VIP 会员访问商品详情/购物车/结算页时缓存为 stale false不显示 dealerPrice
- **修复文件**(共 9 个):
1. `src/api/shop/shopOrder/model/index.ts``OrderGoodsItem` 新增 `price?: string` 字段,下单时传 VIP 单价
2. `src/pages/shop/product-detail.tsx` — 引入 `useVipStatus`,用响应式 `isVip` 替换 `isVipMember()`
3. `src/pages/shop/checkout.tsx` — 引入 `useVipStatus``goodsPrice` useMemo 依赖加 `isVip`;下单时 `OrderGoodsItem``price` 字段
4. `src/pages/shop/index.tsx` — 引入 `useVipStatus`,替换 `isVipMember()`
5. `src/pages/shop/category.tsx` — 引入 `useVipStatus`,替换 `isVipMember()`
6. `src/pages/shop/cart.tsx` — 引入 `useVipStatus`,替换 `isVipMember()`
7. `src/contexts/CartContext.tsx` — CartProvider 引入 `useVipStatus``calcPrice` 用响应式 `isVip`
8. `src/components/business/SkuSelector/index.tsx` — 引入 `useVipStatus`,替换 `isVipMember()`
9. `src/components/common/ProductCard/index.tsx` — 引入 `useVipStatus`,替换 `isVipMember()`
- **原理**`useVipStatus` 在组件挂载时异步调用 `checkAndCacheVipStatus` 查询后端并更新缓存+React statestate 变化触发重渲染,使所有价格显示/计算自动更新为 dealerPrice
- 验证:`npx taro build --type weapp` 构建成功
## 门店订单修改金额 — 修改原因保存字段变更
- 文件:`src/pages/store/orders/index.tsx``src/api/shop/shopOrder/model/index.ts`
- 变更:修改金额时的"修改原因"从追加到 `comments` 字段改为保存到 `merchantRemarks`(商户备注)字段
- `ShopOrder` 接口新增 `merchantRemarks?: string` 字段
- `submitEditPrice``updateShopOrder` 传参从 `comments` 改为 `merchantRemarks`
## 订单备注字段分离buyerRemarks / merchantRemarks / comments
- 文件:`src/api/shop/shopOrder/model/index.ts``src/pages/shop/checkout.tsx``src/pages/store/orders/index.tsx`
- `ShopOrder``OrderCreateRequest` 接口均新增 `buyerRemarks?: string`(买家备注)
- 结算页 `checkout.tsx`:订单备注从 `comments: remarks` 改为 `buyerRemarks: remarks`
- 门店订单列表 `store/orders/index.tsx`:订单卡片新增买家备注展示块(橙色背景,收货信息下方)
- 字段约定:`buyerRemarks`=买家下单备注,`merchantRemarks`=商户修改金额原因,`comments`=系统/其他备注
## 修复线下付款订单状态显示
- 文件:`src/components/common/OrderCard/index.tsx``src/pages/order/list.tsx``src/pages/order/detail.tsx`
- 问题:线下付款(payType=9)下单成功后,订单列表和详情页显示"线下付款·待确认",用户认为状态不对
- 修复内容:
1. OrderCard `getCardStatus`:将线下付款(payType=9)与货到付款(payType=8)统一处理,`!payStatus && !isCod && !isOffline` 才显示"待付款",否则直接按 deliveryStatus 判断,线下付款订单显示"待发货"
2. OrderCard `showCloseButton`:增加 `!order.payStatus` 条件,已付款订单不再显示取消按钮(需到详情页退款)
3. OrderCard 按钮文案:从"关闭订单"改为"取消订单",与详情页一致
4. list.tsx `handleCloseOrder`:弹窗标题改为"确认取消订单"toast 改为"订单已取消"
5. detail.tsx `getOrderDisplayStatus`:线下付款未确认时显示"待发货"蓝色subtitle 保留付款引导文案
6. detail.tsx `getOrderPhase`:保留 `offline_pending` 阶段,底部按钮仍为"取消订单+联系客服"(未确认收款可取消,非退款)
## 修复地址编辑页智能识别 bug
- 文件:`src/pages/user/address-edit.tsx`
- 问题:粘贴带标签的地址文本(如「收件人: 赵忠林 / 手机号码: 137... / 所在地区: 广西... / 详细地址: 大学东路...」)识别后:
1. 姓名识别失败(之前只看文本开头的 2-4 个汉字,姓名不在开头)
2. 详细地址保留了「收件人」「手机号码」「所在地区」「详细地址」等标签文字
- 修复 `parseAddressText`
1. 姓名优先匹配 `(?:收件人|收货人|姓名|联系人)[:]\s*([\u4e00-\u9fa5]{2,4})`,找不到再回退到 `^[\u4e00-\u9fa5]{2,4}`
2. 详细地址提取前先 strip 标签文字(收件人/手机号/所在地区/详细地址 + 中英冒号),再 strip 省市区,最后 strip 零散标签
- 验证:用 Python 模拟Python 是用 JS 同款正则+RegionData解析截图中的输入
- name: 赵忠林 ✓
- phone: 13737128880 ✓
- province/city/region: 广西壮族自治区/南宁市/西乡塘区 ✓
- address: 西乡塘街道大学东路9号瀚林御景3栋1单元1101号房 ✓(街道+详细地址完整保留)
- 项目 type-check (`npx tsc --noEmit`) 无报错
## 修复默认地址唯一性 bug
- 问题:新增/编辑地址时勾选"设为默认"后,后端直接保存 `isDefault=true`,不清除其他地址的默认状态,导致出现多个默认地址
- 后端修复(`/Users/gxwebsoft/JAVA/guilixu-java`3 个文件):
1. `ShopUserAddressService.java` — 新增 `clearDefault(userId, excludeId)` 接口方法
2. `ShopUserAddressServiceImpl.java` — 实现 `clearDefault`:查询该用户所有 `isDefault=true` 的地址(可排除指定 id批量设为 false
3. `ShopUserAddressController.java``save()` 新增时若 `isDefault=true` 先调 `clearDefault(userId, null)``update()` 编辑时若 `isDefault=true` 先调 `clearDefault(userId, id)` 排除当前地址
- 前端修复(`src/pages/user/address-edit.tsx`
- 保存成功后若 `isDefault=true`,再调一次 `setDefaultAddress(savedId)` 触发后端清理逻辑(双重保险)
- 新增时从返回结果中提取 savedId
- 修复 `getShopUserAddress` 返回值类型断言(`as ShopUserAddress`
- 前端渲染兜底(`src/hooks/useAddress.ts`
- `loadAddresses` 中加 `ensureSingleDefault` 工具函数:若后端返回多个 `isDefault=true`,只保留 createTime 最早的为默认,其余置 false
- 后端 Maven 编译命令:`"/Applications/IntelliJ IDEA Ultimate.app/Contents/plugins/maven/lib/maven3/bin/mvn" compile`guilixu 的 mvnw 损坏,用 IntelliJ 自带 Maven
- 编译结果:成功无报错
- 注意:之前误改了 paopao-java 和 websopy-java 两个项目(已废弃),正确后端是 guilixu-java
## 用户商品浏览记录功能(全栈实现)
- 需求:后台记录用户浏览商品的痕迹
- 方案:去重累加模式(同一用户+商品只留一条visit_count 累加5分钟内去重防刷
- 后端(`/Users/gxwebsoft/JAVA/guilixu-java`7 个文件 + 1 SQL
1. `sql/shop_goods_browse.sql` — 建表 SQL含3个索引uk_user_goods 唯一 / idx_user_time / idx_goods_time
2. `ShopGoodsBrowse.java` — Entity参照 ShopGoodsFavorite 范式,含 visitCount/lastVisitTime/browseSource + 关联商品快照字段)
3. `ShopGoodsBrowseParam.java` — 查询参数merchantId 用 Long 与 BaseParam 一致,含 startTime/endTime 时间范围)
4. `ShopGoodsBrowseMapper.java` + XML — Mapper关联 shop_goods 表查询商品名称/图片/价格)
5. `ShopGoodsBrowseService.java` + Impl — Service核心 recordBrowse 去重累加逻辑查已有→5分钟内忽略/超过则累加→首次插入)
6. `ShopGoodsBrowseController.java` — ControllerPOST 上报 / GET page 我的足迹 / DELETE 删单条 / DELETE 清空 / GET admin/page 后台查询)
- 前端(`xinlong-shop-taro`4 个文件):
1. `src/api/shop/shopGoodsBrowse/model/index.ts` — ShopGoodsBrowse + ShopGoodsBrowseParam 类型
2. `src/api/shop/shopGoodsBrowse/index.ts` — reportBrowse(静默)/pageBrowseHistory/deleteBrowseRecord/clearBrowseHistory
3. `src/pages/shop/product-detail.tsx` — addToHistory() 已登录时异步调 reportBrowse 上报
4. `src/pages/user/history-list/index.tsx` — 改为服务端分页(登录)/ 本地兜底(未登录),支持删除单条、清空、上拉加载
5. `src/pages/user/user.tsx` — 恢复浏览历史入口(取消注释)
- 编译验证:后端 Maven compile 成功;前端 tsc --noEmit 无新增类型错误
- 编译踩坑BaseController.success() 有 success(IPage<T>) 重载,泛型方法返回类型用 ApiResult<?> 避免 fail() 分支类型不兼容
- 待执行:数据库需手动执行 `shop_goods_browse.sql` 建表
- 后续可选:后台管理页面(`/Users/gxwebsoft/VUE/guilixu-admin`Vue3+AntD Vue
## 后台管理端浏览记录页面guilixu-admin
- 后端补充:`ShopGoodsBrowseController` 新增 `DELETE /admin/{id}` 管理端删除接口(不限定用户),原 `DELETE /{id}` 限定当前用户
- 前端文件(`/Users/gxwebsoft/VUE/guilixu-admin`4 个文件):
1. `src/api/shop/shopGoodsBrowse/model/index.ts` — ShopGoodsBrowse + ShopGoodsBrowseParam 类型
2. `src/api/shop/shopGoodsBrowse/index.ts` — pageShopGoodsBrowse(调 /admin/page) + removeShopGoodsBrowse(调 /admin/{id})
3. `src/views/shop/shopGoodsBrowse/index.vue` — 页面ele-pro-table 表格ID/用户ID/商品图片/商品名称/价格/浏览次数/浏览来源/最后浏览时间/首次浏览/操作删除)
4. `src/views/shop/shopGoodsBrowse/components/search.vue` — 搜索栏用户ID/商品ID/浏览来源/时间范围)
- 管理后台菜单机制后端动态菜单sys_menu 表),前端按 `component` 字段约定加载 `src/views/{component}/index.vue`
- 菜单配置:后台"系统管理 → 菜单管理"新增component 填 `shop/shopGoodsBrowse`
- 注意:管理后台 API 用 `res.data.code`/`res.data.data`(非小程序端的 `res.code`request 传参用 `{ params }` 包裹
- 编译验证:后端 Maven compile 成功;前端 vue-tsc 无 shopGoodsBrowse 相关错误
- 待操作:后端需重新部署(新增了 admin 删除接口);管理后台需配置菜单
## 帮助中心联系客服入口改为直连微信客服
- 文件:`src/pages/user/help-center/index.tsx`
- 改动:点击「联系在线客服」不再跳转 `/pages/user/customer-service/index`,而是直接用 `<Button openType="contact">` 调起微信客服
- 关键属性:`openType="contact"` + `sessionFrom="help_center"` + `showMessageCard` + `sendMessageTitle="欢迎咨询"` + `onContact` 回调
- Button 样式重置height:auto / lineHeight:normal / padding:16px / border:none / borderRadius:8px保持原视觉白底圆角卡片
- 引入:从 `@tarojs/components` 新增导入 `Button`
- 注意:微信客服 Button 在开发者工具中无效,需真机预览测试
## 订单详情页查看物流改造(显示 shopOrderDelivery
- 需求:订单详情页点击「查看物流」跳转 `/pages/order/logistics`,原页面强制要求 URL 带 expressNo + expressCompany但详情页只传了 orderId导致 toast「缺少物流参数」+ 空状态
- 后端修复(`/Users/gxwebsoft/JAVA/guilixu-java`1 个文件):
- `ShopOrderServiceImpl.java``getByIdRel(orderId)` 原本只调 `baseMapper.selectListRel(param)`,没有像 `pageRel` 那样填充 `shopOrderDelivery`。新增私有方法 `fillShopOrderDelivery(order)`,在快递配送(deliveryType==0/null)且已发货(deliveryStatus>10)时调 `shopOrderDeliveryService.getByOrderId(orderId)` 查发货单,并关联 `shopExpressService.getById(expressId)` 取 expressName 填充
- 编译验证Maven compile 成功
- 前端类型补充(`src/api/shop/shopOrder/model/index.ts`
- 新增 `ShopOrderDelivery` 接口(与后端 ShopOrderDelivery 实体对应deliveryId/orderId/deliveryMethod/expressId/expressName/sendName/sendPhone/sendAddress/expressNo/createTime 等)
- `ShopOrder` 接口新增 `shopOrderDelivery?: ShopOrderDelivery | null` 字段
- 前端物流页改造(`src/pages/order/logistics.tsx`
- 不再强制要求 URL 带 expressNo/expressCompany只要求 orderId
-`getShopOrder(orderId)` 加载订单(含后端填充的 shopOrderDelivery
- 运单号优先级URL expressNo → delivery.expressNo → order.expressNo
- 物流轨迹:仅在有 expressNo + expressCompany 代码时才查询 queryLogistics失败不阻塞发货单展示
- 新增「发货信息」卡:发货人/联系方式/发货地址/物流单号(可复制)/发货时间/发货方式(10手动/20无需物流/30电子面单)
- 新增「收货信息」卡:从订单读 realName/mobile/address
- 保留原物流轨迹时间线(仅在查询到时展示)和温馨提示
- 空状态:订单接口也失败时才显示
- 后端需重启服务才生效
## 修复 VIP 价格首页不显示 + 后端不认 price 字段
- **问题 1首页**`src/pages/index/index.tsx`tabBar 首页)没接入 `useVipStatus`,热销推荐价格一直显示 `product.price` 而非 `dealerPrice`。商品详情/商城列表/购物车/分类都修了,唯独漏了 tabBar 首页
- **问题 2后端 DTO 缺字段)**:前端 `OrderGoodsItem` 已传 `price` 字段,但后端 `OrderCreateRequest.OrderGoodsItem`(嵌套静态类)**根本没有 `price` 字段**Jackson 反序列化直接丢弃。后端 `OrderBusinessService.validateAndCalculateTotal()``saveOrderGoods()` 都用 `goods.getPrice()` 兜底 —— 这就是「前端传的对、后端处理就错」的根本原因
- **修复3 个文件)**
1. `src/pages/index/index.tsx`
- 引入 `useVipStatus`,加 `const { isVip } = useVipStatus()`
-`getHotDisplayPrice()`VIP + 有 dealerPrice → `{price: dealerPrice, original: price}`,否则原逻辑
- Price 组件传 `price + original` + 旁边加金色「VIP」角标
2. `/Users/gxwebsoft/JAVA/guilixu-java/src/main/java/com/gxwebsoft/shop/dto/OrderCreateRequest.java`
- `OrderGoodsItem` 内部类加 `private BigDecimal price;` 字段 + `@DecimalMin("0")` 校验
3. `/Users/gxwebsoft/JAVA/guilixu-java/src/main/java/com/gxwebsoft/shop/service/OrderBusinessService.java`
- `validateAndCalculateTotal()``saveOrderGoods()` 两处重构:先取 sku无论价格来源都要校验 sku 合法性+取库存),再用「前端 price > sku.price > goods.price」优先级决定 actualPrice
- 注意不要把 sku 查询和 price 选择耦合在同一个 if-else 链里,否则 VIP+多规格商品会跳过 sku 库存校验
- **编译验证**Maven compile 成功无报错;后端需重启服务才能生效
- **价格字段约定(再确认)**
- `goods.price` - 到手价(主价格)
- `goods.salePrice` - 市场价(划掉的)
- `goods.dealerPrice` - VIP 专享价
- 前端传 `dealerPrice``OrderGoodsItem.price`,后端通过该字段下单
## 修复订单列表"待付款"Tab 下状态显示"待发货"
- 文件:`src/components/common/OrderCard/index.tsx`
- 问题:用户切到"待付款"Tab下方订单卡片状态文字显示"待发货",与 Tab 不一致
- 根因:线下付款(payType=9)订单的 `payStatus=false`(数据库 pay_status=0后端 `statusFilter=0` 的 SQL 条件 `pay_status=0 AND order_status=0` 把它归入"待付款"Tab 是合理的(待商家确认收款)。但 OrderCard 的 `getCardStatus``isOffline=true``!isOffline=false` → 跳过"待付款"分支,进入 `if (deliveryStatus === 10)` → 显示"待发货"
- 关键业务规则:
- 货到付款(8):后端 Controller `saveLegacy()` 下单时已 `setPayStatus(true)`,不会进入 statusFilter=0
- 线下付款(9):保持 `payStatus=false`,会进入 statusFilter=0
- 微信支付(1):未付款 `payStatus=false`,正常进入 statusFilter=0
- 修复:`getCardStatus` 第 30 行去掉 `&& !isOffline` 条件,改为 `if (!payStatus && !isCod) return { text: '待付款' }`。保留 `!isCod`(货到付款的特殊兜底,虽然正常流程不会走到这里);清理掉不再使用的 `isOffline` 变量
- 决策依据:用户认可"线下付款订单出现在待付款 Tab"是合理的,因此只改前端文案判断,不动后端 SQL
## 修复购物车同一商品并列显示(应合并数量)
- 现象:购物车页面同一商品并列显示多行,本应合并数量到一行
- 根因:后端 `ShopCartController.save()` 直接调用 MyBatis-Plus 默认 `shopCartService.save()`,每次加购都 INSERT 新行,没有"同用户+商品+SKU已存在则累加数量"逻辑
- 后端修复(`/Users/gxwebsoft/JAVA/guilixu-java`3 个文件):
1. `ShopCartService.java` — 接口新增 `boolean addToCart(ShopCart shopCart)` 方法
2. `ShopCartServiceImpl.java` — 实现 `addToCart`:按 user_id+goods_id+sku_id 查询是否已存在skuId 为 null/0 时匹配 IS NULL 或 =0存在则累加 cart_num 后 updateById不存在才 save引入 `QueryWrapper`
3. `ShopCartController.java``save()` 接口从 `shopCartService.save(shopCart)` 改为 `shopCartService.addToCart(shopCart)`
- 注意:`ShopCart.userId``Integer` 类型(不是 Long`goodsId` 是 Long
- 历史数据清理:提供 `sql/shop_cart_merge_duplicates.sql`,按 (user_id,goods_id,sku_id) 分组合并 cart_num 并删除重复行sku_id 可能为 NULL`<=>` null-safe 匹配);执行前先备份
- 编译验证Maven compile 成功
- 后端需重启服务才生效
## 购物车加购报 spec_info 字段缺失
- 现象:添加购物车时报 `Unknown column 'spec_info' in 'field list'`
- 原因:后端实体 `ShopCart.java` 定义了 `specInfo` 字段(存储"颜色:红色|尺寸:L"这类规格信息),但数据库表 `shop_cart` 未建 `spec_info` 列;`ShopCartParam``OrderBusinessService` 均在用该字段,属设计需保留
- 修复:新建迁移脚本 `/Users/gxwebsoft/JAVA/guilixu-java/sql/shop_cart_add_spec_info.sql``ALTER TABLE shop_cart ADD COLUMN spec_info VARCHAR(500) NULL ... AFTER spec`,需在数据库执行
## 修复登录页「未授权手机号」无降级入口
- 现象:用户反复反馈"又又又登录不了了",截图显示点击「手机号快捷登录」按钮后 toast 弹「未授权手机号」,用户被困在登录页
- 根因:`src/passport/login.tsx` 第 162-173 行 `onGetPhoneNumber` 回调里 `detail.code` 为 undefined用户拒绝授权errMsg 含 `user deny`/`fail`)时只 toast 了一句生硬文案,没有任何引导。但项目里其实有 `/passport/sms-login` 短信登录页(且在 `app.config.ts` 路由表里),`login.tsx` 却完全没暴露这个入口。`register.tsx` 同样有此问题,且其 weapp 环境下隐藏了短信按钮(原 `{!isWeapp && ...}` 判断)
- 修复2 个文件 + 1 个 scss
1. `src/passport/login.tsx`
- 新增 `goSmsLogin()` 函数:透传 `redirect` + 邀请参数(`inviter`/`source`/`t`)跳 `/passport/sms-login`
- 新增 `showPhoneAuthFailedModal(errMsg)`:按 errMsg 区分场景弹窗引导
- `user deny` → "您已取消微信手机号授权,可改用短信验证码登录"
- `privacy` → "微信未授权获取手机号,可改用短信验证码登录"
- `no permission` → "小程序暂未获得手机号授权,可改用短信验证码登录"
- 其他 → 通用"请尝试使用短信验证码登录"
- 弹窗带"短信登录"按钮自动跳 sms-login"我知道了"关闭
- 失败分支从 `Taro.showToast` 改为 `showPhoneAuthFailedModal(errMsg)`
- 主登录按钮下方新增长期可见的「使用短信验证码登录」链接(仿 register.tsx 的 goSmsLogin
2. `src/passport/login.scss`
- 新增 `.login-methods__alt` / `.login-methods__alt-link` 样式(白色下划线小字,居中)
3. `src/passport/register.tsx`
- 同步加 `showPhoneAuthFailedModal(errMsg)` 弹窗引导
- 去掉 `{!isWeapp && <Button>短信验证码注册/登录</Button>}` 包裹,让 weapp 下也始终显示短信按钮作为降级入口
- 类型检查:`npx tsc --noEmit` 无报错
- 业务规则:项目当前仅信任微信一键登录作为主路径,短信登录一直是兜底通道。后续如发现用户量大,可考虑直接在登录页主流程就两个按钮并排(不再让微信登录独占)
## sms-login 页面 UI 重构
- 原状:截图显示基本是裸的——只看到两个孤零零的 placeholder 文字发送验证码按钮和登录按钮都看不见。NutUI 的 `Input` + `Button` 在 Taro 小程序上经常渲染不出边框/背景,外层 `flex justify-between` 也没把验证码按钮挤出来
- 改造2 个文件):
1. `src/passport/sms-login.tsx` — 弃用 NutUI 改用 Taro 原生 `View/Text/Input`,重做整页布局
- 渐变背景 + 浮动光圈(和 login.tsx 同一套色系 `#667eea → #764ba2`
- 顶部圆形 logo140rpx 白底圆角 + 阴影)+ 标题 + 副标题
- 白色卡片输入区(圆角 28rpx + 阴影):手机号(📞)+ 灰线分隔 + 验证码(🔐 + 绿色胶囊「获取验证码」按钮)
- 绿色渐变登录大按钮(圆角 50rpx + 阴影 + 字间距 4rpx
- 「← 返回微信快捷登录」白色下划线链接(一键 navigateBack 回到 login.tsx避免用户被困
- 底部协议 + 服务协议/隐私政策超链接
- 业务逻辑state、60s 倒计时、handleSendCode/handleLogin 校验、redirect 跳转、邀请参数透传)全部保留
2. `src/passport/sms-login.scss`(新文件)— 配套样式渐变背景、浮动光圈动画gradientMove + float、卡片毛玻璃、按钮 hover 效果scale 0.98、分阶段入场动画slideDown / slideUp / fadeIn
- 类型检查:`npx tsc --noEmit` 无报错(项目原本的 `@tarojs/taro` 类型错误与本次无关)
- 编译验证:`npx taro build --type weapp` 14.89s 成功
- 设计稿预览:`.workbuddy/sms-login-preview.html`(按 375×812 真机尺寸渲染的 mockup可直接浏览器打开看
- 业务规则login/register/sms-login 三个页面已统一视觉风格(同一套渐变 + 圆形 logo + 大字标题 + 白色卡片),后续新增 passport 类页面照搬这一套即可
## 修复首页轮播图只显示一张的问题
- 文件:`src/pages/index/index.tsx`
- 现象:后台一个广告位下配置了 3 张图片,但小程序首页轮播只显示第一张
- 根因:原代码把每个 `CmsAd` 作为一个 `SwiperItem`,图片只取 `item.imageList?.[0]?.url`,所以无论后台配几张都只渲染首图
- 修复:
- 新增 `bannerSlides`:用 `flatMap` 把每个广告位下的 `imageList` 全部展开成独立轮播项key 用 `adId-uid`(无 uid 则 fallback 到索引)
- 保留 `item.image` 单图兜底逻辑,兼容只配单张图片的老数据
- 渲染 `SwiperItem` 改为遍历 `bannerSlides`,点击跳转仍使用对应广告位的 `path`
- 验证:代码逻辑走查通过;项目原有 `npx tsc --noEmit``@tarojs/taro` 类型定义错误与本次修改无关
## 首页轮播图高度改为按后台 width/height 自适应
- 文件:`src/pages/index/index.tsx`
- 原状:`Swiper``height` 固定写死 `160px`,与后台广告位配置脱节
- 修复:
- 新增 `bannerWrapWidth` 状态,页面渲染后用 `Taro.createSelectorQuery().select('.index-banner-wrap').boundingClientRect()` 获取容器实际宽度
- 新增 `bannerHeight`useMemo取第一个带有效 `width/height` 的广告位,按 `容器宽度 * (height / width)` 计算高度;无值或解析失败则 fallback `160px`
- 轮播容器增加 `index-banner-wrap` 类名用于选择器查询
- `Swiper` 和「暂无轮播图」占位区的高度都改用 `bannerHeight`
- 注意:宽度仍由容器 `w-full` + `mx-3` 边距控制,不直接套用后台 `width` 像素值;高度按比例缩放适配不同屏幕
## 修复「我的」页订单数量统计全为 0 的 bug
- 文件:`src/api/shop/shopUserCard/index.ts``src/api/shop/shopUserCard/model/index.ts``src/pages/user/user.tsx`
- 根因:前后端字段名不匹配
- 后端 `/api/user/orders/stats` 返回 `UserOrderStats` DTO 字段:`total/waitPay/waitDeliver/waitVerify/waitReceive/waitComment/completed/refund/deleted/canceled`(参见 `com.gxwebsoft.shop.dto.UserOrderStats`
- 前端 `getUserOrderStats()` 却读取 `pending/paid/shipped/completed`,导致 `pending/paid/shipped` 永远 undefined → 兜底为 0只有 `completed` 字段名巧合一致才有数
- 附带 buguser.tsx「我的订单」快捷入口的 `status` 值写的是 0/1/2/3`OrderListStatus`0待付款 1待发货 3待收货 5已完成不一致点击「待收货」「已完成」会跳到订单列表页错误的 tab
- 修复:
1. `UserOrderStats` 接口字段与后端 DTO 完全对齐(含 total/waitPay/waitDeliver/waitVerify/waitReceive/waitComment/completed/refund/deleted/canceled
2. `getUserOrderStats()` 改读后端真实字段,并用 `Number(d.x) || 0` 兜底
3. user.tsx 订单快捷入口 count 改用 `waitPay/waitDeliver/waitReceive/completed`status 改为 `0/1/3/5``OrderListStatus` 对齐
4. localStorage 缓存 key 从 `user_order_stats_v2` 升级到 `v3`,让旧格式缓存自动失效,避免冷启动先渲染旧格式导致仍是 0
- 关键约定:`OrderListStatus` 是前端订单 Tab 筛选状态的唯一真相源(-1全部/0待付款/1待发货/2待核销/3待收货/4待评价/5已完成/6退款/7已删除所有跳订单列表的入口传 status 都要对齐它
## 优化「我的订单」角标样式2026-07-15 22:55
- 文件:`src/pages/user/user.tsx`
- 用户反馈:截图中「已完成」下方出现了一个"0"字hot reload 之前旧版代码里 `<View>{item.count}</View>` 直接渲染了 count0 也会显示)。同时希望统计数字更"右上角"
- 修改:
1. 角标位置 `top-0 right-2``-top-1 -right-1`:让小红点紧贴 cell 右上角外缘,更像标准徽标
2. 角标尺寸 `text-xs` + 自由拉伸 → `min-w-[16px] h-4` + 10px/12px 字体:单数字也保持圆形,多位数字自动变椭圆
3. 0 判断 `item.count && item.count > 0``item.count && Number(item.count) > 0`:用 `Number()` 包一层,防止后端返回字符串 "0" 时 `> 0` 误判
## 抽取通用 Badge 组件2026-07-15 23:10
- 新文件:`src/components/common/Badge/index.tsx`
- 用途:替代项目里多处复制粘贴的内联角标 Viewuser.tsx / index/index.tsx / store/center/index.tsx 都有 `min-w-[16px] h-4 px-1 bg-red-500 rounded-full` 这种重复写法)
- API
```ts
interface BadgeProps {
count?: number | null // 0 / null / undefined / 负数 → 不渲染
max?: number // 默认 99超过显示 `${max}+`
className?: string // 外层定位 className如 'absolute -top-1 -right-1'
color?: string // 默认 #ef4444red-500
fontSize?: number // 默认 10px
}
```
- user.tsx 已替换:内联 `{Number(item.count) > 0 && <View ...>}` → `<Badge count={item.count} className='absolute -top-1 -right-1' />`
- 风格遵循项目 common 组件约定:函数式组件 + 默认导出 + Taro View/Text + Tailwind className
- 待办(未做):首页 `src/pages/index/index.tsx:248` 和门店中心 `src/pages/store/center/index.tsx:218` 也有相同内联角标写法,后续可统一替换为 Badge
## 抽取通用 ArrowRight 组件2026-07-15 23:13
- 新文件:`src/components/common/ArrowRight/index.tsx`
- 用途:替代项目里到处可见的 `{'>'}` / `'>'` 文本箭头
- 实现CSS V 形border-top + border-right + rotate(45deg)),颜色用 `currentColor` 跟父级 `text-*` 类走
- API
```ts
interface ArrowRightProps {
className?: string // 颜色用 text-* 类(如 'text-gray-400'),间距用 ml-1 等
size?: number // V 形边长 px默认 6
thickness?: number // 描边粗细 px默认 1.5
}
```
- user.tsx 已替换 3 处 `{'>'}`
1. 用户卡片头部箭头(白色 60% 透明)→ `<ArrowRight className='text-white text-opacity-60' />`
2. 「全部订单 >」→ 拆成 `<View className='flex items-center text-xs text-gray-400'><Text>全部订单</Text><ArrowRight className='ml-1' /></View>`
3. 菜单项末尾箭头amber-700 / gray-300→ `<ArrowRight className={...} />`
- 关键点Tailwind 的 `text-opacity-60` 会通过 `--tw-text-opacity` 影响 colorcurrentColor 会继承这个带 opacity 的 color所以 ArrowRight 的透明度也会跟随
- 待办(未做):其它页面也有 `{'>'}` 文本箭头,后续可统一替换
## 统一替换 index/index.tsx 和 store/center 的内联角标为 Badge2026-07-15 23:17
- Badge 组件 API 扩展:新增 `size`(默认 16和 `fontWeight`(默认 'normal'参数padding 从 className 的 `px-1` 改为 inline style 根据 size 自动计算16→2px20→3px避免与调用方 className 冲突
- `src/pages/index/index.tsx:248`数字角标16x16/10px→ `<Badge count={newOrderCount} className='absolute -top-1 -right-1' />`
- 注意:保留了 `isClerk && newOrderCount > 0` 外层判断(虽然 Badge 内部 0 不渲染,但下面还有 `newOrderCount === 0` 的小红点逻辑,保留判断让语义更明确)
- `src/pages/store/center/index.tsx:218`数字角标20x20/11px/加粗)→ `<Badge count={getBadgeCount(card.key)} className='absolute -top-1 -right-1' size={20} fontSize={11} fontWeight='bold' />`
- 简化了外层判断:`card.showBadge && getBadgeCount > 0` → `card.showBadge`Badge 内部已处理 0 不渲染)
- 项目内联角标已全部统一为 Badge 组件,共 3 处user.tsx / index/index.tsx / store/center/index.tsx
## tabBar「订单」换「购物车」后的链接修正2026-07-15 23:31
- 背景tabBar 原「订单」(pages/order/list) 改为「购物车」(pages/shop/cart)order/list 不再是 tabBar 页
- A. 6 处 `switchTab(order/list)` → `navigateTo`switchTab 只能跳 tabBar 页,否则失败):
- pages/shop/checkout.tsx:304下单成功跳订单列表
- pages/user/user.tsx:144,149handleOrderTabClick / handleViewAllOrders
- pages/user/index.tsx:158,175,190订单概览数/全部订单/订单状态快捷入口)
- B. 4 处 tabBar 判断列表更新为实际 4 个 tabBarindex/shop/shop-cart/user
- pages/user/index.tsx:11、pages/index/index.tsx:185tabBarPages
- passport/login.tsxtabBarUrls顺带去掉非 tabBar 的 points/index
- passport/auth-flow.ts:12TABBAR_URLS原含错误路径 order/order
- C. 2 处 isTabBarUrl 修正(原路径 cart/cart、category/index 不存在):
- passport/register.tsx:88、passport/sms-login.tsx:36
- D. pages/order/list.tsx:127 注释更新(去掉"tabBar 页"描述)
- 遗留未改预先存在pages/user/index.tsx:140 `switchTab(points/index)` 积分页非 tabBar会失败
## product-detail 购物车角标改用 Badge 组件2026-07-15 23:42
- `src/pages/shop/product-detail.tsx` 底部购物车入口的数量角标,原为手写内联 View+Text带白边框、top:-6px/right:-10px 定位),改为 `<Badge count={totalCount} className='absolute -top-1 -right-1' />`
- 用法与 user.tsx "我的订单"图标角标(:284完全一致外层已有 `relative inline-flex` 容器Badge 的 absolute 定位正常
- 新增 import `Badge from '@/components/common/Badge'`
- 去掉了原来的白色边框,与"我的订单"角标样式统一
## 修复订单快捷入口 tab 错位 bug2026-07-15 23:48
- Bug`pages/user/user.tsx` "我的订单"快捷入口(待付款/待发货/待收货/已完成)把"订单状态值"0/1/3/5写入 `order_tab` storagenavigateTo 到 order/list 后,`order/list.tsx:42` 用 `useState(Number(initTab) || 0)` 把传入值直接当 **tabIndex** 用 → 全部错位一个 tab待付款 0→全部, 待发货 1→待付款, 待收货 3 巧合对, 已完成 5 越界→0→全部
- `pages/user/index.tsx` 传 0/1/2/3 也全错位一个 tab
- order/list.tsx `tabList` 顺序index 0=全部 / 1=待付款 / 2=待发货 / 3=待收货 / 4=已完成
- 修复:调用方把字段从 `status`(订单状态)改为 `tabIndex`1/2/3/4navigateTo 加 `?tab=${tabIndex}`order/list 已支持 `router.params.tab` 优先于 storage未登录拦截的 redirect 也带 `?tab=`
- 改动:
- `src/pages/user/user.tsx``handleOrderTabClick` 参数 `status`→`tabIndex` + redirect/navigateTo 带 `?tab=`menuItems 字段 `status(0/1/3/5)`→`tabIndex(1/2/3/4)``key={item.tabIndex}`、`onClick` 传 `item.tabIndex`
- `src/pages/user/index.tsx`:硬编码数组 `status(0/1/2/3)`→`tabIndex(1/2/3/4)``key={item.tabIndex}`、onClick + `?tab=${item.tabIndex}`
- 其他"全部订单"类入口user.tsx:149 handleViewAllOrders、user/index.tsx:22/158/175不传 `?tab=`,进默认"全部" tab合理

View File

@@ -0,0 +1,66 @@
# 2026-07-16 工作记录
## 修复手机号授权登录失败
- 问题:用户点击登录页「手机号快捷登录」后,微信返回 privacy 相关错误,触发降级弹窗「未获取到手机号」。
- 原因:基础库 3.16.1+ 对敏感 APIgetPhoneNumber / chooseImage 等)强制要求隐私协议授权;项目之前直接 resolve agree可能被微信拒绝。
- 修改文件:
- `src/app.tsx`:在 `onNeedPrivacyAuthorization` 中通过 `Taro.showModal` 展示隐私协议授权弹窗,用户点击「同意」后再 `resolve({ event: 'agree', button: 'agree' })`,拒绝则 `resolve({ event: 'disagree', button: 'disagree' })`
- `src/api/system/file/index.ts`:导出 `ensurePrivacyAuthorized()` 工具函数。
- `src/passport/login.tsx`
- 页面显示时通过 `useDidShow` 预检隐私协议;
- 统一使用 `request.post` + `SERVER_API_URL` 调用 `/wx-login/loginByMpWxPhone`
- 替换原 `Taro.request` 和硬编码域名。
- `src/passport/register.tsx`
- 同样增加 `useDidShow` 隐私协议预检;
- 修复接口基址为 `SERVER_API_URL`(原代码误写 `https://shop-api.websoft.top`
- 统一使用 `request.post` 调用登录接口;
- 删除未使用的 `LoginResponse` 类型。
- `src/passport/pay/index.tsx`:修复缺失的 `@/api/passport/wx-login` 导入,改为使用 `@/api/layout` 中的 `getWxOpenId` / `loginByOpenId`
- 验证:`npx taro build --type weapp` 构建成功。
- 后续提醒:小程序后台需确认已添加「开发者收集你的手机号」隐私声明,并确保「手机号快速验证组件」能力已开通且额度充足。
## 继续修复:用户已同意隐私协议仍无法登录
- 问题:用户已在弹窗中点击「同意」,但随后仍出现「未获取到手机号」降级弹窗。
- 原因:`Taro.showModal` 的按钮不是微信认可的隐私授权触发器;基础库 3.16.1+ 要求使用 `open-type="agreePrivacyAuthorization"` 的 Button 组件。
- 修改文件:
- 新增 `src/utils/privacy.ts`:全局隐私协议授权管理器,保存 resolve 回调与弹窗显隐状态。
- 新增 `src/components/PrivacyModal/index.tsx` + `.scss`:使用原生 Button 的 `open-type="agreePrivacyAuthorization"`,用户点击后触发 `onAgreePrivacyAuthorization` 再 resolve。
- `src/app.tsx`
- 移除 `Taro.showModal` 实现;
-`onNeedPrivacyAuthorization` 中调用 `privacyManager.show(resolve)`
- 在 JSX 中渲染 `<PrivacyModal />`
- 验证:`npx taro build --type weapp` 构建成功。
- 后续提醒:
- 由于之前的 `showModal` 授权无效,用户可能需要清理开发者工具/真机缓存后重新进入小程序,才能触发新的 PrivacyModal。
- 仍需在小程序后台添加「手机号」隐私声明,并确认手机号快速验证组件已开通、额度充足。
## 调整隐私授权触发位置:登录页不再主动预检,仅在门店上传图片时触发
- 用户反馈:隐私授权框不应在手机号登录时主动弹出,应只在门店中心上传图片/拍照时弹出。
- 修改文件:
- `src/passport/login.tsx`:移除 `useDidShow` 中主动调用 `ensurePrivacyAuthorized()` 的逻辑;删除对应 import。
- `src/passport/register.tsx`:同样移除 `useDidShow` 中的隐私预检;删除对应 import。
- `src/components/PrivacyModal/index.tsx`:将弹窗文案改为更通用表述,避免用户误以为是相册授权。
- `src/pages/store/orders/index.tsx`:在 `chooseProofImage` 调用 `Taro.chooseImage` 前增加 `ensurePrivacyAuthorized()` 预检(该页面直接调 chooseImage不走 `uploadFile`)。
- 保留逻辑:
- `src/app.tsx` 全局 `onNeedPrivacyAuthorization` 仍通过 `PrivacyModal` 处理,这是微信基础库 3.16.1+ 强制机制,无法移除。
- `src/api/system/file/index.ts``uploadFile()` 内部已调用 `ensurePrivacyAuthorized()`,门店商品管理上传图片时会自动触发隐私授权。
- 说明:登录时若用户尚未同意隐私协议,微信仍会在点击 `getPhoneNumber` 按钮时强制触发 `onNeedPrivacyAuthorization`。这是微信机制,无法避免;但进入登录页本身不会再主动弹框。
- 验证:待构建完成。
## 恢复手机号快捷登录功能(回退 request → Taro.request
- 背景origin/main 上 `ca78e73`(小程序支付隐私 commit`passport/login.tsx``passport/register.tsx` 的登录请求从 `Taro.request` 改成了统一封装的 `request` 工具(`@/utils/request`)。
- 问题根因:`request` 拦截器会自动从 storage 读取 `access_token` 并注入 Authorization 头;对于 `loginByMpWxPhone` 这种登录接口,如果本地残留旧的/过期 token请求会带上它后端返回 401 或认证冲突,导致快捷登录失败。原来的 `Taro.request` 版本不带这个头,所以没问题。
- 恢复方式:通过 git diff 生成 patch将两个文件的登录请求改回 `Taro.request`(直接请求,不带 Authorization 头),保留其他所有改进(隐私协议 PrivacyModal、UI、邀请关系处理、禁用检查等
- 具体改动:
- `src/passport/login.tsx`
- 移除 `import request from '@/utils/request'``SERVER_API_URL` 导入;
- `fetchUserInfo()` 改回 `Taro.request` GET `/api/auth/user`,手动带 `Authorization: Bearer ${token}` 头;
- `handleGetPhoneNumber` 中登录请求改回 `Taro.request` POST `/api/wx-login/loginByMpWxPhone`,不带 Authorization 头;
- 响应数据结构从 `res.code/res.data` 调整为 `res.data.code/res.data.data`Taro.request 多一层包裹)。
- `src/passport/register.tsx`:同样改动,恢复 `Taro.request`,移除 `request``SERVER_API_URL` 导入。
- patch 文件:`login_recover.patch``register_recover.patch`(已应用到工作区,未提交)。

View File

@@ -4,14 +4,26 @@
- 项目名xinlong-shop-taroTaro 微信小程序商城)
- 技术栈Taro + React + TypeScript + Tailwind CSS
- 后端 API 前缀:`/shop/``/api/`
- **后端项目路径:`/Users/gxwebsoft/JAVA/guilixu-java`**(⚠️ 不要找 paopao-java 或 websopy-java那是旧的
- **后端 Maven 编译命令**`cd /Users/gxwebsoft/JAVA/guilixu-java && "/Applications/IntelliJ IDEA Ultimate.app/Contents/plugins/maven/lib/maven3/bin/mvn" compile`guilixu 的 mvnw 损坏,用 IntelliJ 自带的 mvn
- 门店店员身份判断:通过 `getMyClerk()` API (`/shop/shop-store-user/my`) 返回值判断
- VIP/分销商申请记录表:`ShopDealerApply`,状态码 10待审核/20通过/30驳回
## 用户状态与禁用机制
- 用户状态字段:`User.status``0` = 正常,`1` = 禁用
- 禁用接口:`updateUserStatus(userId, status)` → PUT `/system/user/status`
- 禁用拦截工具:`src/utils/auth.ts``isUserDisabled(user)``blockDisabledUser(user)`
- 拦截层级UserContext 启动异步校验 + refreshUser/syncFromStorage/loginUser 检查 + 登录 API 和登录页检查
- 用户管理页:`src/pages/store/users/index.tsx`(门店中心入口,支持搜索/筛选/查看详情/禁用启用)
## VIP 会员机制
- 用户在 `vip-upgrade` 页面提交门店名称和地址申请
- 门店店员在 `vip-review` 页面审核(通过/驳回)
- VIP 会员特权:商品显示 `dealerPrice` 价格,下单使用该价格结算
- VIP 状态判断:`isVipMember()` 从 localStorage 缓存读取,`checkAndCacheVipStatus(userId)` 异步更新
- VIP 状态判断:`isVipMember()` 从 localStorage 缓存读取(同步)`checkAndCacheVipStatus(userId)` 异步更新
- **响应式 VIP 状态**:使用 `useVipStatus` hook`src/hooks/useVipStatus.ts`),组件挂载时异步校验并更新缓存 + React stateisVip 变化触发重渲染
- **已接入 `useVipStatus` 的页面/组件**商品详情、结算、商城首页、分类页、购物车页、CartContext、SkuSelector、ProductCard
- `OrderGoodsItem` 接口新增 `price` 字段VIP 下单时传 dealerPrice 给后端
## 商品价格字段约定
- `product.price` - 到手价(主价格)
@@ -27,6 +39,7 @@
- 门店中心:`src/pages/store/center/index.tsx`
- 购物车 API`src/api/shop/shopCart/index.ts`(批量删除接口 `/shop/shop-cart/batch` 后端期望直接接收 `ArrayList<Integer>``[1,2,3]`,不要用 `{ ids }` 包裹)
- 购物车 Context`src/contexts/CartContext.tsx`
- 购物车合并加购:后端 `ShopCartServiceImpl.addToCart()` 按 user_id+goods_id+sku_id 判重,已存在则累加 cart_num不存在才新增skuId 为 null/0单规格匹配 IS NULL 或 =0。`ShopCart.userId` 是 Integer非 Long。历史重复数据清理脚本 `sql/shop_cart_merge_duplicates.sql`
## 订阅消息
- 模板 ID`sh1K9iK7vZjebUNFu6OsMsnsJxm4whThWGrhN7I4zVg`(交易提醒)
@@ -34,6 +47,28 @@
- 场景说明:下单提醒
- 新订单检测 Hook`src/hooks/useNewOrderDetector.ts`30秒轮询页面隐藏暂停
## 微信隐私协议(基础库 3.16.1+ 强制)
- 报错特征:`chooseImage:fail api scope is not declared in the privacy agreement` / `getPhoneNumber:fail ... privacy ...` + `errno:112`
- 代码修复:
1. 调用 `Taro.chooseImage` / `chooseMedia` / `getPhoneNumber` 等敏感 API 前,先 `getPrivacySetting``requirePrivacyAuthorize` 预检(封装在 `src/api/system/file/index.ts``ensurePrivacyAuthorized()` 里,已 export。**注意**:不应在登录页 `useDidShow` 中主动预检,避免用户一进入登录页就弹框;应由微信在用户点击 `getPhoneNumber` 时自行触发 `onNeedPrivacyAuthorization`
2. `src/app.tsx``useLaunch` 中注册 `Taro.onNeedPrivacyAuthorization` 回调,并通过 `src/components/PrivacyModal` 展示隐私协议授权弹窗。弹窗内的 Button 必须设置 `open-type="agreePrivacyAuthorization"`,用户点击后触发 `onAgreePrivacyAuthorization``resolve({ event: 'agree', button: 'agree' })``Taro.showModal` 的按钮无法被微信识别为有效的隐私授权
3. 门店上传图片页面在调用 `chooseImage` 前主动预检:`src/pages/store/orders/index.tsx``chooseProofImage` 已加 `ensurePrivacyAuthorized()``src/pages/store/goods/index.tsx` 通过 `uploadFile()` 上传,`uploadFile()` 内部已预检。
- 手动配置:**小程序管理后台** → 设置 → 第三方设置 → 用户隐私保护指引 → 添加「开发者收集你的相册/摄像头」和「手机号」声明
- ⚠️ **`requiredPrivateInfos` 字段只接受位置类 API 白名单**chooseAddress/chooseLocation/choosePoi/getFuzzyLocation/getLocation/onLocationChange/startLocationUpdate/startLocationUpdateBackground不要加 `chooseImage`/`chooseMedia`/`getPhoneNumber` 等非位置类,会导致 app.json 解析失败
- 直接调 `Taro.chooseImage` 的页面:`pages/order/evaluate/index.tsx``pages/after-sale/apply/index.tsx``pages/store/orders/index.tsx` —— 这些页面绕过了 `uploadFile`,需要单独加 `ensurePrivacyAuthorized` 预检
- 直接调 `getPhoneNumber` 的页面:`passport/login.tsx``passport/register.tsx` —— 不再主动预检,依赖微信点击按钮时自动触发 `onNeedPrivacyAuthorization`;保留降级短信登录
- 隐私授权弹窗组件:`src/components/PrivacyModal/index.tsx`(全局单例,通过 `src/utils/privacy.ts` 管理显隐与 resolve
## 手机号授权登录降级方案2026-07-15 ~ 07-16 修复)
- **业务现实**`getPhoneNumber` 按钮被用户拒绝后,微信会短期"记住"用户的选择,再次点击会直接 fail`errMsg: getPhoneNumber:fail user deny`)。一旦进入这个状态,前端必须提供降级入口
- **统一模式**`passport/login.tsx``passport/register.tsx` 都有 `getPhoneNumber` 回调,**不允许**只 toast 一句"未授权手机号"就完事
- 必做:失败时调 `showPhoneAuthFailedModal(errMsg)`,按 errMsg 区分场景弹窗user deny / privacy / no permission / 通用)
- 弹窗带"短信登录"按钮自动跳 `/passport/sms-login`(透传 redirect + 邀请参数 inviter/source/t
- 必做:主登录按钮**长期可见**地提供"使用短信验证码登录"链接,不要依赖弹窗
- **接口地址统一**login/register 的手机号登录接口统一使用 `SERVER_API_URL + '/wx-login/loginByMpWxPhone'`register 之前误写 `https://shop-api.websoft.top`
- **短信登录页**`/passport/sms-login`(路由表里已有),调 `loginBySms` + `sendSmsCaptcha` 接口
- **已落地页面**`passport/login.tsx``passport/register.tsx`(隐私协议预检 + 统一 request + 降级弹窗)
## 分享 / 朋友圈能力2026-07-13 接入)
- 统一封装:`src/hooks/useShare.ts``useShare({title, path?, query?, imageUrl?, enableTimeline?, enableCopyUrl?})`,一次注册 `useShareAppMessage` + `useShareTimeline` + 可选 `onCopyUrl` 复制链接;自动在 path/query 追加 `inviter=${当前用户id}` 做裂变归因;`isTimelineSinglePage()` 判断朋友圈单页模式 scene===1154
- 海报组件:`src/components/SharePoster/index.tsx`Canvas 2D 绘制封面+标题+价格+小程序码,返回临时图路径作 imageUrl
@@ -41,3 +76,28 @@
- 裂变约定:分享链接 query 用 `inviter`,与 `src/utils/invite.ts``parseInviteParams` 读取字段一致
- 已接入页面:商品详情、积分商品、拼团、秒杀、活动、赛事(带 canvas 海报邀请下级、分销推广、升级VIP、分享赚佣金仅 enableShare + useShare无海报首页好友+朋友圈+复制链接三按钮全亮)
- 注意tabBar 页**也支持朋友圈分享**(之前误判已纠正)——只要 config 开 enableShareTimeline + 定义 onShareTimeline 即显示按钮;"tabBar 不渲染"仅指朋友圈打开后的单页模式里底部 tabBar 不显示。朋友圈/复制链接开发者工具可能看不到入口,必须真机测试。复制链接是 Beta基础库 2.14.3+)。
## 订单备注字段约定2026-07-15
- `buyerRemarks` — 买家备注用户下单时填写的订单备注checkout 页提交)
- `merchantRemarks` — 商户备注:门店修改金额时填写的原因记录
- `comments` — 系统/其他备注:保留给系统内部使用
- `ShopOrder``OrderCreateRequest` 接口均已包含 `buyerRemarks``merchantRemarks` 字段
## 用户浏览记录功能2026-07-15
- 存储模式:**去重累加**(同一用户+商品只留一条visit_count 累加5分钟内去重
- 数据库表:`shop_goods_browse`字段id/user_id/goods_id/tenant_id/merchant_id/visit_count/last_visit_time/browse_source/create_time/update_time
- 唯一索引:`uk_user_goods (user_id, goods_id)`;查询索引:`idx_user_time``idx_goods_time`
- 后端 API 路径:`/api/shop/shop-goods-browse`POST 上报 / GET page 我的足迹 / DELETE {id} 删单条 / DELETE 清空 / GET admin/page 后台查询)
- 前端 API`src/api/shop/shopGoodsBrowse/index.ts`reportBrowse 静默上报 / pageBrowseHistory / deleteBrowseRecord / clearBrowseHistory
- 商品详情页 `product-detail.tsx``addToHistory()` 已接入:已登录时异步调 reportBrowse未登录仅本地 browse_history
- 浏览历史页 `src/pages/user/history-list/index.tsx` 已改为服务端分页(登录),未登录回退本地存储
- 后端文件:`ShopGoodsBrowse.java`(entity) / `ShopGoodsBrowseParam.java` / `ShopGoodsBrowseMapper.java`+XML / `ShopGoodsBrowseService.java`+Impl / `ShopGoodsBrowseController.java`
- 建表 SQL`/Users/gxwebsoft/JAVA/guilixu-java/sql/shop_goods_browse.sql`
- 后台管理前端:`/Users/gxwebsoft/VUE/guilixu-admin`Vue3 + Ant Design Vue + Vite非 RuoYi
- 注意:`BaseController.success()``success(IPage<T>)` 重载,泛型方法返回类型用 `ApiResult<?>` 而非 `ApiResult<PageResult<T>>`(否则 fail() 分支类型不兼容)
## tabBar 配置2026-07-15
- 当前 4 个 tabBar`/pages/index/index`(首页)、`/pages/shop/index`(分类)、`/pages/shop/cart`(购物车)、`/pages/user/user`(我的)
- 「订单」(pages/order/list) 已从 tabBar 移除,改为普通页,所有进入订单列表的地方用 `navigateTo` 而非 `switchTab`
- 多处 tabBar 判断列表login/register/sms-login/auth-flow/user/index/index/index已统一为实际 4 个 tabBar 路径
- ⚠️ pages/user/index.tsx:140 仍用 `switchTab(points/index)`,积分页非 tabBar待修

View File

@@ -0,0 +1,309 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>sms-login UI 预览</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: #f0f0f3;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 40px 0 80px;
font-family: -apple-system, "PingFang SC", "Helvetica Neue", sans-serif;
}
h1 { font-size: 18px; color: #666; margin-bottom: 30px; font-weight: 500; }
.phone {
width: 375px;
height: 812px;
background: #fff;
border-radius: 50px;
box-shadow: 0 30px 80px rgba(0,0,0,0.18), 0 0 0 12px #1a1a1a, 0 0 0 14px #2a2a2a;
overflow: hidden;
position: relative;
}
.phone::before {
content: "";
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 150px;
height: 28px;
background: #1a1a1a;
border-radius: 0 0 18px 18px;
z-index: 100;
}
.status-bar {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 44px;
padding: 0 30px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 15px;
color: #1a1a1a;
font-weight: 600;
z-index: 50;
}
.status-bar .right {
display: flex;
gap: 6px;
align-items: center;
}
.nav-bar {
position: absolute;
top: 44px;
left: 0;
right: 0;
height: 44px;
display: flex;
justify-content: center;
align-items: center;
font-size: 17px;
font-weight: 600;
color: #1a1a1a;
z-index: 50;
}
.nav-bar::before {
content: "<";
position: absolute;
left: 18px;
font-size: 24px;
color: #1a1a1a;
}
.nav-bar::after {
content: "···";
position: absolute;
right: 60px;
font-size: 22px;
color: #1a1a1a;
letter-spacing: 2px;
}
.screen {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
position: relative;
overflow: hidden;
padding-top: 88px;
}
.bg-circle {
position: absolute;
border-radius: 50%;
background: #fff;
opacity: 0.1;
}
.bg-c1 { width: 400px; height: 400px; top: -150px; right: -100px; }
.bg-c2 { width: 300px; height: 300px; bottom: 10%; left: -100px; opacity: 0.08; }
.bg-gradient {
position: absolute;
top: -50%; left: -50%; right: -50%; bottom: -50%;
background: radial-gradient(circle at 30% 50%, rgba(255,255,255,0.1) 0%, transparent 50%),
radial-gradient(circle at 70% 80%, rgba(255,255,255,0.08) 0%, transparent 50%);
}
.content {
position: relative;
z-index: 1;
padding: 0 50px;
}
.header {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 32px;
margin-bottom: 60px;
}
.logo {
width: 140px;
height: 140px;
border-radius: 35px;
background: rgba(255,255,255,0.95);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 36px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
.logo-icon { font-size: 72px; line-height: 1; }
.title {
font-size: 52px;
font-weight: 700;
color: #fff;
margin-bottom: 18px;
text-shadow: 0 2px 10px rgba(0,0,0,0.2);
letter-spacing: 2px;
}
.subtitle {
font-size: 26px;
color: rgba(255,255,255,0.85);
text-align: center;
}
.card {
background: rgba(255,255,255,0.98);
border-radius: 28px;
padding: 12px 32px;
margin-bottom: 48px;
box-shadow: 0 20px 60px rgba(0,0,0,0.18);
}
.field {
display: flex;
align-items: center;
height: 110px;
}
.field-icon { font-size: 36px; margin-right: 20px; line-height: 1; }
.field-input {
flex: 1;
font-size: 30px;
color: #333;
background: transparent;
border: none;
outline: none;
}
.field-input::placeholder { color: #b8b8b8; }
.divider {
height: 1px;
background: linear-gradient(to right, transparent, #e8e8e8 20%, #e8e8e8 80%, transparent);
}
.send-btn {
flex-shrink: 0;
height: 64px;
padding: 0 22px;
border-radius: 32px;
background: linear-gradient(135deg, #07c160, #06ad56);
color: #fff;
font-size: 26px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
margin-left: 16px;
box-shadow: 0 6px 20px rgba(7, 193, 96, 0.35);
white-space: nowrap;
}
.submit {
height: 100px;
width: 100%;
border-radius: 50px;
background: linear-gradient(135deg, #07c160, #06ad56);
color: #fff;
font-size: 34px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
letter-spacing: 4px;
box-shadow: 0 12px 36px rgba(7, 193, 96, 0.4);
margin-bottom: 36px;
}
.back {
display: flex;
align-items: center;
justify-content: center;
height: 80px;
margin-bottom: 32px;
}
.back-icon { font-size: 30px; color: rgba(255,255,255,0.9); margin-right: 10px; }
.back-text {
font-size: 28px;
color: rgba(255,255,255,0.9);
text-decoration: underline;
}
.tips {
position: absolute;
bottom: 40px;
left: 0;
right: 0;
padding: 0 50px;
display: flex;
justify-content: center;
align-items: center;
flex-wrap: wrap;
}
.tips-text { font-size: 22px; color: rgba(255,255,255,0.7); }
.tips-link { font-size: 22px; color: #fff; text-decoration: underline; margin: 0 4px; }
.legend {
margin-top: 30px;
padding: 20px 28px;
background: #fff;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
font-size: 13px;
color: #555;
max-width: 375px;
line-height: 1.7;
}
.legend strong { color: #07c160; }
</style>
</head>
<body>
<h1>sms-login.tsx 新 UI 预览(按真机 375×812 渲染)</h1>
<div class="phone">
<div class="status-bar">
<span>18:36</span>
<span class="right">📶 🔋</span>
</div>
<div class="nav-bar">验证码登录</div>
<div class="screen">
<div class="bg-gradient"></div>
<div class="bg-circle bg-c1"></div>
<div class="bg-circle bg-c2"></div>
<div class="content">
<div class="header">
<div class="logo"><span class="logo-icon">📱</span></div>
<div class="title">验证码登录</div>
<div class="subtitle">未注册的手机号验证后将自动注册</div>
</div>
<div class="card">
<div class="field">
<span class="field-icon">📞</span>
<input class="field-input" type="text" placeholder="请输入手机号码" maxlength="11" />
</div>
<div class="divider"></div>
<div class="field">
<span class="field-icon">🔐</span>
<input class="field-input" type="text" placeholder="请输入6位验证码" maxlength="6" />
<div class="send-btn">获取验证码</div>
</div>
</div>
<div class="submit">登录 / 注册</div>
<div class="back">
<span class="back-icon"></span>
<span class="back-text">返回微信快捷登录</span>
</div>
</div>
<div class="tips">
<span class="tips-text">登录即代表您已阅读并同意</span>
<span class="tips-link">《服务协议》</span>
<span class="tips-text"></span>
<span class="tips-link">《隐私政策》</span>
</div>
</div>
</div>
<div class="legend">
<strong>视觉变更:</strong><br>
① 紫蓝渐变背景 + 浮动光圈(和 login.tsx 同一套色系)<br>
② 顶部圆形 logo + 大字标题 + 副标题<br>
③ 白色卡片输入区(圆角 28px + 阴影),手机号/验证码图标 + 灰线分隔<br>
④ 验证码右侧绿色胶囊「获取验证码」按钮倒计时会变成「60s 后重试」)<br>
⑤ 绿色渐变登录大按钮(圆角 50px + 阴影)<br>
⑥ 「← 返回微信快捷登录」白色下划线链接(避免用户被困住)<br>
⑦ 底部协议 + 服务协议/隐私政策超链接<br>
<br>
<strong>业务逻辑保留:</strong>表单 state、60s 倒计时、handleSendCode/handleLogin 校验、redirect 跳转、邀请参数透传inviter/source/t全部不变
</div>
</body>
</html>

90
login_recover.patch Normal file
View File

@@ -0,0 +1,90 @@
--- a/src/passport/login.tsx 2026-07-16 15:54:54.000000000 +0800
+++ b/src/passport/login.tsx 2026-07-16 15:53:09.000000000 +0800
@@ -4,9 +4,8 @@
import { TenantId } from '@/config/app'
import { getWxOpenId } from '@/api/layout'
import { getUserInfo } from '@/api/layout'
-import { saveStorageByLoginUser, SERVER_API_URL } from '@/utils/server'
+import { saveStorageByLoginUser } from '@/utils/server'
import { isUserDisabled } from '@/utils/auth'
-import request from '@/utils/request'
import {
checkAndHandleInviteRelation,
hasPendingInvite,
@@ -73,21 +72,22 @@
}
}
-/** 获取用户信息(使用临时 token避免本地 storage 尚未写入) */
+/** 获取用户信息 */
async function fetchUserInfo(token: string) {
try {
- const res: any = await request.get(
- `${SERVER_API_URL}/auth/user`,
- {},
- {
- header: {
- Authorization: `Bearer ${token}`,
- },
- showError: false,
- }
- )
- if (res?.code === 0 && res?.data) {
- return res.data
+ const serverUrl = 'https://server.websoft.top'
+ const res: any = await Taro.request({
+ url: `${serverUrl}/api/auth/user`,
+ method: 'GET',
+ header: {
+ Authorization: `Bearer ${token}`,
+ 'content-type': 'application/json',
+ TenantId: String(TenantId),
+ },
+ })
+
+ if (res.data?.code === 0 && res.data?.data) {
+ return res.data.data
}
return null
} catch (e) {
@@ -215,21 +215,23 @@
const inviteParams = parseInviteParams({ query: router?.params })
const refereeId = inviteParams?.inviter ? parseInt(inviteParams.inviter, 10) : 0
- const res: any = await request.post(
- `${SERVER_API_URL}/wx-login/loginByMpWxPhone`,
- {
+ const serverUrl = 'https://server.websoft.top'
+ const res: any = await Taro.request({
+ url: `${serverUrl}/api/wx-login/loginByMpWxPhone`,
+ method: 'POST',
+ data: {
code: phoneCode,
notVerifyPhone: true,
refereeId,
sceneType: 'save_referee',
tenantId: Number(TenantId),
},
- { showError: false }
- )
+ header: { 'content-type': 'application/json', TenantId: String(TenantId) },
+ })
- if (res?.code === 0 && res?.data?.access_token) {
- const token = res.data.access_token
- let user = res.data.user
+ if (res.data?.code === 0 && res.data?.data?.access_token) {
+ const token = res.data.data.access_token
+ let user = res.data.data.user
// 获取最新的用户信息
const freshUserInfo = await fetchUserInfo(token)
@@ -260,7 +262,7 @@
Taro.showToast({ title: '登录成功', icon: 'success' })
setTimeout(() => navigateAfterLogin(), 800)
} else {
- Taro.showToast({ title: res?.message || '登录失败', icon: 'none' })
+ Taro.showToast({ title: res.data?.message || '登录失败', icon: 'none' })
}
} catch (e: any) {
console.error('微信登录失败:', e)

316
models.json Normal file
View File

@@ -0,0 +1,316 @@
{
"models": [
{
"id": "auto",
"name": "AuTo",
"vendor": "Custom",
"url": "https://api.getaigate.com/v1/chat/completions",
"apiKey": "sk-aigate-2335f1670e169eb855c88f6ed58862cb",
"maxInputTokens": 128000,
"maxOutputTokens": 32000,
"supportsToolCall": true,
"supportsImages": true,
"supportsReasoning": true,
"useCustomProtocol": false
},
{
"id": "deepseek-v4-pro",
"name": "Deepseek-v4-pro",
"vendor": "Custom",
"url": "https://api.getaigate.com/v1/chat/completions",
"apiKey": "sk-aigate-2335f1670e169eb855c88f6ed58862cb",
"maxInputTokens": 1000000,
"maxOutputTokens": 32000,
"supportsToolCall": true,
"supportsImages": true,
"supportsReasoning": true,
"useCustomProtocol": false
},
{
"id": "deepseek-v4-flash",
"name": "Deepseek-v4-flash",
"vendor": "Custom",
"url": "https://api.getaigate.com/v1/chat/completions",
"apiKey": "sk-aigate-2335f1670e169eb855c88f6ed58862cb",
"maxInputTokens": 1000000,
"maxOutputTokens": 32000,
"supportsToolCall": true,
"supportsImages": true,
"supportsReasoning": true,
"useCustomProtocol": false
},
{
"id": "deepseek-v3-2-volc",
"name": "Deepseek-v3-2-volc",
"vendor": "Custom",
"url": "https://api.getaigate.com/v1/chat/completions",
"apiKey": "sk-aigate-2335f1670e169eb855c88f6ed58862cb",
"maxInputTokens": 128000,
"maxOutputTokens": 32000,
"supportsToolCall": true,
"supportsImages": true,
"supportsReasoning": true,
"useCustomProtocol": false
},
{
"id": "deepseek-v3-1",
"name": "Deepseek-v3-1",
"vendor": "Custom",
"url": "https://api.getaigate.com/v1/chat/completions",
"apiKey": "sk-aigate-2335f1670e169eb855c88f6ed58862cb",
"maxInputTokens": 128000,
"maxOutputTokens": 32000,
"supportsToolCall": true,
"supportsImages": true,
"supportsReasoning": true,
"useCustomProtocol": false
},
{
"id": "deepseek-v3-0324",
"name": "Deepseek-v3-0324",
"vendor": "Custom",
"url": "https://api.getaigate.com/v1/chat/completions",
"apiKey": "sk-aigate-2335f1670e169eb855c88f6ed58862cb",
"maxInputTokens": 128000,
"maxOutputTokens": 32000,
"supportsToolCall": true,
"supportsImages": true,
"supportsReasoning": true,
"useCustomProtocol": false
},
{
"id": "deepseek-r1",
"name": "Deepseek-r1",
"vendor": "Custom",
"url": "https://api.getaigate.com/v1/chat/completions",
"apiKey": "sk-aigate-2335f1670e169eb855c88f6ed58862cb",
"maxInputTokens": 128000,
"maxOutputTokens": 32000,
"supportsToolCall": true,
"supportsImages": true,
"supportsReasoning": true,
"useCustomProtocol": false
},
{
"id": "glm-5.2",
"name": "GLM-5.2",
"vendor": "Custom",
"url": "https://api.getaigate.com/v1/chat/completions",
"apiKey": "sk-aigate-2335f1670e169eb855c88f6ed58862cb",
"maxInputTokens": 1000000,
"maxOutputTokens": 128000,
"supportsToolCall": true,
"supportsImages": true,
"supportsReasoning": true,
"useCustomProtocol": false
},
{
"id": "glm-5.1",
"name": "GLM-5.1",
"vendor": "Custom",
"url": "https://api.getaigate.com/v1/chat/completions",
"apiKey": "sk-aigate-2335f1670e169eb855c88f6ed58862cb",
"maxInputTokens": 200000,
"maxOutputTokens": 128000,
"supportsToolCall": true,
"supportsImages": true,
"supportsReasoning": true,
"useCustomProtocol": false
},
{
"id": "glm-5.0",
"name": "GLM-5.0",
"vendor": "Custom",
"url": "https://api.getaigate.com/v1/chat/completions",
"apiKey": "sk-aigate-2335f1670e169eb855c88f6ed58862cb",
"maxInputTokens": 200000,
"maxOutputTokens": 32000,
"supportsToolCall": true,
"supportsImages": true,
"supportsReasoning": true,
"useCustomProtocol": false
},
{
"id": "glm-5.0-turbo",
"name": "GLM-5.0-Turbo",
"vendor": "Custom",
"url": "https://api.getaigate.com/v1/chat/completions",
"apiKey": "sk-aigate-2335f1670e169eb855c88f6ed58862cb",
"maxInputTokens": 200000,
"maxOutputTokens": 32000,
"supportsToolCall": true,
"supportsImages": true,
"supportsReasoning": true,
"useCustomProtocol": false
},
{
"id": "glm-5v-turbo",
"name": "GLM-5v-Turbo",
"vendor": "Custom",
"url": "https://api.getaigate.com/v1/chat/completions",
"apiKey": "sk-aigate-2335f1670e169eb855c88f6ed58862cb",
"maxInputTokens": 200000,
"maxOutputTokens": 32000,
"supportsToolCall": true,
"supportsImages": true,
"supportsReasoning": true,
"useCustomProtocol": false
},
{
"id": "glm-4.7",
"name": "GLM-4.7",
"vendor": "Custom",
"url": "https://api.getaigate.com/v1/chat/completions",
"apiKey": "sk-aigate-2335f1670e169eb855c88f6ed58862cb",
"maxInputTokens": 128000,
"maxOutputTokens": 32000,
"supportsToolCall": true,
"supportsImages": true,
"supportsReasoning": true,
"useCustomProtocol": false
},
{
"id": "glm-4.6",
"name": "GLM-4.6",
"vendor": "Custom",
"url": "https://api.getaigate.com/v1/chat/completions",
"apiKey": "sk-aigate-2335f1670e169eb855c88f6ed58862cb",
"maxInputTokens": 128000,
"maxOutputTokens": 32000,
"supportsToolCall": true,
"supportsImages": true,
"supportsReasoning": true,
"useCustomProtocol": false
},
{
"id": "minimax-m3",
"name": "Minimax-M3",
"vendor": "Custom",
"url": "https://api.getaigate.com/v1/chat/completions",
"apiKey": "sk-aigate-2335f1670e169eb855c88f6ed58862cb",
"maxInputTokens": 1000000,
"maxOutputTokens": 32000,
"supportsToolCall": true,
"supportsImages": true,
"supportsReasoning": true,
"useCustomProtocol": false
},
{
"id": "minimax-m2.7",
"name": "Minimax-M2.7",
"vendor": "Custom",
"url": "https://api.getaigate.com/v1/chat/completions",
"apiKey": "sk-aigate-2335f1670e169eb855c88f6ed58862cb",
"maxInputTokens": 128000,
"maxOutputTokens": 32000,
"supportsToolCall": true,
"supportsImages": true,
"supportsReasoning": true,
"useCustomProtocol": false
},
{
"id": "minimax-m2.5",
"name": "Minimax-M2.5",
"vendor": "Custom",
"url": "https://api.getaigate.com/v1/chat/completions",
"apiKey": "sk-aigate-2335f1670e169eb855c88f6ed58862cb",
"maxInputTokens": 128000,
"maxOutputTokens": 32000,
"supportsToolCall": true,
"supportsImages": true,
"supportsReasoning": true,
"useCustomProtocol": false
},
{
"id": "kimi-k2.7-code",
"name": "Kimi-K2.7-Code",
"vendor": "Custom",
"url": "https://api.getaigate.com/v1/chat/completions",
"apiKey": "sk-aigate-2335f1670e169eb855c88f6ed58862cb",
"maxInputTokens": 256000,
"maxOutputTokens": 20000,
"supportsToolCall": true,
"supportsImages": true,
"supportsReasoning": true,
"useCustomProtocol": false
},
{
"id": "kimi-k2.6",
"name": "Kimi-K2.6",
"vendor": "Custom",
"url": "https://api.getaigate.com/v1/chat/completions",
"apiKey": "sk-aigate-2335f1670e169eb855c88f6ed58862cb",
"maxInputTokens": 256000,
"maxOutputTokens": 32000,
"supportsToolCall": true,
"supportsImages": true,
"supportsReasoning": true,
"useCustomProtocol": false
},
{
"id": "kimi-k2.5",
"name": "Kimi-K2.5",
"vendor": "Custom",
"url": "https://api.getaigate.com/v1/chat/completions",
"apiKey": "sk-aigate-2335f1670e169eb855c88f6ed58862cb",
"maxInputTokens": 256000,
"maxOutputTokens": 32000,
"supportsToolCall": true,
"supportsImages": true,
"supportsReasoning": true,
"useCustomProtocol": false
},
{
"id": "hy3-preview",
"name": "HY-3-Preview",
"vendor": "Custom",
"url": "https://api.getaigate.com/v1/chat/completions",
"apiKey": "sk-aigate-2335f1670e169eb855c88f6ed58862cb",
"maxInputTokens": 200000,
"maxOutputTokens": 32000,
"supportsToolCall": true,
"supportsImages": true,
"supportsReasoning": true,
"useCustomProtocol": false
},
{
"id": "hy3",
"name": "HY-3",
"vendor": "Custom",
"url": "https://api.getaigate.com/v1/chat/completions",
"apiKey": "sk-aigate-2335f1670e169eb855c88f6ed58862cb",
"maxInputTokens": 256000,
"maxOutputTokens": 192000,
"supportsToolCall": true,
"supportsImages": true,
"supportsReasoning": true,
"useCustomProtocol": false
},
{
"id": "hunyuan-chat",
"name": "HY-Chat",
"vendor": "Custom",
"url": "https://api.getaigate.com/v1/chat/completions",
"apiKey": "sk-aigate-2335f1670e169eb855c88f6ed58862cb",
"maxInputTokens": 128000,
"maxOutputTokens": 32000,
"supportsToolCall": true,
"supportsImages": true,
"supportsReasoning": true,
"useCustomProtocol": false
},
{
"id": "hunyuan-2.0-thinking",
"name": "HY-2.0-Thinking",
"vendor": "Custom",
"url": "https://api.getaigate.com/v1/chat/completions",
"apiKey": "sk-aigate-2335f1670e169eb855c88f6ed58862cb",
"maxInputTokens": 200000,
"maxOutputTokens": 32000,
"supportsToolCall": true,
"supportsImages": true,
"supportsReasoning": true,
"useCustomProtocol": false
}
]
}

41
overview.md Normal file
View File

@@ -0,0 +1,41 @@
# 手机号授权登录修复概述
## 修复内容
1. **隐私协议授权流程修复**`src/app.tsx`
- 原代码在 `onNeedPrivacyAuthorization` 中直接 `resolve({ event: 'agree' })`,在基础库 3.16.1+ 下无法通过微信校验。
- 新增 `src/components/PrivacyModal`,使用原生 Button 的 `open-type="agreePrivacyAuthorization"` 触发真正的隐私授权;`Taro.showModal` 的按钮无法被微信识别。
2. **登录/注册页不再主动预检隐私协议**
- 移除 `src/passport/login.tsx``src/passport/register.tsx``useDidShow` 主动调用 `ensurePrivacyAuthorized()` 的逻辑。
- 进入登录页本身不再主动弹出隐私授权框;仅在用户点击手机号登录按钮,且微信检测到未授权时,才由微信强制触发 `onNeedPrivacyAuthorization`
3. **门店中心上传图片前预检隐私协议**
- 门店商品管理(`src/pages/store/goods/index.tsx`)通过 `uploadFile()` 上传图片,该接口内部已调用 `ensurePrivacyAuthorized()`,点击上传时自动触发隐私授权。
- 门店订单管理(`src/pages/store/orders/index.tsx`)直接调用 `Taro.chooseImage` 上传凭证,已在其 `chooseProofImage` 中增加 `ensurePrivacyAuthorized()` 预检。
4. **统一接口调用**
- 登录/注册页统一使用 `request.post` + `SERVER_API_URL` 调用 `/wx-login/loginByMpWxPhone`
- 修复 `register.tsx` 原本错误使用 `https://shop-api.websoft.top` 的问题。
5. **顺手修复构建错误**
- `src/passport/pay/index.tsx` 引用了不存在的 `@/api/passport/wx-login`,改为使用 `@/api/layout` 中的 `getWxOpenId` / `loginByOpenId`
## 验证
`npx taro build --type weapp` 构建成功。
## 需要你确认的后台配置
- 小程序管理后台 → 设置 → 第三方设置 → 用户隐私保护指引 → 添加「开发者收集你的手机号」声明。
- 确认小程序已开通「手机号快速验证组件」能力,且调用额度充足。
## 测试前清理
由于之前 `Taro.showModal` 的授权无效,微信可能已缓存该状态。重新上传后请先在开发者工具或真机中清理小程序缓存,再重新进入登录页,触发新的 PrivacyModal。
## 重要说明
- 微信基础库 3.16.1+ 强制要求:调用 `getPhoneNumber``chooseImage` 等敏感 API 前,用户必须先同意《用户隐私保护指引》。
- 即使登录页不再主动预检,当用户点击「手机号快捷登录」且未授权时,微信仍会强制弹出隐私授权框。这是微信机制,无法绕过。
- 如果用户已同意隐私协议但手机号登录仍失败,请重点检查后台是否已开通「手机号快速验证组件」能力,以及隐私指引中是否包含「手机号」声明。

71
register_recover.patch Normal file
View File

@@ -0,0 +1,71 @@
--- a/src/passport/register.tsx 2026-07-16 15:55:24.000000000 +0800
+++ b/src/passport/register.tsx 2026-07-16 15:54:30.000000000 +0800
@@ -4,9 +4,8 @@
import { Button, Checkbox } from '@nutui/nutui-react-taro'
import { TenantId } from '@/config/app'
import { getUserInfo, getWxOpenId } from '@/api/layout'
-import { saveStorageByLoginUser, SERVER_API_URL } from '@/utils/server'
+import { saveStorageByLoginUser } from '@/utils/server'
import { isUserDisabled } from '@/utils/auth'
-import request from '@/utils/request'
import {
getStoredInviteParams,
parseInviteParams,
@@ -26,6 +25,17 @@
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) => {
@@ -181,9 +191,10 @@
// 获取小程序登录 code用于后续绑定 openid
const wxLoginCode = await getWeappLoginCode()
- const res: any = await request.post(
- `${SERVER_API_URL}/wx-login/loginByMpWxPhone`,
- {
+ const res = (await Taro.request({
+ url: 'https://shop-api.websoft.top/api/wx-login/loginByMpWxPhone',
+ method: 'POST',
+ data: {
code: phoneCode,
encryptedData,
iv,
@@ -192,16 +203,19 @@
sceneType: 'save_referee',
tenantId: TenantId,
},
- { showError: false }
- )
+ header: {
+ 'content-type': 'application/json',
+ TenantId,
+ },
+ })) as unknown as LoginResponse
- if (res?.code === 1) {
- Taro.showToast({ title: res.message || '登录失败', icon: 'none' })
+ if ((res as any)?.data?.code === 1) {
+ Taro.showToast({ title: res.data.message || '登录失败', icon: 'none' })
return
}
- const token = res?.data?.access_token
- const user = res?.data?.user
+ const token = res?.data?.data?.access_token
+ const user = res?.data?.data?.user
if (!token || !user?.userId) {
Taro.showToast({ title: '登录失败,请重试', icon: 'none' })
return

View File

@@ -7,6 +7,7 @@ import type {
SmsCaptchaResult
} from './model';
import {saveStorageByLoginUser, SERVER_API_URL} from "@/utils/server";
import { isUserDisabled } from '@/utils/auth';
/**
* 账号密码登录
@@ -18,6 +19,9 @@ export async function login(data: LoginParam) {
);
if (res.code === 0) {
if (res.data?.user && res.data?.access_token) {
if (isUserDisabled(res.data.user)) {
return Promise.reject(new Error('您的账号已被禁用,请联系管理员'));
}
saveStorageByLoginUser(res.data.access_token, res.data.user)
}
return res.message;
@@ -46,6 +50,9 @@ export async function loginBySms(data: LoginParam) {
);
if (res.code === 0) {
if(res.data?.user){
if (isUserDisabled(res.data.user)) {
return Promise.reject(new Error('您的账号已被禁用,请联系管理员'));
}
saveStorageByLoginUser(`${res.data?.access_token}`, res.data?.user)
}
return res.message;

View File

@@ -132,6 +132,8 @@ export interface ShopGoods {
activityType?: number;
// 配送方式0送上门 1限自提
deliveryMode?: number;
// 门店ID
storeId?: number;
}
export interface BathSet {

View File

@@ -0,0 +1,52 @@
import request from '@/utils/request';
import type { ApiResult, PageResult } from '@/api';
import type { ShopGoodsBrowse, ShopGoodsBrowseParam } from './model';
/**
* 上报浏览记录(异步静默,不抛错影响主流程)
*/
export async function reportBrowse(data: Partial<ShopGoodsBrowse>): Promise<void> {
try {
await request.post<ApiResult<unknown>>('/shop/shop-goods-browse', data);
} catch {
/* 静默失败:浏览记录上报不影响用户体验 */
}
}
/**
* 分页查询我的浏览记录
*/
export async function pageBrowseHistory(params: ShopGoodsBrowseParam) {
const res = await request.get<ApiResult<PageResult<ShopGoodsBrowse>>>(
'/shop/shop-goods-browse/page',
params
);
if (res.code === 0) {
return res.data;
}
return Promise.reject(new Error(res.message));
}
/**
* 删除单条浏览记录
*/
export async function deleteBrowseRecord(id: number) {
const res = await request.del<ApiResult<unknown>>(
`/shop/shop-goods-browse/${id}`
);
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}
/**
* 清空我的浏览记录
*/
export async function clearBrowseHistory() {
const res = await request.del<ApiResult<unknown>>('/shop/shop-goods-browse');
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}

View File

@@ -0,0 +1,39 @@
import type { PageParam } from '@/api/index';
/**
* 用户商品浏览记录
*/
export interface ShopGoodsBrowse {
id?: number;
userId?: number;
goodsId?: number;
tenantId?: number;
merchantId?: number;
/** 浏览次数 */
visitCount?: number;
/** 最后浏览时间 */
lastVisitTime?: string;
/** 浏览来源home/category/search/share/detail */
browseSource?: string;
createTime?: string;
updateTime?: string;
// ========== 关联商品快照字段 ==========
goodsName?: string;
goodsImage?: string;
price?: string;
salePrice?: string;
}
/**
* 浏览记录查询参数
*/
export interface ShopGoodsBrowseParam extends PageParam {
id?: number;
userId?: number;
goodsId?: number;
merchantId?: number;
tenantId?: number;
browseSource?: string;
startTime?: string;
endTime?: string;
}

View File

@@ -154,7 +154,9 @@ export async function prepayShopOrder(data: OrderPrepayRequest) {
*/
export async function createOrder(data: OrderCreateRequest) {
// Java 后端期望 camelCase 字段,直接传
const res = await request.post<ApiResult<WxPayResult>>(
// 注意:货到付款(payType=8)和线下付款(payType=9)时,后端不应创建微信支付订单,
// 理论上返回 data 为 null 或订单信息;微信支付场景才会返回 WxPayResult。
const res = await request.post<ApiResult<WxPayResult | null>>(
'/shop/shop-order',
data
);

View File

@@ -1,6 +1,42 @@
import type { PageParam } from '@/api/index';
import type { ShopOrderGoods } from '@/api/shop/shopOrderGoods/model';
/**
* 发货单(与后端 ShopOrderDelivery 对应)
*/
export interface ShopOrderDelivery {
// 发货单ID
deliveryId?: number;
// 订单ID
orderId?: number;
// 发货方式(10手动录入 20无需物流 30电子面单)
deliveryMethod?: number;
// 物流公司ID
expressId?: number;
// 物流公司名称(关联查询,非数据库字段)
expressName?: string;
// 发货人
sendName?: string;
// 发货人联系方式
sendPhone?: string;
// 发货地址
sendAddress?: string;
// 物流单号
expressNo?: string;
// 电子面单模板内容
eorderHtml?: string;
// 排序号
sortNumber?: number;
// 是否删除, 0否, 1是
deleted?: number;
// 租户id
tenantId?: number;
// 创建时间
createTime?: string;
// 修改时间
updateTime?: string;
}
/**
* 订单
*/
@@ -97,7 +133,7 @@ export interface ShopOrder {
formId?: number;
// 支付的用户id
payUserId?: number;
// 支付方式0余额支付, 1微信支付, 2支付宝, 3银联, 4现金, 5POS机, 6免费, 7积分支付, 8货到付款
// 支付方式0余额支付, 1微信支付, 2支付宝, 3银联, 4现金, 5POS机, 6免费, 7积分支付, 8货到付款, 9线下付款
payType?: number;
// 代付支付方式
friendPayType?: number;
@@ -143,6 +179,10 @@ export interface ShopOrder {
userId?: number;
// 备注
comments?: string;
// 买家备注(下单时用户填写的订单备注)
buyerRemarks?: string;
// 商户备注(门店修改金额原因等)
merchantRemarks?: string;
// 排序号
sortNumber?: number;
// 是否删除, 0否, 1是
@@ -159,6 +199,8 @@ export interface ShopOrder {
hasTakeGift?: string;
// 订单商品项
orderGoods?: ShopOrderGoods[];
// 发货单信息(详情接口关联查询返回,仅快递配送且已发货时有值)
shopOrderDelivery?: ShopOrderDelivery | null;
}
/**
@@ -169,6 +211,8 @@ export interface OrderGoodsItem {
quantity: number;
skuId?: number;
specInfo?: string;
// 单价VIP 会员传 dealerPrice普通用户不传由后端按商品价计算
price?: string;
}
/**
@@ -187,7 +231,7 @@ export interface OrderCreateRequest {
warehouseId?: number;
// 收货地址ID
addressId?: number;
// 支付方式0余额支付, 1微信支付, 2支付宝, 3银联, 4现金, 5POS机, 6免费, 7积分支付, 8货到付款
// 支付方式0余额支付, 1微信支付, 2支付宝, 3银联, 4现金, 5POS机, 6免费, 7积分支付, 8货到付款, 9线下付款
payType: number;
// 支付状态(货到付款由后端自动设置,前端无需传递)
payStatus?: boolean;
@@ -197,6 +241,8 @@ export interface OrderCreateRequest {
couponId?: number;
// 备注
comments?: string;
// 买家备注(下单时用户填写的订单备注)
buyerRemarks?: string;
// 配送开始时间(用于预约/配送时间)
sendStartTime?: string;
// 配送方式 0快递 1自提

View File

@@ -4,6 +4,7 @@ export interface ShopUserAddress {
userId?: number;
name?: string; // 收货人姓名
phone?: string; // 收货人电话
country?: string; // 国家
province?: string; // 省份
city?: string; // 城市
district?: string; // 区县
@@ -11,7 +12,8 @@ export interface ShopUserAddress {
detail?: string; // 详细地址
lng?: string; // 经度
lat?: string; // 纬度
isDefault?: number; // 是否默认 0-否 1-是
isDefault?: boolean; // 是否默认 0-否 1-是
createTime?: string;
updateTime?: string;
address?: string;
}

View File

@@ -33,25 +33,39 @@ export async function getUserCardStats(): Promise<UserCardStats> {
/**
* 获取用户订单统计
* 后端接口:/api/user/orders/stats60s 缓存)
* 字段与后端 UserOrderStats DTO 对齐waitPay/waitDeliver/waitReceive/completed 等
*/
export async function getUserOrderStats(): Promise<UserOrderStats> {
try {
const res = await request.get<ApiResult<any>>('/user/orders/stats')
if (res.code === 0 && res.data) {
const d = res.data
return {
pending: res.data.pending || 0,
paid: res.data.paid || 0,
shipped: res.data.shipped || 0,
completed: res.data.completed || 0,
total: Number(d.total) || 0,
waitPay: Number(d.waitPay) || 0,
waitDeliver: Number(d.waitDeliver) || 0,
waitVerify: Number(d.waitVerify) || 0,
waitReceive: Number(d.waitReceive) || 0,
waitComment: Number(d.waitComment) || 0,
completed: Number(d.completed) || 0,
refund: Number(d.refund) || 0,
deleted: Number(d.deleted) || 0,
canceled: Number(d.canceled) || 0,
}
}
} catch (e) {
console.error('获取订单统计失败:', e)
}
return {
pending: 0,
paid: 0,
shipped: 0,
total: 0,
waitPay: 0,
waitDeliver: 0,
waitVerify: 0,
waitReceive: 0,
waitComment: 0,
completed: 0,
refund: 0,
deleted: 0,
canceled: 0,
}
}

View File

@@ -9,9 +9,20 @@ export interface UserCardStats {
giftCards: number
}
/**
* 用户订单状态数量统计
* 字段与后端 com.gxwebsoft.shop.dto.UserOrderStats 对齐
* statusFilter-1全部 0待支付 1待发货 2待核销 3待收货 4待评价 5已完成 6退款 7已删除 8已取消
*/
export interface UserOrderStats {
pending: number // 待付款
paid: number // 待发货
shipped: number // 待收货
completed: number // 已完成
total: number // 全部(不含已取消)
waitPay: number // 待付款statusFilter=0
waitDeliver: number // 待发货statusFilter=1
waitVerify: number // 待核销statusFilter=2
waitReceive: number // 待收货statusFilter=3
waitComment: number // 待评价statusFilter=4
completed: number // 已完成statusFilter=5
refund: number // 退款/售后statusFilter=6
deleted: number // 已删除statusFilter=7
canceled: number // 已取消statusFilter=8
}

View File

@@ -53,11 +53,52 @@ const computeSignature = (accessKeySecret: string, canonicalString: string): str
return crypto.enc.Base64.stringify(crypto.HmacSHA1(canonicalString, accessKeySecret));
}
/**
* 在调用涉及用户隐私的 APIchooseImage / chooseMedia / getLocation / getPhoneNumber 等)前,
* 等待用户完成微信隐私协议授权。基础库 3.16.1+ 强制要求,未授权会抛 errno:112。
* 旧基础库或不支持隐私协议的版本直接放行。
*/
export const ensurePrivacyAuthorized = (): Promise<void> => {
return new Promise((resolve) => {
const wxAny: any = Taro
if (typeof wxAny.requirePrivacyAuthorize !== 'function') {
resolve()
return
}
if (typeof wxAny.getPrivacySetting === 'function') {
wxAny.getPrivacySetting({
success: (res: any) => {
if (res && res.needAuthorization) {
wxAny.requirePrivacyAuthorize({
success: () => resolve(),
fail: () => resolve(),
})
} else {
resolve()
}
},
fail: () => resolve(),
})
} else {
wxAny.requirePrivacyAuthorize({
success: () => resolve(),
fail: () => resolve(),
})
}
})
}
/**
* 上传阿里云OSS
*/
export async function uploadFile() {
return new Promise(async (resolve: (result: FileRecord) => void, reject) => {
// 修复:基础库 3.16.1+ 强制隐私协议校验,先确保用户已同意
try {
await ensurePrivacyAuthorized()
} catch {
// 忽略前置授权错误,让 chooseImage 自己抛具体的 fail
}
Taro.chooseImage({
count: 1,
sizeType: ['compressed'],

View File

@@ -15,6 +15,8 @@ export default {
'passport/qr-login/index',
'passport/qr-confirm/index',
'passport/unified-qr/index',
// 支付页面
'passport/pay/index',
// 首页子页面
'pages/index/search',
'pages/index/notification',
@@ -91,6 +93,7 @@ export default {
'pages/store/center/index',
'pages/store/orders/index',
'pages/store/goods/index',
'pages/store/users/index',
// 售后页面
'pages/after-sale/apply/index',
'pages/after-sale/progress/index',
@@ -159,10 +162,10 @@ export default {
text: '分类',
},
{
pagePath: 'pages/order/list',
iconPath: 'assets/tabbar/order.png',
selectedIconPath: 'assets/tabbar/order-active.png',
text: '订单',
pagePath: 'pages/shop/cart',
iconPath: 'assets/tabbar/cart.png',
selectedIconPath: 'assets/tabbar/cart-active.png',
text: '购物车',
},
{
pagePath: 'pages/user/user',

View File

@@ -7,6 +7,8 @@ import AppContext from './contexts/AppContext'
import { UserProvider } from './contexts/UserContext'
import { CartProvider } from './contexts/CartContext'
import ErrorBoundary from './components/ErrorBoundary'
import PrivacyModal from './components/PrivacyModal'
import { privacyManager } from './utils/privacy'
import './app.scss'
type AppProps = {
@@ -22,6 +24,24 @@ function App(props: AppProps) {
if (savedTheme === 'light' || savedTheme === 'dark') {
setTheme(savedTheme)
}
// 微信隐私协议(基础库 3.16.1+ 强制)
// getPhoneNumber 等敏感能力必须由用户点击 agreePrivacyAuthorization 按钮授权。
const wxAny: any = Taro
if (typeof wxAny.getPrivacySetting === 'function') {
wxAny.getPrivacySetting({
success: (res: any) => {
if (res?.privacyContractName) {
privacyManager.setPrivacyContractName(res.privacyContractName)
}
},
})
}
if (typeof wxAny.onNeedPrivacyAuthorization === 'function') {
wxAny.onNeedPrivacyAuthorization((resolve: any) => {
privacyManager.show(resolve)
})
}
})
useDidShow(() => {})
@@ -45,6 +65,7 @@ function App(props: AppProps) {
<View className={theme === 'dark' ? 'dark' : ''}>
{props.children}
</View>
<PrivacyModal />
</ErrorBoundary>
</ConfigProvider>
</CartProvider>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
src/assets/tabbar/cart.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,79 @@
.privacy-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
&__mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.6);
}
&__content {
position: relative;
width: 560px;
background-color: #fff;
border-radius: 24px;
padding: 48px 40px 32px;
display: flex;
flex-direction: column;
align-items: center;
}
&__title {
font-size: 36px;
font-weight: 600;
color: #1a1a1a;
margin-bottom: 32px;
}
&__desc {
font-size: 28px;
color: #666;
line-height: 1.6;
text-align: center;
margin-bottom: 48px;
}
&__footer {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
border-top: 1px solid #e5e5e5;
padding-top: 24px;
}
&__btn {
flex: 1;
height: 80px;
line-height: 80px;
font-size: 30px;
text-align: center;
background: transparent;
border: none;
color: #333;
margin: 0;
padding: 0;
border-radius: 0;
&::after {
border: none;
}
&--primary {
color: #07c160;
font-weight: 600;
}
}
}

View File

@@ -0,0 +1,58 @@
import { useEffect, useState } from 'react'
import Taro from '@tarojs/taro'
import { View, Button, Text } from '@tarojs/components'
import { privacyManager } from '@/utils/privacy'
import './index.scss'
/**
* 微信隐私协议授权弹窗(基础库 3.16.1+ 强制)
* 必须使用 open-type="agreePrivacyAuthorization" 的 Button 组件,
* 用户点击后微信才会真正认为隐私协议已授权,后续敏感 API 才能调用。
*/
const PrivacyModal = () => {
const [visible, setVisible] = useState(false)
const [contractName, setContractName] = useState(privacyManager.getPrivacyContractName())
useEffect(() => {
privacyManager.setShowCallback((show) => {
setVisible(show)
if (show) {
setContractName(privacyManager.getPrivacyContractName())
}
})
}, [])
if (!visible) return null
return (
<View className='privacy-modal'>
<View className='privacy-modal__mask' />
<View className='privacy-modal__content'>
<Text className='privacy-modal__title'></Text>
<Text className='privacy-modal__desc'>
{`为提供完整服务,需您同意 ${contractName}。点击同意后可继续使用相关功能。`}
</Text>
<View className='privacy-modal__footer'>
<Button
id='privacy-disagree-btn'
className='privacy-modal__btn'
onClick={() => privacyManager.disagree()}
>
</Button>
<Button
id='privacy-agree-btn'
className='privacy-modal__btn privacy-modal__btn--primary'
openType='agreePrivacyAuthorization'
onAgreePrivacyAuthorization={() => privacyManager.agree()}
onDisagreePrivacyAuthorization={() => privacyManager.disagree()}
>
</Button>
</View>
</View>
</View>
)
}
export default PrivacyModal

View File

@@ -4,7 +4,7 @@ import Taro from '@tarojs/taro'
import type { ShopGoods, ShopGoodsSku } from '@/api/shop/shopGoods/model'
import type { ShopGoodsSpec } from '@/api/shop/shopGoodsSpec/model'
import Price from '@/components/common/Price'
import { isVipMember } from '@/utils/vip'
import { useVipStatus } from '@/hooks/useVipStatus'
import { getCompressedImageUrl } from '@/utils/image'
interface SkuSelectorProps {
@@ -49,6 +49,8 @@ const SkuSelector: React.FC<SkuSelectorProps> = ({
const [quantity, setQuantity] = useState(1)
const [showOverlay, setShowOverlay] = useState(false)
const [slideUp, setSlideUp] = useState(false)
// VIP 状态异步校验并更新缓存isVip 变化时触发重渲染
const { isVip } = useVipStatus()
// 控制动画
useEffect(() => {
@@ -157,7 +159,7 @@ const SkuSelector: React.FC<SkuSelectorProps> = ({
// 单规格商品无SKU列表 或 SKU列表为空数组
if ((!product?.goodsSkus || product.goodsSkus.length === 0) && product) {
// VIP 会员使用 dealerPrice 作为结算价
const vipPrice = isVipMember() && product.dealerPrice ? product.dealerPrice : undefined
const vipPrice = isVip && product.dealerPrice ? product.dealerPrice : undefined
const fakeSku: ShopGoodsSku = {
id: 0,
goodsId: product.goodsId!,
@@ -197,7 +199,7 @@ const SkuSelector: React.FC<SkuSelectorProps> = ({
// - product.price / sku.price : 实际"到手价"(详情页主价格)
// - product.dealerPrice : VIP 会员专享价
// VIP 会员优先使用 dealerPrice
const vipPrice = isVipMember() ? (product?.dealerPrice || selectedSku?.price) : null
const vipPrice = isVip ? (product?.dealerPrice || selectedSku?.price) : null
const currentPrice = vipPrice || selectedSku?.price || product?.price || selectedSku?.salePrice || product?.salePrice || '0'
const currentStock = selectedSku?.stock ?? product?.stock ?? 0
const currentImage = selectedSku?.image || product?.image || (product?.files?.split(',')[0]) || ''

View File

@@ -0,0 +1,42 @@
import React from 'react'
import { View } from '@tarojs/components'
/**
* 右箭头图标CSS V 形,颜色跟随 currentColor
*
* 颜色通过 className 里的 text-* 类控制(如 'text-gray-400' / 'text-white'
* 因为内部用 currentColor会自动继承父元素 text color。
*
* @example
* <View className='flex items-center text-gray-400'>
* <Text>全部订单</Text>
* <ArrowRight className='ml-1' />
* </View>
*/
export interface ArrowRightProps {
/** 颜色/间距等 className颜色用 text-* 类,跟随 currentColor */
className?: string
/** V 形边长 px默认 6 */
size?: number
/** 描边粗细 px默认 1.5 */
thickness?: number
}
const ArrowRight: React.FC<ArrowRightProps> = ({
className = '',
size = 6,
thickness = 1.5,
}) => (
<View
className={`inline-flex items-center justify-center ${className}`}
style={{
width: `${size}px`,
height: `${size}px`,
borderTop: `${thickness}px solid currentColor`,
borderRight: `${thickness}px solid currentColor`,
transform: 'rotate(45deg)',
}}
/>
)
export default ArrowRight

View File

@@ -0,0 +1,72 @@
import React from 'react'
import { View, Text } from '@tarojs/components'
/**
* 数字角标
* - count 为 0 / null / undefined 时不渲染
* - count > max 时显示 `${max}+`(默认 99+
* - 通过 className 控制定位(通常配合父元素 relative + absolute -top-1 -right-1
*
* @example
* <View className='relative'>
* <Text>📦</Text>
* <Badge count={14} className='absolute -top-1 -right-1' />
* </View>
*
* // 大尺寸 + 加粗(如门店中心卡片角标)
* <Badge count={5} className='absolute -top-1 -right-1' size={20} fontSize={11} fontWeight='bold' />
*/
export interface BadgeProps {
/** 数量;为 0 / null / undefined 时不渲染 */
count?: number | null
/** 超过此值显示 `${max}+`,默认 99 */
max?: number
/** 外层定位/样式 className如 'absolute -top-1 -right-1' */
className?: string
/** 背景色,默认红色 #ef4444 */
color?: string
/** 字体大小 px默认 10 */
fontSize?: number
/** 角标尺寸 pxmin-width 与 height 同步),默认 16 */
size?: number
/** 字重,默认 'normal' */
fontWeight?: 'normal' | 'medium' | 'bold'
}
const Badge: React.FC<BadgeProps> = ({
count,
max = 99,
className = '',
color = '#ef4444',
fontSize = 10,
size = 16,
fontWeight = 'normal',
}) => {
// 0 / null / undefined / 负数 都不渲染
const n = Number(count) || 0
if (n <= 0) return null
const display = n > max ? `${max}+` : String(n)
// 横向 padding 跟随 size16→2px20→3px避免多位数字挤压
const padding = Math.max(2, Math.round(size / 8))
return (
<View
className={`rounded-full text-white flex items-center justify-center ${className}`}
style={{
background: color,
minWidth: `${size}px`,
height: `${size}px`,
fontSize: `${fontSize}px`,
lineHeight: `${fontSize + 2}px`,
fontWeight,
paddingLeft: `${padding}px`,
paddingRight: `${padding}px`,
}}
>
<Text>{display}</Text>
</View>
)
}
export default Badge

View File

@@ -8,6 +8,7 @@ import { getCompressedImageUrl } from '@/utils/image'
interface OrderCardProps {
order: ShopOrder
onClick?: () => void
onCloseOrder?: (order: ShopOrder) => void
}
/** 综合 payStatus + deliveryStatus + orderStatus + payType 计算列表卡片状态文案 */
@@ -24,9 +25,9 @@ const getCardStatus = (order: ShopOrder): { text: string; color: string } => {
// 终态orderStatus=1 必须最先判断,盖过 deliveryStatus/payStatus
if (orderStatus === 1) return { text: '已完成', color: '#0e932e' }
// 货到付款payStatus=true 直接进入待发货
// 货到付款(8):下单时后端已 setPayStatus(true),不会走到这里
// 线下付款(9):保持 payStatus=false待商家确认收款应显示"待付款"与 Tab 一致
if (!payStatus && !isCod) return { text: '待付款', color: '#ee0a24' }
if (isCod && deliveryStatus === 10) return { text: '货到付款·待发货', color: '#4b9cf5' }
if (deliveryStatus === 10) return { text: '待发货', color: '#4b9cf5' }
if (deliveryStatus === 20) return { text: '待收货', color: '#4b9cf5' }
if (deliveryStatus === 30) return { text: '已收货', color: '#0e932e' }
@@ -34,7 +35,7 @@ const getCardStatus = (order: ShopOrder): { text: string; color: string } => {
return { text: '已付款', color: '#0e932e' }
}
const OrderCard: React.FC<OrderCardProps> = ({ order, onClick }) => {
const OrderCard: React.FC<OrderCardProps> = ({ order, onClick, onCloseOrder }) => {
const handleClick = () => {
if (onClick) {
onClick()
@@ -43,8 +44,17 @@ const OrderCard: React.FC<OrderCardProps> = ({ order, onClick }) => {
Taro.navigateTo({ url: `/pages/order/detail?id=${order.orderId}` })
}
const handleClose = (e: any) => {
e?.stopPropagation?.()
onCloseOrder?.(order)
}
const { text: statusText, color: statusColor } = getCardStatus(order)
// 仅未付款/待确认收款的订单payStatus=false才显示取消按钮
// 已付款的待发货订单需到详情页申请退款
const showCloseButton = order.deliveryStatus === 10 && !order.payStatus && order.orderStatus !== 2 && order.orderStatus !== 3
// 获取首个商品图片作为封面,或显示默认占位
const coverImage = order.orderGoods?.[0]?.image || ''
const hasGoods = (order.orderGoods?.length || 0) > 0
@@ -124,7 +134,7 @@ const OrderCard: React.FC<OrderCardProps> = ({ order, onClick }) => {
</View>
)}
{/* 底部:日期 + 金额汇总 */}
{/* 底部:日期 + 金额汇总 + 操作按钮 */}
<View className='flex justify-between items-center pt-2 border-t border-gray-50'>
<Text className='text-xs text-gray-500'>
{order.createTime?.slice(0, 10)}
@@ -137,6 +147,18 @@ const OrderCard: React.FC<OrderCardProps> = ({ order, onClick }) => {
<Price price={order.payPrice || order.totalPrice || '0'} size='small' />
</View>
</View>
{/* 操作按钮区 */}
{showCloseButton && onCloseOrder && (
<View className='flex justify-end mt-3 pt-2 border-t border-gray-50'>
<View
className='px-3 py-1 rounded-full border border-gray-300'
onClick={handleClose}
>
<Text className='text-xs text-gray-600'></Text>
</View>
</View>
)}
</View>
)
}

View File

@@ -3,7 +3,7 @@ import { View, Text, Image } from '@tarojs/components'
import Taro from '@tarojs/taro'
import Price from '../Price'
import { isGuest } from '@/utils/auth'
import { isVipMember } from '@/utils/vip'
import { useVipStatus } from '@/hooks/useVipStatus'
import { getCompressedImageUrl } from '@/utils/image'
import type { ShopGoods } from '@/api/shop/shopGoods/model'
@@ -13,6 +13,8 @@ interface ProductCardProps {
}
const ProductCard: React.FC<ProductCardProps> = ({ product, onClick }) => {
// VIP 状态异步校验并更新缓存isVip 变化时触发重渲染
const { isVip } = useVipStatus()
const handleClick = () => {
if (onClick) {
onClick()
@@ -38,8 +40,8 @@ const ProductCard: React.FC<ProductCardProps> = ({ product, onClick }) => {
</Text>
<View className='flex items-end justify-between mt-2'>
<View className='flex-1'>
<Price price={isVipMember() && product.dealerPrice ? product.dealerPrice : (product.price || '0')} size='small' loginMask />
{isVipMember() && product.dealerPrice ? (
<Price price={isVip && product.dealerPrice ? product.dealerPrice : (product.price || '0')} size='small' loginMask />
{isVip && product.dealerPrice ? (
// VIP 用户显示原价划掉
<Text className='text-xs text-gray-400 line-through ml-1'>
¥{product.price}

View File

@@ -2,7 +2,7 @@ import React, { createContext, useContext, useState, useCallback, type ReactNode
import Taro from '@tarojs/taro'
import { listShopCart, addToCart, updateCartNum, removeShopCart, removeBatchShopCart, updateCartAllChecked, updateCartChecked } from '@/api/shop/shopCart'
import type { ShopGoods, ShopGoodsSku } from '@/api/shop/shopGoods/model'
import { isVipMember } from '@/utils/vip'
import { useVipStatus } from '@/hooks/useVipStatus'
export interface CartItem {
id?: number
@@ -47,6 +47,8 @@ const CartContext = createContext<CartContextType | undefined>(undefined)
export const CartProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [items, setItems] = useState<CartItem[]>([])
const [loading, setLoading] = useState(false)
// VIP 状态异步校验并更新缓存isVip 变化时触发重渲染使 calcPrice 重新计算
const { isVip } = useVipStatus()
// 刷新购物车数据
const refresh = useCallback(async () => {
@@ -200,7 +202,7 @@ export const CartProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
}, [items])
const calcPrice = (list: CartItem[]) => {
const vip = isVipMember()
const vip = isVip
return list.reduce((sum, i) => {
// VIP 会员优先使用 dealerPrice后端关联字段 > product 嵌套对象)
const dealerPrice = vip ? (i.dealerPrice || (i.product as any)?.dealerPrice) : null

View File

@@ -3,6 +3,7 @@ import Taro from '@tarojs/taro'
import type { User } from '@/api/system/user/model'
import { getUserInfo } from '@/api/layout'
import { saveStorageByLoginUser, clearStorageByLoginUser } from '@/utils/server'
import { isUserDisabled, blockDisabledUser } from '@/utils/auth'
interface UserContextType {
user: User | null
@@ -25,11 +26,14 @@ export const UserProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const token = Taro.getStorageSync('access_token')
const userData = Taro.getStorageSync('User')
// 情况1: 有本地token和用户数据直接使用
// 情况1: 有本地token和用户数据先用缓存快速显示,再异步校验状态
if (token && userData) {
const parsed = typeof userData === 'string' ? JSON.parse(userData) : userData
setUser(parsed as User)
setLoading(false)
// 异步从服务端校验用户是否被禁用(防止被禁用后仍可使用)
verifyUserStatusFromServer()
return
}
@@ -37,6 +41,12 @@ export const UserProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
if (token) {
try {
const userInfo = await getUserInfo()
if (isUserDisabled(userInfo)) {
blockDisabledUser(userInfo)
setUser(null)
setLoading(false)
return
}
setUser(userInfo)
setLoading(false)
return
@@ -46,13 +56,38 @@ export const UserProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
}
}
// 无本地token保持未登录状态(已禁用静默登录)
// 无本地token保持未登录状态
setLoading(false)
}
init()
}, [])
/** 异步从服务端拉取最新用户信息,校验是否被禁用 */
const verifyUserStatusFromServer = async () => {
try {
const userInfo = await getUserInfo()
if (isUserDisabled(userInfo)) {
// 用户已被禁用,拦截
blockDisabledUser(userInfo)
setUser(null)
} else {
// 状态正常,更新本地缓存和状态
const token = Taro.getStorageSync('access_token')
if (token) {
saveStorageByLoginUser(token, userInfo)
}
setUser(userInfo)
}
} catch {
// 网络错误或token失效静默处理已有缓存的登录态可用
}
}
const loginUser = (token: string, userInfo: User) => {
if (isUserDisabled(userInfo)) {
blockDisabledUser(userInfo)
return
}
saveStorageByLoginUser(token, userInfo)
setUser(userInfo)
}
@@ -68,6 +103,12 @@ export const UserProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const userData = Taro.getStorageSync('User')
if (token && userData) {
const parsed = typeof userData === 'string' ? JSON.parse(userData) : userData
// 快速检查缓存中的 status如果已禁用则拦截
if (isUserDisabled(parsed)) {
blockDisabledUser(parsed)
setUser(null)
return false
}
setUser(parsed as User)
return true
}
@@ -81,6 +122,11 @@ export const UserProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
if (!Taro.getStorageSync('access_token')) return null
try {
const userInfo = await getUserInfo()
if (isUserDisabled(userInfo)) {
blockDisabledUser(userInfo)
setUser(null)
return null
}
const token = Taro.getStorageSync('access_token')
if (token) {
saveStorageByLoginUser(token, userInfo)

View File

@@ -21,6 +21,32 @@ interface UseAddressReturn {
setDefault: (id: number) => Promise<boolean>
}
/**
* 兜底:保证列表中最多只有一个地址是 isDefault=true。
* 兼容历史脏数据(之前没有"取消其他默认"逻辑时遗留下来的多个默认)。
* 保留最早创建id 最小)的为默认;其余的在前端展示时按非默认处理。
*/
function ensureSingleDefault(list: ShopUserAddress[]): ShopUserAddress[] {
const defaults = list.filter(a => a.isDefault)
if (defaults.length <= 1) return list
// 多个默认:保留 createTime 最早的;如果都缺 createTime则保留 id 最小的
const sorted = [...defaults].sort((a, b) => {
const at = a.createTime || ''
const bt = b.createTime || ''
if (at && bt) return at < bt ? -1 : at > bt ? 1 : 0
if (at) return -1
if (bt) return 1
return (a.id || 0) - (b.id || 0)
})
const keepId = sorted[0].id
return list.map(a => {
if (a.isDefault && a.id !== keepId) {
return {...a, isDefault: false}
}
return a
})
}
export function useAddress(): UseAddressReturn {
const [addresses, setAddresses] = useState<ShopUserAddress[]>([])
const [loading, setLoading] = useState(false)
@@ -29,8 +55,8 @@ export function useAddress(): UseAddressReturn {
setLoading(true)
try {
const list = await listShopUserAddress()
const actualList = Array.isArray(list) ? list : (list as any)?.data || []
setAddresses(actualList)
const raw: ShopUserAddress[] = Array.isArray(list) ? list : (list as any)?.data || []
setAddresses(ensureSingleDefault(raw))
} catch (error) {
console.error('Load addresses error:', error)
setAddresses([])

View File

@@ -1,10 +1,11 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'
import { View, Text, ScrollView, Swiper, SwiperItem, Image } from '@tarojs/components'
import Taro, { useDidShow } from '@tarojs/taro'
import { useUser } from '@/hooks/useUser'
import { useShare } from '@/hooks/useShare'
import { useScrollHeight } from '@/hooks/useScrollHeight'
import { useNewOrderDetector } from '@/hooks/useNewOrderDetector'
import { useVipStatus } from '@/hooks/useVipStatus'
import { getMyClerk } from '@/api/shop/shopStoreUser'
import { pageShopOrder } from '@/api/shop/shopOrder'
import { listCmsAd } from '@/api/cms/cmsAd'
@@ -14,6 +15,7 @@ import type { CmsAd } from '@/api/cms/cmsAd/model'
import type { CmsArticle } from '@/api/cms/cmsArticle/model'
import type { ShopGoods } from '@/api/shop/shopGoods/model'
import Price from '@/components/common/Price'
import Badge from '@/components/common/Badge'
import { getCompressedImageUrl } from '@/utils/image'
definePageConfig({
@@ -25,10 +27,13 @@ definePageConfig({
const IndexPage: React.FC = () => {
const { user, isLoggedIn } = useUser()
const [banners, setBanners] = useState<CmsAd[]>([])
const [bannerWrapWidth, setBannerWrapWidth] = useState(0)
const [announcements, setAnnouncements] = useState<CmsArticle[]>([])
const [hotProducts, setHotProducts] = useState<ShopGoods[]>([])
const [loading, setLoading] = useState(true)
const scrollHeight = useScrollHeight(44)
// VIP 状态异步校验并更新缓存isVip 变化时触发重渲染
const { isVip } = useVipStatus()
// 首页分享:好友 + 朋友圈 + 复制链接(右上角三个按钮全亮)
useShare({
@@ -85,6 +90,8 @@ const IndexPage: React.FC = () => {
return pageShopOrder({ page: 1, limit: 5 }).then(r => r?.list || [])
}, []),
onNewOrders: useCallback((count) => {
// 非门店店员不弹新订单提醒
if (!isClerkRef.current) return
Taro.vibrateShort({ type: 'medium' })
Taro.showToast({
title: `您有 ${count} 条新订单`,
@@ -163,8 +170,19 @@ const IndexPage: React.FC = () => {
Taro.navigateTo({ url: '/pages/index/notification' })
}
/**
* VIP 会员显示 dealerPrice同时把 price 作为划线原价),非 VIP 维持原行为
*/
const getHotDisplayPrice = (item: ShopGoods): { price: string; original?: string } => {
if (isVip && item.dealerPrice) {
return { price: item.dealerPrice, original: item.price }
}
const original = item.salePrice && item.salePrice !== item.price ? item.salePrice : undefined
return { price: item.price || '0', original }
}
// tabBar 页面列表
const tabBarPages = ['/pages/index/index', '/pages/shop/index', '/pages/order/list', '/pages/user/user']
const tabBarPages = ['/pages/index/index', '/pages/shop/index', '/pages/shop/cart', '/pages/user/user']
const handleFeatureClick = (url: string) => {
if (tabBarPages.includes(url)) {
@@ -174,6 +192,44 @@ const IndexPage: React.FC = () => {
}
}
// 获取轮播容器实际宽度,用于根据后台 width/height 计算比例高度
useEffect(() => {
if (bannerWrapWidth > 0) return
const timer = setTimeout(() => {
const query = Taro.createSelectorQuery()
query.select('.index-banner-wrap').boundingClientRect((rect) => {
if (rect && rect.width > 0) {
setBannerWrapWidth(rect.width)
}
}).exec()
}, 0)
return () => clearTimeout(timer)
}, [bannerWrapWidth])
// 根据广告位后台返回的 width/height 计算轮播区域高度(无值则 fallback 160px
const bannerHeight = useMemo(() => {
if (bannerWrapWidth <= 0) return 160
const firstAd = banners.find(b => b.width && b.height)
if (!firstAd) return 160
const w = parseInt(firstAd.width || '0', 10)
const h = parseInt(firstAd.height || '0', 10)
if (!w || !h || w <= 0 || h <= 0) return 160
return Math.round(bannerWrapWidth * (h / w))
}, [bannerWrapWidth, banners])
// 轮播图数据:每个广告位下的所有图片都作为独立轮播项
const bannerSlides = banners.flatMap(item =>
(item.imageList && item.imageList.length > 0)
? item.imageList.map((img, idx) => ({
key: `${item.adId}-${img.uid || idx}`,
src: img.url || '',
path: item.path,
}))
: item.image
? [{ key: `${item.adId}-0`, src: item.image, path: item.path }]
: []
)
return (
<View className='min-h-screen bg-gray-50'>
{/* 顶部搜索栏 */}
@@ -189,14 +245,10 @@ const IndexPage: React.FC = () => {
</View>
<View onClick={handleMessageClick} className='relative'>
<Text className='text-xl'>🔔</Text>
{isLoggedIn && newOrderCount > 0 && (
<View className='absolute -top-1 -right-1 min-w-[16px] h-4 px-1 bg-red-500 rounded-full flex items-center justify-center'>
<Text className='text-white text-[10px] leading-none font-medium'>
{newOrderCount > 99 ? '99+' : newOrderCount}
</Text>
</View>
{isClerk && newOrderCount > 0 && (
<Badge count={newOrderCount} className='absolute -top-1 -right-1' />
)}
{isLoggedIn && newOrderCount === 0 && (
{isClerk && newOrderCount === 0 && (
<View className='absolute top-0 right-0 w-2 h-2 bg-red-500 rounded-full' />
)}
</View>
@@ -204,29 +256,29 @@ const IndexPage: React.FC = () => {
<ScrollView scrollY style={{ height: scrollHeight }}>
{/* 轮播图 */}
<View className='mx-3 mt-2 rounded-lg overflow-hidden'>
{banners.length > 0 ? (
<View className='mx-3 mt-2 rounded-lg overflow-hidden index-banner-wrap'>
{bannerSlides.length > 0 ? (
<Swiper
autoplay
interval={3000}
className='rounded-lg'
style={{ height: '160px' }}
style={{ height: `${bannerHeight}px` }}
>
{banners.map(item => (
<SwiperItem key={item.adId}>
{bannerSlides.map(slide => (
<SwiperItem key={slide.key}>
<Image
className='w-full h-full'
src={getCompressedImageUrl(item.imageList?.[0]?.url || item.image || '', { width: 750, quality: 90})}
src={getCompressedImageUrl(slide.src, { width: 750, quality: 90 })}
mode='aspectFill'
onClick={() => {
if (item.path) Taro.navigateTo({ url: item.path })
if (slide.path) Taro.navigateTo({ url: slide.path })
}}
/>
</SwiperItem>
))}
</Swiper>
) : (
<View className='w-full bg-green-50 flex items-center justify-center' style={{ height: '160px' }}>
<View className='w-full bg-green-50 flex items-center justify-center' style={{ height: `${bannerHeight}px` }}>
<Text className='text-gray-400 text-sm'></Text>
</View>
)}
@@ -328,11 +380,14 @@ const IndexPage: React.FC = () => {
<Text className='text-sm text-gray-700 truncate block'>{item.goodsName || item.name}</Text>
<View className='flex items-center mt-1'>
<Price
price={item.price || '0'}
original={item.salePrice && item.salePrice !== item.price ? item.salePrice : undefined}
price={getHotDisplayPrice(item).price}
original={getHotDisplayPrice(item).original}
size='small'
loginMask
/>
{isVip && item.dealerPrice ? (
<Text className='text-xs text-amber-600 ml-1'>VIP</Text>
) : null}
</View>
{item.sales !== undefined && item.sales > 0 && (
<Text className='text-xs text-gray-400 mt-1'> {item.sales}</Text>

View File

@@ -24,6 +24,7 @@ const PAY_TYPE_MAP: Record<number, string> = {
6: '免费',
7: '积分支付',
8: '货到付款',
9: '线下付款',
}
// 发票状态映射
@@ -44,6 +45,7 @@ const DELIVERY_STATUS_MAP: Record<number, string> = {
const getOrderDisplayStatus = (order: ShopOrder): { title: string; bgColor: string; subtitle: string } => {
const { payStatus, deliveryStatus, orderStatus, payType } = order
const isCod = payType === 8 // 货到付款
const isOffline = payType === 9 // 线下付款
// 退款/取消相关状态
if (orderStatus === OrderStatus.Cancelled) {
@@ -63,14 +65,17 @@ const getOrderDisplayStatus = (order: ShopOrder): { title: string; bgColor: stri
}
// 正常订单流程
if (!payStatus && !isCod) {
if (!payStatus && !isCod && !isOffline) {
return { bgColor: '#ff7d00', title: '待付款', subtitle: '请尽快完成支付' }
}
if (isOffline && !payStatus) {
return { bgColor: '#4b9cf5', title: '待发货', subtitle: '请通过微信转账完成付款,商家确认后发货' }
}
if (isCod && !payStatus) {
return { bgColor: '#4b9cf5', title: '货到付款·待发货', subtitle: '送达时支付' }
return { bgColor: '#4b9cf5', title: '待发货', subtitle: '送达时支付' }
}
if (deliveryStatus === 10) {
return { bgColor: '#4b9cf5', title: isCod ? '货到付款·待发货' : '待发货', subtitle: isCod ? '送达时支付' : '商家正在准备商品' }
return { bgColor: '#4b9cf5', title: '待发货', subtitle: '商家正在准备商品' }
}
if (deliveryStatus === 20 || deliveryStatus === 30) {
return { bgColor: '#4b9cf5', title: '待收货', subtitle: '商品运输中,请注意查收' }
@@ -87,17 +92,20 @@ const getOrderDisplayStatus = (order: ShopOrder): { title: string; bgColor: stri
}
/** 根据订单状态判断当前操作阶段 */
type OrderPhase = 'unpaid' | 'unshipped' | 'shipped' | 'completed' | 'cancelled' | 'refund'
type OrderPhase = 'unpaid' | 'offline_pending' | 'unshipped' | 'shipped' | 'completed' | 'cancelled' | 'refund'
const getOrderPhase = (order: ShopOrder): OrderPhase => {
const { payStatus, deliveryStatus, orderStatus, payType } = order
const isCod = payType === 8 // 货到付款
const isOffline = payType === 9 // 线下付款
// 退款/取消
if ([OrderStatus.Cancelled, OrderStatus.Cancelling, OrderStatus.RefundSuccess, OrderStatus.RefundApply, OrderStatus.ClientRefundApply, OrderStatus.RefundRejected].includes(orderStatus as OrderStatus)) {
return orderStatus === OrderStatus.Cancelled || orderStatus === OrderStatus.RefundSuccess ? 'cancelled' : 'refund'
}
// 线下付款且未确认收款:等待商家确认,不显示"立即支付"按钮
if (isOffline && !payStatus) return 'offline_pending'
// 货到付款订单payStatus=true 但还未实际付款,直接进入 unshipped 阶段(不需要"立即支付"按钮)
if (!payStatus && !isCod) return 'unpaid'
if (deliveryStatus === 10) return 'unshipped'
@@ -445,6 +453,28 @@ const OrderDetailPage: React.FC = () => {
</>
)}
{/* 线下付款·待确认: 取消 + 联系客服 */}
{phase === 'offline_pending' && (
<>
<Button
size='small'
className='flex-1'
style={{ borderColor: '#ddd', color: '#666' }}
onClick={handleCancelOrder}
>
</Button>
<Button
size='small'
className='flex-1'
style={{ backgroundColor: '#4b9cf5' }}
onClick={() => Taro.makePhoneCall({ phoneNumber: '400-000-0000' })}
>
</Button>
</>
)}
{/* 待发货: 退款 + 联系客服 */}
{phase === 'unshipped' && (
<>

View File

@@ -1,10 +1,10 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { pageShopOrder } from '@/api/shop/shopOrder'
import { pageShopOrder, updateShopOrder } from '@/api/shop/shopOrder'
import type { ShopOrder, ShopOrderParam } from '@/api/shop/shopOrder/model'
import { useUser } from '@/hooks/useUser'
import { OrderListStatus, OrderListTabText } from '@/types/order'
import { OrderListStatus, OrderListTabText, OrderStatus } from '@/types/order'
import OrderCard from '@/components/common/OrderCard'
import EmptyState from '@/components/common/EmptyState'
import LoadMore from '@/components/common/LoadMore'
@@ -124,7 +124,7 @@ const OrderListPage: React.FC = () => {
}
}, [tabIndex, isLoggedIn, user?.userId])
// 页面每次显示时刷新数据(tabBar 页 switchTab 不会重新挂载组件
// 页面每次显示时刷新数据(覆盖从订单详情页返回等再次显示场景
const isFirstShow = useRef(true)
Taro.useDidShow(() => {
// 首次显示由 useEffect 处理,跳过
@@ -147,6 +147,29 @@ const OrderListPage: React.FC = () => {
}
}
/** 取消订单(未付款/待确认收款状态下允许客户取消) */
const handleCloseOrder = (order: ShopOrder) => {
Taro.showModal({
title: '确认取消订单',
content: '取消后订单将关闭,是否确认?',
confirmColor: '#ee0a24',
success: async (res) => {
if (res.confirm && order?.orderId) {
try {
await updateShopOrder({ ...order, orderStatus: OrderStatus.Cancelled })
Taro.showToast({ title: '订单已取消', icon: 'success' })
// 标记所有 tab 为未初始化,切换时会重新加载(确保「全部」等 tab 能看到最新状态)
setTabStates(prev => prev.map(s => ({ ...s, initialized: false })))
// 立即刷新当前 tab
loadOrders(tabIndex, 1)
} catch (err: any) {
Taro.showToast({ title: err?.message || '取消失败', icon: 'none' })
}
}
}
})
}
const handleTabChange = (index: number) => {
if (index !== tabIndex) {
setTabIndex(index)
@@ -209,7 +232,11 @@ const OrderListPage: React.FC = () => {
<View className='p-3'>
{tabStates[idx].orders.length > 0 ? (
tabStates[idx].orders.map(order => (
<OrderCard key={order.orderId} order={order} />
<OrderCard
key={order.orderId}
order={order}
onCloseOrder={handleCloseOrder}
/>
))
) : tabStates[idx].initialized && !tabStates[idx].loading ? (
<EmptyState text='暂无订单' />

View File

@@ -2,8 +2,11 @@ import React, { useState, useEffect } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { Button } from '@nutui/nutui-react-taro'
import { queryLogistics, formatLogisticsStatus, EXPRESS_COMPANIES } from '@/api/shop/shopLogistics'
import { queryLogistics, formatLogisticsStatus } from '@/api/shop/shopLogistics'
import type { LogisticsInfo, LogisticsTrack } from '@/api/shop/shopLogistics'
import { getShopOrder } from '@/api/shop/shopOrder'
import type { ShopOrder, ShopOrderDelivery } from '@/api/shop/shopOrder/model'
import { OrderStatus } from '@/types/order'
import Loading from '@/components/common/Loading'
import EmptyState from '@/components/common/EmptyState'
@@ -11,9 +14,18 @@ definePageConfig({
navigationBarTitleText: '物流详情',
})
// 发货方式映射
const DELIVERY_METHOD_MAP: Record<number, string> = {
10: '手动录入',
20: '无需物流',
30: '电子面单',
}
const LogisticsPage: React.FC = () => {
const { orderId, expressNo, expressCompany } = Taro.getCurrentInstance().router?.params || {}
const { orderId, expressNo: urlExpressNo, expressCompany: urlExpressCompany } = Taro.getCurrentInstance().router?.params || {}
const [loading, setLoading] = useState(true)
const [order, setOrder] = useState<ShopOrder | null>(null)
const [delivery, setDelivery] = useState<ShopOrderDelivery | null>(null)
const [logisticsInfo, setLogisticsInfo] = useState<LogisticsInfo | null>(null)
const [trackList, setTrackList] = useState<LogisticsTrack[]>([])
@@ -22,27 +34,42 @@ const LogisticsPage: React.FC = () => {
}, [])
const loadLogistics = async () => {
if (!orderId) {
Taro.showToast({ title: '缺少订单参数', icon: 'none' })
setLoading(false)
return
}
setLoading(true)
try {
// 必须传入参数
if (!expressNo || !expressCompany) {
Taro.showToast({ title: '缺少物流参数', icon: 'none' })
setLoading(false)
return
// 1) 拉取订单详情(含 shopOrderDelivery由后端 getByIdRel 填充)
let orderData: ShopOrder | null = null
try {
orderData = await getShopOrder(Number(orderId))
} catch (err) {
console.error('加载订单信息失败', err)
}
if (orderData) {
setOrder(orderData)
setDelivery(orderData.shopOrderDelivery ?? null)
}
const res = await queryLogistics({
orderId,
expressNo,
expressCompany,
})
if (res?.data) {
setLogisticsInfo(res.data.logisticsInfo)
setTrackList(res.data.trackList)
// 2) 决定运单号 / 物流公司:优先 URL 参数,其次订单的 shopOrderDelivery
const expressNo = urlExpressNo || orderData?.shopOrderDelivery?.expressNo || orderData?.expressNo || ''
// expressCompany 通常是快递公司代码SF/YTO 等shopOrderDelivery.expressName 是中文名
const expressCompany = urlExpressCompany || ''
// 3) 如果有运单号 + 物流公司代码,尝试查询物流轨迹(失败不阻塞发货单展示)
if (expressNo && expressCompany) {
try {
const res = await queryLogistics({ orderId, expressNo, expressCompany })
if (res?.data) {
setLogisticsInfo(res.data.logisticsInfo || null)
setTrackList(res.data.trackList || [])
}
} catch (err) {
console.error('查询物流轨迹失败', err)
}
}
} catch (err) {
console.error('加载物流信息失败', err)
Taro.showToast({ title: '加载失败', icon: 'none' })
} finally {
setLoading(false)
}
@@ -50,49 +77,35 @@ const LogisticsPage: React.FC = () => {
// 复制单号
const copyExpressNo = () => {
if (logisticsInfo?.expressNo) {
Taro.setClipboardData({
data: logisticsInfo.expressNo,
success: () => {
Taro.showToast({ title: '单号已复制', icon: 'success' })
},
})
}
const no = logisticsInfo?.expressNo || delivery?.expressNo || order?.expressNo || ''
if (!no) return
Taro.setClipboardData({
data: no,
success: () => Taro.showToast({ title: '单号已复制', icon: 'success' }),
})
}
// 联系快递员(模拟)
const contactRider = () => {
// 联系客服
const contactService = () => {
Taro.makePhoneCall({
phoneNumber: '400-811-1111',
fail: () => {
Taro.showToast({ title: '拨打失败', icon: 'none' })
},
fail: () => Taro.showToast({ title: '拨打失败', icon: 'none' }),
})
}
// 格式化时间
const formatTime = (time: string) => {
if (!time) return ''
const date = new Date(time)
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${month}-${day} ${hours}:${minutes}`
}
// 格式化日期
const formatDate = (time: string) => {
if (!time) return ''
const date = new Date(time)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
const formatTime = (time?: string) => {
if (!time) return '-'
return time.replace('T', ' ').slice(0, 19)
}
if (loading) {
return <Loading fullscreen />
}
if (!logisticsInfo) {
// 既无物流轨迹,又无发货单,又无订单 → 空状态
const hasAnything = logisticsInfo || trackList?.length || delivery || order
if (!hasAnything) {
return (
<View className='min-h-screen bg-gray-50 flex items-center justify-center'>
<EmptyState text='暂无物流信息' />
@@ -100,7 +113,30 @@ const LogisticsPage: React.FC = () => {
)
}
const statusInfo = formatLogisticsStatus(logisticsInfo.status)
const statusInfo = logisticsInfo ? formatLogisticsStatus(logisticsInfo.status) : null
// 发货状态文案:优先用 logisticsInfo.status否则按订单 deliveryStatus 推断
const getDeliveryStatusText = (): string => {
if (logisticsInfo?.status) return logisticsInfo.status
const ds = order?.deliveryStatus
if (ds === 30) return '部分发货'
if (ds === 20) return '已发货'
if (ds === 10) return '未发货'
if (order?.orderStatus === OrderStatus.Completed) return '已签收'
return '已发货'
}
// 物流公司名:优先 logisticsInfo其次 delivery.expressName
const expressCompanyName = logisticsInfo?.expressCompanyName || delivery?.expressName || '未知物流'
// 运单号:优先 logisticsInfo其次 delivery最后 order
const expressNo = logisticsInfo?.expressNo || delivery?.expressNo || order?.expressNo || ''
// 发货人信息
const sendName = delivery?.sendName || order?.expressMerchantName || '-'
const sendPhone = delivery?.sendPhone || '-'
const sendAddress = delivery?.sendAddress || '-'
const deliveryMethodText = delivery?.deliveryMethod != null
? DELIVERY_METHOD_MAP[delivery.deliveryMethod] || '-'
: (expressNo ? '手动录入' : '无需物流')
return (
<View className='min-h-screen bg-gray-50 pb-4 flex flex-col'>
@@ -108,109 +144,147 @@ const LogisticsPage: React.FC = () => {
{/* 快递信息卡片 */}
<View className='bg-white mx-3 mt-3 p-4 rounded-xl'>
<View className='flex items-center gap-3'>
{/* 快递logo */}
<View className='w-12 h-12 rounded-lg bg-gray-100 flex items-center justify-center'>
<Text className='text-xl'>📦</Text>
</View>
<View className='flex-1'>
<View className='flex items-center gap-2'>
<Text className='text-sm font-medium text-gray-800'>{logisticsInfo.expressCompanyName}</Text>
<Text className='text-sm font-medium text-gray-800'>{expressCompanyName}</Text>
<Text className='text-xs text-gray-400'></Text>
<Text className='text-sm text-gray-500'>{statusInfo.text}</Text>
<Text className='text-sm text-gray-500'>{getDeliveryStatusText()}</Text>
</View>
<Text className='text-xs text-gray-400 mt-1'>
: {logisticsInfo.expressNo}
: {expressNo || '暂无'}
</Text>
</View>
<View
className='px-3 py-1 rounded-full bg-gray-50'
onClick={copyExpressNo}
>
<Text className='text-xs text-gray-500'></Text>
</View>
{expressNo ? (
<View
className='px-3 py-1 rounded-full bg-gray-50'
onClick={copyExpressNo}
>
<Text className='text-xs text-gray-500'></Text>
</View>
) : null}
</View>
{/* 预计送达 */}
{logisticsInfo.estimatedTime && (
<View className='mt-3 pt-3 border-t border-gray-100'>
<Text className='text-xs text-gray-400'>
: {formatDate(logisticsInfo.estimatedTime)} 24:00
</Text>
</View>
)}
</View>
{/* 当前状态 */}
<View className='bg-white mx-3 mt-3 p-4 rounded-xl'>
<View className='flex items-start gap-3'>
<View className={`w-10 h-10 rounded-full flex items-center justify-center ${statusInfo.icon === '🚚' ? 'bg-green-50' : 'bg-gray-50'}`}>
<Text>{statusInfo.icon}</Text>
</View>
<View className='flex-1'>
<Text className='text-sm font-medium text-gray-800'>{logisticsInfo.status}</Text>
{logisticsInfo.currentLocation && (
<Text className='text-xs text-gray-400 mt-1'>
: {logisticsInfo.currentLocation}
</Text>
)}
<Text className='text-xs text-gray-400 mt-1'>
: {formatTime(logisticsInfo.updateTime)}
</Text>
</View>
{/* 发货方式 */}
<View className='mt-3 pt-3 border-t border-gray-100 flex justify-between'>
<Text className='text-xs text-gray-400'></Text>
<Text className='text-xs text-gray-600'>{deliveryMethodText}</Text>
</View>
</View>
{/* 收货人信息 */}
{logisticsInfo.receiverInfo && (
{/* 发货单信息shopOrderDelivery */}
{delivery ? (
<View className='bg-white mx-3 mt-3 p-4 rounded-xl'>
<Text className='text-sm font-medium text-gray-800 mb-3 block'></Text>
<View className='space-y-2'>
<View className='flex justify-between'>
<Text className='text-xs text-gray-400'></Text>
<Text className='text-xs text-gray-600'>{sendName}</Text>
</View>
<View className='flex justify-between'>
<Text className='text-xs text-gray-400'></Text>
<Text className='text-xs text-gray-600'>{sendPhone}</Text>
</View>
<View className='flex justify-between'>
<Text className='text-xs text-gray-400'></Text>
<Text className='text-xs text-gray-600 text-right flex-1 ml-4'>{sendAddress}</Text>
</View>
<View className='flex justify-between'>
<Text className='text-xs text-gray-400'></Text>
<View className='flex items-center gap-1'>
<Text className='text-xs text-gray-600'>{delivery.expressNo || '-'}</Text>
{delivery.expressNo ? (
<Text className='text-xs text-blue-500' onClick={copyExpressNo}></Text>
) : null}
</View>
</View>
{delivery.createTime ? (
<View className='flex justify-between'>
<Text className='text-xs text-gray-400'></Text>
<Text className='text-xs text-gray-600'>{formatTime(delivery.createTime)}</Text>
</View>
) : null}
</View>
</View>
) : null}
{/* 收货人信息(从订单读取) */}
{order ? (
<View className='bg-white mx-3 mt-3 p-4 rounded-xl'>
<Text className='text-sm font-medium text-gray-800 mb-2 block'></Text>
<View className='flex items-center gap-2'>
<Text className='text-sm text-gray-600'>{logisticsInfo.receiverInfo.name}</Text>
<Text className='text-sm text-gray-500'>{logisticsInfo.receiverInfo.phone}</Text>
<Text className='text-sm text-gray-600'>{order.realName || '-'}</Text>
<Text className='text-sm text-gray-500'>{order.mobile || order.phone || '-'}</Text>
</View>
<Text className='text-xs text-gray-400 mt-1'>{logisticsInfo.receiverInfo.address}</Text>
<Text className='text-xs text-gray-400 mt-1'>{order.address || '-'}</Text>
</View>
)}
) : null}
{/* 物流轨迹 */}
<View className='bg-white mx-3 mt-3 p-4 rounded-xl'>
<Text className='text-sm font-medium text-gray-800 mb-3 block'></Text>
<View className='relative'>
{/* 竖线 */}
<View className='absolute left-4 top-0 bottom-0 w-px bg-gray-200' />
{/* 物流轨迹(仅在查询到时展示) */}
{trackList && trackList.length > 0 ? (
<View className='bg-white mx-3 mt-3 p-4 rounded-xl'>
<Text className='text-sm font-medium text-gray-800 mb-3 block'></Text>
<View className='relative'>
{/* 竖线 */}
<View className='absolute left-4 top-0 bottom-0 w-px bg-gray-200' />
{trackList.map((item, idx) => (
<View key={idx} className={`relative flex gap-3 pb-4 ${idx === trackList.length - 1 ? 'pb-0' : ''}`}>
{/* 圆点 */}
<View
className={`w-8 h-8 rounded-full flex items-center justify-center z-10 ${
item.isCompleted ? 'bg-green-500' : 'bg-white border-2 border-green-500'
}`}
>
{item.isCompleted && idx !== 0 ? (
<Text className='text-white text-xs'></Text>
) : !item.isCompleted ? (
<View className='w-2 h-2 rounded-full bg-green-500' />
) : null}
</View>
{/* 内容 */}
<View className='flex-1 pt-1'>
<View className='flex justify-between items-start'>
<Text className={`text-sm ${item.isCompleted ? 'text-gray-600' : 'text-gray-800 font-medium'}`}>
{item.status}
</Text>
<Text className='text-xs text-gray-400'>{formatTime(item.time)}</Text>
{trackList.map((item, idx) => (
<View key={idx} className={`relative flex gap-3 pb-4 ${idx === trackList.length - 1 ? 'pb-0' : ''}`}>
{/* 圆点 */}
<View
className={`w-8 h-8 rounded-full flex items-center justify-center z-10 ${
item.isCompleted ? 'bg-green-500' : 'bg-white border-2 border-green-500'
}`}
>
{item.isCompleted && idx !== 0 ? (
<Text className='text-white text-xs'></Text>
) : !item.isCompleted ? (
<View className='w-2 h-2 rounded-full bg-green-500' />
) : null}
</View>
{/* 内容 */}
<View className='flex-1 pt-1'>
<View className='flex justify-between items-start'>
<Text className={`text-sm ${item.isCompleted ? 'text-gray-600' : 'text-gray-800 font-medium'}`}>
{item.status}
</Text>
<Text className='text-xs text-gray-400'>{formatTime(item.time)}</Text>
</View>
<Text className={`text-xs mt-1 ${item.isCompleted ? 'text-gray-400' : 'text-gray-500'}`}>
{item.description}
</Text>
<Text className='text-xs text-gray-400 mt-1'>{item.location}</Text>
</View>
<Text className={`text-xs mt-1 ${item.isCompleted ? 'text-gray-400' : 'text-gray-500'}`}>
{item.description}
</Text>
<Text className='text-xs text-gray-400 mt-1'>{item.location}</Text>
</View>
</View>
))}
))}
</View>
</View>
</View>
) : null}
{/* 当前状态(仅在查询到 logisticsInfo 时展示) */}
{logisticsInfo ? (
<View className='bg-white mx-3 mt-3 p-4 rounded-xl'>
<View className='flex items-start gap-3'>
<View className={`w-10 h-10 rounded-full flex items-center justify-center ${statusInfo?.icon === '🚚' ? 'bg-green-50' : 'bg-gray-50'}`}>
<Text>{statusInfo?.icon}</Text>
</View>
<View className='flex-1'>
<Text className='text-sm font-medium text-gray-800'>{logisticsInfo.status}</Text>
{logisticsInfo.currentLocation ? (
<Text className='text-xs text-gray-400 mt-1'>
: {logisticsInfo.currentLocation}
</Text>
) : null}
<Text className='text-xs text-gray-400 mt-1'>
: {formatTime(logisticsInfo.updateTime)}
</Text>
</View>
</View>
</View>
) : null}
{/* 温馨提示 */}
<View className='bg-orange-50 mx-3 mt-3 p-4 rounded-xl'>
@@ -229,7 +303,7 @@ const LogisticsPage: React.FC = () => {
<Button
size='small'
className='flex-1 rounded-full border-gray-300 text-gray-600'
onClick={contactRider}
onClick={contactService}
>
</Button>

View File

@@ -10,7 +10,7 @@ import Loading from '@/components/common/Loading'
import { isGuest } from '@/utils/auth'
import { getCompressedImageUrl } from '@/utils/image'
import { requireLogin } from '@/utils/login-guard'
import { isVipMember } from '@/utils/vip'
import { useVipStatus } from '@/hooks/useVipStatus'
definePageConfig({
navigationBarTitleText: '购物车',
@@ -19,6 +19,8 @@ definePageConfig({
const CartPage: React.FC = () => {
const { items, selectedCount, selectedPrice, updateQuantity, toggleSelect, selectAll, removeItem, refresh, loading, removeSelected, addItem } = useCartContext()
const { isLoggedIn } = useUserContext()
// VIP 状态异步校验并更新缓存isVip 变化时触发重渲染
const { isVip } = useVipStatus()
const [recommendGoods, setRecommendGoods] = useState<ShopGoods[]>([])
const [recommendPage, setRecommendPage] = useState(1)
const [refreshingRecommend, setRefreshingRecommend] = useState(false)
@@ -110,7 +112,7 @@ const CartPage: React.FC = () => {
>
<Image
className='w-full rounded-md bg-gray-100'
src={getCompressedImageUrl(goods.image || '')}
src={getCompressedImageUrl(goods.image || '', { width: 90 })}
mode='aspectFill'
style={{ height: '90px' }}
/>
@@ -177,7 +179,7 @@ const CartPage: React.FC = () => {
</View>
<Image
className="w-20 h-20 rounded-md bg-gray-100 flex-shrink-0"
src={getCompressedImageUrl(item.goodsImage || item.product?.image || (item.sku as any)?.image || '')}
src={getCompressedImageUrl(item.goodsImage || item.product?.image || (item.sku as any)?.image || '',{ width: 90 })}
mode="aspectFill"
/>
<View className="flex-1 flex flex-col justify-between">
@@ -189,7 +191,7 @@ const CartPage: React.FC = () => {
)}
<View className="flex justify-between items-center">
<Text className="text-sm font-bold text-red-500">
¥{isVipMember() && (item.dealerPrice || (item.product as any)?.dealerPrice) ? (item.dealerPrice || (item.product as any).dealerPrice) : (item.skuPrice || item.sku?.price || item.salePrice || item.product?.salePrice || item.product?.price || '0')}
¥{isVip && (item.dealerPrice || (item.product as any)?.dealerPrice) ? (item.dealerPrice || (item.product as any).dealerPrice) : (item.skuPrice || item.sku?.price || item.salePrice || item.product?.salePrice || item.product?.price || '0')}
</Text>
<View className="flex items-center gap-3">
<View className="w-6 h-6 rounded bg-gray-100 flex items-center justify-center" onClick={() => updateQuantity(item.goodsId, item.skuId, item.quantity - 1)}>
@@ -229,7 +231,7 @@ const CartPage: React.FC = () => {
>
<Image
className="w-full rounded-md bg-gray-100"
src={getCompressedImageUrl(goods.image || '')}
src={getCompressedImageUrl(goods.image || '',{ width: 90 })}
mode="aspectFill"
style={{ height: '90px' }}
/>
@@ -238,7 +240,7 @@ const CartPage: React.FC = () => {
</Text>
<View className="flex items-center justify-between mt-1">
<Text className="text-xs font-bold text-red-500">
¥{isVipMember() && goods.dealerPrice ? goods.dealerPrice : (goods.salePrice || goods.price || '0')}
¥{isVip && goods.dealerPrice ? goods.dealerPrice : (goods.salePrice || goods.price || '0')}
</Text>
<View
className="w-5 h-5 rounded-full flex items-center justify-center"

View File

@@ -7,7 +7,7 @@ import { useCartContext } from '@/contexts/CartContext'
import type { ShopGoodsCategory } from '@/api/shop/shopGoodsCategory/model'
import type { ShopGoods, ShopGoodsParam } from '@/api/shop/shopGoods/model'
import { isGuest } from '@/utils/auth'
import { isVipMember } from '@/utils/vip'
import { useVipStatus } from '@/hooks/useVipStatus'
import { getCompressedImageUrl } from '@/utils/image'
import { requireLogin } from '@/utils/login-guard'
import LoadMore from '@/components/common/LoadMore'
@@ -29,6 +29,8 @@ const SORT_TABS: { key: SortKey; label: string }[] = [
const CategoryPage: React.FC = () => {
const { addItem } = useCartContext()
// VIP 状态异步校验并更新缓存isVip 变化时触发重渲染
const { isVip } = useVipStatus()
const [categories, setCategories] = useState<ShopGoodsCategory[]>([])
const [activeId, setActiveId] = useState<number | undefined>()
@@ -126,7 +128,7 @@ const CategoryPage: React.FC = () => {
/** 获取显示价格 */
const getDisplayPrice = (product: ShopGoods) => {
if (isVipMember() && product.dealerPrice) return product.dealerPrice
if (isVip && product.dealerPrice) return product.dealerPrice
return product.price || '0'
}
@@ -242,7 +244,7 @@ const CategoryPage: React.FC = () => {
<Text className='text-xs text-gray-400'>/{item.unitName}</Text>
) : null}
{/* 原价划掉 */}
{isVipMember() && item.dealerPrice ? (
{isVip && item.dealerPrice ? (
<Text className='text-xs text-gray-400 line-through ml-1'>
¥{item.price}
</Text>

View File

@@ -13,7 +13,7 @@ import { listShopGoods } from '@/api/shop/shopGoods'
import type { ShopGoods } from '@/api/shop/shopGoods/model'
import type { ShopUserCoupon } from '@/api/shop/shopUserCoupon/model'
import type { OrderGoodsItem, OrderCreateRequest } from '@/api/shop/shopOrder/model'
import { isVipMember } from '@/utils/vip'
import { useVipStatus } from '@/hooks/useVipStatus'
import { getCompressedImageUrl } from '@/utils/image'
// 满减门槛配置
@@ -124,8 +124,17 @@ const CheckoutPage: React.FC = () => {
const [submitting, setSubmitting] = useState(false)
const [rushBuyProducts, setRushBuyProducts] = useState<ShopGoods[]>([])
// VIP 状态异步校验并更新缓存isVip 变化时触发重渲染
const { isVip } = useVipStatus()
// 支付方式相关状态
const [payType, setPayType] = useState<number>(8) // 8: 货到付款
const [payType, setPayType] = useState<number>(9) // 9: 线下付款(默认),8: 货到付款(已注释)
// 支付方式选项
const PAYMENT_OPTIONS = [
// { id: 8, name: '货到付款', desc: '送达时支付', icon: '货', bgColor: '#f0fdf4', iconBg: '#dbeafe', iconColor: '#2563eb', borderColor: '#22c55e' },
{ id: 9, name: '线下付款', desc: '微信转账,商家确认后发货', icon: '线', bgColor: '#fffbeb', iconBg: '#fef3c7', iconColor: '#d97706', borderColor: '#f59e0b' },
]
// 从本地存储获取立即购买的数据
const buyNowItems = useMemo(() => {
@@ -147,14 +156,14 @@ const CheckoutPage: React.FC = () => {
// 计算金额
const goodsPrice = useMemo(() => {
if (!items || items.length === 0) return 0
const vip = isVipMember()
const vip = isVip
return items.reduce((sum, item) => {
// VIP 会员优先使用 dealerPrice后端关联字段 > product 嵌套对象)
const dealerPrice = vip ? (item.dealerPrice || (item.product as any)?.dealerPrice) : null
const unitPrice = dealerPrice || item.skuPrice || item.sku?.price || item.salePrice || item.product?.salePrice || item.product?.price || 0
return sum + Number(unitPrice) * (item.quantity || item.num || 1)
}, 0)
}, [buyNowItems, selectedItems])
}, [buyNowItems, selectedItems, isVip])
const couponDiscount = selectedCoupon?.reducePrice ? Number(selectedCoupon.reducePrice) : 0
const totalPrice = Math.max(0, goodsPrice - couponDiscount).toFixed(2)
@@ -256,20 +265,27 @@ const CheckoutPage: React.FC = () => {
setSubmitting(true)
try {
// 构建商品列表
const goodsItems: OrderGoodsItem[] = items.map(item => ({
goodsId: item.goodsId,
skuId: item.skuId && item.skuId > 0 ? item.skuId : undefined,
quantity: item.quantity || item.num || 1,
specInfo: item.skuSpec || item.sku?.sku || item.product?.specName,
}))
const goodsItems: OrderGoodsItem[] = items.map(item => {
// VIP 会员优先使用 dealerPrice 作为下单单价
const dealerPrice = isVip ? (item.dealerPrice || (item.product as any)?.dealerPrice) : null
const unitPrice = dealerPrice || undefined
return {
goodsId: item.goodsId,
skuId: item.skuId && item.skuId > 0 ? item.skuId : undefined,
quantity: item.quantity || item.num || 1,
specInfo: item.skuSpec || item.sku?.sku || item.product?.specName,
price: unitPrice ? String(unitPrice) : undefined,
}
})
// 货到付款:后端会自动设置 payStatus=true无需前端传递 payStatus/payTime
// 货到付款(8):后端会自动设置 payStatus=true无需前端传递 payStatus/payTime
// 线下付款(9):后端会设置 payStatus=false等待商家确认收款后才设为 true
const orderParams: OrderCreateRequest = {
goodsItems,
addressId: defaultAddress.id,
payType,
couponId: selectedCoupon?.id ? Number(selectedCoupon.id) : undefined,
comments: remarks,
buyerRemarks: remarks,
deliveryType: 0, // 快递配送
}
@@ -285,10 +301,18 @@ const CheckoutPage: React.FC = () => {
// 显示成功提示
Taro.showToast({ title: '订单已提交', icon: 'success' })
setTimeout(() => {
Taro.switchTab({ url: '/pages/order/list' })
Taro.navigateTo({ url: '/pages/order/list' })
}, 1500)
} catch (err: any) {
Taro.showToast({ title: err.message || '提交失败', icon: 'none' })
const message = err.message || '提交失败'
// 线下付款(payType=9)下单时,后端如果仍尝试创建微信支付订单,
// 会因支付配置缺失应用ID而报错。这里给出更明确的提示。
if (payType === 9 && /应用ID|appid|appId|支付配置|微信支付订单|创建支付订单失败/.test(message)) {
Taro.showToast({ title: '线下支付暂不可用:微信支付配置缺失', icon: 'none', duration: 3000 })
console.error('[checkout] 线下付款下单失败,后端支付配置问题:', message)
} else {
Taro.showToast({ title: message, icon: 'none' })
}
} finally {
setSubmitting(false)
}
@@ -360,7 +384,7 @@ const CheckoutPage: React.FC = () => {
<View className='flex justify-between items-center mt-1'>
<Text className='text-xs text-gray-500'>x{item.quantity || item.num || 1}</Text>
<Text className='text-sm font-medium text-gray-800'>
¥{isVipMember() && (item.dealerPrice || (item.product as any)?.dealerPrice) ? (item.dealerPrice || (item.product as any).dealerPrice) : (item.skuPrice || item.sku?.salePrice || item.sku?.price || item.salePrice || item.product?.salePrice || item.product?.price || '0')}
¥{isVip && (item.dealerPrice || (item.product as any)?.dealerPrice) ? (item.dealerPrice || (item.product as any).dealerPrice) : (item.skuPrice || item.sku?.salePrice || item.sku?.price || item.salePrice || item.product?.salePrice || item.product?.price || '0')}
</Text>
</View>
</View>
@@ -441,25 +465,45 @@ const CheckoutPage: React.FC = () => {
<View className='bg-white rounded-lg mt-3 p-3'>
<Text className='text-sm font-medium text-gray-800 mb-3 block'></Text>
{/* 货到付款 */}
<View
className='flex items-center py-3 px-2 rounded-lg'
style={{ backgroundColor: '#f0fdf4' }}
>
<View className='w-8 h-8 rounded-full bg-blue-50 flex items-center justify-center mr-3'>
<Text className='text-sm'></Text>
{PAYMENT_OPTIONS.map((option) => {
const selected = payType === option.id
return (
<View
key={option.id}
className='flex items-center py-3 px-2 rounded-lg mb-2'
style={{ backgroundColor: selected ? option.bgColor : '#f9fafb' }}
onClick={() => setPayType(option.id)}
>
<View
className='w-8 h-8 rounded-full flex items-center justify-center mr-3'
style={{ backgroundColor: option.iconBg }}
>
<Text className='text-sm' style={{ color: option.iconColor }}>{option.icon}</Text>
</View>
<View className='flex-1'>
<Text className='text-sm text-gray-800 block'>{option.name}</Text>
<Text className='text-xs text-gray-400 block'>{option.desc}</Text>
</View>
<View
className='w-5 h-5 rounded-full border-2 flex items-center justify-center'
style={{ borderColor: selected ? option.borderColor : '#d1d5db' }}
>
{selected && (
<View className='w-2 h-2 rounded-full' style={{ backgroundColor: option.borderColor }} />
)}
</View>
</View>
)
})}
{/* 线下付款选中时显示提示 */}
{payType === 9 && (
<View className='mt-2 p-2 rounded-lg' style={{ backgroundColor: '#fef3c7' }}>
<Text className='text-xs text-orange-600'>
</Text>
</View>
<View className='flex-1'>
<Text className='text-sm text-gray-800 block'></Text>
<Text className='text-xs text-gray-400 block'></Text>
</View>
<View
className='w-5 h-5 rounded-full border-2 flex items-center justify-center'
style={{ borderColor: '#22c55e' }}
>
<View className='w-2 h-2 rounded-full' style={{ backgroundColor: '#22c55e' }} />
</View>
</View>
)}
</View>
{/* 凑单推荐 */}

View File

@@ -7,7 +7,7 @@ import { useCartContext } from '@/contexts/CartContext'
import type { ShopGoodsCategory } from '@/api/shop/shopGoodsCategory/model'
import type { ShopGoods, ShopGoodsParam } from '@/api/shop/shopGoods/model'
import { isGuest } from '@/utils/auth'
import { isVipMember } from '@/utils/vip'
import { useVipStatus } from '@/hooks/useVipStatus'
import { getCompressedImageUrl } from '@/utils/image'
import { requireLogin } from '@/utils/login-guard'
import { useShare } from '@/hooks/useShare'
@@ -38,6 +38,8 @@ const SORT_TABS: { key: SortKey; label: string }[] = [
const ShopPage: React.FC = () => {
const { addItem, totalCount } = useCartContext()
// VIP 状态异步校验并更新缓存isVip 变化时触发重渲染
const { isVip } = useVipStatus()
const [categoryList, setCategoryList] = useState<any[]>(PRESET_CATEGORIES)
const [activeCategory, setActiveCategory] = useState(0)
@@ -140,7 +142,7 @@ const ShopPage: React.FC = () => {
/** 获取显示价格 */
const getDisplayPrice = (product: ShopGoods) => {
if (isVipMember() && product.dealerPrice) return product.dealerPrice
if (isVip && product.dealerPrice) return product.dealerPrice
return product.price || '0'
}
@@ -304,7 +306,7 @@ const ShopPage: React.FC = () => {
<Price
price={getDisplayPrice(item)}
original={
isVipMember() && item.dealerPrice
isVip && item.dealerPrice
? item.price
: item.salePrice && item.salePrice !== item.price
? item.salePrice

View File

@@ -3,6 +3,7 @@ import { View, Text, Image, ScrollView, Swiper, SwiperItem, RichText, Button } f
import { Tag } from '@nutui/nutui-react-taro'
import Taro, { useRouter, useDidShow } from '@tarojs/taro'
import { getShopGoods } from '@/api/shop/shopGoods'
import { reportBrowse } from '@/api/shop/shopGoodsBrowse'
import {
addShopGoodsFavorite,
removeShopGoodsFavorite,
@@ -10,6 +11,7 @@ import {
} from '@/api/shop/shopGoodsFavorite'
import type { ShopGoods, ShopGoodsSku } from '@/api/shop/shopGoods/model'
import Price from '@/components/common/Price'
import Badge from '@/components/common/Badge'
import SkuSelector from '@/components/business/SkuSelector'
import { useCartContext } from '@/contexts/CartContext'
import { useUserContext } from '@/contexts/UserContext'
@@ -18,7 +20,7 @@ import { useShare } from '@/hooks/useShare'
import SharePoster, { SharePosterHandle } from '@/components/SharePoster'
import { isGuest } from '@/utils/auth'
import { requireLogin } from '@/utils/login-guard'
import { isVipMember } from '@/utils/vip'
import { useVipStatus } from '@/hooks/useVipStatus'
definePageConfig({
navigationBarTitleText: '商品详情',
@@ -36,6 +38,8 @@ const ProductDetailPage: React.FC = () => {
const [isFavorite, setIsFavorite] = useState(false)
const { addItem, totalCount, refresh } = useCartContext()
const { isLoggedIn } = useUserContext()
// VIP 状态异步校验并更新缓存isVip 变化时触发重渲染
const { isVip } = useVipStatus()
// 底部操作栏高度约 90px含按钮 + 安全区),动态计算 ScrollView 可用高度
const scrollHeight = useScrollHeight(44)
@@ -72,7 +76,7 @@ const ProductDetailPage: React.FC = () => {
cover: product.image,
title: product.name || product.goodsName || '优质商品推荐',
price:
product.dealerPrice && isVipMember() ? product.dealerPrice : product.price,
product.dealerPrice && isVip ? product.dealerPrice : product.price,
page: 'pages/shop/product-detail',
})
.then(setPosterPath)
@@ -89,7 +93,7 @@ const ProductDetailPage: React.FC = () => {
// VIP 会员显示 dealerPrice普通用户显示 price
const getDisplayPrice = (): string => {
if (isVipMember() && product?.dealerPrice) {
if (isVip && product?.dealerPrice) {
return product.dealerPrice
}
return product?.price || '0'
@@ -144,6 +148,11 @@ const ProductDetailPage: React.FC = () => {
Taro.setStorageSync('browse_history', reduced)
}
} catch { /* ignore */ }
// 已登录用户:异步上报服务端浏览记录(静默失败,不影响用户体验)
if (isLoggedIn) {
reportBrowse({ goodsId: id, browseSource: 'detail' })
}
}
const handleAddCart = () => {
@@ -172,7 +181,7 @@ const ProductDetailPage: React.FC = () => {
}
} else {
// VIP 会员使用 dealerPrice 作为结算价
const vipPrice = isVipMember() && product.dealerPrice ? product.dealerPrice : undefined
const vipPrice = isVip && product.dealerPrice ? product.dealerPrice : undefined
const buyNowData = [{
goodsId: product.goodsId!,
skuId: sku?.id || 0,
@@ -292,7 +301,7 @@ const ProductDetailPage: React.FC = () => {
<Price price={getDisplayPrice()} size='large' color='#ee0a24' loginMask />
<Tag></Tag>
{/* VIP 会员价 */}
{!isGuest() && isVipMember() && Number(product.dealerPrice) > 0 ? (
{!isGuest() && isVip && Number(product.dealerPrice) > 0 ? (
<View className='ml-auto inline-flex items-center gap-1 bg-amber-50 rounded px-2 py-1'>
<Text className='text-xs text-amber-600 font-medium'>VIP专享</Text>
<Text className='text-sm text-amber-700 font-bold'>¥{product.dealerPrice}</Text>
@@ -304,7 +313,7 @@ const ProductDetailPage: React.FC = () => {
)}
</View>
{/* 会员价 */}
{!isGuest() && !isVipMember() && Number(product.memberStorePrice) > 0 && product.memberStorePrice !== product.price && (
{!isGuest() && !isVip && Number(product.memberStorePrice) > 0 && product.memberStorePrice !== product.price && (
<View className='mt-2 inline-block bg-orange-50 rounded px-2 py-1'>
<Text className='text-xs text-orange-500'>: ¥{product.memberStorePrice}</Text>
</View>
@@ -425,28 +434,8 @@ const ProductDetailPage: React.FC = () => {
<View className='flex-1 flex flex-col items-center' onClick={handleGoCart}>
<View className='relative inline-flex items-center justify-center'>
<Text className='text-lg'>🛒</Text>
{totalCount > 0 && (
<View
className='flex items-center justify-center'
style={{
position: 'absolute',
top: '-6px',
right: '-10px',
minWidth: '16px',
height: '16px',
borderRadius: '8px',
backgroundColor: '#ef4444',
borderWidth: '1.5px',
borderStyle: 'solid',
borderColor: '#ffffff',
paddingHorizontal: '3px',
}}
>
<Text className='text-white text-[10px] font-bold leading-none'>
{totalCount > 99 ? '99+' : totalCount}
</Text>
</View>
)}
{/* 购物车数量角标count 为 0 时组件内部不渲染 */}
<Badge count={totalCount} className='absolute -top-1 -right-1' />
</View>
<Text className='text-xs text-gray-500 mt-1 whitespace-nowrap'></Text>
</View>

View File

@@ -4,6 +4,7 @@ import Taro from '@tarojs/taro'
import { getMyClerk } from '@/api/shop/shopStoreUser'
import { listShopDealerApply } from '@/api/shop/shopDealerApply'
import { pageShopOrder } from '@/api/shop/shopOrder'
import Badge from '@/components/common/Badge'
definePageConfig({
navigationBarTitleText: '门店中心',
@@ -32,6 +33,16 @@ const FEATURE_CARDS = [
// 显示待处理订单数量角标
showBadge: true,
},
{
key: 'users',
icon: '👥',
title: '用户管理',
desc: '查看用户信息、禁用/启用用户',
url: '/pages/store/users/index',
color: '#2563eb',
bgColor: '#dbeafe',
showBadge: false,
},
{
key: 'vip-review',
icon: '👑',
@@ -204,12 +215,8 @@ export default function StoreCenterPage() {
</View>
{/* 待处理角标 */}
{card.showBadge && getBadgeCount(card.key) > 0 && (
<View className='absolute -top-1 -right-1 min-w-[20px] h-[20px] rounded-full bg-red-500 flex items-center justify-center px-1.5'>
<Text className='text-white text-[11px] font-bold'>
{getBadgeCount(card.key) > 99 ? '99+' : getBadgeCount(card.key)}
</Text>
</View>
{card.showBadge && (
<Badge count={getBadgeCount(card.key)} className='absolute -top-1 -right-1' size={20} fontSize={11} fontWeight='bold' />
)}
{/* 箭头 */}
@@ -220,7 +227,7 @@ export default function StoreCenterPage() {
</View>
{/* 底部提示 */}
<View className='mx-3 mt-6 mb-4'>
<View className='mx-3 mt-6 mb-6'>
{/* 订阅消息提醒 */}
<View
className='bg-white rounded-xl p-4 flex items-center gap-3 active:opacity-80 mb-3'
@@ -238,9 +245,9 @@ export default function StoreCenterPage() {
<Text className='text-orange-500 text-sm flex-shrink-0'></Text>
</View>
<Text className='text-gray-300 text-xs text-center block'>
线
</Text>
{/*<Text className='text-gray-300 text-xs text-center block'>*/}
{/* 未来更多功能将持续上线*/}
{/*</Text>*/}
</View>
</View>
)

View File

@@ -1,8 +1,9 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { View, Text, Image, ScrollView, Input, Textarea } from '@tarojs/components'
import Taro, { useDidShow } from '@tarojs/taro'
import { pageShopGoods, updateShopGoods } from '@/api/shop/shopGoods'
import { pageShopGoods, updateShopGoods, addShopGoods } from '@/api/shop/shopGoods'
import { listShopGoodsCategory } from '@/api/shop/shopGoodsCategory'
import { getMyClerk } from '@/api/shop/shopStoreUser'
import { uploadFile } from '@/api/system/file'
import type { ShopGoods, ShopGoodsParam } from '@/api/shop/shopGoods/model'
import type { ShopGoodsCategory } from '@/api/shop/shopGoodsCategory/model'
@@ -66,6 +67,28 @@ export default function StoreGoodsPage() {
const [uploadingImage, setUploadingImage] = useState(false)
const [uploadingBanner, setUploadingBanner] = useState(false)
// 当前登录店员的门店ID
const [storeId, setStoreId] = useState<number | undefined>(undefined)
// 新增商品弹窗
const [showAddModal, setShowAddModal] = useState(false)
const [addForm, setAddForm] = useState({
name: '',
categoryId: undefined as number | undefined,
image: '',
price: '',
salePrice: '',
dealerPrice: '',
stock: '',
sortNumber: '',
comments: '',
files: [] as Array<{ uid?: number; url: string; status?: string }>,
})
const [addSubmitting, setAddSubmitting] = useState(false)
const [addUploadingImage, setAddUploadingImage] = useState(false)
const [addUploadingBanner, setAddUploadingBanner] = useState(false)
const [showAddCategoryPicker, setShowAddCategoryPicker] = useState(false)
const pageSize = 10
const loadingRef = useRef(false)
@@ -113,6 +136,15 @@ export default function StoreGoodsPage() {
.catch(() => {})
}, [])
// 获取当前登录店员的门店ID
useEffect(() => {
getMyClerk()
.then(data => {
if (data?.storeId) setStoreId(data.storeId)
})
.catch(() => {})
}, [])
// 切换 tab 时重新加载
useEffect(() => {
loadGoods(activeTab, 1)
@@ -306,6 +338,131 @@ export default function StoreGoodsPage() {
}
}
// ─── 新增商品相关 ──────────────────────────────────────────────
/** 打开新增弹窗 */
const openAddModal = () => {
setAddForm({
name: '',
categoryId: undefined,
image: '',
price: '',
salePrice: '',
dealerPrice: '',
stock: '',
sortNumber: '',
comments: '',
files: [],
})
setShowAddModal(true)
}
/** 关闭新增弹窗 */
const closeAddModal = () => {
setShowAddModal(false)
}
/** 上传商品图片(新增) */
const handleAddUploadImage = async () => {
if (addUploadingImage) return
setAddUploadingImage(true)
try {
const res = await uploadFile()
const imageUrl = res.path || ''
if (imageUrl) {
setAddForm(prev => ({ ...prev, image: imageUrl }))
Taro.showToast({ title: '上传成功', icon: 'success' })
}
} catch (e: any) {
Taro.showToast({ title: e.message || '上传失败', icon: 'none' })
} finally {
setAddUploadingImage(false)
}
}
/** 上传轮播图(新增) */
const handleAddUploadBanner = async () => {
if (addUploadingBanner) return
setAddUploadingBanner(true)
try {
const res = await uploadFile()
const imageUrl = res.path || ''
if (imageUrl) {
setAddForm(prev => ({
...prev,
files: [...prev.files, { uid: res.id, url: imageUrl, status: 'done' }],
}))
Taro.showToast({ title: '上传成功', icon: 'success' })
}
} catch (e: any) {
Taro.showToast({ title: e.message || '上传失败', icon: 'none' })
} finally {
setAddUploadingBanner(false)
}
}
/** 删除轮播图(新增) */
const handleAddRemoveBanner = (index: number) => {
setAddForm(prev => ({
...prev,
files: prev.files.filter((_, i) => i !== index),
}))
}
/** 提交新增商品 */
const submitAdd = async () => {
if (!addForm.name.trim()) {
Taro.showToast({ title: '请输入商品名称', icon: 'none' })
return
}
if (!addForm.image) {
Taro.showToast({ title: '请上传商品图片', icon: 'none' })
return
}
const price = parseFloat(addForm.price)
if (isNaN(price) || price < 0) {
Taro.showToast({ title: '请输入有效的到手价', icon: 'none' })
return
}
const stock = parseInt(addForm.stock, 10)
if (isNaN(stock) || stock < 0) {
Taro.showToast({ title: '请输入有效的库存', icon: 'none' })
return
}
if (!storeId) {
Taro.showToast({ title: '未获取到门店信息,请重试', icon: 'none' })
return
}
setAddSubmitting(true)
try {
await addShopGoods({
name: addForm.name.trim(),
categoryId: addForm.categoryId,
image: addForm.image,
price: addForm.price,
salePrice: addForm.salePrice || undefined,
dealerPrice: addForm.dealerPrice || undefined,
stock,
sortNumber: parseInt(addForm.sortNumber, 10) || 0,
comments: addForm.comments || undefined,
files: JSON.stringify(addForm.files),
storeId,
status: 1, // 新增默认待上架
recommend: 0,
type: 1, // 实物商品
})
Taro.showToast({ title: '添加成功', icon: 'success' })
closeAddModal()
loadGoods(activeTab, 1)
} catch (e: any) {
Taro.showToast({ title: e.message || '添加失败', icon: 'none' })
} finally {
setAddSubmitting(false)
}
}
/** 渲染单个商品卡片 */
const renderGoodsCard = (goods: ShopGoods) => {
const isSoldOut = (goods.stock ?? 0) <= 0
@@ -493,9 +650,18 @@ export default function StoreGoodsPage() {
<Text className='text-gray-300 text-xs'></Text>
</View>
)}
<View className='h-6' />
<View className='h-20' />
</ScrollView>
{/* 新增商品浮动按钮 */}
<View
className='fixed right-4 bottom-8 w-14 h-14 rounded-full bg-cyan-500 flex items-center justify-center active:opacity-80'
style={{ boxShadow: '0 4px 12px rgba(8, 145, 178, 0.4)' }}
onClick={openAddModal}
>
<Text className='text-white text-3xl leading-none' style={{ marginTop: '-2px' }}>+</Text>
</View>
{/* 分类选择弹窗 */}
{showCategoryPicker && (
<View className='fixed inset-0 z-50' onClick={() => setShowCategoryPicker(false)}>
@@ -672,6 +838,217 @@ export default function StoreGoodsPage() {
</View>
</View>
)}
{/* 新增商品弹窗 */}
{showAddModal && (
<View className='fixed inset-0 z-50 flex items-end justify-center'>
<View className='absolute inset-0 bg-black/50' onClick={closeAddModal} />
<View className='relative bg-white rounded-t-2xl w-full px-5 pt-6 pb-10 max-h-[85vh] overflow-y-auto'>
<Text className='text-lg font-medium text-gray-800 text-center mb-5 block'></Text>
{/* 商品图片 */}
<View className='mb-4'>
<Text className='text-sm text-gray-600 mb-2 block'> <Text className='text-red-500'>*</Text></Text>
<View className='relative w-20 h-20' onClick={handleAddUploadImage}>
{addForm.image ? (
<Image className='w-20 h-20 rounded-lg' src={getCompressedImageUrl(addForm.image)} mode='aspectFill' />
) : (
<View className='w-20 h-20 rounded-lg bg-gray-100 flex items-center justify-center'>
<Text className='text-2xl text-gray-300'>📷</Text>
</View>
)}
<View className='absolute inset-0 rounded-lg bg-black/30 flex items-center justify-center'>
<Text className='text-white text-xs'>
{addUploadingImage ? '上传中...' : '上传'}
</Text>
</View>
</View>
</View>
{/* 商品名称 */}
<View className='mb-4'>
<Text className='text-sm text-gray-600 mb-2 block'> <Text className='text-red-500'>*</Text></Text>
<Input
className='bg-gray-50 rounded-lg px-4 py-3 text-sm'
placeholder='请输入商品名称'
value={addForm.name}
onInput={(e) => setAddForm(prev => ({ ...prev, name: e.detail.value }))}
maxlength={100}
/>
</View>
{/* 商品分类 */}
<View className='mb-4'>
<Text className='text-sm text-gray-600 mb-2 block'></Text>
<View
className='bg-gray-50 rounded-lg px-4 py-3 flex items-center justify-between'
onClick={() => setShowAddCategoryPicker(true)}
>
<Text className={`text-sm ${addForm.categoryId ? 'text-gray-800' : 'text-gray-400'}`}>
{addForm.categoryId
? categories.find(c => c.categoryId === addForm.categoryId)?.title || '选择分类'
: '选择分类(选填)'}
</Text>
<Text className='text-gray-400 text-xs'></Text>
</View>
</View>
{/* 轮播图 */}
<View className='mb-5'>
<Text className='text-sm text-gray-600 mb-2 block'></Text>
<View className='flex flex-wrap gap-3'>
{addForm.files.map((file, index) => (
<View key={file.url + index} className='relative w-20 h-20'>
<Image className='w-20 h-20 rounded-lg bg-gray-100' src={getCompressedImageUrl(file.url)} mode='aspectFill' />
<View
className='absolute -top-1.5 -right-1.5 w-5 h-5 bg-red-500 rounded-full flex items-center justify-center'
onClick={() => handleAddRemoveBanner(index)}
>
<Text className='text-white text-xs'>×</Text>
</View>
</View>
))}
<View
className='w-20 h-20 rounded-lg border border-dashed border-gray-300 flex flex-col items-center justify-center'
onClick={handleAddUploadBanner}
>
<Text className='text-2xl text-gray-300 mb-0.5'>{addUploadingBanner ? '...' : '+'}</Text>
<Text className='text-xs text-gray-400'>{addUploadingBanner ? '上传中' : '上传'}</Text>
</View>
</View>
</View>
{/* 到手价 */}
<View className='mb-4'>
<Text className='text-sm text-gray-600 mb-2 block'> (¥) <Text className='text-red-500'>*</Text></Text>
<Input
className='bg-gray-50 rounded-lg px-4 py-3 text-sm'
type='digit'
placeholder='请输入价格'
value={addForm.price}
onInput={(e) => setAddForm(prev => ({ ...prev, price: e.detail.value }))}
/>
</View>
{/* 市场价 */}
<View className='mb-4'>
<Text className='text-sm text-gray-600 mb-2 block'> (¥)</Text>
<Input
className='bg-gray-50 rounded-lg px-4 py-3 text-sm'
type='digit'
placeholder='选填,划线价'
value={addForm.salePrice}
onInput={(e) => setAddForm(prev => ({ ...prev, salePrice: e.detail.value }))}
/>
</View>
{/* 会员价 */}
<View className='mb-4'>
<Text className='text-sm text-gray-600 mb-2 block'> (¥)</Text>
<Input
className='bg-gray-50 rounded-lg px-4 py-3 text-sm'
type='digit'
placeholder='VIP/经销商专享价'
value={addForm.dealerPrice}
onInput={(e) => setAddForm(prev => ({ ...prev, dealerPrice: e.detail.value }))}
/>
</View>
{/* 库存 */}
<View className='mb-4'>
<Text className='text-sm text-gray-600 mb-2 block'> <Text className='text-red-500'>*</Text></Text>
<Input
className='bg-gray-50 rounded-lg px-4 py-3 text-sm'
type='number'
placeholder='请输入库存数量'
value={addForm.stock}
onInput={(e) => setAddForm(prev => ({ ...prev, stock: e.detail.value }))}
/>
</View>
{/* 排序号 */}
<View className='mb-4'>
<Text className='text-sm text-gray-600 mb-2 block'></Text>
<Input
className='bg-gray-50 rounded-lg px-4 py-3 text-sm'
type='number'
placeholder='数字越小越靠前'
value={addForm.sortNumber}
onInput={(e) => setAddForm(prev => ({ ...prev, sortNumber: e.detail.value }))}
/>
</View>
{/* 备注 */}
<View className='mb-6'>
<Text className='text-sm text-gray-600 mb-2 block'></Text>
<Textarea
className='bg-gray-50 rounded-lg px-4 py-3 text-sm w-full'
placeholder='选填'
value={addForm.comments}
onInput={(e) => setAddForm(prev => ({ ...prev, comments: e.detail.value }))}
maxlength={200}
style={{ minHeight: '60px' }}
/>
</View>
{/* 操作按钮 */}
<View className='flex gap-3'>
<View className='flex-1 py-3 rounded-xl bg-gray-100 text-center' onClick={closeAddModal}>
<Text className='text-sm text-gray-600'></Text>
</View>
<View
className='flex-1 py-3 rounded-xl bg-cyan-500 text-center'
onClick={addSubmitting ? undefined : submitAdd}
>
<Text className='text-sm text-white'>
{addSubmitting ? '添加中...' : '添加'}
</Text>
</View>
</View>
</View>
</View>
)}
{/* 新增商品分类选择弹窗 */}
{showAddCategoryPicker && (
<View className='fixed inset-0 z-[60]' onClick={() => setShowAddCategoryPicker(false)}>
<View className='absolute inset-0 bg-black/50' />
<View
className='absolute bottom-0 left-0 right-0 bg-white rounded-t-2xl max-h-[60vh] overflow-y-auto'
onClick={(e) => e.stopPropagation()}
>
<View className='sticky top-0 bg-white border-b border-gray-50 px-5 py-4 flex justify-between items-center'>
<Text className='text-base font-medium text-gray-800'></Text>
<Text className='text-gray-400 text-lg' onClick={() => setShowAddCategoryPicker(false)}>×</Text>
</View>
<View
className='px-5 py-3.5 border-b border-gray-50'
onClick={() => {
setAddForm(prev => ({ ...prev, categoryId: undefined }))
setShowAddCategoryPicker(false)
}}
>
<Text className='text-sm text-gray-600'></Text>
</View>
{categories.map(cat => (
<View
key={cat.categoryId}
className='px-5 py-3.5 border-b border-gray-50 flex justify-between items-center'
onClick={() => {
setAddForm(prev => ({ ...prev, categoryId: cat.categoryId }))
setShowAddCategoryPicker(false)
}}
>
<Text className='text-sm text-gray-700'>{cat.title}</Text>
{addForm.categoryId === cat.categoryId && (
<Text className='text-cyan-500'></Text>
)}
</View>
))}
<View className='h-8' />
</View>
</View>
)}
</View>
)
}

View File

@@ -29,6 +29,23 @@ function formatDistance(km: number) {
return km < 1 ? `${Math.round(km * 1000)}m` : `${km.toFixed(1)}km`
}
/** 解析 lngAndLat 字符串,自动识别 "lat,lng" / "lng,lat" 两种格式 */
function parseLngLat(str: string): { lat: number; lng: number } | null {
const parts = str.split(',')
if (parts.length !== 2) return null
const a = parseFloat(parts[0])
const b = parseFloat(parts[1])
if (isNaN(a) || isNaN(b)) return null
// 纬度范围 -90~90经度范围 -180~180通过值域区分两者
if (Math.abs(a) <= 90 && Math.abs(b) <= 180) {
return { lat: a, lng: b } // "lat,lng"
}
if (Math.abs(b) <= 90 && Math.abs(a) <= 180) {
return { lat: b, lng: a } // "lng,lat"
}
return null
}
const StoreListPage: React.FC = () => {
const params = Taro.getCurrentInstance().router?.params || {}
// selectMode=1 时表示从预约页跳来,选中后回传
@@ -110,13 +127,9 @@ const StoreListPage: React.FC = () => {
list = list
.map((store) => {
if (store.lngAndLat) {
const parts = store.lngAndLat.split(',')
if (parts.length === 2) {
const sLng = parseFloat(parts[0])
const sLat = parseFloat(parts[1])
if (sLat && sLng) {
return { ...store, distance: calcDistance(userLat, userLng, sLat, sLng) }
}
const parsed = parseLngLat(store.lngAndLat)
if (parsed) {
return { ...store, distance: calcDistance(userLat, userLng, parsed.lat, parsed.lng) }
}
}
return store
@@ -163,20 +176,14 @@ const StoreListPage: React.FC = () => {
Taro.showToast({ title: '暂无位置信息', icon: 'none' })
return
}
const parts = store.lngAndLat.split(',')
if (parts.length !== 2) {
const parsed = parseLngLat(store.lngAndLat)
if (!parsed) {
Taro.showToast({ title: '位置信息格式有误', icon: 'none' })
return
}
const longitude = parseFloat(parts[0])
const latitude = parseFloat(parts[1])
if (!latitude || !longitude) {
Taro.showToast({ title: '暂无位置信息', icon: 'none' })
return
}
Taro.openLocation({
latitude,
longitude,
latitude: parsed.lat,
longitude: parsed.lng,
name: store.name || '',
address: store.address || '',
})

View File

@@ -3,6 +3,7 @@ import Taro from '@tarojs/taro'
import { View, Text, Input, Image } from '@tarojs/components'
import { storeLogin } from '@/api/shop/shopStore'
import { saveStorageByLoginUser } from '@/utils/server'
import { isUserDisabled } from '@/utils/auth'
import './index.scss'
const StoreLogin = () => {
@@ -55,6 +56,19 @@ const StoreLogin = () => {
if (res?.access_token) {
const token = res.access_token
const user = res.user
// 检查用户是否被禁用
if (isUserDisabled(user)) {
Taro.showModal({
title: '账号已被禁用',
content: '您的账号已被管理员禁用,暂时无法使用。如有疑问请联系管理员。',
showCancel: false,
confirmText: '我知道了',
confirmColor: '#ee0a24',
})
return
}
saveStorageByLoginUser(token, user)
Taro.showToast({ title: '登录成功', icon: 'success' })
setTimeout(() => navigateAfterLogin(), 800)

View File

@@ -1,11 +1,13 @@
import React, {useState, useEffect, useCallback, useRef} from 'react'
import {View, Text, Image, ScrollView} from '@tarojs/components'
import {View, Text, Image, ScrollView, Input, Textarea} from '@tarojs/components'
import Taro, {useDidShow} from '@tarojs/taro'
import {pageShopOrder, updateShopOrder, removeShopOrder} from '@/api/shop/shopOrder'
import type {ShopOrder, ShopOrderParam} from '@/api/shop/shopOrder/model'
import {TenantId} from '@/config/app'
import {useNewOrderDetector} from '@/hooks/useNewOrderDetector'
import { getCompressedImageUrl } from '@/utils/image'
import { getMyClerk } from '@/api/shop/shopStoreUser'
import { ensurePrivacyAuthorized } from '@/api/system/file'
definePageConfig({
navigationBarTitleText: '订单管理',
@@ -24,16 +26,18 @@ const TABS: { key: TabKey; label: string; params: Partial<ShopOrderParam>[] }[]
]
// ─── 操作类型 ─────────────────────────────────────────────────────
type OpType = 'pay' | 'complete'
type OpType = 'pay' | 'complete' | 'editPrice'
const OP_LABEL: Record<OpType, string> = {
pay: '已收款',
complete: '已完成',
editPrice: '金额',
}
const OP_DESC: Record<OpType, string> = {
pay: '变更支付状态为已付款',
complete: '同时变更支付状态为已付款、收货状态为已收货、订单状态为已完成',
editPrice: '修改订单实付金额',
}
// ─── 图片上传 ─────────────────────────────────────────────────────
@@ -92,7 +96,11 @@ function parseSendEndImg(raw: string): string[] {
function getStatusText(order: ShopOrder): string {
if (order.orderStatus === 1) return '已完成'
if (order.orderStatus === 2) return '已关闭'
if (order.payStatus === false || order.payStatus === null) return '待收款'
if (order.payStatus === false || order.payStatus === null) {
// 线下付款待确认收款
if (order.payType === 9) return '待确认收款'
return '待收款'
}
if (order.payStatus && order.deliveryStatus === 10) return '待发货'
if (order.payStatus && order.deliveryStatus === 20) return '待收货'
return '进行中'
@@ -115,6 +123,8 @@ function isOrderActionable(order: ShopOrder): boolean {
function getOrderActions(order: ShopOrder): { label: string; type: OpType }[] {
if (!isOrderActionable(order)) return []
const actions: { label: string; type: OpType }[] = []
// 修改金额(门店权限)
actions.push({label: '修改金额', type: 'editPrice'})
// 货到付款:未付款 / 已付款 都直接"确认完成"
// 确认完成会同时设置 payStatus=true无需单独的"确认收款"按钮
if (order.orderStatus !== 1 && order.orderStatus !== 2) {
@@ -140,9 +150,33 @@ export default function StoreOrdersPage() {
const [proofImages, setProofImages] = useState<string[]>([])
const [submitting, setSubmitting] = useState(false)
// 修改金额弹窗状态
const [showEditPriceModal, setShowEditPriceModal] = useState(false)
const [editPayPrice, setEditPayPrice] = useState('')
const [editReason, setEditReason] = useState('')
const pageSize = 10
const loadingRef = useRef(false)
// ─── 访问控制:仅门店店员可访问此页面 ──────────────────────
const [accessChecking, setAccessChecking] = useState(true)
useEffect(() => {
getMyClerk()
.then(data => {
if (!data) {
Taro.showToast({ title: '无权访问', icon: 'none', duration: 1500 })
setTimeout(() => Taro.navigateBack(), 1500)
} else {
setAccessChecking(false)
}
})
.catch(() => {
Taro.showToast({ title: '无权访问', icon: 'none', duration: 1500 })
setTimeout(() => Taro.navigateBack(), 1500)
})
}, [])
// ─── 新订单轮询检测 ──────────────────────────────────────────
const { newOrderCount, clearUnread } = useNewOrderDetector({
interval: 30000, // 30 秒轮询一次
@@ -242,12 +276,70 @@ export default function StoreOrdersPage() {
setProofImages([])
}
/** 打开修改金额弹窗 */
const openEditPriceModal = (order: ShopOrder) => {
setCurrentOrder(order)
setEditPayPrice(String(order.payPrice || order.totalPrice || ''))
setEditReason('')
setShowEditPriceModal(true)
}
/** 关闭修改金额弹窗 */
const closeEditPriceModal = () => {
setShowEditPriceModal(false)
setCurrentOrder(null)
setEditPayPrice('')
setEditReason('')
}
/** 提交修改金额 */
const submitEditPrice = async () => {
if (!currentOrder) return
const newPrice = parseFloat(editPayPrice)
if (isNaN(newPrice) || newPrice < 0) {
Taro.showToast({title: '请输入有效的金额', icon: 'none'})
return
}
if (!editReason.trim()) {
Taro.showToast({title: '请输入修改原因', icon: 'none'})
return
}
setSubmitting(true)
try {
const oldPrice = currentOrder.payPrice || currentOrder.totalPrice || '0'
const changeRecord = `【门店修改金额】原实付¥${oldPrice} → 新实付¥${newPrice.toFixed(2)},原因:${editReason.trim()}`
await updateShopOrder({
orderId: currentOrder.orderId,
payPrice: newPrice.toFixed(2),
merchantRemarks: (currentOrder.merchantRemarks || '') + `\n${changeRecord}`,
} as ShopOrder)
Taro.showToast({title: '修改成功', icon: 'success'})
closeEditPriceModal()
loadOrders(activeTab, 1)
} catch (e: any) {
Taro.showToast({title: e.message || '修改失败', icon: 'none'})
} finally {
setSubmitting(false)
}
}
/** 选择并上传凭证图片 */
const chooseProofImage = () => {
const chooseProofImage = async () => {
const maxCount = 3
const remaining = maxCount - proofImages.length
if (remaining <= 0) return
// 门店上传凭证需要访问相册/摄像头,先预检隐私协议授权
try {
await ensurePrivacyAuthorized()
} catch {
// 授权失败由 chooseImage 自行处理
}
Taro.chooseImage({
count: remaining,
sizeType: ['compressed'],
@@ -359,18 +451,18 @@ export default function StoreOrdersPage() {
{/* 商品列表 */}
{orderGoods.map((goods: any, idx: number) => (
<View key={idx} className='flex items-center gap-3 mb-3'>
{goods.coverImage && (
<Image className='w-16 h-16 rounded-lg bg-gray-50' src={getCompressedImageUrl(goods.coverImage)}
{goods.image && (
<Image className='w-16 h-16 rounded-lg bg-gray-50' src={getCompressedImageUrl(goods.image, { width: 80 })}
mode='aspectFill'/>
)}
<View className='flex-1'>
<Text className='text-sm text-gray-800 block'>{goods.goodsName}</Text>
{goods.specInfo && (
<Text className='text-xs text-gray-400 mt-1 block'>{goods.specInfo}</Text>
{goods.spec && (
<Text className='text-xs text-gray-400 mt-1 block'>{goods.spec}</Text>
)}
<View className='flex justify-between items-center mt-1'>
<Text className='text-sm text-red-500 font-medium'>¥{goods.price}</Text>
<Text className='text-xs text-gray-400'>x{goods.quantity}</Text>
<Text className='text-xs text-gray-400'>x{goods.totalNum}</Text>
</View>
</View>
</View>
@@ -386,6 +478,14 @@ export default function StoreOrdersPage() {
</View>
)}
{/* 买家备注 */}
{order.buyerRemarks && (
<View className='bg-orange-50 rounded-lg p-3 mb-3'>
<Text className='text-xs text-orange-500 mb-1 block'></Text>
<Text className='text-sm text-gray-600 block'>{order.buyerRemarks}</Text>
</View>
)}
{/* 送达凭证(已完成订单) */}
{order.sendEndImg && (() => {
const imgs = parseSendEndImg(order.sendEndImg)
@@ -413,7 +513,7 @@ export default function StoreOrdersPage() {
{/* 金额 + 支付方式 */}
<View className='flex justify-between items-center mb-3'>
<Text className='text-xs text-gray-400'>
{order.payType === 0 ? '货到付款' : order.payType === 4 ? '现金支付' : '货到付款'}
{order.payType === 8 ? '货到付款' : order.payType === 9 ? '线下付款' : order.payType === 4 ? '现金支付' : order.payType === 1 ? '微信支付' : '其他'}
</Text>
<Text className='text-sm text-gray-700'>
<Text className='text-red-500 font-medium'>¥{order.payPrice || order.totalPrice}</Text>
@@ -441,12 +541,14 @@ export default function StoreOrdersPage() {
className={`px-4 py-2 rounded-lg ${
act.type === 'complete'
? 'bg-green-500'
: act.type === 'editPrice'
? 'bg-orange-500'
: 'border border-blue-500'
}`}
onClick={() => openModal(order, act.type)}
onClick={() => act.type === 'editPrice' ? openEditPriceModal(order) : openModal(order, act.type)}
>
<Text className={`text-sm ${
act.type === 'complete' ? 'text-white' : 'text-blue-500'
act.type === 'complete' || act.type === 'editPrice' ? 'text-white' : 'text-blue-500'
}`}>{act.label}</Text>
</View>
))}
@@ -456,6 +558,11 @@ export default function StoreOrdersPage() {
)
}
// 访问校验中或已拒绝:不渲染页面内容
if (accessChecking) {
return <View className='min-h-full bg-gray-50' />
}
return (
<View className='min-h-full bg-gray-50'>
{/* Tab 栏 */}
@@ -590,6 +697,70 @@ export default function StoreOrdersPage() {
</View>
</View>
)}
{/* 修改金额弹窗 */}
{showEditPriceModal && currentOrder && (
<View className='fixed inset-0 z-50 flex items-end justify-center'>
{/* 遮罩 */}
<View className='absolute inset-0 bg-black/50' onClick={closeEditPriceModal}/>
{/* 弹窗内容 */}
<View className='relative bg-white rounded-t-2xl w-full px-5 pt-6 pb-10'>
<Text className='text-lg font-medium text-gray-800 text-center mb-5 block'>
</Text>
{/* 订单信息 */}
<View className='bg-gray-50 rounded-xl p-4 mb-5'>
<Text className='text-sm text-gray-700 block'>{currentOrder.orderNo}</Text>
<Text className='text-sm text-gray-700 mt-1 block'>
<Text className='text-red-500 font-medium'>¥{currentOrder.payPrice || currentOrder.totalPrice}</Text>
</Text>
</View>
{/* 新金额输入 */}
<View className='mb-5'>
<Text className='text-sm text-gray-600 mb-2 block'></Text>
<Input
type='digit'
className='w-full px-4 py-3 rounded-xl border border-gray-200 text-lg text-gray-800'
value={editPayPrice}
onInput={(e) => setEditPayPrice(e.detail.value)}
placeholder='请输入新的实付金额'
/>
</View>
{/* 修改原因 */}
<View className='mb-6'>
<Text className='text-sm text-gray-600 mb-2 block'></Text>
<Textarea
className='w-full px-4 py-3 rounded-xl border border-gray-200 text-sm text-gray-800'
style={{minHeight: '60px'}}
value={editReason}
onInput={(e) => setEditReason(e.detail.value)}
placeholder='请输入修改原因,如:商品缺货调整、协商降价等'
maxlength={100}
/>
</View>
{/* 操作按钮 */}
<View className='flex gap-3'>
<View className='flex-1 py-3 rounded-xl bg-gray-100 text-center' onClick={closeEditPriceModal}>
<Text className='text-sm text-gray-600'></Text>
</View>
<View
className='flex-1 py-3 rounded-xl bg-orange-500 text-center flex items-center justify-center'
onClick={submitting ? undefined : submitEditPrice}
>
{submitting ? (
<Text className='text-sm text-white'>...</Text>
) : (
<Text className='text-sm text-white'></Text>
)}
</View>
</View>
</View>
</View>
)}
</View>
)
}

View File

@@ -0,0 +1,445 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { View, Text, Image, ScrollView, Input } from '@tarojs/components'
import Taro, { useDidShow } from '@tarojs/taro'
import { pageUsers, updateUserStatus } from '@/api/system/user'
import type { User } from '@/api/system/user/model'
import { getCompressedImageUrl } from '@/utils/image'
definePageConfig({
navigationBarTitleText: '用户管理',
})
// ─── Tab 配置 ──────────────────────────────────────────────────
type TabKey = 'all' | 'normal' | 'disabled'
const TABS: { key: TabKey; label: string; status?: number }[] = [
{ key: 'all', label: '全部' },
{ key: 'normal', label: '正常', status: 0 },
{ key: 'disabled', label: '已禁用', status: 1 },
]
// ─── 页面组件 ──────────────────────────────────────────────────────
export default function StoreUsersPage() {
const [activeTab, setActiveTab] = useState<TabKey>('all')
// 用户列表与分页
const [userList, setUserList] = useState<User[]>([])
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true)
const [loading, setLoading] = useState(false)
// 搜索
const [keyword, setKeyword] = useState('')
const [searchText, setSearchText] = useState('')
// 用户详情弹窗
const [showDetail, setShowDetail] = useState(false)
const [detailUser, setDetailUser] = useState<User | null>(null)
const pageSize = 20
const loadingRef = useRef(false)
/** 加载用户列表 */
const loadUsers = useCallback(async (tab: TabKey, pageNo: number = 1, append = false, kw?: string) => {
if (loadingRef.current) return
loadingRef.current = true
setLoading(true)
try {
const tabConfig = TABS.find(t => t.key === tab)
const params: any = {
page: pageNo,
limit: pageSize,
}
if (tabConfig?.status !== undefined) {
params.status = tabConfig.status
}
const effectiveKw = kw !== undefined ? kw : keyword
if (effectiveKw) params.keywords = effectiveKw
const res = await pageUsers(params)
const list = res?.list || []
setUserList(prev => append ? [...prev, ...list] : list)
setPage(pageNo)
setHasMore(list.length >= pageSize)
} catch (e: any) {
Taro.showToast({ title: e.message || '加载失败', icon: 'none' })
if (!append) setUserList([])
} finally {
loadingRef.current = false
setLoading(false)
}
}, [keyword])
// 切换 tab 时重新加载
useEffect(() => {
loadUsers(activeTab, 1)
}, [activeTab, loadUsers])
// 页面重新显示时刷新
useDidShow(() => {
loadUsers(activeTab, 1)
})
/** 加载更多 */
const handleLoadMore = () => {
if (hasMore && !loadingRef.current) {
loadUsers(activeTab, page + 1, true)
}
}
/** 执行搜索 */
const handleSearch = () => {
setKeyword(searchText)
loadUsers(activeTab, 1, false, searchText)
}
/** 清除搜索 */
const handleClearSearch = () => {
setSearchText('')
setKeyword('')
loadUsers(activeTab, 1, false, '')
}
/** 切换用户状态(禁用/启用) */
const handleToggleStatus = (user: User) => {
const currentStatus = user.status ?? 0
const newStatus = currentStatus === 0 ? 1 : 0
const actionText = newStatus === 1 ? '禁用' : '启用'
Taro.showModal({
title: '确认操作',
content: `确定要${actionText}用户「${user.nickname || user.realName || user.phone || user.userId}」吗?${newStatus === 1 ? '禁用后该用户将无法登录。' : ''}`,
confirmColor: newStatus === 1 ? '#ee0a24' : '#0e932e',
success: async (res) => {
if (!res.confirm) return
try {
Taro.showLoading({ title: `${actionText}中...` })
await updateUserStatus(user.userId, newStatus)
Taro.hideLoading()
Taro.showToast({ title: `${actionText}成功`, icon: 'success' })
// 更新列表和详情中的用户状态
setUserList(prev => prev.map(u =>
u.userId === user.userId ? { ...u, status: newStatus } : u
))
if (detailUser?.userId === user.userId) {
setDetailUser(prev => prev ? { ...prev, status: newStatus } : null)
}
} catch (e: any) {
Taro.hideLoading()
Taro.showToast({ title: e.message || `${actionText}失败`, icon: 'none' })
}
},
})
}
/** 打开用户详情 */
const openDetail = (user: User) => {
setDetailUser(user)
setShowDetail(true)
}
/** 格式化时间 */
const formatTime = (time?: string) => {
if (!time) return '-'
// 兼容各种时间格式
return time.replace('T', ' ').substring(0, 19)
}
/** 渲染单个用户卡片 */
const renderUserCard = (user: User) => {
const isDisabled = user.status === 1
const displayName = user.nickname || user.realName || '未设置昵称'
return (
<View
key={user.userId || user.id}
className='bg-white rounded-xl mx-3 mt-3 p-4 active:opacity-80'
onClick={() => openDetail(user)}
>
{/* 用户头部 */}
<View className='flex items-center gap-3'>
{/* 头像 */}
<View className='w-12 h-12 rounded-full overflow-hidden flex-shrink-0 bg-gray-100'>
{user.avatar ? (
<Image
className='w-full h-full'
src={getCompressedImageUrl(user.avatar, { width: 80, quality: 80 })}
mode='aspectFill'
/>
) : (
<View className='w-full h-full flex items-center justify-center'>
<Text className='text-2xl'>👤</Text>
</View>
)}
</View>
{/* 信息 */}
<View className='flex-1 min-w-0'>
<View className='flex items-center gap-2'>
<Text className='text-sm text-gray-800 font-medium' style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{displayName}
</Text>
<View
className='px-1.5 py-0.5 rounded text-[10px]'
style={{
color: isDisabled ? '#dc2626' : '#16a34a',
background: isDisabled ? '#fef2f2' : '#f0fdf4',
}}
>
{isDisabled ? '已禁用' : '正常'}
</View>
</View>
<Text className='text-xs text-gray-400 mt-0.5 block'>
{user.phone || user.mobile || '未绑定手机'}
</Text>
</View>
{/* 禁用/启用按钮 */}
<View
className={`px-3 py-1.5 rounded-lg flex-shrink-0 ${isDisabled ? 'bg-green-50' : 'bg-red-50'}`}
onClick={(e) => {
e.stopPropagation()
handleToggleStatus(user)
}}
>
<Text className={`text-xs ${isDisabled ? 'text-green-500' : 'text-red-500'}`}>
{isDisabled ? '启用' : '禁用'}
</Text>
</View>
</View>
{/* 附加信息 */}
<View className='flex items-center gap-4 mt-3 pt-3 border-t border-gray-50'>
{user.memberLevelName && (
<View className='flex items-center gap-1'>
<Text className='text-[10px] text-gray-400'>:</Text>
<Text className='text-xs text-purple-500'>{user.memberLevelName}</Text>
</View>
)}
<View className='flex items-center gap-1'>
<Text className='text-[10px] text-gray-400'>:</Text>
<Text className='text-xs text-gray-500'>{formatTime(user.createTime)}</Text>
</View>
{user.balance !== undefined && Number(user.balance) > 0 && (
<View className='flex items-center gap-1'>
<Text className='text-[10px] text-gray-400'>:</Text>
<Text className='text-xs text-orange-500'>¥{user.balance}</Text>
</View>
)}
</View>
</View>
)
}
return (
<View className='min-h-full bg-gray-50'>
{/* 搜索栏 */}
<View className='bg-white px-3 py-2 flex items-center gap-2'>
<View className='flex-1 flex items-center bg-gray-100 rounded-lg px-3 py-1.5'>
<Input
className='flex-1 text-sm'
placeholder='搜索昵称 / 手机号'
value={searchText}
onInput={(e) => setSearchText(e.detail.value)}
onConfirm={handleSearch}
confirmType='search'
/>
{searchText ? (
<Text
className='text-gray-400 text-lg px-1'
onClick={handleClearSearch}
>×</Text>
) : null}
</View>
<View
className='px-3 py-1.5 rounded-lg bg-blue-500'
onClick={handleSearch}
>
<Text className='text-sm text-white'></Text>
</View>
</View>
{/* Tab 栏 */}
<View className='bg-white flex border-b border-gray-50'>
{TABS.map(tab => (
<View
key={tab.key}
className={`flex-1 text-center py-3 border-b-2 ${activeTab === tab.key ? 'border-blue-500' : 'border-transparent'}`}
onClick={() => setActiveTab(tab.key)}
>
<Text className={`text-sm ${activeTab === tab.key ? 'text-blue-500 font-medium' : 'text-gray-500'}`}>
{tab.label}
</Text>
</View>
))}
</View>
{/* 用户列表 */}
<ScrollView
scrollY
style={{ height: 'calc(100vh - 100px)' }}
onScrollToLower={handleLoadMore}
lowerThreshold={100}
>
{loading && userList.length === 0 ? (
<View className='flex justify-center items-center py-20'>
<Text className='text-gray-400'>...</Text>
</View>
) : userList.length === 0 ? (
<View className='flex justify-center items-center py-20'>
<Text className='text-gray-400'></Text>
</View>
) : (
userList.map(renderUserCard)
)}
{loading && userList.length > 0 && (
<View className='flex justify-center items-center py-4'>
<Text className='text-gray-400 text-sm'>...</Text>
</View>
)}
{!hasMore && userList.length > 0 && (
<View className='flex justify-center items-center py-4'>
<Text className='text-gray-300 text-xs'></Text>
</View>
)}
<View className='h-6' />
</ScrollView>
{/* 用户详情弹窗 */}
{showDetail && detailUser && (
<View className='fixed inset-0 z-50 flex items-end justify-center'>
{/* 遮罩 */}
<View className='absolute inset-0 bg-black/50' onClick={() => setShowDetail(false)} />
{/* 弹窗内容 */}
<View className='relative bg-white rounded-t-2xl w-full px-5 pt-6 pb-10 max-h-[75vh] overflow-y-auto'>
{/* 标题 */}
<View className='flex justify-between items-center mb-5'>
<Text className='text-lg font-medium text-gray-800'></Text>
<Text className='text-gray-400 text-lg' onClick={() => setShowDetail(false)}>×</Text>
</View>
{/* 用户基本信息 */}
<View className='flex items-center gap-4 mb-5'>
<View className='w-16 h-16 rounded-full overflow-hidden flex-shrink-0 bg-gray-100'>
{detailUser.avatar ? (
<Image
className='w-full h-full'
src={getCompressedImageUrl(detailUser.avatar, { width: 100, quality: 90 })}
mode='aspectFill'
/>
) : (
<View className='w-full h-full flex items-center justify-center'>
<Text className='text-3xl'>👤</Text>
</View>
)}
</View>
<View className='flex-1 min-w-0'>
<View className='flex items-center gap-2'>
<Text className='text-base font-medium text-gray-800'>
{detailUser.nickname || detailUser.realName || '未设置昵称'}
</Text>
<View
className='px-2 py-0.5 rounded text-xs'
style={{
color: detailUser.status === 1 ? '#dc2626' : '#16a34a',
background: detailUser.status === 1 ? '#fef2f2' : '#f0fdf4',
}}
>
{detailUser.status === 1 ? '已禁用' : '正常'}
</View>
</View>
<Text className='text-xs text-gray-400 mt-1 block'>
ID: {detailUser.userId || detailUser.id}
</Text>
</View>
</View>
{/* 详细信息列表 */}
<View className='bg-gray-50 rounded-xl p-4 mb-5'>
{/* 手机号 */}
<View className='flex justify-between items-center py-2 border-b border-gray-100'>
<Text className='text-sm text-gray-500'></Text>
<Text className='text-sm text-gray-800'>{detailUser.phone || detailUser.mobile || '-'}</Text>
</View>
{/* 真实姓名 */}
{detailUser.realName && (
<View className='flex justify-between items-center py-2 border-b border-gray-100'>
<Text className='text-sm text-gray-500'></Text>
<Text className='text-sm text-gray-800'>{detailUser.realName}</Text>
</View>
)}
{/* 性别 */}
<View className='flex justify-between items-center py-2 border-b border-gray-100'>
<Text className='text-sm text-gray-500'></Text>
<Text className='text-sm text-gray-800'>
{detailUser.gender === 1 ? '男' : detailUser.gender === 2 ? '女' : '未知'}
</Text>
</View>
{/* 会员等级 */}
{detailUser.memberLevelName && (
<View className='flex justify-between items-center py-2 border-b border-gray-100'>
<Text className='text-sm text-gray-500'></Text>
<Text className='text-sm text-purple-500'>{detailUser.memberLevelName}</Text>
</View>
)}
{/* 余额 */}
{detailUser.balance !== undefined && (
<View className='flex justify-between items-center py-2 border-b border-gray-100'>
<Text className='text-sm text-gray-500'></Text>
<Text className='text-sm text-orange-500'>¥{detailUser.balance || '0.00'}</Text>
</View>
)}
{/* 积分 */}
{detailUser.points !== undefined && (
<View className='flex justify-between items-center py-2 border-b border-gray-100'>
<Text className='text-sm text-gray-500'></Text>
<Text className='text-sm text-gray-800'>{detailUser.points || 0}</Text>
</View>
)}
{/* 邮箱 */}
{detailUser.email && (
<View className='flex justify-between items-center py-2 border-b border-gray-100'>
<Text className='text-sm text-gray-500'></Text>
<Text className='text-sm text-gray-800'>{detailUser.email}</Text>
</View>
)}
{/* 注册时间 */}
<View className='flex justify-between items-center py-2'>
<Text className='text-sm text-gray-500'></Text>
<Text className='text-sm text-gray-800'>{formatTime(detailUser.createTime)}</Text>
</View>
</View>
{/* 操作按钮 */}
<View className='flex gap-3'>
<View
className='flex-1 py-3 rounded-xl bg-gray-100 text-center'
onClick={() => setShowDetail(false)}
>
<Text className='text-sm text-gray-600'></Text>
</View>
<View
className={`flex-1 py-3 rounded-xl text-center ${detailUser.status === 1 ? 'bg-green-500' : 'bg-red-500'}`}
onClick={() => handleToggleStatus(detailUser)}
>
<Text className='text-sm text-white'>
{detailUser.status === 1 ? '启用用户' : '禁用用户'}
</Text>
</View>
</View>
</View>
</View>
)}
</View>
)
}

View File

@@ -1,11 +1,132 @@
import React from 'react'
import React, { useState, useRef, useEffect } from 'react'
import Taro from '@tarojs/taro'
import { View, Text, ScrollView } from '@tarojs/components'
import { Input, Button } from '@nutui/nutui-react-taro'
import { loginBySms, sendSmsCaptcha } from '@/api/passport/login'
import type { LoginParam } from '@/api/passport/login/model'
import { useUserContext } from '@/contexts/UserContext'
definePageConfig({
navigationBarTitleText: '关于我们',
})
/** 触发隐藏登录的点击次数 */
const DEV_LOGIN_CLICK_COUNT = 11
const AboutPage: React.FC = () => {
const { syncFromStorage } = useUserContext()
// ---- 隐藏开发者登录相关状态 ----
const clickCountRef = useRef(0)
const lastClickTime = useRef(0)
const [showLogin, setShowLogin] = useState(false)
const [loading, setLoading] = useState(false)
const [sendingCode, setSendingCode] = useState(false)
const [countdown, setCountdown] = useState(0)
const [formData, setFormData] = useState<LoginParam>({ phone: '', code: '' })
// 倒计时
useEffect(() => {
if (countdown <= 0) return
const timer = setTimeout(() => setCountdown(countdown - 1), 1000)
return () => clearTimeout(timer)
}, [countdown])
/** 点击版权信息 —— 累计 11 次弹出隐藏登录 */
const handleCopyrightClick = () => {
const now = Date.now()
// 超过 3 秒未点击则重新计数
if (now - lastClickTime.current > 3000) {
clickCountRef.current = 1
} else {
clickCountRef.current += 1
}
lastClickTime.current = now
const current = clickCountRef.current
const remaining = DEV_LOGIN_CLICK_COUNT - current
if (current >= DEV_LOGIN_CLICK_COUNT) {
clickCountRef.current = 0
setShowLogin(true)
return
}
// 从第 6 次开始给步数提示
if (current >= 6) {
Taro.showToast({
title: `再点击 ${remaining} 次开启开发者登录`,
icon: 'none',
duration: 1200,
})
}
}
/** 校验手机号 */
const validatePhone = (phone: string): boolean => /^1[3-9]\d{9}$/.test(phone)
/** 发送短信验证码 */
const handleSendCode = async () => {
if (!formData.phone) {
Taro.showToast({ title: '请输入手机号码', icon: 'none' })
return
}
if (!validatePhone(formData.phone)) {
Taro.showToast({ title: '请输入正确的手机号码', icon: 'none' })
return
}
if (sendingCode || countdown > 0) return
try {
setSendingCode(true)
await sendSmsCaptcha({ phone: formData.phone })
Taro.showToast({ title: '验证码已发送', icon: 'success' })
setCountdown(60)
} catch (error: any) {
Taro.showToast({ title: error.message || '发送失败', icon: 'none' })
} finally {
setSendingCode(false)
}
}
/** 登录提交 */
const handleLogin = async () => {
if (loading) return
if (!formData.phone || !validatePhone(formData.phone)) {
Taro.showToast({ title: '请输入正确的手机号码', icon: 'none' })
return
}
if (!formData.code || formData.code.length !== 6) {
Taro.showToast({ title: '请输入6位验证码', icon: 'none' })
return
}
try {
setLoading(true)
await loginBySms({ phone: formData.phone, code: formData.code })
// loginBySms 内部已写入 storage这里同步 React 登录态
syncFromStorage()
Taro.showToast({ title: '登录成功', icon: 'success' })
// 重置表单并关闭弹窗
setFormData({ phone: '', code: '' })
setCountdown(0)
setTimeout(() => setShowLogin(false), 800)
} catch (error: any) {
Taro.showToast({ title: error.message || '登录失败', icon: 'none' })
} finally {
setLoading(false)
}
}
/** 关闭弹窗时重置状态 */
const handleCloseLogin = () => {
if (loading) return
setShowLogin(false)
setFormData({ phone: '', code: '' })
setCountdown(0)
}
return (
<ScrollView scrollY className='min-h-screen bg-gray-50'>
{/* 头部品牌区 */}
@@ -13,7 +134,7 @@ const AboutPage: React.FC = () => {
<View className='w-20 h-20 rounded-2xl bg-gradient-to-br from-blue-400 to-blue-600 flex items-center justify-center shadow-lg mb-4'>
<Text className='text-white text-4xl font-bold'></Text>
</View>
<Text className='text-xl font-bold text-gray-800'></Text>
<Text className='text-xl font-bold text-gray-800'></Text>
<Text className='text-sm text-gray-400 mt-1'> v1.0.0</Text>
</View>
@@ -21,7 +142,7 @@ const AboutPage: React.FC = () => {
<View className='bg-white mx-3 mt-3 rounded-xl p-4' style={{ boxShadow: '0 2px 8px rgba(0,0,0,0.04)' }}>
<Text className='text-base font-medium text-gray-800 mb-3 block'></Text>
<Text className='text-sm text-gray-600 leading-relaxed'>
</Text>
@@ -60,7 +181,7 @@ const AboutPage: React.FC = () => {
<Text className='text-base font-medium text-gray-800 mb-3 block'></Text>
<View className='flex flex-col gap-2'>
{[
{ label: '经营部名称', value: '玉林市玉州区鑫龙家电经营部' },
{ label: '经营部名称', value: '鑫龙商贸电器' },
{ label: '经营地址', value: '大新里南718号' },
{ label: '联系电话', value: '18269229683' },
{ label: '营业时间', value: '9:00 ~ 18:00' },
@@ -74,10 +195,84 @@ const AboutPage: React.FC = () => {
</View>
{/* 底部版权 */}
<View className='flex flex-col items-center py-8'>
<Text className='text-xs text-gray-400'>Copyright © 2024 </Text>
<View className='flex flex-col items-center py-8' onClick={handleCopyrightClick}>
<Text className='text-xs text-gray-400'>Copyright © 2024 </Text>
<Text className='text-xs text-gray-400 mt-1'>All Rights Reserved</Text>
</View>
{/* 隐藏的开发者登录弹窗 */}
{showLogin && (
<View
className='fixed inset-0 z-[9999] flex items-center justify-center'
style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}
onClick={handleCloseLogin}
catchMove
>
<View
className='rounded-2xl bg-white'
style={{ width: '340px', padding: '24px 20px' }}
onClick={(e) => e.stopPropagation()}
>
<View className='mb-3 flex items-center justify-between'>
<Text className='text-lg font-bold text-gray-800'></Text>
<Text
className='text-lg text-gray-400'
style={{ padding: '4px 8px' }}
onClick={handleCloseLogin}
>
</Text>
</View>
<Text className='mb-5 block text-xs text-gray-400'>
使+
</Text>
<View className='mb-3'>
<Input
type='number'
placeholder='请输入手机号码'
maxLength={11}
value={formData.phone || ''}
onChange={(val) => setFormData({ ...formData, phone: val })}
style={{ backgroundColor: '#f5f5f5', borderRadius: '8px' }}
/>
</View>
<View className='mb-6 flex items-center bg-white rounded-lg pr-2'>
<Input
type='number'
placeholder='请输入6位验证码'
maxLength={6}
value={formData.code || ''}
onChange={(val) => setFormData({ ...formData, code: val })}
style={{ backgroundColor: '#f5f5f5', borderRadius: '8px' }}
/>
<Button
size='small'
type={countdown > 0 ? 'default' : 'primary'}
loading={sendingCode}
disabled={sendingCode || countdown > 0}
onClick={handleSendCode}
>
{countdown > 0 ? `${countdown}s` : sendingCode ? '发送中' : '获取验证码'}
</Button>
</View>
<View className='flex justify-center'>
<Button
type='primary'
size='large'
className='w-full rounded-lg p-2'
loading={loading}
disabled={loading}
onClick={handleLogin}
>
{loading ? '登录中...' : '登录'}
</Button>
</View>
</View>
</View>
)}
</ScrollView>
)
}

View File

@@ -1,7 +1,7 @@
import React, {useState, useEffect, useRef} from 'react'
import {View, Text, Input, Textarea, ScrollView, Picker} from '@tarojs/components'
import Taro, {useRouter} from '@tarojs/taro'
import {getShopUserAddress, addShopUserAddress, updateShopUserAddress} from '@/api/shop/shopUserAddress'
import {getShopUserAddress, addShopUserAddress, updateShopUserAddress, setDefaultAddress} from '@/api/shop/shopUserAddress'
import type {ShopUserAddress} from '@/api/shop/shopUserAddress/model'
import {parseLngLatFromText} from '@/utils/geofence'
import RegionData from '@/api/json/regions-data.json'
@@ -101,6 +101,7 @@ const AddressEditPage: React.FC = () => {
const [formData, setFormData] = useState<Partial<ShopUserAddress>>({
name: '',
phone: '',
country: '',
province: '',
city: '',
region: '',
@@ -148,15 +149,22 @@ const AddressEditPage: React.FC = () => {
const parseAddressText = (text: string) => {
const result: any = {}
// 手机号正则
// 手机号正则11 位国内手机号)
const phoneRegex = /1[3-9]\d{9}/
const phoneMatch = text.match(phoneRegex)
if (phoneMatch) result.phone = phoneMatch[0]
// 姓名正则2-4个中文字符,通常在开头)
const nameRegex = /^[\u4e00-\u9fa5]{2,4}/
const nameMatch = text.match(nameRegex)
if (nameMatch) result.name = nameMatch[0]
// 姓名正则:优先匹配“收件人/收货人/姓名/联系人:”后面的 2-4 个中文字符
const nameWithPrefixRegex = /(?:收件人|收货人|姓名|联系人)[:]\s*([\u4e00-\u9fa5]{2,4})/
const nameWithPrefixMatch = text.match(nameWithPrefixRegex)
if (nameWithPrefixMatch) {
result.name = nameWithPrefixMatch[1]
} else {
// 兜底:文本开头 2-4 个中文字符
const nameRegex = /^[\u4e00-\u9fa5]{2,4}/
const nameMatch = text.match(nameRegex)
if (nameMatch) result.name = nameMatch[0]
}
// 省市区识别
const regionResult = parseRegion(text)
@@ -166,13 +174,29 @@ const AddressEditPage: React.FC = () => {
result.region = regionResult.region
}
// 详细地址提取
// 详细地址提取:先把常见的标签字段和已知字段去掉,再清洗标点
let addressText = text
// 去除已提取的姓名、手机号
if (result.name) addressText = addressText.replace(result.name, '')
if (result.phone) addressText = addressText.replace(result.phone, '')
// 去除常见标签文案(如“收件人:”、“手机号码:”、“所在地区:”、“详细地址:”)
addressText = addressText
.replace(/(?:收件人|收货人|姓名|联系人)[:]\s*/g, '')
.replace(/(?:手机号码|手机号|电话|联系电话)[:]\s*/g, '')
.replace(/(?:所在地区|地区|省市区)[:]\s*/g, '')
.replace(/(?:详细地址|地址|街道)[:]\s*/g, '')
// 去除已识别出的省市区
if (result.province) addressText = addressText.replace(result.province, '')
if (result.city) addressText = addressText.replace(result.city, '')
if (result.region) addressText = addressText.replace(result.region, '')
// 去除零散的标签关键字(如“手机号码”、“所在地区”、“详细地址”本身)
addressText = addressText
.replace(/(?:收件人|收货人|手机号码|手机号|所在地区|详细地址)/g, '')
result.address = addressText.replace(/[,。\s]+/g, '').trim()
return result
@@ -384,10 +408,23 @@ const AddressEditPage: React.FC = () => {
}
// 执行新增或更新
let savedId: number | undefined = addressId
if (isEditMode) {
await updateShopUserAddress(submitData)
} else {
await addShopUserAddress(submitData)
const res: any = await addShopUserAddress(submitData)
// 后端新增返回可能带有 id 字段
savedId = (res && (res.data?.id || res.id)) || addressId
}
// 兜底:勾选默认时主动调用 set-default让后端走统一的"取消其他默认"逻辑
// 这样即使 save/update 在某些老版本后端上漏处理,前端也能保证唯一默认
if (submitData.isDefault && savedId) {
try {
await setDefaultAddress(savedId)
} catch (e) {
console.warn('setDefaultAddress fallback failed:', e)
}
}
Taro.showToast({title: `${isEditMode ? '更新' : '保存'}成功`, icon: 'success'})
@@ -432,11 +469,14 @@ const AddressEditPage: React.FC = () => {
// 编辑模式:加载已有地址数据
if (isEditMode && addressId) {
try {
const addr = await getShopUserAddress(addressId)
const addr = (await getShopUserAddress(addressId)) as ShopUserAddress
setFormData(addr)
setRegionText(`${addr.province || ''} ${addr.city || ''} ${addr.region || ''}`)
const p = String((addr as any)?.province || '').trim()
const c = String((addr as any)?.city || '').trim()
const r = String((addr as any)?.region || '').trim()
setRegionText([p, c, r].filter(Boolean).join(' '))
if (hasValidLngLat(addr)) {
setSelectedLocation({lng: String(addr.lng), lat: String(addr.lat)})
setSelectedLocation({lng: String((addr as any).lng), lat: String((addr as any).lat)})
setRegionLocked(true)
}
} catch (error) {
@@ -465,7 +505,7 @@ const AddressEditPage: React.FC = () => {
{/* 地址智能识别区 */}
<View className="bg-white px-3 pt-3 pb-2">
<View
className="rounded-lg p-2 relative"
className="rounded-lg p-2"
style={{border: '1px dashed #0e932e'}}
>
<Textarea
@@ -475,19 +515,20 @@ const AddressEditPage: React.FC = () => {
placeholder="粘贴文本,点击「识别」自动识别收货人、地址、电话"
maxlength={200}
/>
<View
className="absolute right-2 bottom-2"
style={{
backgroundColor: '#0e932e',
borderRadius: '12px',
paddingLeft: '12px',
paddingRight: '12px',
paddingTop: '4px',
paddingBottom: '4px'
}}
onClick={recognizeAddress}
>
<Text className="text-white text-xs"></Text>
<View className="flex justify-end mt-2">
<View
style={{
backgroundColor: '#0e932e',
borderRadius: '12px',
paddingLeft: '12px',
paddingRight: '12px',
paddingTop: '4px',
paddingBottom: '4px'
}}
onClick={recognizeAddress}
>
<Text className="text-white text-xs"></Text>
</View>
</View>
</View>
</View>

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import { View, Text, ScrollView, Button } from '@tarojs/components'
import Taro from '@tarojs/taro'
import EmptyState from '@/components/common/EmptyState'
@@ -127,14 +127,25 @@ const HelpCenterPage: React.FC = () => {
</View>
))}
{/* 联系客服入口 */}
<View
className='bg-white rounded-lg p-4 flex items-center justify-center gap-2 mt-3'
onClick={() => Taro.navigateTo({ url: '/pages/user/customer-service/index' })}
{/* 联系客服入口 - 直接进入微信客服 */}
<Button
openType="contact"
sessionFrom="help_center"
showMessageCard
sendMessageTitle="欢迎咨询"
onContact={(e: any) => console.log('客服会话回调:', e.detail)}
className='bg-white rounded-lg w-full mt-3 flex items-center justify-center gap-2'
style={{
height: 'auto',
lineHeight: 'normal',
padding: '16px',
border: 'none',
borderRadius: '8px',
}}
>
<Text className='text-xl'>📞</Text>
<Text className='text-sm text-blue-500'>线</Text>
</View>
</Button>
<View className='h-4'></View>
</View>

View File

@@ -1,35 +1,96 @@
import React, { useState, useEffect } from 'react'
import React, { useState, useEffect, useCallback } from 'react'
import { View, Text, Image, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import EmptyState from '@/components/common/EmptyState'
import LoadMore from '@/components/common/LoadMore'
import { getCompressedImageUrl } from '@/utils/image'
import { useUserContext } from '@/contexts/UserContext'
import {
pageBrowseHistory,
deleteBrowseRecord,
clearBrowseHistory
} from '@/api/shop/shopGoodsBrowse'
import type { ShopGoodsBrowseParam } from '@/api/shop/shopGoodsBrowse/model'
definePageConfig({
navigationBarTitleText: '浏览历史',
enablePullDownRefresh: true,
})
interface HistoryItem {
/** 统一展示类型(兼容服务端 + 本地兜底) */
interface DisplayItem {
id?: number
goodsId: number
name?: string
image?: string
price?: string
timestamp: number
visitCount?: number
lastVisitTime?: string
}
const PAGE_SIZE = 20
const BrowseHistoryPage: React.FC = () => {
const [list, setList] = useState<HistoryItem[]>([])
const { isLoggedIn } = useUserContext()
const [list, setList] = useState<DisplayItem[]>([])
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true)
const [loading, setLoading] = useState(false)
const loadHistory = useCallback(async (pageNum: number) => {
if (loading) return
setLoading(true)
try {
if (isLoggedIn) {
// 已登录:服务端分页查询
const params: ShopGoodsBrowseParam = { page: pageNum, limit: PAGE_SIZE }
const res = await pageBrowseHistory(params)
const items: DisplayItem[] = (res.list || []).map(item => ({
id: item.id,
goodsId: item.goodsId!,
name: item.goodsName,
image: item.goodsImage,
price: item.price,
visitCount: item.visitCount,
lastVisitTime: item.lastVisitTime,
}))
setList(prev => pageNum === 1 ? items : [...prev, ...items])
setHasMore(list.length + items.length < res.count)
setPage(pageNum)
} else {
// 未登录:本地存储兜底
const history = Taro.getStorageSync('browse_history') || []
const items: DisplayItem[] = history.map((item: any) => ({
goodsId: item.goodsId,
name: item.name,
image: item.image,
price: item.price,
lastVisitTime: item.timestamp ? new Date(item.timestamp).toISOString() : undefined,
}))
setList(items)
setHasMore(false)
}
} catch {
Taro.showToast({ title: '加载失败', icon: 'none' })
} finally {
setLoading(false)
}
}, [isLoggedIn, loading, list.length])
useEffect(() => {
loadHistory()
}, [])
loadHistory(1)
}, [isLoggedIn])
const loadHistory = () => {
try {
const history = Taro.getStorageSync('browse_history') || []
setList(history)
} catch {
setList([])
// 下拉刷新
const onPullDownRefresh = async () => {
await loadHistory(1)
Taro.stopPullDownRefresh()
}
// 上拉加载更多
const onScrollToLower = () => {
if (hasMore && !loading && isLoggedIn) {
loadHistory(page + 1)
}
}
@@ -37,31 +98,74 @@ const BrowseHistoryPage: React.FC = () => {
Taro.navigateTo({ url: `/pages/shop/product-detail?id=${goodsId}` })
}
const handleDelete = async (item: DisplayItem) => {
Taro.showModal({
title: '提示',
content: '确定要删除这条浏览记录吗?',
success: async (res) => {
if (res.confirm) {
if (isLoggedIn && item.id) {
try {
await deleteBrowseRecord(item.id)
} catch {
Taro.showToast({ title: '删除失败', icon: 'none' })
return
}
}
setList(prev => prev.filter(i => i.goodsId !== item.goodsId))
Taro.showToast({ title: '已删除', icon: 'success' })
}
}
})
}
const handleClearHistory = () => {
Taro.showModal({
title: '提示',
content: '确定要清空浏览历史吗?',
success: (res) => {
content: '确定要清空全部浏览历史吗?',
success: async (res) => {
if (res.confirm) {
if (isLoggedIn) {
try {
await clearBrowseHistory()
} catch {
Taro.showToast({ title: '清空失败', icon: 'none' })
return
}
}
Taro.removeStorageSync('browse_history')
setList([])
setHasMore(false)
Taro.showToast({ title: '已清空', icon: 'success' })
}
}
})
}
const formatTime = (timestamp: number) => {
const date = new Date(timestamp)
return `${date.getMonth() + 1}/${date.getDate()} ${date.getHours()}:${String(date.getMinutes()).padStart(2, '0')}`
const formatTime = (timeStr?: string) => {
if (!timeStr) return ''
const date = new Date(timeStr)
const now = new Date()
const diff = now.getTime() - date.getTime()
const minutes = Math.floor(diff / 60000)
const hours = Math.floor(diff / 3600000)
const days = Math.floor(diff / 86400000)
if (minutes < 1) return '刚刚'
if (minutes < 60) return `${minutes}分钟前`
if (hours < 24) return `${hours}小时前`
if (days < 7) return `${days}天前`
return `${date.getMonth() + 1}/${date.getDate()}`
}
return (
<View className='min-h-screen bg-gray-50'>
{/* 顶部操作栏 */}
{list.length > 0 && (
<View className='bg-white px-4 py-2 flex justify-end border-b border-gray-100'>
<Text
<View className='bg-white px-4 py-2 flex justify-between items-center border-b border-gray-100'>
<Text className='text-xs text-gray-400'>
{isLoggedIn ? `${list.length} 条记录` : '本地记录'}
</Text>
<Text
className='text-sm text-red-500'
onClick={handleClearHistory}
>
@@ -69,15 +173,20 @@ const BrowseHistoryPage: React.FC = () => {
</Text>
</View>
)}
<ScrollView scrollY className='h-screen'>
<ScrollView
scrollY
className='h-screen'
onScrollToLower={onScrollToLower}
lowerThreshold={80}
>
<View className='p-3'>
{list.length > 0 ? (
<View className='grid grid-cols-2 gap-3'>
{list.map(item => (
<View
key={`${item.goodsId}-${item.timestamp}`}
className='bg-white rounded-lg overflow-hidden'
key={`${item.goodsId}-${item.id || item.lastVisitTime}`}
className='bg-white rounded-lg overflow-hidden relative'
onClick={() => handleItemClick(item.goodsId)}
>
<Image
@@ -95,9 +204,22 @@ const BrowseHistoryPage: React.FC = () => {
¥{item.price || '0'}
</Text>
<Text className='text-xs text-gray-400'>
{formatTime(item.timestamp)}
{formatTime(item.lastVisitTime)}
</Text>
</View>
{item.visitCount && item.visitCount > 1 && (
<Text className='text-xs text-orange-400 mt-1 block'>
{item.visitCount}
</Text>
)}
</View>
{/* 删除按钮 */}
<View
className='absolute top-1 right-1 w-6 h-6 rounded-full bg-black bg-opacity-40 flex items-center justify-center'
catchMove
onClick={(e) => { e.stopPropagation(); handleDelete(item) }}
>
<Text className='text-white text-xs'>×</Text>
</View>
</View>
))}
@@ -105,6 +227,9 @@ const BrowseHistoryPage: React.FC = () => {
) : (
<EmptyState text='暂无浏览历史' />
)}
{isLoggedIn && list.length > 0 && (
<LoadMore hasMore={hasMore} loading={loading} />
)}
</View>
</ScrollView>
</View>

View File

@@ -8,7 +8,7 @@ const UserPage: React.FC = () => {
const { user, isLoggedIn } = useUser()
// TabBar 页面列表
const tabBarPages = ['/pages/index/index', '/pages/shop/index', '/pages/order/list', '/pages/user/user']
const tabBarPages = ['/pages/index/index', '/pages/shop/index', '/pages/shop/cart', '/pages/user/user']
const handleNavigate = (url: string) => {
if (tabBarPages.includes(url)) {
@@ -155,7 +155,7 @@ const UserPage: React.FC = () => {
</View>
<View
className='text-center py-2 rounded-lg'
onClick={() => Taro.switchTab({ url: '/pages/order/list' })}
onClick={() => Taro.navigateTo({ url: '/pages/order/list' })}
>
<Text className='text-xl font-bold text-transparent' style={{ background: 'linear-gradient(135deg, #4facfe, #00f2fe)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent' }}>
0
@@ -172,22 +172,22 @@ const UserPage: React.FC = () => {
<View className='bg-white rounded-xl mx-3 mt-3 p-4' style={{ boxShadow: '0 2px 8px rgba(0, 0, 0, 0.05)' }}>
<View className='flex justify-between items-center mb-3'>
<Text className='text-base font-medium text-gray-800'></Text>
<Text className='text-xs text-gray-400' onClick={() => Taro.switchTab({ url: '/pages/order/list' })}>
<Text className='text-xs text-gray-400' onClick={() => Taro.navigateTo({ url: '/pages/order/list' })}>
{'>'}
</Text>
</View>
<View className='grid grid-cols-4 gap-2'>
{[
{ icon: '💳', label: '待付款', status: 0, color: '#f093fb' },
{ icon: '📦', label: '待发货', status: 1, color: '#667eea' },
{ icon: '🚚', label: '待收货', status: 2, color: '#4facfe' },
{ icon: '✅', label: '已完成', status: 3, color: '#52c41a' },
{ icon: '💳', label: '待付款', tabIndex: 1, color: '#f093fb' },
{ icon: '📦', label: '待发货', tabIndex: 2, color: '#667eea' },
{ icon: '🚚', label: '待收货', tabIndex: 3, color: '#4facfe' },
{ icon: '✅', label: '已完成', tabIndex: 4, color: '#52c41a' },
].map(item => (
<View
key={item.status}
key={item.tabIndex}
className='flex flex-col items-center py-2 rounded-lg'
style={{ background: `${item.color}10` }}
onClick={() => { Taro.setStorageSync('order_tab', item.status); Taro.switchTab({ url: '/pages/order/list' }) }}
onClick={() => { Taro.setStorageSync('order_tab', item.tabIndex); Taro.navigateTo({ url: `/pages/order/list?tab=${item.tabIndex}` }) }}
>
<Text className='text-xl mb-1'>{item.icon}</Text>
<Text className='text-xs text-gray-600'>{item.label}</Text>

View File

@@ -11,6 +11,8 @@ import type { UserRole } from '@/api/system/userRole/model'
import { checkAndCacheVipStatus } from '@/utils/vip'
import { requireLogin } from '@/utils/login-guard'
import MemberBadge from '@/components/business/MemberBadge'
import Badge from '@/components/common/Badge'
import ArrowRight from '@/components/common/ArrowRight'
const UserPage: React.FC = () => {
const scrollHeight = useScrollHeight()
@@ -41,7 +43,8 @@ const UserPage: React.FC = () => {
cacheKey: 'user_order_stats',
cacheTime: 30 * 1000,
onSuccess: (data) => {
Taro.setStorageSync('user_order_stats_v2', data)
// v3: 字段结构变更(与后端 UserOrderStats DTO 对齐),旧版 v2 缓存自动失效
Taro.setStorageSync('user_order_stats_v3', data)
},
})
@@ -51,7 +54,7 @@ const UserPage: React.FC = () => {
// 冷启动优化:先从 localStorage 读取缓存即时展示,消除骨架屏
const cachedCardStats = Taro.getStorageSync('user_card_stats_v2')
if (cachedCardStats) mutateCardStats(cachedCardStats)
const cachedOrderStats = Taro.getStorageSync('user_order_stats_v2')
const cachedOrderStats = Taro.getStorageSync('user_order_stats_v3')
if (cachedOrderStats) mutateOrderStats(cachedOrderStats)
// 然后发起网络请求stale-while-revalidate 后台刷新)
runCardStats()
@@ -78,7 +81,7 @@ const UserPage: React.FC = () => {
if (cachedCardStats) {
mutateCardStats(cachedCardStats)
}
const cachedOrderStats = Taro.getStorageSync('user_order_stats_v2')
const cachedOrderStats = Taro.getStorageSync('user_order_stats_v3')
if (cachedOrderStats) {
mutateOrderStats(cachedOrderStats)
}
@@ -106,7 +109,7 @@ const UserPage: React.FC = () => {
{ icon: '📍', label: '收货地址', url: '/pages/user/address-list', requireAuth: true },
// { icon: '⭐', label: '积分明细', url: '/pages/user/points-record' },
{ icon: '❤️', label: '我的收藏', url: '/pages/user/favorite-list/index', requireAuth: true },
// { icon: '🕐', label: '浏览历史', url: '/pages/user/history-list/index' },
{ icon: '🕐', label: '浏览历史', url: '/pages/user/history-list/index' },
{ icon: '❓', label: '帮助中心', url: '/pages/user/help-center/index' },
// { icon: '🔔', label: '消息通知', url: '/pages/index/notification' },
// 门店管理:仅门店经理/店员显示
@@ -135,15 +138,15 @@ const UserPage: React.FC = () => {
}
// 订单快捷入口点击:需要登录
const handleOrderTabClick = (status: number) => {
if (!requireLogin({ action: 'viewOrder', redirect: '/pages/order/list', content: '登录后才能查看订单,是否前往登录?' })) return
Taro.setStorageSync('order_tab', status)
Taro.switchTab({ url: '/pages/order/list' })
const handleOrderTabClick = (tabIndex: number) => {
if (!requireLogin({ action: 'viewOrder', redirect: `/pages/order/list?tab=${tabIndex}`, content: '登录后才能查看订单,是否前往登录?' })) return
Taro.setStorageSync('order_tab', tabIndex)
Taro.navigateTo({ url: `/pages/order/list?tab=${tabIndex}` })
}
const handleViewAllOrders = () => {
if (!requireLogin({ action: 'viewOrder', redirect: '/pages/order/list', content: '登录后才能查看订单,是否前往登录?' })) return
Taro.switchTab({ url: '/pages/order/list' })
Taro.navigateTo({ url: '/pages/order/list' })
}
const loading = cardStatsLoading || orderStatsLoading
@@ -214,7 +217,7 @@ const UserPage: React.FC = () => {
))}
</View>
</View>
{isLoggedIn && <Text className='text-white text-opacity-60 text-sm'>{'>'}</Text>}
{isLoggedIn && <ArrowRight className='text-white text-opacity-60' />}
</View>
{/* 数据概览 */}
{isLoggedIn && (
@@ -257,30 +260,28 @@ const UserPage: React.FC = () => {
<View className='bg-white rounded-xl mx-3 mt-3 p-4'>
<View className='flex justify-between items-center mb-3'>
<Text className='text-base font-medium text-gray-800'></Text>
<Text className='text-xs text-gray-400' onClick={handleViewAllOrders}>
{'>'}
</Text>
<View className='flex items-center text-xs text-gray-400' onClick={handleViewAllOrders}>
<Text></Text>
<ArrowRight className='ml-1' />
</View>
</View>
<View className='grid grid-cols-4 gap-2'>
{[
{ icon: '💳', label: '待付款', status: 0, count: orderStats?.pending },
{ icon: '📦', label: '待发货', status: 1, count: orderStats?.paid },
{ icon: '🚚', label: '待货', status: 2, count: orderStats?.shipped },
{ icon: '', label: '已完成', status: 3, count: orderStats?.completed },
// status 与 OrderListStatus 对齐0待付款 1待发货 3待收货 5已完成
{ icon: '💳', label: '待付款', tabIndex: 1, count: orderStats?.waitPay },
{ icon: '📦', label: '待货', tabIndex: 2, count: orderStats?.waitDeliver },
{ icon: '🚚', label: '待收货', tabIndex: 3, count: orderStats?.waitReceive },
{ icon: '✅', label: '已完成', tabIndex: 4, count: orderStats?.completed },
].map(item => (
<View
key={item.status}
key={item.tabIndex}
className={`flex flex-col items-center py-2 relative ${!isLoggedIn ? 'opacity-50' : ''}`}
onClick={() => handleOrderTabClick(item.status)}
onClick={() => handleOrderTabClick(item.tabIndex)}
>
<Text className='text-xl mb-1'>{item.icon}</Text>
<Text className='text-xs text-gray-600'>{item.label}</Text>
{/* 数量角标 */}
{item.count && item.count > 0 && (
<View className='absolute top-0 right-2 bg-red-500 text-white text-xs rounded-full min-w-4 h-4 flex items-center justify-center px-1'>
{item.count > 99 ? '99+' : item.count}
</View>
)}
{/* 数量角标count 为 0 时组件内部不渲染 */}
<Badge count={item.count} className='absolute -top-1 -right-1' />
</View>
))}
</View>
@@ -311,7 +312,7 @@ const UserPage: React.FC = () => {
)}
</View>
</View>
<Text className={`text-sm ${(item as any).highlight ? 'text-amber-700' : 'text-gray-300'}`}>{'>'}</Text>
<ArrowRight className={(item as any).highlight ? 'text-amber-700' : 'text-gray-300'} />
</View>
))}
</View>

View File

@@ -9,7 +9,7 @@ import {
trackInviteSource,
} from '@/utils/invite'
const TABBAR_URLS = ['/pages/index/index', '/pages/order/order', '/pages/user/user']
const TABBAR_URLS = ['/pages/index/index', '/pages/shop/index', '/pages/shop/cart', '/pages/user/user']
export function decodeRedirect(input?: string): string {
if (!input) return ''

View File

@@ -206,6 +206,21 @@
margin-top: 10px;
}
// 降级登录入口(短信验证码)
.login-methods__alt {
text-align: center;
margin-top: 4px;
}
.login-methods__alt-link {
display: inline-block;
font-size: 26px;
color: rgba(255, 255, 255, 0.85);
text-decoration: underline;
padding: 8px 16px;
letter-spacing: 0.5px;
}
// 登录按钮
.login-btn {
height: 96px;

View File

@@ -1,10 +1,17 @@
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import Taro from '@tarojs/taro'
import { View, Image, Text, Button } from '@tarojs/components'
import { TenantId } from '@/config/app'
import { getWxOpenId } from '@/api/layout'
import { getUserInfo } from '@/api/layout'
import { saveStorageByLoginUser } from '@/utils/server'
import { saveStorageByLoginUser, SERVER_API_URL } from '@/utils/server'
import { isUserDisabled } from '@/utils/auth'
import request from '@/utils/request'
import {
getPhoneAuthCooldownRemaining,
isPhoneAuthCoolingDown,
markPhoneAuthCalled,
} from '@/utils/phoneAuth'
import {
checkAndHandleInviteRelation,
hasPendingInvite,
@@ -74,15 +81,15 @@ async function ensureWxOpenIdSaved() {
/** 获取用户信息 */
async function fetchUserInfo(token: string) {
try {
const serverUrl = 'https://server.websoft.top'
const res: any = await Taro.request({
url: `${serverUrl}/api/auth/user`,
url: `${SERVER_API_URL}/auth/user`,
method: 'GET',
header: {
'Authorization': `Bearer ${token}`,
Authorization: token,
'content-type': 'application/json',
TenantId: String(TenantId),
},
timeout: 15000,
})
if (res.data?.code === 0 && res.data?.data) {
@@ -98,16 +105,89 @@ async function fetchUserInfo(token: string) {
const Login = () => {
const [isAgree, setIsAgree] = useState(false)
const [loading, setLoading] = useState(false)
const [phoneAuthPending, setPhoneAuthPending] = useState(false)
const [phoneAuthCooling, setPhoneAuthCooling] = useState(isPhoneAuthCoolingDown())
const [showContent, setShowContent] = useState(false)
const phoneAuthInFlightRef = useRef(false)
const phoneAuthPendingRef = useRef(false)
const phoneAuthTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const phoneAuthPendingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const router = Taro.getCurrentInstance().router
const isWeapp = IS_WEAPP
const canUsePhoneAuth = isAgree && !loading && !phoneAuthPending && !phoneAuthCooling
/** 页面加载动画 */
useEffect(() => {
setTimeout(() => setShowContent(true), 100)
}, [])
useEffect(() => {
return () => {
if (phoneAuthTimerRef.current) {
clearTimeout(phoneAuthTimerRef.current)
phoneAuthTimerRef.current = null
}
if (phoneAuthPendingTimerRef.current) {
clearTimeout(phoneAuthPendingTimerRef.current)
phoneAuthPendingTimerRef.current = null
}
}
}, [])
const startPhoneAuthCooldown = () => {
const remaining = Math.max(getPhoneAuthCooldownRemaining(), 300)
setPhoneAuthCooling(true)
if (phoneAuthTimerRef.current) clearTimeout(phoneAuthTimerRef.current)
phoneAuthTimerRef.current = setTimeout(() => {
phoneAuthTimerRef.current = null
setPhoneAuthCooling(false)
}, remaining)
}
const clearPhoneAuthPending = () => {
phoneAuthPendingRef.current = false
setPhoneAuthPending(false)
if (phoneAuthPendingTimerRef.current) {
clearTimeout(phoneAuthPendingTimerRef.current)
phoneAuthPendingTimerRef.current = null
}
}
const startPhoneAuthPending = () => {
phoneAuthPendingRef.current = true
setPhoneAuthPending(true)
markPhoneAuthCalled()
startPhoneAuthCooldown()
if (phoneAuthPendingTimerRef.current) clearTimeout(phoneAuthPendingTimerRef.current)
phoneAuthPendingTimerRef.current = setTimeout(() => {
clearPhoneAuthPending()
Taro.showModal({
title: '手机号授权超时',
content: '微信手机号授权暂时无响应,请稍后重试或改用短信验证码登录。',
confirmText: '短信登录',
cancelText: '稍后重试',
confirmColor: '#07c160',
success: (res) => {
if (res.confirm) goSmsLogin()
},
})
}, 12000)
}
const handlePhoneAuthTap = () => {
if (!isAgree) {
Taro.showToast({ title: '请先勾选同意协议', icon: 'none' })
return
}
if (loading || phoneAuthInFlightRef.current || phoneAuthPendingRef.current || isPhoneAuthCoolingDown()) {
startPhoneAuthCooldown()
Taro.showToast({ title: '请稍后再试', icon: 'none' })
return
}
startPhoneAuthPending()
}
/** 解析 redirect 参数 */
const redirectUrl = (() => {
const raw = (router?.params as Record<string, string> | undefined)?.redirect
@@ -143,9 +223,8 @@ const Login = () => {
const tabBarUrls = [
'/pages/index/index',
'/pages/shop/index',
'/pages/points/index',
'/pages/user/user',
'/pages/order/list',
'/pages/shop/cart',
]
const pure = redirectUrl.split('?')[0]
@@ -157,17 +236,65 @@ const Login = () => {
await Taro.redirectTo({ url: redirectUrl })
}
/** 跳转到短信验证码登录(降级方案) */
const goSmsLogin = () => {
const params: Record<string, string> = {}
if (redirectUrl) params.redirect = redirectUrl
// 透传邀请参数,避免从登录页切到短信登录时丢失 inviter
const inviteParams = parseInviteParams({ query: router?.params })
if (inviteParams?.inviter) params.inviter = inviteParams.inviter
if (inviteParams?.source) params.source = inviteParams.source
if (inviteParams?.t) params.t = inviteParams.t
const qs = Object.entries(params)
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
.join('&')
Taro.navigateTo({ url: `/passport/sms-login${qs ? `?${qs}` : ''}` })
}
/** 手机号授权失败时弹窗引导走短信登录(区分原因) */
const showPhoneAuthFailedModal = (errMsg?: string) => {
let content = '请尝试使用短信验证码登录'
if (errMsg?.includes('user deny')) {
content = '您已取消微信手机号授权,可改用短信验证码登录'
} else if (errMsg?.includes('privacy')) {
content = '微信未授权获取手机号,可改用短信验证码登录'
} else if (errMsg?.includes('no permission')) {
content = '小程序暂未获得手机号授权,可改用短信验证码登录'
}
Taro.showModal({
title: '未获取到手机号',
content,
confirmText: '短信登录',
cancelText: '我知道了',
confirmColor: '#07c160',
success: (res) => {
if (res.confirm) goSmsLogin()
},
})
}
/** 手机号快捷登录 */
const handleGetPhoneNumber = async ({ detail }: GetPhoneNumberEvent) => {
clearPhoneAuthPending()
if (!isAgree) {
Taro.showToast({ title: '请先勾选同意协议', icon: 'none' })
return
}
if (loading) return
if (loading || phoneAuthInFlightRef.current) {
startPhoneAuthCooldown()
Taro.showToast({ title: '请稍后再试', icon: 'none' })
return
}
const { code: phoneCode, errMsg } = detail || {}
phoneAuthInFlightRef.current = true
startPhoneAuthCooldown()
const { code: phoneCode, encryptedData, iv, errMsg } = detail || {}
if (!phoneCode || (errMsg && errMsg.includes('fail'))) {
Taro.showToast({ title: '未授权手机号', icon: 'none' })
phoneAuthInFlightRef.current = false
showPhoneAuthFailedModal(errMsg)
return
}
@@ -177,23 +304,28 @@ const Login = () => {
const inviteParams = parseInviteParams({ query: router?.params })
const refereeId = inviteParams?.inviter ? parseInt(inviteParams.inviter, 10) : 0
const serverUrl = 'https://server.websoft.top'
const res: any = await Taro.request({
url: `${serverUrl}/api/wx-login/loginByMpWxPhone`,
method: 'POST',
data: {
const res: any = await request.post(
`${SERVER_API_URL}/wx-login/loginByMpWxPhone`,
{
code: phoneCode,
encryptedData,
iv,
notVerifyPhone: true,
refereeId,
sceneType: 'save_referee',
tenantId: Number(TenantId),
},
header: { 'content-type': 'application/json', TenantId: String(TenantId) },
})
{
timeout: 20000,
retry: 0,
showError: false,
returnRaw: true,
}
)
if (res.data?.code === 0 && res.data?.data?.access_token) {
const token = res.data.data.access_token
let user = res.data.data.user
if (res?.code === 0 && res?.data?.access_token) {
const token = res.data.access_token
let user = res.data.user
// 获取最新的用户信息
const freshUserInfo = await fetchUserInfo(token)
@@ -201,6 +333,18 @@ const Login = () => {
user = freshUserInfo
}
// 检查用户是否被禁用
if (isUserDisabled(user)) {
Taro.showModal({
title: '账号已被禁用',
content: '您的账号已被管理员禁用,暂时无法使用小程序。如有疑问请联系门店客服。',
showCancel: false,
confirmText: '我知道了',
confirmColor: '#ee0a24',
})
return
}
saveStorageByLoginUser(token, user)
// 绑定 openid + 处理邀请关系
@@ -212,12 +356,14 @@ const Login = () => {
Taro.showToast({ title: '登录成功', icon: 'success' })
setTimeout(() => navigateAfterLogin(), 800)
} else {
Taro.showToast({ title: res.data?.message || '登录失败', icon: 'none' })
Taro.showToast({ title: res?.message || '登录失败', icon: 'none' })
}
} catch (e: any) {
console.error('微信登录失败:', e)
Taro.showToast({ title: e?.message || '登录失败', icon: 'none' })
} finally {
phoneAuthInFlightRef.current = false
clearPhoneAuthPending()
setLoading(false)
}
}
@@ -257,20 +403,30 @@ const Login = () => {
color: '#ffffff',
fontWeight: '600',
textAlign: 'center',
opacity: (!isAgree || loading) ? 0.5 : 1,
opacity: canUsePhoneAuth ? 1 : 0.5,
margin: 0,
padding: 0,
}}
openType='getPhoneNumber'
openType={canUsePhoneAuth ? 'getPhoneNumber' : undefined}
onClick={handlePhoneAuthTap}
onGetPhoneNumber={handleGetPhoneNumber}
disabled={!isAgree || loading}
loading={loading}
disabled={!canUsePhoneAuth}
loading={loading || phoneAuthPending}
>
</Button>
<View className='login-methods__tip text-gray-200'>
<Text></Text>
{/*<View className='login-methods__tip text-gray-200'>*/}
{/* <Text>点击上方按钮,快速登录</Text>*/}
{/*</View>*/}
<View className='login-methods__alt'>
<Text
className='login-methods__alt-link'
onClick={() => goSmsLogin()}
>
使
</Text>
</View>
<View className='login-methods__divider'>

View File

@@ -0,0 +1,5 @@
export default {
navigationBarTitleText: '确认支付',
navigationBarTextStyle: 'black',
navigationBarBackgroundColor: '#ffffff'
}

398
src/passport/pay/index.tsx Normal file
View File

@@ -0,0 +1,398 @@
import React, { useState, useEffect, useCallback } from 'react'
import { View, Text } from '@tarojs/components'
import { Button, Cell, Price, Divider } from '@nutui/nutui-react-taro'
import { Check, Close, Tips, Clock } from '@nutui/icons-react-taro'
import Taro from '@tarojs/taro'
import { request } from '@/utils/request'
import { getWxOpenId, loginByOpenId } from '@/api/layout'
import { saveStorageByLoginUser } from '@/utils/server'
import { TenantId } from '@/config/app'
/**
* 小程序支付页面
* 接收参数 subscriptionNo完成 JSAPI 支付
*
* 重要:判断订单是否已支付,必须用 payStatus0=未支付 1=已支付),
* 不能用 status订阅生命周期active/pending/expired/cancelled
* 续费场景下后端 renewPay 会保留原 status=active、仅设 payStatus=0
* 若用 status 判支付会直接误判为"已支付"。
*/
interface SubscriptionDetail {
id: number
subscriptionNo: string
productId: number
productName: string
productLogo?: string
productIcon?: string
status: string
// 支付状态: 0-未支付 1-已支付
payStatus?: number
payTime?: string
transactionId?: string
priceType: string
payPrice: number
subscriptionPeriod?: string
}
interface JsapiPayParams {
timeStamp: string
nonceStr: string
package: string
signType: string
paySign: string
outTradeNo: string
}
const PayPage: React.FC = () => {
const [loading, setLoading] = useState(true)
const [paying, setPaying] = useState(false)
const [detail, setDetail] = useState<SubscriptionDetail | null>(null)
const [errorMsg, setErrorMsg] = useState('')
const [paid, setPaid] = useState(false)
// 从页面参数中获取 subscriptionNo
// 小程序码 scene 会作为 query.scene 传入,需解码
const getSubscriptionNo = useCallback(() => {
const instance = Taro.getCurrentInstance()
const params = instance?.router?.params || {}
console.log('[PayPage] ===== 参数调试开始 =====')
console.log('[PayPage] 原始 params:', JSON.stringify(params))
console.log('[PayPage] params.scene:', params.scene)
console.log('[PayPage] params.subscriptionNo:', params.subscriptionNo)
// 优先读取 scene小程序码参数再读取 subscriptionNo普通链接参数
const scene = params.scene ? decodeURIComponent(params.scene) : ''
const subscriptionNo = scene || params.subscriptionNo || ''
console.log('[PayPage] decodeURIComponent(scene):', scene)
console.log('[PayPage] 最终 subscriptionNo:', subscriptionNo)
console.log('[PayPage] ===== 参数调试结束 =====')
return subscriptionNo
}, [])
// 获取订阅详情
const fetchDetail = useCallback(async () => {
const subscriptionNo = getSubscriptionNo()
if (!subscriptionNo) {
console.error('[PayPage] subscriptionNo 为空,无法请求')
setErrorMsg('缺少订单参数')
setLoading(false)
return
}
console.log('[PayPage] ===== 请求调试开始 =====')
console.log('[PayPage] 请求 URL:', `/app/subscription/detail-by-no/${subscriptionNo}`)
console.log('[PayPage] 当前 token:', Taro.getStorageSync('access_token') || '(无token)')
try {
// request 默认 returnRaw=false拦截器已拆包返回的就是 data 部分(订阅对象)
const data: any = await request({
url: `/app/subscription/detail-by-no/${subscriptionNo}`,
method: 'GET',
returnRaw: true // 先用 returnRaw=true 拿到完整响应,方便调试
})
console.log('[PayPage] 接口完整返回returnRaw=true:', JSON.stringify(data))
console.log('[PayPage] data.code:', data?.code)
console.log('[PayPage] data.message:', data?.message)
console.log('[PayPage] data.data:', data?.data)
// 判断业务状态码
if (data?.code === 0 || data?.code === 200) {
const subscription = data.data || data
console.log('[PayPage] 订阅对象:', JSON.stringify(subscription))
console.log('[PayPage] 订阅状态 status:', subscription?.status)
console.log('[PayPage] 支付状态 payStatus:', subscription?.payStatus)
console.log('[PayPage] 支付时间 payTime:', subscription?.payTime)
// 关键修复:判断是否已支付,必须用 payStatus 而非 status
// 续费场景下后端 renewPay 会保留原 status=active、仅设 payStatus=0
const isPaid = subscription?.payStatus === 1
const isCancelled = subscription?.status === 'cancelled'
const isExpired = subscription?.status === 'expired'
if (isCancelled) {
setErrorMsg('订单已取消,无法继续支付')
} else if (isExpired) {
setErrorMsg('订单已过期,请重新下单')
} else if (isPaid) {
setPaid(true)
setDetail(subscription)
} else {
setDetail(subscription)
}
} else {
console.error('[PayPage] 业务错误 code:', data?.code, 'message:', data?.message)
setErrorMsg(data?.message || `业务错误(code=${data?.code})`)
}
} catch (err: any) {
console.error('[PayPage] ===== 请求异常 =====')
console.error('[PayPage] err.name:', err?.name)
console.error('[PayPage] err.type:', err?.type)
console.error('[PayPage] err.code:', err?.code)
console.error('[PayPage] err.message:', err?.message)
console.error('[PayPage] err.data:', err?.data)
console.error('[PayPage] err 完整:', JSON.stringify(err))
setErrorMsg(err?.message || '网络异常,请重试')
} finally {
setLoading(false)
console.log('[PayPage] ===== 请求调试结束 =====')
}
}, [getSubscriptionNo])
useEffect(() => {
fetchDetail()
}, [fetchDetail])
// 执行支付
const handlePay = async () => {
if (!detail) return
setPaying(true)
try {
// 1. 获取微信登录凭证code
const loginRes = await Taro.login()
if (!loginRes.code) {
throw new Error('获取登录凭证失败')
}
console.log('[PayPage] Taro.login code:', loginRes.code)
// 2. 用 code 换取 openid关键步骤之前遗漏了
const openIdRes: any = await getWxOpenId({ code: loginRes.code })
console.log('[PayPage] getWxOpenId 结果:', openIdRes)
const openid = openIdRes?.openid || openIdRes
if (!openid) {
throw new Error(openIdRes?.message || '获取 openid 失败,请重试')
}
// 缓存 openid后续支付确认等接口可用
Taro.setStorageSync('openid', openid)
console.log('[PayPage] 获取到 openid:', openid)
// 3. 尝试自动登录(获取 tokenmp-prepay 接口需要登录态)
// 如果用户已在小程序注册过loginByOpenId 会返回 token
// 如果用户未注册,不影响 openid 已获取,但 mp-prepay 可能需要特殊处理
try {
const loginResult: any = await loginByOpenId({
code: loginRes.code,
tenantId: TenantId
})
console.log('[PayPage] loginByOpenId 结果:', loginResult)
if (loginResult) {
// 登录成功,保存 token
saveStorageByLoginUser(loginResult.access_token || '', loginResult.user)
console.log('[PayPage] 自动登录成功,已保存 token')
} else {
console.warn('[PayPage] 自动登录未返回数据')
}
} catch (loginErr: any) {
console.warn('[PayPage] 自动登录异常:', loginErr)
// 登录异常但不阻断支付openid 已拿到,尝试继续
// 如果后端提示未注册,引导用户去登录
if (loginErr?.message?.includes('未注册') || loginErr?.message?.includes('不存在')) {
Taro.showModal({
title: '需要先登录',
content: '支付前需要先注册账号,是否前往登录?',
confirmText: '去登录',
cancelText: '取消'
}).then(modalRes => {
if (modalRes.confirm) {
Taro.navigateTo({ url: '/passport/login' })
}
})
return // 不继续支付流程
}
}
// 4. 请求后端创建 JSAPI 预支付订单
const prepayRes: any = await request({
url: `/app/subscription/mp-prepay/${detail.id}`,
method: 'POST',
data: { openid },
returnRaw: true
})
console.log('[PayPage] mp-prepay 完整返回:', JSON.stringify(prepayRes))
if (prepayRes?.code !== 0 && prepayRes?.code !== 200) {
throw new Error(prepayRes?.message || '创建支付订单失败')
}
const payParams: JsapiPayParams = prepayRes.data || prepayRes
if (!payParams.timeStamp || !payParams.package || !payParams.paySign) {
throw new Error('支付参数不完整')
}
// 5. 调用微信支付
await Taro.requestPayment({
timeStamp: payParams.timeStamp,
nonceStr: payParams.nonceStr,
package: payParams.package,
signType: (payParams.signType || 'RSA') as any,
paySign: payParams.paySign
})
// 6. 支付成功后通知后端确认
try {
await request({
url: `/app/subscription/mp-confirm/${detail.subscriptionNo}`,
method: 'POST',
data: {}
})
} catch (confirmErr) {
console.warn('支付确认通知失败,等待轮询:', confirmErr)
}
// 7. 显示成功状态
setPaid(true)
Taro.showToast({ title: '支付成功', icon: 'success' })
// 8. 延迟自动跳转到已购产品页面
setTimeout(() => {
Taro.navigateTo({ url: '/user/apps/index' }).catch(() => {
// 如果 navigateTo 失败(比如在 tab 页),回退到用户页
Taro.switchTab({ url: '/pages/user/user' })
})
}, 1500)
} catch (err: any) {
console.error('[PayPage] 支付失败:', err)
console.error('[PayPage] err.name:', err?.name, 'err.type:', err?.type)
console.error('[PayPage] err.code:', err?.code, 'err.message:', err?.message)
if (err?.errMsg?.includes('cancel')) {
Taro.showToast({ title: '支付已取消', icon: 'none' })
} else {
const msg = err?.message || err?.errMsg || '支付失败,请重试'
Taro.showToast({ title: msg, icon: 'error', duration: 2000 })
}
} finally {
setPaying(false)
}
}
// 返回
const handleBack = () => {
Taro.navigateBack({ delta: 1 }).catch(() => {
Taro.switchTab({ url: '/pages/user/user' })
})
}
// 格式化价格描述
const getPriceDesc = () => {
if (!detail) return ''
if (detail.priceType === 'one_time') return '永久买断'
if (detail.priceType === 'subscription') {
return detail.subscriptionPeriod === 'year' ? '按年订阅' : '按月订阅'
}
return ''
}
// --- 加载中 ---
if (loading) {
return (
<View className="pay-page min-h-screen bg-gray-50 flex items-center justify-center">
<View className="text-center">
<Clock className="text-blue-500 mb-4" size="48" />
<Text className="block text-gray-500">...</Text>
</View>
</View>
)
}
// --- 错误 ---
if (errorMsg) {
return (
<View className="pay-page min-h-screen bg-gray-50 flex items-center justify-center">
<View className="text-center p-8">
<Close className="text-red-500 mb-4" size="48" />
<Text className="block text-gray-800 text-lg mb-2"></Text>
<Text className="block text-gray-500 mb-6">{errorMsg}</Text>
<Button type="primary" onClick={handleBack}></Button>
</View>
</View>
)
}
// --- 已支付 ---
if (paid) {
return (
<View className="pay-page min-h-screen bg-gray-50 flex items-center justify-center">
<View className="text-center p-8">
<View className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<Check className="text-green-500" size="40" />
</View>
<Text className="block text-green-600 text-xl font-bold mb-2"></Text>
<Text className="block text-gray-500 mb-2">{detail?.productName || '应用订阅'}</Text>
{detail?.payPrice ? (
<Text className="block text-gray-400 mb-6">
¥{Number(detail.payPrice).toFixed(2)}
</Text>
) : null}
<Button type="primary" onClick={handleBack}></Button>
</View>
</View>
)
}
// --- 支付确认 ---
return (
<View className="pay-page min-h-screen bg-gray-50">
<View className="p-4">
{/* 订单信息 */}
<View className="bg-white rounded-lg shadow-sm p-4 mb-4">
<Text className="block text-lg font-bold text-gray-800 mb-3"></Text>
<Cell title="商品名称" description={detail?.productName || '-'} />
<Cell title="价格类型" description={getPriceDesc()} />
<View className="flex items-center justify-between px-4 py-3">
<Text className="text-gray-600"></Text>
<Price
price={detail?.payPrice}
size="large"
thousands
/>
</View>
<Divider />
<View className="flex items-start px-4 py-2">
<Tips className="text-orange-500 mr-2 mt-1" size="16" />
<Text className="text-sm text-gray-500">
使
</Text>
</View>
</View>
{/* 支付按钮 */}
<View className="fixed bottom-0 left-0 right-0 p-4 bg-white border-t border-gray-100">
<View className="flex gap-3">
<Button
block
onClick={handleBack}
className="flex-1"
>
</Button>
<Button
type="primary"
block
loading={paying}
onClick={handlePay}
className="flex-1"
>
{paying ? '支付中...' : '立即支付'}
</Button>
</View>
</View>
</View>
{/* 底部安全距离占位 */}
<View style={{ height: '100px' }} />
</View>
)
}
export default PayPage

View File

@@ -5,6 +5,7 @@ import { Button, Checkbox } from '@nutui/nutui-react-taro'
import { TenantId } from '@/config/app'
import { getUserInfo, getWxOpenId } from '@/api/layout'
import { saveStorageByLoginUser } from '@/utils/server'
import { isUserDisabled } from '@/utils/auth'
import {
getStoredInviteParams,
parseInviteParams,
@@ -88,9 +89,9 @@ 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'
pure === '/pages/shop/index' ||
pure === '/pages/shop/cart' ||
pure === '/pages/user/user'
)
}
@@ -109,10 +110,6 @@ const Register = () => {
const router = Taro.getCurrentInstance().router
useEffect(() => {
// register 页面不是 tabBar 页面,无需调用 hideTabBar
}, [])
const redirectUrl = useMemo(() => {
const raw = (router?.params as any)?.redirect as string | undefined
const decoded = safeDecodeMaybeEncoded(raw)
@@ -149,6 +146,28 @@ const Register = () => {
await Taro.redirectTo({ url: redirectUrl })
}
/** 手机号授权失败时弹窗引导走短信注册/登录(区分原因) */
const showPhoneAuthFailedModal = (errMsg?: string) => {
let content = '请尝试使用短信验证码注册/登录'
if (errMsg?.includes('user deny')) {
content = '您已取消微信手机号授权,可改用短信验证码注册/登录'
} else if (errMsg?.includes('privacy')) {
content = '微信未授权获取手机号,可改用短信验证码注册/登录'
} else if (errMsg?.includes('no permission')) {
content = '小程序暂未获得手机号授权,可改用短信验证码注册/登录'
}
Taro.showModal({
title: '未获取到手机号',
content,
confirmText: '短信注册/登录',
cancelText: '我知道了',
confirmColor: '#07c160',
success: (res) => {
if (res.confirm) goSmsLogin()
},
})
}
const handleGetPhoneNumber = async ({ detail }: GetPhoneNumberEvent) => {
if (!isAgree) {
Taro.showToast({ title: '请先勾选同意协议', icon: 'none' })
@@ -158,7 +177,7 @@ const Register = () => {
const { code: phoneCode, encryptedData, iv, errMsg } = detail || {}
if (!phoneCode || (errMsg && errMsg.includes('fail'))) {
Taro.showToast({ title: '未授权手机号', icon: 'none' })
showPhoneAuthFailedModal(errMsg)
return
}
@@ -202,6 +221,18 @@ const Register = () => {
return
}
// 检查用户是否被禁用
if (isUserDisabled(user)) {
Taro.showModal({
title: '账号已被禁用',
content: '您的账号已被管理员禁用,暂时无法使用小程序。如有疑问请联系门店客服。',
showCancel: false,
confirmText: '我知道了',
confirmColor: '#ee0a24',
})
return
}
saveStorageByLoginUser(token, user)
// 注册/登录成功后,立即补齐 openidJSAPI 支付必需)
@@ -268,11 +299,9 @@ const Register = () => {
/
</Button>
{!isWeapp && (
<Button type='default' block disabled={!isAgree || loading} onClick={goSmsLogin}>
/
</Button>
)}
<Button type='default' block disabled={!isAgree || loading} onClick={goSmsLogin}>
/
</Button>
</View>
<View className='mt-6 flex text-sm items-center px-1'>

332
src/passport/sms-login.scss Normal file
View File

@@ -0,0 +1,332 @@
// 页面容器
.page-sms-login {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
}
// 背景装饰
.sms-login-bg {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
overflow: hidden;
}
.sms-login-bg__gradient {
position: absolute;
top: -50%;
left: -50%;
right: -50%;
bottom: -50%;
background: radial-gradient(circle at 30% 50%, rgba(255, 255, 255, 0.1) 0%, transparent 50%),
radial-gradient(circle at 70% 80%, rgba(255, 255, 255, 0.08) 0%, transparent 50%);
animation: gradientMove 15s ease-in-out infinite;
}
@keyframes gradientMove {
0%, 100% {
transform: translate(0, 0) rotate(0deg);
}
33% {
transform: translate(30px, -30px) rotate(120deg);
}
66% {
transform: translate(-20px, 20px) rotate(240deg);
}
}
.sms-login-bg__circle {
position: absolute;
border-radius: 50%;
opacity: 0.1;
background: #ffffff;
animation: float 20s ease-in-out infinite;
}
.sms-login-bg__circle--1 {
width: 400px;
height: 400px;
top: -150px;
right: -100px;
animation-delay: 0s;
}
.sms-login-bg__circle--2 {
width: 300px;
height: 300px;
bottom: 10%;
left: -100px;
animation-delay: -7s;
}
@keyframes float {
0%, 100% {
transform: translate(0, 0) scale(1);
}
33% {
transform: translate(30px, -30px) scale(1.1);
}
66% {
transform: translate(-20px, 20px) scale(0.9);
}
}
// 内容区域
.sms-login-content {
display: flex;
flex-direction: column;
min-height: 100vh;
padding: 0 50px;
position: relative;
z-index: 1;
}
// 头部 Logo 和标题
.sms-login-header {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 120px;
margin-bottom: 60px;
animation: slideDown 0.8s ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.sms-login-logo {
width: 140px;
height: 140px;
border-radius: 35px;
background: rgba(255, 255, 255, 0.95);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 36px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
animation: logoFloat 3s ease-in-out infinite;
}
@keyframes logoFloat {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-8px);
}
}
.sms-login-logo__icon {
font-size: 72px;
line-height: 1;
}
.sms-login-title {
font-size: 52px;
font-weight: 700;
color: #ffffff;
margin-bottom: 18px;
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
letter-spacing: 2px;
}
.sms-login-subtitle {
font-size: 26px;
color: rgba(255, 255, 255, 0.85);
text-align: center;
}
// 表单卡片
.sms-login-card {
background: rgba(255, 255, 255, 0.98);
border-radius: 28px;
padding: 12px 32px;
margin-bottom: 48px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.18);
animation: slideUp 0.6s ease-out 0.2s both;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.sms-login-field {
display: flex;
align-items: center;
height: 110px;
position: relative;
}
.sms-login-field--code {
padding: 0;
}
.sms-login-field__icon-wrap {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
height: 100%;
}
.sms-login-field__icon {
font-size: 36px;
margin-right: 20px;
line-height: 1;
flex-shrink: 0;
}
.sms-login-field__input {
flex: 1;
height: 110px;
font-size: 30px;
color: #333333;
letter-spacing: 1px;
background: transparent;
border: none;
outline: none;
padding: 0;
min-width: 0;
}
.sms-login-field__placeholder {
color: #b8b8b8;
font-size: 30px;
}
.sms-login-divider {
height: 1px;
background: linear-gradient(to right, transparent, #e8e8e8 20%, #e8e8e8 80%, transparent);
margin: 0 -8px;
}
// 发送验证码按钮
.sms-login-send {
flex-shrink: 0;
height: 64px;
padding: 0 22px;
border-radius: 32px;
background: linear-gradient(135deg, #07c160, #06ad56);
color: #ffffff;
font-size: 26px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
margin-left: 16px;
box-shadow: 0 6px 20px rgba(7, 193, 96, 0.35);
letter-spacing: 0.5px;
white-space: nowrap;
}
.sms-login-send--disabled {
background: #e0e0e0;
color: #999999;
box-shadow: none;
}
// 登录按钮
.sms-login-submit {
height: 100px;
width: 100%;
border-radius: 50px;
background: linear-gradient(135deg, #07c160, #06ad56);
color: #ffffff;
font-size: 34px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
letter-spacing: 4px;
box-shadow: 0 12px 36px rgba(7, 193, 96, 0.4);
margin-bottom: 36px;
animation: slideUp 0.6s ease-out 0.35s both;
}
.sms-login-submit--disabled {
background: #b8e0c5;
color: rgba(255, 255, 255, 0.85);
box-shadow: 0 4px 12px rgba(7, 193, 96, 0.15);
}
.sms-login-submit--hover {
transform: scale(0.98);
opacity: 0.92;
}
// 返回链接
.sms-login-back {
display: flex;
align-items: center;
justify-content: center;
height: 80px;
margin-bottom: 32px;
animation: fadeIn 0.8s ease-out 0.5s both;
}
.sms-login-back__icon {
font-size: 30px;
color: rgba(255, 255, 255, 0.9);
margin-right: 10px;
line-height: 1;
}
.sms-login-back__text {
font-size: 28px;
color: rgba(255, 255, 255, 0.9);
text-decoration: underline;
letter-spacing: 1px;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
// 底部协议
.sms-login-tips {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
padding: 0 40px;
margin-top: auto;
padding-bottom: 60px;
animation: fadeIn 0.8s ease-out 0.6s both;
}
.sms-login-tips__text {
font-size: 22px;
color: rgba(255, 255, 255, 0.7);
line-height: 1.6;
}
.sms-login-tips__link {
font-size: 22px;
color: #ffffff;
text-decoration: underline;
margin: 0 4px;
line-height: 1.6;
}

View File

@@ -1,10 +1,10 @@
import {useEffect, useState} from "react";
import Taro from '@tarojs/taro'
import { View } from '@tarojs/components'
import {Input, Button} from '@nutui/nutui-react-taro'
import { View, Text, Input } from '@tarojs/components'
import {loginBySms, sendSmsCaptcha} from "@/api/passport/login";
import {LoginParam} from "@/api/passport/login/model";
import {checkAndHandleInviteRelation, hasPendingInvite, parseInviteParams, saveInviteParams, trackInviteSource} from "@/utils/invite";
import './sms-login.scss'
const SmsLogin = () => {
const [loading, setLoading] = useState<boolean>(false)
@@ -37,9 +37,9 @@ const SmsLogin = () => {
const pure = url.split('?')[0]
return (
pure === '/pages/index/index' ||
pure === '/pages/cart/cart' ||
pure === '/pages/user/user' ||
pure === '/pages/category/index'
pure === '/pages/shop/index' ||
pure === '/pages/shop/cart' ||
pure === '/pages/user/user'
)
}
@@ -55,12 +55,8 @@ const SmsLogin = () => {
await Taro.redirectTo({ url: redirectUrl })
}
const reload = () => {
// sms-login 页面不是 tabBar 页面,无需调用 hideTabBar
}
useEffect(() => {
reload()
// sms-login 页面不是 tabBar 页面,无需调用 hideTabBar
}, [])
// 如果从分享/二维码链接进入短信登录页,先暂存邀请信息
@@ -78,7 +74,7 @@ const SmsLogin = () => {
// 倒计时效果
useEffect(() => {
let timer: NodeJS.Timeout
let timer: ReturnType<typeof setTimeout> | undefined
if (countdown > 0) {
timer = setTimeout(() => {
setCountdown(countdown - 1)
@@ -218,52 +214,114 @@ const SmsLogin = () => {
}
}
const phoneValid = validatePhone(formData.phone)
const codeValid = formData.code.length === 6
const canSubmit = phoneValid && codeValid && !loading
const sendDisabled = !phoneValid || sendingCode || countdown > 0
const sendBtnText = countdown > 0
? `${countdown}s 后重试`
: sendingCode
? '发送中'
: '获取验证码'
return (
<>
<View className='flex flex-col justify-center px-5 pt-3'>
<View className='flex flex-col justify-between items-center my-2'>
<Input
type='number'
placeholder='请输入手机号码'
maxLength={11}
value={formData.phone}
onChange={(value) => setFormData({...formData, phone: value})}
style={{backgroundColor: '#ffffff', borderRadius: '8px'}}
/>
<View className='page-sms-login'>
{/* 渐变背景 */}
<View className='sms-login-bg'>
<View className='sms-login-bg__gradient' />
<View className='sms-login-bg__circle sms-login-bg__circle--1' />
<View className='sms-login-bg__circle sms-login-bg__circle--2' />
</View>
<View className='sms-login-content'>
{/* 头部 Logo 和标题 */}
<View className='sms-login-header'>
<View className='sms-login-logo'>
<Text className='sms-login-logo__icon'>📱</Text>
</View>
<Text className='sms-login-title'></Text>
<Text className='sms-login-subtitle'></Text>
</View>
<View className='flex justify-between items-center bg-white rounded-lg my-2 pr-2'>
<Input
type='number'
placeholder='请输入6位验证码'
maxLength={6}
value={formData.code}
onChange={(value) => setFormData({...formData, code: value})}
style={{ backgroundColor: '#ffffff', borderRadius: '8px'}}
/>
<Button
size='small'
type={countdown > 0 ? "default" : "primary"}
loading={sendingCode}
disabled={sendingCode || countdown > 0}
onClick={handleSendCode}
>
{countdown > 0 ? `${countdown}s` : sendingCode ? '发送中...' : '获取验证码'}
</Button>
{/* 表单卡片 */}
<View className='sms-login-card'>
{/* 手机号 */}
<View className='sms-login-field'>
<Text className='sms-login-field__icon'>📞</Text>
<Input
className='sms-login-field__input'
type='number'
placeholder='请输入手机号码'
placeholderClass='sms-login-field__placeholder'
maxLength={11}
value={formData.phone}
onInput={(e: any) => setFormData({ ...formData, phone: e.detail.value })}
/>
</View>
<View className='sms-login-divider' />
{/* 验证码 */}
<View className='sms-login-field sms-login-field--code'>
<View className='sms-login-field__icon-wrap'>
<Text className='sms-login-field__icon'>🔐</Text>
<Input
className='sms-login-field__input'
type='number'
placeholder='请输入6位验证码'
placeholderClass='sms-login-field__placeholder'
maxLength={6}
value={formData.code}
onInput={(e: any) => setFormData({ ...formData, code: e.detail.value })}
/>
</View>
<View
className={`sms-login-send ${sendDisabled ? 'sms-login-send--disabled' : ''}`}
onClick={handleSendCode}
>
{sendBtnText}
</View>
</View>
</View>
<View className='flex justify-center my-5'>
<Button
type='info'
size='large'
className='w-full rounded-lg p-2'
loading={loading}
disabled={loading}
onClick={handleLogin}
{/* 登录按钮 */}
<View
className={`sms-login-submit ${canSubmit ? '' : 'sms-login-submit--disabled'}`}
hoverClass={canSubmit ? 'sms-login-submit--hover' : 'none'}
onClick={canSubmit ? handleLogin : undefined}
>
{loading ? '登录中...' : '登录 / 注册'}
</View>
{/* 返回微信登录 */}
<View
className='sms-login-back'
onClick={() => Taro.navigateBack({ delta: 1 })}
>
<Text className='sms-login-back__icon'></Text>
<Text className='sms-login-back__text'></Text>
</View>
{/* 协议 */}
<View className='sms-login-tips'>
<Text className='sms-login-tips__text'></Text>
<Text
className='sms-login-tips__link'
onClick={() => Taro.navigateTo({ url: '/passport/agreement?type=terms' })}
>
{loading ? '登录中...' : '登录'}
</Button>
</Text>
<Text className='sms-login-tips__text'></Text>
<Text
className='sms-login-tips__link'
onClick={() => Taro.navigateTo({ url: '/passport/agreement?type=privacy' })}
>
</Text>
</View>
</View>
</>
</View>
)
}
export default SmsLogin

View File

@@ -1,5 +1,6 @@
import Taro from '@tarojs/taro'
import { clearStorageByLoginUser } from '@/utils/server'
import type { User } from '@/api/system/user/model'
/**
* 登录态 / 游客态 标识。
@@ -34,3 +35,31 @@ export function ensureLoggedIn(redirect?: string): boolean {
export function logout() {
clearStorageByLoginUser()
}
/**
* 判断用户是否被禁用status === 1
*/
export function isUserDisabled(user?: User | null): boolean {
return user?.status === 1
}
/**
* 拦截被禁用的用户:清除登录态、弹窗提示、跳转登录页。
* 返回 false 表示用户已被禁用并已处理,调用方应中止后续流程。
*/
export function blockDisabledUser(user?: User | null): boolean {
if (!isUserDisabled(user)) return true
clearStorageByLoginUser()
Taro.showModal({
title: '账号已被禁用',
content: '您的账号已被管理员禁用,暂时无法使用小程序。如有疑问请联系门店客服。',
showCancel: false,
confirmText: '我知道了',
confirmColor: '#ee0a24',
success: () => {
Taro.reLaunch({ url: '/passport/login' })
},
})
return false
}

35
src/utils/phoneAuth.ts Normal file
View File

@@ -0,0 +1,35 @@
/**
* getPhoneNumber 全局频率控制
*
* 微信原生层对 getPhoneNumber API 有调用频率限制,短时间内多次调用会报:
* [渲染层错误] invoke getPhoneNumber too frequently
*
* 本模块提供模块级(跨页面)的时间戳锁,配合各页面的 useRef 同步锁 + 条件渲染使用。
*/
/** 上次 getPhoneNumber 回调触发的时间戳ms */
let lastPhoneAuthTime = 0
/** 冷却期ms在此期间不允许再次触发 getPhoneNumber */
const PHONE_AUTH_COOLDOWN = 8000
/**
* 检查当前是否在冷却期内。
* 在渲染 getPhoneNumber 按钮前调用此函数,若在冷却期内则不渲染 openType。
*/
export function isPhoneAuthCoolingDown(): boolean {
if (lastPhoneAuthTime === 0) return false
return Date.now() - lastPhoneAuthTime < PHONE_AUTH_COOLDOWN
}
/** 记录一次 getPhoneNumber 回调已触发(在 onGetPhoneNumber 回调入口调用) */
export function markPhoneAuthCalled(): void {
lastPhoneAuthTime = Date.now()
}
/** 获取冷却期剩余时间ms用于设置 unlock 延迟 */
export function getPhoneAuthCooldownRemaining(): number {
if (lastPhoneAuthTime === 0) return 0
const remaining = PHONE_AUTH_COOLDOWN - (Date.now() - lastPhoneAuthTime)
return remaining > 0 ? remaining : 0
}

53
src/utils/privacy.ts Normal file
View File

@@ -0,0 +1,53 @@
/**
* 微信隐私协议授权全局管理器
* 用于在基础库 3.16.1+ 下处理 onNeedPrivacyAuthorization 回调。
* 必须在页面中渲染 <PrivacyModal /> 并绑定本管理器。
*/
type PrivacyResolve = (result: { event: 'agree' | 'disagree'; buttonId: string }) => void
let currentResolve: PrivacyResolve | null = null
let showCallback: ((show: boolean) => void) | null = null
let privacyContractName = '《用户隐私保护指引》'
export const privacyManager = {
/** 设置弹窗显示/隐藏回调 */
setShowCallback(cb: (show: boolean) => void) {
showCallback = cb
},
/** 设置隐私协议名称(从 getPrivacySetting 读取) */
setPrivacyContractName(name: string) {
privacyContractName = name || '《用户隐私保护指引》'
},
getPrivacyContractName() {
return privacyContractName
},
/** 展示隐私协议授权弹窗 */
show(resolve: PrivacyResolve) {
currentResolve = resolve
showCallback?.(true)
},
/** 隐私协议授权流程已结束,隐藏弹窗 */
hide() {
currentResolve = null
showCallback?.(false)
},
/** 用户点击同意(由 open-type=agreePrivacyAuthorization 的 button 触发) */
agree() {
currentResolve?.({ event: 'agree', buttonId: 'privacy-agree-btn' })
currentResolve = null
showCallback?.(false)
},
/** 用户点击拒绝 */
disagree() {
currentResolve?.({ event: 'disagree', buttonId: 'privacy-disagree-btn' })
currentResolve = null
showCallback?.(false)
},
}