71 Commits
v1.0 ... main

Author SHA1 Message Date
f532b3923d fix(shop): 始终显示商品详情销量,避免销量隐藏
- 修改商品详情页销量展示,去除销量大于0的判断
- 销量字段不存在或为0时统一显示为0,提升信息一致性
- 列表页仍保持销量为0时不显示的原有逻辑
- 适配后端新增销量累加方式配置,支持显示和回滚逻辑调整
2026-08-01 21:19:06 +08:00
f1c0349f64 feat(api): 新增 cms-api API 域名支持并切换幻灯片广告接口域名
- 配置文件 env.js 中新增 CMS_API_URL,三个环境均统一配置
- app.ts 导出 CmsBaseUrl,用于 cms-api 相关请求
- cmsAd 模块接口调用统一改为通过 CmsBaseUrl 拼接完整 cms-api 路径
- 幻灯片广告相关接口走独立 cms-api 域名,保证数据隔离
- 更新工作备忘录,补充 API 域名配置与幻灯片广告调用说明
2026-07-27 12:42:27 +08:00
0cc0d089e9 feat(store): 在订单列表页新增下单时间展示
- 订单卡片头部左侧新增「下单时间:YYYY-MM-DD HH:mm」显示,位于订单号下方
- 状态标签右侧对齐方式由垂直居中调整为顶端对齐,适配两行文本
- 新增辅助函数 formatOrderTime 兼容 ISO 和空格分隔时间格式,仅保留到分钟
- 复用后台返回的 ShopOrder.createTime 字段,无需后端改动,保证时间数据准确同步
2026-07-23 02:03:50 +08:00
c6e022f815 feat(store): 门店订单页新增付款凭证展示
- 在订单详情买家备注下方添加付款凭证展示区域
- 付款凭证区域背景为绿色(bg-green-50),仅在有凭证时显示
- 支持点击付款凭证图片预览大图
- 修复之前只展示送达凭证未展示付款凭证的问题
2026-07-17 17:08:01 +08:00
0d0c4b327a feat(store): 恢复门店端独立改价按钮和弹窗
- 去除确认收款弹窗中的“实付金额”输入框
- 改价功能从合并回独立弹窗及按钮
- 新增改价相关状态和函数处理逻辑
- 线下付款未付款状态显示“改价”与“确认收款”按钮
- 改价弹窗包含订单信息、新金额输入及改价备注
- 改价按钮样式调整为橙色背景白字
- 改价提交时更新订单金额及备注并刷新列表
2026-07-17 16:50:18 +08:00
7ff921d973 refactor(store): 合并改价到确认收款弹窗
- 去除独立的“改价”按钮与修改金额弹窗相关状态和逻辑
- 在确认收款弹窗内新增“实付金额”输入框,预填当前值支持修改
- 取消改价时单独输入原因,仅保留收款备注说明
- 提交确认收款时,先调用接口更新改价信息,再调用确认收款接口
- 调整订单操作类型及按钮展示,移除改价相关项
- 更新界面样式及交互,支持合并改价与收款流程
- 更新业务状态说明,调整发货及付款状态文案逻辑
2026-07-17 16:39:34 +08:00
69862bfc0c feat(order): 拆分“确认完成”为“送达”和“确认完成”按钮
- 新增送达操作,已发货且无送达照片时显示,可上传送达凭证
- 确认完成操作仅限已付款订单,标记为已完成,不需上传凭证
- 取消deliveryStatus=30表示已送达的用法,避免混淆“部分发货”状态
- 统一按钮显示逻辑,支持线下付款场景下同时显示确认收款/送达按钮
- 调整订单状态文案,新增“已送达待收款”与“待确认完成”状态描述
- 优化订单列表及详情中付款和物流状态的判断,去除对deliveryStatus=30的依赖
- 确认证据上传界面调整,确认完成操作不再显示上传凭证区域
2026-07-17 16:27:09 +08:00
184763a15c feat(store): 新增订单送达操作功能
- 新增操作类型 'deliver',用于上传送达凭证照片并标记为已收货
- 在操作列表中增加“送达”按钮,仅在订单已发货状态显示
- 送达操作不改变付款状态和订单完成状态
- 送达操作要求上传凭证照片,否则提示用户上传
- 确认完成操作仅在订单已付款时可用
- 调整凭证上传提示文案,针对送达操作显示专属提示
- 更新订单状态逻辑,送达时设置配送状态为“已收货”,完成时标记订单为已完成
2026-07-17 16:00:40 +08:00
147802dbe9 fix(order): 统一线下付款已发货未付款状态显示
- 用户端订单卡的已发货待收款状态调整,线下付款且未付款状态显示红色标识
- 用户端订单详情新增该状态的红色展示和提示信息
- 门店端订单列表状态文案和颜色同步更新,保持与用户端一致
- 门店端订单操作按钮逻辑调整,未付款已发货订单显示“确认完成”按钮
- 解决线下付款先发货后付款订单的状态显示和按钮操作不一致问题
2026-07-17 15:51:22 +08:00
37cea1b6d0 fix(order): 修正未付款已发货订单状态显示错误
- 订单列表卡片 OrderCard 中将已发货/已收货状态判断提前,覆盖未付款状态显示
- 订单详情页 detail.tsx 调整 getOrderDisplayStatus 和 getOrderPhase 函数,优先显示物流状态
- 确保未付款但已发货订单状态正确显示为“待收货”
- 改动不影响正常付款和发货流程,仅修正异常状态显示问题
2026-07-17 15:28:57 +08:00
65bcd07ca7 fix(orders): 优化线下付款订单状态和发货操作逻辑
- 调整确认收款操作描述为“确认已收到线下付款”
- 增加线下付款已发货待收款状态显示处理
- 支持线下付款订单先发货后确认收款的发货操作
- 修改发货按钮显示条件,提升线下付款订单兼容性
2026-07-17 15:10:20 +08:00
bd3589af61 feat(store-center): 在门店中心添加扫码登录PC后台功能
- 门店中心右上角新增扫码登录按钮,使用base64 SVG图标兼容小程序Image组件渲染
- 扫码后通过解析二维码内容调用confirmWechatQRLogin接口完成PC端登录确认
- 支持扫码取消静默处理,扫码失败则弹窗提示错误信息
- 优化门店中心页面布局,调整扫码按钮绝对定位及样式
- 优化了用户管理页面的小型样式,包括按钮和文本间距调整
- 调整用户页面部分Badge显示逻辑,排除tabIndex为4的项不显示角标
2026-07-17 12:38:27 +08:00
21b8403fab feat(order): 支持商家送货的配送方式展示
- 新增配送方式映射,支持快递配送、自提和商家送货三种类型
- 修改判断逻辑,将商家送货归类为快递配送类型以便正确渲染
- 根据配送方式不同显示相应图标,商家送货显示摩托车图标
- 商家送货时添加配送门店信息展示
- 订单详情增加配送方式字段的显示项
2026-07-17 12:10:38 +08:00
955b156399 refactor(user-profile): 调整个人信息页面布局优化结构
- 调整用户ID位置至头像之上以强化信息层次
- 统一并优化代码格式排版,提升代码可读性
- 合并重复的用户ID展示,避免冗余
- 保持功能一致,未改动交互逻辑
- 优化注释及空白行,代码风格更规范
2026-07-17 11:42:04 +08:00
ceba1cabec fix(store): 修复店员列表拉取逻辑,保证数据正确性
- 获取当前登录店员的 storeId,避免使用无效 storeId 查询
- 根据 storeId 拉取本门店店员列表,修复空列表问题
- 加载失败时提示“加载店员失败”
- 提示门店信息未就绪时的用户反馈
- 默认选中当前登录用户对应的店员项
2026-07-17 11:36:15 +08:00
01f0638a37 fix(store): 修复获取店员列表时缺少店铺ID参数问题
- 移除调用 listShopStoreUser 时的 storeId 参数
- 解决因参数传递导致的加载店员失败问题
- 保持加载和错误提示逻辑不变
2026-07-17 11:24:06 +08:00
197722adeb fix(orders): 修复确认完成按钮显示逻辑
- 调整确认完成按钮的注释缩进,提升代码可读性
- 保持确认完成按钮仅在已付款状态下显示
- 未改变按钮显示的实际逻辑行为
2026-07-17 04:01:38 +08:00
7071ec408e feat(store): 门店订单页新增发货按钮及选择发货人功能
- 物流页发货信息依赖 shopOrderDelivery,之前门店确认完成不创建发货单导致显示为空
- 在门店订单页确认收款后增加发货按钮,允许选择门店店员作为发货人并创建发货单
- 新增接口 saveShopOrderDelivery 调用 POST /shop/shop-order-delivery 创建发货单
- 补全 shopStoreUser 模型字段,支持店员姓名、电话、角色等信息
- 订单列表支持发货操作按钮显示及样式,增加对应操作逻辑及UI弹窗选择发货人员
- 发货后订单状态置为已发货(deliveryStatus=20),同步更新发货时间和物流页显示
- 用户可选择门店全部店员(含经理)作为发货人员,确认完成按钮可与发货按钮共存,行为保持兼容
- 完成相关接口调用错误提示与加载状态处理,确保用户体验
- taro 构建通过,无类型错误
2026-07-17 03:49:03 +08:00
fff2ceb40d feat(order): 增加门店订单页收货信息一键导航功能
- 订单卡片收货信息区域添加点击事件,实现一键导航至收货地址
- 新增 handleNavigate 方法,利用微信小程序内置地图 API 实现导航
- 导航功能支持坐标有效性检查,坐标无效时显示错误提示
- 订单详情页“过期时间”字段改为“送达时间”并显示对应数据
- 保留“发货时间”字段,确保送达时间与发货时间分别展示
- 确认代码构建和类型检查无新增错误
2026-07-17 02:18:37 +08:00
098fc01623 feat(store-orders): 增强订单页支持搜索及重构Tab
- 将订单列表底部时间显示格式由只含日期改为显示至分钟的完整时间
- 门店订单页新增搜索功能,支持订单号、手机号、昵称等多字段模糊匹配
- 实现单个搜索框叠加于当前Tab内,切Tab及搜索提交自动刷新订单列表
- 调整ScrollView高度以适配搜索栏新增布局
- 优化订单Tab结构,由“全部/已完成/已关闭”改为“待处理/已完成/已关闭”
- 待处理Tab合并待付款、待发货、待核销、待收货多状态订单
- 去除原“全部”Tab,默认激活改为“待处理”Tab
- 新订单角标由“全部”Tab迁移到“待处理”Tab显示
- 搜索无结果时显示“未找到与‘xxx’相关的订单”提示
- 确保后端筛选与前端Tab状态映射一致,提升用户体验
2026-07-17 02:00:24 +08:00
37bd61ea85 feat(order): 显示订单下单时间到时分
- 修改订单列表中 OrderCard 组件底部日期显示格式
- 由原先只显示年月日改为显示到时分(YYYY-MM-DD HH:mm)
- 兼容 ISO 格式时间字符串中的 T 分隔符与空格分隔符
- 提升订单时间显示的精确度和用户体验
2026-07-17 01:39:51 +08:00
48f8c4a343 fix(store): 修复门店订单管理页操作按钮与标签筛选问题
- 调整已关闭标签参数,移除待发货状态,避免混入错误订单
- 修改金额按钮仅在未付款状态下显示,确认收款后隐藏
- 确认完成按钮改为仅在已付款状态下显示,避免过早显示
- 优化订单操作按钮显示逻辑,确保按钮状态与订单支付状态一致
2026-07-17 01:26:31 +08:00
1077b16f58 fix(user): 修复地址和购物车相关的多个问题
- 解决鸿蒙手机首次打开地图选择不显示 POI 列表,先获取当前位置传入地图
- 修正收货地址编辑接口路径,改为无 id 的 PUT /shop/shop-user-address
- 防御性合并旧地址数据,避免 null 字段覆盖初始值
- 新用户注册和登录后同步更新 UserContext 状态,确保页面刷新购物车数据
- 各页面 useDidShow 内增加从 storage 同步用户状态,解决状态不同步问题
- 优化地图选择初始化逻辑,提升用户体验
- 保证购物车、商品详情和首页登录状态及时同步更新
2026-07-17 01:07:54 +08:00
22fed3b163 feat(store): 新增线下付款确认收款功能并改为关闭订单
- 新增接口 confirmOfflinePayment 支持门店确认线下付款收款
- 修改订单操作类型,增加“确认收款”按钮供线下付款未付款订单使用
- 确认收款弹窗新增备注输入框和支付凭证上传(必填,单张)
- 调整提交逻辑,确认收款操作调用新接口,并校验凭证上传
- 订单列表页“删除订单”改为“关闭订单”,调用更新接口逻辑关闭订单
- 修改订单详情页金额明细渲染逻辑,修复0时错误显示问题
2026-07-17 00:38:57 +08:00
d8e0563b9d fix(passport): 更新协议和隐私政策中的联系方式及公司名称
- 将协议及隐私政策中公司名称由南宁市网宿信息科技有限公司改为鑫龙家电
- 修改联系电话为18269229683
- 更新客服邮箱为support@163.comprivacy@163.com
2026-07-17 00:10:18 +08:00
74e9a254df fix(store): 修正订单收货状态设置错误
- 将订单收货状态从30修改为20,表示已收货
- 确保收货时间正确记录
- 保持订单状态设置为已完成
- 不影响其他订单数据更新逻辑
2026-07-17 00:04:50 +08:00
217b97b430 ======= V3.0.1 稳定版 ======= 2026-07-16 23:24:25 +08:00
1461a0e563 fix(shop): 修正购物车页面跳转为切换标签页
- 将购物车按钮点击事件由 navigateTo 改为 switchTab
- 统一购物车跳转逻辑,提升用户体验
- 修复商品详情页跳转购物车方式错误问题
2026-07-16 21:58:54 +08:00
1b3ce58528 refactor(app): 移除微信隐私协议相关代码
- 删除了PrivacyModal组件及相关引用
- 移除了基于微信基础库3.16.1+的隐私协议处理逻辑
- 清理了与privacyManager相关的调用代码
- 简化了应用初始化逻辑,去除不再使用的隐私授权监听
- 优化了代码结构,减少冗余依赖和模块导入
2026-07-16 21:55:38 +08:00
b285210182 chore(project): 更新项目名称及相关接口调用
- 将项目名称由 "Paopao Taro" 修改为 "Websopy Inc."
- 修改首页标题为 "Websopy Inc."
- 更新余额接口调用由 paopao-api 变更为 shop-api
- 调整订单页面项目名称显示为 "WebSopy Inc."
- 变更README文件项目名称为 Shop-Taro - 项目框架
2026-07-16 21:41:14 +08:00
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
81 changed files with 7227 additions and 2146 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

@@ -0,0 +1,34 @@
# 2025-07-17 工作日志
## 门店订单管理页修复pages/store/orders/index.tsx
三个问题修复:
1. **确认完成按钮过早显示**`getOrderActions` 中"确认完成"原来对所有可操作订单都显示,改为仅在 `order.payStatus === true`(已确认收款)后才显示,避免占用位置。
2. **确认收款后仍可修改金额**`getOrderActions` 中"修改金额"原来对所有可操作订单都显示,改为仅在 `!order.payStatus`(未付款)时显示。确认收款后修改金额按钮自动隐藏。
3. **已关闭 Tab 混入待发货订单**`TABS` 配置中 `pending`(已关闭)的 params 原来同时包含 `statusFilter: 1`(待发货)和 `statusFilter: 8`(已关闭),导致待发货订单也出现。已改为只查 `statusFilter: 8`
### 操作按钮逻辑总结(修复后)
- **修改金额**:仅未付款(`!payStatus`)时显示
- **确认收款**仅线下付款payType=9且未付款时显示
- **确认完成**:仅已付款(`payStatus === true`)时显示
- **关闭订单**:所有可操作订单(非已完成、非已关闭)均显示
## 订单列表下单时间显示到时分components/common/OrderCard
`pages/order/list` 列表卡片(`OrderCard`)底部日期原为 `order.createTime?.slice(0, 10)`(仅显示 YYYY-MM-DD
改为 `order.createTime?.replace('T', ' ').slice(0, 16)`,显示「下单时间: YYYY-MM-DD HH:mm」`replace('T',' ')` 兼容后端 ISOT 分隔)与空格分隔两种时间格式。
## 门店订单页增加搜索功能pages/store/orders/index.tsx
方案(已与用户确认):单个搜索框 + 在当前 Tab 内叠加筛选,无需改后端、无需改类型定义。
实现要点:
- 新增 `searchInput`(受控输入)、`searchKeyword`(已提交搜索词)两个 state。
- `loadOrders` 每个 `pageShopOrder` 请求附带 `keywords: searchKeyword || undefined`(后端 `keywords` 已支持订单号/订单ID/手机号/昵称/备注组合模糊搜索);`useCallback` 依赖加 `searchKeyword`,切 Tab/提交搜索自动重加载。
- 触发:点「搜索」按钮 / 键盘回车(`onConfirm` + `confirmType='search'`/ 点「×」清空。
- 搜索无结果显示「未找到与"xxx"相关的订单」,否则「暂无订单」。
- ScrollView 高度由 `calc(100vh - 50px)` 改为 `calc(100vh - 100px)`(为新增搜索栏留位)。
- 注意后端「全部」Tab 默认隐藏已关闭单(`statusFilter != 8``order_status != 2`搜已关闭单需切到「已关闭」Tab。手机号在 keywords 里是精确匹配(`b.phone =`),订单号支持部分匹配。

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

@@ -0,0 +1,155 @@
# 2026-07-17 工作日志
## 门店订单管理页改造src/pages/store/orders/index.tsx
### 1. 删除订单改为关闭订单
- 原"删除订单"按钮调用 `removeShopOrder`(物理删除),改为"关闭订单"调用 `updateShopOrder({ orderId, orderStatus: 2 })`(逻辑关闭)
- 确认弹窗文案从"删除后无法恢复"改为"关闭后无法恢复"
- 移除 `removeShopOrder` 导入,新增 `confirmOfflinePayment` 导入
### 2. 待付款订单加确认收款按钮
- **背景**:线下付款(payType=9)且未付款的订单,门店需要"确认收款"功能与后台管理guilixu-admin对齐
- **后端接口**`PUT /shop/shop-order/confirm-offline-payment/{id}?remarks=xxx&paymentVoucher=xxx`
- 后端 `ShopOrderController.confirmOfflinePayment()``ShopOrderServiceImpl.confirmOfflinePayment()`
- 校验payType必须为9(线下付款)、orderStatus不能为2(已关闭)、不能重复确认
- 确认后设置 payStatus=true、payTime=now(),可选保存 remarks(merchantRemarks) 和 paymentVoucher
- **前端API**:在 `src/api/shop/shopOrder/index.ts` 新增 `confirmOfflinePayment(id, remarks?, paymentVoucher?)` 函数
- 注意Taro 的 `request.put` 不支持 `params` 参数,需手动拼接 query string
- **ShopOrder Model**:新增 `paymentVoucher?: string` 字段
- **页面改动**
- OpType 从 `'pay'|'complete'|'editPrice'` 改为 `'confirmPay'|'complete'|'editPrice'`
- `getOrderActions` 新增条件:`!payStatus && payType===9 && orderStatus===0` → 显示"确认收款"按钮
- 弹窗新增备注 Textarea仅 confirmPay 显示凭证图片限制1张confirmPay必填校验
- `submitOperation` 新增 confirmPay 分支,调用 `confirmOfflinePayment` API
- 新增 `payRemarks` state
### 3. 订单详情页金额明细0隐藏src/pages/order/detail.tsx
- **Bug**React 经典陷阱 `{order.reducePrice && Number(order.reducePrice) > 0 && (...)}`,当 reducePrice 为数字 0 时,`0 && ...` 短路求值为 0React 渲染文本"0"
- **修复**:改为 `{Number(order.reducePrice || 0) > 0 && (...)}`,始终返回 boolean
### 验证
- `npx taro build --type weapp` 构建成功
## 地址/购物车问题修复
### 5. 地图选择首次打开列表不显示src/pages/user/address-edit.tsx
- **问题**:鸿蒙手机首次打开 `Taro.chooseLocation` 时地图 POI 列表不显示,需拖动才出现
- **根因**:未传入经纬度时地图默认定位到北京,鸿蒙系统不会自动获取用户位置
- **修复**:调 `chooseLocation` 前先 `Taro.getLocation({ type: 'gcj02' })` 获取当前位置,将 latitude/longitude 传给地图确保首次打开就在用户位置附近POI 列表立即加载
### 6. 收货地址编辑不加载旧数据
- **Bug 1API 路径不匹配)**:前端 `updateShopUserAddress``PUT /shop/shop-user-address/{id}`,但后端 `@PutMapping()``/{id}` 路径 → 404
- 修复:改为 `PUT /shop/shop-user-address`(去掉 `/{id}`id 通过 body 传递)
- **Bug 2加载防御**`setFormData(addr)` 直接替换 state若后端返回 null 字段会覆盖初始 `''`
- 修复:改为 `setFormData(prev => ({...prev, ...addr, name: addr.name || '', ...}))` 保证字段非 null
### 7. 新用户注册后首次加购购物车不刷新
- **根因**:注册页 `register.tsx` 和登录页 `login.tsx` 只调 `saveStorageByLoginUser()` 存 storage未调 `UserContext.loginUser()` 更新 React 状态 → `isLoggedIn` 仍为 `false` → 购物车页 `useDidShow``if (isLoggedIn) refresh()` 不执行
- **修复**
1. `register.tsx``login.tsx``saveStorageByLoginUser` 后加 `loginUser(token, user)` 同步 UserContext
2. `sms-login.tsx``loginBySms` 后加 `syncFromStorage()` 同步状态
3. 防御性措施:`cart.tsx``product-detail.tsx``index/index.tsx``useDidShow``syncFromStorage()` 确保页面显示时从 storage 同步用户状态
## 门店订单页 Tab 改造src/pages/store/orders/index.tsx
- **需求**:原「全部 / 已完成 / 已关闭」Tab 改为「待处理 / 已完成 / 已关闭」
- 待处理 = 待付款(0) + 待发货(1) + 待核销(2) + 待收货(3),按用户确认把待核销(2)也并入
- 已完成 = statusFilter 5不变已关闭 = statusFilter 8不变
- **TabKey 类型**`'all' | 'pending' | 'completed'``'pending' | 'completed' | 'closed'`
- **TABS 配置**:删除原「全部」无筛选 Tab待处理用 params 数组 `[{statusFilter:0},{1},{2},{3}]` 并行请求合并去重loadOrders 已支持)
- **默认选中**`useState<TabKey>('all')``'pending'`
- **新订单角标**`tab.key === 'all'``tab.key === 'pending'`(移到待处理 Tab
- **statusFilter 后端映射参考**ShopOrderMapper.xml 第240-282行0待支付=未付款、1待发货=pay1&delivery10&order0、2待核销=pay1&order0、3待收货=delivery20&order!=1、5已完成=order1、8已取消=order2
- **验证**tsc 仅剩预存 `@tarojs/taro` 类型缺失环境报错,本文件无新错误
- ⚠️ 注意原「全部」Tab 显示的「待评价(statusFilter=4)」订单在改版后无 Tab 承接(用户确认不含待评价),如需可见后续可并入待处理
## 门店订单页「收货信息」一键导航src/pages/store/orders/index.tsx
- **需求**:订单卡片里的收货信息区域点击可一键导航
- **实现**`renderOrderCard` 的「收货信息」整块 View 加 `onClick={() => handleNavigate(order)}`,右侧加绿色「› 导航」标识提示可点
- **新增 `handleNavigate(order)`**:读取 `order.addressLat`/`order.addressLng`(模型里为 string`parseFloat` 转 number坐标有效则 `Taro.openLocation({ latitude, longitude, name: realName, address, scale: 16 })`(微信内置地图,自带导航选 App坐标缺失/非法则 toast「该订单未记录定位信息无法导航」兜底
- **注意**`Taro.openLocation` 是地图展示类 API不在微信 `requiredPrivateInfos` 受限清单内,无需在小程序后台声明、无需 `ensurePrivacyAuthorized` 预检(与 chooseImage/getPhoneNumber 不同)
- **验证**tsc 仅剩预存 `@tarojs/taro` 类型缺失环境报错,本文件无新错误
## 订单详情页「过期时间」→「送达时间」src/pages/order/detail.tsx
- **需求**:订单信息区原「过期时间」(order.expirationTime) 行改为「送达时间」
- **字段确认**:用户选择「送达时间」用 `order.deliveryTime`,且**保留**现有「发货时间」行 → `deliveryTime` 在两行各显示一次(用户明知并接受)
- **改动**第408-413行 `{order.expirationTime && ...过期时间}` 改为 `{order.deliveryTime && ...送达时间}`,数据用 `formatTime(order.deliveryTime)`
## 门店订单页「发货」按钮 + 选发货人员写 shopOrderDelivery解决物流页发货信息为空
- **根因**:物流页 `pages/order/logistics.tsx` 的「发货信息」卡片依赖 `shopOrderDelivery` 发货单;门店「确认完成」只改订单状态、不建发货单 → `delivery` 为 null → 卡片不渲染(空白)
- **方案**:在「确认收款」之后加「发货」按钮,选门店店员作为发货人,写入 `shopOrderDelivery`,从源头让物流页有数据
- **后端可行性(已确认)**`POST /shop/shop-order-delivery`(save) 已存在,实体字段全可空最少传 orderId`listShopStoreUser({storeId})` 已存在;请求层自动注入 `TenantId` 请求头(无需手动传租户);`ShopOrderDelivery` 实体有 orderId/deliveryMethod/sendName/sendPhone/sendAddress
- **前端改动**
- 新增 `src/api/shop/shopOrderDelivery/index.ts``saveShopOrderDelivery(data)` 封装 `POST /shop/shop-order-delivery`
- 补全 `src/api/shop/shopStoreUser/model``ShopStoreUser` 类型缺的 `name`/`phone`/`roleType`/`storeName`/`image`(前端模型此前不完整,后端实体有)
- `src/pages/store/orders/index.tsx`
- `OpType` 增加 `'ship'``OP_LABEL`/`OP_DESC` 补 ship
- `getOrderActions`:已付款 且 `deliveryStatus<20`(或未发货)且 `orderStatus!==1` 时显示「发货」(紫色 `bg-purple-500`)
- 新增状态 `showShipModal`/`clerkList`/`selectedClerkId`/`loadingClerks`/`shipping`
- 新增 `openShipModal(order)`(拉 `listShopStoreUser({storeId: order.storeId})`)与 `handleConfirmShip()`(先 `saveShopOrderDelivery({orderId, deliveryMethod:20, sendName:clerk.name, sendPhone:clerk.phone, sendAddress:order.storeName})`,再 `updateShopOrder({orderId, deliveryStatus:20, deliveryTime})`
- 渲染「选择发货人员」底部弹层(店员列表可点选,确认发货按钮置灰直到选中)
- **设计决策(用户确认)**:① 发货即把订单置「已发货」(deliveryStatus=20);② 发货人可选门店全部店员(经理+店员)
- **物流页**:无需改动,`shopOrderDelivery` 存在即自动渲染发货人
- **验证**tsc 仅剩预存 `@tarojs/taro` 类型缺失环境报错,本文件无新错误
- ⚠️ 行为说明:已付款未发货时「发货」与「确认完成」两个按钮同时显示;店员可跳过发货直接确认完成(该订单仍无发货单,物流页空白,与旧行为一致);如需强制「先发货才能确认完成」,可隐藏未发货订单的「确认完成」按钮(一行判断改动),待用户决定
### 验证
- `npx taro build --type weapp` 构建成功
## 门店中心右上角扫码登录 PC 后台src/pages/store/center/index.tsx
- **需求**:门店中心顶部绿色渐变区右上角加一个二维码扫码图标,点击后调起微信扫码,扫码结果解析出 token 后调 `confirmWechatQRLogin` 确认 PC 端登录
- **参考**`/Users/gxwebsoft/VUE/template-10584/src/pages/user/components/UserCard.tsx` 中的 `UnifiedQRButton` 组件
- **实现**
- 复用已有 API`parseQRContent` + `confirmWechatQRLogin``@/api/passport/qr-login`),与 `src/components/QRLoginScanner.tsx` 同款
- 新增 `handleScanLogin` callback`Taro.scanCode``parseQRContent` → 校验 token/userId → `confirmWechatQRLogin` → 成功弹窗
- 右上角按钮用 `absolute top-3 right-4 z-20` 定位,半透明白色圆形背景 + base64 SVG 图标
- 小程序不支持 `<svg>` 标签,用 `Image` 组件 + `data:image/svg+xml;base64,...` data URI 渲染图标
- 用户取消扫码errMsg 含 'cancel')静默处理
## 修复未付款已发货订单状态显示错误
- **问题**订单列表卡片OrderCard和详情页detail.tsx状态判断时 `!payStatus`(待付款)优先于 `deliveryStatus` 判断,导致"未付款但已发货"(payStatus=false + deliveryStatus=20) 的订单显示"待付款"而非"待收货"
- **修改文件**
- `src/components/common/OrderCard/index.tsx``getCardStatus``deliveryStatus===20/30` 判断提前到 `!payStatus` 之前
- `src/pages/order/detail.tsx``getOrderDisplayStatus``getOrderPhase` 两个函数都做同样调整,物流状态优先于付款状态
- **影响范围**:仅影响"未付款但已发货"的异常场景,正常流程不受影响
### 状态描述对齐已发货待收款payType=9 + deliveryStatus=20 + 未付款)
- **需求**:线下付款(payType=9)已发货但未确认收款的订单,用户端应与门店端一致显示"已发货待收款"(红色),而非"待收货"(蓝色)
- **修改文件**
- `src/components/common/OrderCard/index.tsx``getCardStatus``deliveryStatus===20` 之前加 `!payStatus && payType===9 && deliveryStatus===20` → "已发货待收款"(红色)
- `src/pages/order/detail.tsx``getOrderDisplayStatus` 在物流状态判断前加 `isOffline && !payStatus && (deliveryStatus===20||30)` → "已发货待收款"红色subtitle: 商品已发货,请尽快转账付款)
- `src/pages/store/orders/index.tsx``getStatusText` 恢复 `payType===9 && deliveryStatus===20` → "已发货待收款"`getStatusColor` 对应红色
- **最终统一**:三端状态文案完全对齐
| 场景 | 用户端列表 | 用户端详情 | 门店端 |
|------|-----------|-----------|--------|
| 线下付款+已发货+未付款 | 已发货待收款(红) | 已发货待收款(红) | 已发货待收款(红) |
| 其他已发货 | 待收货(蓝) | 待收货(蓝) | 待收货(蓝) |
### 门店端:拆分"确认完成"为"送达"和"确认完成"两个操作
- **新增操作类型** `deliver`送达deliveryStatus=20已发货且无 sendEndImg 时显示,上传送达照片后按钮自动隐藏(用 sendEndImg 判断,不改变 deliveryStatus
- **修改** `complete`(确认完成):仅已付款才可操作,只标记 orderStatus=1已完成不需要上传凭证
- ⚠️ `deliveryStatus=30` 是"部分发货"含义,不能用来表示"已送达",所有相关判断已清除
- **按钮显示逻辑**
| 场景 | 送达 | 确认完成 | 确认收款 |
|------|:---:|:---:|:---:|
| 线下付款+未付款+已发货(无送达照片) | ✅ | ❌ | ✅ |
| 线下付款+未付款+已发货(有送达照片) | ❌ | ❌ | ✅ |
| 已付款+已发货(无送达照片) | ✅ | ✅ | ❌ |
| 已付款+已发货(有送达照片) | ❌ | ✅ | ❌ |
- **deliveryStatus 含义**10=待发货, 20=已发货, 30=部分发货(未使用)
- **状态文案**deliveryStatus=20+未付款+线下付款 → "已发货待收款"deliveryStatus=20+已付款 → "待收货"(蓝)
### 门店端:改价功能合并到确认收款弹窗 → 又改回来了
- 先去掉了独立的"改价"按钮和修改金额弹窗(`editPrice` OpType、`showEditPriceModal``editReason` 等)
- 在确认收款弹窗中增加了"实付金额"输入框
- **2026-07-17 下午**:用户要求改回来,恢复独立的改价按钮和弹窗
- OpType 加回 `editPrice`
- `getOrderActions` 中线下付款未付款时显示「改价」+「确认收款」两个按钮
- 恢复 `showEditPriceModal`/`editPriceOrder`/`editPriceValue`/`editPriceRemarks`/`editingPrice` 状态
- 恢复 `openEditPriceModal`/`closeEditPriceModal`/`submitEditPrice` 函数
- 恢复独立改价弹窗(订单信息 + 新金额输入 + 改价原因备注)
- 确认收款弹窗去掉「实付金额」输入框,`submitOperation` 中 confirmPay 分支去掉改价逻辑
- 改价按钮样式:橙色背景白字 `bg-orange-500`
### 门店订单页:付款凭证(paymentVoucher)未显示
- **问题**`renderOrderCard` 中只展示了 `sendEndImg`(配送/送达凭证),没有展示 `paymentVoucher`(付款凭证)。后端 `ShopOrder.paymentVoucher` 字段已正确保存和返回
- **修复**:在买家备注下方、送达凭证上方新增付款凭证展示区域(绿色背景 `bg-green-50`),仅当 `order.paymentVoucher` 有值时渲染,支持点击预览大图

View File

@@ -0,0 +1,7 @@
# 2026-07-23 工作日志
## 门店订单列表补充「下单时间」
- 文件:`src/pages/store/orders/index.tsx`
- 在订单卡片头部左侧新增「下单时间YYYY-MM-DD HH:mm」位于订单号下方状态标签仍在右侧`items-center``items-start` 适配两行)。
- 数据字段:复用 `ShopOrder.createTime`(创建时间=下单时间,接口本来就有返回,列表排序也用它,无需改后端)。
- 新增辅助函数 `formatOrderTime(raw)`:兼容 `2026-07-23T02:00:18``2026-07-23 02:00:18` 两种格式,仅保留到分钟。

View File

@@ -0,0 +1,29 @@
# 2026-08-01 工作记录
## 销量累加方式后台可配置功能
- 用户需求商品详情页缺销量字段实际是销量为0时隐藏、下单后销量不累计希望后台可配置「销量累加方式」默认支付累加、可选下单累加。
- 确认项1) 销量为0展示「销量0」2) 用户已加 `shop_order.sales_accumulated` 字段3) 取消订单回滚销量4) 退款不扣减销量本次预留结构不实现5) 列表页不显示已售0。
### 后端改动guilixu-java
- `ShopGoodsMapper.addSaleCount`:改为 `GREATEST(IFNULL(sales,0)+saleCount, 0)`,支持负数回滚且不为负。
- `ShopOrder` 实体新增 `salesAccumulated` 字段Integer用户已加表字段
- `ShopSettingService` 新增 `getSalesAccumulateType()`默认10=支付累加20=下单累加),读取 order 分类下 `salesAccumulateType`
- `ShopOrderServiceImpl.updateGoodsSales(order, rollback)`:改造支持幂等(按 salesAccumulated 标记)+ 回滚delta 取负)。`updateSingleGoodsSales` 接收 delta。
- `handlePaymentSuccess`:仅当配置=10 时才累加销量。
- 新增公开方法 `accumulateSalesOnOrderCreated(order)`(配置=20时下单累加`handlePaidSuccess(order)`(复用 handlePaymentSuccess供非微信支付路径触发`rollbackSalesOnCancel(order)`
- `OrderBusinessService.createOrder`:保存订单商品后调 `accumulateSalesOnOrderCreated`;货到付款、余额支付分支补调 `handlePaidSuccess`(修复此前非微信支付不累加销量的 bug
- `ShopOrderServiceImpl.confirmOfflinePayment`:确认线下收款成功后调 `handlePaymentSuccess`(覆盖线下付款场景)。
- `OrderCancelServiceImpl.cancelOrder`:已累加销量时调 `rollbackSalesOnCancel` 回滚。
### 前端改动xinlong-shop-taro
- `pages/shop/product-detail.tsx`:销量展示去掉 `>0` 判断,改为 `{product.sales || 0}` 始终显示。列表页(首页/分类/卡片保持原逻辑销量为0不显示
### 后台管理guilixu-admin
- `views/shop/shopSetting/components/order.vue`新增「销量累加方式」单选10支付/20下单默认值10load 解析 salesAccumulateType。保存复用 batchSaveShopSetting。
### 配置初始化
- 新增 `guilixu-java/sql/sales_accumulate_config.sql`可选代码默认已是10并补充 `UPDATE shop_goods SET sales=0 WHERE sales IS NULL`
- 注意:数据库字段名需与实体 `salesAccumulated` 对应(下划线 `sales_accumulated`MyBatis-Plus 默认映射)。
### 编译
- `mvn -o compile` 通过exit 0

View File

@@ -4,14 +4,28 @@
- 项目名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
- **API 域名配置**`config/env.js` 三环境一致):`API_BASE_URL=https://shop-api.websoft.top/api``SERVER_API_URL=https://server.websoft.top/api``CMS_API_URL=https://cms-api.websoft.top/api``config/app.ts` 导出 `BaseUrl/ServerBaseUrl/CmsBaseUrl`
- **幻灯片广告(轮播图)调用已切到 cms-api**`src/api/cms/cmsAd/index.ts` 的读接口(`listCmsAd`/`getCmsAd`/`getCmsAdByCode`/`pageCmsAd`)通过 `cmsUrl()` 拼接 `CmsBaseUrl`,首页轮播 `listCmsAd({ adType: 'banner', status: 0 })``https://cms-api.websoft.top/api/cms/cms-ad`;写接口仍走 shop-api
- 门店店员身份判断:通过 `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 +41,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 +49,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 +78,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>

View File

@@ -0,0 +1,55 @@
2026年7月17日 下午改动问题总结
================================================
1. 门店订单页 — 付款凭证(paymentVoucher)未显示
文件: src/pages/store/orders/index.tsx
问题: renderOrderCard 中只渲染了送达凭证(sendEndImg),遗漏了付款凭证(paymentVoucher)
修复: 在买家备注下方、送达凭证上方新增付款凭证展示区(绿色背景),支持点击预览大图
2. 门店订单页 — 改价功能来回调整
文件: src/pages/store/orders/index.tsx
问题: 先把独立「改价」按钮和弹窗合并到「确认收款」弹窗中
修复: 下午用户要求改回来恢复独立的改价按钮橙色bg-orange-500和弹窗确认收款弹窗去掉实付金额输入框
3. 门店订单页 — 拆分"确认完成"为"送达"和"确认完成"
文件: src/pages/store/orders/index.tsx
问题: 新增deliver送达操作已发货但无送达照片时显示上传照片后按钮自动隐藏
修复: complete确认完成仅已付款可操作只标记orderStatus=1。清除误用deliveryStatus=30表示"已送达"的逻辑
4. 状态文案对齐 — "已发货待收款"
文件: src/pages/store/orders/index.tsx, src/components/common/OrderCard/index.tsx, src/pages/order/detail.tsx
问题: 线下付款(payType=9)+已发货+未付款 → 三端统一显示"已发货待收款"(红色)
修复: 其他已发货 → "待收货"(蓝色)
5. 修复未付款已发货订单状态显示错误
文件: src/components/common/OrderCard/index.tsx, src/pages/order/detail.tsx
问题: !payStatus判断优先于deliveryStatus导致"未付款但已发货"显示为"待付款"
修复: 物流状态判断提前到付款状态之前
6. 门店中心 — 右上角扫码登录PC后台
文件: src/pages/store/center/index.tsx
问题: 顶部渐变区右上角新增扫码图标,点击调起微信扫码 → 解析token → confirmWechatQRLogin确认PC端登录
7. 订单详情页 — "过期时间"改为"送达时间"
文件: src/pages/order/detail.tsx
问题: 信息区「过期时间」(expirationTime)改为「送达时间」(deliveryTime)
8. 新用户注册后首次加购购物车不刷新
文件: src/passport/login.tsx, src/passport/register.tsx, src/passport/sms-login.tsx, src/pages/shop/cart.tsx等
问题: 登录/注册后只存了storage未同步UserContext → isLoggedIn仍为false → 购物车不刷新
修复: 登录/注册后调loginUser()/syncFromStorage()同步React状态
9. 收货地址编辑不加载旧数据
文件: src/pages/user/address-edit.tsx, src/api/shop/shopUserAddress/index.ts
问题: API路径PUT /shop/shop-user-address/{id} → PUT /shop/shop-user-address后端无/{id}路径导致404
修复: setFormData改为merge模式防止null覆盖
10. 地图选择首次打开列表不显示
文件: src/pages/user/address-edit.tsx
问题: 鸿蒙手机首次打开chooseLocation时POI列表空白
修复: 先getLocation获取当前位置传入地图确保首次打开就在用户位置附近
11. 订单详情页金额明细0隐藏问题
文件: src/pages/order/detail.tsx
问题: {order.reducePrice && Number(order.reducePrice) > 0}当reducePrice为0时React渲染了文本"0"
修复: 改为{Number(order.reducePrice || 0) > 0 && (...)}

481
README.md
View File

@@ -1,151 +1,257 @@
# Paopao Taro - 项目框架
# 小程序商城 (shop-taro)
基于 Taro 4.0.8 + React 18.3.1 + TypeScript 5.7.2 的跨端开发框架
> 基于 Taro + React + TypeScript 的微信小程序商城系统支持多门店、VIP 会员、拼团秒杀、积分商城、分销裂变等完整电商业务场景。
## 📦 技术栈
**作者**: 科技小王子
**版本**: 1.0.0
**License**: MIT
### 核心框架
- **Taro**: `4.0.8` - 跨端开发框架
- **React**: `18.3.1` - UI 框架
- **TypeScript**: `5.7.2` - 类型系统
---
### UI 组件库
- **NutUI React Taro**: `2.7.4` - 京东风格的 Taro React 组件库
- **NutUI Icons**: `^1.0.0` - 图标库
## 技术栈
### 样式方案
- **Sass**: `^1.81.0` - CSS 预处理器
- **TailwindCSS**: `3.4.17` - 原子化 CSS 框架
| 分类 | 技术 | 版本 | 说明 |
|------|------|------|------|
| 跨端框架 | Taro | 4.1.11 | 支持微信/H5/支付宝等多端编译 |
| UI 框架 | React | 18.3.1 | 函数组件 + Hooks |
| 类型系统 | TypeScript | 5.7.2 | 严格模式 |
| 组件库 | NutUI React Taro | 2.7.4 | 京东风格组件库 |
| 原子化 CSS | TailwindCSS | 3.4.17 | 含 weapp-tailwindcss 适配 |
| CSS 预处理 | Sass | ^1.81.0 | |
| 日期处理 | Day.js | ^1.11.13 | |
| 加密 | Crypto-js | ^4.2.0 | AES / MD5 |
| 图表 | ECharts Taro3 React | ^1.0.13 | 数据可视化 |
| 二维码 | weapp-qrcode | ^1.0.0 | 小程序码生成 |
### 状态管理
- **React Hooks** - 内置 Hooks
- **Context API** - 跨组件状态共享
> **Node 版本要求**: >= 18
### 工具库
- **Day.js**: `^1.11.13` - 日期处理
- **Crypto-js**: `^4.2.0` - 加密解密
- **React Router DOM**: `^6.28.0` - 路由管理
---
## 🚀 快速开始
## 核心业务功能
### 商城交易
- 商品浏览 / 搜索 / 分类筛选
- 商品详情 + 多规格 SKU 选择
- 购物车(合并加购、批量操作)
- 下单结算(普通订单 / 拼团 / 秒杀)
- 订单管理(待付款 / 待发货 / 待收货 / 已完成)
- 售后退款申请与进度跟踪
- 发票申请与抬头管理
### 会员体系
- 手机号授权登录(微信一键 + 短信验证码降级)
- VIP 会员申请与审核(门店店员审核)
- VIP 专享价dealerPrice
- 会员权益包兑换
- 积分系统(签到 / 积分商品兑换 / 积分明细)
- 用户余额(充值 / 消费 / 提现)
### 分销与裂变
- 邀请下级注册
- 分销佣金记录
- 分享海报Canvas 绘制 + 小程序码)
- 朋友圈分享 / 复制链接
- 邀请归因追踪
### 门店管理(店员端)
- 门店中心(订单 / 商品 / 用户管理)
- VIP 审核(通过 / 驳回)
- 订单修改金额与备注
- 上传发货凭证
- 门店会员管理(搜索 / 筛选 / 禁用启用)
### 营销活动
- 拼团(单独成团 / 参团)
- 秒杀(限时抢购)
- 优惠券(领取中心 / 下单核销)
- 赛事报名
- 预约订单
- 礼品卡(购买 / 兑换 / 余额查询)
- 文章资讯 / 公告通知
### 其他
- 浏览历史记录(服务端去重累加)
- 商品收藏
- 客服会话
- 数据统计看板(销售 / 用户)
- 微信隐私协议授权弹窗
- 订阅消息推送(新订单提醒)
---
## 快速开始
### 安装依赖
```bash
# 使用 npm
npm install
# 使用 yarn
yarn install
# 使用 pnpm
pnpm install
```
### 开发模式
```bash
# 微信小程序
npm run dev:weapp
# 微信小程序(主要目标平台)
pnpm dev:weapp
# H5
npm run dev:h5
# 支付宝小程序
npm run dev:alipay
# 百度小程序
npm run dev:swan
# 字节跳动小程序
npm run dev:tt
# QQ 小程序
npm run dev:qq
pnpm dev:h5
```
### 生产构建
```bash
# 微信小程序
npm run build:weapp
# H5
npm run build:h5
# 其他平台类似
npm run build:[platform]
pnpm build:weapp
pnpm build:h5
```
## 📁 项目结构
### 代码检查
```bash
# ESLint 修复
pnpm lint
# TypeScript 类型检查
pnpm type-check
```
> 构建产物在 `dist/` 目录,使用微信开发者工具打开该目录进行预览和调试。
---
## 项目结构
```
shop-taro/
├── config/ # Taro 配置文件
│ ├── index.ts # 主配置
│ ├── dev.ts # 开发配置
│ └── prod.ts # 生产配置
├── src/ # 源代码
│ ├── app.config.ts # 应用配置页面路由、tabBar等
│ ├── app.tsx # 应用入口
│ ├── app.scss # 全局样式
│ ├── pages/ # 页面目录
│ │ ├── index/ # 首页
│ │ │ ├── index.tsx
│ │ │ ├── index.scss
│ │ │ └── index.config.ts
│ │ ── home/ # 个人中心页
│ │ ├── home.tsx
│ │ ├── home.scss
│ │ └── home.config.ts
│ ├── components/ # 自定义组件
│ ├── contexts/ # Context API
│ │ ── AppContext.tsx # 应用上下文
│ ├── hooks/ # 自定义 Hooks
│ │ ── useAppContext.ts
│ ├── utils/ # 工具函数
│ │ ── index.ts # 常用工具(日期、加密、防抖节流等)
├── types/ # TypeScript 类型定义
│ └── global.d.ts # 全局类型声明
│ ├── styles/ # 样式文
└── assets/ # 静态资源
── tabbar/ # tabBar 图标
├── package.json # 项目依赖
├── tsconfig.json # TypeScript 配置
├── tailwind.config.js # TailwindCSS 配置
├── postcss.config.js # PostCSS 配置
├── project.config.json # 小程序项目配置
└── README.md # 项目说明
├── config/ # Taro 编译配置
│ ├── index.ts # 主配置
│ ├── dev.ts # 开发环境
│ └── prod.ts # 生产环境
├── src/
│ ├── app.tsx # 应用入口
│ ├── app.config.ts # 路由 / tabBar / 全局配置
│ ├── app.scss # 全局样式
│ ├── pages/ # 页面目录
│ │ ├── index/ # 首页tabBar
│ │ ├── shop/ # 商城(分类/详情/购物车/结算/拼团/秒杀)
│ │ ├── user/ # 用户中心tabBar
│ │ ├── points/ # 积分商城
│ │ ── order/ # 订单管理
│ │ ├── store/ # 门店管理(店员端)
│ │ ├── after-sale/ # 售后退款
│ │ ├── activity/ # 营销活动
│ ├── event/ # 赛事报名
│ ├── booking/ # 预约订单
│ │ ── message/ # 消息通知
│ ├── gift-card/ # 礼品卡
│ │ ── invoice/ # 发票
│ ├── share/ # 分享返利
│ │ ── rebate/ # 返利记录
│ └── statistics/ # 数据统计
├── passport/ # 登录/注册/短信登录/扫码登录
│ ├── components/ # 公共组
│ ├── business/ # 业务组件
── common/ # 通用组件
│ │ ├── layout/ # 布局组件
│ │ ├── NavBar/ # 导航栏
│ │ ├── SharePoster/ # 分享海报
│ │ ├── PrivacyModal/ # 隐私协议弹窗
│ │ └── ErrorBoundary.tsx # 错误边界
│ ├── contexts/ # 全局状态 Context
│ │ ├── AppContext.tsx # 应用上下文
│ │ ├── UserContext.tsx # 用户信息
│ │ └── CartContext.tsx # 购物车
│ ├── hooks/ # 自定义 Hooks
│ │ ├── useUser.ts # 用户信息
│ │ ├── useVipStatus.ts # VIP 状态(响应式)
│ │ ├── useShare.ts # 分享/朋友圈/复制链接
│ │ ├── usePayment.ts # 支付
│ │ ├── useAddress.ts # 收货地址
│ │ ├── useNewOrderDetector.ts # 新订单轮询检测
│ │ ├── useCountDown.ts # 倒计时
│ │ ├── usePagination.ts # 分页
│ │ ├── useRequest.ts # 请求封装
│ │ └── ...
│ ├── api/ # 后端接口封装
│ │ ├── shop/ # 商城业务接口50+ 模块)
│ │ ├── system/ # 系统接口(文件上传等)
│ │ ├── passport/ # 认证接口
│ │ ├── cms/ # 内容管理
│ │ └── share.ts # 分享相关
│ ├── utils/ # 工具函数
│ │ ├── request.ts # 网络请求封装
│ │ ├── auth.ts # 用户禁用拦截
│ │ ├── vip.ts # VIP 状态判断
│ │ ├── privacy.ts # 隐私授权管理
│ │ ├── invite.ts # 邀请参数解析
│ │ ├── server.ts # 服务器地址
│ │ ├── image.ts # 图片处理
│ │ ├── geofence.ts # 地理围栏
│ │ └── common.ts # 通用工具
│ ├── types/ # TypeScript 类型定义
│ ├── styles/ # 全局样式
│ └── assets/ # 静态资源tabBar 图标等)
├── package.json
├── tsconfig.json
├── tailwind.config.js
├── postcss.config.js
├── project.config.json # 小程序项目配置
└── README.md
```
## ✨ 特性
---
### 1. 主题切换
- 支持浅色/深色模式切换
- 使用 Context API 管理主题状态
- 暗黑模式适配
## tabBar 配置
### 2. NutUI 组件库
- 已集成 @nutui/nutui-react-taro@2.7.4
- 支持按需引入
- 中文国际化配置
| Tab | 页面路径 | 图标 | 说明 |
|-----|---------|------|------|
| 首页 | `pages/index/index` | home | 商城首页、Banner、快捷入口 |
| 分类 | `pages/shop/index` | category | 商品分类与列表 |
| 购物车 | `pages/shop/cart` | cart | 购物车管理 |
| 我的 | `pages/user/user` | user | 个人中心 |
### 3. TailwindCSS 集成
- 支持原子化 CSS 编写
- 暗黑模式支持dark mode
- 自定义主题色配置
---
### 4. 工具函数封装
- **日期处理**: 基于 Day.js 的格式化
- **加密解密**: MD5、AES 加密
- **性能优化**: 防抖、节流函数
- **数据操作**: 深拷贝等工具
## 关键技术实现
### 5. TypeScript 支持
- 完整的类型定义
- 路径别名配置 `@/*`
- 严格模式开启
### VIP 会员价格体系
## 🎨 代码示例
商品价格字段约定:
| 字段 | 含义 |
|------|------|
| `product.price` | 到手价(主价格) |
| `product.salePrice` | 市场价(划线价) |
| `product.dealerPrice` | VIP 会员专享价 |
| `product.memberStorePrice` | 会员价 |
- 使用 `useVipStatus` Hook 实现响应式 VIP 状态更新
- VIP 状态通过异步校验缓存,组件挂载时自动刷新
### 微信隐私协议(基础库 3.16.1+
- 调用 `chooseImage` / `getPhoneNumber` 等敏感 API 前预检授权
- `app.tsx` 注册 `onNeedPrivacyAuthorization` 回调
- `PrivacyModal` 组件展示授权弹窗
### 手机号登录降级方案
- 微信 `getPhoneNumber` 被拒绝后自动降级到短信验证码登录
- 统一错误处理弹窗,引导跳转短信登录页
### 分享裂变
- `useShare` Hook 一次注册 `useShareAppMessage` + `useShareTimeline`
- 分享链接自动追加 `inviter` 参数做归因
- `SharePoster` 组件用 Canvas 2D 绘制海报 + 小程序码
### 用户禁用机制
- `User.status` 字段:`0` 正常 / `1` 禁用
- 多层级拦截UserContext 启动校验 + 登录检查 + 页面入口检查
---
## 代码示例
### 使用 NutUI 组件
@@ -156,9 +262,7 @@ export default function MyPage() {
return (
<CellGroup>
<Cell title="标题" description="描述" />
<Button type="primary" block>
提交
</Button>
<Button type="primary" block>提交</Button>
</CellGroup>
)
}
@@ -167,97 +271,100 @@ export default function MyPage() {
### 使用 TailwindCSS
```tsx
<View className="p-4 bg-white dark:bg-gray-800">
<Text className="text-lg font-bold text-gray-800 dark:text-white">
暗黑模式适配文本
</Text>
<View className="p-4 bg-white">
<Text className="text-lg font-bold text-gray-800">商品名称</Text>
</View>
```
### 使用 Context 管理状态
### 使用 VIP 状态 Hook
```tsx
import { useAppContext } from '@/hooks/useAppContext'
import { useVipStatus } from '@/hooks/useVipStatus'
export default function MyPage() {
const { theme, toggleTheme } = useAppContext()
return (
<Button onClick={toggleTheme}>
当前主题: {theme}
</Button>
)
export default function ProductCard({ product }) {
const { isVip } = useVipStatus()
const displayPrice = isVip ? product.dealerPrice : product.price
return <Text className="text-red-500">¥{displayPrice}</Text>
}
```
### 使用工具函数
### 使用分享 Hook
```tsx
import { formatDate, md5, debounce } from '@/utils'
import { useShare } from '@/hooks/useShare'
// 日期格式化
const dateStr = formatDate(new Date())
// MD5 加密
const encrypted = md5('hello world')
// 防抖
const handleSearch = debounce((keyword) => {
console.log(keyword)
}, 500)
export default function ProductDetail({ product }) {
useShare({
title: product.name,
path: '/pages/shop/product-detail',
query: { id: product.id },
enableTimeline: true,
enableCopyUrl: true,
})
// ...
}
```
## 📝 开发规范
### 网络请求
### 文件命名
- 页面目录: `kebab-case` (如 `user-profile/`)
- 组件文件: `PascalCase` (如 `UserProfile.tsx`)
- 工具文件: `camelCase` (如 `formatDate.ts`)
```tsx
import { getShopOrderList } from '@/api/shop/shopOrder'
### 代码风格
- 使用 ESLint + @typescript-eslint 进行代码检查
- 使用 2 空格缩进
- 分号结尾
- 单引号优先
### 提交规范
const { data } = await getShopOrderList({ status: 'pending', page: 1 })
```
feat: 新功能
fix: 修复 bug
docs: 文档更新
style: 代码格式调整
refactor: 重构
test: 测试相关
chore: 构建/工具配置
```
## 🔧 常见问题
### 1. TailwindCSS 样式不生效?
检查 `postcss.config.js` 配置,确保已安装 `postcss-nested`
### 2. NutUI 组件样式丢失?
确保在 `app.scss` 中引入了 NutUI 样式:
```scss
@import '@nutui/nutui-react-taro/dist/styles/vitamin.css';
```
### 3. TypeScript 路径别名报错?
检查 `tsconfig.json``paths` 配置,并确保 IDE 正确识别。
## 📚 相关文档
- [Taro 官方文档](https://taro-docs.jd.com/)
- [React 官方文档](https://react.dev/)
- [NutUI React 文档](https://nutui.jd.com/react-taro/)
- [TailwindCSS 文档](https://tailwindcss.com/docs)
- [TypeScript 文档](https://www.typescriptlang.org/docs/)
## 📄 License
MIT
---
**构建时间**: 2026-05-10
**作者**: Senior Developer
## 开发规范
### 文件命名
- 页面目录:`kebab-case`(如 `product-detail/`
- 组件文件:`PascalCase`(如 `ProductCard.tsx`
- 工具文件:`camelCase`(如 `formatDate.ts`
### 代码风格
- 2 空格缩进
- 单引号优先
- 分号结尾
- ESLint + @typescript-eslint 检查
### 提交规范
```
feat: 新功能
fix: 修复 bug
docs: 文档更新
style: 代码格式调整
refactor: 代码重构
test: 测试相关
chore: 构建/工具配置
```
---
## 常见问题
### TailwindCSS 样式在小程序中不生效?
检查 `postcss.config.js``tailwind.config.js` 配置,确保已安装 `weapp-tailwindcss` 并正确配置 content 路径。
### NutUI 组件样式丢失?
确保在 `app.scss` 中引入了 NutUI 样式文件。
### TypeScript 路径别名 `@/*` 报错?
检查 `tsconfig.json``paths` 配置,并确保 IDEVS Code正确识别。
### 隐私协议报错 `errno:112`
小程序后台需配置用户隐私保护指引,声明相册/摄像头/手机号等权限。
---
## 相关文档
- [Taro 官方文档](https://docs.taro.zone/)
- [React 官方文档](https://react.dev/)
- [NutUI React Taro 文档](https://nutui.jd.com/taro/react/2x/)
- [TailwindCSS 文档](https://tailwindcss.com/docs)
- [TypeScript 文档](https://www.typescriptlang.org/docs/)
- [微信小程序文档](https://developers.weixin.qq.com/miniprogram/dev/framework/)

View File

@@ -1,7 +1,8 @@
import { API_BASE_URL, SERVER_API_URL } from './env'
import { API_BASE_URL, SERVER_API_URL, CMS_API_URL } from './env'
export const TenantId = '10611'
export const TenantName = '鑫龙家电'
export const BaseUrl = API_BASE_URL
export const ServerBaseUrl = SERVER_API_URL
export const CmsBaseUrl = CMS_API_URL
export const Version = 'v1.0.0'

View File

@@ -4,18 +4,21 @@ export const ENV_CONFIG = {
development: {
API_BASE_URL: 'https://shop-api.websoft.top/api',
SERVER_API_URL: 'https://server.websoft.top/api',
CMS_API_URL: 'https://cms-api.websoft.top/api',
APP_NAME: '鑫龙家电',
DEBUG: 'true',
},
test: {
API_BASE_URL: 'https://shop-api.websoft.top/api',
SERVER_API_URL: 'https://server.websoft.top/api',
CMS_API_URL: 'https://cms-api.websoft.top/api',
APP_NAME: '鑫龙家电',
DEBUG: 'true',
},
production: {
API_BASE_URL: 'https://shop-api.websoft.top/api',
SERVER_API_URL: 'https://server.websoft.top/api',
CMS_API_URL: 'https://cms-api.websoft.top/api',
APP_NAME: '鑫龙家电',
DEBUG: 'false',
},
@@ -25,4 +28,4 @@ export function getEnvConfig() {
return ENV_CONFIG[CURRENT_ENV] || ENV_CONFIG.development
}
export const { API_BASE_URL, SERVER_API_URL, APP_NAME, DEBUG } = getEnvConfig()
export const { API_BASE_URL, SERVER_API_URL, CMS_API_URL, APP_NAME, DEBUG } = getEnvConfig()

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
}
]
}

View File

@@ -1,6 +1,10 @@
import request from '@/utils/request';
import type { ApiResult, PageResult } from '@/api';
import type { CmsAd, CmsAdParam } from './model';
import { CmsBaseUrl } from '@/config/app';
// 幻灯片/广告位等 CMS 内容走独立的 cms-api 域名
const cmsUrl = (path: string) => `${CmsBaseUrl}${path}`;
/**
@@ -8,7 +12,7 @@ import type { CmsAd, CmsAdParam } from './model';
*/
export async function pageCmsAd(params: CmsAdParam) {
const res = await request.get<ApiResult<PageResult<CmsAd>>>(
'/cms/cms-ad/page',
cmsUrl('/cms/cms-ad/page'),
params
);
if (res.code === 0) {
@@ -22,7 +26,7 @@ export async function pageCmsAd(params: CmsAdParam) {
*/
export async function listCmsAd(params?: CmsAdParam) {
const res = await request.get<ApiResult<CmsAd[]>>(
'/cms/cms-ad',
cmsUrl('/cms/cms-ad'),
params
);
if (res.code === 0 && res.data) {
@@ -93,7 +97,7 @@ export async function removeBatchCmsAd(data: (number | undefined)[]) {
*/
export async function getCmsAd(id: number) {
const res = await request.get<ApiResult<CmsAd>>(
'/cms/cms-ad/' + id
cmsUrl('/cms/cms-ad/' + id)
);
if (res.code === 0 && res.data) {
return res.data;
@@ -106,7 +110,7 @@ export async function getCmsAd(id: number) {
*/
export async function getCmsAdByCode(code: string) {
const res = await request.get<ApiResult<CmsAd>>(
'/cms/cms-ad/getByCode/' + code
cmsUrl('/cms/cms-ad/getByCode/' + code)
);
if (res.code === 0 && res.data) {
return res.data;

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
);
@@ -192,3 +194,23 @@ export async function refundShopOrder(data: ShopOrder) {
}
return Promise.reject(new Error(res.message));
}
/**
* 确认线下付款收款
* 商家确认已收到线下付款(微信转账/银行汇款等),确认后订单进入待发货状态
*/
export async function confirmOfflinePayment(
id: number,
remarks?: string,
paymentVoucher?: string
) {
const params: string[] = []
if (remarks) params.push(`remarks=${encodeURIComponent(remarks)}`)
if (paymentVoucher) params.push(`paymentVoucher=${encodeURIComponent(paymentVoucher)}`)
const url = '/shop/shop-order/confirm-offline-payment/' + id + (params.length ? '?' + params.join('&') : '')
const res = await request.put<ApiResult<unknown>>(url, null)
if (res.code === 0) {
return res.message;
}
return Promise.reject(new Error(res.message));
}

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,12 @@ export interface ShopOrder {
userId?: number;
// 备注
comments?: string;
// 买家备注(下单时用户填写的订单备注)
buyerRemarks?: string;
// 商户备注(门店修改金额原因等)
merchantRemarks?: string;
// 线下付款支付凭证图片URL
paymentVoucher?: string;
// 排序号
sortNumber?: number;
// 是否删除, 0否, 1是
@@ -159,6 +201,8 @@ export interface ShopOrder {
hasTakeGift?: string;
// 订单商品项
orderGoods?: ShopOrderGoods[];
// 发货单信息(详情接口关联查询返回,仅快递配送且已发货时有值)
shopOrderDelivery?: ShopOrderDelivery | null;
}
/**
@@ -169,6 +213,8 @@ export interface OrderGoodsItem {
quantity: number;
skuId?: number;
specInfo?: string;
// 单价VIP 会员传 dealerPrice普通用户不传由后端按商品价计算
price?: string;
}
/**
@@ -187,7 +233,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 +243,8 @@ export interface OrderCreateRequest {
couponId?: number;
// 备注
comments?: string;
// 买家备注(下单时用户填写的订单备注)
buyerRemarks?: string;
// 配送开始时间(用于预约/配送时间)
sendStartTime?: string;
// 配送方式 0快递 1自提

View File

@@ -0,0 +1,18 @@
import request from '@/utils/request'
import type { ApiResult } from '@/api'
import type { ShopOrderDelivery } from '@/api/shop/shopOrder/model'
/**
* 创建发货单(门店发货:记录发货人员)
* 后端 POST /shop/shop-order-deliverysave
*/
export async function saveShopOrderDelivery(data: ShopOrderDelivery) {
const res = await request.post<ApiResult<unknown>>(
'/shop/shop-order-delivery',
data
)
if (res.code === 0) {
return res.message
}
return Promise.reject(new Error(res.message))
}

View File

@@ -10,6 +10,16 @@ export interface ShopStoreUser {
storeId?: number;
// 用户ID
userId?: number;
// 姓名
name?: string;
// 手机号
phone?: string;
// 头像
image?: string;
// 角色类型: 1-门店经理, 2-店员
roleType?: number;
// 门店名称(关联字段)
storeName?: string;
// 备注
comments?: string;
// 排序号

View File

@@ -46,10 +46,11 @@ export async function addShopUserAddress(data: ShopUserAddress) {
/**
* 修改收货地址
* 后端 @PutMapping() 无 /{id} 路径id 通过 body 传递
*/
export async function updateShopUserAddress(data: ShopUserAddress) {
const res = await request.put<ApiResult<unknown>>(
'/shop/shop-user-address/' + data.id,
'/shop/shop-user-address',
data
);
if (res.code === 0) {

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

@@ -4,7 +4,7 @@ import type { UserBalance } from './model'
/**
* 获取当前用户余额信息(跨库读取 gxwebsoft_core.sys_user
* 通过 paopao-api 本地 /auth/user-balance 接口,无需跨域请求 core 系统
* 通过 shop-api 本地 /auth/user-balance 接口,无需跨域请求 core 系统
*/
export async function getUserBalance(): Promise<UserBalance> {
const res = await request.get<ApiResult<any>>(

View File

@@ -229,7 +229,7 @@ export async function checkExistence(
}
/**
* 统计用户余额(使用 paopao-api
* 统计用户余额(使用 shop-api
*/
export async function countUserBalance(params?: UserParam) {
const res = await request.get<ApiResult<unknown>>(

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',
@@ -130,7 +133,7 @@ export default {
window: {
backgroundTextStyle: 'light',
navigationBarBackgroundColor: '#fff',
navigationBarTitleText: 'Paopao Taro',
navigationBarTitleText: 'Websopy Inc.',
navigationBarTextStyle: 'black',
backgroundColor: '#f8f8f8',
},
@@ -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',

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,17 +25,21 @@ const getCardStatus = (order: ShopOrder): { text: string; color: string } => {
// 终态orderStatus=1 必须最先判断,盖过 deliveryStatus/payStatus
if (orderStatus === 1) return { text: '已完成', color: '#0e932e' }
// 货到付款payStatus=true 直接进入待发货
if (!payStatus && !isCod) return { text: '待付款', color: '#ee0a24' }
if (isCod && deliveryStatus === 10) return { text: '货到付款·待发货', color: '#4b9cf5' }
if (deliveryStatus === 10) return { text: '待发货', color: '#4b9cf5' }
// 线下付款(9)已发货但未确认收款:显示"已发货待收款"(红色),与门店端一致
if (!payStatus && payType === 9 && deliveryStatus === 20) return { text: '待付款,已发货', color: '#ee0a24' }
// 物流状态优先于付款状态(已发货的订单,即使未付款也显示物流状态)
if (deliveryStatus === 20) return { text: '待收货', color: '#4b9cf5' }
if (deliveryStatus === 30) return { text: '已收货', color: '#0e932e' }
// 货到付款(8):下单时后端已 setPayStatus(true),不会走到这里
// 线下付款(9):保持 payStatus=false待商家确认收款应显示"待付款"与 Tab 一致
if (!payStatus && !isCod) return { text: '待付款', color: '#ee0a24' }
if (deliveryStatus === 10) return { text: '待发货', color: '#4b9cf5' }
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 +48,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,10 +138,10 @@ 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)}
: {order.createTime?.replace('T', ' ').slice(0, 16)}
</Text>
<View className='flex items-center'>
<Text className='text-xs text-gray-500 mr-1'>
@@ -137,6 +151,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

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<title>Paopao Taro</title>
<title>Websopy Inc.</title>
<% if (typeof script !== 'undefined' && script) { %><script><%= script %></script><% } %>
</head>
<body>

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({
@@ -23,12 +25,15 @@ definePageConfig({
})
const IndexPage: React.FC = () => {
const { user, isLoggedIn } = useUser()
const { user, isLoggedIn, syncFromStorage } = 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({
@@ -63,6 +68,8 @@ const IndexPage: React.FC = () => {
// 页面重新显示时也刷新店员身份(可能从其他页面回来时状态变了)
useDidShow(() => {
// 先从 storage 同步用户状态(解决注册/登录后 UserContext 状态未更新的问题)
syncFromStorage()
if (isLoggedIn) {
getMyClerk()
.then(data => {
@@ -85,6 +92,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 +172,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 +194,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 +247,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 +258,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 +382,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: '线下付款',
}
// 发票状态映射
@@ -40,10 +41,18 @@ const DELIVERY_STATUS_MAP: Record<number, string> = {
30: '部分发货',
}
// 配送方式映射: 0=快递配送, 1=无需发货/自提, 2=商家送货
const DELIVERY_TYPE_MAP: Record<number, string> = {
0: '快递配送',
1: '自提',
2: '商家送货',
}
/** 根据订单状态计算展示用的状态标签 */
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) {
@@ -62,18 +71,28 @@ const getOrderDisplayStatus = (order: ShopOrder): { title: string; bgColor: stri
return { bgColor: '#ee0a24', title: '退款被拒绝', subtitle: '退款申请已被拒绝' }
}
// 正常订单流程
if (!payStatus && !isCod) {
// 线下付款已发货但未确认收款:显示"已发货待收款"(红色),与门店端一致
if (isOffline && !payStatus && deliveryStatus === 20) {
return { bgColor: '#ee0a24', title: '已发货待收款', subtitle: '商品已发货,请尽快转账付款' }
}
// 物流状态优先于付款状态
if (deliveryStatus === 20) {
return { bgColor: '#4b9cf5', title: '待收货', subtitle: '商品运输中,请注意查收' }
}
// 未发货时按付款状态显示
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 ? '送达时支付' : '商家正在准备商品' }
}
if (deliveryStatus === 20 || deliveryStatus === 30) {
return { bgColor: '#4b9cf5', title: '待收货', subtitle: '商品运输中,请注意查收' }
return { bgColor: '#4b9cf5', title: '待发货', subtitle: '商家正在准备商品' }
}
if (orderStatus === OrderStatus.Completed) {
return { bgColor: '#0e932e', title: '已完成', subtitle: '交易已完成,感谢购买' }
@@ -87,21 +106,26 @@ 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'
}
// 物流状态优先于付款状态(已发货的订单,即使未付款也进入 shipped 阶段)
if (deliveryStatus === 20) return 'shipped'
// 线下付款且未确认收款:等待商家确认,不显示"立即支付"按钮
if (isOffline && !payStatus) return 'offline_pending'
// 货到付款订单payStatus=true 但还未实际付款,直接进入 unshipped 阶段(不需要"立即支付"按钮)
if (!payStatus && !isCod) return 'unpaid'
if (deliveryStatus === 10) return 'unshipped'
if (deliveryStatus === 20 || deliveryStatus === 30) return 'shipped'
if (orderStatus === OrderStatus.Completed) return 'completed'
return 'unshipped'
@@ -237,7 +261,8 @@ const OrderDetailPage: React.FC = () => {
const displayStatus = getOrderDisplayStatus(order)
const phase = getOrderPhase(order)
const isExpress = order.deliveryType === 0 || order.deliveryType === undefined
// deliveryType: 0=快递配送, 1=无需发货/自提, 2=商家送货
const isExpress = order.deliveryType === 0 || order.deliveryType === 2 || order.deliveryType === undefined
const goodsCount = order.orderGoods?.reduce((sum, g) => sum + (g.totalNum || 1), 0) || 0
return (
@@ -266,13 +291,16 @@ const OrderDetailPage: React.FC = () => {
{isExpress ? (
<View className='bg-white mx-3 mt-3 p-3 rounded-lg'>
<View className='flex items-start gap-2'>
<Text className='text-base'>📦</Text>
<Text className='text-base'>{order.deliveryType === 2 ? '🛵' : '📦'}</Text>
<View className='flex-1'>
<View className='flex gap-2 mb-1'>
<Text className='text-sm font-medium text-gray-800'>{order.realName || '未知'}</Text>
<Text className='text-sm text-gray-600'>{order.mobile || order.phone || '-'}</Text>
</View>
<Text className='text-xs text-gray-500'>{order.address || '未填写收货地址'}</Text>
{order.deliveryType === 2 && order.expressMerchantName && (
<Text className='text-xs text-gray-400 mt-1'>: {order.expressMerchantName}</Text>
)}
{(order.sendStartTime || order.sendEndTime) && (
<Text className='text-xs text-gray-400 mt-1'>
: {order.sendStartTime?.slice(0, 10)} ~ {order.sendEndTime?.slice(0, 10)}
@@ -341,7 +369,7 @@ const OrderDetailPage: React.FC = () => {
<Text className='text-sm text-gray-500'></Text>
<Text className='text-sm text-gray-700'>¥{order.totalPrice || '0.00'}</Text>
</View>
{order.reducePrice && Number(order.reducePrice) > 0 && (
{Number(order.reducePrice || 0) > 0 && (
<View className='flex justify-between'>
<Text className='text-sm text-gray-500'></Text>
<Text className='text-sm text-green-600'>-¥{order.reducePrice}</Text>
@@ -381,6 +409,10 @@ const OrderDetailPage: React.FC = () => {
<Text className='text-xs text-gray-400'></Text>
<Text className='text-xs text-gray-600'>{DELIVERY_STATUS_MAP[order.deliveryStatus ?? 10]}</Text>
</View>
<View className='flex justify-between'>
<Text className='text-xs text-gray-400'></Text>
<Text className='text-xs text-gray-600'>{DELIVERY_TYPE_MAP[order.deliveryType ?? 0] || '未知'}</Text>
</View>
<View className='flex justify-between'>
<Text className='text-xs text-gray-400'></Text>
<Text className='text-xs text-gray-600'>{formatTime(order.createTime)}</Text>
@@ -397,12 +429,6 @@ const OrderDetailPage: React.FC = () => {
<Text className='text-xs text-gray-600'>{formatTime(order.deliveryTime)}</Text>
</View>
)}
{order.expirationTime && (
<View className='flex justify-between'>
<Text className='text-xs text-gray-400'></Text>
<Text className='text-xs text-gray-600'>{formatTime(order.expirationTime)}</Text>
</View>
)}
{order.comments && (
<View className='flex justify-start'>
<Text className='text-xs text-gray-400 mr-2'></Text>
@@ -445,6 +471,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

@@ -24,7 +24,7 @@ export default function Order() {
</View>
<CellGroup className='mb-4' title='项目信息'>
<Cell title='项目名称' description='Paopao Taro' />
<Cell title='项目名称' description='WebSopy Inc.' />
<Cell title='当前主题' description={theme === 'light' ? '浅色模式 🌞' : '深色模式 🌙'} />
<Cell
title='切换主题'

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: '购物车',
@@ -18,7 +18,9 @@ definePageConfig({
const CartPage: React.FC = () => {
const { items, selectedCount, selectedPrice, updateQuantity, toggleSelect, selectAll, removeItem, refresh, loading, removeSelected, addItem } = useCartContext()
const { isLoggedIn } = useUserContext()
const { isLoggedIn, syncFromStorage } = useUserContext()
// VIP 状态异步校验并更新缓存isVip 变化时触发重渲染
const { isVip } = useVipStatus()
const [recommendGoods, setRecommendGoods] = useState<ShopGoods[]>([])
const [recommendPage, setRecommendPage] = useState(1)
const [refreshingRecommend, setRefreshingRecommend] = useState(false)
@@ -32,7 +34,9 @@ const CartPage: React.FC = () => {
// 页面再次显示时刷新购物车数据(从其他页面加购后回到购物车)
useDidShow(() => {
if (isLoggedIn) {
// 先从 storage 同步用户状态(解决注册/登录后 UserContext 状态未更新的问题)
const loggedIn = syncFromStorage()
if (loggedIn) {
refresh()
}
})
@@ -110,7 +114,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 +181,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 +193,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 +233,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 +242,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
@@ -359,7 +361,7 @@ const ShopPage: React.FC = () => {
zIndex: 999,
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
}}
onClick={() => Taro.navigateTo({ url: '/pages/shop/cart' })}
onClick={() => Taro.switchTab({ url: '/pages/shop/cart' })}
>
<Image
className='w-7 h-7'

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: '商品详情',
@@ -35,7 +37,9 @@ const ProductDetailPage: React.FC = () => {
const [skuMode, setSkuMode] = useState<'cart' | 'buy'>('cart')
const [isFavorite, setIsFavorite] = useState(false)
const { addItem, totalCount, refresh } = useCartContext()
const { isLoggedIn } = useUserContext()
const { isLoggedIn, syncFromStorage } = useUserContext()
// VIP 状态异步校验并更新缓存isVip 变化时触发重渲染
const { isVip } = useVipStatus()
// 底部操作栏高度约 90px含按钮 + 安全区),动态计算 ScrollView 可用高度
const scrollHeight = useScrollHeight(44)
@@ -60,6 +64,8 @@ const ProductDetailPage: React.FC = () => {
// 页面显示时刷新购物车数量,确保底部购物车角标准确
useDidShow(() => {
// 先从 storage 同步用户状态(解决注册/登录后 UserContext 状态未更新的问题)
syncFromStorage()
if (isLoggedIn) refresh()
})
@@ -72,7 +78,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 +95,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 +150,11 @@ const ProductDetailPage: React.FC = () => {
Taro.setStorageSync('browse_history', reduced)
}
} catch { /* ignore */ }
// 已登录用户:异步上报服务端浏览记录(静默失败,不影响用户体验)
if (isLoggedIn) {
reportBrowse({ goodsId: id, browseSource: 'detail' })
}
}
const handleAddCart = () => {
@@ -172,7 +183,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,
@@ -190,7 +201,7 @@ const ProductDetailPage: React.FC = () => {
}
const handleGoCart = () => {
Taro.navigateTo({ url: '/pages/shop/cart' })
Taro.switchTab({ url: '/pages/shop/cart' })
}
if (!product) {
@@ -292,7 +303,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 +315,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>
@@ -316,9 +327,7 @@ const ProductDetailPage: React.FC = () => {
</View>
)}
<View className='flex gap-2 mt-2'>
{Number(product.sales) > 0 && (
<Text className='text-xs text-gray-400'>: {product.sales}</Text>
)}
<Text className='text-xs text-gray-400'>: {product.sales || 0}</Text>
<Text className='text-xs text-gray-400'>: {product.stock || 0}</Text>
{product.unitName && (
<Text className='text-xs text-gray-400'>: {product.unitName}</Text>
@@ -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

@@ -1,247 +1,306 @@
import React, { useState, useEffect, useCallback } from 'react'
import { View, Text, Image } from '@tarojs/components'
import React, {useState, useEffect, useCallback} from 'react'
import {View, Text, Image} from '@tarojs/components'
import Taro from '@tarojs/taro'
import { getMyClerk } from '@/api/shop/shopStoreUser'
import { listShopDealerApply } from '@/api/shop/shopDealerApply'
import { pageShopOrder } from '@/api/shop/shopOrder'
import {getMyClerk} from '@/api/shop/shopStoreUser'
import {listShopDealerApply} from '@/api/shop/shopDealerApply'
import {pageShopOrder} from '@/api/shop/shopOrder'
import {confirmWechatQRLogin, parseQRContent} from '@/api/passport/qr-login'
import Badge from '@/components/common/Badge'
// 二维码扫码图标base64 SVG data URI兼容小程序 Image 组件)
const QR_SCAN_ICON = "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNMyA3VjVhMiAyIDAgMCAxIDItMmgyTTE3IDNoMmEyIDIgMCAwIDEgMiAydjJNMjEgMTd2MmEyIDIgMCAwIDEtMiAyaC0yTTcgMjFINWEyIDIgMCAwIDEtMi0ydi0yIiBzdHJva2U9IiNmZmZmZmYiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiAvPjxyZWN0IHg9IjciIHk9IjciIHdpZHRoPSI0IiBoZWlnaHQ9IjQiIHN0cm9rZT0iI2ZmZmZmZiIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiAvPjxyZWN0IHg9IjEzIiB5PSI3IiB3aWR0aD0iNCIgaGVpZ2h0PSI0IiBzdHJva2U9IiNmZmZmZmYiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgLz48cmVjdCB4PSI3IiB5PSIxMyIgd2lkdGg9IjQiIGhlaWdodD0iNCIgc3Ryb2tlPSIjZmZmZmZmIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIC8+PHBhdGggZD0iTTEzIDEzaDJ2Mk0xNyAxM3YyTTE3IDE3djJNMTMgMTd2Mk0xMyAxOWgyIiBzdHJva2U9IiNmZmZmZmYiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiAvPjwvc3ZnPg=="
definePageConfig({
navigationBarTitleText: '门店中心',
navigationBarTitleText: '门店中心',
})
// 功能卡片定义(未来新增功能只需在这里加一项)
const FEATURE_CARDS = [
{
key: 'goods',
icon: '🏷️',
title: '商品管理',
desc: '管理门店商品上下架和库存',
url: '/pages/store/goods/index',
color: '#0891b2',
bgColor: '#cffafe',
showBadge: false,
},
{
key: 'orders',
icon: '📦',
title: '订单管理',
desc: '查看和处理门店订单',
url: '/pages/store/orders/index',
color: '#15803d',
bgColor: '#dcfce7',
// 显示待处理订单数量角标
showBadge: true,
},
{
key: 'vip-review',
icon: '👑',
title: 'VIP会员审核',
desc: '审核客户VIP会员申请',
url: '/pages/user/vip-review/index',
color: '#7c3aed',
bgColor: '#ede9fe',
showBadge: true,
},
{
key: 'goods',
icon: '🏷️',
title: '商品管理',
desc: '管理门店商品上下架和库存',
url: '/pages/store/goods/index',
color: '#0891b2',
bgColor: '#cffafe',
showBadge: false,
},
{
key: 'orders',
icon: '📦',
title: '订单管理',
desc: '查看和处理门店订单',
url: '/pages/store/orders/index',
color: '#15803d',
bgColor: '#dcfce7',
// 显示待处理订单数量角标
showBadge: true,
},
{
key: 'users',
icon: '👥',
title: '用户管理',
desc: '查看用户信息、禁用/启用用户',
url: '/pages/store/users/index',
color: '#2563eb',
bgColor: '#dbeafe',
showBadge: false,
},
{
key: 'vip-review',
icon: '👑',
title: 'VIP会员审核',
desc: '审核客户VIP会员申请',
url: '/pages/user/vip-review/index',
color: '#7c3aed',
bgColor: '#ede9fe',
showBadge: true,
},
]
// 订阅消息模板 ID
const SUBSCRIBE_TMPL_IDS = [
'RpZaVfxTuNxRmUxvinAwk6FHTpjV9yTznShdy8OHiTk', // 交易提醒:订单号、商品名称、联系人、联系电话、送货地址
'RpZaVfxTuNxRmUxvinAwk6FHTpjV9yTznShdy8OHiTk', // 交易提醒:订单号、商品名称、联系人、联系电话、送货地址
]
export default function StoreCenterPage() {
const [storeInfo, setStoreInfo] = useState<any>(null)
const [pendingVipCount, setPendingVipCount] = useState(0)
const [pendingOrderCount, setPendingOrderCount] = useState(0)
const [loading, setLoading] = useState(true)
const [storeInfo, setStoreInfo] = useState<any>(null)
const [pendingVipCount, setPendingVipCount] = useState(0)
const [pendingOrderCount, setPendingOrderCount] = useState(0)
const [loading, setLoading] = useState(true)
useEffect(() => {
// 验证店员身份
getMyClerk()
.then(data => {
if (!data) {
Taro.showToast({ title: '仅门店店员可访问', icon: 'none' })
setTimeout(() => Taro.navigateBack(), 1500)
return
useEffect(() => {
// 验证店员身份
getMyClerk()
.then(data => {
if (!data) {
Taro.showToast({title: '仅门店店员可访问', icon: 'none'})
setTimeout(() => Taro.navigateBack(), 1500)
return
}
setStoreInfo(data)
// 加载待审核数量
loadBadgeCounts()
})
.catch(() => {
Taro.showToast({title: '仅门店店员可访问', icon: 'none'})
setTimeout(() => Taro.navigateBack(), 1500)
})
.finally(() => setLoading(false))
}, [])
/** 加载各类待处理角标数量 */
const loadBadgeCounts = async () => {
try {
// VIP 待审核
const vipData = await listShopDealerApply({applyStatus: 10})
setPendingVipCount((vipData || []).length)
} catch { /* ignore */
}
setStoreInfo(data)
// 加载待审核数量
loadBadgeCounts()
})
.catch(() => {
Taro.showToast({ title: '仅门店店员可访问', icon: 'none' })
setTimeout(() => Taro.navigateBack(), 1500)
})
.finally(() => setLoading(false))
}, [])
/** 加载各类待处理角标数量 */
const loadBadgeCounts = async () => {
try {
// VIP 待审核
const vipData = await listShopDealerApply({ applyStatus: 10 })
setPendingVipCount((vipData || []).length)
} catch { /* ignore */ }
try {
// 待处理订单:未付款/待发货/待收货
const orderData = await pageShopOrder({ statusFilter: 1, page: 1, limit: 1 })
setPendingOrderCount(orderData?.total || 0)
} catch { /* ignore */ }
}
/** 获取卡片角标数字 */
const getBadgeCount = (key: string) => {
if (key === 'orders') return pendingOrderCount
if (key === 'vip-review') return pendingVipCount
return 0
}
/** 请求订阅消息授权 */
const handleSubscribe = useCallback(() => {
if (SUBSCRIBE_TMPL_IDS.length === 0) {
Taro.showToast({ title: '暂无可订阅的消息模板', icon: 'none' })
return
try {
// 待处理订单:未付款/待发货/待收货
const orderData = await pageShopOrder({statusFilter: 1, page: 1, limit: 1})
setPendingOrderCount(orderData?.total || 0)
} catch { /* ignore */
}
}
Taro.requestSubscribeMessage({
tmplIds: SUBSCRIBE_TMPL_IDS,
success: (res) => {
// res[templateId] === 'accept' 表示用户同意订阅
const accepted = SUBSCRIBE_TMPL_IDS.filter(id => res[id] === 'accept')
if (accepted.length > 0) {
Taro.showToast({ title: '订阅成功', icon: 'success' })
/** 获取卡片角标数字 */
const getBadgeCount = (key: string) => {
if (key === 'orders') return pendingOrderCount
if (key === 'vip-review') return pendingVipCount
return 0
}
/** 扫码登录 PC 后台 */
const handleScanLogin = useCallback(async () => {
try {
Taro.showLoading({title: '请扫码...', mask: true})
const scanRes = await Taro.scanCode({onlyFromCamera: false, scanType: ['qrCode']})
Taro.hideLoading()
const rawContent = scanRes.result || ''
const token = parseQRContent(rawContent)
const userId = Number(Taro.getStorageSync('UserId'))
if (!token) {
Taro.showModal({title: '提示', content: '未识别到有效的登录二维码', showCancel: false})
return
}
if (!userId) {
Taro.showModal({title: '提示', content: '当前用户未登录', showCancel: false})
return
}
Taro.showLoading({title: '正在确认登录...', mask: true})
await confirmWechatQRLogin(token, userId)
Taro.hideLoading()
Taro.showModal({title: '登录成功', content: 'PC 端后台登录已确认,请返回网页端查看', showCancel: false})
} catch (err: any) {
Taro.hideLoading()
if (err?.errMsg?.includes('cancel')) {
return // 用户取消扫码,静默处理
}
Taro.showModal({title: '登录失败', content: err?.message || '扫码登录失败,请重试', showCancel: false})
}
},
fail: (err: any) => {
console.error('订阅失败:', err)
// errCode 20001: 模板ID不存在或未绑定到当前小程序
if (err.errCode === 20001) {
Taro.showModal({
title: '订阅模板未配置',
content: '请联系管理员在微信公众平台选用「新订单通知」订阅消息模板',
showCancel: false,
})
} else if (err.errCode === 20004 || err.errCode === 20002) {
// 用户拒绝/关闭了主开关,静默处理
Taro.showToast({ title: '已取消订阅', icon: 'none' })
}, [])
/** 请求订阅消息授权 */
const handleSubscribe = useCallback(() => {
if (SUBSCRIBE_TMPL_IDS.length === 0) {
Taro.showToast({title: '暂无可订阅的消息模板', icon: 'none'})
return
}
},
})
}, [])
Taro.requestSubscribeMessage({
tmplIds: SUBSCRIBE_TMPL_IDS,
success: (res) => {
// res[templateId] === 'accept' 表示用户同意订阅
const accepted = SUBSCRIBE_TMPL_IDS.filter(id => res[id] === 'accept')
if (accepted.length > 0) {
Taro.showToast({title: '订阅成功', icon: 'success'})
}
},
fail: (err: any) => {
console.error('订阅失败:', err)
// errCode 20001: 模板ID不存在或未绑定到当前小程序
if (err.errCode === 20001) {
Taro.showModal({
title: '订阅模板未配置',
content: '请联系管理员在微信公众平台选用「新订单通知」订阅消息模板',
showCancel: false,
})
} else if (err.errCode === 20004 || err.errCode === 20002) {
// 用户拒绝/关闭了主开关,静默处理
Taro.showToast({title: '已取消订阅', icon: 'none'})
}
},
})
}, [])
if (loading) {
return (
<View className='min-h-full bg-gray-50 flex items-center justify-center'>
<Text className='text-gray-400'>...</Text>
</View>
)
}
if (!storeInfo) return null
return (
<View className='min-h-full bg-gray-50'>
{/* 顶部信息区:店员头像 + 门店名称 */}
<View
className='pt-8 pb-12 px-5 rounded-b-3xl relative overflow-hidden'
style={{ background: 'linear-gradient(135deg, #15803d 0%, #22c55e 60%, #4ade80 100%)' }}
>
<View className='absolute -top-10 -right-10 w-40 h-40 rounded-full opacity-10'
style={{ background: 'radial-gradient(circle, #ffffff, transparent)' }} />
<View className='absolute -bottom-6 -left-6 w-24 h-24 rounded-full opacity-15'
style={{ background: 'radial-gradient(circle, #ffffff, transparent)' }} />
<View className='relative z-10 flex items-center gap-4'>
{/* 店员头像 */}
<View className='w-16 h-16 rounded-full bg-white/20 border-2 border-white/40 overflow-hidden flex-shrink-0'>
{storeInfo.avatar ? (
<Image
className='w-full h-full'
src={storeInfo.avatar}
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'>
<Text className='text-white text-lg font-bold block truncate'>
{storeInfo.storeName || '门店中心'}
</Text>
<Text className='text-white text-opacity-80 text-sm mt-1 block truncate'>
{storeInfo.name} {storeInfo.phone ? `· ${storeInfo.phone}` : ''}
</Text>
</View>
</View>
</View>
{/* 功能卡片区 */}
<View className='mx-3 -mt-6 relative z-20'>
<View className='flex flex-col gap-3'>
{FEATURE_CARDS.map(card => (
<View
key={card.key}
className='bg-white rounded-xl p-4 flex items-center gap-4 active:opacity-80 relative'
onClick={() => Taro.navigateTo({ url: card.url })}
>
{/* 图标 */}
<View
className='w-12 h-12 rounded-xl flex items-center justify-center flex-shrink-0'
style={{ background: card.bgColor }}
>
<Text className='text-2xl'>{card.icon}</Text>
</View>
{/* 文字 */}
<View className='flex-1 min-w-0'>
<Text className='text-gray-800 text-base font-medium block'>{card.title}</Text>
<Text className='text-gray-400 text-xs mt-0.5 block'>{card.desc}</Text>
</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>
)}
{/* 箭头 */}
<Text className='text-gray-300 text-lg flex-shrink-0'></Text>
if (loading) {
return (
<View className='min-h-full bg-gray-50 flex items-center justify-center'>
<Text className='text-gray-400'>...</Text>
</View>
))}
</View>
</View>
)
}
{/* 底部提示 */}
<View className='mx-3 mt-6 mb-4'>
{/* 订阅消息提醒 */}
<View
className='bg-white rounded-xl p-4 flex items-center gap-3 active:opacity-80 mb-3'
onClick={handleSubscribe}
>
<View className='w-10 h-10 rounded-full bg-orange-50 flex items-center justify-center flex-shrink-0'>
<Text className='text-xl'>🔔</Text>
</View>
<View className='flex-1 min-w-0'>
<Text className='text-gray-800 text-sm font-medium block'></Text>
<Text className='text-gray-400 text-xs mt-0.5 block'>
</Text>
</View>
<Text className='text-orange-500 text-sm flex-shrink-0'></Text>
</View>
if (!storeInfo) return null
<Text className='text-gray-300 text-xs text-center block'>
线
</Text>
</View>
</View>
)
return (
<View className='min-h-full bg-gray-50'>
{/* 顶部信息区:店员头像 + 门店名称 */}
<View
className='pt-8 pb-12 px-5 rounded-b-3xl relative overflow-hidden'
style={{background: 'linear-gradient(135deg, #15803d 0%, #22c55e 60%, #4ade80 100%)'}}
>
<View className='absolute -top-10 -right-10 w-40 h-40 rounded-full opacity-10'
style={{background: 'radial-gradient(circle, #ffffff, transparent)'}}/>
<View className='absolute -bottom-6 -left-6 w-24 h-24 rounded-full opacity-15'
style={{background: 'radial-gradient(circle, #ffffff, transparent)'}}/>
{/* 右上角扫码登录按钮 */}
<View
className='absolute top-3 right-4 z-20 w-9 h-9 rounded-full bg-white/20 border border-white/30 flex items-center justify-center active:opacity-70'
onClick={handleScanLogin}
>
<Image src={QR_SCAN_ICON} className='w-5 h-5' mode='aspectFit'/>
</View>
<View className='relative z-10 flex items-center gap-4'>
{/* 店员头像 */}
<View
className='w-16 h-16 rounded-full bg-white/20 border-2 border-white/40 overflow-hidden flex-shrink-0'>
{storeInfo.avatar ? (
<Image
className='w-full h-full'
src={storeInfo.avatar}
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'>
<Text className='text-white text-lg font-bold block truncate'>
{storeInfo.storeName || '门店中心'}
</Text>
<Text className='text-white text-opacity-80 text-sm mt-1 block truncate'>
{storeInfo.name} {storeInfo.phone ? `· ${storeInfo.phone}` : ''}
</Text>
</View>
</View>
</View>
{/* 功能卡片区 */}
<View className='mx-3 -mt-6 relative z-20'>
<View className='flex flex-col gap-3'>
{FEATURE_CARDS.map(card => (
<View
key={card.key}
className='bg-white rounded-xl p-4 flex items-center gap-4 active:opacity-80 relative'
onClick={() => Taro.navigateTo({url: card.url})}
>
{/* 图标 */}
<View
className='w-12 h-12 rounded-xl flex items-center justify-center flex-shrink-0'
style={{background: card.bgColor}}
>
<Text className='text-2xl'>{card.icon}</Text>
</View>
{/* 文字 */}
<View className='flex-1 min-w-0'>
<Text className='text-gray-800 text-base font-medium block'>{card.title}</Text>
<Text className='text-gray-400 text-xs mt-0.5 block'>{card.desc}</Text>
</View>
{/* 待处理角标 */}
{card.showBadge && (
<Badge count={getBadgeCount(card.key)} className='absolute -top-1 -right-1' size={20}
fontSize={11} fontWeight='bold'/>
)}
{/* 箭头 */}
<Text className='text-gray-300 text-lg flex-shrink-0'></Text>
</View>
))}
</View>
</View>
{/* 底部提示 */}
<View className='mx-3 mt-3 mb-6'>
{/* 订阅消息提醒 */}
<View
className='bg-white rounded-xl p-4 flex items-center gap-3 active:opacity-80 mb-3'
onClick={handleSubscribe}
>
<View
className='w-10 h-10 rounded-full bg-orange-50 flex items-center justify-center flex-shrink-0'>
<Text className='text-xl'>🔔</Text>
</View>
<View className='flex-1 min-w-0'>
<Text className='text-gray-800 text-sm font-medium block'></Text>
<Text className='text-gray-400 text-xs mt-0.5 block'>
</Text>
</View>
<Text className='text-orange-500 text-sm flex-shrink-0'></Text>
</View>
<Text className='text-gray-300 text-xs text-center block'>
线
</Text>
<View className={'h-4'}></View>
</View>
</View>
)
}

File diff suppressed because it is too large Load Diff

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,39 +1,49 @@
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 {pageShopOrder, updateShopOrder, confirmOfflinePayment} from '@/api/shop/shopOrder'
import type {ShopOrder, ShopOrderParam} from '@/api/shop/shopOrder/model'
import {saveShopOrderDelivery} from '@/api/shop/shopOrderDelivery'
import {TenantId} from '@/config/app'
import {useNewOrderDetector} from '@/hooks/useNewOrderDetector'
import { getCompressedImageUrl } from '@/utils/image'
import { getMyClerk, listShopStoreUser } from '@/api/shop/shopStoreUser'
import type {ShopStoreUser} from '@/api/shop/shopStoreUser/model'
import { ensurePrivacyAuthorized } from '@/api/system/file'
definePageConfig({
navigationBarTitleText: '订单管理',
})
// ─── Tab 配置(全部 + 待处理 + 已完成)──────────────────────────
type TabKey = 'all' | 'pending' | 'completed'
// ─── Tab 配置(待处理 + 已完成 + 已关闭)──────────────────────────
type TabKey = 'pending' | 'completed' | 'closed'
const TABS: { key: TabKey; label: string; params: Partial<ShopOrderParam>[] }[] = [
// 全部:不传 statusFilter
{key: 'all', label: '全部', params: [{}]},
// 待处理:合并 statusFilter=0待付款、1待发货、2待核销、3待收货
{key: 'pending', label: '待处理', params: [{statusFilter: 0}, {statusFilter: 1}, {statusFilter: 2}, {statusFilter: 3}]},
// 已完成statusFilter=5与后台管理一致
{key: 'completed', label: '已完成', params: [{statusFilter: 5}]},
// 待处理:合并 statusFilter=1待发货statusFilter=8(已关闭)
{key: 'pending', label: '已关闭', params: [{statusFilter: 1}, {statusFilter: 8}]},
// 已关闭:statusFilter=8
{key: 'closed', label: '已关闭', params: [{statusFilter: 8}]},
]
// ─── 操作类型 ─────────────────────────────────────────────────────
type OpType = 'pay' | 'complete'
type OpType = 'confirmPay' | 'deliver' | 'complete' | 'ship' | 'editPrice'
const OP_LABEL: Record<OpType, string> = {
pay: '收款',
confirmPay: '收款',
deliver: '送达',
complete: '已完成',
ship: '发货',
editPrice: '改价',
}
const OP_DESC: Record<OpType, string> = {
pay: '变更支付状态为已付款',
complete: '同时变更支付状态为已付款、收货状态为已收货、订单状态为已完成',
confirmPay: '确认已收到线下付款',
deliver: '上传送达凭证照片,标记为已收货(不改变付款和订单完成状态)',
complete: '确认订单已完成,收款后点击此按钮标记订单完成',
ship: '选择发货人员并生成发货单,订单将进入已发货状态',
editPrice: '修改订单实付金额',
}
// ─── 图片上传 ─────────────────────────────────────────────────────
@@ -74,6 +84,17 @@ function formatDateTime(date: Date): string {
return `${y}-${m}-${d} ${h}:${mi}:${s}`
}
/** 格式化下单时间为「YYYY-MM-DD HH:mm」兼容 "2026-07-23T02:00:18" 与 "2026-07-23 02:00:18" */
function formatOrderTime(raw?: string): string {
if (!raw) return ''
const s = raw.replace('T', ' ').trim()
const parts = s.split(' ')
if (parts.length < 2) return s
const timeParts = parts[1].split(':')
const hhmm = `${timeParts[0]}:${timeParts[1] || '00'}`
return `${parts[0]} ${hhmm}`
}
/** 解析 sendEndImg兼容 JSON 数组(新格式)和逗号分隔(旧格式) */
function parseSendEndImg(raw: string): string[] {
if (!raw || !raw.trim()) return []
@@ -92,16 +113,25 @@ 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 && order.deliveryStatus === 10) return '发货'
// 线下付款·已发货待收款(先发货后结款)
if (order.payType === 9 && order.deliveryStatus === 20) return '发货待收款'
if (order.payStatus === false || order.payStatus === null) {
// 线下付款待确认收款
if (order.payType === 9) return '待确认收款'
return '待收款'
}
if (order.payStatus && order.deliveryStatus === 20) return '待收货'
if (order.payStatus && order.deliveryStatus === 10) return '待发货'
return '进行中'
}
function getStatusColor(order: ShopOrder): string {
if (order.orderStatus === 1) return '#0e932e'
if (order.orderStatus === 2) return '#999'
// 已发货待收款:红色(与用户端一致)
if (order.payType === 9 && order.deliveryStatus === 20) return '#ee0a24'
if (order.payStatus === false || order.payStatus === null) return '#ee0a24'
if (order.deliveryStatus === 20) return '#4b9cf5'
if (order.payStatus) return '#ff7d00'
return '#999'
}
@@ -115,9 +145,28 @@ function isOrderActionable(order: ShopOrder): boolean {
function getOrderActions(order: ShopOrder): { label: string; type: OpType }[] {
if (!isOrderActionable(order)) return []
const actions: { label: string; type: OpType }[] = []
// 货到付款:未付款 / 已付款 都直接"确认完成"
// 确认完成会同时设置 payStatus=true无需单独的"确认收款"按钮
if (order.orderStatus !== 1 && order.orderStatus !== 2) {
// 线下付款·待确认收款:显示"确认收款"和"改价"按钮
if (!order.payStatus && order.payType === 9 && order.orderStatus === 0) {
actions.push({label: '改价', type: 'editPrice'})
actions.push({label: '确认收款', type: 'confirmPay'})
}
// 发货:
// - 已付款且未发货:正常发货
// - 线下付款未付款且未发货:支持先发货后结款
if (order.orderStatus !== 1) {
const notShipped = order.deliveryStatus == null || order.deliveryStatus < 20
if (notShipped && (order.payStatus || order.payType === 9)) {
actions.push({label: '发货', type: 'ship'})
}
}
// 送达已发货但未上传送达照片时显示deliveryStatus=20 且 sendEndImg 为空)
const shipped = order.deliveryStatus != null && order.deliveryStatus === 20
const hasDeliverProof = order.sendEndImg && order.sendEndImg !== '[]'
if (shipped && !hasDeliverProof) {
actions.push({label: '送达', type: 'deliver'})
}
// 确认完成:仅已付款才可标记订单为已完成
if (order.payStatus) {
actions.push({label: '确认完成', type: 'complete'})
}
return actions
@@ -125,7 +174,7 @@ function getOrderActions(order: ShopOrder): { label: string; type: OpType }[] {
// ─── 页面组件 ──────────────────────────────────────────────────────
export default function StoreOrdersPage() {
const [activeTab, setActiveTab] = useState<TabKey>('all')
const [activeTab, setActiveTab] = useState<TabKey>('pending')
// 订单列表与分页
const [orderList, setOrderList] = useState<ShopOrder[]>([])
@@ -138,11 +187,49 @@ export default function StoreOrdersPage() {
const [currentOrder, setCurrentOrder] = useState<ShopOrder | null>(null)
const [opType, setOpType] = useState<OpType>('pay')
const [proofImages, setProofImages] = useState<string[]>([])
const [payRemarks, setPayRemarks] = useState('')
const [submitting, setSubmitting] = useState(false)
// 改价弹窗状态
const [showEditPriceModal, setShowEditPriceModal] = useState(false)
const [editPriceOrder, setEditPriceOrder] = useState<ShopOrder | null>(null)
const [editPriceValue, setEditPriceValue] = useState('')
const [editPriceRemarks, setEditPriceRemarks] = useState('')
const [editingPrice, setEditingPrice] = useState(false)
// 发货弹窗状态
const [showShipModal, setShowShipModal] = useState(false)
const [clerkList, setClerkList] = useState<ShopStoreUser[]>([])
const [selectedClerkId, setSelectedClerkId] = useState<number | null>(null)
const [loadingClerks, setLoadingClerks] = useState(false)
const [shipping, setShipping] = useState(false)
// 搜索searchInput 受控输入框searchKeyword 为已提交的搜索词
const [searchInput, setSearchInput] = useState('')
const [searchKeyword, setSearchKeyword] = 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 秒轮询一次
@@ -174,7 +261,7 @@ export default function StoreOrdersPage() {
// 并行请求所有 params 组合,合并去重
const allRequests = tabConfig.params.map(p =>
pageShopOrder({...p, page: pageNo, limit: pageSize})
pageShopOrder({...p, page: pageNo, limit: pageSize, keywords: searchKeyword || undefined})
)
const allResults = await Promise.all(allRequests)
@@ -208,7 +295,7 @@ export default function StoreOrdersPage() {
loadingRef.current = false
setLoading(false)
}
}, [])
}, [searchKeyword])
// 切换 tab 时重新加载
useEffect(() => {
@@ -227,11 +314,28 @@ export default function StoreOrdersPage() {
}
}
/** 提交搜索(点击搜索按钮或键盘回车) */
const handleSearch = () => {
setSearchKeyword(searchInput.trim())
}
/** 清空搜索 */
const handleClearSearch = () => {
setSearchInput('')
setSearchKeyword('')
}
/** 打开操作弹窗 */
const openModal = (order: ShopOrder, type: OpType) => {
// 改价走独立弹窗
if (type === 'editPrice') {
openEditPriceModal(order)
return
}
setCurrentOrder(order)
setOpType(type)
setProofImages([])
setPayRemarks('')
setShowModal(true)
}
@@ -240,14 +344,22 @@ export default function StoreOrdersPage() {
setShowModal(false)
setCurrentOrder(null)
setProofImages([])
setPayRemarks('')
}
/** 选择并上传凭证图片 */
const chooseProofImage = () => {
const maxCount = 3
const chooseProofImage = async () => {
const maxCount = opType === 'confirmPay' ? 1 : 3
const remaining = maxCount - proofImages.length
if (remaining <= 0) return
// 门店上传凭证需要访问相册/摄像头,先预检隐私协议授权
try {
await ensurePrivacyAuthorized()
} catch {
// 授权失败由 chooseImage 自行处理
}
Taro.chooseImage({
count: remaining,
sizeType: ['compressed'],
@@ -277,37 +389,42 @@ export default function StoreOrdersPage() {
const submitOperation = async () => {
if (!currentOrder) return
// 确认完成必须上传凭证照片
if (opType === 'complete' && proofImages.length === 0) {
Taro.showToast({title: '请上传送凭证照片', icon: 'none'})
// 确认送达必须上传凭证照片
if (opType === 'deliver' && proofImages.length === 0) {
Taro.showToast({title: '请上传送凭证照片', icon: 'none'})
return
}
// 确认收款必须上传支付凭证
if (opType === 'confirmPay' && proofImages.length === 0) {
Taro.showToast({title: '请上传支付凭证', icon: 'none'})
return
}
setSubmitting(true)
try {
const updateData: any = {orderId: currentOrder.orderId}
if (opType === 'confirmPay') {
// 确认线下收款
await confirmOfflinePayment(
currentOrder.orderId!,
payRemarks.trim() || undefined,
proofImages[0]
)
} else {
const updateData: any = {orderId: currentOrder.orderId}
if (opType === 'pay') {
// 确认收款:变更支付状态为已付款
updateData.payStatus = true
updateData.payTime = formatDateTime(new Date())
} else if (opType === 'complete') {
// 确认完成:用户已收到货
updateData.payStatus = true
updateData.payTime = formatDateTime(new Date())
updateData.deliveryStatus = 30 // 发货状态 → 已收货
updateData.deliveryTime = formatDateTime(new Date()) // 收货时间
updateData.orderStatus = 1 // 订单状态 → 已完成
updateData.sendEndImg = JSON.stringify(proofImages) // 配送员送达拍照JSON数组
if (opType === 'deliver') {
// 送达上传送达照片不改变付款状态、deliveryStatus 和订单完成状态)
updateData.deliveryTime = formatDateTime(new Date())
updateData.sendEndImg = JSON.stringify(proofImages)
} else if (opType === 'complete') {
// 确认完成:标记订单为已完成
updateData.orderStatus = 1 // 订单状态 → 已完成
}
await updateShopOrder(updateData)
}
// 收款操作如有凭证也追加到备注
if (opType === 'pay' && proofImages.length > 0) {
const proofText = `【门店收款凭证】:${JSON.stringify(proofImages)}`
updateData.comments = (currentOrder.comments || '') + `\n${proofText}`
}
await updateShopOrder(updateData)
Taro.showToast({title: '操作成功', icon: 'success'})
closeModal()
loadOrders(activeTab, 1) // 刷新列表
@@ -318,39 +435,174 @@ export default function StoreOrdersPage() {
}
}
/** 删除订单 */
const handleDelete = (order: ShopOrder) => {
/** 打开改价弹窗 */
const openEditPriceModal = (order: ShopOrder) => {
setEditPriceOrder(order)
setEditPriceValue(String(order.payPrice || order.totalPrice || ''))
setEditPriceRemarks('')
setShowEditPriceModal(true)
}
/** 关闭改价弹窗 */
const closeEditPriceModal = () => {
setShowEditPriceModal(false)
setEditPriceOrder(null)
setEditPriceValue('')
setEditPriceRemarks('')
}
/** 提交改价 */
const submitEditPrice = async () => {
if (!editPriceOrder) return
const newPrice = parseFloat(editPriceValue)
if (isNaN(newPrice) || newPrice < 0) {
Taro.showToast({title: '请输入有效金额', icon: 'none'})
return
}
const oldPriceStr = String(editPriceOrder.payPrice || editPriceOrder.totalPrice || '0')
const oldPrice = parseFloat(oldPriceStr)
if (!isNaN(oldPrice) && Math.abs(newPrice - oldPrice) < 0.01) {
Taro.showToast({title: '金额未变化', icon: 'none'})
return
}
setEditingPrice(true)
try {
const changeRecord = `【门店修改金额】原实付¥${oldPriceStr} → 新实付¥${newPrice.toFixed(2)}`
await updateShopOrder({
orderId: editPriceOrder.orderId,
payPrice: newPrice.toFixed(2),
merchantRemarks: (editPriceOrder.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 {
setEditingPrice(false)
}
}
/** 打开发货弹窗:拉取门店店员列表供选择发货人员 */
const openShipModal = async (order: ShopOrder) => {
setCurrentOrder(order)
setSelectedClerkId(null)
setClerkList([])
setShowShipModal(true)
setLoadingClerks(true)
try {
// 获取当前登录店员(含 storeId / userId
// 用 storeId 拉取本门店店员列表,避免 ?storeId=0 拉不到数据
const myClerk = await getMyClerk()
const storeId = myClerk?.storeId
if (!storeId) {
Taro.showToast({title: '门店信息未就绪,请稍后再试', icon: 'none'})
return
}
const res = await listShopStoreUser({storeId})
setClerkList(res || [])
// 默认选中与当前登录用户 userId 一致的店员
if (res && myClerk?.userId) {
const match = res.find(c => c.userId === myClerk!.userId)
if (match) setSelectedClerkId(match.id ?? null)
}
} catch (e) {
Taro.showToast({title: '加载店员失败', icon: 'none'})
} finally {
setLoadingClerks(false)
}
}
/** 确认发货:创建发货单并记录发货人员,订单置为已发货 */
const handleConfirmShip = async () => {
if (!currentOrder) return
const clerk = clerkList.find(c => c.id === selectedClerkId)
if (!clerk) {
Taro.showToast({title: '请选择发货人员', icon: 'none'})
return
}
setShipping(true)
try {
// 1) 创建发货单,记录发货人信息
await saveShopOrderDelivery({
orderId: currentOrder.orderId,
deliveryMethod: 20, // 20=无需物流(门店自送/自提,无快递单号)
sendName: clerk.name,
sendPhone: clerk.phone,
// 发货地址取门店名称(店员实体无地址字段)
sendAddress: currentOrder.storeName || '',
})
// 2) 订单置为已发货deliveryStatus=20物流页与列表状态同步更新
await updateShopOrder({
orderId: currentOrder.orderId,
deliveryStatus: 20,
deliveryTime: formatDateTime(new Date()),
})
Taro.showToast({title: '发货成功', icon: 'success'})
setShowShipModal(false)
loadOrders(activeTab, 1) // 刷新列表(发货按钮随之隐藏)
} catch (e: any) {
Taro.showToast({title: e.message || '发货失败', icon: 'none'})
} finally {
setShipping(false)
}
}
/** 关闭订单 */
const handleCloseOrder = (order: ShopOrder) => {
Taro.showModal({
title: '提示',
content: `确定要删除订单 ${order.orderNo} 吗?删除后无法恢复。`,
content: `确定要关闭订单 ${order.orderNo} 吗?关闭后无法恢复。`,
confirmColor: '#ee0a24',
success: async (res) => {
if (!res.confirm) return
try {
Taro.showLoading({title: '删除中...'})
await removeShopOrder(order.orderId)
Taro.showLoading({title: '关闭中...'})
await updateShopOrder({orderId: order.orderId, orderStatus: 2} as ShopOrder)
Taro.hideLoading()
Taro.showToast({title: '删除成功', icon: 'success'})
Taro.showToast({title: '关闭成功', icon: 'success'})
loadOrders(activeTab, 1)
} catch (e: any) {
Taro.hideLoading()
Taro.showToast({title: e.message || '删除失败', icon: 'none'})
Taro.showToast({title: e.message || '关闭失败', icon: 'none'})
}
},
})
}
/** 一键导航到收货地址(调用微信内置地图) */
const handleNavigate = (order: ShopOrder) => {
const lat = parseFloat(order.addressLat || '')
const lng = parseFloat(order.addressLng || '')
if (isNaN(lat) || isNaN(lng)) {
Taro.showToast({title: '该订单未记录定位信息,无法导航', icon: 'none'})
return
}
Taro.openLocation({
latitude: lat,
longitude: lng,
name: order.realName || '收货地址',
address: order.address || '',
scale: 16,
})
}
/** 渲染单个订单卡片 */
const renderOrderCard = (order: ShopOrder) => {
const actions = getOrderActions(order)
const canDelete = isOrderActionable(order) // 仅未完成、未关闭的订单可删除
const canDelete = isOrderActionable(order) // 仅未完成、未关闭的订单可关闭
const orderGoods = (order as any).orderGoods || []
return (
<View key={order.orderId} className='bg-white rounded-xl mx-3 mt-3 p-4'>
{/* 订单头部 */}
<View className='flex justify-between items-center mb-3'>
<Text className='text-xs text-gray-400'>{order.orderNo}</Text>
<View className='flex justify-between items-start mb-3'>
<View className='flex flex-col'>
<Text className='text-xs text-gray-400'>{order.orderNo}</Text>
{order.createTime ? (
<Text className='text-xs text-gray-400 mt-1'>{formatOrderTime(order.createTime)}</Text>
) : null}
</View>
<Text className='text-xs' style={{color: getStatusColor(order)}}>
{getStatusText(order)}
</Text>
@@ -359,30 +611,63 @@ 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>
))}
{/* 收货信息 */}
{/* 收货信息(点击区域一键导航) */}
{(order.realName || order.phone) && (
<View className='bg-gray-50 rounded-lg p-3 mb-3'>
<Text className='text-sm text-gray-700 block'>{order.realName} {order.phone}</Text>
{order.address && (
<Text className='text-xs text-gray-400 mt-1 block'>{order.address}</Text>
)}
<View
className='bg-gray-50 rounded-lg p-3 mb-3 flex items-center'
onClick={() => handleNavigate(order)}
>
<View className='flex-1'>
<Text className='text-sm text-gray-700 block'>{order.realName} {order.phone}</Text>
{order.address && (
<Text className='text-xs text-gray-400 mt-1 block'>{order.address}</Text>
)}
</View>
<View className='ml-2 flex flex-col items-center justify-center px-1'>
<Text className='text-lg text-green-500 leading-none'></Text>
<Text className='text-xs text-green-500 mt-0.5'></Text>
</View>
</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.paymentVoucher && (
<View className='bg-green-50 rounded-lg p-3 mb-3'>
<Text className='text-xs text-green-500 mb-2 block'></Text>
<Image
className='w-20 h-20 rounded-lg bg-gray-100'
src={getCompressedImageUrl(order.paymentVoucher)}
mode='aspectFill'
onClick={() => Taro.previewImage({
current: order.paymentVoucher!,
urls: [order.paymentVoucher!]
})}
/>
</View>
)}
@@ -413,7 +698,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>
@@ -422,13 +707,13 @@ export default function StoreOrdersPage() {
{/* 操作按钮 */}
<View className='flex justify-between items-center border-t border-gray-50 pt-3'>
{/* 删除按钮:仅未完成/未关闭的订单显示 */}
{/* 关闭按钮:仅未完成/未关闭的订单显示 */}
{canDelete ? (
<View
className='px-3 py-1.5'
onClick={() => handleDelete(order)}
onClick={() => handleCloseOrder(order)}
>
<Text className='text-xs text-gray-400'></Text>
<Text className='text-xs text-gray-400'></Text>
</View>
) : (
<View/>
@@ -441,12 +726,20 @@ export default function StoreOrdersPage() {
className={`px-4 py-2 rounded-lg ${
act.type === 'complete'
? 'bg-green-500'
: act.type === 'confirmPay'
? 'bg-blue-500'
: act.type === 'ship'
? 'bg-purple-500'
: act.type === 'editPrice'
? 'bg-orange-500'
: 'border border-blue-500'
}`}
onClick={() => openModal(order, act.type)}
onClick={() => act.type === 'ship'
? openShipModal(order)
: openModal(order, act.type)}
>
<Text className={`text-sm ${
act.type === 'complete' ? 'text-white' : 'text-blue-500'
act.type === 'complete' || act.type === 'confirmPay' || act.type === 'ship' || act.type === 'editPrice' ? 'text-white' : 'text-blue-500'
}`}>{act.label}</Text>
</View>
))}
@@ -456,8 +749,38 @@ export default function StoreOrdersPage() {
)
}
// 访问校验中或已拒绝:不渲染页面内容
if (accessChecking) {
return <View className='min-h-full bg-gray-50' />
}
return (
<View className='min-h-full bg-gray-50'>
{/* 搜索栏 */}
<View className='bg-white px-3 py-2 flex items-center'>
<View className='flex-1 flex items-center bg-gray-100 rounded-full px-3 h-9'>
<Input
className='flex-1 text-sm text-gray-800'
value={searchInput}
onInput={(e) => setSearchInput(e.detail.value)}
onConfirm={handleSearch}
placeholder='搜索订单号 / 手机号 / 昵称'
confirmType='search'
/>
{searchInput ? (
<View
className='ml-2 w-5 h-5 flex items-center justify-center'
onClick={handleClearSearch}
>
<Text className='text-gray-400 text-base leading-none'>×</Text>
</View>
) : null}
</View>
<View className='ml-1 px-2 py-1' onClick={handleSearch}>
<Text className='text-sm text-green-500 font-medium'></Text>
</View>
</View>
{/* Tab 栏 */}
<View className='bg-white flex'>
{TABS.map(tab => (
@@ -475,8 +798,8 @@ export default function StoreOrdersPage() {
className={`text-sm ${activeTab === tab.key ? 'text-green-500 font-medium' : 'text-gray-500'}`}>
{tab.label}
</Text>
{/* 新订单角标:在"全部"Tab 上显示 */}
{tab.key === 'all' && newOrderCount > 0 && (
{/* 新订单角标:在"待处理"Tab 上显示 */}
{tab.key === 'pending' && newOrderCount > 0 && (
<View className='absolute -top-0.5 right-2 min-w-[18px] h-[18px] rounded-full bg-red-500 flex items-center justify-center px-1'>
<Text className='text-white text-[10px] font-bold'>
{newOrderCount > 99 ? '99+' : newOrderCount}
@@ -490,7 +813,7 @@ export default function StoreOrdersPage() {
{/* 订单列表 */}
<ScrollView
scrollY
style={{height: 'calc(100vh - 50px)'}}
style={{height: 'calc(100vh - 100px)'}}
onScrollToLower={handleLoadMore}
lowerThreshold={100}
>
@@ -500,7 +823,9 @@ export default function StoreOrdersPage() {
</View>
) : orderList.length === 0 ? (
<View className='flex justify-center items-center py-20'>
<Text className='text-gray-400'></Text>
<Text className='text-gray-400'>
{searchKeyword ? `未找到与“${searchKeyword}”相关的订单` : '暂无订单'}
</Text>
</View>
) : (
orderList.map(renderOrderCard)
@@ -543,31 +868,57 @@ export default function StoreOrdersPage() {
</View>
)}
{/* 凭证上传 */}
<Text className='text-sm text-gray-600 mb-3 block'>
{opType === 'complete' ? '必填配送货物到达后拍照上传最多3张' : '选填最多3张'}
</Text>
<View className='flex flex-wrap gap-3 mb-6'>
{proofImages.map((url, idx) => (
<View key={idx} className='relative'>
<Image className='w-20 h-20 rounded-lg bg-gray-50' src={getCompressedImageUrl(url)} mode='aspectFill'/>
<View
className='absolute -top-2 -right-2 w-5 h-5 bg-red-500 rounded-full flex items-center justify-center'
onClick={() => removeProofImage(idx)}
>
<Text className='text-white text-xs'>×</Text>
</View>
{/* 凭证上传(确认完成不需要凭证) */}
{opType !== 'complete' && (
<>
<Text className='text-sm text-gray-600 mb-3 block'>
{opType === 'confirmPay'
? '上传支付凭证(必填,如微信转账截图)'
: opType === 'deliver'
? '上传送达凭证必填配送货物到达后拍照上传最多3张'
: '上传凭证照片选填最多3张'}
</Text>
<View className='flex flex-wrap gap-3 mb-4'>
{proofImages.map((url, idx) => (
<View key={idx} className='relative'>
<Image className='w-20 h-20 rounded-lg bg-gray-50' src={getCompressedImageUrl(url)} mode='aspectFill'/>
<View
className='absolute -top-2 -right-2 w-5 h-5 bg-red-500 rounded-full flex items-center justify-center'
onClick={() => removeProofImage(idx)}
>
<Text className='text-white text-xs'>×</Text>
</View>
</View>
))}
{proofImages.length < (opType === 'confirmPay' ? 1 : 3) && (
<View
className='w-20 h-20 rounded-lg bg-gray-50 border-2 border-dashed border-gray-200 flex items-center justify-center'
onClick={chooseProofImage}
>
<Text className='text-2xl text-gray-300'>+</Text>
</View>
)}
</View>
))}
{proofImages.length < 3 && (
<View
className='w-20 h-20 rounded-lg bg-gray-50 border-2 border-dashed border-gray-200 flex items-center justify-center'
onClick={chooseProofImage}
>
<Text className='text-2xl text-gray-300'>+</Text>
</View>
)}
</View>
</>
)}
{/* 备注(仅确认收款时显示) */}
{opType === 'confirmPay' && (
<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={payRemarks}
onInput={(e) => setPayRemarks(e.detail.value)}
placeholder='可填写备注(如:微信转账已收到)'
maxlength={200}
/>
</View>
)}
{/* 确认完成不需要备注时补充间距 */}
{opType !== 'confirmPay' && <View className='mb-2'/>}
{/* 操作按钮 */}
<View className='flex gap-3'>
@@ -590,6 +941,147 @@ export default function StoreOrdersPage() {
</View>
</View>
)}
{/* 发货弹窗:选择发货人员 */}
{showShipModal && currentOrder && (
<View className='fixed inset-0 z-50 flex items-end justify-center'>
<View className='absolute inset-0 bg-black/50' onClick={() => setShowShipModal(false)}/>
<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='max-h-80 overflow-y-auto mb-5'>
{loadingClerks ? (
<View className='py-8 flex justify-center'>
<Text className='text-sm text-gray-400'>...</Text>
</View>
) : clerkList.length === 0 ? (
<View className='py-8 flex justify-center'>
<Text className='text-sm text-gray-400'></Text>
</View>
) : (
clerkList.map(clerk => {
const selected = clerk.id === selectedClerkId
return (
<View
key={clerk.id}
className={`flex items-center justify-between px-4 py-3 rounded-xl mb-2 border ${
selected ? 'border-purple-500 bg-purple-50' : 'border-gray-200'
}`}
onClick={() => setSelectedClerkId(clerk.id ?? null)}
>
<View className='flex-1'>
<Text className='text-sm text-gray-800'>
{clerk.name}
<Text className='text-xs text-gray-400 ml-2'>
{clerk.roleType === 1 ? '经理' : '店员'}
</Text>
</Text>
<Text className='text-xs text-gray-500 mt-0.5 block'>{clerk.phone}</Text>
</View>
{selected && (
<Text className='text-purple-500 text-sm'></Text>
)}
</View>
)
})
)}
</View>
{/* 操作按钮 */}
<View className='flex gap-3'>
<View className='flex-1 py-3 rounded-xl bg-gray-100 text-center' onClick={() => setShowShipModal(false)}>
<Text className='text-sm text-gray-600'></Text>
</View>
<View
className={`flex-1 py-3 rounded-xl text-center flex items-center justify-center ${
selectedClerkId != null ? 'bg-purple-500' : 'bg-gray-300'
}`}
onClick={selectedClerkId != null && !shipping ? handleConfirmShip : undefined}
>
{shipping ? (
<Text className='text-sm text-white'>...</Text>
) : (
<Text className='text-sm text-white'></Text>
)}
</View>
</View>
</View>
</View>
)}
{/* 改价弹窗 */}
{showEditPriceModal && editPriceOrder && (
<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'>{editPriceOrder.orderNo}</Text>
<Text className='text-sm text-gray-700 mt-1 block'>
<Text className='text-red-500 font-medium'>¥{editPriceOrder.payPrice || editPriceOrder.totalPrice}</Text>
</Text>
</View>
{/* 新金额输入 */}
<View className='mb-6'>
<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={editPriceValue}
onInput={(e) => setEditPriceValue(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={editPriceRemarks}
onInput={(e) => setEditPriceRemarks(e.detail.value)}
placeholder='如:客户协商优惠、多收退款等'
maxlength={200}
/>
</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={editingPrice ? undefined : submitEditPrice}
>
{editingPrice ? (
<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 py-1 rounded text-xs'
style={{
color: isDisabled ? '#dc2626' : '#16a34a',
background: isDisabled ? '#fef2f2' : '#f0fdf4',
}}
>
{isDisabled ? '已禁用' : '正常'}
</View>
</View>
<Text className='text-xs text-gray-400 mt-1 block'>
{user.phone || user.mobile || '未绑定手机'}
</Text>
</View>
{/* 禁用/启用按钮 */}
<View
className={`px-3 py-1 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-xs 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-xs 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-xs 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'>
<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 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-1 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
@@ -289,11 +313,35 @@ const AddressEditPage: React.FC = () => {
return
}
// 3. 仅在有有效经纬度时传入初始化参数,避免 undefined 触发部分机型失败
const initLat = selectedLocation?.lat ? Number(selectedLocation.lat) : undefined
const initLng = selectedLocation?.lng ? Number(selectedLocation.lng) : undefined
const latitude = typeof initLat === 'number' && Number.isFinite(initLat) ? initLat : undefined
const longitude = typeof initLng === 'number' && Number.isFinite(initLng) ? initLng : undefined
// 3. 优先使用已有经纬度;没有则先调 getLocation 获取当前位置
// 解决鸿蒙等机型首次打开地图时 POI 列表不显示的问题
let latitude: number | undefined
let longitude: number | undefined
if (selectedLocation?.lat && selectedLocation?.lng) {
const initLat = Number(selectedLocation.lat)
const initLng = Number(selectedLocation.lng)
if (Number.isFinite(initLat) && Number.isFinite(initLng)) {
latitude = initLat
longitude = initLng
}
}
// 没有已有定位时,主动获取当前位置传给地图,确保地图打开就在用户位置附近
// 这样地图的 POI 列表能立即加载,不需要用户手动拖动
if (latitude === undefined || longitude === undefined) {
try {
const loc: any = await Taro.getLocation({ type: 'gcj02' })
if (loc && Number.isFinite(loc.latitude) && Number.isFinite(loc.longitude)) {
latitude = loc.latitude
longitude = loc.longitude
}
} catch (locErr) {
// getLocation 失败不阻断流程chooseLocation 仍可打开(只是默认定位到北京)
console.warn('getLocation 失败,地图将以默认位置打开:', locErr)
}
}
const params: any = {}
if (typeof latitude === 'number') params.latitude = latitude
if (typeof longitude === 'number') params.longitude = longitude
@@ -384,10 +432,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,12 +493,29 @@ const AddressEditPage: React.FC = () => {
// 编辑模式:加载已有地址数据
if (isEditMode && addressId) {
try {
const addr = await getShopUserAddress(addressId)
setFormData(addr)
setRegionText(`${addr.province || ''} ${addr.city || ''} ${addr.region || ''}`)
if (hasValidLngLat(addr)) {
setSelectedLocation({lng: String(addr.lng), lat: String(addr.lat)})
setRegionLocked(true)
const addr = (await getShopUserAddress(addressId)) as ShopUserAddress
if (addr) {
// 合并到 formData保证初始字段不被 null 覆盖为 undefined
setFormData(prev => ({
...prev,
...addr,
name: addr.name || prev.name || '',
phone: addr.phone || prev.phone || '',
address: addr.address || prev.address || '',
province: addr.province || prev.province || '',
city: addr.city || prev.city || '',
region: addr.region || prev.region || '',
country: addr.country || prev.country || '中国',
isDefault: addr.isDefault ?? false,
}))
const p = String(addr?.province || '').trim()
const c = String(addr?.city || '').trim()
const r = String(addr?.region || '').trim()
setRegionText([p, c, r].filter(Boolean).join(' '))
if (hasValidLngLat(addr)) {
setSelectedLocation({lng: String((addr as any).lng), lat: String((addr as any).lat)})
setRegionLocked(true)
}
}
} catch (error) {
console.error('加载地址失败:', error)
@@ -465,7 +543,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 +553,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

@@ -1,229 +1,207 @@
import React, { useState, useEffect } from 'react'
import { View, Text, Image, Input, Button as TaroButton } from '@tarojs/components'
import React, {useState, useEffect} from 'react'
import {View, Text, Image, Input, Button as TaroButton} from '@tarojs/components'
import Taro from '@tarojs/taro'
import BottomButton from '@/components/common/BottomButton'
import { useUser } from '@/hooks/useUser'
import { updateUser } from '@/api/system/user'
import type { User } from '@/api/system/user/model'
import { TenantId } from '@/config/app'
import {useUser} from '@/hooks/useUser'
import {updateUser} from '@/api/system/user'
import type {User} from '@/api/system/user/model'
import {TenantId} from '@/config/app'
definePageConfig({
navigationBarTitleText: '个人信息',
navigationBarTitleText: '个人信息',
})
const genderOptions = ['保密', '男', '女']
const ProfilePage: React.FC = () => {
const { user, refreshUser } = useUser()
const [form, setForm] = useState<Partial<User>>({})
const [loading, setLoading] = useState(false)
const [showGenderSheet, setShowGenderSheet] = useState(false)
const {user, refreshUser} = useUser()
const [form, setForm] = useState<Partial<User>>({})
const [loading, setLoading] = useState(false)
const [showGenderSheet, setShowGenderSheet] = useState(false)
useEffect(() => {
if (user) {
setForm({
userId: (user as any)?.userId || (user as any)?.id,
nickname: (user as any)?.nickname || '',
avatar: (user as any)?.avatar || '',
gender: (user as any)?.gender ?? 0,
phone: (user as any)?.phone || '',
merchantName: (user as any)?.merchantName || '',
address: (user as any)?.address || '',
})
}
}, [user])
// 微信头像选择回调open-type="chooseAvatar"
const handleWechatAvatar = (e: any) => {
const { avatarUrl } = e.detail
if (!avatarUrl) return
Taro.showLoading({ title: '上传中...' })
Taro.uploadFile({
url: 'https://server.websoft.top/api/oss/upload',
filePath: avatarUrl,
name: 'file',
header: {
'content-type': 'application/json',
TenantId,
},
success: (uploadRes) => {
const data = JSON.parse(uploadRes.data)
if (data.code === 0 && data.data?.url) {
setForm(prev => ({ ...prev, avatar: data.data.url }))
Taro.showToast({ title: '上传成功', icon: 'success' })
} else {
Taro.showToast({ title: data.message || '上传失败', icon: 'none' })
useEffect(() => {
if (user) {
setForm({
userId: (user as any)?.userId || (user as any)?.id,
nickname: (user as any)?.nickname || '',
avatar: (user as any)?.avatar || '',
gender: (user as any)?.gender ?? 0,
phone: (user as any)?.phone || '',
merchantName: (user as any)?.merchantName || '',
address: (user as any)?.address || '',
})
}
},
fail: () => {
Taro.showToast({ title: '上传失败', icon: 'none' })
},
complete: () => {
Taro.hideLoading()
},
})
}
}, [user])
// 选择性别
const handleGenderSelect = (index: number) => {
setForm(prev => ({ ...prev, gender: index }))
setShowGenderSheet(false)
}
// 保存
const handleSave = async () => {
if (!form.nickname?.trim()) {
Taro.showToast({ title: '昵称不能为空', icon: 'none' })
return
// 微信头像选择回调open-type="chooseAvatar"
const handleWechatAvatar = (e: any) => {
const {avatarUrl} = e.detail
if (!avatarUrl) return
Taro.showLoading({title: '上传中...'})
Taro.uploadFile({
url: 'https://server.websoft.top/api/oss/upload',
filePath: avatarUrl,
name: 'file',
header: {
'content-type': 'application/json',
TenantId,
},
success: (uploadRes) => {
const data = JSON.parse(uploadRes.data)
if (data.code === 0 && data.data?.url) {
setForm(prev => ({...prev, avatar: data.data.url}))
Taro.showToast({title: '上传成功', icon: 'success'})
} else {
Taro.showToast({title: data.message || '上传失败', icon: 'none'})
}
},
fail: () => {
Taro.showToast({title: '上传失败', icon: 'none'})
},
complete: () => {
Taro.hideLoading()
},
})
}
setLoading(true)
try {
await updateUser(form as User)
await refreshUser()
Taro.showToast({ title: '保存成功', icon: 'success' })
} catch (error) {
console.error('保存失败:', error)
Taro.showToast({ title: '保存失败', icon: 'none' })
} finally {
setLoading(false)
// 选择性别
const handleGenderSelect = (index: number) => {
setForm(prev => ({...prev, gender: index}))
setShowGenderSheet(false)
}
}
const displayId = (user as any)?.userId || (user as any)?.id || ''
const registerTime = (user as any)?.createTime || (user as any)?.createdAt || ''
// 保存
const handleSave = async () => {
if (!form.nickname?.trim()) {
Taro.showToast({title: '昵称不能为空', icon: 'none'})
return
}
setLoading(true)
try {
await updateUser(form as User)
await refreshUser()
Taro.showToast({title: '保存成功', icon: 'success'})
} catch (error) {
console.error('保存失败:', error)
Taro.showToast({title: '保存失败', icon: 'none'})
} finally {
setLoading(false)
}
}
return (
<View className='min-h-screen bg-gray-50 pb-32'>
{/* 头像 - 使用 Taro 原生 Button 支持 open-type="chooseAvatar" */}
<View className='flex items-center justify-between px-4 py-3 bg-white border-b border-gray-50'>
<Text className='text-sm text-gray-700 w-20'></Text>
<View className='flex items-center gap-2'>
<TaroButton
openType='chooseAvatar'
onChooseAvatar={handleWechatAvatar}
plain
style={{
padding: 0,
margin: 0,
background: 'transparent',
border: 'none',
lineHeight: '40px',
width: 'auto',
minHeight: 0,
}}
>
{form.avatar ? (
<Image className='w-10 h-10 rounded-full' src={form.avatar} mode='aspectFill' />
) : (
<View className='w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center'>
<Text className='text-gray-300 text-xs'></Text>
</View>
const displayId = (user as any)?.userId || (user as any)?.id || ''
const registerTime = (user as any)?.createTime || (user as any)?.createdAt || ''
return (
<View className='min-h-screen bg-gray-50 pb-32'>
{/* 用户ID */}
<View className='flex items-center justify-between px-4 py-3 bg-white border-b border-gray-50'>
<Text className='text-sm text-gray-700 w-20'>ID</Text>
<Text className='text-sm text-gray-600'>{displayId || '未设置'}</Text>
</View>
{/* 头像 - 使用 Taro 原生 Button 支持 open-type="chooseAvatar" */}
<View className='flex items-center justify-between px-4 py-3 bg-white border-b border-gray-50'>
<Text className='text-sm text-gray-700 w-20'></Text>
<View className='flex items-center gap-2'>
<TaroButton
openType='chooseAvatar'
onChooseAvatar={handleWechatAvatar}
plain
style={{
padding: 0,
margin: 0,
background: 'transparent',
border: 'none',
lineHeight: '40px',
width: 'auto',
minHeight: 0,
}}
>
{form.avatar ? (
<Image className='w-10 h-10 rounded-full' src={form.avatar} mode='aspectFill'/>
) : (
<View className='w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center'>
<Text className='text-gray-300 text-xs'></Text>
</View>
)}
</TaroButton>
<Text className='text-gray-300 text-sm'>{'>'}</Text>
</View>
</View>
{/* 昵称 - 支持获取微信昵称 */}
<View className='flex items-center justify-between px-4 py-3 bg-white border-b border-gray-50'>
<Text className='text-sm text-gray-700 w-20'></Text>
<Input
className='flex-1 text-right text-sm text-gray-600'
placeholder='请输入昵称'
value={form.nickname || ''}
onInput={(e: any) => setForm(prev => ({...prev, nickname: e.detail.value}))}
maxlength={20}
type='nickname'
/>
</View>
{/* 性别 */}
<View className='flex items-center justify-between px-4 py-3 bg-white border-b border-gray-50'
onClick={() => setShowGenderSheet(true)}>
<Text className='text-sm text-gray-700 w-20'></Text>
<View className='flex items-center gap-2'>
<Text className='text-sm text-gray-600'>{genderOptions[form.gender ?? 0]}</Text>
<Text className='text-gray-300 text-sm'>{'>'}</Text>
</View>
</View>
{/* 手机号 - 不可编辑 */}
<View className='flex items-center justify-between px-4 py-3 bg-white border-b border-gray-50'>
<Text className='text-sm text-gray-700 w-20'></Text>
<Text className='text-sm text-gray-600'>{form.phone || '未绑定'}</Text>
</View>
{/* 注册时间 */}
<View className='flex items-center justify-between px-4 py-3 bg-white border-b border-gray-50'>
<Text className='text-sm text-gray-700 w-20'></Text>
<Text className='text-sm text-gray-600'>{registerTime || '未知'}</Text>
</View>
{/* 保存按钮 */}
<BottomButton
text='保存'
onClick={handleSave}
loading={loading}
disabled={loading}
/>
{/* 性别选择弹窗 */}
{showGenderSheet && (
<View className='fixed inset-0 z-50'>
<View className='absolute inset-0 bg-black bg-opacity-50'
onClick={() => setShowGenderSheet(false)}/>
<View className='absolute bottom-0 left-0 right-0 bg-white rounded-t-xl'>
<View className='px-4 py-3 border-b border-gray-50'>
<Text className='text-base font-medium text-gray-800'></Text>
</View>
{genderOptions.map((label, index) => (
<View
key={index}
className='px-4 py-3 border-b border-gray-50 flex items-center justify-between'
onClick={() => handleGenderSelect(index)}
>
<Text
className={`text-sm ${(form.gender ?? 0) === index ? 'text-green-600 font-medium' : 'text-gray-700'}`}>
{label}
</Text>
{(form.gender ?? 0) === index && <Text className='text-green-600'></Text>}
</View>
))}
<View className='px-4 py-3' onClick={() => setShowGenderSheet(false)}>
<Text className='text-sm text-gray-400 text-center block'></Text>
</View>
</View>
</View>
)}
</TaroButton>
<Text className='text-gray-300 text-sm'>{'>'}</Text>
</View>
</View>
{/* 昵称 - 支持获取微信昵称 */}
<View className='flex items-center justify-between px-4 py-3 bg-white border-b border-gray-50'>
<Text className='text-sm text-gray-700 w-20'></Text>
<Input
className='flex-1 text-right text-sm text-gray-600'
placeholder='请输入昵称'
value={form.nickname || ''}
onInput={(e: any) => setForm(prev => ({ ...prev, nickname: e.detail.value }))}
maxlength={20}
type='nickname'
/>
</View>
{/* 性别 */}
<View className='flex items-center justify-between px-4 py-3 bg-white border-b border-gray-50' onClick={() => setShowGenderSheet(true)}>
<Text className='text-sm text-gray-700 w-20'></Text>
<View className='flex items-center gap-2'>
<Text className='text-sm text-gray-600'>{genderOptions[form.gender ?? 0]}</Text>
<Text className='text-gray-300 text-sm'>{'>'}</Text>
</View>
</View>
{/* 手机号 - 不可编辑 */}
<View className='flex items-center justify-between px-4 py-3 bg-white border-b border-gray-50'>
<Text className='text-sm text-gray-700 w-20'></Text>
<Text className='text-sm text-gray-600'>{form.phone || '未绑定'}</Text>
</View>
{/* 用户ID */}
<View className='flex items-center justify-between px-4 py-3 bg-white border-b border-gray-50'>
<Text className='text-sm text-gray-700 w-20'>ID</Text>
<Text className='text-sm text-gray-600'>{displayId || '未设置'}</Text>
</View>
{/* 注册时间 */}
<View className='flex items-center justify-between px-4 py-3 bg-white border-b border-gray-50'>
<Text className='text-sm text-gray-700 w-20'></Text>
<Text className='text-sm text-gray-600'>{registerTime || '未知'}</Text>
</View>
{/* 门店名称 */}
<View className='flex items-center justify-between px-4 py-3 bg-white border-b border-gray-50'>
<Text className='text-sm text-gray-700 w-20'></Text>
<Input
className='flex-1 text-right text-sm text-gray-600'
placeholder='请输入门店名称'
value={(form as any)?.merchantName || ''}
onInput={(e: any) => setForm(prev => ({ ...prev, merchantName: e.detail.value }))}
maxlength={50}
/>
</View>
{/* 门店地址 */}
<View className='flex items-center justify-between px-4 py-3 bg-white border-b border-gray-50'>
<Text className='text-sm text-gray-700 w-20'></Text>
<Input
className='flex-1 text-right text-sm text-gray-600'
placeholder='请输入门店地址'
value={(form as any)?.address || ''}
onInput={(e: any) => setForm(prev => ({ ...prev, address: e.detail.value }))}
maxlength={100}
/>
</View>
{/* 保存按钮 */}
<BottomButton
text='保存'
onClick={handleSave}
loading={loading}
disabled={loading}
/>
{/* 性别选择弹窗 */}
{showGenderSheet && (
<View className='fixed inset-0 z-50'>
<View className='absolute inset-0 bg-black bg-opacity-50' onClick={() => setShowGenderSheet(false)} />
<View className='absolute bottom-0 left-0 right-0 bg-white rounded-t-xl'>
<View className='px-4 py-3 border-b border-gray-50'>
<Text className='text-base font-medium text-gray-800'></Text>
</View>
{genderOptions.map((label, index) => (
<View
key={index}
className='px-4 py-3 border-b border-gray-50 flex items-center justify-between'
onClick={() => handleGenderSelect(index)}
>
<Text className={`text-sm ${(form.gender ?? 0) === index ? 'text-green-600 font-medium' : 'text-gray-700'}`}>
{label}
</Text>
{(form.gender ?? 0) === index && <Text className='text-green-600'></Text>}
</View>
))}
<View className='px-4 py-3' onClick={() => setShowGenderSheet(false)}>
<Text className='text-sm text-gray-400 text-center block'></Text>
</View>
</View>
</View>
)}
</View>
)
)
}
export default ProfilePage

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 时组件内部不渲染 */}
{item.tabIndex != 4 && <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

@@ -11,7 +11,7 @@ const TERMS_CONTENT = `
<p><strong>最后更新日期2026年5月16日</strong></p>
<h3>一、协议的范围与接受</h3>
<p>欢迎使用鑫龙家电(以下简称"本小程序")。本小程序由南宁市网宿信息科技有限公司(以下简称"我们")开发并运营。请您在使用本小程序服务之前,仔细阅读并充分理解本协议的全部内容。您点击"同意"或实际使用本小程序服务,即视为您已阅读、理解并同意接受本协议的约束。</p>
<p>欢迎使用鑫龙家电(以下简称"本小程序")。本小程序由鑫龙家电(以下简称"我们")开发并运营。请您在使用本小程序服务之前,仔细阅读并充分理解本协议的全部内容。您点击"同意"或实际使用本小程序服务,即视为您已阅读、理解并同意接受本协议的约束。</p>
<h3>二、服务内容</h3>
<p>本小程序为用户提供以下服务:</p>
@@ -54,8 +54,8 @@ const TERMS_CONTENT = `
<h3>九、联系我们</h3>
<p>如您对本协议有任何疑问,请联系:</p>
<p>客服电话:0771-5386339</p>
<p>客服邮箱support@paopao.com</p>
<p>客服电话:18269229683</p>
<p>客服邮箱support@163.com</p>
`
/** 隐私政策内容 */
@@ -63,7 +63,7 @@ const PRIVACY_CONTENT = `
<h2>隐私政策</h2>
<p><strong>最后更新日期2026年5月16日</strong></p>
<p>南宁市网宿信息科技有限公司(以下简称"我们")非常重视您的个人信息保护。本隐私政策说明我们如何收集、使用、存储和保护您的个人信息。</p>
<p>鑫龙家电(以下简称"我们")非常重视您的个人信息保护。本隐私政策说明我们如何收集、使用、存储和保护您的个人信息。</p>
<h3>一、我们收集的信息</h3>
<p>为了向您提供服务,我们可能需要收集以下信息:</p>
@@ -124,8 +124,8 @@ const PRIVACY_CONTENT = `
<h3>八、联系我们</h3>
<p>如您对本隐私政策有任何疑问,请联系:</p>
<p>客服电话:0771-5386339</p>
<p>客服邮箱privacy@paopao.com</p>
<p>客服电话:18269229683</p>
<p>客服邮箱privacy@163.com</p>
`
const Agreement = () => {

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,18 @@
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 { useUserContext } from '@/contexts/UserContext'
import request from '@/utils/request'
import {
getPhoneAuthCooldownRemaining,
isPhoneAuthCoolingDown,
markPhoneAuthCalled,
} from '@/utils/phoneAuth'
import {
checkAndHandleInviteRelation,
hasPendingInvite,
@@ -74,15 +82,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 +106,90 @@ 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 { loginUser } = useUserContext()
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 +225,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 +238,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 +306,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,7 +335,21 @@ const Login = () => {
user = freshUserInfo
}
// 检查用户是否被禁用
if (isUserDisabled(user)) {
Taro.showModal({
title: '账号已被禁用',
content: '您的账号已被管理员禁用,暂时无法使用小程序。如有疑问请联系门店客服。',
showCancel: false,
confirmText: '我知道了',
confirmColor: '#ee0a24',
})
return
}
saveStorageByLoginUser(token, user)
// 同步更新 UserContext 状态,确保后续页面 isLoggedIn 为 true
loginUser(token, user)
// 绑定 openid + 处理邀请关系
await ensureWxOpenIdSaved()
@@ -212,12 +360,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 +407,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,8 @@ 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 { useUserContext } from '@/contexts/UserContext'
import {
getStoredInviteParams,
parseInviteParams,
@@ -88,15 +90,16 @@ 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'
)
}
const Register = () => {
const [isAgree, setIsAgree] = useState(false)
const [loading, setLoading] = useState(false)
const { loginUser } = useUserContext()
// 短信验证码登录仅在非微信小程序端展示
const isWeapp = useMemo(() => {
@@ -109,10 +112,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 +148,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 +179,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,7 +223,21 @@ const Register = () => {
return
}
// 检查用户是否被禁用
if (isUserDisabled(user)) {
Taro.showModal({
title: '账号已被禁用',
content: '您的账号已被管理员禁用,暂时无法使用小程序。如有疑问请联系门店客服。',
showCancel: false,
confirmText: '我知道了',
confirmColor: '#ee0a24',
})
return
}
saveStorageByLoginUser(token, user)
// 同步更新 UserContext 状态,确保后续页面 isLoggedIn 为 true
loginUser(token, user)
// 注册/登录成功后,立即补齐 openidJSAPI 支付必需)
try {
@@ -268,11 +303,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,15 +1,17 @@
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 { useUserContext } from '@/contexts/UserContext'
import './sms-login.scss'
const SmsLogin = () => {
const [loading, setLoading] = useState<boolean>(false)
const [sendingCode, setSendingCode] = useState<boolean>(false)
const [countdown, setCountdown] = useState<number>(0)
const { syncFromStorage } = useUserContext()
const [formData, setFormData] = useState<LoginParam>({
phone: '',
code: ''
@@ -37,9 +39,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 +57,8 @@ const SmsLogin = () => {
await Taro.redirectTo({ url: redirectUrl })
}
const reload = () => {
// sms-login 页面不是 tabBar 页面,无需调用 hideTabBar
}
useEffect(() => {
reload()
// sms-login 页面不是 tabBar 页面,无需调用 hideTabBar
}, [])
// 如果从分享/二维码链接进入短信登录页,先暂存邀请信息
@@ -78,7 +76,7 @@ const SmsLogin = () => {
// 倒计时效果
useEffect(() => {
let timer: NodeJS.Timeout
let timer: ReturnType<typeof setTimeout> | undefined
if (countdown > 0) {
timer = setTimeout(() => {
setCountdown(countdown - 1)
@@ -186,6 +184,9 @@ const SmsLogin = () => {
code: formData.code
})
// loginBySms 内部已调 saveStorageByLoginUser这里同步 UserContext 状态
syncFromStorage()
// 登录成功后(可能是新注册用户),检查是否存在待处理的邀请关系并尝试绑定
if (hasPendingInvite()) {
try {
@@ -218,52 +219,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)
},
}