Compare commits

32 Commits

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
43 changed files with 2963 additions and 1950 deletions

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

@@ -50,3 +50,17 @@
- 说明:登录时若用户尚未同意隐私协议,微信仍会在点击 `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

@@ -6,6 +6,8 @@
- 后端 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驳回

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()

View File

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

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

@@ -194,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

@@ -183,6 +183,8 @@ export interface ShopOrder {
buyerRemarks?: string;
// 商户备注(门店修改金额原因等)
merchantRemarks?: string;
// 线下付款支付凭证图片URL
paymentVoucher?: string;
// 排序号
sortNumber?: number;
// 是否删除, 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,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

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

View File

@@ -7,8 +7,6 @@ import AppContext from './contexts/AppContext'
import { UserProvider } from './contexts/UserContext'
import { CartProvider } from './contexts/CartContext'
import ErrorBoundary from './components/ErrorBoundary'
import PrivacyModal from './components/PrivacyModal'
import { privacyManager } from './utils/privacy'
import './app.scss'
type AppProps = {
@@ -24,25 +22,6 @@ function App(props: AppProps) {
if (savedTheme === 'light' || savedTheme === 'dark') {
setTheme(savedTheme)
}
// 微信隐私协议(基础库 3.16.1+ 强制)
// 必须使用 open-type="agreePrivacyAuthorization" 的 Button 让用户点击同意,
// Taro.showModal 的按钮无法被微信识别为隐私授权,会导致后续敏感 API 仍被拒绝。
const wxAny: any = Taro
if (typeof wxAny.onNeedPrivacyAuthorization === 'function') {
wxAny.onNeedPrivacyAuthorization((resolve: any) => {
wxAny.getPrivacySetting({
success: (setting: any) => {
privacyManager.setPrivacyContractName(setting?.privacyContractName)
privacyManager.show(resolve)
},
fail: () => {
// 获取设置失败时,默认 resolve 同意,避免流程阻塞
resolve({ event: 'agree', button: 'agree' })
},
})
})
}
})
useDidShow(() => {})
@@ -66,7 +45,6 @@ function App(props: AppProps) {
<View className={theme === 'dark' ? 'dark' : ''}>
{props.children}
</View>
<PrivacyModal />
</ErrorBoundary>
</ConfigProvider>
</CartProvider>

View File

@@ -34,12 +34,14 @@ const PrivacyModal = () => {
</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()}

View File

@@ -25,12 +25,16 @@ const getCardStatus = (order: ShopOrder): { text: string; color: string } => {
// 终态orderStatus=1 必须最先判断,盖过 deliveryStatus/payStatus
if (orderStatus === 1) return { text: '已完成', color: '#0e932e' }
// 线下付款(9)已发货但未确认收款:显示"已发货待收款"(红色),与门店端一致
if (!payStatus && payType === 9 && deliveryStatus === 20) return { text: '待付款,已发货', color: '#ee0a24' }
// 物流状态优先于付款状态(已发货的订单,即使未付款也显示物流状态)
if (deliveryStatus === 20) return { text: '待收货', color: '#4b9cf5' }
// 货到付款(8):下单时后端已 setPayStatus(true),不会走到这里
// 线下付款(9):保持 payStatus=false待商家确认收款应显示"待付款"与 Tab 一致
if (!payStatus && !isCod) return { text: '待付款', color: '#ee0a24' }
if (deliveryStatus === 10) return { text: '待发货', color: '#4b9cf5' }
if (deliveryStatus === 20) return { text: '待收货', color: '#4b9cf5' }
if (deliveryStatus === 30) return { text: '已收货', color: '#0e932e' }
return { text: '已付款', color: '#0e932e' }
}
@@ -137,7 +141,7 @@ const OrderCard: React.FC<OrderCardProps> = ({ order, onClick, onCloseOrder }) =
{/* 底部:日期 + 金额汇总 + 操作按钮 */}
<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'>

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

@@ -25,7 +25,7 @@ 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[]>([])
@@ -68,6 +68,8 @@ const IndexPage: React.FC = () => {
// 页面重新显示时也刷新店员身份(可能从其他页面回来时状态变了)
useDidShow(() => {
// 先从 storage 同步用户状态(解决注册/登录后 UserContext 状态未更新的问题)
syncFromStorage()
if (isLoggedIn) {
getMyClerk()
.then(data => {

View File

@@ -41,6 +41,13 @@ 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
@@ -64,7 +71,17 @@ const getOrderDisplayStatus = (order: ShopOrder): { title: string; bgColor: stri
return { bgColor: '#ee0a24', title: '退款被拒绝', subtitle: '退款申请已被拒绝' }
}
// 正常订单流程
// 线下付款已发货但未确认收款:显示"已发货待收款"(红色),与门店端一致
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: '请尽快完成支付' }
}
@@ -77,9 +94,6 @@ const getOrderDisplayStatus = (order: ShopOrder): { title: string; bgColor: stri
if (deliveryStatus === 10) {
return { bgColor: '#4b9cf5', title: '待发货', subtitle: '商家正在准备商品' }
}
if (deliveryStatus === 20 || deliveryStatus === 30) {
return { bgColor: '#4b9cf5', title: '待收货', subtitle: '商品运输中,请注意查收' }
}
if (orderStatus === OrderStatus.Completed) {
return { bgColor: '#0e932e', title: '已完成', subtitle: '交易已完成,感谢购买' }
}
@@ -104,12 +118,14 @@ const getOrderPhase = (order: ShopOrder): OrderPhase => {
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'
@@ -245,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 (
@@ -274,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)}
@@ -349,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>
@@ -389,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>
@@ -405,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>

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

@@ -18,7 +18,7 @@ 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[]>([])
@@ -34,7 +34,9 @@ const CartPage: React.FC = () => {
// 页面再次显示时刷新购物车数据(从其他页面加购后回到购物车)
useDidShow(() => {
if (isLoggedIn) {
// 先从 storage 同步用户状态(解决注册/登录后 UserContext 状态未更新的问题)
const loggedIn = syncFromStorage()
if (loggedIn) {
refresh()
}
})

View File

@@ -361,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

@@ -37,7 +37,7 @@ 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 可用高度
@@ -64,6 +64,8 @@ const ProductDetailPage: React.FC = () => {
// 页面显示时刷新购物车数量,确保底部购物车角标准确
useDidShow(() => {
// 先从 storage 同步用户状态(解决注册/登录后 UserContext 状态未更新的问题)
syncFromStorage()
if (isLoggedIn) refresh()
})
@@ -199,7 +201,7 @@ const ProductDetailPage: React.FC = () => {
}
const handleGoCart = () => {
Taro.navigateTo({ url: '/pages/shop/cart' })
Taro.switchTab({ url: '/pages/shop/cart' })
}
if (!product) {
@@ -325,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>

View File

@@ -1,254 +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: '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,
},
{
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 && (
<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>
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-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>
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

@@ -1,42 +1,48 @@
import React, {useState, useEffect, useCallback, useRef} from 'react'
import {View, Text, Image, ScrollView, Input, Textarea} from '@tarojs/components'
import Taro, {useDidShow} from '@tarojs/taro'
import {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 } from '@/api/shop/shopStoreUser'
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' | 'editPrice'
type OpType = 'confirmPay' | 'deliver' | 'complete' | 'ship' | 'editPrice'
const OP_LABEL: Record<OpType, string> = {
pay: '收款',
confirmPay: '收款',
deliver: '送达',
complete: '已完成',
editPrice: '金额',
ship: '发货',
editPrice: '改价',
}
const OP_DESC: Record<OpType, string> = {
pay: '变更支付状态为已付款',
complete: '同时变更支付状态为已付款、收货状态为已收货、订单状态为已完成',
confirmPay: '确认已收到线下付款',
deliver: '上传送达凭证照片,标记为已收货(不改变付款和订单完成状态)',
complete: '确认订单已完成,收款后点击此按钮标记订单完成',
ship: '选择发货人员并生成发货单,订单将进入已发货状态',
editPrice: '修改订单实付金额',
}
@@ -78,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 []
@@ -96,20 +113,25 @@ function parseSendEndImg(raw: string): string[] {
function getStatusText(order: ShopOrder): string {
if (order.orderStatus === 1) return '已完成'
if (order.orderStatus === 2) return '已关闭'
// 线下付款·已发货待收款(先发货后结款)
if (order.payType === 9 && order.deliveryStatus === 20) return '已发货待收款'
if (order.payStatus === false || order.payStatus === null) {
// 线下付款待确认收款
if (order.payType === 9) return '待确认收款'
return '待收款'
// 线下付款待确认收款
if (order.payType === 9) return '待确认收款'
return '待收款'
}
if (order.payStatus && order.deliveryStatus === 10) 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'
}
@@ -123,11 +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 }[] = []
// 修改金额(门店权限)
actions.push({label: '修改金额', type: 'editPrice'})
// 货到付款:未付款 / 已付款 都直接"确认完成"
// 确认完成会同时设置 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
@@ -135,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[]>([])
@@ -148,12 +187,26 @@ 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 [editPayPrice, setEditPayPrice] = useState('')
const [editReason, setEditReason] = useState('')
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)
@@ -208,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)
@@ -242,7 +295,7 @@ export default function StoreOrdersPage() {
loadingRef.current = false
setLoading(false)
}
}, [])
}, [searchKeyword])
// 切换 tab 时重新加载
useEffect(() => {
@@ -261,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)
}
@@ -274,62 +344,12 @@ export default function StoreOrdersPage() {
setShowModal(false)
setCurrentOrder(null)
setProofImages([])
}
/** 打开修改金额弹窗 */
const openEditPriceModal = (order: ShopOrder) => {
setCurrentOrder(order)
setEditPayPrice(String(order.payPrice || order.totalPrice || ''))
setEditReason('')
setShowEditPriceModal(true)
}
/** 关闭修改金额弹窗 */
const closeEditPriceModal = () => {
setShowEditPriceModal(false)
setCurrentOrder(null)
setEditPayPrice('')
setEditReason('')
}
/** 提交修改金额 */
const submitEditPrice = async () => {
if (!currentOrder) return
const newPrice = parseFloat(editPayPrice)
if (isNaN(newPrice) || newPrice < 0) {
Taro.showToast({title: '请输入有效的金额', icon: 'none'})
return
}
if (!editReason.trim()) {
Taro.showToast({title: '请输入修改原因', icon: 'none'})
return
}
setSubmitting(true)
try {
const oldPrice = currentOrder.payPrice || currentOrder.totalPrice || '0'
const changeRecord = `【门店修改金额】原实付¥${oldPrice} → 新实付¥${newPrice.toFixed(2)},原因:${editReason.trim()}`
await updateShopOrder({
orderId: currentOrder.orderId,
payPrice: newPrice.toFixed(2),
merchantRemarks: (currentOrder.merchantRemarks || '') + `\n${changeRecord}`,
} as ShopOrder)
Taro.showToast({title: '修改成功', icon: 'success'})
closeEditPriceModal()
loadOrders(activeTab, 1)
} catch (e: any) {
Taro.showToast({title: e.message || '修改失败', icon: 'none'})
} finally {
setSubmitting(false)
}
setPayRemarks('')
}
/** 选择并上传凭证图片 */
const chooseProofImage = async () => {
const maxCount = 3
const maxCount = opType === 'confirmPay' ? 1 : 3
const remaining = maxCount - proofImages.length
if (remaining <= 0) return
@@ -369,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) // 刷新列表
@@ -410,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>
@@ -468,13 +628,22 @@ export default function StoreOrdersPage() {
</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>
)}
@@ -486,6 +655,22 @@ export default function StoreOrdersPage() {
</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>
)}
{/* 送达凭证(已完成订单) */}
{order.sendEndImg && (() => {
const imgs = parseSendEndImg(order.sendEndImg)
@@ -522,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/>
@@ -541,14 +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={() => act.type === 'editPrice' ? openEditPriceModal(order) : openModal(order, act.type)}
onClick={() => act.type === 'ship'
? openShipModal(order)
: openModal(order, act.type)}
>
<Text className={`text-sm ${
act.type === 'complete' || act.type === 'editPrice' ? '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>
))}
@@ -565,6 +756,31 @@ export default function StoreOrdersPage() {
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 => (
@@ -582,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}
@@ -597,7 +813,7 @@ export default function StoreOrdersPage() {
{/* 订单列表 */}
<ScrollView
scrollY
style={{height: 'calc(100vh - 50px)'}}
style={{height: 'calc(100vh - 100px)'}}
onScrollToLower={handleLoadMore}
lowerThreshold={100}
>
@@ -607,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)
@@ -650,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'>
@@ -698,12 +942,89 @@ export default function StoreOrdersPage() {
</View>
)}
{/* 修改金额弹窗 */}
{showEditPriceModal && currentOrder && (
{/* 发货弹窗:选择发货人员 */}
{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'>
@@ -711,34 +1032,34 @@ export default function StoreOrdersPage() {
{/* 订单信息 */}
<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 block'>{editPriceOrder.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 className='text-red-500 font-medium'>¥{editPriceOrder.payPrice || editPriceOrder.totalPrice}</Text>
</Text>
</View>
{/* 新金额输入 */}
<View className='mb-5'>
<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={editPayPrice}
onInput={(e) => setEditPayPrice(e.detail.value)}
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>
<Text className='text-sm text-gray-600 mb-2 block'></Text>
<Textarea
className='w-full px-4 py-3 rounded-xl border border-gray-200 text-sm text-gray-800'
style={{minHeight: '60px'}}
value={editReason}
onInput={(e) => setEditReason(e.detail.value)}
placeholder='请输入修改原因,如:商品缺货调整、协商降价等'
maxlength={100}
value={editPriceRemarks}
onInput={(e) => setEditPriceRemarks(e.detail.value)}
placeholder='如:客户协商优惠、多收退款等'
maxlength={200}
/>
</View>
@@ -749,12 +1070,12 @@ export default function StoreOrdersPage() {
</View>
<View
className='flex-1 py-3 rounded-xl bg-orange-500 text-center flex items-center justify-center'
onClick={submitting ? undefined : submitEditPrice}
onClick={editingPrice ? undefined : submitEditPrice}
>
{submitting ? (
{editingPrice ? (
<Text className='text-sm text-white'>...</Text>
) : (
<Text className='text-sm text-white'></Text>
<Text className='text-sm text-white'></Text>
)}
</View>
</View>

View File

@@ -183,7 +183,7 @@ export default function StoreUsersPage() {
{displayName}
</Text>
<View
className='px-1.5 py-0.5 rounded text-[10px]'
className='px-1 py-1 rounded text-xs'
style={{
color: isDisabled ? '#dc2626' : '#16a34a',
background: isDisabled ? '#fef2f2' : '#f0fdf4',
@@ -192,14 +192,14 @@ export default function StoreUsersPage() {
{isDisabled ? '已禁用' : '正常'}
</View>
</View>
<Text className='text-xs text-gray-400 mt-0.5 block'>
<Text className='text-xs text-gray-400 mt-1 block'>
{user.phone || user.mobile || '未绑定手机'}
</Text>
</View>
{/* 禁用/启用按钮 */}
<View
className={`px-3 py-1.5 rounded-lg flex-shrink-0 ${isDisabled ? 'bg-green-50' : 'bg-red-50'}`}
className={`px-3 py-1 rounded-lg flex-shrink-0 ${isDisabled ? 'bg-green-50' : 'bg-red-50'}`}
onClick={(e) => {
e.stopPropagation()
handleToggleStatus(user)
@@ -215,17 +215,17 @@ export default function StoreUsersPage() {
<View className='flex items-center gap-4 mt-3 pt-3 border-t border-gray-50'>
{user.memberLevelName && (
<View className='flex items-center gap-1'>
<Text className='text-[10px] text-gray-400'>:</Text>
<Text className='text-xs text-gray-400'>:</Text>
<Text className='text-xs text-purple-500'>{user.memberLevelName}</Text>
</View>
)}
<View className='flex items-center gap-1'>
<Text className='text-[10px] text-gray-400'>:</Text>
<Text className='text-xs text-gray-400'>:</Text>
<Text className='text-xs text-gray-500'>{formatTime(user.createTime)}</Text>
</View>
{user.balance !== undefined && Number(user.balance) > 0 && (
<View className='flex items-center gap-1'>
<Text className='text-[10px] text-gray-400'>:</Text>
<Text className='text-xs text-gray-400'>:</Text>
<Text className='text-xs text-orange-500'>¥{user.balance}</Text>
</View>
)}
@@ -238,7 +238,7 @@ export default function StoreUsersPage() {
<View className='min-h-full bg-gray-50'>
{/* 搜索栏 */}
<View className='bg-white px-3 py-2 flex items-center gap-2'>
<View className='flex-1 flex items-center bg-gray-100 rounded-lg px-3 py-1.5'>
<View className='flex-1 flex items-center bg-gray-100 rounded-lg px-3 py-1'>
<Input
className='flex-1 text-sm'
placeholder='搜索昵称 / 手机号'
@@ -255,7 +255,7 @@ export default function StoreUsersPage() {
) : null}
</View>
<View
className='px-3 py-1.5 rounded-lg bg-blue-500'
className='px-3 py-1 rounded-lg bg-blue-500'
onClick={handleSearch}
>
<Text className='text-sm text-white'></Text>
@@ -342,7 +342,7 @@ export default function StoreUsersPage() {
{detailUser.nickname || detailUser.realName || '未设置昵称'}
</Text>
<View
className='px-2 py-0.5 rounded text-xs'
className='px-2 py-1 rounded text-xs'
style={{
color: detailUser.status === 1 ? '#dc2626' : '#16a34a',
background: detailUser.status === 1 ? '#fef2f2' : '#f0fdf4',

View File

@@ -313,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
@@ -470,14 +494,28 @@ const AddressEditPage: React.FC = () => {
if (isEditMode && addressId) {
try {
const addr = (await getShopUserAddress(addressId)) as ShopUserAddress
setFormData(addr)
const p = String((addr as any)?.province || '').trim()
const c = String((addr as any)?.city || '').trim()
const r = String((addr as any)?.region || '').trim()
setRegionText([p, c, r].filter(Boolean).join(' '))
if (hasValidLngLat(addr)) {
setSelectedLocation({lng: String((addr as any).lng), lat: String((addr as any).lat)})
setRegionLocked(true)
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)

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

@@ -281,7 +281,7 @@ const UserPage: React.FC = () => {
<Text className='text-xl mb-1'>{item.icon}</Text>
<Text className='text-xs text-gray-600'>{item.label}</Text>
{/* 数量角标count 为 0 时组件内部不渲染 */}
<Badge count={item.count} className='absolute -top-1 -right-1' />
{item.tabIndex != 4 && <Badge count={item.count} className='absolute -top-1 -right-1' />}
</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

@@ -1,4 +1,4 @@
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'
@@ -6,7 +6,13 @@ import { getWxOpenId } from '@/api/layout'
import { getUserInfo } from '@/api/layout'
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,
@@ -73,21 +79,22 @@ async function ensureWxOpenIdSaved() {
}
}
/** 获取用户信息(使用临时 token避免本地 storage 尚未写入) */
/** 获取用户信息 */
async function fetchUserInfo(token: string) {
try {
const res: any = await request.get(
`${SERVER_API_URL}/auth/user`,
{},
{
header: {
Authorization: `Bearer ${token}`,
},
showError: false,
}
)
if (res?.code === 0 && res?.data) {
return res.data
const res: any = await Taro.request({
url: `${SERVER_API_URL}/auth/user`,
method: 'GET',
header: {
Authorization: token,
'content-type': 'application/json',
TenantId: String(TenantId),
},
timeout: 15000,
})
if (res.data?.code === 0 && res.data?.data) {
return res.data.data
}
return null
} catch (e) {
@@ -99,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
@@ -197,14 +278,24 @@ const Login = () => {
/** 手机号快捷登录 */
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'))) {
phoneAuthInFlightRef.current = false
showPhoneAuthFailedModal(errMsg)
return
}
@@ -219,12 +310,19 @@ const Login = () => {
`${SERVER_API_URL}/wx-login/loginByMpWxPhone`,
{
code: phoneCode,
encryptedData,
iv,
notVerifyPhone: true,
refereeId,
sceneType: 'save_referee',
tenantId: Number(TenantId),
},
{ showError: false }
{
timeout: 20000,
retry: 0,
showError: false,
returnRaw: true,
}
)
if (res?.code === 0 && res?.data?.access_token) {
@@ -250,6 +348,8 @@ const Login = () => {
}
saveStorageByLoginUser(token, user)
// 同步更新 UserContext 状态,确保后续页面 isLoggedIn 为 true
loginUser(token, user)
// 绑定 openid + 处理邀请关系
await ensureWxOpenIdSaved()
@@ -266,6 +366,8 @@ const Login = () => {
console.error('微信登录失败:', e)
Taro.showToast({ title: e?.message || '登录失败', icon: 'none' })
} finally {
phoneAuthInFlightRef.current = false
clearPhoneAuthPending()
setLoading(false)
}
}
@@ -305,14 +407,15 @@ 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 File

@@ -4,9 +4,9 @@ import { View, Text } from '@tarojs/components'
import { Button, Checkbox } from '@nutui/nutui-react-taro'
import { TenantId } from '@/config/app'
import { getUserInfo, getWxOpenId } from '@/api/layout'
import { saveStorageByLoginUser, SERVER_API_URL } from '@/utils/server'
import { saveStorageByLoginUser } from '@/utils/server'
import { isUserDisabled } from '@/utils/auth'
import request from '@/utils/request'
import { useUserContext } from '@/contexts/UserContext'
import {
getStoredInviteParams,
parseInviteParams,
@@ -26,6 +26,17 @@ interface GetPhoneNumberEvent {
detail: GetPhoneNumberDetail
}
interface LoginResponse {
data: {
code?: number
message?: string
data?: {
access_token: string
user: any
}
}
}
async function getWeappLoginCode(): Promise<string | undefined> {
try {
const res = await new Promise<{ code?: string }>((resolve, reject) => {
@@ -88,6 +99,7 @@ function isTabBarUrl(url: string) {
const Register = () => {
const [isAgree, setIsAgree] = useState(false)
const [loading, setLoading] = useState(false)
const { loginUser } = useUserContext()
// 短信验证码登录仅在非微信小程序端展示
const isWeapp = useMemo(() => {
@@ -181,9 +193,10 @@ const Register = () => {
// 获取小程序登录 code用于后续绑定 openid
const wxLoginCode = await getWeappLoginCode()
const res: any = await request.post(
`${SERVER_API_URL}/wx-login/loginByMpWxPhone`,
{
const res = (await Taro.request({
url: 'https://shop-api.websoft.top/api/wx-login/loginByMpWxPhone',
method: 'POST',
data: {
code: phoneCode,
encryptedData,
iv,
@@ -192,16 +205,19 @@ const Register = () => {
sceneType: 'save_referee',
tenantId: TenantId,
},
{ showError: false }
)
header: {
'content-type': 'application/json',
TenantId,
},
})) as unknown as LoginResponse
if (res?.code === 1) {
Taro.showToast({ title: res.message || '登录失败', icon: 'none' })
if ((res as any)?.data?.code === 1) {
Taro.showToast({ title: res.data.message || '登录失败', icon: 'none' })
return
}
const token = res?.data?.access_token
const user = res?.data?.user
const token = res?.data?.data?.access_token
const user = res?.data?.data?.user
if (!token || !user?.userId) {
Taro.showToast({ title: '登录失败,请重试', icon: 'none' })
return
@@ -220,6 +236,8 @@ const Register = () => {
}
saveStorageByLoginUser(token, user)
// 同步更新 UserContext 状态,确保后续页面 isLoggedIn 为 true
loginUser(token, user)
// 注册/登录成功后,立即补齐 openidJSAPI 支付必需)
try {

View File

@@ -4,12 +4,14 @@ 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: ''
@@ -182,6 +184,9 @@ const SmsLogin = () => {
code: formData.code
})
// loginBySms 内部已调 saveStorageByLoginUser这里同步 UserContext 状态
syncFromStorage()
// 登录成功后(可能是新注册用户),检查是否存在待处理的邀请关系并尝试绑定
if (hasPendingInvite()) {
try {

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
}

View File

@@ -4,7 +4,7 @@
* 必须在页面中渲染 <PrivacyModal /> 并绑定本管理器。
*/
type PrivacyResolve = (result: { event: 'agree' | 'disagree'; button: string }) => void
type PrivacyResolve = (result: { event: 'agree' | 'disagree'; buttonId: string }) => void
let currentResolve: PrivacyResolve | null = null
let showCallback: ((show: boolean) => void) | null = null
@@ -31,16 +31,22 @@ export const privacyManager = {
showCallback?.(true)
},
/** 隐私协议授权流程已结束,隐藏弹窗 */
hide() {
currentResolve = null
showCallback?.(false)
},
/** 用户点击同意(由 open-type=agreePrivacyAuthorization 的 button 触发) */
agree() {
currentResolve?.({ event: 'agree', button: 'agree' })
currentResolve?.({ event: 'agree', buttonId: 'privacy-agree-btn' })
currentResolve = null
showCallback?.(false)
},
/** 用户点击拒绝 */
disagree() {
currentResolve?.({ event: 'disagree', button: 'disagree' })
currentResolve?.({ event: 'disagree', buttonId: 'privacy-disagree-btn' })
currentResolve = null
showCallback?.(false)
},