Compare commits
32 Commits
ce1e2bd4dd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f532b3923d | |||
| f1c0349f64 | |||
| 0cc0d089e9 | |||
| c6e022f815 | |||
| 0d0c4b327a | |||
| 7ff921d973 | |||
| 69862bfc0c | |||
| 184763a15c | |||
| 147802dbe9 | |||
| 37cea1b6d0 | |||
| 65bcd07ca7 | |||
| bd3589af61 | |||
| 21b8403fab | |||
| 955b156399 | |||
| ceba1cabec | |||
| 01f0638a37 | |||
| 197722adeb | |||
| 7071ec408e | |||
| fff2ceb40d | |||
| 098fc01623 | |||
| 37bd61ea85 | |||
| 48f8c4a343 | |||
| 1077b16f58 | |||
| 22fed3b163 | |||
| d8e0563b9d | |||
| 74e9a254df | |||
| 217b97b430 | |||
| 1461a0e563 | |||
| 1b3ce58528 | |||
| b285210182 | |||
| 8d46016d8b | |||
| 46298c011f |
34
.workbuddy/memory/2025-07-17.md
Normal file
34
.workbuddy/memory/2025-07-17.md
Normal 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',' ')` 兼容后端 ISO(T 分隔)与空格分隔两种时间格式。
|
||||
|
||||
## 门店订单页增加搜索功能(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 =`),订单号支持部分匹配。
|
||||
@@ -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`(已应用到工作区,未提交)。
|
||||
|
||||
|
||||
155
.workbuddy/memory/2026-07-17.md
Normal file
155
.workbuddy/memory/2026-07-17.md
Normal 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 && ...` 短路求值为 0,React 渲染文本"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 1(API 路径不匹配)**:前端 `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` 有值时渲染,支持点击预览大图
|
||||
7
.workbuddy/memory/2026-07-23.md
Normal file
7
.workbuddy/memory/2026-07-23.md
Normal 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` 两种格式,仅保留到分钟。
|
||||
29
.workbuddy/memory/2026-08-01.md
Normal file
29
.workbuddy/memory/2026-08-01.md
Normal 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下单),默认值10,load 解析 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)。
|
||||
@@ -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驳回
|
||||
|
||||
|
||||
55
2026-07-17- 功能修复点.txt
Normal file
55
2026-07-17- 功能修复点.txt
Normal 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
481
README.md
@@ -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` 配置,并确保 IDE(VS 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/)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
|
||||
41
overview.md
41
overview.md
@@ -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 前,用户必须先同意《用户隐私保护指引》。
|
||||
- 即使登录页不再主动预检,当用户点击「手机号快捷登录」且未授权时,微信仍会强制弹出隐私授权框。这是微信机制,无法绕过。
|
||||
- 如果用户已同意隐私协议但手机号登录仍失败,请重点检查后台是否已开通「手机号快速验证组件」能力,以及隐私指引中是否包含「手机号」声明。
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -183,6 +183,8 @@ export interface ShopOrder {
|
||||
buyerRemarks?: string;
|
||||
// 商户备注(门店修改金额原因等)
|
||||
merchantRemarks?: string;
|
||||
// 线下付款支付凭证(图片URL)
|
||||
paymentVoucher?: string;
|
||||
// 排序号
|
||||
sortNumber?: number;
|
||||
// 是否删除, 0否, 1是
|
||||
|
||||
18
src/api/shop/shopOrderDelivery/index.ts
Normal file
18
src/api/shop/shopOrderDelivery/index.ts
Normal 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-delivery(save)
|
||||
*/
|
||||
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))
|
||||
}
|
||||
@@ -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;
|
||||
// 排序号
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>>(
|
||||
|
||||
@@ -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>>(
|
||||
|
||||
@@ -133,7 +133,7 @@ export default {
|
||||
window: {
|
||||
backgroundTextStyle: 'light',
|
||||
navigationBarBackgroundColor: '#fff',
|
||||
navigationBarTitleText: 'Paopao Taro',
|
||||
navigationBarTitleText: 'Websopy Inc.',
|
||||
navigationBarTextStyle: 'black',
|
||||
backgroundColor: '#f8f8f8',
|
||||
},
|
||||
|
||||
22
src/app.tsx
22
src/app.tsx
@@ -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>
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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='切换主题'
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
// 注册/登录成功后,立即补齐 openid(JSAPI 支付必需)
|
||||
try {
|
||||
|
||||
@@ -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
35
src/utils/phoneAuth.ts
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user