Compare commits
71 Commits
| 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 | |||
| ce1e2bd4dd | |||
| 1b72ae37a5 | |||
| af90bd7a55 | |||
| ca78e73f51 | |||
| 549e8258b6 | |||
| fbaa5c32bc | |||
| c12bba66eb | |||
| b8fca7bed2 | |||
| 909e0fd43f | |||
| ad616943c8 | |||
| 501e79eed7 | |||
| 4d646b3fd6 | |||
| b47ea6bd90 | |||
| fad7c5f58e | |||
| b45a5722eb | |||
| af645c058e | |||
| ca6ba52993 | |||
| 6dbe128ddd | |||
| 3f1931e420 | |||
| 3955e64844 | |||
| 81324cbb82 | |||
| ad3c209fe5 | |||
| e47a0d3d3e | |||
| 747a2a1d3a | |||
| 38a7b4320d | |||
| 19ca568d60 | |||
| 1e4549095a | |||
| 6dafbf47c9 | |||
| a353bc9ea8 | |||
| 1fcfcdec73 | |||
| beabbaefc0 | |||
| c870e75b3b | |||
| dc123d79a2 | |||
| 4c059522a2 | |||
| 0ca26801b1 | |||
| 711e588cfe | |||
| 8d6c0fda69 | |||
| f878ea146e | |||
| dfbcbcf615 |
20
.workbuddy/memory/2025-07-15.md
Normal file
20
.workbuddy/memory/2025-07-15.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# 2025-07-15 工作日志
|
||||
|
||||
## 关于页面隐藏开发者登录
|
||||
- 在 `src/pages/user/about/index.tsx` 底部版权信息上添加了点击 11 次弹出隐藏登录弹窗的功能
|
||||
- 登录方式:手机号 + 短信验证码,复用 `loginBySms` / `sendSmsCaptcha` API(from `@/api/passport/login`)
|
||||
- 点击计数使用 `useRef` 保证快速点击时的同步性,超过 3 秒未点击自动重置
|
||||
- 步数提示从**第 6 次**点击开始显示(Android 开发者模式风格)
|
||||
- **(已更新)** 弹窗布局完全对齐 `sms-login.tsx` 写法:验证码行取消 `gap-2`/`flex-1` 包裹,改用 `flex items-center` 直接放 Input + Button;登录按钮取消 `block`/`shape`/`style`,改用 `className='w-full rounded-lg p-2'` + 外层 `flex justify-center`,解决小程序中 Button 不显示的问题
|
||||
- 弹窗宽度从 300px 加宽到 **340px**
|
||||
- 登录成功后调用 `useUserContext().syncFromStorage()` 同步 React 登录态(`loginBySms` 内部已写入 storage 但不返回数据)
|
||||
- 弹窗 UI 使用 NutUI `Input` + `Button`,带 60 秒倒计时
|
||||
|
||||
## 修复首页铃铛权限漏洞和非门店用户新订单提醒
|
||||
- **问题1**:非门店角色用户也能通过铃铛进入订单管理页
|
||||
- **问题2**:新订单提醒(红点badge + Toast)对非门店用户也显示
|
||||
- **修复**:
|
||||
1. `src/pages/index/index.tsx`:铃铛 badge 显示条件从 `isLoggedIn` 改为 `isClerk`(红点和计数仅门店角色可见)
|
||||
2. `src/pages/index/index.tsx`:`onNewOrders` 回调增加 `isClerkRef.current` 检查,非门店角色不弹 Toast/震动
|
||||
3. `src/pages/store/orders/index.tsx`:订单管理页增加访问控制,`getMyClerk()` 校验非门店角色自动跳回(防御性拦截)
|
||||
- `handleMessageClick` 中已有 `isClerk` 判断(店员→订单管理,普通用户→消息通知页),无需修改
|
||||
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 =`),订单号支持部分匹配。
|
||||
@@ -1,4 +1,4 @@
|
||||
- 修改 `src/pages/user/about/index.tsx`:根据截图将“广西金炜建设工程有限公司”的简介更新为“玉林市玉州区鑫龙家电经营部”相关内容,包括头部品牌、公司简介、核心业务、联系信息及版权。
|
||||
- 修改 `src/pages/user/about/index.tsx`:根据截图将“广西金炜建设工程有限公司”的简介更新为“鑫龙商贸电器”相关内容,包括头部品牌、公司简介、核心业务、联系信息及版权。
|
||||
|
||||
## 门店中心功能实现
|
||||
|
||||
|
||||
@@ -1,294 +1,36 @@
|
||||
## 购物车功能修复(2026-07-14)
|
||||
# 2026-07-14 工作日志
|
||||
|
||||
## 门店订单页商品图片修复
|
||||
- 文件:`src/pages/store/orders/index.tsx`
|
||||
- 问题:订单商品图片不显示,字段名与模型不匹配
|
||||
- 修复:`coverImage` → `image`、`specInfo` → `spec`、`quantity` → `totalNum`(按 `ShopOrderGoods` 模型定义)
|
||||
- 图片压缩:`getCompressedImageUrl(goods.image, { width: 80 })`,展示尺寸 w-16 h-16(64px),压缩宽度 80 够用
|
||||
|
||||
## 门店中心新增用户管理功能
|
||||
- 新页面:`src/pages/store/users/index.tsx`
|
||||
- 功能:分页查询用户列表、搜索(昵称/手机号)、Tab 筛选(全部/正常/已禁用)、查看用户详情弹窗、禁用/启用用户
|
||||
- 使用的 API:`pageUsers`(`/system/user/page`)、`updateUserStatus`(`/system/user/status`,参数 userId + status,0=正常 1=禁用)
|
||||
- 入口:`src/pages/store/center/index.tsx` FEATURE_CARDS 中,在 VIP会员审核上方加了"用户管理"卡片(蓝色主题 👥 图标)
|
||||
- 路由注册:`src/app.config.ts` 新增 `pages/store/users/index`
|
||||
|
||||
## 禁用用户拦截(防止被禁用用户仍可浏览小程序)
|
||||
- 新增工具函数:`src/utils/auth.ts` 中 `isUserDisabled(user)` 和 `blockDisabledUser(user)`(清登录态+弹窗+跳转登录页)
|
||||
- 拦截层级:
|
||||
1. **UserContext 启动校验**(`src/contexts/UserContext.tsx`):init 时先用缓存快速显示,再异步调 `getUserInfo()` 校验 status;若被禁用则 `blockDisabledUser` 拦截
|
||||
2. **UserContext refreshUser / syncFromStorage / loginUser**:均加了 `isUserDisabled` 检查
|
||||
3. **登录 API**(`src/api/passport/login/index.ts`):`login()` 和 `loginBySms()` 在 `saveStorageByLoginUser` 前检查 status,被禁用则 reject
|
||||
4. **登录页面**:`passport/login.tsx`、`passport/register.tsx`、`pages/store/login/index.tsx` 在保存登录态前检查 status,被禁用则弹窗拦截
|
||||
- 用户状态约定:`status === 0` 正常,`status === 1` 禁用(通过 `updateUserStatus(userId, status)` 接口控制)
|
||||
|
||||
## 微信隐私协议(chooseImage errno:112)修复
|
||||
- 报错:`MiniProgramError {errMsg:"chooseImage:fail api scope is not declared in the privacy agreement", errno:112}`(基础库 3.16.1+ 强制)
|
||||
- 影响范围:所有调用 `Taro.chooseImage` 的地方,本轮修复统一入口 `uploadFile`(商品管理页、订单评价、售后申请、门店订单都走这个)
|
||||
- 修复:
|
||||
1. **`src/api/system/file/index.ts`** — `uploadFile` 加 `ensurePrivacyAuthorized()` 预检(`getPrivacySetting` + `requirePrivacyAuthorize`)
|
||||
2. **`src/app.tsx`** — `useLaunch` 里注册 `Taro.onNeedPrivacyAuthorization` 回调,弹自定义 showModal 协议弹窗,同意 resolve({event:'agree'}) 拒绝 resolve({event:'disagree'})
|
||||
3. **小程序管理后台**(必须手动去配)→ 设置 → 第三方设置 → 用户隐私保护指引 → 添加「开发者收集你的相册/摄像头」声明,否则首次调用仍会触发协议弹窗
|
||||
- 旧基础库兼容:`typeof wxAny.requirePrivacyAuthorize !== 'function'` 时直接放行
|
||||
- ⚠️ **易错点:`requiredPrivateInfos` 字段只接受位置类 API 白名单**(chooseAddress/chooseLocation/choosePoi/getFuzzyLocation/getLocation/onLocationChange/startLocationUpdate/startLocationUpdateBackground),不要加 `chooseImage`/`chooseMedia` 等非位置类,会导致 app.json 解析失败
|
||||
- 后续若别的页面直接调 `Taro.chooseImage`(如 `evaluate/index.tsx`、`after-sale/apply/index.tsx`、`store/orders/index.tsx`),仍要单独加 `ensurePrivacyAuthorized` 预检
|
||||
|
||||
### 问题
|
||||
购物车页面(pages/shop/cart)商品显示异常:
|
||||
- 商品名称/图片/价格信息缺失
|
||||
- 加入购物车后无法正确显示
|
||||
|
||||
### 根因
|
||||
`CartContext.refresh()` 方法在将后端 API 返回的购物车项映射为前端 `CartItem` 时,**遗漏了 `product`(商品详情)和 `sku`(SKU 详情)嵌套对象的映射**。导致页面中 `item.product?.name/image/price` 和 `item.sku?.price/image` 均为 `undefined`,UI 只能显示硬编码的默认值。
|
||||
|
||||
### 修改
|
||||
1. **`src/contexts/CartContext.tsx`**(核心修复)
|
||||
- `refresh()` 的 `mappedItems` 映射中补充 `product` 和 `sku` 字段
|
||||
- 使用 `(item as any).product` / `(item as any).sku` 获取后端返回的嵌套对象
|
||||
|
||||
2. **`src/utils/image.ts`**(增强健壮性)
|
||||
- 新增 `ensureFullUrl()` 辅助函数:相对路径自动补全 OSS 域名 `https://oss.wsdns.cn`
|
||||
- `getCompressedImageUrl()` 内部调用 `ensureFullUrl` 确保 URL 完整性
|
||||
|
||||
3. **`src/pages/shop/cart.tsx`**(补充图片回退源)
|
||||
- 图片 `src` 回退链增加 `(item.sku as any)?.image`
|
||||
- 完整回退链:`goodsImage -> product.image -> sku.image -> ''`
|
||||
|
||||
## 收藏列表图片/价格不显示修复(2026-07-14)
|
||||
|
||||
### 问题
|
||||
`pages/user/favorite-list/index` 收藏列表:商品图片、价格、名称均空白。
|
||||
|
||||
### 根因
|
||||
后端 `/api/shop/goods/favorite/list`(前端 `/shop/goods/favorite/list`)走 `ShopGoodsFavoriteServiceImpl.listRel` → `selectListRel`,SQL 只 `SELECT a.*` 未关联商品表,返回字段仅 `id/userId/goodsId/merchantId/tenantId/createTime`,不含商品信息。前端依赖 `goodsImage/goodsName/price/salePrice` 均为 undefined。
|
||||
|
||||
### 修改(方案1:后端返回商品信息)
|
||||
1. **后端** `guilixu-java`:
|
||||
- `ShopGoodsFavorite.java`:新增 4 个 `@TableField(exist=false)` 非持久化字段 `goodsName(String)`、`goodsImage(String)`、`price(BigDecimal)`、`salePrice(BigDecimal)`,并补 `BigDecimal`/`TableField` import。
|
||||
- `ShopGoodsFavoriteMapper.xml` 的 `selectSql`:`SELECT a.*, g.name AS goods_name, g.image AS goods_image, g.price AS price, g.sale_price AS sale_price`,`LEFT JOIN shop_goods g ON a.goods_id = g.goods_id`(已确认 `map-underscore-to-camel-case: true`,别名可映射)。
|
||||
- 注意:本机无 maven 无法编译,改动为常规 MyBatis/MP 写法,需用户自行编译部署。
|
||||
|
||||
2. **前端**:
|
||||
- `src/api/shop/shopGoodsFavorite/model.ts`:`favoriteId` 改为 `id`(后端实际返回 `id`);新增 `price?: string|number`;`salePrice` 改为 `string|number`。
|
||||
- `pages/user/favorite-list/index.tsx`:图片用 `item.goodsImage`;价格改为优先 `item.price`(到手价),`salePrice` 作为划线价(`salePrice > price` 时显示);`key` 改用 `item.id ?? item.goodsId`(原 `favoriteId` 始终 undefined 会导致 key 重复)。
|
||||
- 注:`src/pages/favorite-list.tsx` 为未注册路由的重复文件,未改动。
|
||||
|
||||
### 遗留(非本次范围,供参考)
|
||||
adapter `/list` 接口忽略分页,一次返回全部收藏;前端 `listShopGoodsFavorite({page,limit:20})` 的无限滚动在收藏>20条时会重复追加。如需彻底解决建议改前端走 `/page` 接口或后端 `/list` 支持分页。
|
||||
|
||||
## 购物车更新 API 请求方式修复(2026-07-14)
|
||||
|
||||
### 问题
|
||||
`PUT /api/shop/shop-cart/{id}` 返回 `HttpRequestMethodNotSupportedException: Request method 'PUT' not supported`。
|
||||
|
||||
### 根因
|
||||
`src/api/shop/shopCart/index.ts` 中 3 个函数用了非标准 URL 模式 `PUT /shop/shop-cart/{id}`,而项目所有其他 API(shopOrder、shopArticle 等)的更新操作统一走 `PUT /shop/shop-cart`(基础 URL,id 放 body)。
|
||||
|
||||
### 修改
|
||||
1. `updateCartNum`:`PUT /shop/shop-cart/{id}` + `{quantity}` → `PUT /shop/shop-cart` + `{id, num}`
|
||||
2. `updateCartChecked`:`PUT /shop/shop-cart/{id}` + `{selected}` → `PUT /shop/shop-cart` + `{id, checked}`(同时修正字段名 `selected`→`checked` 与 model 一致)
|
||||
3. `updateCartAllChecked`:`PUT /shop/shop-cart/selected` + `{selected}` → `POST /shop/shop-cart/selected` + `{checked}`(改用 POST,自定义批量操作更可能支持 POST;CartContext 已有静默 catch 兜底)
|
||||
|
||||
## 购物车后端 SQL 关联查询修复(2026-07-14)
|
||||
|
||||
### 问题
|
||||
`GET /api/shop/shop-cart` 返回的数据没有商品图片、价格等信息。前端 `CartContext.refresh()` 已映射 `goodsName/goodsImage/skuPrice/skuSpec/stock` 等字段,但后端根本没返回这些字段。
|
||||
|
||||
### 根因
|
||||
后端 `ShopCartMapper.xml` 的 SQL 只做 `SELECT a.* FROM shop_cart a`,没有 JOIN 商品表和 SKU 表。
|
||||
|
||||
### 后端修改(`guilixu-java` 项目)
|
||||
1. **`ShopCart.java`**:新增 7 个 `@TableField(exist = false)` 非持久化字段:`goodsName`、`goodsImage`、`skuPrice`、`skuSpec`、`stock`、`salePrice`、`dealerPrice`;补充 `TableField` import
|
||||
2. **`ShopCartMapper.xml`**:SQL 改为 `LEFT JOIN shop_goods g ON a.goods_id = g.goods_id` + `LEFT JOIN shop_goods_sku s ON a.sku_id = s.id`,SELECT 别名映射到新字段(`COALESCE(s.price, g.price) AS sku_price`、`COALESCE(s.stock, g.stock) AS stock`);同时修复 `userId/goodsId/merchantId` 从 `LIKE` 改为 `=`(原 LIKE 会导致 userId=1 匹配到 10/11/21 等)
|
||||
3. **`ShopCartController.java`**:`list()` 方法自动按当前登录用户过滤(`param.setUserId(loginUser.getUserId().longValue())`)
|
||||
|
||||
### 前端修改
|
||||
4. **`src/api/shop/shopCart/model/index.ts`**:`ShopCart` 接口新增 `salePrice`、`dealerPrice` 字段
|
||||
5. **`src/contexts/CartContext.tsx`**:`CartItem` 新增 `salePrice`、`dealerPrice`;`refresh()` 映射这两个字段;`calcPrice()` VIP 优先用扁平 `i.dealerPrice`(之前只看 `i.product?.dealerPrice`)
|
||||
6. **`src/pages/shop/cart.tsx`** + **`src/pages/shop/checkout.tsx`**:价格显示逻辑同步更新,优先使用扁平 `item.dealerPrice` / `item.salePrice`
|
||||
|
||||
## 购物车前后端字段名对齐修复(2026-07-14)
|
||||
|
||||
### 问题
|
||||
修改购物车数量时后端报 `UPDATE shop_cart WHERE id=?`(SET 子句为空),MyBatis-Plus 找不到要更新的字段。
|
||||
|
||||
### 根因
|
||||
前后端字段名不匹配:
|
||||
- 前端发送 `num`,后端实体字段是 `cartNum` → Jackson 反序列化时 `cartNum` 为 null → 空 UPDATE
|
||||
- 前端发送 `checked`,后端实体字段是 `selected` → 同样不匹配
|
||||
- 列表返回 `cartNum`,前端读 `item.num` → 数量永远为默认值 1
|
||||
|
||||
### 修改文件
|
||||
1. **`src/api/shop/shopCart/model/index.ts`**:`ShopCart.num` → `cartNum`;新增 `selected` 字段;`AddToCartParam.num` → `cartNum`;`UpdateCartNumParam.num` → `cartNum`
|
||||
2. **`src/api/shop/shopCart/index.ts`**:`updateCartNum` 发 `{id, cartNum}`;`updateCartChecked` 发 `{id, selected}`
|
||||
3. **`src/contexts/CartContext.tsx`**:`refresh()` 读 `item.cartNum` 和 `item.selected`;`addItem()` 发 `cartNum`;`updateQuantity` 调用 `{id, cartNum}`
|
||||
4. **`src/pages/shop/index.tsx`** + **`src/pages/shop/category.tsx`**:`addToCart` 参数 `num` → `cartNum`
|
||||
|
||||
## 禁用 loginByOpenId 静默登录(2026-07-14)
|
||||
|
||||
### 改动
|
||||
- **`src/contexts/UserContext.tsx`**:移除"情况3"静默登录块(`Taro.login` + `loginByOpenId`),无 token 时直接保持未登录状态;清理 `loginByOpenId`、`TenantId` 无用导入
|
||||
- **`src/hooks/useUser.ts`**:移除降级方案中的 `loginByOpenId` 调用,仅从 storage 读取;清理 `loginByOpenId`、`TenantId` 无用导入
|
||||
- `src/api/layout/index.ts` 中 `loginByOpenId` 函数定义保留,仅不再被调用
|
||||
|
||||
## 商城分类页价格登录可见(2026-07-14)
|
||||
|
||||
### 改动
|
||||
- **`src/pages/shop/index.tsx`**:价格显示从手写 `Text` 改为 `Price` 组件 + `loginMask` 属性(与首页一致),游客看到"登录后查看价格";VIP 划线价逻辑保留在 `original` prop 中;单位 `unitName` 仅登录后显示
|
||||
|
||||
## 订单列表页未登录可查看订单 Bug 修复(2026-07-14)
|
||||
|
||||
### 问题
|
||||
`pages/order/list` 未登录用户也能看到订单数据。
|
||||
|
||||
### 根因
|
||||
页面缺少登录校验——`useEffect` 直接调用 `loadOrders`,未登录时 `user?.userId` 为 `undefined`,后端未按用户过滤直接返回了订单。
|
||||
|
||||
### 修改
|
||||
- **`src/pages/order/list.tsx`**:
|
||||
1. 从 `useUser()` 获取 `isLoggedIn`
|
||||
2. `useEffect` 中未登录时调用 `requireLogin({ action: 'viewOrder', redirect: '/pages/order/list', content: '登录后才能查看订单,是否前往登录?' })` 弹窗引导,而不是直接跳转
|
||||
3. render 中 `if (!isLoggedIn) return null` 兜底,避免未登录时渲染订单内容
|
||||
- **`src/passport/login.tsx`**:登录成功后的 `tabBarUrls` 白名单补全 `pages/order/list`,确保从订单入口登录后能 `switchTab` 回订单页
|
||||
|
||||
## 我的页面未登录可点击需登录功能 Bug 修复(2026-07-14)
|
||||
|
||||
### 问题
|
||||
`pages/user/user` 未登录时,点击「我的订单」下的按钮(待付款/待发货/待收货/已完成)、「全部订单」、以及菜单中的「升级VIP会员」「我的钱包」「收货地址」「我的收藏」都能直接跳转页面。
|
||||
|
||||
### 修改
|
||||
- **`src/pages/user/user.tsx`**:
|
||||
1. 导入 `requireLogin` from `@/utils/login-guard`
|
||||
2. 给需要登录的菜单项加 `requireAuth: true` 标记(升级VIP会员/门店中心/我的钱包/收货地址/我的收藏)
|
||||
3. 新增 `handleMenuClick(item)`:`requireAuth` 项调用 `requireLogin` 弹窗拦截,已登录才 `navigateTo`
|
||||
4. 新增 `handleOrderTabClick(status)` 和 `handleViewAllOrders()`:订单入口 `requireLogin` 拦截,并加 `redirect: '/pages/order/list'` 让登录后回跳订单页
|
||||
5. 未登录时需登录的菜单项和订单按钮加 `opacity-50` 视觉降级
|
||||
6. 「帮助中心」「设置」不需要登录,保持原样
|
||||
|
||||
## 订单列表页登录后仍看到别人订单 Bug 修复(2026-07-14)
|
||||
|
||||
### 问题
|
||||
`pages/order/list` 刚登录成功后仍能看到别人的订单,刷新重进后才正常。
|
||||
|
||||
### 根因
|
||||
`loadOrders` 的 `useCallback` 依赖数组为 `[updateTabState]`,未包含 `user`,导致**闭包过期**——登录后 `isLoggedIn` 变 `true` 触发 effect 调用 `loadOrders`,但闭包中的 `user` 仍是初始渲染时的 `null`,`userId: user?.userId` 传了 `undefined`,后端未按用户过滤返回了全部订单。
|
||||
|
||||
### 修改
|
||||
- **`src/pages/order/list.tsx`**:
|
||||
1. 新增 `userIdRef`(`useRef(user?.userId)`),每渲染同步 `userIdRef.current = user?.userId`,避免闭包过期
|
||||
2. `loadOrders` 开头从 `userIdRef.current` 取 userId,`if (!userId) return` 硬拦截——没有当前登录用户 userId 就禁止加载
|
||||
3. `useEffect` 中增加 `if (!user?.userId) return` 判断,已登录但 userId 还没拿到时等待下次渲染
|
||||
4. `useEffect` 依赖数组加 `user?.userId`,userId 变化时重新触发加载
|
||||
|
||||
## 商城列表页浮动购物车按钮(2026-07-14)
|
||||
|
||||
### 需求
|
||||
`pages/shop/index` 页面右下角添加浮动购物车按钮,显示购物车商品数量角标。
|
||||
|
||||
### 实现
|
||||
- **`src/pages/shop/index.tsx`**:
|
||||
1. 从 `useCartContext()` 额外解构 `totalCount`
|
||||
2. 页面末尾添加 fixed 定位浮动按钮:绿色圆形(48px),`bottom:80px right:16px`(避开 tabBar)
|
||||
3. `totalCount > 0` 时右上角显示红色角标,超过 99 显示 `99+`
|
||||
4. 点击 `Taro.navigateTo` 跳转购物车页(cart 非 tabBar 页)
|
||||
5. 数量实时更新:`addItem` 内部调 `refresh()` → `totalCount` 派生值自动重渲染
|
||||
|
||||
## 购物车页面进入不刷新 Bug 修复(2026-07-14)
|
||||
|
||||
### 问题
|
||||
从商品列表页/分类页加入购物车后,进入购物车页面,新加的商品不显示,只有之前已在购物车里的商品。
|
||||
|
||||
### 根因
|
||||
1. **购物车页面缺少 `useDidShow`**:`cart.tsx` 仅在 `useEffect([isLoggedIn])` 中调用 `refresh()`,登录状态不变时不会重新拉取数据。从其他页面返回购物车时,页面不刷新。
|
||||
2. **商品列表页/分类页直接调 API 加购**:`shop/index.tsx` 和 `shop/category.tsx` 直接调用 `addToCart` API,未通过 `CartContext.addItem`,导致 CartContext 状态不同步(`addItem` 内部会调 `refresh()`)。
|
||||
|
||||
### 修改
|
||||
1. **`src/pages/shop/cart.tsx`**:导入 `useDidShow`,添加 `useDidShow(() => { if (isLoggedIn) refresh() })`,页面每次显示时从服务端拉取最新购物车数据
|
||||
2. **`src/pages/shop/index.tsx`**:导入 `useCartContext`,`handleAddToCart` 改用 `addItem(product, undefined, product.step || 1)` 代替直接调 `addToCart` API;移除 `addToCart` 导入
|
||||
3. **`src/pages/shop/category.tsx`**:同上,改用 `useCartContext` 的 `addItem`
|
||||
|
||||
### 需求
|
||||
首页顶部搜索栏右侧铃铛图标,门店店员有新订单时能收到提醒。
|
||||
|
||||
### 改动
|
||||
- **`src/pages/index/index.tsx`**:
|
||||
1. 导入 `useNewOrderDetector`、`getMyClerk`、`pageShopOrder`、`useDidShow`、`useCallback`、`useRef`
|
||||
2. 登录后调 `getMyClerk()` 检测店员身份,存 `isClerk` state + `isClerkRef` ref(ref 用于轮询回调中读取最新值,避免闭包过期)
|
||||
3. `useDidShow` 时也刷新店员身份
|
||||
4. 接入 `useNewOrderDetector`:30秒轮询 `pageShopOrder({page:1,limit:5})`;`fetchLatestOrders` 内用 `isClerkRef.current` 网关——非店员直接返回空数组(不建 baseline,无副作用)
|
||||
5. 新订单回调:`Taro.vibrateShort` 震动 + `Taro.showToast` 提示"您有 N 条新订单"
|
||||
6. 铃铛角标从静态红点改为动态:`newOrderCount > 0` 显示数字角标(>99 显示 99+),`=== 0` 时显示小圆点
|
||||
7. 点击处理:店员 → `navigateTo` 门店订单页(有新订单时先 `clearUnread()`);普通用户 → 通知页
|
||||
|
||||
### 设计要点
|
||||
- `isClerkRef` 解决 hooks 闭包过期问题——`fetchLatestOrders` 的 `useCallback` 依赖空数组,但通过 ref 读取最新店员状态
|
||||
- 非店员时 `fetchLatestOrders` 返回 `[]`,hook 内 `doCheck` 在 `list.length === 0` 时 return,`knownOrderIdsRef` 保持 `null` 不建 baseline;店员身份确认后首次真实请求才建 baseline,不会误报
|
||||
- `useNewOrderDetector` 内部已处理 `useDidShow`/`useDidHide` 生命周期,tabBar 页切走自动停轮询
|
||||
|
||||
## 商品分类页浮动购物车图标优化(2026-07-14)
|
||||
|
||||
### 问题
|
||||
商品分类页(pages/shop/index)右下角绿色圆形浮动购物车按钮,使用 emoji `🛒` 作为图标,在绿色背景上对比度不足、线条不清晰。
|
||||
|
||||
### 修改
|
||||
1. 新增白色线条购物车 SVG 图标:`src/assets/icons/cart.svg`
|
||||
2. 导出 base64 数据 URI 常量:`src/assets/icons/index.ts` 中 `CART_ICON_WHITE`
|
||||
3. `src/pages/shop/index.tsx` 浮动购物车按钮改用 `Image` 组件加载 `CART_ICON_WHITE`(28×28px,`mode='aspectFit'`),图标更清晰
|
||||
|
||||
## 商品详情页出现孤立 "0" 排查修复(2026-07-14)
|
||||
|
||||
### 问题
|
||||
`pages/shop/product-detail` 价格区域下方出现一个不归属任何标签的孤立 `0`。
|
||||
|
||||
### 根因
|
||||
`src/pages/shop/product-detail.tsx` 中赚取积分行的条件渲染写成:
|
||||
```tsx
|
||||
{product.gainIntegral && Number(product.gainIntegral) > 0 && (...)}
|
||||
```
|
||||
当后端返回 `gainIntegral` 为数字 `0` 时,JSX 短路与运算直接返回 `0`,React 会把它渲染成一个纯文本节点。这就是页面上那个孤立的 `0`。
|
||||
|
||||
### 修改
|
||||
- **`src/pages/shop/product-detail.tsx`**:
|
||||
- 去掉条件里的 `product.gainIntegral &&`,改成 `{Number(product.gainIntegral) > 0 && (...)}`,避免 `0` 泄漏。
|
||||
- 顺手把配送区域 `goodsWeight` 的同类写法也改成 `{Number(product.goodsWeight) > 0 && (...)}`,防止重量为 0 时同样泄漏。
|
||||
- 另按用户要求「是 0 就不显示」,把销量行也改为 `{Number(product.sales) > 0 && (...)}`,销量为 0 时整条「销量: 0」不渲染。
|
||||
|
||||
## 订单列表页下单后不自动刷新修复(2026-07-14)
|
||||
|
||||
### 问题
|
||||
`pages/order/list` 下单后从 checkout 页 `Taro.switchTab` 跳过来,订单列表不刷新,看不到新订单。
|
||||
|
||||
### 根因
|
||||
订单列表页是 tabBar 页,`switchTab` 不会重新挂载组件。`useEffect` 依赖 `[tabIndex, isLoggedIn, user?.userId]`,这些值不变就不会重新触发;且 effect 内有 `if (!tabStates[tabIndex].initialized)` 守卫,页面已初始化过就直接跳过。
|
||||
|
||||
### 修改
|
||||
- **`src/pages/order/list.tsx`**:新增 `Taro.useDidShow` 生命周期钩子
|
||||
- 用 `isFirstShow` ref 跳过首次显示(由 `useEffect` 处理初始加载)
|
||||
- 后续每次显示时:标记所有 tab 为 `initialized: false`(切 tab 时会重新加载),并立即 `loadOrders(tabIndex, 1)` 刷新当前 tab
|
||||
- 与之前购物车页 `useDidShow` 刷新是同一模式
|
||||
|
||||
## 商品详情页底部购物车角标(2026-07-14)
|
||||
|
||||
### 需求
|
||||
`pages/shop/product-detail` 底部操作栏的购物车入口需要显示当前购物车商品数量角标。
|
||||
|
||||
### 修改
|
||||
- **`src/pages/shop/product-detail.tsx`**:
|
||||
1. 从 `useCartContext` 额外解构 `totalCount`、`refresh`
|
||||
2. 引入 `useDidShow`,页面显示时(已登录)调 `refresh()` 拉取最新购物车,保证角标准确
|
||||
3. 购物车图标外包一层 `relative` 容器,`totalCount > 0` 时右上角显示红色圆角角标(>99 显示 `99+`),样式与商城列表页浮动购物车角标一致
|
||||
|
||||
## 在线客服功能修复(2026-07-14)
|
||||
|
||||
### 问题
|
||||
`pages/user/customer-service/index` 在线客服功能使用不了,用户已在微信后台添加客服人员。
|
||||
|
||||
### 根因(多个问题叠加)
|
||||
1. **`openType="contact"` 按钮缺少关键属性**:缺少 `sessionFrom`(会话来源标识)、`onContact`(客服回调)、`showMessageCard`/`sendMessageTitle`(客服消息卡片),且按钮被 Tailwind `border-0` 类可能干扰原生渲染
|
||||
2. **开发者工具限制**:`open-type="contact"` 在微信开发者工具中不生效,只能在真机上使用,页面缺少提示
|
||||
3. **自定义聊天系统消息发送缺失 `conversationId`**:`addShopChatMessage` 调用未传 `conversationId`,消息无法关联到会话;`ShopChatMessage` 模型本身也缺少该字段
|
||||
4. **消息发送缺失 `formUserId`**:发送人 ID 未设置
|
||||
5. **`conv.lastMessage` 字段不存在**:`ShopChatConversation` 模型只有 `content` 字段,代码引用了不存在的 `lastMessage`
|
||||
6. **`useReachBottom` 用错场景**:页面使用 `ScrollView` 组件,`useReachBottom` 是页面级滚动钩子,对 ScrollView 无效,应改用 `onScrollToLower`
|
||||
7. **`scrollToBottom` ref 设了但从未使用**:死代码
|
||||
|
||||
### 修改
|
||||
1. **`src/api/shop/shopChatMessage/model/index.ts`**:
|
||||
- `ShopChatMessage` 接口新增 `conversationId?: number`
|
||||
- `ShopChatMessageParam` 接口新增 `conversationId?: number`
|
||||
2. **`src/pages/user/customer-service/index.tsx`**(全面修复):
|
||||
- 微信客服按钮补全 `sessionFrom="customer_service"`、`onContact`、`showMessageCard`、`sendMessageTitle`
|
||||
- 按钮样式从 Tailwind 类改为 inline style(避免 `border-0` 干扰原生按钮),显式设置 `border: 'none'`
|
||||
- 添加"需真机预览测试"提示文字
|
||||
- `addShopChatMessage` 调用补全 `conversationId` 和 `formUserId`(从 `Taro.getStorageSync('UserId')` 获取)
|
||||
- `conv.lastMessage` 全部改为 `conv.content`
|
||||
- 会话列表更新 `lastMessage: text` 改为 `content: text`
|
||||
- 移除 `useReachBottom`,改用 ScrollView `onScrollToLower` + `lowerThreshold={50}`
|
||||
- 移除未使用的 `scrollToBottom` ref
|
||||
- `pageShopChatMessage` 参数去掉 `as any` 断言(模型已支持 `conversationId`)
|
||||
- 新增 `getCurrentUserId()` 辅助函数
|
||||
- `handleScrollToLower` 增加 `showHistory` 条件守卫(仅展开历史时才加载更多)
|
||||
3. **`src/components/NavBar/index.tsx`**(关键修复,导致白屏):
|
||||
- Taro 4.1.11 没有 `useNavigate` hook,原代码 `import { useNavigate } from '@tarojs/taro'` 运行时为 `undefined`
|
||||
- 改为 `import Taro from '@tarojs/taro'`,回退逻辑用 `Taro.navigateBack({ delta: 1 })`
|
||||
4. **删除未注册的死代码**:
|
||||
- `src/pages/customer-service.tsx`(重复且仍引用 `useReachBottom`,干扰编译/热更新)
|
||||
- `src/pages/customer-service.config.ts`
|
||||
|
||||
### 验证
|
||||
- 全局搜索确认无其他 `useNavigate` 和 `useReachBottom` 引用
|
||||
- TypeScript 检查修改文件无错误
|
||||
|
||||
### 客服功能简化(21:00)
|
||||
- 用户反馈客服功能太复杂用不了,改为直接用微信原生 `open-type="contact"` 按钮
|
||||
- `src/pages/shop/product-detail.tsx`:客服图标从 `View + onClick navigateTo` 改为 `Button openType="contact" sessionFrom="product_detail"`
|
||||
- 删除了 `handleContactService` 函数和跳转到 `/pages/user/customer-service/index` 的逻辑
|
||||
- **注意:`open-type="contact"` 必须真机预览测试,开发者工具中点击无反应**
|
||||
- `pages/user/customer-service/index` 页面保留但不再从商品详情页跳转
|
||||
|
||||
363
.workbuddy/memory/2026-07-15.md
Normal file
363
.workbuddy/memory/2026-07-15.md
Normal file
@@ -0,0 +1,363 @@
|
||||
## 线下付款下单失败排查
|
||||
- 文件:`src/pages/shop/checkout.tsx`、`src/api/shop/shopOrder/index.ts`
|
||||
- 现象:结算页选择线下付款(payType=9)下单时报错:`创建支付订单失败:创建微信支付订单失败:支付配置中应用ID为null或空`
|
||||
- 原因:前端传参正确,但后端 `POST /shop/shop-order` 在 payType=9 时仍尝试创建微信支付订单,因未配置 appId 失败
|
||||
- 前端处理:
|
||||
- `checkout.tsx` 增加线下付款支付配置类错误的友好提示
|
||||
- `createOrder` 返回类型放宽为 `WxPayResult | null`,兼容非微信支付场景
|
||||
- 后端修复(`/Users/gxwebsoft/JAVA/guilixu-java`,3 个文件):
|
||||
1. `OrderBusinessService.java` — `createOrder()` 中 Integer 比较从 `==` 改为 `.equals()`,避免装箱陷阱导致 payType=8/9 拦截失效;微信支付分支前加日志
|
||||
2. `ShopOrderServiceImpl.java` — `createWxOrder()` 方法开头增加 payType 守卫,非微信支付(1/102)直接抛异常,不走 JSAPI 分支
|
||||
3. `ShopOrderController.java` — `saveLegacy()` 方法增加 payType 检查,货到付款/线下付款/余额支付跳过 createWxOrder
|
||||
- 验证:Maven compile 成功,无编译错误
|
||||
- 注意:后端服务需重启才能生效
|
||||
|
||||
## 结算页支付方式调整
|
||||
- 文件:`src/pages/shop/checkout.tsx`
|
||||
- 注释掉货到付款(id=8)选项,只保留线下付款(id=9)
|
||||
- 默认支付方式从 8(货到付款) 改为 9(线下付款)
|
||||
|
||||
## 门店订单修改金额功能
|
||||
- 文件:`src/pages/store/orders/index.tsx`
|
||||
- 新增 OpType `'editPrice'`,在订单卡片操作区增加橙色「修改金额」按钮
|
||||
- 仅对可操作订单(非已完成/非已关闭)显示
|
||||
- 弹窗包含:当前实付展示、新金额输入(digit)、修改原因(必填 textarea)
|
||||
- 提交调用 `updateShopOrder({ orderId, payPrice, comments })`,金额变更记录追加到 comments
|
||||
- 复用现有 `updateShopOrder` API(PUT `/shop/shop-order`),无需新增接口
|
||||
|
||||
## 修复 VIP 会员价格显示和下单问题
|
||||
- **根因**:`useVipStatus` hook 已定义但从未被任何页面使用,所有页面直接调用 `isVipMember()` 读取同步缓存。该缓存仅在「个人中心」页刷新,导致 VIP 会员访问商品详情/购物车/结算页时缓存为 stale false,不显示 dealerPrice
|
||||
- **修复文件**(共 9 个):
|
||||
1. `src/api/shop/shopOrder/model/index.ts` — `OrderGoodsItem` 新增 `price?: string` 字段,下单时传 VIP 单价
|
||||
2. `src/pages/shop/product-detail.tsx` — 引入 `useVipStatus`,用响应式 `isVip` 替换 `isVipMember()`
|
||||
3. `src/pages/shop/checkout.tsx` — 引入 `useVipStatus`,`goodsPrice` useMemo 依赖加 `isVip`;下单时 `OrderGoodsItem` 传 `price` 字段
|
||||
4. `src/pages/shop/index.tsx` — 引入 `useVipStatus`,替换 `isVipMember()`
|
||||
5. `src/pages/shop/category.tsx` — 引入 `useVipStatus`,替换 `isVipMember()`
|
||||
6. `src/pages/shop/cart.tsx` — 引入 `useVipStatus`,替换 `isVipMember()`
|
||||
7. `src/contexts/CartContext.tsx` — CartProvider 引入 `useVipStatus`,`calcPrice` 用响应式 `isVip`
|
||||
8. `src/components/business/SkuSelector/index.tsx` — 引入 `useVipStatus`,替换 `isVipMember()`
|
||||
9. `src/components/common/ProductCard/index.tsx` — 引入 `useVipStatus`,替换 `isVipMember()`
|
||||
- **原理**:`useVipStatus` 在组件挂载时异步调用 `checkAndCacheVipStatus` 查询后端并更新缓存+React state,state 变化触发重渲染,使所有价格显示/计算自动更新为 dealerPrice
|
||||
- 验证:`npx taro build --type weapp` 构建成功
|
||||
|
||||
## 门店订单修改金额 — 修改原因保存字段变更
|
||||
- 文件:`src/pages/store/orders/index.tsx`、`src/api/shop/shopOrder/model/index.ts`
|
||||
- 变更:修改金额时的"修改原因"从追加到 `comments` 字段改为保存到 `merchantRemarks`(商户备注)字段
|
||||
- `ShopOrder` 接口新增 `merchantRemarks?: string` 字段
|
||||
- `submitEditPrice` 中 `updateShopOrder` 传参从 `comments` 改为 `merchantRemarks`
|
||||
|
||||
## 订单备注字段分离(buyerRemarks / merchantRemarks / comments)
|
||||
- 文件:`src/api/shop/shopOrder/model/index.ts`、`src/pages/shop/checkout.tsx`、`src/pages/store/orders/index.tsx`
|
||||
- `ShopOrder` 和 `OrderCreateRequest` 接口均新增 `buyerRemarks?: string`(买家备注)
|
||||
- 结算页 `checkout.tsx`:订单备注从 `comments: remarks` 改为 `buyerRemarks: remarks`
|
||||
- 门店订单列表 `store/orders/index.tsx`:订单卡片新增买家备注展示块(橙色背景,收货信息下方)
|
||||
- 字段约定:`buyerRemarks`=买家下单备注,`merchantRemarks`=商户修改金额原因,`comments`=系统/其他备注
|
||||
|
||||
## 修复线下付款订单状态显示
|
||||
- 文件:`src/components/common/OrderCard/index.tsx`、`src/pages/order/list.tsx`、`src/pages/order/detail.tsx`
|
||||
- 问题:线下付款(payType=9)下单成功后,订单列表和详情页显示"线下付款·待确认",用户认为状态不对
|
||||
- 修复内容:
|
||||
1. OrderCard `getCardStatus`:将线下付款(payType=9)与货到付款(payType=8)统一处理,`!payStatus && !isCod && !isOffline` 才显示"待付款",否则直接按 deliveryStatus 判断,线下付款订单显示"待发货"
|
||||
2. OrderCard `showCloseButton`:增加 `!order.payStatus` 条件,已付款订单不再显示取消按钮(需到详情页退款)
|
||||
3. OrderCard 按钮文案:从"关闭订单"改为"取消订单",与详情页一致
|
||||
4. list.tsx `handleCloseOrder`:弹窗标题改为"确认取消订单",toast 改为"订单已取消"
|
||||
5. detail.tsx `getOrderDisplayStatus`:线下付款未确认时显示"待发货"(蓝色),subtitle 保留付款引导文案
|
||||
6. detail.tsx `getOrderPhase`:保留 `offline_pending` 阶段,底部按钮仍为"取消订单+联系客服"(未确认收款可取消,非退款)
|
||||
|
||||
## 修复地址编辑页智能识别 bug
|
||||
- 文件:`src/pages/user/address-edit.tsx`
|
||||
- 问题:粘贴带标签的地址文本(如「收件人: 赵忠林 / 手机号码: 137... / 所在地区: 广西... / 详细地址: 大学东路...」)识别后:
|
||||
1. 姓名识别失败(之前只看文本开头的 2-4 个汉字,姓名不在开头)
|
||||
2. 详细地址保留了「收件人」「手机号码」「所在地区」「详细地址」等标签文字
|
||||
- 修复 `parseAddressText`:
|
||||
1. 姓名优先匹配 `(?:收件人|收货人|姓名|联系人)[::]\s*([\u4e00-\u9fa5]{2,4})`,找不到再回退到 `^[\u4e00-\u9fa5]{2,4}`
|
||||
2. 详细地址提取前先 strip 标签文字(收件人/手机号/所在地区/详细地址 + 中英冒号),再 strip 省市区,最后 strip 零散标签
|
||||
- 验证:用 Python 模拟(Python 是用 JS 同款正则+RegionData)解析截图中的输入:
|
||||
- name: 赵忠林 ✓
|
||||
- phone: 13737128880 ✓
|
||||
- province/city/region: 广西壮族自治区/南宁市/西乡塘区 ✓
|
||||
- address: 西乡塘街道大学东路9号瀚林御景3栋1单元1101号房 ✓(街道+详细地址完整保留)
|
||||
- 项目 type-check (`npx tsc --noEmit`) 无报错
|
||||
|
||||
## 修复默认地址唯一性 bug
|
||||
- 问题:新增/编辑地址时勾选"设为默认"后,后端直接保存 `isDefault=true`,不清除其他地址的默认状态,导致出现多个默认地址
|
||||
- 后端修复(`/Users/gxwebsoft/JAVA/guilixu-java`,3 个文件):
|
||||
1. `ShopUserAddressService.java` — 新增 `clearDefault(userId, excludeId)` 接口方法
|
||||
2. `ShopUserAddressServiceImpl.java` — 实现 `clearDefault`:查询该用户所有 `isDefault=true` 的地址(可排除指定 id),批量设为 false
|
||||
3. `ShopUserAddressController.java` — `save()` 新增时若 `isDefault=true` 先调 `clearDefault(userId, null)`;`update()` 编辑时若 `isDefault=true` 先调 `clearDefault(userId, id)` 排除当前地址
|
||||
- 前端修复(`src/pages/user/address-edit.tsx`):
|
||||
- 保存成功后若 `isDefault=true`,再调一次 `setDefaultAddress(savedId)` 触发后端清理逻辑(双重保险)
|
||||
- 新增时从返回结果中提取 savedId
|
||||
- 修复 `getShopUserAddress` 返回值类型断言(`as ShopUserAddress`)
|
||||
- 前端渲染兜底(`src/hooks/useAddress.ts`):
|
||||
- `loadAddresses` 中加 `ensureSingleDefault` 工具函数:若后端返回多个 `isDefault=true`,只保留 createTime 最早的为默认,其余置 false
|
||||
- 后端 Maven 编译命令:`"/Applications/IntelliJ IDEA Ultimate.app/Contents/plugins/maven/lib/maven3/bin/mvn" compile`(guilixu 的 mvnw 损坏,用 IntelliJ 自带 Maven)
|
||||
- 编译结果:成功无报错
|
||||
- 注意:之前误改了 paopao-java 和 websopy-java 两个项目(已废弃),正确后端是 guilixu-java
|
||||
|
||||
## 用户商品浏览记录功能(全栈实现)
|
||||
- 需求:后台记录用户浏览商品的痕迹
|
||||
- 方案:去重累加模式(同一用户+商品只留一条,visit_count 累加,5分钟内去重防刷)
|
||||
- 后端(`/Users/gxwebsoft/JAVA/guilixu-java`,7 个文件 + 1 SQL):
|
||||
1. `sql/shop_goods_browse.sql` — 建表 SQL(含3个索引:uk_user_goods 唯一 / idx_user_time / idx_goods_time)
|
||||
2. `ShopGoodsBrowse.java` — Entity(参照 ShopGoodsFavorite 范式,含 visitCount/lastVisitTime/browseSource + 关联商品快照字段)
|
||||
3. `ShopGoodsBrowseParam.java` — 查询参数(merchantId 用 Long 与 BaseParam 一致,含 startTime/endTime 时间范围)
|
||||
4. `ShopGoodsBrowseMapper.java` + XML — Mapper(关联 shop_goods 表查询商品名称/图片/价格)
|
||||
5. `ShopGoodsBrowseService.java` + Impl — Service(核心 recordBrowse 去重累加逻辑:查已有→5分钟内忽略/超过则累加→首次插入)
|
||||
6. `ShopGoodsBrowseController.java` — Controller(POST 上报 / GET page 我的足迹 / DELETE 删单条 / DELETE 清空 / GET admin/page 后台查询)
|
||||
- 前端(`xinlong-shop-taro`,4 个文件):
|
||||
1. `src/api/shop/shopGoodsBrowse/model/index.ts` — ShopGoodsBrowse + ShopGoodsBrowseParam 类型
|
||||
2. `src/api/shop/shopGoodsBrowse/index.ts` — reportBrowse(静默)/pageBrowseHistory/deleteBrowseRecord/clearBrowseHistory
|
||||
3. `src/pages/shop/product-detail.tsx` — addToHistory() 已登录时异步调 reportBrowse 上报
|
||||
4. `src/pages/user/history-list/index.tsx` — 改为服务端分页(登录)/ 本地兜底(未登录),支持删除单条、清空、上拉加载
|
||||
5. `src/pages/user/user.tsx` — 恢复浏览历史入口(取消注释)
|
||||
- 编译验证:后端 Maven compile 成功;前端 tsc --noEmit 无新增类型错误
|
||||
- 编译踩坑:BaseController.success() 有 success(IPage<T>) 重载,泛型方法返回类型用 ApiResult<?> 避免 fail() 分支类型不兼容
|
||||
- 待执行:数据库需手动执行 `shop_goods_browse.sql` 建表
|
||||
- 后续可选:后台管理页面(`/Users/gxwebsoft/VUE/guilixu-admin`,Vue3+AntD Vue)
|
||||
|
||||
## 后台管理端浏览记录页面(guilixu-admin)
|
||||
- 后端补充:`ShopGoodsBrowseController` 新增 `DELETE /admin/{id}` 管理端删除接口(不限定用户),原 `DELETE /{id}` 限定当前用户
|
||||
- 前端文件(`/Users/gxwebsoft/VUE/guilixu-admin`,4 个文件):
|
||||
1. `src/api/shop/shopGoodsBrowse/model/index.ts` — ShopGoodsBrowse + ShopGoodsBrowseParam 类型
|
||||
2. `src/api/shop/shopGoodsBrowse/index.ts` — pageShopGoodsBrowse(调 /admin/page) + removeShopGoodsBrowse(调 /admin/{id})
|
||||
3. `src/views/shop/shopGoodsBrowse/index.vue` — 页面(ele-pro-table 表格,列:ID/用户ID/商品图片/商品名称/价格/浏览次数/浏览来源/最后浏览时间/首次浏览/操作删除)
|
||||
4. `src/views/shop/shopGoodsBrowse/components/search.vue` — 搜索栏(用户ID/商品ID/浏览来源/时间范围)
|
||||
- 管理后台菜单机制:后端动态菜单(sys_menu 表),前端按 `component` 字段约定加载 `src/views/{component}/index.vue`
|
||||
- 菜单配置:后台"系统管理 → 菜单管理"新增,component 填 `shop/shopGoodsBrowse`
|
||||
- 注意:管理后台 API 用 `res.data.code`/`res.data.data`(非小程序端的 `res.code`),request 传参用 `{ params }` 包裹
|
||||
- 编译验证:后端 Maven compile 成功;前端 vue-tsc 无 shopGoodsBrowse 相关错误
|
||||
- 待操作:后端需重新部署(新增了 admin 删除接口);管理后台需配置菜单
|
||||
|
||||
|
||||
## 帮助中心联系客服入口改为直连微信客服
|
||||
- 文件:`src/pages/user/help-center/index.tsx`
|
||||
- 改动:点击「联系在线客服」不再跳转 `/pages/user/customer-service/index`,而是直接用 `<Button openType="contact">` 调起微信客服
|
||||
- 关键属性:`openType="contact"` + `sessionFrom="help_center"` + `showMessageCard` + `sendMessageTitle="欢迎咨询"` + `onContact` 回调
|
||||
- Button 样式重置:height:auto / lineHeight:normal / padding:16px / border:none / borderRadius:8px,保持原视觉(白底圆角卡片)
|
||||
- 引入:从 `@tarojs/components` 新增导入 `Button`
|
||||
- 注意:微信客服 Button 在开发者工具中无效,需真机预览测试
|
||||
|
||||
## 订单详情页查看物流改造(显示 shopOrderDelivery)
|
||||
- 需求:订单详情页点击「查看物流」跳转 `/pages/order/logistics`,原页面强制要求 URL 带 expressNo + expressCompany,但详情页只传了 orderId,导致 toast「缺少物流参数」+ 空状态
|
||||
- 后端修复(`/Users/gxwebsoft/JAVA/guilixu-java`,1 个文件):
|
||||
- `ShopOrderServiceImpl.java` — `getByIdRel(orderId)` 原本只调 `baseMapper.selectListRel(param)`,没有像 `pageRel` 那样填充 `shopOrderDelivery`。新增私有方法 `fillShopOrderDelivery(order)`,在快递配送(deliveryType==0/null)且已发货(deliveryStatus>10)时调 `shopOrderDeliveryService.getByOrderId(orderId)` 查发货单,并关联 `shopExpressService.getById(expressId)` 取 expressName 填充
|
||||
- 编译验证:Maven compile 成功
|
||||
- 前端类型补充(`src/api/shop/shopOrder/model/index.ts`):
|
||||
- 新增 `ShopOrderDelivery` 接口(与后端 ShopOrderDelivery 实体对应:deliveryId/orderId/deliveryMethod/expressId/expressName/sendName/sendPhone/sendAddress/expressNo/createTime 等)
|
||||
- `ShopOrder` 接口新增 `shopOrderDelivery?: ShopOrderDelivery | null` 字段
|
||||
- 前端物流页改造(`src/pages/order/logistics.tsx`):
|
||||
- 不再强制要求 URL 带 expressNo/expressCompany;只要求 orderId
|
||||
- 调 `getShopOrder(orderId)` 加载订单(含后端填充的 shopOrderDelivery)
|
||||
- 运单号优先级:URL expressNo → delivery.expressNo → order.expressNo
|
||||
- 物流轨迹:仅在有 expressNo + expressCompany 代码时才查询 queryLogistics,失败不阻塞发货单展示
|
||||
- 新增「发货信息」卡:发货人/联系方式/发货地址/物流单号(可复制)/发货时间/发货方式(10手动/20无需物流/30电子面单)
|
||||
- 新增「收货信息」卡:从订单读 realName/mobile/address
|
||||
- 保留原物流轨迹时间线(仅在查询到时展示)和温馨提示
|
||||
- 空状态:订单接口也失败时才显示
|
||||
- 后端需重启服务才生效
|
||||
|
||||
## 修复 VIP 价格首页不显示 + 后端不认 price 字段
|
||||
- **问题 1(首页)**:`src/pages/index/index.tsx`(tabBar 首页)没接入 `useVipStatus`,热销推荐价格一直显示 `product.price` 而非 `dealerPrice`。商品详情/商城列表/购物车/分类都修了,唯独漏了 tabBar 首页
|
||||
- **问题 2(后端 DTO 缺字段)**:前端 `OrderGoodsItem` 已传 `price` 字段,但后端 `OrderCreateRequest.OrderGoodsItem`(嵌套静态类)**根本没有 `price` 字段**,Jackson 反序列化直接丢弃。后端 `OrderBusinessService.validateAndCalculateTotal()` 和 `saveOrderGoods()` 都用 `goods.getPrice()` 兜底 —— 这就是「前端传的对、后端处理就错」的根本原因
|
||||
- **修复(3 个文件)**:
|
||||
1. `src/pages/index/index.tsx`
|
||||
- 引入 `useVipStatus`,加 `const { isVip } = useVipStatus()`
|
||||
- 加 `getHotDisplayPrice()`:VIP + 有 dealerPrice → `{price: dealerPrice, original: price}`,否则原逻辑
|
||||
- Price 组件传 `price + original` + 旁边加金色「VIP」角标
|
||||
2. `/Users/gxwebsoft/JAVA/guilixu-java/src/main/java/com/gxwebsoft/shop/dto/OrderCreateRequest.java`
|
||||
- `OrderGoodsItem` 内部类加 `private BigDecimal price;` 字段 + `@DecimalMin("0")` 校验
|
||||
3. `/Users/gxwebsoft/JAVA/guilixu-java/src/main/java/com/gxwebsoft/shop/service/OrderBusinessService.java`
|
||||
- `validateAndCalculateTotal()` 和 `saveOrderGoods()` 两处重构:先取 sku(无论价格来源都要校验 sku 合法性+取库存),再用「前端 price > sku.price > goods.price」优先级决定 actualPrice
|
||||
- 注意不要把 sku 查询和 price 选择耦合在同一个 if-else 链里,否则 VIP+多规格商品会跳过 sku 库存校验
|
||||
- **编译验证**:Maven compile 成功无报错;后端需重启服务才能生效
|
||||
- **价格字段约定(再确认)**:
|
||||
- `goods.price` - 到手价(主价格)
|
||||
- `goods.salePrice` - 市场价(划掉的)
|
||||
- `goods.dealerPrice` - VIP 专享价
|
||||
- 前端传 `dealerPrice` 到 `OrderGoodsItem.price`,后端通过该字段下单
|
||||
|
||||
## 修复订单列表"待付款"Tab 下状态显示"待发货"
|
||||
- 文件:`src/components/common/OrderCard/index.tsx`
|
||||
- 问题:用户切到"待付款"Tab,下方订单卡片状态文字显示"待发货",与 Tab 不一致
|
||||
- 根因:线下付款(payType=9)订单的 `payStatus=false`(数据库 pay_status=0),后端 `statusFilter=0` 的 SQL 条件 `pay_status=0 AND order_status=0` 把它归入"待付款"Tab 是合理的(待商家确认收款)。但 OrderCard 的 `getCardStatus` 因 `isOffline=true` → `!isOffline=false` → 跳过"待付款"分支,进入 `if (deliveryStatus === 10)` → 显示"待发货"
|
||||
- 关键业务规则:
|
||||
- 货到付款(8):后端 Controller `saveLegacy()` 下单时已 `setPayStatus(true)`,不会进入 statusFilter=0
|
||||
- 线下付款(9):保持 `payStatus=false`,会进入 statusFilter=0
|
||||
- 微信支付(1):未付款 `payStatus=false`,正常进入 statusFilter=0
|
||||
- 修复:`getCardStatus` 第 30 行去掉 `&& !isOffline` 条件,改为 `if (!payStatus && !isCod) return { text: '待付款' }`。保留 `!isCod`(货到付款的特殊兜底,虽然正常流程不会走到这里);清理掉不再使用的 `isOffline` 变量
|
||||
- 决策依据:用户认可"线下付款订单出现在待付款 Tab"是合理的,因此只改前端文案判断,不动后端 SQL
|
||||
|
||||
## 修复购物车同一商品并列显示(应合并数量)
|
||||
- 现象:购物车页面同一商品并列显示多行,本应合并数量到一行
|
||||
- 根因:后端 `ShopCartController.save()` 直接调用 MyBatis-Plus 默认 `shopCartService.save()`,每次加购都 INSERT 新行,没有"同用户+商品+SKU已存在则累加数量"逻辑
|
||||
- 后端修复(`/Users/gxwebsoft/JAVA/guilixu-java`,3 个文件):
|
||||
1. `ShopCartService.java` — 接口新增 `boolean addToCart(ShopCart shopCart)` 方法
|
||||
2. `ShopCartServiceImpl.java` — 实现 `addToCart`:按 user_id+goods_id+sku_id 查询是否已存在(skuId 为 null/0 时匹配 IS NULL 或 =0),存在则累加 cart_num 后 updateById,不存在才 save;引入 `QueryWrapper`
|
||||
3. `ShopCartController.java` — `save()` 接口从 `shopCartService.save(shopCart)` 改为 `shopCartService.addToCart(shopCart)`
|
||||
- 注意:`ShopCart.userId` 是 `Integer` 类型(不是 Long),`goodsId` 是 Long
|
||||
- 历史数据清理:提供 `sql/shop_cart_merge_duplicates.sql`,按 (user_id,goods_id,sku_id) 分组合并 cart_num 并删除重复行(sku_id 可能为 NULL,用 `<=>` null-safe 匹配);执行前先备份
|
||||
- 编译验证:Maven compile 成功
|
||||
- 后端需重启服务才生效
|
||||
|
||||
## 购物车加购报 spec_info 字段缺失
|
||||
- 现象:添加购物车时报 `Unknown column 'spec_info' in 'field list'`
|
||||
- 原因:后端实体 `ShopCart.java` 定义了 `specInfo` 字段(存储"颜色:红色|尺寸:L"这类规格信息),但数据库表 `shop_cart` 未建 `spec_info` 列;`ShopCartParam`、`OrderBusinessService` 均在用该字段,属设计需保留
|
||||
- 修复:新建迁移脚本 `/Users/gxwebsoft/JAVA/guilixu-java/sql/shop_cart_add_spec_info.sql`,`ALTER TABLE shop_cart ADD COLUMN spec_info VARCHAR(500) NULL ... AFTER spec`,需在数据库执行
|
||||
|
||||
## 修复登录页「未授权手机号」无降级入口
|
||||
- 现象:用户反复反馈"又又又登录不了了",截图显示点击「手机号快捷登录」按钮后 toast 弹「未授权手机号」,用户被困在登录页
|
||||
- 根因:`src/passport/login.tsx` 第 162-173 行 `onGetPhoneNumber` 回调里 `detail.code` 为 undefined(用户拒绝授权,errMsg 含 `user deny`/`fail`)时只 toast 了一句生硬文案,没有任何引导。但项目里其实有 `/passport/sms-login` 短信登录页(且在 `app.config.ts` 路由表里),`login.tsx` 却完全没暴露这个入口。`register.tsx` 同样有此问题,且其 weapp 环境下隐藏了短信按钮(原 `{!isWeapp && ...}` 判断)
|
||||
- 修复(2 个文件 + 1 个 scss):
|
||||
1. `src/passport/login.tsx`
|
||||
- 新增 `goSmsLogin()` 函数:透传 `redirect` + 邀请参数(`inviter`/`source`/`t`)跳 `/passport/sms-login`
|
||||
- 新增 `showPhoneAuthFailedModal(errMsg)`:按 errMsg 区分场景弹窗引导
|
||||
- `user deny` → "您已取消微信手机号授权,可改用短信验证码登录"
|
||||
- `privacy` → "微信未授权获取手机号,可改用短信验证码登录"
|
||||
- `no permission` → "小程序暂未获得手机号授权,可改用短信验证码登录"
|
||||
- 其他 → 通用"请尝试使用短信验证码登录"
|
||||
- 弹窗带"短信登录"按钮自动跳 sms-login,"我知道了"关闭
|
||||
- 失败分支从 `Taro.showToast` 改为 `showPhoneAuthFailedModal(errMsg)`
|
||||
- 主登录按钮下方新增长期可见的「使用短信验证码登录」链接(仿 register.tsx 的 goSmsLogin)
|
||||
2. `src/passport/login.scss`
|
||||
- 新增 `.login-methods__alt` / `.login-methods__alt-link` 样式(白色下划线小字,居中)
|
||||
3. `src/passport/register.tsx`
|
||||
- 同步加 `showPhoneAuthFailedModal(errMsg)` 弹窗引导
|
||||
- 去掉 `{!isWeapp && <Button>短信验证码注册/登录</Button>}` 包裹,让 weapp 下也始终显示短信按钮作为降级入口
|
||||
- 类型检查:`npx tsc --noEmit` 无报错
|
||||
- 业务规则:项目当前仅信任微信一键登录作为主路径,短信登录一直是兜底通道。后续如发现用户量大,可考虑直接在登录页主流程就两个按钮并排(不再让微信登录独占)
|
||||
|
||||
## sms-login 页面 UI 重构
|
||||
- 原状:截图显示基本是裸的——只看到两个孤零零的 placeholder 文字,发送验证码按钮和登录按钮都看不见。NutUI 的 `Input` + `Button` 在 Taro 小程序上经常渲染不出边框/背景,外层 `flex justify-between` 也没把验证码按钮挤出来
|
||||
- 改造(2 个文件):
|
||||
1. `src/passport/sms-login.tsx` — 弃用 NutUI 改用 Taro 原生 `View/Text/Input`,重做整页布局
|
||||
- 渐变背景 + 浮动光圈(和 login.tsx 同一套色系 `#667eea → #764ba2`)
|
||||
- 顶部圆形 logo(140rpx 白底圆角 + 阴影)+ 标题 + 副标题
|
||||
- 白色卡片输入区(圆角 28rpx + 阴影):手机号(📞)+ 灰线分隔 + 验证码(🔐 + 绿色胶囊「获取验证码」按钮)
|
||||
- 绿色渐变登录大按钮(圆角 50rpx + 阴影 + 字间距 4rpx)
|
||||
- 「← 返回微信快捷登录」白色下划线链接(一键 navigateBack 回到 login.tsx,避免用户被困)
|
||||
- 底部协议 + 服务协议/隐私政策超链接
|
||||
- 业务逻辑(state、60s 倒计时、handleSendCode/handleLogin 校验、redirect 跳转、邀请参数透传)全部保留
|
||||
2. `src/passport/sms-login.scss`(新文件)— 配套样式:渐变背景、浮动光圈动画(gradientMove + float)、卡片毛玻璃、按钮 hover 效果(scale 0.98)、分阶段入场动画(slideDown / slideUp / fadeIn)
|
||||
- 类型检查:`npx tsc --noEmit` 无报错(项目原本的 `@tarojs/taro` 类型错误与本次无关)
|
||||
- 编译验证:`npx taro build --type weapp` 14.89s 成功
|
||||
- 设计稿预览:`.workbuddy/sms-login-preview.html`(按 375×812 真机尺寸渲染的 mockup,可直接浏览器打开看)
|
||||
- 业务规则:login/register/sms-login 三个页面已统一视觉风格(同一套渐变 + 圆形 logo + 大字标题 + 白色卡片),后续新增 passport 类页面照搬这一套即可
|
||||
|
||||
## 修复首页轮播图只显示一张的问题
|
||||
- 文件:`src/pages/index/index.tsx`
|
||||
- 现象:后台一个广告位下配置了 3 张图片,但小程序首页轮播只显示第一张
|
||||
- 根因:原代码把每个 `CmsAd` 作为一个 `SwiperItem`,图片只取 `item.imageList?.[0]?.url`,所以无论后台配几张都只渲染首图
|
||||
- 修复:
|
||||
- 新增 `bannerSlides`:用 `flatMap` 把每个广告位下的 `imageList` 全部展开成独立轮播项,key 用 `adId-uid`(无 uid 则 fallback 到索引)
|
||||
- 保留 `item.image` 单图兜底逻辑,兼容只配单张图片的老数据
|
||||
- 渲染 `SwiperItem` 改为遍历 `bannerSlides`,点击跳转仍使用对应广告位的 `path`
|
||||
- 验证:代码逻辑走查通过;项目原有 `npx tsc --noEmit` 的 `@tarojs/taro` 类型定义错误与本次修改无关
|
||||
|
||||
## 首页轮播图高度改为按后台 width/height 自适应
|
||||
- 文件:`src/pages/index/index.tsx`
|
||||
- 原状:`Swiper` 的 `height` 固定写死 `160px`,与后台广告位配置脱节
|
||||
- 修复:
|
||||
- 新增 `bannerWrapWidth` 状态,页面渲染后用 `Taro.createSelectorQuery().select('.index-banner-wrap').boundingClientRect()` 获取容器实际宽度
|
||||
- 新增 `bannerHeight`(useMemo):取第一个带有效 `width/height` 的广告位,按 `容器宽度 * (height / width)` 计算高度;无值或解析失败则 fallback `160px`
|
||||
- 轮播容器增加 `index-banner-wrap` 类名用于选择器查询
|
||||
- `Swiper` 和「暂无轮播图」占位区的高度都改用 `bannerHeight`
|
||||
- 注意:宽度仍由容器 `w-full` + `mx-3` 边距控制,不直接套用后台 `width` 像素值;高度按比例缩放适配不同屏幕
|
||||
|
||||
|
||||
|
||||
## 修复「我的」页订单数量统计全为 0 的 bug
|
||||
- 文件:`src/api/shop/shopUserCard/index.ts`、`src/api/shop/shopUserCard/model/index.ts`、`src/pages/user/user.tsx`
|
||||
- 根因:前后端字段名不匹配
|
||||
- 后端 `/api/user/orders/stats` 返回 `UserOrderStats` DTO 字段:`total/waitPay/waitDeliver/waitVerify/waitReceive/waitComment/completed/refund/deleted/canceled`(参见 `com.gxwebsoft.shop.dto.UserOrderStats`)
|
||||
- 前端 `getUserOrderStats()` 却读取 `pending/paid/shipped/completed`,导致 `pending/paid/shipped` 永远 undefined → 兜底为 0;只有 `completed` 字段名巧合一致才有数
|
||||
- 附带 bug:user.tsx「我的订单」快捷入口的 `status` 值写的是 0/1/2/3,与 `OrderListStatus`(0待付款 1待发货 3待收货 5已完成)不一致,点击「待收货」「已完成」会跳到订单列表页错误的 tab
|
||||
- 修复:
|
||||
1. `UserOrderStats` 接口字段与后端 DTO 完全对齐(含 total/waitPay/waitDeliver/waitVerify/waitReceive/waitComment/completed/refund/deleted/canceled)
|
||||
2. `getUserOrderStats()` 改读后端真实字段,并用 `Number(d.x) || 0` 兜底
|
||||
3. user.tsx 订单快捷入口 count 改用 `waitPay/waitDeliver/waitReceive/completed`,status 改为 `0/1/3/5` 与 `OrderListStatus` 对齐
|
||||
4. localStorage 缓存 key 从 `user_order_stats_v2` 升级到 `v3`,让旧格式缓存自动失效,避免冷启动先渲染旧格式导致仍是 0
|
||||
- 关键约定:`OrderListStatus` 是前端订单 Tab 筛选状态的唯一真相源(-1全部/0待付款/1待发货/2待核销/3待收货/4待评价/5已完成/6退款/7已删除),所有跳订单列表的入口传 status 都要对齐它
|
||||
|
||||
## 优化「我的订单」角标样式(2026-07-15 22:55)
|
||||
- 文件:`src/pages/user/user.tsx`
|
||||
- 用户反馈:截图中「已完成」下方出现了一个"0"字(hot reload 之前旧版代码里 `<View>{item.count}</View>` 直接渲染了 count,0 也会显示)。同时希望统计数字更"右上角"
|
||||
- 修改:
|
||||
1. 角标位置 `top-0 right-2` → `-top-1 -right-1`:让小红点紧贴 cell 右上角外缘,更像标准徽标
|
||||
2. 角标尺寸 `text-xs` + 自由拉伸 → `min-w-[16px] h-4` + 10px/12px 字体:单数字也保持圆形,多位数字自动变椭圆
|
||||
3. 0 判断 `item.count && item.count > 0` → `item.count && Number(item.count) > 0`:用 `Number()` 包一层,防止后端返回字符串 "0" 时 `> 0` 误判
|
||||
|
||||
## 抽取通用 Badge 组件(2026-07-15 23:10)
|
||||
- 新文件:`src/components/common/Badge/index.tsx`
|
||||
- 用途:替代项目里多处复制粘贴的内联角标 View(user.tsx / index/index.tsx / store/center/index.tsx 都有 `min-w-[16px] h-4 px-1 bg-red-500 rounded-full` 这种重复写法)
|
||||
- API:
|
||||
```ts
|
||||
interface BadgeProps {
|
||||
count?: number | null // 0 / null / undefined / 负数 → 不渲染
|
||||
max?: number // 默认 99,超过显示 `${max}+`
|
||||
className?: string // 外层定位 className,如 'absolute -top-1 -right-1'
|
||||
color?: string // 默认 #ef4444(red-500)
|
||||
fontSize?: number // 默认 10px
|
||||
}
|
||||
```
|
||||
- user.tsx 已替换:内联 `{Number(item.count) > 0 && <View ...>}` → `<Badge count={item.count} className='absolute -top-1 -right-1' />`
|
||||
- 风格遵循项目 common 组件约定:函数式组件 + 默认导出 + Taro View/Text + Tailwind className
|
||||
- 待办(未做):首页 `src/pages/index/index.tsx:248` 和门店中心 `src/pages/store/center/index.tsx:218` 也有相同内联角标写法,后续可统一替换为 Badge
|
||||
|
||||
## 抽取通用 ArrowRight 组件(2026-07-15 23:13)
|
||||
- 新文件:`src/components/common/ArrowRight/index.tsx`
|
||||
- 用途:替代项目里到处可见的 `{'>'}` / `'>'` 文本箭头
|
||||
- 实现:CSS V 形(border-top + border-right + rotate(45deg)),颜色用 `currentColor` 跟父级 `text-*` 类走
|
||||
- API:
|
||||
```ts
|
||||
interface ArrowRightProps {
|
||||
className?: string // 颜色用 text-* 类(如 'text-gray-400'),间距用 ml-1 等
|
||||
size?: number // V 形边长 px,默认 6
|
||||
thickness?: number // 描边粗细 px,默认 1.5
|
||||
}
|
||||
```
|
||||
- user.tsx 已替换 3 处 `{'>'}`:
|
||||
1. 用户卡片头部箭头(白色 60% 透明)→ `<ArrowRight className='text-white text-opacity-60' />`
|
||||
2. 「全部订单 >」→ 拆成 `<View className='flex items-center text-xs text-gray-400'><Text>全部订单</Text><ArrowRight className='ml-1' /></View>`
|
||||
3. 菜单项末尾箭头(amber-700 / gray-300)→ `<ArrowRight className={...} />`
|
||||
- 关键点:Tailwind 的 `text-opacity-60` 会通过 `--tw-text-opacity` 影响 color,currentColor 会继承这个带 opacity 的 color,所以 ArrowRight 的透明度也会跟随
|
||||
- 待办(未做):其它页面也有 `{'>'}` 文本箭头,后续可统一替换
|
||||
|
||||
## 统一替换 index/index.tsx 和 store/center 的内联角标为 Badge(2026-07-15 23:17)
|
||||
- Badge 组件 API 扩展:新增 `size`(默认 16)和 `fontWeight`(默认 'normal')参数;padding 从 className 的 `px-1` 改为 inline style 根据 size 自动计算(16→2px,20→3px),避免与调用方 className 冲突
|
||||
- `src/pages/index/index.tsx:248`:数字角标(16x16/10px)→ `<Badge count={newOrderCount} className='absolute -top-1 -right-1' />`
|
||||
- 注意:保留了 `isClerk && newOrderCount > 0` 外层判断(虽然 Badge 内部 0 不渲染,但下面还有 `newOrderCount === 0` 的小红点逻辑,保留判断让语义更明确)
|
||||
- `src/pages/store/center/index.tsx:218`:数字角标(20x20/11px/加粗)→ `<Badge count={getBadgeCount(card.key)} className='absolute -top-1 -right-1' size={20} fontSize={11} fontWeight='bold' />`
|
||||
- 简化了外层判断:`card.showBadge && getBadgeCount > 0` → `card.showBadge`(Badge 内部已处理 0 不渲染)
|
||||
- 项目内联角标已全部统一为 Badge 组件,共 3 处(user.tsx / index/index.tsx / store/center/index.tsx)
|
||||
|
||||
## tabBar「订单」换「购物车」后的链接修正(2026-07-15 23:31)
|
||||
- 背景:tabBar 原「订单」(pages/order/list) 改为「购物车」(pages/shop/cart),order/list 不再是 tabBar 页
|
||||
- A. 6 处 `switchTab(order/list)` → `navigateTo`(switchTab 只能跳 tabBar 页,否则失败):
|
||||
- pages/shop/checkout.tsx:304(下单成功跳订单列表)
|
||||
- pages/user/user.tsx:144,149(handleOrderTabClick / handleViewAllOrders)
|
||||
- pages/user/index.tsx:158,175,190(订单概览数/全部订单/订单状态快捷入口)
|
||||
- B. 4 处 tabBar 判断列表更新为实际 4 个 tabBar(index/shop/shop-cart/user):
|
||||
- pages/user/index.tsx:11、pages/index/index.tsx:185(tabBarPages)
|
||||
- passport/login.tsx(tabBarUrls,顺带去掉非 tabBar 的 points/index)
|
||||
- passport/auth-flow.ts:12(TABBAR_URLS,原含错误路径 order/order)
|
||||
- C. 2 处 isTabBarUrl 修正(原路径 cart/cart、category/index 不存在):
|
||||
- passport/register.tsx:88、passport/sms-login.tsx:36
|
||||
- D. pages/order/list.tsx:127 注释更新(去掉"tabBar 页"描述)
|
||||
- 遗留(未改,预先存在):pages/user/index.tsx:140 `switchTab(points/index)` 积分页非 tabBar,会失败
|
||||
|
||||
## product-detail 购物车角标改用 Badge 组件(2026-07-15 23:42)
|
||||
- `src/pages/shop/product-detail.tsx` 底部购物车入口的数量角标,原为手写内联 View+Text(带白边框、top:-6px/right:-10px 定位),改为 `<Badge count={totalCount} className='absolute -top-1 -right-1' />`
|
||||
- 用法与 user.tsx "我的订单"图标角标(:284)完全一致;外层已有 `relative inline-flex` 容器,Badge 的 absolute 定位正常
|
||||
- 新增 import `Badge from '@/components/common/Badge'`
|
||||
- 去掉了原来的白色边框,与"我的订单"角标样式统一
|
||||
|
||||
## 修复订单快捷入口 tab 错位 bug(2026-07-15 23:48)
|
||||
- Bug:`pages/user/user.tsx` "我的订单"快捷入口(待付款/待发货/待收货/已完成)把"订单状态值"(0/1/3/5)写入 `order_tab` storage,navigateTo 到 order/list 后,`order/list.tsx:42` 用 `useState(Number(initTab) || 0)` 把传入值直接当 **tabIndex** 用 → 全部错位一个 tab(待付款 0→全部, 待发货 1→待付款, 待收货 3 巧合对, 已完成 5 越界→0→全部)
|
||||
- `pages/user/index.tsx` 传 0/1/2/3 也全错位一个 tab
|
||||
- order/list.tsx `tabList` 顺序:index 0=全部 / 1=待付款 / 2=待发货 / 3=待收货 / 4=已完成
|
||||
- 修复:调用方把字段从 `status`(订单状态)改为 `tabIndex`(1/2/3/4),navigateTo 加 `?tab=${tabIndex}`(order/list 已支持 `router.params.tab` 优先于 storage),未登录拦截的 redirect 也带 `?tab=`
|
||||
- 改动:
|
||||
- `src/pages/user/user.tsx`:`handleOrderTabClick` 参数 `status`→`tabIndex` + redirect/navigateTo 带 `?tab=`;menuItems 字段 `status(0/1/3/5)`→`tabIndex(1/2/3/4)`;`key={item.tabIndex}`、`onClick` 传 `item.tabIndex`
|
||||
- `src/pages/user/index.tsx`:硬编码数组 `status(0/1/2/3)`→`tabIndex(1/2/3/4)`;`key={item.tabIndex}`、onClick + `?tab=${item.tabIndex}`
|
||||
- 其他"全部订单"类入口(user.tsx:149 handleViewAllOrders、user/index.tsx:22/158/175)不传 `?tab=`,进默认"全部" tab,合理
|
||||
66
.workbuddy/memory/2026-07-16.md
Normal file
66
.workbuddy/memory/2026-07-16.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# 2026-07-16 工作记录
|
||||
|
||||
## 修复手机号授权登录失败
|
||||
|
||||
- 问题:用户点击登录页「手机号快捷登录」后,微信返回 privacy 相关错误,触发降级弹窗「未获取到手机号」。
|
||||
- 原因:基础库 3.16.1+ 对敏感 API(getPhoneNumber / chooseImage 等)强制要求隐私协议授权;项目之前直接 resolve agree,可能被微信拒绝。
|
||||
- 修改文件:
|
||||
- `src/app.tsx`:在 `onNeedPrivacyAuthorization` 中通过 `Taro.showModal` 展示隐私协议授权弹窗,用户点击「同意」后再 `resolve({ event: 'agree', button: 'agree' })`,拒绝则 `resolve({ event: 'disagree', button: 'disagree' })`。
|
||||
- `src/api/system/file/index.ts`:导出 `ensurePrivacyAuthorized()` 工具函数。
|
||||
- `src/passport/login.tsx`:
|
||||
- 页面显示时通过 `useDidShow` 预检隐私协议;
|
||||
- 统一使用 `request.post` + `SERVER_API_URL` 调用 `/wx-login/loginByMpWxPhone`;
|
||||
- 替换原 `Taro.request` 和硬编码域名。
|
||||
- `src/passport/register.tsx`:
|
||||
- 同样增加 `useDidShow` 隐私协议预检;
|
||||
- 修复接口基址为 `SERVER_API_URL`(原代码误写 `https://shop-api.websoft.top`);
|
||||
- 统一使用 `request.post` 调用登录接口;
|
||||
- 删除未使用的 `LoginResponse` 类型。
|
||||
- `src/passport/pay/index.tsx`:修复缺失的 `@/api/passport/wx-login` 导入,改为使用 `@/api/layout` 中的 `getWxOpenId` / `loginByOpenId`。
|
||||
- 验证:`npx taro build --type weapp` 构建成功。
|
||||
- 后续提醒:小程序后台需确认已添加「开发者收集你的手机号」隐私声明,并确保「手机号快速验证组件」能力已开通且额度充足。
|
||||
|
||||
## 继续修复:用户已同意隐私协议仍无法登录
|
||||
|
||||
- 问题:用户已在弹窗中点击「同意」,但随后仍出现「未获取到手机号」降级弹窗。
|
||||
- 原因:`Taro.showModal` 的按钮不是微信认可的隐私授权触发器;基础库 3.16.1+ 要求使用 `open-type="agreePrivacyAuthorization"` 的 Button 组件。
|
||||
- 修改文件:
|
||||
- 新增 `src/utils/privacy.ts`:全局隐私协议授权管理器,保存 resolve 回调与弹窗显隐状态。
|
||||
- 新增 `src/components/PrivacyModal/index.tsx` + `.scss`:使用原生 Button 的 `open-type="agreePrivacyAuthorization"`,用户点击后触发 `onAgreePrivacyAuthorization` 再 resolve。
|
||||
- `src/app.tsx`:
|
||||
- 移除 `Taro.showModal` 实现;
|
||||
- 在 `onNeedPrivacyAuthorization` 中调用 `privacyManager.show(resolve)`;
|
||||
- 在 JSX 中渲染 `<PrivacyModal />`。
|
||||
- 验证:`npx taro build --type weapp` 构建成功。
|
||||
- 后续提醒:
|
||||
- 由于之前的 `showModal` 授权无效,用户可能需要清理开发者工具/真机缓存后重新进入小程序,才能触发新的 PrivacyModal。
|
||||
- 仍需在小程序后台添加「手机号」隐私声明,并确认手机号快速验证组件已开通、额度充足。
|
||||
|
||||
## 调整隐私授权触发位置:登录页不再主动预检,仅在门店上传图片时触发
|
||||
|
||||
- 用户反馈:隐私授权框不应在手机号登录时主动弹出,应只在门店中心上传图片/拍照时弹出。
|
||||
- 修改文件:
|
||||
- `src/passport/login.tsx`:移除 `useDidShow` 中主动调用 `ensurePrivacyAuthorized()` 的逻辑;删除对应 import。
|
||||
- `src/passport/register.tsx`:同样移除 `useDidShow` 中的隐私预检;删除对应 import。
|
||||
- `src/components/PrivacyModal/index.tsx`:将弹窗文案改为更通用表述,避免用户误以为是相册授权。
|
||||
- `src/pages/store/orders/index.tsx`:在 `chooseProofImage` 调用 `Taro.chooseImage` 前增加 `ensurePrivacyAuthorized()` 预检(该页面直接调 chooseImage,不走 `uploadFile`)。
|
||||
- 保留逻辑:
|
||||
- `src/app.tsx` 全局 `onNeedPrivacyAuthorization` 仍通过 `PrivacyModal` 处理,这是微信基础库 3.16.1+ 强制机制,无法移除。
|
||||
- `src/api/system/file/index.ts` 的 `uploadFile()` 内部已调用 `ensurePrivacyAuthorized()`,门店商品管理上传图片时会自动触发隐私授权。
|
||||
- 说明:登录时若用户尚未同意隐私协议,微信仍会在点击 `getPhoneNumber` 按钮时强制触发 `onNeedPrivacyAuthorization`。这是微信机制,无法避免;但进入登录页本身不会再主动弹框。
|
||||
- 验证:待构建完成。
|
||||
|
||||
## 恢复手机号快捷登录功能(回退 request → Taro.request)
|
||||
|
||||
- 背景:origin/main 上 `ca78e73`(小程序支付隐私 commit)把 `passport/login.tsx` 和 `passport/register.tsx` 的登录请求从 `Taro.request` 改成了统一封装的 `request` 工具(`@/utils/request`)。
|
||||
- 问题根因:`request` 拦截器会自动从 storage 读取 `access_token` 并注入 Authorization 头;对于 `loginByMpWxPhone` 这种登录接口,如果本地残留旧的/过期 token,请求会带上它,后端返回 401 或认证冲突,导致快捷登录失败。原来的 `Taro.request` 版本不带这个头,所以没问题。
|
||||
- 恢复方式:通过 git diff 生成 patch,将两个文件的登录请求改回 `Taro.request`(直接请求,不带 Authorization 头),保留其他所有改进(隐私协议 PrivacyModal、UI、邀请关系处理、禁用检查等)。
|
||||
- 具体改动:
|
||||
- `src/passport/login.tsx`:
|
||||
- 移除 `import request from '@/utils/request'` 和 `SERVER_API_URL` 导入;
|
||||
- `fetchUserInfo()` 改回 `Taro.request` GET `/api/auth/user`,手动带 `Authorization: Bearer ${token}` 头;
|
||||
- `handleGetPhoneNumber` 中登录请求改回 `Taro.request` POST `/api/wx-login/loginByMpWxPhone`,不带 Authorization 头;
|
||||
- 响应数据结构从 `res.code/res.data` 调整为 `res.data.code/res.data.data`(Taro.request 多一层包裹)。
|
||||
- `src/passport/register.tsx`:同样改动,恢复 `Taro.request`,移除 `request` 和 `SERVER_API_URL` 导入。
|
||||
- patch 文件:`login_recover.patch`、`register_recover.patch`(已应用到工作区,未提交)。
|
||||
|
||||
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)。
|
||||
@@ -4,14 +4,28 @@
|
||||
- 项目名:xinlong-shop-taro(Taro 微信小程序商城)
|
||||
- 技术栈:Taro + React + TypeScript + Tailwind CSS
|
||||
- 后端 API 前缀:`/shop/`、`/api/`
|
||||
- **后端项目路径:`/Users/gxwebsoft/JAVA/guilixu-java`**(⚠️ 不要找 paopao-java 或 websopy-java,那是旧的)
|
||||
- **后端 Maven 编译命令**:`cd /Users/gxwebsoft/JAVA/guilixu-java && "/Applications/IntelliJ IDEA Ultimate.app/Contents/plugins/maven/lib/maven3/bin/mvn" compile`(guilixu 的 mvnw 损坏,用 IntelliJ 自带的 mvn)
|
||||
- **API 域名配置**(`config/env.js` 三环境一致):`API_BASE_URL=https://shop-api.websoft.top/api`、`SERVER_API_URL=https://server.websoft.top/api`、`CMS_API_URL=https://cms-api.websoft.top/api`;`config/app.ts` 导出 `BaseUrl/ServerBaseUrl/CmsBaseUrl`
|
||||
- **幻灯片广告(轮播图)调用已切到 cms-api**:`src/api/cms/cmsAd/index.ts` 的读接口(`listCmsAd`/`getCmsAd`/`getCmsAdByCode`/`pageCmsAd`)通过 `cmsUrl()` 拼接 `CmsBaseUrl`,首页轮播 `listCmsAd({ adType: 'banner', status: 0 })` 走 `https://cms-api.websoft.top/api/cms/cms-ad`;写接口仍走 shop-api
|
||||
- 门店店员身份判断:通过 `getMyClerk()` API (`/shop/shop-store-user/my`) 返回值判断
|
||||
- VIP/分销商申请记录表:`ShopDealerApply`,状态码 10待审核/20通过/30驳回
|
||||
|
||||
## 用户状态与禁用机制
|
||||
- 用户状态字段:`User.status`,`0` = 正常,`1` = 禁用
|
||||
- 禁用接口:`updateUserStatus(userId, status)` → PUT `/system/user/status`
|
||||
- 禁用拦截工具:`src/utils/auth.ts` 中 `isUserDisabled(user)` 和 `blockDisabledUser(user)`
|
||||
- 拦截层级:UserContext 启动异步校验 + refreshUser/syncFromStorage/loginUser 检查 + 登录 API 和登录页检查
|
||||
- 用户管理页:`src/pages/store/users/index.tsx`(门店中心入口,支持搜索/筛选/查看详情/禁用启用)
|
||||
|
||||
## VIP 会员机制
|
||||
- 用户在 `vip-upgrade` 页面提交门店名称和地址申请
|
||||
- 门店店员在 `vip-review` 页面审核(通过/驳回)
|
||||
- VIP 会员特权:商品显示 `dealerPrice` 价格,下单使用该价格结算
|
||||
- VIP 状态判断:`isVipMember()` 从 localStorage 缓存读取,`checkAndCacheVipStatus(userId)` 异步更新
|
||||
- VIP 状态判断:`isVipMember()` 从 localStorage 缓存读取(同步),`checkAndCacheVipStatus(userId)` 异步更新
|
||||
- **响应式 VIP 状态**:使用 `useVipStatus` hook(`src/hooks/useVipStatus.ts`),组件挂载时异步校验并更新缓存 + React state,isVip 变化触发重渲染
|
||||
- **已接入 `useVipStatus` 的页面/组件**:商品详情、结算、商城首页、分类页、购物车页、CartContext、SkuSelector、ProductCard
|
||||
- `OrderGoodsItem` 接口新增 `price` 字段,VIP 下单时传 dealerPrice 给后端
|
||||
|
||||
## 商品价格字段约定
|
||||
- `product.price` - 到手价(主价格)
|
||||
@@ -27,6 +41,7 @@
|
||||
- 门店中心:`src/pages/store/center/index.tsx`
|
||||
- 购物车 API:`src/api/shop/shopCart/index.ts`(批量删除接口 `/shop/shop-cart/batch` 后端期望直接接收 `ArrayList<Integer>` 即 `[1,2,3]`,不要用 `{ ids }` 包裹)
|
||||
- 购物车 Context:`src/contexts/CartContext.tsx`
|
||||
- 购物车合并加购:后端 `ShopCartServiceImpl.addToCart()` 按 user_id+goods_id+sku_id 判重,已存在则累加 cart_num,不存在才新增;skuId 为 null/0(单规格)匹配 IS NULL 或 =0。`ShopCart.userId` 是 Integer(非 Long)。历史重复数据清理脚本 `sql/shop_cart_merge_duplicates.sql`
|
||||
|
||||
## 订阅消息
|
||||
- 模板 ID:`sh1K9iK7vZjebUNFu6OsMsnsJxm4whThWGrhN7I4zVg`(交易提醒)
|
||||
@@ -34,6 +49,28 @@
|
||||
- 场景说明:下单提醒
|
||||
- 新订单检测 Hook:`src/hooks/useNewOrderDetector.ts`(30秒轮询,页面隐藏暂停)
|
||||
|
||||
## 微信隐私协议(基础库 3.16.1+ 强制)
|
||||
- 报错特征:`chooseImage:fail api scope is not declared in the privacy agreement` / `getPhoneNumber:fail ... privacy ...` + `errno:112`
|
||||
- 代码修复:
|
||||
1. 调用 `Taro.chooseImage` / `chooseMedia` / `getPhoneNumber` 等敏感 API 前,先 `getPrivacySetting` → `requirePrivacyAuthorize` 预检(封装在 `src/api/system/file/index.ts` 的 `ensurePrivacyAuthorized()` 里,已 export)。**注意**:不应在登录页 `useDidShow` 中主动预检,避免用户一进入登录页就弹框;应由微信在用户点击 `getPhoneNumber` 时自行触发 `onNeedPrivacyAuthorization`。
|
||||
2. `src/app.tsx` 的 `useLaunch` 中注册 `Taro.onNeedPrivacyAuthorization` 回调,并通过 `src/components/PrivacyModal` 展示隐私协议授权弹窗。弹窗内的 Button 必须设置 `open-type="agreePrivacyAuthorization"`,用户点击后触发 `onAgreePrivacyAuthorization` 再 `resolve({ event: 'agree', button: 'agree' })`;`Taro.showModal` 的按钮无法被微信识别为有效的隐私授权
|
||||
3. 门店上传图片页面在调用 `chooseImage` 前主动预检:`src/pages/store/orders/index.tsx` 的 `chooseProofImage` 已加 `ensurePrivacyAuthorized()`;`src/pages/store/goods/index.tsx` 通过 `uploadFile()` 上传,`uploadFile()` 内部已预检。
|
||||
- 手动配置:**小程序管理后台** → 设置 → 第三方设置 → 用户隐私保护指引 → 添加「开发者收集你的相册/摄像头」和「手机号」声明
|
||||
- ⚠️ **`requiredPrivateInfos` 字段只接受位置类 API 白名单**(chooseAddress/chooseLocation/choosePoi/getFuzzyLocation/getLocation/onLocationChange/startLocationUpdate/startLocationUpdateBackground),不要加 `chooseImage`/`chooseMedia`/`getPhoneNumber` 等非位置类,会导致 app.json 解析失败
|
||||
- 直接调 `Taro.chooseImage` 的页面:`pages/order/evaluate/index.tsx`、`pages/after-sale/apply/index.tsx`、`pages/store/orders/index.tsx` —— 这些页面绕过了 `uploadFile`,需要单独加 `ensurePrivacyAuthorized` 预检
|
||||
- 直接调 `getPhoneNumber` 的页面:`passport/login.tsx`、`passport/register.tsx` —— 不再主动预检,依赖微信点击按钮时自动触发 `onNeedPrivacyAuthorization`;保留降级短信登录
|
||||
- 隐私授权弹窗组件:`src/components/PrivacyModal/index.tsx`(全局单例,通过 `src/utils/privacy.ts` 管理显隐与 resolve)
|
||||
|
||||
## 手机号授权登录降级方案(2026-07-15 ~ 07-16 修复)
|
||||
- **业务现实**:`getPhoneNumber` 按钮被用户拒绝后,微信会短期"记住"用户的选择,再次点击会直接 fail(`errMsg: getPhoneNumber:fail user deny`)。一旦进入这个状态,前端必须提供降级入口
|
||||
- **统一模式**:`passport/login.tsx` 和 `passport/register.tsx` 都有 `getPhoneNumber` 回调,**不允许**只 toast 一句"未授权手机号"就完事
|
||||
- 必做:失败时调 `showPhoneAuthFailedModal(errMsg)`,按 errMsg 区分场景弹窗(user deny / privacy / no permission / 通用)
|
||||
- 弹窗带"短信登录"按钮自动跳 `/passport/sms-login`(透传 redirect + 邀请参数 inviter/source/t)
|
||||
- 必做:主登录按钮**长期可见**地提供"使用短信验证码登录"链接,不要依赖弹窗
|
||||
- **接口地址统一**:login/register 的手机号登录接口统一使用 `SERVER_API_URL + '/wx-login/loginByMpWxPhone'`(register 之前误写 `https://shop-api.websoft.top`)
|
||||
- **短信登录页**:`/passport/sms-login`(路由表里已有),调 `loginBySms` + `sendSmsCaptcha` 接口
|
||||
- **已落地页面**:`passport/login.tsx`、`passport/register.tsx`(隐私协议预检 + 统一 request + 降级弹窗)
|
||||
|
||||
## 分享 / 朋友圈能力(2026-07-13 接入)
|
||||
- 统一封装:`src/hooks/useShare.ts`(`useShare({title, path?, query?, imageUrl?, enableTimeline?, enableCopyUrl?})`,一次注册 `useShareAppMessage` + `useShareTimeline` + 可选 `onCopyUrl` 复制链接;自动在 path/query 追加 `inviter=${当前用户id}` 做裂变归因;`isTimelineSinglePage()` 判断朋友圈单页模式 scene===1154)
|
||||
- 海报组件:`src/components/SharePoster/index.tsx`(Canvas 2D 绘制封面+标题+价格+小程序码,返回临时图路径作 imageUrl)
|
||||
@@ -41,3 +78,28 @@
|
||||
- 裂变约定:分享链接 query 用 `inviter`,与 `src/utils/invite.ts` 的 `parseInviteParams` 读取字段一致
|
||||
- 已接入页面:商品详情、积分商品、拼团、秒杀、活动、赛事(带 canvas 海报);邀请下级、分销推广、升级VIP、分享赚佣金(仅 enableShare + useShare,无海报);首页(好友+朋友圈+复制链接三按钮全亮)
|
||||
- 注意:tabBar 页**也支持朋友圈分享**(之前误判已纠正)——只要 config 开 enableShareTimeline + 定义 onShareTimeline 即显示按钮;"tabBar 不渲染"仅指朋友圈打开后的单页模式里底部 tabBar 不显示。朋友圈/复制链接开发者工具可能看不到入口,必须真机测试。复制链接是 Beta(基础库 2.14.3+)。
|
||||
|
||||
## 订单备注字段约定(2026-07-15)
|
||||
- `buyerRemarks` — 买家备注:用户下单时填写的订单备注(checkout 页提交)
|
||||
- `merchantRemarks` — 商户备注:门店修改金额时填写的原因记录
|
||||
- `comments` — 系统/其他备注:保留给系统内部使用
|
||||
- `ShopOrder` 和 `OrderCreateRequest` 接口均已包含 `buyerRemarks` 和 `merchantRemarks` 字段
|
||||
|
||||
## 用户浏览记录功能(2026-07-15)
|
||||
- 存储模式:**去重累加**(同一用户+商品只留一条,visit_count 累加,5分钟内去重)
|
||||
- 数据库表:`shop_goods_browse`(字段:id/user_id/goods_id/tenant_id/merchant_id/visit_count/last_visit_time/browse_source/create_time/update_time)
|
||||
- 唯一索引:`uk_user_goods (user_id, goods_id)`;查询索引:`idx_user_time`、`idx_goods_time`
|
||||
- 后端 API 路径:`/api/shop/shop-goods-browse`(POST 上报 / GET page 我的足迹 / DELETE {id} 删单条 / DELETE 清空 / GET admin/page 后台查询)
|
||||
- 前端 API:`src/api/shop/shopGoodsBrowse/index.ts`(reportBrowse 静默上报 / pageBrowseHistory / deleteBrowseRecord / clearBrowseHistory)
|
||||
- 商品详情页 `product-detail.tsx` 的 `addToHistory()` 已接入:已登录时异步调 reportBrowse,未登录仅本地 browse_history
|
||||
- 浏览历史页 `src/pages/user/history-list/index.tsx` 已改为服务端分页(登录),未登录回退本地存储
|
||||
- 后端文件:`ShopGoodsBrowse.java`(entity) / `ShopGoodsBrowseParam.java` / `ShopGoodsBrowseMapper.java`+XML / `ShopGoodsBrowseService.java`+Impl / `ShopGoodsBrowseController.java`
|
||||
- 建表 SQL:`/Users/gxwebsoft/JAVA/guilixu-java/sql/shop_goods_browse.sql`
|
||||
- 后台管理前端:`/Users/gxwebsoft/VUE/guilixu-admin`(Vue3 + Ant Design Vue + Vite,非 RuoYi)
|
||||
- 注意:`BaseController.success()` 有 `success(IPage<T>)` 重载,泛型方法返回类型用 `ApiResult<?>` 而非 `ApiResult<PageResult<T>>`(否则 fail() 分支类型不兼容)
|
||||
|
||||
## tabBar 配置(2026-07-15)
|
||||
- 当前 4 个 tabBar:`/pages/index/index`(首页)、`/pages/shop/index`(分类)、`/pages/shop/cart`(购物车)、`/pages/user/user`(我的)
|
||||
- 「订单」(pages/order/list) 已从 tabBar 移除,改为普通页,所有进入订单列表的地方用 `navigateTo` 而非 `switchTab`
|
||||
- 多处 tabBar 判断列表(login/register/sms-login/auth-flow/user/index/index/index)已统一为实际 4 个 tabBar 路径
|
||||
- ⚠️ pages/user/index.tsx:140 仍用 `switchTab(points/index)`,积分页非 tabBar,待修
|
||||
|
||||
309
.workbuddy/sms-login-preview.html
Normal file
309
.workbuddy/sms-login-preview.html
Normal file
@@ -0,0 +1,309 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>sms-login UI 预览</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
background: #f0f0f3;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 40px 0 80px;
|
||||
font-family: -apple-system, "PingFang SC", "Helvetica Neue", sans-serif;
|
||||
}
|
||||
h1 { font-size: 18px; color: #666; margin-bottom: 30px; font-weight: 500; }
|
||||
.phone {
|
||||
width: 375px;
|
||||
height: 812px;
|
||||
background: #fff;
|
||||
border-radius: 50px;
|
||||
box-shadow: 0 30px 80px rgba(0,0,0,0.18), 0 0 0 12px #1a1a1a, 0 0 0 14px #2a2a2a;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.phone::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 150px;
|
||||
height: 28px;
|
||||
background: #1a1a1a;
|
||||
border-radius: 0 0 18px 18px;
|
||||
z-index: 100;
|
||||
}
|
||||
.status-bar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 44px;
|
||||
padding: 0 30px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 15px;
|
||||
color: #1a1a1a;
|
||||
font-weight: 600;
|
||||
z-index: 50;
|
||||
}
|
||||
.status-bar .right {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.nav-bar {
|
||||
position: absolute;
|
||||
top: 44px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
z-index: 50;
|
||||
}
|
||||
.nav-bar::before {
|
||||
content: "<";
|
||||
position: absolute;
|
||||
left: 18px;
|
||||
font-size: 24px;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
.nav-bar::after {
|
||||
content: "···";
|
||||
position: absolute;
|
||||
right: 60px;
|
||||
font-size: 22px;
|
||||
color: #1a1a1a;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
.screen {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding-top: 88px;
|
||||
}
|
||||
.bg-circle {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
opacity: 0.1;
|
||||
}
|
||||
.bg-c1 { width: 400px; height: 400px; top: -150px; right: -100px; }
|
||||
.bg-c2 { width: 300px; height: 300px; bottom: 10%; left: -100px; opacity: 0.08; }
|
||||
.bg-gradient {
|
||||
position: absolute;
|
||||
top: -50%; left: -50%; right: -50%; bottom: -50%;
|
||||
background: radial-gradient(circle at 30% 50%, rgba(255,255,255,0.1) 0%, transparent 50%),
|
||||
radial-gradient(circle at 70% 80%, rgba(255,255,255,0.08) 0%, transparent 50%);
|
||||
}
|
||||
.content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 0 50px;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 32px;
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
.logo {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
border-radius: 35px;
|
||||
background: rgba(255,255,255,0.95);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 36px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
||||
}
|
||||
.logo-icon { font-size: 72px; line-height: 1; }
|
||||
.title {
|
||||
font-size: 52px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
margin-bottom: 18px;
|
||||
text-shadow: 0 2px 10px rgba(0,0,0,0.2);
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
.subtitle {
|
||||
font-size: 26px;
|
||||
color: rgba(255,255,255,0.85);
|
||||
text-align: center;
|
||||
}
|
||||
.card {
|
||||
background: rgba(255,255,255,0.98);
|
||||
border-radius: 28px;
|
||||
padding: 12px 32px;
|
||||
margin-bottom: 48px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.18);
|
||||
}
|
||||
.field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 110px;
|
||||
}
|
||||
.field-icon { font-size: 36px; margin-right: 20px; line-height: 1; }
|
||||
.field-input {
|
||||
flex: 1;
|
||||
font-size: 30px;
|
||||
color: #333;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
.field-input::placeholder { color: #b8b8b8; }
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: linear-gradient(to right, transparent, #e8e8e8 20%, #e8e8e8 80%, transparent);
|
||||
}
|
||||
.send-btn {
|
||||
flex-shrink: 0;
|
||||
height: 64px;
|
||||
padding: 0 22px;
|
||||
border-radius: 32px;
|
||||
background: linear-gradient(135deg, #07c160, #06ad56);
|
||||
color: #fff;
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: 16px;
|
||||
box-shadow: 0 6px 20px rgba(7, 193, 96, 0.35);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.submit {
|
||||
height: 100px;
|
||||
width: 100%;
|
||||
border-radius: 50px;
|
||||
background: linear-gradient(135deg, #07c160, #06ad56);
|
||||
color: #fff;
|
||||
font-size: 34px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
letter-spacing: 4px;
|
||||
box-shadow: 0 12px 36px rgba(7, 193, 96, 0.4);
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
.back {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 80px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.back-icon { font-size: 30px; color: rgba(255,255,255,0.9); margin-right: 10px; }
|
||||
.back-text {
|
||||
font-size: 28px;
|
||||
color: rgba(255,255,255,0.9);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.tips {
|
||||
position: absolute;
|
||||
bottom: 40px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 0 50px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tips-text { font-size: 22px; color: rgba(255,255,255,0.7); }
|
||||
.tips-link { font-size: 22px; color: #fff; text-decoration: underline; margin: 0 4px; }
|
||||
.legend {
|
||||
margin-top: 30px;
|
||||
padding: 20px 28px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
|
||||
font-size: 13px;
|
||||
color: #555;
|
||||
max-width: 375px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.legend strong { color: #07c160; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>sms-login.tsx 新 UI 预览(按真机 375×812 渲染)</h1>
|
||||
|
||||
<div class="phone">
|
||||
<div class="status-bar">
|
||||
<span>18:36</span>
|
||||
<span class="right">📶 🔋</span>
|
||||
</div>
|
||||
<div class="nav-bar">验证码登录</div>
|
||||
|
||||
<div class="screen">
|
||||
<div class="bg-gradient"></div>
|
||||
<div class="bg-circle bg-c1"></div>
|
||||
<div class="bg-circle bg-c2"></div>
|
||||
|
||||
<div class="content">
|
||||
<div class="header">
|
||||
<div class="logo"><span class="logo-icon">📱</span></div>
|
||||
<div class="title">验证码登录</div>
|
||||
<div class="subtitle">未注册的手机号验证后将自动注册</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="field">
|
||||
<span class="field-icon">📞</span>
|
||||
<input class="field-input" type="text" placeholder="请输入手机号码" maxlength="11" />
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
<div class="field">
|
||||
<span class="field-icon">🔐</span>
|
||||
<input class="field-input" type="text" placeholder="请输入6位验证码" maxlength="6" />
|
||||
<div class="send-btn">获取验证码</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="submit">登录 / 注册</div>
|
||||
|
||||
<div class="back">
|
||||
<span class="back-icon">←</span>
|
||||
<span class="back-text">返回微信快捷登录</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tips">
|
||||
<span class="tips-text">登录即代表您已阅读并同意</span>
|
||||
<span class="tips-link">《服务协议》</span>
|
||||
<span class="tips-text">和</span>
|
||||
<span class="tips-link">《隐私政策》</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="legend">
|
||||
<strong>视觉变更:</strong><br>
|
||||
① 紫蓝渐变背景 + 浮动光圈(和 login.tsx 同一套色系)<br>
|
||||
② 顶部圆形 logo + 大字标题 + 副标题<br>
|
||||
③ 白色卡片输入区(圆角 28px + 阴影),手机号/验证码图标 + 灰线分隔<br>
|
||||
④ 验证码右侧绿色胶囊「获取验证码」按钮(倒计时会变成「60s 后重试」)<br>
|
||||
⑤ 绿色渐变登录大按钮(圆角 50px + 阴影)<br>
|
||||
⑥ 「← 返回微信快捷登录」白色下划线链接(避免用户被困住)<br>
|
||||
⑦ 底部协议 + 服务协议/隐私政策超链接<br>
|
||||
<br>
|
||||
<strong>业务逻辑保留:</strong>表单 state、60s 倒计时、handleSendCode/handleLogin 校验、redirect 跳转、邀请参数透传(inviter/source/t)全部不变
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
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()
|
||||
|
||||
316
models.json
Normal file
316
models.json
Normal file
@@ -0,0 +1,316 @@
|
||||
{
|
||||
"models": [
|
||||
{
|
||||
"id": "auto",
|
||||
"name": "AuTo",
|
||||
"vendor": "Custom",
|
||||
"url": "https://api.getaigate.com/v1/chat/completions",
|
||||
"apiKey": "sk-aigate-2335f1670e169eb855c88f6ed58862cb",
|
||||
"maxInputTokens": 128000,
|
||||
"maxOutputTokens": 32000,
|
||||
"supportsToolCall": true,
|
||||
"supportsImages": true,
|
||||
"supportsReasoning": true,
|
||||
"useCustomProtocol": false
|
||||
},
|
||||
{
|
||||
"id": "deepseek-v4-pro",
|
||||
"name": "Deepseek-v4-pro",
|
||||
"vendor": "Custom",
|
||||
"url": "https://api.getaigate.com/v1/chat/completions",
|
||||
"apiKey": "sk-aigate-2335f1670e169eb855c88f6ed58862cb",
|
||||
"maxInputTokens": 1000000,
|
||||
"maxOutputTokens": 32000,
|
||||
"supportsToolCall": true,
|
||||
"supportsImages": true,
|
||||
"supportsReasoning": true,
|
||||
"useCustomProtocol": false
|
||||
},
|
||||
{
|
||||
"id": "deepseek-v4-flash",
|
||||
"name": "Deepseek-v4-flash",
|
||||
"vendor": "Custom",
|
||||
"url": "https://api.getaigate.com/v1/chat/completions",
|
||||
"apiKey": "sk-aigate-2335f1670e169eb855c88f6ed58862cb",
|
||||
"maxInputTokens": 1000000,
|
||||
"maxOutputTokens": 32000,
|
||||
"supportsToolCall": true,
|
||||
"supportsImages": true,
|
||||
"supportsReasoning": true,
|
||||
"useCustomProtocol": false
|
||||
},
|
||||
{
|
||||
"id": "deepseek-v3-2-volc",
|
||||
"name": "Deepseek-v3-2-volc",
|
||||
"vendor": "Custom",
|
||||
"url": "https://api.getaigate.com/v1/chat/completions",
|
||||
"apiKey": "sk-aigate-2335f1670e169eb855c88f6ed58862cb",
|
||||
"maxInputTokens": 128000,
|
||||
"maxOutputTokens": 32000,
|
||||
"supportsToolCall": true,
|
||||
"supportsImages": true,
|
||||
"supportsReasoning": true,
|
||||
"useCustomProtocol": false
|
||||
},
|
||||
{
|
||||
"id": "deepseek-v3-1",
|
||||
"name": "Deepseek-v3-1",
|
||||
"vendor": "Custom",
|
||||
"url": "https://api.getaigate.com/v1/chat/completions",
|
||||
"apiKey": "sk-aigate-2335f1670e169eb855c88f6ed58862cb",
|
||||
"maxInputTokens": 128000,
|
||||
"maxOutputTokens": 32000,
|
||||
"supportsToolCall": true,
|
||||
"supportsImages": true,
|
||||
"supportsReasoning": true,
|
||||
"useCustomProtocol": false
|
||||
},
|
||||
{
|
||||
"id": "deepseek-v3-0324",
|
||||
"name": "Deepseek-v3-0324",
|
||||
"vendor": "Custom",
|
||||
"url": "https://api.getaigate.com/v1/chat/completions",
|
||||
"apiKey": "sk-aigate-2335f1670e169eb855c88f6ed58862cb",
|
||||
"maxInputTokens": 128000,
|
||||
"maxOutputTokens": 32000,
|
||||
"supportsToolCall": true,
|
||||
"supportsImages": true,
|
||||
"supportsReasoning": true,
|
||||
"useCustomProtocol": false
|
||||
},
|
||||
{
|
||||
"id": "deepseek-r1",
|
||||
"name": "Deepseek-r1",
|
||||
"vendor": "Custom",
|
||||
"url": "https://api.getaigate.com/v1/chat/completions",
|
||||
"apiKey": "sk-aigate-2335f1670e169eb855c88f6ed58862cb",
|
||||
"maxInputTokens": 128000,
|
||||
"maxOutputTokens": 32000,
|
||||
"supportsToolCall": true,
|
||||
"supportsImages": true,
|
||||
"supportsReasoning": true,
|
||||
"useCustomProtocol": false
|
||||
},
|
||||
{
|
||||
"id": "glm-5.2",
|
||||
"name": "GLM-5.2",
|
||||
"vendor": "Custom",
|
||||
"url": "https://api.getaigate.com/v1/chat/completions",
|
||||
"apiKey": "sk-aigate-2335f1670e169eb855c88f6ed58862cb",
|
||||
"maxInputTokens": 1000000,
|
||||
"maxOutputTokens": 128000,
|
||||
"supportsToolCall": true,
|
||||
"supportsImages": true,
|
||||
"supportsReasoning": true,
|
||||
"useCustomProtocol": false
|
||||
},
|
||||
{
|
||||
"id": "glm-5.1",
|
||||
"name": "GLM-5.1",
|
||||
"vendor": "Custom",
|
||||
"url": "https://api.getaigate.com/v1/chat/completions",
|
||||
"apiKey": "sk-aigate-2335f1670e169eb855c88f6ed58862cb",
|
||||
"maxInputTokens": 200000,
|
||||
"maxOutputTokens": 128000,
|
||||
"supportsToolCall": true,
|
||||
"supportsImages": true,
|
||||
"supportsReasoning": true,
|
||||
"useCustomProtocol": false
|
||||
},
|
||||
{
|
||||
"id": "glm-5.0",
|
||||
"name": "GLM-5.0",
|
||||
"vendor": "Custom",
|
||||
"url": "https://api.getaigate.com/v1/chat/completions",
|
||||
"apiKey": "sk-aigate-2335f1670e169eb855c88f6ed58862cb",
|
||||
"maxInputTokens": 200000,
|
||||
"maxOutputTokens": 32000,
|
||||
"supportsToolCall": true,
|
||||
"supportsImages": true,
|
||||
"supportsReasoning": true,
|
||||
"useCustomProtocol": false
|
||||
},
|
||||
{
|
||||
"id": "glm-5.0-turbo",
|
||||
"name": "GLM-5.0-Turbo",
|
||||
"vendor": "Custom",
|
||||
"url": "https://api.getaigate.com/v1/chat/completions",
|
||||
"apiKey": "sk-aigate-2335f1670e169eb855c88f6ed58862cb",
|
||||
"maxInputTokens": 200000,
|
||||
"maxOutputTokens": 32000,
|
||||
"supportsToolCall": true,
|
||||
"supportsImages": true,
|
||||
"supportsReasoning": true,
|
||||
"useCustomProtocol": false
|
||||
},
|
||||
{
|
||||
"id": "glm-5v-turbo",
|
||||
"name": "GLM-5v-Turbo",
|
||||
"vendor": "Custom",
|
||||
"url": "https://api.getaigate.com/v1/chat/completions",
|
||||
"apiKey": "sk-aigate-2335f1670e169eb855c88f6ed58862cb",
|
||||
"maxInputTokens": 200000,
|
||||
"maxOutputTokens": 32000,
|
||||
"supportsToolCall": true,
|
||||
"supportsImages": true,
|
||||
"supportsReasoning": true,
|
||||
"useCustomProtocol": false
|
||||
},
|
||||
{
|
||||
"id": "glm-4.7",
|
||||
"name": "GLM-4.7",
|
||||
"vendor": "Custom",
|
||||
"url": "https://api.getaigate.com/v1/chat/completions",
|
||||
"apiKey": "sk-aigate-2335f1670e169eb855c88f6ed58862cb",
|
||||
"maxInputTokens": 128000,
|
||||
"maxOutputTokens": 32000,
|
||||
"supportsToolCall": true,
|
||||
"supportsImages": true,
|
||||
"supportsReasoning": true,
|
||||
"useCustomProtocol": false
|
||||
},
|
||||
{
|
||||
"id": "glm-4.6",
|
||||
"name": "GLM-4.6",
|
||||
"vendor": "Custom",
|
||||
"url": "https://api.getaigate.com/v1/chat/completions",
|
||||
"apiKey": "sk-aigate-2335f1670e169eb855c88f6ed58862cb",
|
||||
"maxInputTokens": 128000,
|
||||
"maxOutputTokens": 32000,
|
||||
"supportsToolCall": true,
|
||||
"supportsImages": true,
|
||||
"supportsReasoning": true,
|
||||
"useCustomProtocol": false
|
||||
},
|
||||
{
|
||||
"id": "minimax-m3",
|
||||
"name": "Minimax-M3",
|
||||
"vendor": "Custom",
|
||||
"url": "https://api.getaigate.com/v1/chat/completions",
|
||||
"apiKey": "sk-aigate-2335f1670e169eb855c88f6ed58862cb",
|
||||
"maxInputTokens": 1000000,
|
||||
"maxOutputTokens": 32000,
|
||||
"supportsToolCall": true,
|
||||
"supportsImages": true,
|
||||
"supportsReasoning": true,
|
||||
"useCustomProtocol": false
|
||||
},
|
||||
{
|
||||
"id": "minimax-m2.7",
|
||||
"name": "Minimax-M2.7",
|
||||
"vendor": "Custom",
|
||||
"url": "https://api.getaigate.com/v1/chat/completions",
|
||||
"apiKey": "sk-aigate-2335f1670e169eb855c88f6ed58862cb",
|
||||
"maxInputTokens": 128000,
|
||||
"maxOutputTokens": 32000,
|
||||
"supportsToolCall": true,
|
||||
"supportsImages": true,
|
||||
"supportsReasoning": true,
|
||||
"useCustomProtocol": false
|
||||
},
|
||||
{
|
||||
"id": "minimax-m2.5",
|
||||
"name": "Minimax-M2.5",
|
||||
"vendor": "Custom",
|
||||
"url": "https://api.getaigate.com/v1/chat/completions",
|
||||
"apiKey": "sk-aigate-2335f1670e169eb855c88f6ed58862cb",
|
||||
"maxInputTokens": 128000,
|
||||
"maxOutputTokens": 32000,
|
||||
"supportsToolCall": true,
|
||||
"supportsImages": true,
|
||||
"supportsReasoning": true,
|
||||
"useCustomProtocol": false
|
||||
},
|
||||
{
|
||||
"id": "kimi-k2.7-code",
|
||||
"name": "Kimi-K2.7-Code",
|
||||
"vendor": "Custom",
|
||||
"url": "https://api.getaigate.com/v1/chat/completions",
|
||||
"apiKey": "sk-aigate-2335f1670e169eb855c88f6ed58862cb",
|
||||
"maxInputTokens": 256000,
|
||||
"maxOutputTokens": 20000,
|
||||
"supportsToolCall": true,
|
||||
"supportsImages": true,
|
||||
"supportsReasoning": true,
|
||||
"useCustomProtocol": false
|
||||
},
|
||||
{
|
||||
"id": "kimi-k2.6",
|
||||
"name": "Kimi-K2.6",
|
||||
"vendor": "Custom",
|
||||
"url": "https://api.getaigate.com/v1/chat/completions",
|
||||
"apiKey": "sk-aigate-2335f1670e169eb855c88f6ed58862cb",
|
||||
"maxInputTokens": 256000,
|
||||
"maxOutputTokens": 32000,
|
||||
"supportsToolCall": true,
|
||||
"supportsImages": true,
|
||||
"supportsReasoning": true,
|
||||
"useCustomProtocol": false
|
||||
},
|
||||
{
|
||||
"id": "kimi-k2.5",
|
||||
"name": "Kimi-K2.5",
|
||||
"vendor": "Custom",
|
||||
"url": "https://api.getaigate.com/v1/chat/completions",
|
||||
"apiKey": "sk-aigate-2335f1670e169eb855c88f6ed58862cb",
|
||||
"maxInputTokens": 256000,
|
||||
"maxOutputTokens": 32000,
|
||||
"supportsToolCall": true,
|
||||
"supportsImages": true,
|
||||
"supportsReasoning": true,
|
||||
"useCustomProtocol": false
|
||||
},
|
||||
{
|
||||
"id": "hy3-preview",
|
||||
"name": "HY-3-Preview",
|
||||
"vendor": "Custom",
|
||||
"url": "https://api.getaigate.com/v1/chat/completions",
|
||||
"apiKey": "sk-aigate-2335f1670e169eb855c88f6ed58862cb",
|
||||
"maxInputTokens": 200000,
|
||||
"maxOutputTokens": 32000,
|
||||
"supportsToolCall": true,
|
||||
"supportsImages": true,
|
||||
"supportsReasoning": true,
|
||||
"useCustomProtocol": false
|
||||
},
|
||||
{
|
||||
"id": "hy3",
|
||||
"name": "HY-3",
|
||||
"vendor": "Custom",
|
||||
"url": "https://api.getaigate.com/v1/chat/completions",
|
||||
"apiKey": "sk-aigate-2335f1670e169eb855c88f6ed58862cb",
|
||||
"maxInputTokens": 256000,
|
||||
"maxOutputTokens": 192000,
|
||||
"supportsToolCall": true,
|
||||
"supportsImages": true,
|
||||
"supportsReasoning": true,
|
||||
"useCustomProtocol": false
|
||||
},
|
||||
{
|
||||
"id": "hunyuan-chat",
|
||||
"name": "HY-Chat",
|
||||
"vendor": "Custom",
|
||||
"url": "https://api.getaigate.com/v1/chat/completions",
|
||||
"apiKey": "sk-aigate-2335f1670e169eb855c88f6ed58862cb",
|
||||
"maxInputTokens": 128000,
|
||||
"maxOutputTokens": 32000,
|
||||
"supportsToolCall": true,
|
||||
"supportsImages": true,
|
||||
"supportsReasoning": true,
|
||||
"useCustomProtocol": false
|
||||
},
|
||||
{
|
||||
"id": "hunyuan-2.0-thinking",
|
||||
"name": "HY-2.0-Thinking",
|
||||
"vendor": "Custom",
|
||||
"url": "https://api.getaigate.com/v1/chat/completions",
|
||||
"apiKey": "sk-aigate-2335f1670e169eb855c88f6ed58862cb",
|
||||
"maxInputTokens": 200000,
|
||||
"maxOutputTokens": 32000,
|
||||
"supportsToolCall": true,
|
||||
"supportsImages": true,
|
||||
"supportsReasoning": true,
|
||||
"useCustomProtocol": false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
SmsCaptchaResult
|
||||
} from './model';
|
||||
import {saveStorageByLoginUser, SERVER_API_URL} from "@/utils/server";
|
||||
import { isUserDisabled } from '@/utils/auth';
|
||||
|
||||
/**
|
||||
* 账号密码登录
|
||||
@@ -18,6 +19,9 @@ export async function login(data: LoginParam) {
|
||||
);
|
||||
if (res.code === 0) {
|
||||
if (res.data?.user && res.data?.access_token) {
|
||||
if (isUserDisabled(res.data.user)) {
|
||||
return Promise.reject(new Error('您的账号已被禁用,请联系管理员'));
|
||||
}
|
||||
saveStorageByLoginUser(res.data.access_token, res.data.user)
|
||||
}
|
||||
return res.message;
|
||||
@@ -46,6 +50,9 @@ export async function loginBySms(data: LoginParam) {
|
||||
);
|
||||
if (res.code === 0) {
|
||||
if(res.data?.user){
|
||||
if (isUserDisabled(res.data.user)) {
|
||||
return Promise.reject(new Error('您的账号已被禁用,请联系管理员'));
|
||||
}
|
||||
saveStorageByLoginUser(`${res.data?.access_token}`, res.data?.user)
|
||||
}
|
||||
return res.message;
|
||||
|
||||
@@ -132,6 +132,8 @@ export interface ShopGoods {
|
||||
activityType?: number;
|
||||
// 配送方式:0送上门 1限自提
|
||||
deliveryMode?: number;
|
||||
// 门店ID
|
||||
storeId?: number;
|
||||
}
|
||||
|
||||
export interface BathSet {
|
||||
|
||||
52
src/api/shop/shopGoodsBrowse/index.ts
Normal file
52
src/api/shop/shopGoodsBrowse/index.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import request from '@/utils/request';
|
||||
import type { ApiResult, PageResult } from '@/api';
|
||||
import type { ShopGoodsBrowse, ShopGoodsBrowseParam } from './model';
|
||||
|
||||
/**
|
||||
* 上报浏览记录(异步静默,不抛错影响主流程)
|
||||
*/
|
||||
export async function reportBrowse(data: Partial<ShopGoodsBrowse>): Promise<void> {
|
||||
try {
|
||||
await request.post<ApiResult<unknown>>('/shop/shop-goods-browse', data);
|
||||
} catch {
|
||||
/* 静默失败:浏览记录上报不影响用户体验 */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询我的浏览记录
|
||||
*/
|
||||
export async function pageBrowseHistory(params: ShopGoodsBrowseParam) {
|
||||
const res = await request.get<ApiResult<PageResult<ShopGoodsBrowse>>>(
|
||||
'/shop/shop-goods-browse/page',
|
||||
params
|
||||
);
|
||||
if (res.code === 0) {
|
||||
return res.data;
|
||||
}
|
||||
return Promise.reject(new Error(res.message));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除单条浏览记录
|
||||
*/
|
||||
export async function deleteBrowseRecord(id: number) {
|
||||
const res = await request.del<ApiResult<unknown>>(
|
||||
`/shop/shop-goods-browse/${id}`
|
||||
);
|
||||
if (res.code === 0) {
|
||||
return res.message;
|
||||
}
|
||||
return Promise.reject(new Error(res.message));
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空我的浏览记录
|
||||
*/
|
||||
export async function clearBrowseHistory() {
|
||||
const res = await request.del<ApiResult<unknown>>('/shop/shop-goods-browse');
|
||||
if (res.code === 0) {
|
||||
return res.message;
|
||||
}
|
||||
return Promise.reject(new Error(res.message));
|
||||
}
|
||||
39
src/api/shop/shopGoodsBrowse/model/index.ts
Normal file
39
src/api/shop/shopGoodsBrowse/model/index.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { PageParam } from '@/api/index';
|
||||
|
||||
/**
|
||||
* 用户商品浏览记录
|
||||
*/
|
||||
export interface ShopGoodsBrowse {
|
||||
id?: number;
|
||||
userId?: number;
|
||||
goodsId?: number;
|
||||
tenantId?: number;
|
||||
merchantId?: number;
|
||||
/** 浏览次数 */
|
||||
visitCount?: number;
|
||||
/** 最后浏览时间 */
|
||||
lastVisitTime?: string;
|
||||
/** 浏览来源:home/category/search/share/detail */
|
||||
browseSource?: string;
|
||||
createTime?: string;
|
||||
updateTime?: string;
|
||||
// ========== 关联商品快照字段 ==========
|
||||
goodsName?: string;
|
||||
goodsImage?: string;
|
||||
price?: string;
|
||||
salePrice?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 浏览记录查询参数
|
||||
*/
|
||||
export interface ShopGoodsBrowseParam extends PageParam {
|
||||
id?: number;
|
||||
userId?: number;
|
||||
goodsId?: number;
|
||||
merchantId?: number;
|
||||
tenantId?: number;
|
||||
browseSource?: string;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
}
|
||||
@@ -154,7 +154,9 @@ export async function prepayShopOrder(data: OrderPrepayRequest) {
|
||||
*/
|
||||
export async function createOrder(data: OrderCreateRequest) {
|
||||
// Java 后端期望 camelCase 字段,直接传
|
||||
const res = await request.post<ApiResult<WxPayResult>>(
|
||||
// 注意:货到付款(payType=8)和线下付款(payType=9)时,后端不应创建微信支付订单,
|
||||
// 理论上返回 data 为 null 或订单信息;微信支付场景才会返回 WxPayResult。
|
||||
const res = await request.post<ApiResult<WxPayResult | null>>(
|
||||
'/shop/shop-order',
|
||||
data
|
||||
);
|
||||
@@ -192,3 +194,23 @@ export async function refundShopOrder(data: ShopOrder) {
|
||||
}
|
||||
return Promise.reject(new Error(res.message));
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认线下付款收款
|
||||
* 商家确认已收到线下付款(微信转账/银行汇款等),确认后订单进入待发货状态
|
||||
*/
|
||||
export async function confirmOfflinePayment(
|
||||
id: number,
|
||||
remarks?: string,
|
||||
paymentVoucher?: string
|
||||
) {
|
||||
const params: string[] = []
|
||||
if (remarks) params.push(`remarks=${encodeURIComponent(remarks)}`)
|
||||
if (paymentVoucher) params.push(`paymentVoucher=${encodeURIComponent(paymentVoucher)}`)
|
||||
const url = '/shop/shop-order/confirm-offline-payment/' + id + (params.length ? '?' + params.join('&') : '')
|
||||
const res = await request.put<ApiResult<unknown>>(url, null)
|
||||
if (res.code === 0) {
|
||||
return res.message;
|
||||
}
|
||||
return Promise.reject(new Error(res.message));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,42 @@
|
||||
import type { PageParam } from '@/api/index';
|
||||
import type { ShopOrderGoods } from '@/api/shop/shopOrderGoods/model';
|
||||
|
||||
/**
|
||||
* 发货单(与后端 ShopOrderDelivery 对应)
|
||||
*/
|
||||
export interface ShopOrderDelivery {
|
||||
// 发货单ID
|
||||
deliveryId?: number;
|
||||
// 订单ID
|
||||
orderId?: number;
|
||||
// 发货方式(10手动录入 20无需物流 30电子面单)
|
||||
deliveryMethod?: number;
|
||||
// 物流公司ID
|
||||
expressId?: number;
|
||||
// 物流公司名称(关联查询,非数据库字段)
|
||||
expressName?: string;
|
||||
// 发货人
|
||||
sendName?: string;
|
||||
// 发货人联系方式
|
||||
sendPhone?: string;
|
||||
// 发货地址
|
||||
sendAddress?: string;
|
||||
// 物流单号
|
||||
expressNo?: string;
|
||||
// 电子面单模板内容
|
||||
eorderHtml?: string;
|
||||
// 排序号
|
||||
sortNumber?: number;
|
||||
// 是否删除, 0否, 1是
|
||||
deleted?: number;
|
||||
// 租户id
|
||||
tenantId?: number;
|
||||
// 创建时间
|
||||
createTime?: string;
|
||||
// 修改时间
|
||||
updateTime?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 订单
|
||||
*/
|
||||
@@ -97,7 +133,7 @@ export interface ShopOrder {
|
||||
formId?: number;
|
||||
// 支付的用户id
|
||||
payUserId?: number;
|
||||
// 支付方式:0余额支付, 1微信支付, 2支付宝, 3银联, 4现金, 5POS机, 6免费, 7积分支付, 8货到付款
|
||||
// 支付方式:0余额支付, 1微信支付, 2支付宝, 3银联, 4现金, 5POS机, 6免费, 7积分支付, 8货到付款, 9线下付款
|
||||
payType?: number;
|
||||
// 代付支付方式
|
||||
friendPayType?: number;
|
||||
@@ -143,6 +179,12 @@ export interface ShopOrder {
|
||||
userId?: number;
|
||||
// 备注
|
||||
comments?: string;
|
||||
// 买家备注(下单时用户填写的订单备注)
|
||||
buyerRemarks?: string;
|
||||
// 商户备注(门店修改金额原因等)
|
||||
merchantRemarks?: string;
|
||||
// 线下付款支付凭证(图片URL)
|
||||
paymentVoucher?: string;
|
||||
// 排序号
|
||||
sortNumber?: number;
|
||||
// 是否删除, 0否, 1是
|
||||
@@ -159,6 +201,8 @@ export interface ShopOrder {
|
||||
hasTakeGift?: string;
|
||||
// 订单商品项
|
||||
orderGoods?: ShopOrderGoods[];
|
||||
// 发货单信息(详情接口关联查询返回,仅快递配送且已发货时有值)
|
||||
shopOrderDelivery?: ShopOrderDelivery | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -169,6 +213,8 @@ export interface OrderGoodsItem {
|
||||
quantity: number;
|
||||
skuId?: number;
|
||||
specInfo?: string;
|
||||
// 单价(VIP 会员传 dealerPrice,普通用户不传由后端按商品价计算)
|
||||
price?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -187,7 +233,7 @@ export interface OrderCreateRequest {
|
||||
warehouseId?: number;
|
||||
// 收货地址ID
|
||||
addressId?: number;
|
||||
// 支付方式:0余额支付, 1微信支付, 2支付宝, 3银联, 4现金, 5POS机, 6免费, 7积分支付, 8货到付款
|
||||
// 支付方式:0余额支付, 1微信支付, 2支付宝, 3银联, 4现金, 5POS机, 6免费, 7积分支付, 8货到付款, 9线下付款
|
||||
payType: number;
|
||||
// 支付状态(货到付款由后端自动设置,前端无需传递)
|
||||
payStatus?: boolean;
|
||||
@@ -197,6 +243,8 @@ export interface OrderCreateRequest {
|
||||
couponId?: number;
|
||||
// 备注
|
||||
comments?: string;
|
||||
// 买家备注(下单时用户填写的订单备注)
|
||||
buyerRemarks?: string;
|
||||
// 配送开始时间(用于预约/配送时间)
|
||||
sendStartTime?: string;
|
||||
// 配送方式 0快递 1自提
|
||||
|
||||
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,6 +4,7 @@ export interface ShopUserAddress {
|
||||
userId?: number;
|
||||
name?: string; // 收货人姓名
|
||||
phone?: string; // 收货人电话
|
||||
country?: string; // 国家
|
||||
province?: string; // 省份
|
||||
city?: string; // 城市
|
||||
district?: string; // 区县
|
||||
@@ -11,7 +12,8 @@ export interface ShopUserAddress {
|
||||
detail?: string; // 详细地址
|
||||
lng?: string; // 经度
|
||||
lat?: string; // 纬度
|
||||
isDefault?: number; // 是否默认 0-否 1-是
|
||||
isDefault?: boolean; // 是否默认 0-否 1-是
|
||||
createTime?: string;
|
||||
updateTime?: string;
|
||||
address?: string;
|
||||
}
|
||||
|
||||
@@ -33,25 +33,39 @@ export async function getUserCardStats(): Promise<UserCardStats> {
|
||||
/**
|
||||
* 获取用户订单统计
|
||||
* 后端接口:/api/user/orders/stats(60s 缓存)
|
||||
* 字段与后端 UserOrderStats DTO 对齐:waitPay/waitDeliver/waitReceive/completed 等
|
||||
*/
|
||||
export async function getUserOrderStats(): Promise<UserOrderStats> {
|
||||
try {
|
||||
const res = await request.get<ApiResult<any>>('/user/orders/stats')
|
||||
if (res.code === 0 && res.data) {
|
||||
const d = res.data
|
||||
return {
|
||||
pending: res.data.pending || 0,
|
||||
paid: res.data.paid || 0,
|
||||
shipped: res.data.shipped || 0,
|
||||
completed: res.data.completed || 0,
|
||||
total: Number(d.total) || 0,
|
||||
waitPay: Number(d.waitPay) || 0,
|
||||
waitDeliver: Number(d.waitDeliver) || 0,
|
||||
waitVerify: Number(d.waitVerify) || 0,
|
||||
waitReceive: Number(d.waitReceive) || 0,
|
||||
waitComment: Number(d.waitComment) || 0,
|
||||
completed: Number(d.completed) || 0,
|
||||
refund: Number(d.refund) || 0,
|
||||
deleted: Number(d.deleted) || 0,
|
||||
canceled: Number(d.canceled) || 0,
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取订单统计失败:', e)
|
||||
}
|
||||
return {
|
||||
pending: 0,
|
||||
paid: 0,
|
||||
shipped: 0,
|
||||
total: 0,
|
||||
waitPay: 0,
|
||||
waitDeliver: 0,
|
||||
waitVerify: 0,
|
||||
waitReceive: 0,
|
||||
waitComment: 0,
|
||||
completed: 0,
|
||||
refund: 0,
|
||||
deleted: 0,
|
||||
canceled: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,20 @@ export interface UserCardStats {
|
||||
giftCards: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户订单状态数量统计
|
||||
* 字段与后端 com.gxwebsoft.shop.dto.UserOrderStats 对齐
|
||||
* (statusFilter:-1全部 0待支付 1待发货 2待核销 3待收货 4待评价 5已完成 6退款 7已删除 8已取消)
|
||||
*/
|
||||
export interface UserOrderStats {
|
||||
pending: number // 待付款
|
||||
paid: number // 待发货
|
||||
shipped: number // 待收货
|
||||
completed: number // 已完成
|
||||
total: number // 全部(不含已取消)
|
||||
waitPay: number // 待付款(statusFilter=0)
|
||||
waitDeliver: number // 待发货(statusFilter=1)
|
||||
waitVerify: number // 待核销(statusFilter=2)
|
||||
waitReceive: number // 待收货(statusFilter=3)
|
||||
waitComment: number // 待评价(statusFilter=4)
|
||||
completed: number // 已完成(statusFilter=5)
|
||||
refund: number // 退款/售后(statusFilter=6)
|
||||
deleted: number // 已删除(statusFilter=7)
|
||||
canceled: number // 已取消(statusFilter=8)
|
||||
}
|
||||
|
||||
@@ -53,11 +53,52 @@ const computeSignature = (accessKeySecret: string, canonicalString: string): str
|
||||
return crypto.enc.Base64.stringify(crypto.HmacSHA1(canonicalString, accessKeySecret));
|
||||
}
|
||||
|
||||
/**
|
||||
* 在调用涉及用户隐私的 API(chooseImage / chooseMedia / getLocation / getPhoneNumber 等)前,
|
||||
* 等待用户完成微信隐私协议授权。基础库 3.16.1+ 强制要求,未授权会抛 errno:112。
|
||||
* 旧基础库或不支持隐私协议的版本直接放行。
|
||||
*/
|
||||
export const ensurePrivacyAuthorized = (): Promise<void> => {
|
||||
return new Promise((resolve) => {
|
||||
const wxAny: any = Taro
|
||||
if (typeof wxAny.requirePrivacyAuthorize !== 'function') {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
if (typeof wxAny.getPrivacySetting === 'function') {
|
||||
wxAny.getPrivacySetting({
|
||||
success: (res: any) => {
|
||||
if (res && res.needAuthorization) {
|
||||
wxAny.requirePrivacyAuthorize({
|
||||
success: () => resolve(),
|
||||
fail: () => resolve(),
|
||||
})
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
},
|
||||
fail: () => resolve(),
|
||||
})
|
||||
} else {
|
||||
wxAny.requirePrivacyAuthorize({
|
||||
success: () => resolve(),
|
||||
fail: () => resolve(),
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传阿里云OSS
|
||||
*/
|
||||
export async function uploadFile() {
|
||||
return new Promise(async (resolve: (result: FileRecord) => void, reject) => {
|
||||
// 修复:基础库 3.16.1+ 强制隐私协议校验,先确保用户已同意
|
||||
try {
|
||||
await ensurePrivacyAuthorized()
|
||||
} catch {
|
||||
// 忽略前置授权错误,让 chooseImage 自己抛具体的 fail
|
||||
}
|
||||
Taro.chooseImage({
|
||||
count: 1,
|
||||
sizeType: ['compressed'],
|
||||
|
||||
@@ -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>>(
|
||||
|
||||
@@ -15,6 +15,8 @@ export default {
|
||||
'passport/qr-login/index',
|
||||
'passport/qr-confirm/index',
|
||||
'passport/unified-qr/index',
|
||||
// 支付页面
|
||||
'passport/pay/index',
|
||||
// 首页子页面
|
||||
'pages/index/search',
|
||||
'pages/index/notification',
|
||||
@@ -91,6 +93,7 @@ export default {
|
||||
'pages/store/center/index',
|
||||
'pages/store/orders/index',
|
||||
'pages/store/goods/index',
|
||||
'pages/store/users/index',
|
||||
// 售后页面
|
||||
'pages/after-sale/apply/index',
|
||||
'pages/after-sale/progress/index',
|
||||
@@ -130,7 +133,7 @@ export default {
|
||||
window: {
|
||||
backgroundTextStyle: 'light',
|
||||
navigationBarBackgroundColor: '#fff',
|
||||
navigationBarTitleText: 'Paopao Taro',
|
||||
navigationBarTitleText: 'Websopy Inc.',
|
||||
navigationBarTextStyle: 'black',
|
||||
backgroundColor: '#f8f8f8',
|
||||
},
|
||||
@@ -159,10 +162,10 @@ export default {
|
||||
text: '分类',
|
||||
},
|
||||
{
|
||||
pagePath: 'pages/order/list',
|
||||
iconPath: 'assets/tabbar/order.png',
|
||||
selectedIconPath: 'assets/tabbar/order-active.png',
|
||||
text: '订单',
|
||||
pagePath: 'pages/shop/cart',
|
||||
iconPath: 'assets/tabbar/cart.png',
|
||||
selectedIconPath: 'assets/tabbar/cart-active.png',
|
||||
text: '购物车',
|
||||
},
|
||||
{
|
||||
pagePath: 'pages/user/user',
|
||||
|
||||
BIN
src/assets/tabbar/cart-active.png
Normal file
BIN
src/assets/tabbar/cart-active.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
BIN
src/assets/tabbar/cart.png
Normal file
BIN
src/assets/tabbar/cart.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
79
src/components/PrivacyModal/index.scss
Normal file
79
src/components/PrivacyModal/index.scss
Normal file
@@ -0,0 +1,79 @@
|
||||
.privacy-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&__mask {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
&__content {
|
||||
position: relative;
|
||||
width: 560px;
|
||||
background-color: #fff;
|
||||
border-radius: 24px;
|
||||
padding: 48px 40px 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 36px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
font-size: 28px;
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
text-align: center;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-top: 1px solid #e5e5e5;
|
||||
padding-top: 24px;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
flex: 1;
|
||||
height: 80px;
|
||||
line-height: 80px;
|
||||
font-size: 30px;
|
||||
text-align: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
|
||||
&::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
&--primary {
|
||||
color: #07c160;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
58
src/components/PrivacyModal/index.tsx
Normal file
58
src/components/PrivacyModal/index.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { View, Button, Text } from '@tarojs/components'
|
||||
import { privacyManager } from '@/utils/privacy'
|
||||
import './index.scss'
|
||||
|
||||
/**
|
||||
* 微信隐私协议授权弹窗(基础库 3.16.1+ 强制)
|
||||
* 必须使用 open-type="agreePrivacyAuthorization" 的 Button 组件,
|
||||
* 用户点击后微信才会真正认为隐私协议已授权,后续敏感 API 才能调用。
|
||||
*/
|
||||
const PrivacyModal = () => {
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [contractName, setContractName] = useState(privacyManager.getPrivacyContractName())
|
||||
|
||||
useEffect(() => {
|
||||
privacyManager.setShowCallback((show) => {
|
||||
setVisible(show)
|
||||
if (show) {
|
||||
setContractName(privacyManager.getPrivacyContractName())
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
<View className='privacy-modal'>
|
||||
<View className='privacy-modal__mask' />
|
||||
<View className='privacy-modal__content'>
|
||||
<Text className='privacy-modal__title'>隐私协议授权</Text>
|
||||
<Text className='privacy-modal__desc'>
|
||||
{`为提供完整服务,需您同意 ${contractName}。点击同意后可继续使用相关功能。`}
|
||||
</Text>
|
||||
<View className='privacy-modal__footer'>
|
||||
<Button
|
||||
id='privacy-disagree-btn'
|
||||
className='privacy-modal__btn'
|
||||
onClick={() => privacyManager.disagree()}
|
||||
>
|
||||
拒绝
|
||||
</Button>
|
||||
<Button
|
||||
id='privacy-agree-btn'
|
||||
className='privacy-modal__btn privacy-modal__btn--primary'
|
||||
openType='agreePrivacyAuthorization'
|
||||
onAgreePrivacyAuthorization={() => privacyManager.agree()}
|
||||
onDisagreePrivacyAuthorization={() => privacyManager.disagree()}
|
||||
>
|
||||
同意
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default PrivacyModal
|
||||
@@ -4,7 +4,7 @@ import Taro from '@tarojs/taro'
|
||||
import type { ShopGoods, ShopGoodsSku } from '@/api/shop/shopGoods/model'
|
||||
import type { ShopGoodsSpec } from '@/api/shop/shopGoodsSpec/model'
|
||||
import Price from '@/components/common/Price'
|
||||
import { isVipMember } from '@/utils/vip'
|
||||
import { useVipStatus } from '@/hooks/useVipStatus'
|
||||
import { getCompressedImageUrl } from '@/utils/image'
|
||||
|
||||
interface SkuSelectorProps {
|
||||
@@ -49,6 +49,8 @@ const SkuSelector: React.FC<SkuSelectorProps> = ({
|
||||
const [quantity, setQuantity] = useState(1)
|
||||
const [showOverlay, setShowOverlay] = useState(false)
|
||||
const [slideUp, setSlideUp] = useState(false)
|
||||
// VIP 状态:异步校验并更新缓存,isVip 变化时触发重渲染
|
||||
const { isVip } = useVipStatus()
|
||||
|
||||
// 控制动画
|
||||
useEffect(() => {
|
||||
@@ -157,7 +159,7 @@ const SkuSelector: React.FC<SkuSelectorProps> = ({
|
||||
// 单规格商品(无SKU列表 或 SKU列表为空数组)
|
||||
if ((!product?.goodsSkus || product.goodsSkus.length === 0) && product) {
|
||||
// VIP 会员使用 dealerPrice 作为结算价
|
||||
const vipPrice = isVipMember() && product.dealerPrice ? product.dealerPrice : undefined
|
||||
const vipPrice = isVip && product.dealerPrice ? product.dealerPrice : undefined
|
||||
const fakeSku: ShopGoodsSku = {
|
||||
id: 0,
|
||||
goodsId: product.goodsId!,
|
||||
@@ -197,7 +199,7 @@ const SkuSelector: React.FC<SkuSelectorProps> = ({
|
||||
// - product.price / sku.price : 实际"到手价"(详情页主价格)
|
||||
// - product.dealerPrice : VIP 会员专享价
|
||||
// VIP 会员优先使用 dealerPrice
|
||||
const vipPrice = isVipMember() ? (product?.dealerPrice || selectedSku?.price) : null
|
||||
const vipPrice = isVip ? (product?.dealerPrice || selectedSku?.price) : null
|
||||
const currentPrice = vipPrice || selectedSku?.price || product?.price || selectedSku?.salePrice || product?.salePrice || '0'
|
||||
const currentStock = selectedSku?.stock ?? product?.stock ?? 0
|
||||
const currentImage = selectedSku?.image || product?.image || (product?.files?.split(',')[0]) || ''
|
||||
|
||||
42
src/components/common/ArrowRight/index.tsx
Normal file
42
src/components/common/ArrowRight/index.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from 'react'
|
||||
import { View } from '@tarojs/components'
|
||||
|
||||
/**
|
||||
* 右箭头图标(CSS V 形,颜色跟随 currentColor)
|
||||
*
|
||||
* 颜色通过 className 里的 text-* 类控制(如 'text-gray-400' / 'text-white'),
|
||||
* 因为内部用 currentColor,会自动继承父元素 text color。
|
||||
*
|
||||
* @example
|
||||
* <View className='flex items-center text-gray-400'>
|
||||
* <Text>全部订单</Text>
|
||||
* <ArrowRight className='ml-1' />
|
||||
* </View>
|
||||
*/
|
||||
export interface ArrowRightProps {
|
||||
/** 颜色/间距等 className(颜色用 text-* 类,跟随 currentColor) */
|
||||
className?: string
|
||||
/** V 形边长 px,默认 6 */
|
||||
size?: number
|
||||
/** 描边粗细 px,默认 1.5 */
|
||||
thickness?: number
|
||||
}
|
||||
|
||||
const ArrowRight: React.FC<ArrowRightProps> = ({
|
||||
className = '',
|
||||
size = 6,
|
||||
thickness = 1.5,
|
||||
}) => (
|
||||
<View
|
||||
className={`inline-flex items-center justify-center ${className}`}
|
||||
style={{
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
borderTop: `${thickness}px solid currentColor`,
|
||||
borderRight: `${thickness}px solid currentColor`,
|
||||
transform: 'rotate(45deg)',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
export default ArrowRight
|
||||
72
src/components/common/Badge/index.tsx
Normal file
72
src/components/common/Badge/index.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import React from 'react'
|
||||
import { View, Text } from '@tarojs/components'
|
||||
|
||||
/**
|
||||
* 数字角标
|
||||
* - count 为 0 / null / undefined 时不渲染
|
||||
* - count > max 时显示 `${max}+`(默认 99+)
|
||||
* - 通过 className 控制定位(通常配合父元素 relative + absolute -top-1 -right-1)
|
||||
*
|
||||
* @example
|
||||
* <View className='relative'>
|
||||
* <Text>📦</Text>
|
||||
* <Badge count={14} className='absolute -top-1 -right-1' />
|
||||
* </View>
|
||||
*
|
||||
* // 大尺寸 + 加粗(如门店中心卡片角标)
|
||||
* <Badge count={5} className='absolute -top-1 -right-1' size={20} fontSize={11} fontWeight='bold' />
|
||||
*/
|
||||
export interface BadgeProps {
|
||||
/** 数量;为 0 / null / undefined 时不渲染 */
|
||||
count?: number | null
|
||||
/** 超过此值显示 `${max}+`,默认 99 */
|
||||
max?: number
|
||||
/** 外层定位/样式 className(如 'absolute -top-1 -right-1') */
|
||||
className?: string
|
||||
/** 背景色,默认红色 #ef4444 */
|
||||
color?: string
|
||||
/** 字体大小 px,默认 10 */
|
||||
fontSize?: number
|
||||
/** 角标尺寸 px(min-width 与 height 同步),默认 16 */
|
||||
size?: number
|
||||
/** 字重,默认 'normal' */
|
||||
fontWeight?: 'normal' | 'medium' | 'bold'
|
||||
}
|
||||
|
||||
const Badge: React.FC<BadgeProps> = ({
|
||||
count,
|
||||
max = 99,
|
||||
className = '',
|
||||
color = '#ef4444',
|
||||
fontSize = 10,
|
||||
size = 16,
|
||||
fontWeight = 'normal',
|
||||
}) => {
|
||||
// 0 / null / undefined / 负数 都不渲染
|
||||
const n = Number(count) || 0
|
||||
if (n <= 0) return null
|
||||
|
||||
const display = n > max ? `${max}+` : String(n)
|
||||
// 横向 padding 跟随 size:16→2px,20→3px,避免多位数字挤压
|
||||
const padding = Math.max(2, Math.round(size / 8))
|
||||
|
||||
return (
|
||||
<View
|
||||
className={`rounded-full text-white flex items-center justify-center ${className}`}
|
||||
style={{
|
||||
background: color,
|
||||
minWidth: `${size}px`,
|
||||
height: `${size}px`,
|
||||
fontSize: `${fontSize}px`,
|
||||
lineHeight: `${fontSize + 2}px`,
|
||||
fontWeight,
|
||||
paddingLeft: `${padding}px`,
|
||||
paddingRight: `${padding}px`,
|
||||
}}
|
||||
>
|
||||
<Text>{display}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default Badge
|
||||
@@ -8,6 +8,7 @@ import { getCompressedImageUrl } from '@/utils/image'
|
||||
interface OrderCardProps {
|
||||
order: ShopOrder
|
||||
onClick?: () => void
|
||||
onCloseOrder?: (order: ShopOrder) => void
|
||||
}
|
||||
|
||||
/** 综合 payStatus + deliveryStatus + orderStatus + payType 计算列表卡片状态文案 */
|
||||
@@ -24,17 +25,21 @@ const getCardStatus = (order: ShopOrder): { text: string; color: string } => {
|
||||
// 终态:orderStatus=1 必须最先判断,盖过 deliveryStatus/payStatus
|
||||
if (orderStatus === 1) return { text: '已完成', color: '#0e932e' }
|
||||
|
||||
// 货到付款:payStatus=true 直接进入待发货
|
||||
if (!payStatus && !isCod) return { text: '待付款', color: '#ee0a24' }
|
||||
if (isCod && deliveryStatus === 10) return { text: '货到付款·待发货', color: '#4b9cf5' }
|
||||
if (deliveryStatus === 10) return { text: '待发货', color: '#4b9cf5' }
|
||||
// 线下付款(9)已发货但未确认收款:显示"已发货待收款"(红色),与门店端一致
|
||||
if (!payStatus && payType === 9 && deliveryStatus === 20) return { text: '待付款,已发货', color: '#ee0a24' }
|
||||
|
||||
// 物流状态优先于付款状态(已发货的订单,即使未付款也显示物流状态)
|
||||
if (deliveryStatus === 20) return { text: '待收货', color: '#4b9cf5' }
|
||||
if (deliveryStatus === 30) return { text: '已收货', color: '#0e932e' }
|
||||
|
||||
// 货到付款(8):下单时后端已 setPayStatus(true),不会走到这里
|
||||
// 线下付款(9):保持 payStatus=false(待商家确认收款),应显示"待付款"与 Tab 一致
|
||||
if (!payStatus && !isCod) return { text: '待付款', color: '#ee0a24' }
|
||||
if (deliveryStatus === 10) return { text: '待发货', color: '#4b9cf5' }
|
||||
|
||||
return { text: '已付款', color: '#0e932e' }
|
||||
}
|
||||
|
||||
const OrderCard: React.FC<OrderCardProps> = ({ order, onClick }) => {
|
||||
const OrderCard: React.FC<OrderCardProps> = ({ order, onClick, onCloseOrder }) => {
|
||||
const handleClick = () => {
|
||||
if (onClick) {
|
||||
onClick()
|
||||
@@ -43,8 +48,17 @@ const OrderCard: React.FC<OrderCardProps> = ({ order, onClick }) => {
|
||||
Taro.navigateTo({ url: `/pages/order/detail?id=${order.orderId}` })
|
||||
}
|
||||
|
||||
const handleClose = (e: any) => {
|
||||
e?.stopPropagation?.()
|
||||
onCloseOrder?.(order)
|
||||
}
|
||||
|
||||
const { text: statusText, color: statusColor } = getCardStatus(order)
|
||||
|
||||
// 仅未付款/待确认收款的订单(payStatus=false)才显示取消按钮
|
||||
// 已付款的待发货订单需到详情页申请退款
|
||||
const showCloseButton = order.deliveryStatus === 10 && !order.payStatus && order.orderStatus !== 2 && order.orderStatus !== 3
|
||||
|
||||
// 获取首个商品图片作为封面,或显示默认占位
|
||||
const coverImage = order.orderGoods?.[0]?.image || ''
|
||||
const hasGoods = (order.orderGoods?.length || 0) > 0
|
||||
@@ -124,10 +138,10 @@ const OrderCard: React.FC<OrderCardProps> = ({ order, onClick }) => {
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 底部:日期 + 金额汇总 */}
|
||||
{/* 底部:日期 + 金额汇总 + 操作按钮 */}
|
||||
<View className='flex justify-between items-center pt-2 border-t border-gray-50'>
|
||||
<Text className='text-xs text-gray-500'>
|
||||
{order.createTime?.slice(0, 10)}
|
||||
下单时间: {order.createTime?.replace('T', ' ').slice(0, 16)}
|
||||
</Text>
|
||||
<View className='flex items-center'>
|
||||
<Text className='text-xs text-gray-500 mr-1'>
|
||||
@@ -137,6 +151,18 @@ const OrderCard: React.FC<OrderCardProps> = ({ order, onClick }) => {
|
||||
<Price price={order.payPrice || order.totalPrice || '0'} size='small' />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 操作按钮区 */}
|
||||
{showCloseButton && onCloseOrder && (
|
||||
<View className='flex justify-end mt-3 pt-2 border-t border-gray-50'>
|
||||
<View
|
||||
className='px-3 py-1 rounded-full border border-gray-300'
|
||||
onClick={handleClose}
|
||||
>
|
||||
<Text className='text-xs text-gray-600'>取消订单</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { View, Text, Image } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import Price from '../Price'
|
||||
import { isGuest } from '@/utils/auth'
|
||||
import { isVipMember } from '@/utils/vip'
|
||||
import { useVipStatus } from '@/hooks/useVipStatus'
|
||||
import { getCompressedImageUrl } from '@/utils/image'
|
||||
import type { ShopGoods } from '@/api/shop/shopGoods/model'
|
||||
|
||||
@@ -13,6 +13,8 @@ interface ProductCardProps {
|
||||
}
|
||||
|
||||
const ProductCard: React.FC<ProductCardProps> = ({ product, onClick }) => {
|
||||
// VIP 状态:异步校验并更新缓存,isVip 变化时触发重渲染
|
||||
const { isVip } = useVipStatus()
|
||||
const handleClick = () => {
|
||||
if (onClick) {
|
||||
onClick()
|
||||
@@ -38,8 +40,8 @@ const ProductCard: React.FC<ProductCardProps> = ({ product, onClick }) => {
|
||||
</Text>
|
||||
<View className='flex items-end justify-between mt-2'>
|
||||
<View className='flex-1'>
|
||||
<Price price={isVipMember() && product.dealerPrice ? product.dealerPrice : (product.price || '0')} size='small' loginMask />
|
||||
{isVipMember() && product.dealerPrice ? (
|
||||
<Price price={isVip && product.dealerPrice ? product.dealerPrice : (product.price || '0')} size='small' loginMask />
|
||||
{isVip && product.dealerPrice ? (
|
||||
// VIP 用户显示原价划掉
|
||||
<Text className='text-xs text-gray-400 line-through ml-1'>
|
||||
¥{product.price}
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { createContext, useContext, useState, useCallback, type ReactNode
|
||||
import Taro from '@tarojs/taro'
|
||||
import { listShopCart, addToCart, updateCartNum, removeShopCart, removeBatchShopCart, updateCartAllChecked, updateCartChecked } from '@/api/shop/shopCart'
|
||||
import type { ShopGoods, ShopGoodsSku } from '@/api/shop/shopGoods/model'
|
||||
import { isVipMember } from '@/utils/vip'
|
||||
import { useVipStatus } from '@/hooks/useVipStatus'
|
||||
|
||||
export interface CartItem {
|
||||
id?: number
|
||||
@@ -47,6 +47,8 @@ const CartContext = createContext<CartContextType | undefined>(undefined)
|
||||
export const CartProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [items, setItems] = useState<CartItem[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
// VIP 状态:异步校验并更新缓存,isVip 变化时触发重渲染使 calcPrice 重新计算
|
||||
const { isVip } = useVipStatus()
|
||||
|
||||
// 刷新购物车数据
|
||||
const refresh = useCallback(async () => {
|
||||
@@ -200,7 +202,7 @@ export const CartProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
}, [items])
|
||||
|
||||
const calcPrice = (list: CartItem[]) => {
|
||||
const vip = isVipMember()
|
||||
const vip = isVip
|
||||
return list.reduce((sum, i) => {
|
||||
// VIP 会员优先使用 dealerPrice(后端关联字段 > product 嵌套对象)
|
||||
const dealerPrice = vip ? (i.dealerPrice || (i.product as any)?.dealerPrice) : null
|
||||
|
||||
@@ -3,6 +3,7 @@ import Taro from '@tarojs/taro'
|
||||
import type { User } from '@/api/system/user/model'
|
||||
import { getUserInfo } from '@/api/layout'
|
||||
import { saveStorageByLoginUser, clearStorageByLoginUser } from '@/utils/server'
|
||||
import { isUserDisabled, blockDisabledUser } from '@/utils/auth'
|
||||
|
||||
interface UserContextType {
|
||||
user: User | null
|
||||
@@ -25,11 +26,14 @@ export const UserProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
const token = Taro.getStorageSync('access_token')
|
||||
const userData = Taro.getStorageSync('User')
|
||||
|
||||
// 情况1: 有本地token和用户数据,直接使用
|
||||
// 情况1: 有本地token和用户数据,先用缓存快速显示,再异步校验状态
|
||||
if (token && userData) {
|
||||
const parsed = typeof userData === 'string' ? JSON.parse(userData) : userData
|
||||
setUser(parsed as User)
|
||||
setLoading(false)
|
||||
|
||||
// 异步从服务端校验用户是否被禁用(防止被禁用后仍可使用)
|
||||
verifyUserStatusFromServer()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -37,6 +41,12 @@ export const UserProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
if (token) {
|
||||
try {
|
||||
const userInfo = await getUserInfo()
|
||||
if (isUserDisabled(userInfo)) {
|
||||
blockDisabledUser(userInfo)
|
||||
setUser(null)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
setUser(userInfo)
|
||||
setLoading(false)
|
||||
return
|
||||
@@ -46,13 +56,38 @@ export const UserProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
}
|
||||
}
|
||||
|
||||
// 无本地token,保持未登录状态(已禁用静默登录)
|
||||
// 无本地token,保持未登录状态
|
||||
setLoading(false)
|
||||
}
|
||||
init()
|
||||
}, [])
|
||||
|
||||
/** 异步从服务端拉取最新用户信息,校验是否被禁用 */
|
||||
const verifyUserStatusFromServer = async () => {
|
||||
try {
|
||||
const userInfo = await getUserInfo()
|
||||
if (isUserDisabled(userInfo)) {
|
||||
// 用户已被禁用,拦截
|
||||
blockDisabledUser(userInfo)
|
||||
setUser(null)
|
||||
} else {
|
||||
// 状态正常,更新本地缓存和状态
|
||||
const token = Taro.getStorageSync('access_token')
|
||||
if (token) {
|
||||
saveStorageByLoginUser(token, userInfo)
|
||||
}
|
||||
setUser(userInfo)
|
||||
}
|
||||
} catch {
|
||||
// 网络错误或token失效,静默处理(已有缓存的登录态可用)
|
||||
}
|
||||
}
|
||||
|
||||
const loginUser = (token: string, userInfo: User) => {
|
||||
if (isUserDisabled(userInfo)) {
|
||||
blockDisabledUser(userInfo)
|
||||
return
|
||||
}
|
||||
saveStorageByLoginUser(token, userInfo)
|
||||
setUser(userInfo)
|
||||
}
|
||||
@@ -68,6 +103,12 @@ export const UserProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
const userData = Taro.getStorageSync('User')
|
||||
if (token && userData) {
|
||||
const parsed = typeof userData === 'string' ? JSON.parse(userData) : userData
|
||||
// 快速检查缓存中的 status,如果已禁用则拦截
|
||||
if (isUserDisabled(parsed)) {
|
||||
blockDisabledUser(parsed)
|
||||
setUser(null)
|
||||
return false
|
||||
}
|
||||
setUser(parsed as User)
|
||||
return true
|
||||
}
|
||||
@@ -81,6 +122,11 @@ export const UserProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
if (!Taro.getStorageSync('access_token')) return null
|
||||
try {
|
||||
const userInfo = await getUserInfo()
|
||||
if (isUserDisabled(userInfo)) {
|
||||
blockDisabledUser(userInfo)
|
||||
setUser(null)
|
||||
return null
|
||||
}
|
||||
const token = Taro.getStorageSync('access_token')
|
||||
if (token) {
|
||||
saveStorageByLoginUser(token, userInfo)
|
||||
|
||||
@@ -21,6 +21,32 @@ interface UseAddressReturn {
|
||||
setDefault: (id: number) => Promise<boolean>
|
||||
}
|
||||
|
||||
/**
|
||||
* 兜底:保证列表中最多只有一个地址是 isDefault=true。
|
||||
* 兼容历史脏数据(之前没有"取消其他默认"逻辑时遗留下来的多个默认)。
|
||||
* 保留最早创建(id 最小)的为默认;其余的在前端展示时按非默认处理。
|
||||
*/
|
||||
function ensureSingleDefault(list: ShopUserAddress[]): ShopUserAddress[] {
|
||||
const defaults = list.filter(a => a.isDefault)
|
||||
if (defaults.length <= 1) return list
|
||||
// 多个默认:保留 createTime 最早的;如果都缺 createTime,则保留 id 最小的
|
||||
const sorted = [...defaults].sort((a, b) => {
|
||||
const at = a.createTime || ''
|
||||
const bt = b.createTime || ''
|
||||
if (at && bt) return at < bt ? -1 : at > bt ? 1 : 0
|
||||
if (at) return -1
|
||||
if (bt) return 1
|
||||
return (a.id || 0) - (b.id || 0)
|
||||
})
|
||||
const keepId = sorted[0].id
|
||||
return list.map(a => {
|
||||
if (a.isDefault && a.id !== keepId) {
|
||||
return {...a, isDefault: false}
|
||||
}
|
||||
return a
|
||||
})
|
||||
}
|
||||
|
||||
export function useAddress(): UseAddressReturn {
|
||||
const [addresses, setAddresses] = useState<ShopUserAddress[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -29,8 +55,8 @@ export function useAddress(): UseAddressReturn {
|
||||
setLoading(true)
|
||||
try {
|
||||
const list = await listShopUserAddress()
|
||||
const actualList = Array.isArray(list) ? list : (list as any)?.data || []
|
||||
setAddresses(actualList)
|
||||
const raw: ShopUserAddress[] = Array.isArray(list) ? list : (list as any)?.data || []
|
||||
setAddresses(ensureSingleDefault(raw))
|
||||
} catch (error) {
|
||||
console.error('Load addresses error:', error)
|
||||
setAddresses([])
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'
|
||||
import { View, Text, ScrollView, Swiper, SwiperItem, Image } from '@tarojs/components'
|
||||
import Taro, { useDidShow } from '@tarojs/taro'
|
||||
import { useUser } from '@/hooks/useUser'
|
||||
import { useShare } from '@/hooks/useShare'
|
||||
import { useScrollHeight } from '@/hooks/useScrollHeight'
|
||||
import { useNewOrderDetector } from '@/hooks/useNewOrderDetector'
|
||||
import { useVipStatus } from '@/hooks/useVipStatus'
|
||||
import { getMyClerk } from '@/api/shop/shopStoreUser'
|
||||
import { pageShopOrder } from '@/api/shop/shopOrder'
|
||||
import { listCmsAd } from '@/api/cms/cmsAd'
|
||||
@@ -14,6 +15,7 @@ import type { CmsAd } from '@/api/cms/cmsAd/model'
|
||||
import type { CmsArticle } from '@/api/cms/cmsArticle/model'
|
||||
import type { ShopGoods } from '@/api/shop/shopGoods/model'
|
||||
import Price from '@/components/common/Price'
|
||||
import Badge from '@/components/common/Badge'
|
||||
import { getCompressedImageUrl } from '@/utils/image'
|
||||
|
||||
definePageConfig({
|
||||
@@ -23,12 +25,15 @@ definePageConfig({
|
||||
})
|
||||
|
||||
const IndexPage: React.FC = () => {
|
||||
const { user, isLoggedIn } = useUser()
|
||||
const { user, isLoggedIn, syncFromStorage } = useUser()
|
||||
const [banners, setBanners] = useState<CmsAd[]>([])
|
||||
const [bannerWrapWidth, setBannerWrapWidth] = useState(0)
|
||||
const [announcements, setAnnouncements] = useState<CmsArticle[]>([])
|
||||
const [hotProducts, setHotProducts] = useState<ShopGoods[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const scrollHeight = useScrollHeight(44)
|
||||
// VIP 状态:异步校验并更新缓存,isVip 变化时触发重渲染
|
||||
const { isVip } = useVipStatus()
|
||||
|
||||
// 首页分享:好友 + 朋友圈 + 复制链接(右上角三个按钮全亮)
|
||||
useShare({
|
||||
@@ -63,6 +68,8 @@ const IndexPage: React.FC = () => {
|
||||
|
||||
// 页面重新显示时也刷新店员身份(可能从其他页面回来时状态变了)
|
||||
useDidShow(() => {
|
||||
// 先从 storage 同步用户状态(解决注册/登录后 UserContext 状态未更新的问题)
|
||||
syncFromStorage()
|
||||
if (isLoggedIn) {
|
||||
getMyClerk()
|
||||
.then(data => {
|
||||
@@ -85,6 +92,8 @@ const IndexPage: React.FC = () => {
|
||||
return pageShopOrder({ page: 1, limit: 5 }).then(r => r?.list || [])
|
||||
}, []),
|
||||
onNewOrders: useCallback((count) => {
|
||||
// 非门店店员不弹新订单提醒
|
||||
if (!isClerkRef.current) return
|
||||
Taro.vibrateShort({ type: 'medium' })
|
||||
Taro.showToast({
|
||||
title: `您有 ${count} 条新订单`,
|
||||
@@ -163,8 +172,19 @@ const IndexPage: React.FC = () => {
|
||||
Taro.navigateTo({ url: '/pages/index/notification' })
|
||||
}
|
||||
|
||||
/**
|
||||
* VIP 会员显示 dealerPrice(同时把 price 作为划线原价),非 VIP 维持原行为
|
||||
*/
|
||||
const getHotDisplayPrice = (item: ShopGoods): { price: string; original?: string } => {
|
||||
if (isVip && item.dealerPrice) {
|
||||
return { price: item.dealerPrice, original: item.price }
|
||||
}
|
||||
const original = item.salePrice && item.salePrice !== item.price ? item.salePrice : undefined
|
||||
return { price: item.price || '0', original }
|
||||
}
|
||||
|
||||
// tabBar 页面列表
|
||||
const tabBarPages = ['/pages/index/index', '/pages/shop/index', '/pages/order/list', '/pages/user/user']
|
||||
const tabBarPages = ['/pages/index/index', '/pages/shop/index', '/pages/shop/cart', '/pages/user/user']
|
||||
|
||||
const handleFeatureClick = (url: string) => {
|
||||
if (tabBarPages.includes(url)) {
|
||||
@@ -174,6 +194,44 @@ const IndexPage: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 获取轮播容器实际宽度,用于根据后台 width/height 计算比例高度
|
||||
useEffect(() => {
|
||||
if (bannerWrapWidth > 0) return
|
||||
const timer = setTimeout(() => {
|
||||
const query = Taro.createSelectorQuery()
|
||||
query.select('.index-banner-wrap').boundingClientRect((rect) => {
|
||||
if (rect && rect.width > 0) {
|
||||
setBannerWrapWidth(rect.width)
|
||||
}
|
||||
}).exec()
|
||||
}, 0)
|
||||
return () => clearTimeout(timer)
|
||||
}, [bannerWrapWidth])
|
||||
|
||||
// 根据广告位后台返回的 width/height 计算轮播区域高度(无值则 fallback 160px)
|
||||
const bannerHeight = useMemo(() => {
|
||||
if (bannerWrapWidth <= 0) return 160
|
||||
const firstAd = banners.find(b => b.width && b.height)
|
||||
if (!firstAd) return 160
|
||||
const w = parseInt(firstAd.width || '0', 10)
|
||||
const h = parseInt(firstAd.height || '0', 10)
|
||||
if (!w || !h || w <= 0 || h <= 0) return 160
|
||||
return Math.round(bannerWrapWidth * (h / w))
|
||||
}, [bannerWrapWidth, banners])
|
||||
|
||||
// 轮播图数据:每个广告位下的所有图片都作为独立轮播项
|
||||
const bannerSlides = banners.flatMap(item =>
|
||||
(item.imageList && item.imageList.length > 0)
|
||||
? item.imageList.map((img, idx) => ({
|
||||
key: `${item.adId}-${img.uid || idx}`,
|
||||
src: img.url || '',
|
||||
path: item.path,
|
||||
}))
|
||||
: item.image
|
||||
? [{ key: `${item.adId}-0`, src: item.image, path: item.path }]
|
||||
: []
|
||||
)
|
||||
|
||||
return (
|
||||
<View className='min-h-screen bg-gray-50'>
|
||||
{/* 顶部搜索栏 */}
|
||||
@@ -189,14 +247,10 @@ const IndexPage: React.FC = () => {
|
||||
</View>
|
||||
<View onClick={handleMessageClick} className='relative'>
|
||||
<Text className='text-xl'>🔔</Text>
|
||||
{isLoggedIn && newOrderCount > 0 && (
|
||||
<View className='absolute -top-1 -right-1 min-w-[16px] h-4 px-1 bg-red-500 rounded-full flex items-center justify-center'>
|
||||
<Text className='text-white text-[10px] leading-none font-medium'>
|
||||
{newOrderCount > 99 ? '99+' : newOrderCount}
|
||||
</Text>
|
||||
</View>
|
||||
{isClerk && newOrderCount > 0 && (
|
||||
<Badge count={newOrderCount} className='absolute -top-1 -right-1' />
|
||||
)}
|
||||
{isLoggedIn && newOrderCount === 0 && (
|
||||
{isClerk && newOrderCount === 0 && (
|
||||
<View className='absolute top-0 right-0 w-2 h-2 bg-red-500 rounded-full' />
|
||||
)}
|
||||
</View>
|
||||
@@ -204,29 +258,29 @@ const IndexPage: React.FC = () => {
|
||||
|
||||
<ScrollView scrollY style={{ height: scrollHeight }}>
|
||||
{/* 轮播图 */}
|
||||
<View className='mx-3 mt-2 rounded-lg overflow-hidden'>
|
||||
{banners.length > 0 ? (
|
||||
<View className='mx-3 mt-2 rounded-lg overflow-hidden index-banner-wrap'>
|
||||
{bannerSlides.length > 0 ? (
|
||||
<Swiper
|
||||
autoplay
|
||||
interval={3000}
|
||||
className='rounded-lg'
|
||||
style={{ height: '160px' }}
|
||||
style={{ height: `${bannerHeight}px` }}
|
||||
>
|
||||
{banners.map(item => (
|
||||
<SwiperItem key={item.adId}>
|
||||
{bannerSlides.map(slide => (
|
||||
<SwiperItem key={slide.key}>
|
||||
<Image
|
||||
className='w-full h-full'
|
||||
src={getCompressedImageUrl(item.imageList?.[0]?.url || item.image || '', { width: 750, quality: 90})}
|
||||
src={getCompressedImageUrl(slide.src, { width: 750, quality: 90 })}
|
||||
mode='aspectFill'
|
||||
onClick={() => {
|
||||
if (item.path) Taro.navigateTo({ url: item.path })
|
||||
if (slide.path) Taro.navigateTo({ url: slide.path })
|
||||
}}
|
||||
/>
|
||||
</SwiperItem>
|
||||
))}
|
||||
</Swiper>
|
||||
) : (
|
||||
<View className='w-full bg-green-50 flex items-center justify-center' style={{ height: '160px' }}>
|
||||
<View className='w-full bg-green-50 flex items-center justify-center' style={{ height: `${bannerHeight}px` }}>
|
||||
<Text className='text-gray-400 text-sm'>暂无轮播图</Text>
|
||||
</View>
|
||||
)}
|
||||
@@ -328,11 +382,14 @@ const IndexPage: React.FC = () => {
|
||||
<Text className='text-sm text-gray-700 truncate block'>{item.goodsName || item.name}</Text>
|
||||
<View className='flex items-center mt-1'>
|
||||
<Price
|
||||
price={item.price || '0'}
|
||||
original={item.salePrice && item.salePrice !== item.price ? item.salePrice : undefined}
|
||||
price={getHotDisplayPrice(item).price}
|
||||
original={getHotDisplayPrice(item).original}
|
||||
size='small'
|
||||
loginMask
|
||||
/>
|
||||
{isVip && item.dealerPrice ? (
|
||||
<Text className='text-xs text-amber-600 ml-1'>VIP</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{item.sales !== undefined && item.sales > 0 && (
|
||||
<Text className='text-xs text-gray-400 mt-1'>已售 {item.sales}</Text>
|
||||
|
||||
@@ -24,6 +24,7 @@ const PAY_TYPE_MAP: Record<number, string> = {
|
||||
6: '免费',
|
||||
7: '积分支付',
|
||||
8: '货到付款',
|
||||
9: '线下付款',
|
||||
}
|
||||
|
||||
// 发票状态映射
|
||||
@@ -40,10 +41,18 @@ const DELIVERY_STATUS_MAP: Record<number, string> = {
|
||||
30: '部分发货',
|
||||
}
|
||||
|
||||
// 配送方式映射: 0=快递配送, 1=无需发货/自提, 2=商家送货
|
||||
const DELIVERY_TYPE_MAP: Record<number, string> = {
|
||||
0: '快递配送',
|
||||
1: '自提',
|
||||
2: '商家送货',
|
||||
}
|
||||
|
||||
/** 根据订单状态计算展示用的状态标签 */
|
||||
const getOrderDisplayStatus = (order: ShopOrder): { title: string; bgColor: string; subtitle: string } => {
|
||||
const { payStatus, deliveryStatus, orderStatus, payType } = order
|
||||
const isCod = payType === 8 // 货到付款
|
||||
const isOffline = payType === 9 // 线下付款
|
||||
|
||||
// 退款/取消相关状态
|
||||
if (orderStatus === OrderStatus.Cancelled) {
|
||||
@@ -62,18 +71,28 @@ const getOrderDisplayStatus = (order: ShopOrder): { title: string; bgColor: stri
|
||||
return { bgColor: '#ee0a24', title: '退款被拒绝', subtitle: '退款申请已被拒绝' }
|
||||
}
|
||||
|
||||
// 正常订单流程
|
||||
if (!payStatus && !isCod) {
|
||||
// 线下付款已发货但未确认收款:显示"已发货待收款"(红色),与门店端一致
|
||||
if (isOffline && !payStatus && deliveryStatus === 20) {
|
||||
return { bgColor: '#ee0a24', title: '已发货待收款', subtitle: '商品已发货,请尽快转账付款' }
|
||||
}
|
||||
|
||||
// 物流状态优先于付款状态
|
||||
if (deliveryStatus === 20) {
|
||||
return { bgColor: '#4b9cf5', title: '待收货', subtitle: '商品运输中,请注意查收' }
|
||||
}
|
||||
|
||||
// 未发货时按付款状态显示
|
||||
if (!payStatus && !isCod && !isOffline) {
|
||||
return { bgColor: '#ff7d00', title: '待付款', subtitle: '请尽快完成支付' }
|
||||
}
|
||||
if (isOffline && !payStatus) {
|
||||
return { bgColor: '#4b9cf5', title: '待发货', subtitle: '请通过微信转账完成付款,商家确认后发货' }
|
||||
}
|
||||
if (isCod && !payStatus) {
|
||||
return { bgColor: '#4b9cf5', title: '货到付款·待发货', subtitle: '送达时支付' }
|
||||
return { bgColor: '#4b9cf5', title: '待发货', subtitle: '送达时支付' }
|
||||
}
|
||||
if (deliveryStatus === 10) {
|
||||
return { bgColor: '#4b9cf5', title: isCod ? '货到付款·待发货' : '待发货', subtitle: isCod ? '送达时支付' : '商家正在准备商品' }
|
||||
}
|
||||
if (deliveryStatus === 20 || deliveryStatus === 30) {
|
||||
return { bgColor: '#4b9cf5', title: '待收货', subtitle: '商品运输中,请注意查收' }
|
||||
return { bgColor: '#4b9cf5', title: '待发货', subtitle: '商家正在准备商品' }
|
||||
}
|
||||
if (orderStatus === OrderStatus.Completed) {
|
||||
return { bgColor: '#0e932e', title: '已完成', subtitle: '交易已完成,感谢购买' }
|
||||
@@ -87,21 +106,26 @@ const getOrderDisplayStatus = (order: ShopOrder): { title: string; bgColor: stri
|
||||
}
|
||||
|
||||
/** 根据订单状态判断当前操作阶段 */
|
||||
type OrderPhase = 'unpaid' | 'unshipped' | 'shipped' | 'completed' | 'cancelled' | 'refund'
|
||||
type OrderPhase = 'unpaid' | 'offline_pending' | 'unshipped' | 'shipped' | 'completed' | 'cancelled' | 'refund'
|
||||
|
||||
const getOrderPhase = (order: ShopOrder): OrderPhase => {
|
||||
const { payStatus, deliveryStatus, orderStatus, payType } = order
|
||||
const isCod = payType === 8 // 货到付款
|
||||
const isOffline = payType === 9 // 线下付款
|
||||
|
||||
// 退款/取消
|
||||
if ([OrderStatus.Cancelled, OrderStatus.Cancelling, OrderStatus.RefundSuccess, OrderStatus.RefundApply, OrderStatus.ClientRefundApply, OrderStatus.RefundRejected].includes(orderStatus as OrderStatus)) {
|
||||
return orderStatus === OrderStatus.Cancelled || orderStatus === OrderStatus.RefundSuccess ? 'cancelled' : 'refund'
|
||||
}
|
||||
|
||||
// 物流状态优先于付款状态(已发货的订单,即使未付款也进入 shipped 阶段)
|
||||
if (deliveryStatus === 20) return 'shipped'
|
||||
|
||||
// 线下付款且未确认收款:等待商家确认,不显示"立即支付"按钮
|
||||
if (isOffline && !payStatus) return 'offline_pending'
|
||||
// 货到付款订单:payStatus=true 但还未实际付款,直接进入 unshipped 阶段(不需要"立即支付"按钮)
|
||||
if (!payStatus && !isCod) return 'unpaid'
|
||||
if (deliveryStatus === 10) return 'unshipped'
|
||||
if (deliveryStatus === 20 || deliveryStatus === 30) return 'shipped'
|
||||
if (orderStatus === OrderStatus.Completed) return 'completed'
|
||||
|
||||
return 'unshipped'
|
||||
@@ -237,7 +261,8 @@ const OrderDetailPage: React.FC = () => {
|
||||
|
||||
const displayStatus = getOrderDisplayStatus(order)
|
||||
const phase = getOrderPhase(order)
|
||||
const isExpress = order.deliveryType === 0 || order.deliveryType === undefined
|
||||
// deliveryType: 0=快递配送, 1=无需发货/自提, 2=商家送货
|
||||
const isExpress = order.deliveryType === 0 || order.deliveryType === 2 || order.deliveryType === undefined
|
||||
const goodsCount = order.orderGoods?.reduce((sum, g) => sum + (g.totalNum || 1), 0) || 0
|
||||
|
||||
return (
|
||||
@@ -266,13 +291,16 @@ const OrderDetailPage: React.FC = () => {
|
||||
{isExpress ? (
|
||||
<View className='bg-white mx-3 mt-3 p-3 rounded-lg'>
|
||||
<View className='flex items-start gap-2'>
|
||||
<Text className='text-base'>📦</Text>
|
||||
<Text className='text-base'>{order.deliveryType === 2 ? '🛵' : '📦'}</Text>
|
||||
<View className='flex-1'>
|
||||
<View className='flex gap-2 mb-1'>
|
||||
<Text className='text-sm font-medium text-gray-800'>{order.realName || '未知'}</Text>
|
||||
<Text className='text-sm text-gray-600'>{order.mobile || order.phone || '-'}</Text>
|
||||
</View>
|
||||
<Text className='text-xs text-gray-500'>{order.address || '未填写收货地址'}</Text>
|
||||
{order.deliveryType === 2 && order.expressMerchantName && (
|
||||
<Text className='text-xs text-gray-400 mt-1'>配送门店: {order.expressMerchantName}</Text>
|
||||
)}
|
||||
{(order.sendStartTime || order.sendEndTime) && (
|
||||
<Text className='text-xs text-gray-400 mt-1'>
|
||||
期望配送: {order.sendStartTime?.slice(0, 10)} ~ {order.sendEndTime?.slice(0, 10)}
|
||||
@@ -341,7 +369,7 @@ const OrderDetailPage: React.FC = () => {
|
||||
<Text className='text-sm text-gray-500'>商品总额</Text>
|
||||
<Text className='text-sm text-gray-700'>¥{order.totalPrice || '0.00'}</Text>
|
||||
</View>
|
||||
{order.reducePrice && Number(order.reducePrice) > 0 && (
|
||||
{Number(order.reducePrice || 0) > 0 && (
|
||||
<View className='flex justify-between'>
|
||||
<Text className='text-sm text-gray-500'>优惠</Text>
|
||||
<Text className='text-sm text-green-600'>-¥{order.reducePrice}</Text>
|
||||
@@ -381,6 +409,10 @@ const OrderDetailPage: React.FC = () => {
|
||||
<Text className='text-xs text-gray-400'>发货状态</Text>
|
||||
<Text className='text-xs text-gray-600'>{DELIVERY_STATUS_MAP[order.deliveryStatus ?? 10]}</Text>
|
||||
</View>
|
||||
<View className='flex justify-between'>
|
||||
<Text className='text-xs text-gray-400'>配送方式</Text>
|
||||
<Text className='text-xs text-gray-600'>{DELIVERY_TYPE_MAP[order.deliveryType ?? 0] || '未知'}</Text>
|
||||
</View>
|
||||
<View className='flex justify-between'>
|
||||
<Text className='text-xs text-gray-400'>创建时间</Text>
|
||||
<Text className='text-xs text-gray-600'>{formatTime(order.createTime)}</Text>
|
||||
@@ -397,12 +429,6 @@ const OrderDetailPage: React.FC = () => {
|
||||
<Text className='text-xs text-gray-600'>{formatTime(order.deliveryTime)}</Text>
|
||||
</View>
|
||||
)}
|
||||
{order.expirationTime && (
|
||||
<View className='flex justify-between'>
|
||||
<Text className='text-xs text-gray-400'>过期时间</Text>
|
||||
<Text className='text-xs text-gray-600'>{formatTime(order.expirationTime)}</Text>
|
||||
</View>
|
||||
)}
|
||||
{order.comments && (
|
||||
<View className='flex justify-start'>
|
||||
<Text className='text-xs text-gray-400 mr-2'>备注</Text>
|
||||
@@ -445,6 +471,28 @@ const OrderDetailPage: React.FC = () => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 线下付款·待确认: 取消 + 联系客服 */}
|
||||
{phase === 'offline_pending' && (
|
||||
<>
|
||||
<Button
|
||||
size='small'
|
||||
className='flex-1'
|
||||
style={{ borderColor: '#ddd', color: '#666' }}
|
||||
onClick={handleCancelOrder}
|
||||
>
|
||||
取消订单
|
||||
</Button>
|
||||
<Button
|
||||
size='small'
|
||||
className='flex-1'
|
||||
style={{ backgroundColor: '#4b9cf5' }}
|
||||
onClick={() => Taro.makePhoneCall({ phoneNumber: '400-000-0000' })}
|
||||
>
|
||||
联系客服
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 待发货: 退款 + 联系客服 */}
|
||||
{phase === 'unshipped' && (
|
||||
<>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { View, Text, ScrollView } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { pageShopOrder } from '@/api/shop/shopOrder'
|
||||
import { pageShopOrder, updateShopOrder } from '@/api/shop/shopOrder'
|
||||
import type { ShopOrder, ShopOrderParam } from '@/api/shop/shopOrder/model'
|
||||
import { useUser } from '@/hooks/useUser'
|
||||
import { OrderListStatus, OrderListTabText } from '@/types/order'
|
||||
import { OrderListStatus, OrderListTabText, OrderStatus } from '@/types/order'
|
||||
import OrderCard from '@/components/common/OrderCard'
|
||||
import EmptyState from '@/components/common/EmptyState'
|
||||
import LoadMore from '@/components/common/LoadMore'
|
||||
@@ -124,7 +124,7 @@ const OrderListPage: React.FC = () => {
|
||||
}
|
||||
}, [tabIndex, isLoggedIn, user?.userId])
|
||||
|
||||
// 页面每次显示时刷新数据(tabBar 页 switchTab 不会重新挂载组件)
|
||||
// 页面每次显示时刷新数据(覆盖从订单详情页返回等再次显示场景)
|
||||
const isFirstShow = useRef(true)
|
||||
Taro.useDidShow(() => {
|
||||
// 首次显示由 useEffect 处理,跳过
|
||||
@@ -147,6 +147,29 @@ const OrderListPage: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 取消订单(未付款/待确认收款状态下允许客户取消) */
|
||||
const handleCloseOrder = (order: ShopOrder) => {
|
||||
Taro.showModal({
|
||||
title: '确认取消订单',
|
||||
content: '取消后订单将关闭,是否确认?',
|
||||
confirmColor: '#ee0a24',
|
||||
success: async (res) => {
|
||||
if (res.confirm && order?.orderId) {
|
||||
try {
|
||||
await updateShopOrder({ ...order, orderStatus: OrderStatus.Cancelled })
|
||||
Taro.showToast({ title: '订单已取消', icon: 'success' })
|
||||
// 标记所有 tab 为未初始化,切换时会重新加载(确保「全部」等 tab 能看到最新状态)
|
||||
setTabStates(prev => prev.map(s => ({ ...s, initialized: false })))
|
||||
// 立即刷新当前 tab
|
||||
loadOrders(tabIndex, 1)
|
||||
} catch (err: any) {
|
||||
Taro.showToast({ title: err?.message || '取消失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleTabChange = (index: number) => {
|
||||
if (index !== tabIndex) {
|
||||
setTabIndex(index)
|
||||
@@ -209,7 +232,11 @@ const OrderListPage: React.FC = () => {
|
||||
<View className='p-3'>
|
||||
{tabStates[idx].orders.length > 0 ? (
|
||||
tabStates[idx].orders.map(order => (
|
||||
<OrderCard key={order.orderId} order={order} />
|
||||
<OrderCard
|
||||
key={order.orderId}
|
||||
order={order}
|
||||
onCloseOrder={handleCloseOrder}
|
||||
/>
|
||||
))
|
||||
) : tabStates[idx].initialized && !tabStates[idx].loading ? (
|
||||
<EmptyState text='暂无订单' />
|
||||
|
||||
@@ -2,8 +2,11 @@ import React, { useState, useEffect } from 'react'
|
||||
import { View, Text, ScrollView } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { Button } from '@nutui/nutui-react-taro'
|
||||
import { queryLogistics, formatLogisticsStatus, EXPRESS_COMPANIES } from '@/api/shop/shopLogistics'
|
||||
import { queryLogistics, formatLogisticsStatus } from '@/api/shop/shopLogistics'
|
||||
import type { LogisticsInfo, LogisticsTrack } from '@/api/shop/shopLogistics'
|
||||
import { getShopOrder } from '@/api/shop/shopOrder'
|
||||
import type { ShopOrder, ShopOrderDelivery } from '@/api/shop/shopOrder/model'
|
||||
import { OrderStatus } from '@/types/order'
|
||||
import Loading from '@/components/common/Loading'
|
||||
import EmptyState from '@/components/common/EmptyState'
|
||||
|
||||
@@ -11,9 +14,18 @@ definePageConfig({
|
||||
navigationBarTitleText: '物流详情',
|
||||
})
|
||||
|
||||
// 发货方式映射
|
||||
const DELIVERY_METHOD_MAP: Record<number, string> = {
|
||||
10: '手动录入',
|
||||
20: '无需物流',
|
||||
30: '电子面单',
|
||||
}
|
||||
|
||||
const LogisticsPage: React.FC = () => {
|
||||
const { orderId, expressNo, expressCompany } = Taro.getCurrentInstance().router?.params || {}
|
||||
const { orderId, expressNo: urlExpressNo, expressCompany: urlExpressCompany } = Taro.getCurrentInstance().router?.params || {}
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [order, setOrder] = useState<ShopOrder | null>(null)
|
||||
const [delivery, setDelivery] = useState<ShopOrderDelivery | null>(null)
|
||||
const [logisticsInfo, setLogisticsInfo] = useState<LogisticsInfo | null>(null)
|
||||
const [trackList, setTrackList] = useState<LogisticsTrack[]>([])
|
||||
|
||||
@@ -22,27 +34,42 @@ const LogisticsPage: React.FC = () => {
|
||||
}, [])
|
||||
|
||||
const loadLogistics = async () => {
|
||||
if (!orderId) {
|
||||
Taro.showToast({ title: '缺少订单参数', icon: 'none' })
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
try {
|
||||
// 必须传入参数
|
||||
if (!expressNo || !expressCompany) {
|
||||
Taro.showToast({ title: '缺少物流参数', icon: 'none' })
|
||||
setLoading(false)
|
||||
return
|
||||
// 1) 拉取订单详情(含 shopOrderDelivery,由后端 getByIdRel 填充)
|
||||
let orderData: ShopOrder | null = null
|
||||
try {
|
||||
orderData = await getShopOrder(Number(orderId))
|
||||
} catch (err) {
|
||||
console.error('加载订单信息失败', err)
|
||||
}
|
||||
if (orderData) {
|
||||
setOrder(orderData)
|
||||
setDelivery(orderData.shopOrderDelivery ?? null)
|
||||
}
|
||||
|
||||
const res = await queryLogistics({
|
||||
orderId,
|
||||
expressNo,
|
||||
expressCompany,
|
||||
})
|
||||
if (res?.data) {
|
||||
setLogisticsInfo(res.data.logisticsInfo)
|
||||
setTrackList(res.data.trackList)
|
||||
// 2) 决定运单号 / 物流公司:优先 URL 参数,其次订单的 shopOrderDelivery
|
||||
const expressNo = urlExpressNo || orderData?.shopOrderDelivery?.expressNo || orderData?.expressNo || ''
|
||||
// expressCompany 通常是快递公司代码(SF/YTO 等);shopOrderDelivery.expressName 是中文名
|
||||
const expressCompany = urlExpressCompany || ''
|
||||
|
||||
// 3) 如果有运单号 + 物流公司代码,尝试查询物流轨迹(失败不阻塞发货单展示)
|
||||
if (expressNo && expressCompany) {
|
||||
try {
|
||||
const res = await queryLogistics({ orderId, expressNo, expressCompany })
|
||||
if (res?.data) {
|
||||
setLogisticsInfo(res.data.logisticsInfo || null)
|
||||
setTrackList(res.data.trackList || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('查询物流轨迹失败', err)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('加载物流信息失败', err)
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -50,49 +77,35 @@ const LogisticsPage: React.FC = () => {
|
||||
|
||||
// 复制单号
|
||||
const copyExpressNo = () => {
|
||||
if (logisticsInfo?.expressNo) {
|
||||
Taro.setClipboardData({
|
||||
data: logisticsInfo.expressNo,
|
||||
success: () => {
|
||||
Taro.showToast({ title: '单号已复制', icon: 'success' })
|
||||
},
|
||||
})
|
||||
}
|
||||
const no = logisticsInfo?.expressNo || delivery?.expressNo || order?.expressNo || ''
|
||||
if (!no) return
|
||||
Taro.setClipboardData({
|
||||
data: no,
|
||||
success: () => Taro.showToast({ title: '单号已复制', icon: 'success' }),
|
||||
})
|
||||
}
|
||||
|
||||
// 联系快递员(模拟)
|
||||
const contactRider = () => {
|
||||
// 联系客服
|
||||
const contactService = () => {
|
||||
Taro.makePhoneCall({
|
||||
phoneNumber: '400-811-1111',
|
||||
fail: () => {
|
||||
Taro.showToast({ title: '拨打失败', icon: 'none' })
|
||||
},
|
||||
fail: () => Taro.showToast({ title: '拨打失败', icon: 'none' }),
|
||||
})
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (time: string) => {
|
||||
if (!time) return ''
|
||||
const date = new Date(time)
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${month}-${day} ${hours}:${minutes}`
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (time: string) => {
|
||||
if (!time) return ''
|
||||
const date = new Date(time)
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
||||
const formatTime = (time?: string) => {
|
||||
if (!time) return '-'
|
||||
return time.replace('T', ' ').slice(0, 19)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <Loading fullscreen />
|
||||
}
|
||||
|
||||
if (!logisticsInfo) {
|
||||
// 既无物流轨迹,又无发货单,又无订单 → 空状态
|
||||
const hasAnything = logisticsInfo || trackList?.length || delivery || order
|
||||
if (!hasAnything) {
|
||||
return (
|
||||
<View className='min-h-screen bg-gray-50 flex items-center justify-center'>
|
||||
<EmptyState text='暂无物流信息' />
|
||||
@@ -100,7 +113,30 @@ const LogisticsPage: React.FC = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const statusInfo = formatLogisticsStatus(logisticsInfo.status)
|
||||
const statusInfo = logisticsInfo ? formatLogisticsStatus(logisticsInfo.status) : null
|
||||
|
||||
// 发货状态文案:优先用 logisticsInfo.status,否则按订单 deliveryStatus 推断
|
||||
const getDeliveryStatusText = (): string => {
|
||||
if (logisticsInfo?.status) return logisticsInfo.status
|
||||
const ds = order?.deliveryStatus
|
||||
if (ds === 30) return '部分发货'
|
||||
if (ds === 20) return '已发货'
|
||||
if (ds === 10) return '未发货'
|
||||
if (order?.orderStatus === OrderStatus.Completed) return '已签收'
|
||||
return '已发货'
|
||||
}
|
||||
|
||||
// 物流公司名:优先 logisticsInfo,其次 delivery.expressName
|
||||
const expressCompanyName = logisticsInfo?.expressCompanyName || delivery?.expressName || '未知物流'
|
||||
// 运单号:优先 logisticsInfo,其次 delivery,最后 order
|
||||
const expressNo = logisticsInfo?.expressNo || delivery?.expressNo || order?.expressNo || ''
|
||||
// 发货人信息
|
||||
const sendName = delivery?.sendName || order?.expressMerchantName || '-'
|
||||
const sendPhone = delivery?.sendPhone || '-'
|
||||
const sendAddress = delivery?.sendAddress || '-'
|
||||
const deliveryMethodText = delivery?.deliveryMethod != null
|
||||
? DELIVERY_METHOD_MAP[delivery.deliveryMethod] || '-'
|
||||
: (expressNo ? '手动录入' : '无需物流')
|
||||
|
||||
return (
|
||||
<View className='min-h-screen bg-gray-50 pb-4 flex flex-col'>
|
||||
@@ -108,109 +144,147 @@ const LogisticsPage: React.FC = () => {
|
||||
{/* 快递信息卡片 */}
|
||||
<View className='bg-white mx-3 mt-3 p-4 rounded-xl'>
|
||||
<View className='flex items-center gap-3'>
|
||||
{/* 快递logo */}
|
||||
<View className='w-12 h-12 rounded-lg bg-gray-100 flex items-center justify-center'>
|
||||
<Text className='text-xl'>📦</Text>
|
||||
</View>
|
||||
<View className='flex-1'>
|
||||
<View className='flex items-center gap-2'>
|
||||
<Text className='text-sm font-medium text-gray-800'>{logisticsInfo.expressCompanyName}</Text>
|
||||
<Text className='text-sm font-medium text-gray-800'>{expressCompanyName}</Text>
|
||||
<Text className='text-xs text-gray-400'>•</Text>
|
||||
<Text className='text-sm text-gray-500'>{statusInfo.text}</Text>
|
||||
<Text className='text-sm text-gray-500'>{getDeliveryStatusText()}</Text>
|
||||
</View>
|
||||
<Text className='text-xs text-gray-400 mt-1'>
|
||||
运单号: {logisticsInfo.expressNo}
|
||||
运单号: {expressNo || '暂无'}
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
className='px-3 py-1 rounded-full bg-gray-50'
|
||||
onClick={copyExpressNo}
|
||||
>
|
||||
<Text className='text-xs text-gray-500'>复制</Text>
|
||||
</View>
|
||||
{expressNo ? (
|
||||
<View
|
||||
className='px-3 py-1 rounded-full bg-gray-50'
|
||||
onClick={copyExpressNo}
|
||||
>
|
||||
<Text className='text-xs text-gray-500'>复制</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{/* 预计送达 */}
|
||||
{logisticsInfo.estimatedTime && (
|
||||
<View className='mt-3 pt-3 border-t border-gray-100'>
|
||||
<Text className='text-xs text-gray-400'>
|
||||
预计送达: {formatDate(logisticsInfo.estimatedTime)} 24:00 前
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 当前状态 */}
|
||||
<View className='bg-white mx-3 mt-3 p-4 rounded-xl'>
|
||||
<View className='flex items-start gap-3'>
|
||||
<View className={`w-10 h-10 rounded-full flex items-center justify-center ${statusInfo.icon === '🚚' ? 'bg-green-50' : 'bg-gray-50'}`}>
|
||||
<Text>{statusInfo.icon}</Text>
|
||||
</View>
|
||||
<View className='flex-1'>
|
||||
<Text className='text-sm font-medium text-gray-800'>{logisticsInfo.status}</Text>
|
||||
{logisticsInfo.currentLocation && (
|
||||
<Text className='text-xs text-gray-400 mt-1'>
|
||||
当前位于: {logisticsInfo.currentLocation}
|
||||
</Text>
|
||||
)}
|
||||
<Text className='text-xs text-gray-400 mt-1'>
|
||||
更新时间: {formatTime(logisticsInfo.updateTime)}
|
||||
</Text>
|
||||
</View>
|
||||
{/* 发货方式 */}
|
||||
<View className='mt-3 pt-3 border-t border-gray-100 flex justify-between'>
|
||||
<Text className='text-xs text-gray-400'>发货方式</Text>
|
||||
<Text className='text-xs text-gray-600'>{deliveryMethodText}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 收货人信息 */}
|
||||
{logisticsInfo.receiverInfo && (
|
||||
{/* 发货单信息(shopOrderDelivery) */}
|
||||
{delivery ? (
|
||||
<View className='bg-white mx-3 mt-3 p-4 rounded-xl'>
|
||||
<Text className='text-sm font-medium text-gray-800 mb-3 block'>发货信息</Text>
|
||||
<View className='space-y-2'>
|
||||
<View className='flex justify-between'>
|
||||
<Text className='text-xs text-gray-400'>发货人</Text>
|
||||
<Text className='text-xs text-gray-600'>{sendName}</Text>
|
||||
</View>
|
||||
<View className='flex justify-between'>
|
||||
<Text className='text-xs text-gray-400'>联系方式</Text>
|
||||
<Text className='text-xs text-gray-600'>{sendPhone}</Text>
|
||||
</View>
|
||||
<View className='flex justify-between'>
|
||||
<Text className='text-xs text-gray-400'>发货地址</Text>
|
||||
<Text className='text-xs text-gray-600 text-right flex-1 ml-4'>{sendAddress}</Text>
|
||||
</View>
|
||||
<View className='flex justify-between'>
|
||||
<Text className='text-xs text-gray-400'>物流单号</Text>
|
||||
<View className='flex items-center gap-1'>
|
||||
<Text className='text-xs text-gray-600'>{delivery.expressNo || '-'}</Text>
|
||||
{delivery.expressNo ? (
|
||||
<Text className='text-xs text-blue-500' onClick={copyExpressNo}>复制</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
{delivery.createTime ? (
|
||||
<View className='flex justify-between'>
|
||||
<Text className='text-xs text-gray-400'>发货时间</Text>
|
||||
<Text className='text-xs text-gray-600'>{formatTime(delivery.createTime)}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* 收货人信息(从订单读取) */}
|
||||
{order ? (
|
||||
<View className='bg-white mx-3 mt-3 p-4 rounded-xl'>
|
||||
<Text className='text-sm font-medium text-gray-800 mb-2 block'>收货信息</Text>
|
||||
<View className='flex items-center gap-2'>
|
||||
<Text className='text-sm text-gray-600'>{logisticsInfo.receiverInfo.name}</Text>
|
||||
<Text className='text-sm text-gray-500'>{logisticsInfo.receiverInfo.phone}</Text>
|
||||
<Text className='text-sm text-gray-600'>{order.realName || '-'}</Text>
|
||||
<Text className='text-sm text-gray-500'>{order.mobile || order.phone || '-'}</Text>
|
||||
</View>
|
||||
<Text className='text-xs text-gray-400 mt-1'>{logisticsInfo.receiverInfo.address}</Text>
|
||||
<Text className='text-xs text-gray-400 mt-1'>{order.address || '-'}</Text>
|
||||
</View>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{/* 物流轨迹 */}
|
||||
<View className='bg-white mx-3 mt-3 p-4 rounded-xl'>
|
||||
<Text className='text-sm font-medium text-gray-800 mb-3 block'>物流详情</Text>
|
||||
<View className='relative'>
|
||||
{/* 竖线 */}
|
||||
<View className='absolute left-4 top-0 bottom-0 w-px bg-gray-200' />
|
||||
{/* 物流轨迹(仅在查询到时展示) */}
|
||||
{trackList && trackList.length > 0 ? (
|
||||
<View className='bg-white mx-3 mt-3 p-4 rounded-xl'>
|
||||
<Text className='text-sm font-medium text-gray-800 mb-3 block'>物流详情</Text>
|
||||
<View className='relative'>
|
||||
{/* 竖线 */}
|
||||
<View className='absolute left-4 top-0 bottom-0 w-px bg-gray-200' />
|
||||
|
||||
{trackList.map((item, idx) => (
|
||||
<View key={idx} className={`relative flex gap-3 pb-4 ${idx === trackList.length - 1 ? 'pb-0' : ''}`}>
|
||||
{/* 圆点 */}
|
||||
<View
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center z-10 ${
|
||||
item.isCompleted ? 'bg-green-500' : 'bg-white border-2 border-green-500'
|
||||
}`}
|
||||
>
|
||||
{item.isCompleted && idx !== 0 ? (
|
||||
<Text className='text-white text-xs'>✓</Text>
|
||||
) : !item.isCompleted ? (
|
||||
<View className='w-2 h-2 rounded-full bg-green-500' />
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{/* 内容 */}
|
||||
<View className='flex-1 pt-1'>
|
||||
<View className='flex justify-between items-start'>
|
||||
<Text className={`text-sm ${item.isCompleted ? 'text-gray-600' : 'text-gray-800 font-medium'}`}>
|
||||
{item.status}
|
||||
</Text>
|
||||
<Text className='text-xs text-gray-400'>{formatTime(item.time)}</Text>
|
||||
{trackList.map((item, idx) => (
|
||||
<View key={idx} className={`relative flex gap-3 pb-4 ${idx === trackList.length - 1 ? 'pb-0' : ''}`}>
|
||||
{/* 圆点 */}
|
||||
<View
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center z-10 ${
|
||||
item.isCompleted ? 'bg-green-500' : 'bg-white border-2 border-green-500'
|
||||
}`}
|
||||
>
|
||||
{item.isCompleted && idx !== 0 ? (
|
||||
<Text className='text-white text-xs'>✓</Text>
|
||||
) : !item.isCompleted ? (
|
||||
<View className='w-2 h-2 rounded-full bg-green-500' />
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{/* 内容 */}
|
||||
<View className='flex-1 pt-1'>
|
||||
<View className='flex justify-between items-start'>
|
||||
<Text className={`text-sm ${item.isCompleted ? 'text-gray-600' : 'text-gray-800 font-medium'}`}>
|
||||
{item.status}
|
||||
</Text>
|
||||
<Text className='text-xs text-gray-400'>{formatTime(item.time)}</Text>
|
||||
</View>
|
||||
<Text className={`text-xs mt-1 ${item.isCompleted ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
{item.description}
|
||||
</Text>
|
||||
<Text className='text-xs text-gray-400 mt-1'>{item.location}</Text>
|
||||
</View>
|
||||
<Text className={`text-xs mt-1 ${item.isCompleted ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
{item.description}
|
||||
</Text>
|
||||
<Text className='text-xs text-gray-400 mt-1'>{item.location}</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* 当前状态(仅在查询到 logisticsInfo 时展示) */}
|
||||
{logisticsInfo ? (
|
||||
<View className='bg-white mx-3 mt-3 p-4 rounded-xl'>
|
||||
<View className='flex items-start gap-3'>
|
||||
<View className={`w-10 h-10 rounded-full flex items-center justify-center ${statusInfo?.icon === '🚚' ? 'bg-green-50' : 'bg-gray-50'}`}>
|
||||
<Text>{statusInfo?.icon}</Text>
|
||||
</View>
|
||||
<View className='flex-1'>
|
||||
<Text className='text-sm font-medium text-gray-800'>{logisticsInfo.status}</Text>
|
||||
{logisticsInfo.currentLocation ? (
|
||||
<Text className='text-xs text-gray-400 mt-1'>
|
||||
当前位于: {logisticsInfo.currentLocation}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text className='text-xs text-gray-400 mt-1'>
|
||||
更新时间: {formatTime(logisticsInfo.updateTime)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* 温馨提示 */}
|
||||
<View className='bg-orange-50 mx-3 mt-3 p-4 rounded-xl'>
|
||||
@@ -229,7 +303,7 @@ const LogisticsPage: React.FC = () => {
|
||||
<Button
|
||||
size='small'
|
||||
className='flex-1 rounded-full border-gray-300 text-gray-600'
|
||||
onClick={contactRider}
|
||||
onClick={contactService}
|
||||
>
|
||||
联系客服
|
||||
</Button>
|
||||
|
||||
@@ -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='切换主题'
|
||||
|
||||
@@ -10,7 +10,7 @@ import Loading from '@/components/common/Loading'
|
||||
import { isGuest } from '@/utils/auth'
|
||||
import { getCompressedImageUrl } from '@/utils/image'
|
||||
import { requireLogin } from '@/utils/login-guard'
|
||||
import { isVipMember } from '@/utils/vip'
|
||||
import { useVipStatus } from '@/hooks/useVipStatus'
|
||||
|
||||
definePageConfig({
|
||||
navigationBarTitleText: '购物车',
|
||||
@@ -18,7 +18,9 @@ definePageConfig({
|
||||
|
||||
const CartPage: React.FC = () => {
|
||||
const { items, selectedCount, selectedPrice, updateQuantity, toggleSelect, selectAll, removeItem, refresh, loading, removeSelected, addItem } = useCartContext()
|
||||
const { isLoggedIn } = useUserContext()
|
||||
const { isLoggedIn, syncFromStorage } = useUserContext()
|
||||
// VIP 状态:异步校验并更新缓存,isVip 变化时触发重渲染
|
||||
const { isVip } = useVipStatus()
|
||||
const [recommendGoods, setRecommendGoods] = useState<ShopGoods[]>([])
|
||||
const [recommendPage, setRecommendPage] = useState(1)
|
||||
const [refreshingRecommend, setRefreshingRecommend] = useState(false)
|
||||
@@ -32,7 +34,9 @@ const CartPage: React.FC = () => {
|
||||
|
||||
// 页面再次显示时刷新购物车数据(从其他页面加购后回到购物车)
|
||||
useDidShow(() => {
|
||||
if (isLoggedIn) {
|
||||
// 先从 storage 同步用户状态(解决注册/登录后 UserContext 状态未更新的问题)
|
||||
const loggedIn = syncFromStorage()
|
||||
if (loggedIn) {
|
||||
refresh()
|
||||
}
|
||||
})
|
||||
@@ -110,7 +114,7 @@ const CartPage: React.FC = () => {
|
||||
>
|
||||
<Image
|
||||
className='w-full rounded-md bg-gray-100'
|
||||
src={getCompressedImageUrl(goods.image || '')}
|
||||
src={getCompressedImageUrl(goods.image || '', { width: 90 })}
|
||||
mode='aspectFill'
|
||||
style={{ height: '90px' }}
|
||||
/>
|
||||
@@ -177,7 +181,7 @@ const CartPage: React.FC = () => {
|
||||
</View>
|
||||
<Image
|
||||
className="w-20 h-20 rounded-md bg-gray-100 flex-shrink-0"
|
||||
src={getCompressedImageUrl(item.goodsImage || item.product?.image || (item.sku as any)?.image || '')}
|
||||
src={getCompressedImageUrl(item.goodsImage || item.product?.image || (item.sku as any)?.image || '',{ width: 90 })}
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<View className="flex-1 flex flex-col justify-between">
|
||||
@@ -189,7 +193,7 @@ const CartPage: React.FC = () => {
|
||||
)}
|
||||
<View className="flex justify-between items-center">
|
||||
<Text className="text-sm font-bold text-red-500">
|
||||
¥{isVipMember() && (item.dealerPrice || (item.product as any)?.dealerPrice) ? (item.dealerPrice || (item.product as any).dealerPrice) : (item.skuPrice || item.sku?.price || item.salePrice || item.product?.salePrice || item.product?.price || '0')}
|
||||
¥{isVip && (item.dealerPrice || (item.product as any)?.dealerPrice) ? (item.dealerPrice || (item.product as any).dealerPrice) : (item.skuPrice || item.sku?.price || item.salePrice || item.product?.salePrice || item.product?.price || '0')}
|
||||
</Text>
|
||||
<View className="flex items-center gap-3">
|
||||
<View className="w-6 h-6 rounded bg-gray-100 flex items-center justify-center" onClick={() => updateQuantity(item.goodsId, item.skuId, item.quantity - 1)}>
|
||||
@@ -229,7 +233,7 @@ const CartPage: React.FC = () => {
|
||||
>
|
||||
<Image
|
||||
className="w-full rounded-md bg-gray-100"
|
||||
src={getCompressedImageUrl(goods.image || '')}
|
||||
src={getCompressedImageUrl(goods.image || '',{ width: 90 })}
|
||||
mode="aspectFill"
|
||||
style={{ height: '90px' }}
|
||||
/>
|
||||
@@ -238,7 +242,7 @@ const CartPage: React.FC = () => {
|
||||
</Text>
|
||||
<View className="flex items-center justify-between mt-1">
|
||||
<Text className="text-xs font-bold text-red-500">
|
||||
¥{isVipMember() && goods.dealerPrice ? goods.dealerPrice : (goods.salePrice || goods.price || '0')}
|
||||
¥{isVip && goods.dealerPrice ? goods.dealerPrice : (goods.salePrice || goods.price || '0')}
|
||||
</Text>
|
||||
<View
|
||||
className="w-5 h-5 rounded-full flex items-center justify-center"
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useCartContext } from '@/contexts/CartContext'
|
||||
import type { ShopGoodsCategory } from '@/api/shop/shopGoodsCategory/model'
|
||||
import type { ShopGoods, ShopGoodsParam } from '@/api/shop/shopGoods/model'
|
||||
import { isGuest } from '@/utils/auth'
|
||||
import { isVipMember } from '@/utils/vip'
|
||||
import { useVipStatus } from '@/hooks/useVipStatus'
|
||||
import { getCompressedImageUrl } from '@/utils/image'
|
||||
import { requireLogin } from '@/utils/login-guard'
|
||||
import LoadMore from '@/components/common/LoadMore'
|
||||
@@ -29,6 +29,8 @@ const SORT_TABS: { key: SortKey; label: string }[] = [
|
||||
|
||||
const CategoryPage: React.FC = () => {
|
||||
const { addItem } = useCartContext()
|
||||
// VIP 状态:异步校验并更新缓存,isVip 变化时触发重渲染
|
||||
const { isVip } = useVipStatus()
|
||||
const [categories, setCategories] = useState<ShopGoodsCategory[]>([])
|
||||
const [activeId, setActiveId] = useState<number | undefined>()
|
||||
|
||||
@@ -126,7 +128,7 @@ const CategoryPage: React.FC = () => {
|
||||
|
||||
/** 获取显示价格 */
|
||||
const getDisplayPrice = (product: ShopGoods) => {
|
||||
if (isVipMember() && product.dealerPrice) return product.dealerPrice
|
||||
if (isVip && product.dealerPrice) return product.dealerPrice
|
||||
return product.price || '0'
|
||||
}
|
||||
|
||||
@@ -242,7 +244,7 @@ const CategoryPage: React.FC = () => {
|
||||
<Text className='text-xs text-gray-400'>/{item.unitName}</Text>
|
||||
) : null}
|
||||
{/* 原价划掉 */}
|
||||
{isVipMember() && item.dealerPrice ? (
|
||||
{isVip && item.dealerPrice ? (
|
||||
<Text className='text-xs text-gray-400 line-through ml-1'>
|
||||
¥{item.price}
|
||||
</Text>
|
||||
|
||||
@@ -13,7 +13,7 @@ import { listShopGoods } from '@/api/shop/shopGoods'
|
||||
import type { ShopGoods } from '@/api/shop/shopGoods/model'
|
||||
import type { ShopUserCoupon } from '@/api/shop/shopUserCoupon/model'
|
||||
import type { OrderGoodsItem, OrderCreateRequest } from '@/api/shop/shopOrder/model'
|
||||
import { isVipMember } from '@/utils/vip'
|
||||
import { useVipStatus } from '@/hooks/useVipStatus'
|
||||
import { getCompressedImageUrl } from '@/utils/image'
|
||||
|
||||
// 满减门槛配置
|
||||
@@ -124,8 +124,17 @@ const CheckoutPage: React.FC = () => {
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [rushBuyProducts, setRushBuyProducts] = useState<ShopGoods[]>([])
|
||||
|
||||
// VIP 状态:异步校验并更新缓存,isVip 变化时触发重渲染
|
||||
const { isVip } = useVipStatus()
|
||||
|
||||
// 支付方式相关状态
|
||||
const [payType, setPayType] = useState<number>(8) // 8: 货到付款
|
||||
const [payType, setPayType] = useState<number>(9) // 9: 线下付款(默认),8: 货到付款(已注释)
|
||||
|
||||
// 支付方式选项
|
||||
const PAYMENT_OPTIONS = [
|
||||
// { id: 8, name: '货到付款', desc: '送达时支付', icon: '货', bgColor: '#f0fdf4', iconBg: '#dbeafe', iconColor: '#2563eb', borderColor: '#22c55e' },
|
||||
{ id: 9, name: '线下付款', desc: '微信转账,商家确认后发货', icon: '线', bgColor: '#fffbeb', iconBg: '#fef3c7', iconColor: '#d97706', borderColor: '#f59e0b' },
|
||||
]
|
||||
|
||||
// 从本地存储获取立即购买的数据
|
||||
const buyNowItems = useMemo(() => {
|
||||
@@ -147,14 +156,14 @@ const CheckoutPage: React.FC = () => {
|
||||
// 计算金额
|
||||
const goodsPrice = useMemo(() => {
|
||||
if (!items || items.length === 0) return 0
|
||||
const vip = isVipMember()
|
||||
const vip = isVip
|
||||
return items.reduce((sum, item) => {
|
||||
// VIP 会员优先使用 dealerPrice(后端关联字段 > product 嵌套对象)
|
||||
const dealerPrice = vip ? (item.dealerPrice || (item.product as any)?.dealerPrice) : null
|
||||
const unitPrice = dealerPrice || item.skuPrice || item.sku?.price || item.salePrice || item.product?.salePrice || item.product?.price || 0
|
||||
return sum + Number(unitPrice) * (item.quantity || item.num || 1)
|
||||
}, 0)
|
||||
}, [buyNowItems, selectedItems])
|
||||
}, [buyNowItems, selectedItems, isVip])
|
||||
|
||||
const couponDiscount = selectedCoupon?.reducePrice ? Number(selectedCoupon.reducePrice) : 0
|
||||
const totalPrice = Math.max(0, goodsPrice - couponDiscount).toFixed(2)
|
||||
@@ -256,20 +265,27 @@ const CheckoutPage: React.FC = () => {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
// 构建商品列表
|
||||
const goodsItems: OrderGoodsItem[] = items.map(item => ({
|
||||
goodsId: item.goodsId,
|
||||
skuId: item.skuId && item.skuId > 0 ? item.skuId : undefined,
|
||||
quantity: item.quantity || item.num || 1,
|
||||
specInfo: item.skuSpec || item.sku?.sku || item.product?.specName,
|
||||
}))
|
||||
const goodsItems: OrderGoodsItem[] = items.map(item => {
|
||||
// VIP 会员优先使用 dealerPrice 作为下单单价
|
||||
const dealerPrice = isVip ? (item.dealerPrice || (item.product as any)?.dealerPrice) : null
|
||||
const unitPrice = dealerPrice || undefined
|
||||
return {
|
||||
goodsId: item.goodsId,
|
||||
skuId: item.skuId && item.skuId > 0 ? item.skuId : undefined,
|
||||
quantity: item.quantity || item.num || 1,
|
||||
specInfo: item.skuSpec || item.sku?.sku || item.product?.specName,
|
||||
price: unitPrice ? String(unitPrice) : undefined,
|
||||
}
|
||||
})
|
||||
|
||||
// 货到付款:后端会自动设置 payStatus=true,无需前端传递 payStatus/payTime
|
||||
// 货到付款(8):后端会自动设置 payStatus=true,无需前端传递 payStatus/payTime
|
||||
// 线下付款(9):后端会设置 payStatus=false,等待商家确认收款后才设为 true
|
||||
const orderParams: OrderCreateRequest = {
|
||||
goodsItems,
|
||||
addressId: defaultAddress.id,
|
||||
payType,
|
||||
couponId: selectedCoupon?.id ? Number(selectedCoupon.id) : undefined,
|
||||
comments: remarks,
|
||||
buyerRemarks: remarks,
|
||||
deliveryType: 0, // 快递配送
|
||||
}
|
||||
|
||||
@@ -285,10 +301,18 @@ const CheckoutPage: React.FC = () => {
|
||||
// 显示成功提示
|
||||
Taro.showToast({ title: '订单已提交', icon: 'success' })
|
||||
setTimeout(() => {
|
||||
Taro.switchTab({ url: '/pages/order/list' })
|
||||
Taro.navigateTo({ url: '/pages/order/list' })
|
||||
}, 1500)
|
||||
} catch (err: any) {
|
||||
Taro.showToast({ title: err.message || '提交失败', icon: 'none' })
|
||||
const message = err.message || '提交失败'
|
||||
// 线下付款(payType=9)下单时,后端如果仍尝试创建微信支付订单,
|
||||
// 会因支付配置缺失应用ID而报错。这里给出更明确的提示。
|
||||
if (payType === 9 && /应用ID|appid|appId|支付配置|微信支付订单|创建支付订单失败/.test(message)) {
|
||||
Taro.showToast({ title: '线下支付暂不可用:微信支付配置缺失', icon: 'none', duration: 3000 })
|
||||
console.error('[checkout] 线下付款下单失败,后端支付配置问题:', message)
|
||||
} else {
|
||||
Taro.showToast({ title: message, icon: 'none' })
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@@ -360,7 +384,7 @@ const CheckoutPage: React.FC = () => {
|
||||
<View className='flex justify-between items-center mt-1'>
|
||||
<Text className='text-xs text-gray-500'>x{item.quantity || item.num || 1}</Text>
|
||||
<Text className='text-sm font-medium text-gray-800'>
|
||||
¥{isVipMember() && (item.dealerPrice || (item.product as any)?.dealerPrice) ? (item.dealerPrice || (item.product as any).dealerPrice) : (item.skuPrice || item.sku?.salePrice || item.sku?.price || item.salePrice || item.product?.salePrice || item.product?.price || '0')}
|
||||
¥{isVip && (item.dealerPrice || (item.product as any)?.dealerPrice) ? (item.dealerPrice || (item.product as any).dealerPrice) : (item.skuPrice || item.sku?.salePrice || item.sku?.price || item.salePrice || item.product?.salePrice || item.product?.price || '0')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -441,25 +465,45 @@ const CheckoutPage: React.FC = () => {
|
||||
<View className='bg-white rounded-lg mt-3 p-3'>
|
||||
<Text className='text-sm font-medium text-gray-800 mb-3 block'>支付方式</Text>
|
||||
|
||||
{/* 货到付款 */}
|
||||
<View
|
||||
className='flex items-center py-3 px-2 rounded-lg'
|
||||
style={{ backgroundColor: '#f0fdf4' }}
|
||||
>
|
||||
<View className='w-8 h-8 rounded-full bg-blue-50 flex items-center justify-center mr-3'>
|
||||
<Text className='text-sm'>货</Text>
|
||||
{PAYMENT_OPTIONS.map((option) => {
|
||||
const selected = payType === option.id
|
||||
return (
|
||||
<View
|
||||
key={option.id}
|
||||
className='flex items-center py-3 px-2 rounded-lg mb-2'
|
||||
style={{ backgroundColor: selected ? option.bgColor : '#f9fafb' }}
|
||||
onClick={() => setPayType(option.id)}
|
||||
>
|
||||
<View
|
||||
className='w-8 h-8 rounded-full flex items-center justify-center mr-3'
|
||||
style={{ backgroundColor: option.iconBg }}
|
||||
>
|
||||
<Text className='text-sm' style={{ color: option.iconColor }}>{option.icon}</Text>
|
||||
</View>
|
||||
<View className='flex-1'>
|
||||
<Text className='text-sm text-gray-800 block'>{option.name}</Text>
|
||||
<Text className='text-xs text-gray-400 block'>{option.desc}</Text>
|
||||
</View>
|
||||
<View
|
||||
className='w-5 h-5 rounded-full border-2 flex items-center justify-center'
|
||||
style={{ borderColor: selected ? option.borderColor : '#d1d5db' }}
|
||||
>
|
||||
{selected && (
|
||||
<View className='w-2 h-2 rounded-full' style={{ backgroundColor: option.borderColor }} />
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* 线下付款选中时显示提示 */}
|
||||
{payType === 9 && (
|
||||
<View className='mt-2 p-2 rounded-lg' style={{ backgroundColor: '#fef3c7' }}>
|
||||
<Text className='text-xs text-orange-600'>
|
||||
请下单后通过微信转账付款,商家确认收款后发货。
|
||||
</Text>
|
||||
</View>
|
||||
<View className='flex-1'>
|
||||
<Text className='text-sm text-gray-800 block'>货到付款</Text>
|
||||
<Text className='text-xs text-gray-400 block'>送达时支付</Text>
|
||||
</View>
|
||||
<View
|
||||
className='w-5 h-5 rounded-full border-2 flex items-center justify-center'
|
||||
style={{ borderColor: '#22c55e' }}
|
||||
>
|
||||
<View className='w-2 h-2 rounded-full' style={{ backgroundColor: '#22c55e' }} />
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 凑单推荐 */}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useCartContext } from '@/contexts/CartContext'
|
||||
import type { ShopGoodsCategory } from '@/api/shop/shopGoodsCategory/model'
|
||||
import type { ShopGoods, ShopGoodsParam } from '@/api/shop/shopGoods/model'
|
||||
import { isGuest } from '@/utils/auth'
|
||||
import { isVipMember } from '@/utils/vip'
|
||||
import { useVipStatus } from '@/hooks/useVipStatus'
|
||||
import { getCompressedImageUrl } from '@/utils/image'
|
||||
import { requireLogin } from '@/utils/login-guard'
|
||||
import { useShare } from '@/hooks/useShare'
|
||||
@@ -38,6 +38,8 @@ const SORT_TABS: { key: SortKey; label: string }[] = [
|
||||
|
||||
const ShopPage: React.FC = () => {
|
||||
const { addItem, totalCount } = useCartContext()
|
||||
// VIP 状态:异步校验并更新缓存,isVip 变化时触发重渲染
|
||||
const { isVip } = useVipStatus()
|
||||
const [categoryList, setCategoryList] = useState<any[]>(PRESET_CATEGORIES)
|
||||
const [activeCategory, setActiveCategory] = useState(0)
|
||||
|
||||
@@ -140,7 +142,7 @@ const ShopPage: React.FC = () => {
|
||||
|
||||
/** 获取显示价格 */
|
||||
const getDisplayPrice = (product: ShopGoods) => {
|
||||
if (isVipMember() && product.dealerPrice) return product.dealerPrice
|
||||
if (isVip && product.dealerPrice) return product.dealerPrice
|
||||
return product.price || '0'
|
||||
}
|
||||
|
||||
@@ -304,7 +306,7 @@ const ShopPage: React.FC = () => {
|
||||
<Price
|
||||
price={getDisplayPrice(item)}
|
||||
original={
|
||||
isVipMember() && item.dealerPrice
|
||||
isVip && item.dealerPrice
|
||||
? item.price
|
||||
: item.salePrice && item.salePrice !== item.price
|
||||
? item.salePrice
|
||||
@@ -359,7 +361,7 @@ const ShopPage: React.FC = () => {
|
||||
zIndex: 999,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
}}
|
||||
onClick={() => Taro.navigateTo({ url: '/pages/shop/cart' })}
|
||||
onClick={() => Taro.switchTab({ url: '/pages/shop/cart' })}
|
||||
>
|
||||
<Image
|
||||
className='w-7 h-7'
|
||||
|
||||
@@ -3,6 +3,7 @@ import { View, Text, Image, ScrollView, Swiper, SwiperItem, RichText, Button } f
|
||||
import { Tag } from '@nutui/nutui-react-taro'
|
||||
import Taro, { useRouter, useDidShow } from '@tarojs/taro'
|
||||
import { getShopGoods } from '@/api/shop/shopGoods'
|
||||
import { reportBrowse } from '@/api/shop/shopGoodsBrowse'
|
||||
import {
|
||||
addShopGoodsFavorite,
|
||||
removeShopGoodsFavorite,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
} from '@/api/shop/shopGoodsFavorite'
|
||||
import type { ShopGoods, ShopGoodsSku } from '@/api/shop/shopGoods/model'
|
||||
import Price from '@/components/common/Price'
|
||||
import Badge from '@/components/common/Badge'
|
||||
import SkuSelector from '@/components/business/SkuSelector'
|
||||
import { useCartContext } from '@/contexts/CartContext'
|
||||
import { useUserContext } from '@/contexts/UserContext'
|
||||
@@ -18,7 +20,7 @@ import { useShare } from '@/hooks/useShare'
|
||||
import SharePoster, { SharePosterHandle } from '@/components/SharePoster'
|
||||
import { isGuest } from '@/utils/auth'
|
||||
import { requireLogin } from '@/utils/login-guard'
|
||||
import { isVipMember } from '@/utils/vip'
|
||||
import { useVipStatus } from '@/hooks/useVipStatus'
|
||||
|
||||
definePageConfig({
|
||||
navigationBarTitleText: '商品详情',
|
||||
@@ -35,7 +37,9 @@ const ProductDetailPage: React.FC = () => {
|
||||
const [skuMode, setSkuMode] = useState<'cart' | 'buy'>('cart')
|
||||
const [isFavorite, setIsFavorite] = useState(false)
|
||||
const { addItem, totalCount, refresh } = useCartContext()
|
||||
const { isLoggedIn } = useUserContext()
|
||||
const { isLoggedIn, syncFromStorage } = useUserContext()
|
||||
// VIP 状态:异步校验并更新缓存,isVip 变化时触发重渲染
|
||||
const { isVip } = useVipStatus()
|
||||
// 底部操作栏高度约 90px(含按钮 + 安全区),动态计算 ScrollView 可用高度
|
||||
const scrollHeight = useScrollHeight(44)
|
||||
|
||||
@@ -60,6 +64,8 @@ const ProductDetailPage: React.FC = () => {
|
||||
|
||||
// 页面显示时刷新购物车数量,确保底部购物车角标准确
|
||||
useDidShow(() => {
|
||||
// 先从 storage 同步用户状态(解决注册/登录后 UserContext 状态未更新的问题)
|
||||
syncFromStorage()
|
||||
if (isLoggedIn) refresh()
|
||||
})
|
||||
|
||||
@@ -72,7 +78,7 @@ const ProductDetailPage: React.FC = () => {
|
||||
cover: product.image,
|
||||
title: product.name || product.goodsName || '优质商品推荐',
|
||||
price:
|
||||
product.dealerPrice && isVipMember() ? product.dealerPrice : product.price,
|
||||
product.dealerPrice && isVip ? product.dealerPrice : product.price,
|
||||
page: 'pages/shop/product-detail',
|
||||
})
|
||||
.then(setPosterPath)
|
||||
@@ -89,7 +95,7 @@ const ProductDetailPage: React.FC = () => {
|
||||
|
||||
// VIP 会员显示 dealerPrice,普通用户显示 price
|
||||
const getDisplayPrice = (): string => {
|
||||
if (isVipMember() && product?.dealerPrice) {
|
||||
if (isVip && product?.dealerPrice) {
|
||||
return product.dealerPrice
|
||||
}
|
||||
return product?.price || '0'
|
||||
@@ -144,6 +150,11 @@ const ProductDetailPage: React.FC = () => {
|
||||
Taro.setStorageSync('browse_history', reduced)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// 已登录用户:异步上报服务端浏览记录(静默失败,不影响用户体验)
|
||||
if (isLoggedIn) {
|
||||
reportBrowse({ goodsId: id, browseSource: 'detail' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddCart = () => {
|
||||
@@ -172,7 +183,7 @@ const ProductDetailPage: React.FC = () => {
|
||||
}
|
||||
} else {
|
||||
// VIP 会员使用 dealerPrice 作为结算价
|
||||
const vipPrice = isVipMember() && product.dealerPrice ? product.dealerPrice : undefined
|
||||
const vipPrice = isVip && product.dealerPrice ? product.dealerPrice : undefined
|
||||
const buyNowData = [{
|
||||
goodsId: product.goodsId!,
|
||||
skuId: sku?.id || 0,
|
||||
@@ -190,7 +201,7 @@ const ProductDetailPage: React.FC = () => {
|
||||
}
|
||||
|
||||
const handleGoCart = () => {
|
||||
Taro.navigateTo({ url: '/pages/shop/cart' })
|
||||
Taro.switchTab({ url: '/pages/shop/cart' })
|
||||
}
|
||||
|
||||
if (!product) {
|
||||
@@ -292,7 +303,7 @@ const ProductDetailPage: React.FC = () => {
|
||||
<Price price={getDisplayPrice()} size='large' color='#ee0a24' loginMask />
|
||||
<Tag>到手价</Tag>
|
||||
{/* VIP 会员价 */}
|
||||
{!isGuest() && isVipMember() && Number(product.dealerPrice) > 0 ? (
|
||||
{!isGuest() && isVip && Number(product.dealerPrice) > 0 ? (
|
||||
<View className='ml-auto inline-flex items-center gap-1 bg-amber-50 rounded px-2 py-1'>
|
||||
<Text className='text-xs text-amber-600 font-medium'>VIP专享</Text>
|
||||
<Text className='text-sm text-amber-700 font-bold'>¥{product.dealerPrice}</Text>
|
||||
@@ -304,7 +315,7 @@ const ProductDetailPage: React.FC = () => {
|
||||
)}
|
||||
</View>
|
||||
{/* 会员价 */}
|
||||
{!isGuest() && !isVipMember() && Number(product.memberStorePrice) > 0 && product.memberStorePrice !== product.price && (
|
||||
{!isGuest() && !isVip && Number(product.memberStorePrice) > 0 && product.memberStorePrice !== product.price && (
|
||||
<View className='mt-2 inline-block bg-orange-50 rounded px-2 py-1'>
|
||||
<Text className='text-xs text-orange-500'>会员价: ¥{product.memberStorePrice}</Text>
|
||||
</View>
|
||||
@@ -316,9 +327,7 @@ const ProductDetailPage: React.FC = () => {
|
||||
</View>
|
||||
)}
|
||||
<View className='flex gap-2 mt-2'>
|
||||
{Number(product.sales) > 0 && (
|
||||
<Text className='text-xs text-gray-400'>销量: {product.sales}</Text>
|
||||
)}
|
||||
<Text className='text-xs text-gray-400'>销量: {product.sales || 0}</Text>
|
||||
<Text className='text-xs text-gray-400'>库存: {product.stock || 0}</Text>
|
||||
{product.unitName && (
|
||||
<Text className='text-xs text-gray-400'>单位: {product.unitName}</Text>
|
||||
@@ -425,28 +434,8 @@ const ProductDetailPage: React.FC = () => {
|
||||
<View className='flex-1 flex flex-col items-center' onClick={handleGoCart}>
|
||||
<View className='relative inline-flex items-center justify-center'>
|
||||
<Text className='text-lg'>🛒</Text>
|
||||
{totalCount > 0 && (
|
||||
<View
|
||||
className='flex items-center justify-center'
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-6px',
|
||||
right: '-10px',
|
||||
minWidth: '16px',
|
||||
height: '16px',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: '#ef4444',
|
||||
borderWidth: '1.5px',
|
||||
borderStyle: 'solid',
|
||||
borderColor: '#ffffff',
|
||||
paddingHorizontal: '3px',
|
||||
}}
|
||||
>
|
||||
<Text className='text-white text-[10px] font-bold leading-none'>
|
||||
{totalCount > 99 ? '99+' : totalCount}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{/* 购物车数量角标:count 为 0 时组件内部不渲染 */}
|
||||
<Badge count={totalCount} className='absolute -top-1 -right-1' />
|
||||
</View>
|
||||
<Text className='text-xs text-gray-500 mt-1 whitespace-nowrap'>购物车</Text>
|
||||
</View>
|
||||
|
||||
@@ -1,247 +1,306 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { View, Text, Image } from '@tarojs/components'
|
||||
import React, {useState, useEffect, useCallback} from 'react'
|
||||
import {View, Text, Image} from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { getMyClerk } from '@/api/shop/shopStoreUser'
|
||||
import { listShopDealerApply } from '@/api/shop/shopDealerApply'
|
||||
import { pageShopOrder } from '@/api/shop/shopOrder'
|
||||
import {getMyClerk} from '@/api/shop/shopStoreUser'
|
||||
import {listShopDealerApply} from '@/api/shop/shopDealerApply'
|
||||
import {pageShopOrder} from '@/api/shop/shopOrder'
|
||||
import {confirmWechatQRLogin, parseQRContent} from '@/api/passport/qr-login'
|
||||
import Badge from '@/components/common/Badge'
|
||||
|
||||
// 二维码扫码图标(base64 SVG data URI,兼容小程序 Image 组件)
|
||||
const QR_SCAN_ICON = "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNMyA3VjVhMiAyIDAgMCAxIDItMmgyTTE3IDNoMmEyIDIgMCAwIDEgMiAydjJNMjEgMTd2MmEyIDIgMCAwIDEtMiAyaC0yTTcgMjFINWEyIDIgMCAwIDEtMi0ydi0yIiBzdHJva2U9IiNmZmZmZmYiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiAvPjxyZWN0IHg9IjciIHk9IjciIHdpZHRoPSI0IiBoZWlnaHQ9IjQiIHN0cm9rZT0iI2ZmZmZmZiIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiAvPjxyZWN0IHg9IjEzIiB5PSI3IiB3aWR0aD0iNCIgaGVpZ2h0PSI0IiBzdHJva2U9IiNmZmZmZmYiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgLz48cmVjdCB4PSI3IiB5PSIxMyIgd2lkdGg9IjQiIGhlaWdodD0iNCIgc3Ryb2tlPSIjZmZmZmZmIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIC8+PHBhdGggZD0iTTEzIDEzaDJ2Mk0xNyAxM3YyTTE3IDE3djJNMTMgMTd2Mk0xMyAxOWgyIiBzdHJva2U9IiNmZmZmZmYiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiAvPjwvc3ZnPg=="
|
||||
|
||||
definePageConfig({
|
||||
navigationBarTitleText: '门店中心',
|
||||
navigationBarTitleText: '门店中心',
|
||||
})
|
||||
|
||||
// 功能卡片定义(未来新增功能只需在这里加一项)
|
||||
const FEATURE_CARDS = [
|
||||
{
|
||||
key: 'goods',
|
||||
icon: '🏷️',
|
||||
title: '商品管理',
|
||||
desc: '管理门店商品上下架和库存',
|
||||
url: '/pages/store/goods/index',
|
||||
color: '#0891b2',
|
||||
bgColor: '#cffafe',
|
||||
showBadge: false,
|
||||
},
|
||||
{
|
||||
key: 'orders',
|
||||
icon: '📦',
|
||||
title: '订单管理',
|
||||
desc: '查看和处理门店订单',
|
||||
url: '/pages/store/orders/index',
|
||||
color: '#15803d',
|
||||
bgColor: '#dcfce7',
|
||||
// 显示待处理订单数量角标
|
||||
showBadge: true,
|
||||
},
|
||||
{
|
||||
key: 'vip-review',
|
||||
icon: '👑',
|
||||
title: 'VIP会员审核',
|
||||
desc: '审核客户VIP会员申请',
|
||||
url: '/pages/user/vip-review/index',
|
||||
color: '#7c3aed',
|
||||
bgColor: '#ede9fe',
|
||||
showBadge: true,
|
||||
},
|
||||
{
|
||||
key: 'goods',
|
||||
icon: '🏷️',
|
||||
title: '商品管理',
|
||||
desc: '管理门店商品上下架和库存',
|
||||
url: '/pages/store/goods/index',
|
||||
color: '#0891b2',
|
||||
bgColor: '#cffafe',
|
||||
showBadge: false,
|
||||
},
|
||||
{
|
||||
key: 'orders',
|
||||
icon: '📦',
|
||||
title: '订单管理',
|
||||
desc: '查看和处理门店订单',
|
||||
url: '/pages/store/orders/index',
|
||||
color: '#15803d',
|
||||
bgColor: '#dcfce7',
|
||||
// 显示待处理订单数量角标
|
||||
showBadge: true,
|
||||
},
|
||||
{
|
||||
key: 'users',
|
||||
icon: '👥',
|
||||
title: '用户管理',
|
||||
desc: '查看用户信息、禁用/启用用户',
|
||||
url: '/pages/store/users/index',
|
||||
color: '#2563eb',
|
||||
bgColor: '#dbeafe',
|
||||
showBadge: false,
|
||||
},
|
||||
{
|
||||
key: 'vip-review',
|
||||
icon: '👑',
|
||||
title: 'VIP会员审核',
|
||||
desc: '审核客户VIP会员申请',
|
||||
url: '/pages/user/vip-review/index',
|
||||
color: '#7c3aed',
|
||||
bgColor: '#ede9fe',
|
||||
showBadge: true,
|
||||
},
|
||||
]
|
||||
|
||||
// 订阅消息模板 ID
|
||||
const SUBSCRIBE_TMPL_IDS = [
|
||||
'RpZaVfxTuNxRmUxvinAwk6FHTpjV9yTznShdy8OHiTk', // 交易提醒:订单号、商品名称、联系人、联系电话、送货地址
|
||||
'RpZaVfxTuNxRmUxvinAwk6FHTpjV9yTznShdy8OHiTk', // 交易提醒:订单号、商品名称、联系人、联系电话、送货地址
|
||||
]
|
||||
|
||||
export default function StoreCenterPage() {
|
||||
const [storeInfo, setStoreInfo] = useState<any>(null)
|
||||
const [pendingVipCount, setPendingVipCount] = useState(0)
|
||||
const [pendingOrderCount, setPendingOrderCount] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [storeInfo, setStoreInfo] = useState<any>(null)
|
||||
const [pendingVipCount, setPendingVipCount] = useState(0)
|
||||
const [pendingOrderCount, setPendingOrderCount] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
// 验证店员身份
|
||||
getMyClerk()
|
||||
.then(data => {
|
||||
if (!data) {
|
||||
Taro.showToast({ title: '仅门店店员可访问', icon: 'none' })
|
||||
setTimeout(() => Taro.navigateBack(), 1500)
|
||||
return
|
||||
useEffect(() => {
|
||||
// 验证店员身份
|
||||
getMyClerk()
|
||||
.then(data => {
|
||||
if (!data) {
|
||||
Taro.showToast({title: '仅门店店员可访问', icon: 'none'})
|
||||
setTimeout(() => Taro.navigateBack(), 1500)
|
||||
return
|
||||
}
|
||||
setStoreInfo(data)
|
||||
// 加载待审核数量
|
||||
loadBadgeCounts()
|
||||
})
|
||||
.catch(() => {
|
||||
Taro.showToast({title: '仅门店店员可访问', icon: 'none'})
|
||||
setTimeout(() => Taro.navigateBack(), 1500)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
/** 加载各类待处理角标数量 */
|
||||
const loadBadgeCounts = async () => {
|
||||
try {
|
||||
// VIP 待审核
|
||||
const vipData = await listShopDealerApply({applyStatus: 10})
|
||||
setPendingVipCount((vipData || []).length)
|
||||
} catch { /* ignore */
|
||||
}
|
||||
setStoreInfo(data)
|
||||
// 加载待审核数量
|
||||
loadBadgeCounts()
|
||||
})
|
||||
.catch(() => {
|
||||
Taro.showToast({ title: '仅门店店员可访问', icon: 'none' })
|
||||
setTimeout(() => Taro.navigateBack(), 1500)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
/** 加载各类待处理角标数量 */
|
||||
const loadBadgeCounts = async () => {
|
||||
try {
|
||||
// VIP 待审核
|
||||
const vipData = await listShopDealerApply({ applyStatus: 10 })
|
||||
setPendingVipCount((vipData || []).length)
|
||||
} catch { /* ignore */ }
|
||||
|
||||
try {
|
||||
// 待处理订单:未付款/待发货/待收货
|
||||
const orderData = await pageShopOrder({ statusFilter: 1, page: 1, limit: 1 })
|
||||
setPendingOrderCount(orderData?.total || 0)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/** 获取卡片角标数字 */
|
||||
const getBadgeCount = (key: string) => {
|
||||
if (key === 'orders') return pendingOrderCount
|
||||
if (key === 'vip-review') return pendingVipCount
|
||||
return 0
|
||||
}
|
||||
|
||||
/** 请求订阅消息授权 */
|
||||
const handleSubscribe = useCallback(() => {
|
||||
if (SUBSCRIBE_TMPL_IDS.length === 0) {
|
||||
Taro.showToast({ title: '暂无可订阅的消息模板', icon: 'none' })
|
||||
return
|
||||
try {
|
||||
// 待处理订单:未付款/待发货/待收货
|
||||
const orderData = await pageShopOrder({statusFilter: 1, page: 1, limit: 1})
|
||||
setPendingOrderCount(orderData?.total || 0)
|
||||
} catch { /* ignore */
|
||||
}
|
||||
}
|
||||
Taro.requestSubscribeMessage({
|
||||
tmplIds: SUBSCRIBE_TMPL_IDS,
|
||||
success: (res) => {
|
||||
// res[templateId] === 'accept' 表示用户同意订阅
|
||||
const accepted = SUBSCRIBE_TMPL_IDS.filter(id => res[id] === 'accept')
|
||||
if (accepted.length > 0) {
|
||||
Taro.showToast({ title: '订阅成功', icon: 'success' })
|
||||
|
||||
/** 获取卡片角标数字 */
|
||||
const getBadgeCount = (key: string) => {
|
||||
if (key === 'orders') return pendingOrderCount
|
||||
if (key === 'vip-review') return pendingVipCount
|
||||
return 0
|
||||
}
|
||||
|
||||
/** 扫码登录 PC 后台 */
|
||||
const handleScanLogin = useCallback(async () => {
|
||||
try {
|
||||
Taro.showLoading({title: '请扫码...', mask: true})
|
||||
const scanRes = await Taro.scanCode({onlyFromCamera: false, scanType: ['qrCode']})
|
||||
Taro.hideLoading()
|
||||
|
||||
const rawContent = scanRes.result || ''
|
||||
const token = parseQRContent(rawContent)
|
||||
const userId = Number(Taro.getStorageSync('UserId'))
|
||||
|
||||
if (!token) {
|
||||
Taro.showModal({title: '提示', content: '未识别到有效的登录二维码', showCancel: false})
|
||||
return
|
||||
}
|
||||
if (!userId) {
|
||||
Taro.showModal({title: '提示', content: '当前用户未登录', showCancel: false})
|
||||
return
|
||||
}
|
||||
|
||||
Taro.showLoading({title: '正在确认登录...', mask: true})
|
||||
await confirmWechatQRLogin(token, userId)
|
||||
Taro.hideLoading()
|
||||
Taro.showModal({title: '登录成功', content: 'PC 端后台登录已确认,请返回网页端查看', showCancel: false})
|
||||
} catch (err: any) {
|
||||
Taro.hideLoading()
|
||||
if (err?.errMsg?.includes('cancel')) {
|
||||
return // 用户取消扫码,静默处理
|
||||
}
|
||||
Taro.showModal({title: '登录失败', content: err?.message || '扫码登录失败,请重试', showCancel: false})
|
||||
}
|
||||
},
|
||||
fail: (err: any) => {
|
||||
console.error('订阅失败:', err)
|
||||
// errCode 20001: 模板ID不存在或未绑定到当前小程序
|
||||
if (err.errCode === 20001) {
|
||||
Taro.showModal({
|
||||
title: '订阅模板未配置',
|
||||
content: '请联系管理员在微信公众平台选用「新订单通知」订阅消息模板',
|
||||
showCancel: false,
|
||||
})
|
||||
} else if (err.errCode === 20004 || err.errCode === 20002) {
|
||||
// 用户拒绝/关闭了主开关,静默处理
|
||||
Taro.showToast({ title: '已取消订阅', icon: 'none' })
|
||||
}, [])
|
||||
|
||||
/** 请求订阅消息授权 */
|
||||
const handleSubscribe = useCallback(() => {
|
||||
if (SUBSCRIBE_TMPL_IDS.length === 0) {
|
||||
Taro.showToast({title: '暂无可订阅的消息模板', icon: 'none'})
|
||||
return
|
||||
}
|
||||
},
|
||||
})
|
||||
}, [])
|
||||
Taro.requestSubscribeMessage({
|
||||
tmplIds: SUBSCRIBE_TMPL_IDS,
|
||||
success: (res) => {
|
||||
// res[templateId] === 'accept' 表示用户同意订阅
|
||||
const accepted = SUBSCRIBE_TMPL_IDS.filter(id => res[id] === 'accept')
|
||||
if (accepted.length > 0) {
|
||||
Taro.showToast({title: '订阅成功', icon: 'success'})
|
||||
}
|
||||
},
|
||||
fail: (err: any) => {
|
||||
console.error('订阅失败:', err)
|
||||
// errCode 20001: 模板ID不存在或未绑定到当前小程序
|
||||
if (err.errCode === 20001) {
|
||||
Taro.showModal({
|
||||
title: '订阅模板未配置',
|
||||
content: '请联系管理员在微信公众平台选用「新订单通知」订阅消息模板',
|
||||
showCancel: false,
|
||||
})
|
||||
} else if (err.errCode === 20004 || err.errCode === 20002) {
|
||||
// 用户拒绝/关闭了主开关,静默处理
|
||||
Taro.showToast({title: '已取消订阅', icon: 'none'})
|
||||
}
|
||||
},
|
||||
})
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View className='min-h-full bg-gray-50 flex items-center justify-center'>
|
||||
<Text className='text-gray-400'>加载中...</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
if (!storeInfo) return null
|
||||
|
||||
return (
|
||||
<View className='min-h-full bg-gray-50'>
|
||||
|
||||
{/* 顶部信息区:店员头像 + 门店名称 */}
|
||||
<View
|
||||
className='pt-8 pb-12 px-5 rounded-b-3xl relative overflow-hidden'
|
||||
style={{ background: 'linear-gradient(135deg, #15803d 0%, #22c55e 60%, #4ade80 100%)' }}
|
||||
>
|
||||
<View className='absolute -top-10 -right-10 w-40 h-40 rounded-full opacity-10'
|
||||
style={{ background: 'radial-gradient(circle, #ffffff, transparent)' }} />
|
||||
<View className='absolute -bottom-6 -left-6 w-24 h-24 rounded-full opacity-15'
|
||||
style={{ background: 'radial-gradient(circle, #ffffff, transparent)' }} />
|
||||
<View className='relative z-10 flex items-center gap-4'>
|
||||
{/* 店员头像 */}
|
||||
<View className='w-16 h-16 rounded-full bg-white/20 border-2 border-white/40 overflow-hidden flex-shrink-0'>
|
||||
{storeInfo.avatar ? (
|
||||
<Image
|
||||
className='w-full h-full'
|
||||
src={storeInfo.avatar}
|
||||
mode='aspectFill'
|
||||
/>
|
||||
) : (
|
||||
<View className='w-full h-full flex items-center justify-center'>
|
||||
<Text className='text-2xl'>👤</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 门店信息 */}
|
||||
<View className='flex-1 min-w-0'>
|
||||
<Text className='text-white text-lg font-bold block truncate'>
|
||||
{storeInfo.storeName || '门店中心'}
|
||||
</Text>
|
||||
<Text className='text-white text-opacity-80 text-sm mt-1 block truncate'>
|
||||
{storeInfo.name} {storeInfo.phone ? `· ${storeInfo.phone}` : ''}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 功能卡片区 */}
|
||||
<View className='mx-3 -mt-6 relative z-20'>
|
||||
|
||||
<View className='flex flex-col gap-3'>
|
||||
{FEATURE_CARDS.map(card => (
|
||||
<View
|
||||
key={card.key}
|
||||
className='bg-white rounded-xl p-4 flex items-center gap-4 active:opacity-80 relative'
|
||||
onClick={() => Taro.navigateTo({ url: card.url })}
|
||||
>
|
||||
{/* 图标 */}
|
||||
<View
|
||||
className='w-12 h-12 rounded-xl flex items-center justify-center flex-shrink-0'
|
||||
style={{ background: card.bgColor }}
|
||||
>
|
||||
<Text className='text-2xl'>{card.icon}</Text>
|
||||
</View>
|
||||
|
||||
{/* 文字 */}
|
||||
<View className='flex-1 min-w-0'>
|
||||
<Text className='text-gray-800 text-base font-medium block'>{card.title}</Text>
|
||||
<Text className='text-gray-400 text-xs mt-0.5 block'>{card.desc}</Text>
|
||||
</View>
|
||||
|
||||
{/* 待处理角标 */}
|
||||
{card.showBadge && getBadgeCount(card.key) > 0 && (
|
||||
<View className='absolute -top-1 -right-1 min-w-[20px] h-[20px] rounded-full bg-red-500 flex items-center justify-center px-1.5'>
|
||||
<Text className='text-white text-[11px] font-bold'>
|
||||
{getBadgeCount(card.key) > 99 ? '99+' : getBadgeCount(card.key)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 箭头 */}
|
||||
<Text className='text-gray-300 text-lg flex-shrink-0'>›</Text>
|
||||
if (loading) {
|
||||
return (
|
||||
<View className='min-h-full bg-gray-50 flex items-center justify-center'>
|
||||
<Text className='text-gray-400'>加载中...</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
{/* 底部提示 */}
|
||||
<View className='mx-3 mt-6 mb-4'>
|
||||
{/* 订阅消息提醒 */}
|
||||
<View
|
||||
className='bg-white rounded-xl p-4 flex items-center gap-3 active:opacity-80 mb-3'
|
||||
onClick={handleSubscribe}
|
||||
>
|
||||
<View className='w-10 h-10 rounded-full bg-orange-50 flex items-center justify-center flex-shrink-0'>
|
||||
<Text className='text-xl'>🔔</Text>
|
||||
</View>
|
||||
<View className='flex-1 min-w-0'>
|
||||
<Text className='text-gray-800 text-sm font-medium block'>接收新订单提醒</Text>
|
||||
<Text className='text-gray-400 text-xs mt-0.5 block'>
|
||||
开启后新订单将通过微信服务通知提醒您
|
||||
</Text>
|
||||
</View>
|
||||
<Text className='text-orange-500 text-sm flex-shrink-0'>去开启</Text>
|
||||
</View>
|
||||
if (!storeInfo) return null
|
||||
|
||||
<Text className='text-gray-300 text-xs text-center block'>
|
||||
未来更多功能将持续上线
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
return (
|
||||
<View className='min-h-full bg-gray-50'>
|
||||
|
||||
{/* 顶部信息区:店员头像 + 门店名称 */}
|
||||
<View
|
||||
className='pt-8 pb-12 px-5 rounded-b-3xl relative overflow-hidden'
|
||||
style={{background: 'linear-gradient(135deg, #15803d 0%, #22c55e 60%, #4ade80 100%)'}}
|
||||
>
|
||||
<View className='absolute -top-10 -right-10 w-40 h-40 rounded-full opacity-10'
|
||||
style={{background: 'radial-gradient(circle, #ffffff, transparent)'}}/>
|
||||
<View className='absolute -bottom-6 -left-6 w-24 h-24 rounded-full opacity-15'
|
||||
style={{background: 'radial-gradient(circle, #ffffff, transparent)'}}/>
|
||||
|
||||
{/* 右上角扫码登录按钮 */}
|
||||
<View
|
||||
className='absolute top-3 right-4 z-20 w-9 h-9 rounded-full bg-white/20 border border-white/30 flex items-center justify-center active:opacity-70'
|
||||
onClick={handleScanLogin}
|
||||
>
|
||||
<Image src={QR_SCAN_ICON} className='w-5 h-5' mode='aspectFit'/>
|
||||
</View>
|
||||
|
||||
<View className='relative z-10 flex items-center gap-4'>
|
||||
{/* 店员头像 */}
|
||||
<View
|
||||
className='w-16 h-16 rounded-full bg-white/20 border-2 border-white/40 overflow-hidden flex-shrink-0'>
|
||||
{storeInfo.avatar ? (
|
||||
<Image
|
||||
className='w-full h-full'
|
||||
src={storeInfo.avatar}
|
||||
mode='aspectFill'
|
||||
/>
|
||||
) : (
|
||||
<View className='w-full h-full flex items-center justify-center'>
|
||||
<Text className='text-2xl'>👤</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 门店信息 */}
|
||||
<View className='flex-1 min-w-0'>
|
||||
<Text className='text-white text-lg font-bold block truncate'>
|
||||
{storeInfo.storeName || '门店中心'}
|
||||
</Text>
|
||||
<Text className='text-white text-opacity-80 text-sm mt-1 block truncate'>
|
||||
{storeInfo.name} {storeInfo.phone ? `· ${storeInfo.phone}` : ''}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 功能卡片区 */}
|
||||
<View className='mx-3 -mt-6 relative z-20'>
|
||||
|
||||
<View className='flex flex-col gap-3'>
|
||||
{FEATURE_CARDS.map(card => (
|
||||
<View
|
||||
key={card.key}
|
||||
className='bg-white rounded-xl p-4 flex items-center gap-4 active:opacity-80 relative'
|
||||
onClick={() => Taro.navigateTo({url: card.url})}
|
||||
>
|
||||
{/* 图标 */}
|
||||
<View
|
||||
className='w-12 h-12 rounded-xl flex items-center justify-center flex-shrink-0'
|
||||
style={{background: card.bgColor}}
|
||||
>
|
||||
<Text className='text-2xl'>{card.icon}</Text>
|
||||
</View>
|
||||
|
||||
{/* 文字 */}
|
||||
<View className='flex-1 min-w-0'>
|
||||
<Text className='text-gray-800 text-base font-medium block'>{card.title}</Text>
|
||||
<Text className='text-gray-400 text-xs mt-0.5 block'>{card.desc}</Text>
|
||||
</View>
|
||||
|
||||
{/* 待处理角标 */}
|
||||
{card.showBadge && (
|
||||
<Badge count={getBadgeCount(card.key)} className='absolute -top-1 -right-1' size={20}
|
||||
fontSize={11} fontWeight='bold'/>
|
||||
)}
|
||||
|
||||
{/* 箭头 */}
|
||||
<Text className='text-gray-300 text-lg flex-shrink-0'>›</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 底部提示 */}
|
||||
<View className='mx-3 mt-3 mb-6'>
|
||||
{/* 订阅消息提醒 */}
|
||||
<View
|
||||
className='bg-white rounded-xl p-4 flex items-center gap-3 active:opacity-80 mb-3'
|
||||
onClick={handleSubscribe}
|
||||
>
|
||||
<View
|
||||
className='w-10 h-10 rounded-full bg-orange-50 flex items-center justify-center flex-shrink-0'>
|
||||
<Text className='text-xl'>🔔</Text>
|
||||
</View>
|
||||
<View className='flex-1 min-w-0'>
|
||||
<Text className='text-gray-800 text-sm font-medium block'>接收新订单提醒</Text>
|
||||
<Text className='text-gray-400 text-xs mt-0.5 block'>
|
||||
开启后新订单将通过微信服务通知提醒您
|
||||
</Text>
|
||||
</View>
|
||||
<Text className='text-orange-500 text-sm flex-shrink-0'>去开启</Text>
|
||||
</View>
|
||||
|
||||
<Text className='text-gray-300 text-xs text-center block'>
|
||||
未来更多功能将持续上线
|
||||
</Text>
|
||||
<View className={'h-4'}></View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,23 @@ function formatDistance(km: number) {
|
||||
return km < 1 ? `${Math.round(km * 1000)}m` : `${km.toFixed(1)}km`
|
||||
}
|
||||
|
||||
/** 解析 lngAndLat 字符串,自动识别 "lat,lng" / "lng,lat" 两种格式 */
|
||||
function parseLngLat(str: string): { lat: number; lng: number } | null {
|
||||
const parts = str.split(',')
|
||||
if (parts.length !== 2) return null
|
||||
const a = parseFloat(parts[0])
|
||||
const b = parseFloat(parts[1])
|
||||
if (isNaN(a) || isNaN(b)) return null
|
||||
// 纬度范围 -90~90,经度范围 -180~180,通过值域区分两者
|
||||
if (Math.abs(a) <= 90 && Math.abs(b) <= 180) {
|
||||
return { lat: a, lng: b } // "lat,lng"
|
||||
}
|
||||
if (Math.abs(b) <= 90 && Math.abs(a) <= 180) {
|
||||
return { lat: b, lng: a } // "lng,lat"
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const StoreListPage: React.FC = () => {
|
||||
const params = Taro.getCurrentInstance().router?.params || {}
|
||||
// selectMode=1 时表示从预约页跳来,选中后回传
|
||||
@@ -110,13 +127,9 @@ const StoreListPage: React.FC = () => {
|
||||
list = list
|
||||
.map((store) => {
|
||||
if (store.lngAndLat) {
|
||||
const parts = store.lngAndLat.split(',')
|
||||
if (parts.length === 2) {
|
||||
const sLng = parseFloat(parts[0])
|
||||
const sLat = parseFloat(parts[1])
|
||||
if (sLat && sLng) {
|
||||
return { ...store, distance: calcDistance(userLat, userLng, sLat, sLng) }
|
||||
}
|
||||
const parsed = parseLngLat(store.lngAndLat)
|
||||
if (parsed) {
|
||||
return { ...store, distance: calcDistance(userLat, userLng, parsed.lat, parsed.lng) }
|
||||
}
|
||||
}
|
||||
return store
|
||||
@@ -163,20 +176,14 @@ const StoreListPage: React.FC = () => {
|
||||
Taro.showToast({ title: '暂无位置信息', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const parts = store.lngAndLat.split(',')
|
||||
if (parts.length !== 2) {
|
||||
const parsed = parseLngLat(store.lngAndLat)
|
||||
if (!parsed) {
|
||||
Taro.showToast({ title: '位置信息格式有误', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const longitude = parseFloat(parts[0])
|
||||
const latitude = parseFloat(parts[1])
|
||||
if (!latitude || !longitude) {
|
||||
Taro.showToast({ title: '暂无位置信息', icon: 'none' })
|
||||
return
|
||||
}
|
||||
Taro.openLocation({
|
||||
latitude,
|
||||
longitude,
|
||||
latitude: parsed.lat,
|
||||
longitude: parsed.lng,
|
||||
name: store.name || '',
|
||||
address: store.address || '',
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import Taro from '@tarojs/taro'
|
||||
import { View, Text, Input, Image } from '@tarojs/components'
|
||||
import { storeLogin } from '@/api/shop/shopStore'
|
||||
import { saveStorageByLoginUser } from '@/utils/server'
|
||||
import { isUserDisabled } from '@/utils/auth'
|
||||
import './index.scss'
|
||||
|
||||
const StoreLogin = () => {
|
||||
@@ -55,6 +56,19 @@ const StoreLogin = () => {
|
||||
if (res?.access_token) {
|
||||
const token = res.access_token
|
||||
const user = res.user
|
||||
|
||||
// 检查用户是否被禁用
|
||||
if (isUserDisabled(user)) {
|
||||
Taro.showModal({
|
||||
title: '账号已被禁用',
|
||||
content: '您的账号已被管理员禁用,暂时无法使用。如有疑问请联系管理员。',
|
||||
showCancel: false,
|
||||
confirmText: '我知道了',
|
||||
confirmColor: '#ee0a24',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
saveStorageByLoginUser(token, user)
|
||||
Taro.showToast({ title: '登录成功', icon: 'success' })
|
||||
setTimeout(() => navigateAfterLogin(), 800)
|
||||
|
||||
@@ -1,39 +1,49 @@
|
||||
import React, {useState, useEffect, useCallback, useRef} from 'react'
|
||||
import {View, Text, Image, ScrollView} from '@tarojs/components'
|
||||
import {View, Text, Image, ScrollView, Input, Textarea} from '@tarojs/components'
|
||||
import Taro, {useDidShow} from '@tarojs/taro'
|
||||
import {pageShopOrder, updateShopOrder, removeShopOrder} from '@/api/shop/shopOrder'
|
||||
import {pageShopOrder, updateShopOrder, confirmOfflinePayment} from '@/api/shop/shopOrder'
|
||||
import type {ShopOrder, ShopOrderParam} from '@/api/shop/shopOrder/model'
|
||||
import {saveShopOrderDelivery} from '@/api/shop/shopOrderDelivery'
|
||||
import {TenantId} from '@/config/app'
|
||||
import {useNewOrderDetector} from '@/hooks/useNewOrderDetector'
|
||||
import { getCompressedImageUrl } from '@/utils/image'
|
||||
import { getMyClerk, listShopStoreUser } from '@/api/shop/shopStoreUser'
|
||||
import type {ShopStoreUser} from '@/api/shop/shopStoreUser/model'
|
||||
import { ensurePrivacyAuthorized } from '@/api/system/file'
|
||||
|
||||
definePageConfig({
|
||||
navigationBarTitleText: '订单管理',
|
||||
})
|
||||
|
||||
// ─── Tab 配置(全部 + 待处理 + 已完成)──────────────────────────
|
||||
type TabKey = 'all' | 'pending' | 'completed'
|
||||
// ─── Tab 配置(待处理 + 已完成 + 已关闭)──────────────────────────
|
||||
type TabKey = 'pending' | 'completed' | 'closed'
|
||||
|
||||
const TABS: { key: TabKey; label: string; params: Partial<ShopOrderParam>[] }[] = [
|
||||
// 全部:不传 statusFilter
|
||||
{key: 'all', label: '全部', params: [{}]},
|
||||
// 待处理:合并 statusFilter=0(待付款)、1(待发货)、2(待核销)、3(待收货)
|
||||
{key: 'pending', label: '待处理', params: [{statusFilter: 0}, {statusFilter: 1}, {statusFilter: 2}, {statusFilter: 3}]},
|
||||
// 已完成:statusFilter=5(与后台管理一致)
|
||||
{key: 'completed', label: '已完成', params: [{statusFilter: 5}]},
|
||||
// 待处理:合并 statusFilter=1(待发货)和 statusFilter=8(已关闭)
|
||||
{key: 'pending', label: '已关闭', params: [{statusFilter: 1}, {statusFilter: 8}]},
|
||||
// 已关闭:statusFilter=8
|
||||
{key: 'closed', label: '已关闭', params: [{statusFilter: 8}]},
|
||||
]
|
||||
|
||||
// ─── 操作类型 ─────────────────────────────────────────────────────
|
||||
type OpType = 'pay' | 'complete'
|
||||
type OpType = 'confirmPay' | 'deliver' | 'complete' | 'ship' | 'editPrice'
|
||||
|
||||
const OP_LABEL: Record<OpType, string> = {
|
||||
pay: '已收款',
|
||||
confirmPay: '收款',
|
||||
deliver: '送达',
|
||||
complete: '已完成',
|
||||
ship: '发货',
|
||||
editPrice: '改价',
|
||||
}
|
||||
|
||||
const OP_DESC: Record<OpType, string> = {
|
||||
pay: '变更支付状态为已付款',
|
||||
complete: '同时变更支付状态为已付款、收货状态为已收货、订单状态为已完成',
|
||||
confirmPay: '确认已收到线下付款',
|
||||
deliver: '上传送达凭证照片,标记为已收货(不改变付款和订单完成状态)',
|
||||
complete: '确认订单已完成,收款后点击此按钮标记订单完成',
|
||||
ship: '选择发货人员并生成发货单,订单将进入已发货状态',
|
||||
editPrice: '修改订单实付金额',
|
||||
}
|
||||
|
||||
// ─── 图片上传 ─────────────────────────────────────────────────────
|
||||
@@ -74,6 +84,17 @@ function formatDateTime(date: Date): string {
|
||||
return `${y}-${m}-${d} ${h}:${mi}:${s}`
|
||||
}
|
||||
|
||||
/** 格式化下单时间为「YYYY-MM-DD HH:mm」,兼容 "2026-07-23T02:00:18" 与 "2026-07-23 02:00:18" */
|
||||
function formatOrderTime(raw?: string): string {
|
||||
if (!raw) return ''
|
||||
const s = raw.replace('T', ' ').trim()
|
||||
const parts = s.split(' ')
|
||||
if (parts.length < 2) return s
|
||||
const timeParts = parts[1].split(':')
|
||||
const hhmm = `${timeParts[0]}:${timeParts[1] || '00'}`
|
||||
return `${parts[0]} ${hhmm}`
|
||||
}
|
||||
|
||||
/** 解析 sendEndImg:兼容 JSON 数组(新格式)和逗号分隔(旧格式) */
|
||||
function parseSendEndImg(raw: string): string[] {
|
||||
if (!raw || !raw.trim()) return []
|
||||
@@ -92,16 +113,25 @@ function parseSendEndImg(raw: string): string[] {
|
||||
function getStatusText(order: ShopOrder): string {
|
||||
if (order.orderStatus === 1) return '已完成'
|
||||
if (order.orderStatus === 2) return '已关闭'
|
||||
if (order.payStatus === false || order.payStatus === null) return '待收款'
|
||||
if (order.payStatus && order.deliveryStatus === 10) return '待发货'
|
||||
// 线下付款·已发货待收款(先发货后结款)
|
||||
if (order.payType === 9 && order.deliveryStatus === 20) return '已发货待收款'
|
||||
if (order.payStatus === false || order.payStatus === null) {
|
||||
// 线下付款待确认收款
|
||||
if (order.payType === 9) return '待确认收款'
|
||||
return '待收款'
|
||||
}
|
||||
if (order.payStatus && order.deliveryStatus === 20) return '待收货'
|
||||
if (order.payStatus && order.deliveryStatus === 10) return '待发货'
|
||||
return '进行中'
|
||||
}
|
||||
|
||||
function getStatusColor(order: ShopOrder): string {
|
||||
if (order.orderStatus === 1) return '#0e932e'
|
||||
if (order.orderStatus === 2) return '#999'
|
||||
// 已发货待收款:红色(与用户端一致)
|
||||
if (order.payType === 9 && order.deliveryStatus === 20) return '#ee0a24'
|
||||
if (order.payStatus === false || order.payStatus === null) return '#ee0a24'
|
||||
if (order.deliveryStatus === 20) return '#4b9cf5'
|
||||
if (order.payStatus) return '#ff7d00'
|
||||
return '#999'
|
||||
}
|
||||
@@ -115,9 +145,28 @@ function isOrderActionable(order: ShopOrder): boolean {
|
||||
function getOrderActions(order: ShopOrder): { label: string; type: OpType }[] {
|
||||
if (!isOrderActionable(order)) return []
|
||||
const actions: { label: string; type: OpType }[] = []
|
||||
// 货到付款:未付款 / 已付款 都直接"确认完成",
|
||||
// 确认完成会同时设置 payStatus=true,无需单独的"确认收款"按钮
|
||||
if (order.orderStatus !== 1 && order.orderStatus !== 2) {
|
||||
// 线下付款·待确认收款:显示"确认收款"和"改价"按钮
|
||||
if (!order.payStatus && order.payType === 9 && order.orderStatus === 0) {
|
||||
actions.push({label: '改价', type: 'editPrice'})
|
||||
actions.push({label: '确认收款', type: 'confirmPay'})
|
||||
}
|
||||
// 发货:
|
||||
// - 已付款且未发货:正常发货
|
||||
// - 线下付款未付款且未发货:支持先发货后结款
|
||||
if (order.orderStatus !== 1) {
|
||||
const notShipped = order.deliveryStatus == null || order.deliveryStatus < 20
|
||||
if (notShipped && (order.payStatus || order.payType === 9)) {
|
||||
actions.push({label: '发货', type: 'ship'})
|
||||
}
|
||||
}
|
||||
// 送达:已发货但未上传送达照片时显示(deliveryStatus=20 且 sendEndImg 为空)
|
||||
const shipped = order.deliveryStatus != null && order.deliveryStatus === 20
|
||||
const hasDeliverProof = order.sendEndImg && order.sendEndImg !== '[]'
|
||||
if (shipped && !hasDeliverProof) {
|
||||
actions.push({label: '送达', type: 'deliver'})
|
||||
}
|
||||
// 确认完成:仅已付款才可标记订单为已完成
|
||||
if (order.payStatus) {
|
||||
actions.push({label: '确认完成', type: 'complete'})
|
||||
}
|
||||
return actions
|
||||
@@ -125,7 +174,7 @@ function getOrderActions(order: ShopOrder): { label: string; type: OpType }[] {
|
||||
|
||||
// ─── 页面组件 ──────────────────────────────────────────────────────
|
||||
export default function StoreOrdersPage() {
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('all')
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('pending')
|
||||
|
||||
// 订单列表与分页
|
||||
const [orderList, setOrderList] = useState<ShopOrder[]>([])
|
||||
@@ -138,11 +187,49 @@ export default function StoreOrdersPage() {
|
||||
const [currentOrder, setCurrentOrder] = useState<ShopOrder | null>(null)
|
||||
const [opType, setOpType] = useState<OpType>('pay')
|
||||
const [proofImages, setProofImages] = useState<string[]>([])
|
||||
const [payRemarks, setPayRemarks] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
// 改价弹窗状态
|
||||
const [showEditPriceModal, setShowEditPriceModal] = useState(false)
|
||||
const [editPriceOrder, setEditPriceOrder] = useState<ShopOrder | null>(null)
|
||||
const [editPriceValue, setEditPriceValue] = useState('')
|
||||
const [editPriceRemarks, setEditPriceRemarks] = useState('')
|
||||
const [editingPrice, setEditingPrice] = useState(false)
|
||||
|
||||
// 发货弹窗状态
|
||||
const [showShipModal, setShowShipModal] = useState(false)
|
||||
const [clerkList, setClerkList] = useState<ShopStoreUser[]>([])
|
||||
const [selectedClerkId, setSelectedClerkId] = useState<number | null>(null)
|
||||
const [loadingClerks, setLoadingClerks] = useState(false)
|
||||
const [shipping, setShipping] = useState(false)
|
||||
|
||||
// 搜索:searchInput 受控输入框,searchKeyword 为已提交的搜索词
|
||||
const [searchInput, setSearchInput] = useState('')
|
||||
const [searchKeyword, setSearchKeyword] = useState('')
|
||||
|
||||
const pageSize = 10
|
||||
const loadingRef = useRef(false)
|
||||
|
||||
// ─── 访问控制:仅门店店员可访问此页面 ──────────────────────
|
||||
const [accessChecking, setAccessChecking] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
getMyClerk()
|
||||
.then(data => {
|
||||
if (!data) {
|
||||
Taro.showToast({ title: '无权访问', icon: 'none', duration: 1500 })
|
||||
setTimeout(() => Taro.navigateBack(), 1500)
|
||||
} else {
|
||||
setAccessChecking(false)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
Taro.showToast({ title: '无权访问', icon: 'none', duration: 1500 })
|
||||
setTimeout(() => Taro.navigateBack(), 1500)
|
||||
})
|
||||
}, [])
|
||||
|
||||
// ─── 新订单轮询检测 ──────────────────────────────────────────
|
||||
const { newOrderCount, clearUnread } = useNewOrderDetector({
|
||||
interval: 30000, // 30 秒轮询一次
|
||||
@@ -174,7 +261,7 @@ export default function StoreOrdersPage() {
|
||||
|
||||
// 并行请求所有 params 组合,合并去重
|
||||
const allRequests = tabConfig.params.map(p =>
|
||||
pageShopOrder({...p, page: pageNo, limit: pageSize})
|
||||
pageShopOrder({...p, page: pageNo, limit: pageSize, keywords: searchKeyword || undefined})
|
||||
)
|
||||
const allResults = await Promise.all(allRequests)
|
||||
|
||||
@@ -208,7 +295,7 @@ export default function StoreOrdersPage() {
|
||||
loadingRef.current = false
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
}, [searchKeyword])
|
||||
|
||||
// 切换 tab 时重新加载
|
||||
useEffect(() => {
|
||||
@@ -227,11 +314,28 @@ export default function StoreOrdersPage() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 提交搜索(点击搜索按钮或键盘回车) */
|
||||
const handleSearch = () => {
|
||||
setSearchKeyword(searchInput.trim())
|
||||
}
|
||||
|
||||
/** 清空搜索 */
|
||||
const handleClearSearch = () => {
|
||||
setSearchInput('')
|
||||
setSearchKeyword('')
|
||||
}
|
||||
|
||||
/** 打开操作弹窗 */
|
||||
const openModal = (order: ShopOrder, type: OpType) => {
|
||||
// 改价走独立弹窗
|
||||
if (type === 'editPrice') {
|
||||
openEditPriceModal(order)
|
||||
return
|
||||
}
|
||||
setCurrentOrder(order)
|
||||
setOpType(type)
|
||||
setProofImages([])
|
||||
setPayRemarks('')
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
@@ -240,14 +344,22 @@ export default function StoreOrdersPage() {
|
||||
setShowModal(false)
|
||||
setCurrentOrder(null)
|
||||
setProofImages([])
|
||||
setPayRemarks('')
|
||||
}
|
||||
|
||||
/** 选择并上传凭证图片 */
|
||||
const chooseProofImage = () => {
|
||||
const maxCount = 3
|
||||
const chooseProofImage = async () => {
|
||||
const maxCount = opType === 'confirmPay' ? 1 : 3
|
||||
const remaining = maxCount - proofImages.length
|
||||
if (remaining <= 0) return
|
||||
|
||||
// 门店上传凭证需要访问相册/摄像头,先预检隐私协议授权
|
||||
try {
|
||||
await ensurePrivacyAuthorized()
|
||||
} catch {
|
||||
// 授权失败由 chooseImage 自行处理
|
||||
}
|
||||
|
||||
Taro.chooseImage({
|
||||
count: remaining,
|
||||
sizeType: ['compressed'],
|
||||
@@ -277,37 +389,42 @@ export default function StoreOrdersPage() {
|
||||
const submitOperation = async () => {
|
||||
if (!currentOrder) return
|
||||
|
||||
// 确认完成必须上传凭证照片
|
||||
if (opType === 'complete' && proofImages.length === 0) {
|
||||
Taro.showToast({title: '请上传配送凭证照片', icon: 'none'})
|
||||
// 确认送达必须上传凭证照片
|
||||
if (opType === 'deliver' && proofImages.length === 0) {
|
||||
Taro.showToast({title: '请上传送达凭证照片', icon: 'none'})
|
||||
return
|
||||
}
|
||||
|
||||
// 确认收款必须上传支付凭证
|
||||
if (opType === 'confirmPay' && proofImages.length === 0) {
|
||||
Taro.showToast({title: '请上传支付凭证', icon: 'none'})
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const updateData: any = {orderId: currentOrder.orderId}
|
||||
if (opType === 'confirmPay') {
|
||||
// 确认线下收款
|
||||
await confirmOfflinePayment(
|
||||
currentOrder.orderId!,
|
||||
payRemarks.trim() || undefined,
|
||||
proofImages[0]
|
||||
)
|
||||
} else {
|
||||
const updateData: any = {orderId: currentOrder.orderId}
|
||||
|
||||
if (opType === 'pay') {
|
||||
// 确认收款:变更支付状态为已付款
|
||||
updateData.payStatus = true
|
||||
updateData.payTime = formatDateTime(new Date())
|
||||
} else if (opType === 'complete') {
|
||||
// 确认完成:用户已收到货
|
||||
updateData.payStatus = true
|
||||
updateData.payTime = formatDateTime(new Date())
|
||||
updateData.deliveryStatus = 30 // 发货状态 → 已收货
|
||||
updateData.deliveryTime = formatDateTime(new Date()) // 收货时间
|
||||
updateData.orderStatus = 1 // 订单状态 → 已完成
|
||||
updateData.sendEndImg = JSON.stringify(proofImages) // 配送员送达拍照(JSON数组)
|
||||
if (opType === 'deliver') {
|
||||
// 送达:上传送达照片(不改变付款状态、deliveryStatus 和订单完成状态)
|
||||
updateData.deliveryTime = formatDateTime(new Date())
|
||||
updateData.sendEndImg = JSON.stringify(proofImages)
|
||||
} else if (opType === 'complete') {
|
||||
// 确认完成:标记订单为已完成
|
||||
updateData.orderStatus = 1 // 订单状态 → 已完成
|
||||
}
|
||||
|
||||
await updateShopOrder(updateData)
|
||||
}
|
||||
|
||||
// 收款操作如有凭证也追加到备注
|
||||
if (opType === 'pay' && proofImages.length > 0) {
|
||||
const proofText = `【门店收款凭证】:${JSON.stringify(proofImages)}`
|
||||
updateData.comments = (currentOrder.comments || '') + `\n${proofText}`
|
||||
}
|
||||
|
||||
await updateShopOrder(updateData)
|
||||
Taro.showToast({title: '操作成功', icon: 'success'})
|
||||
closeModal()
|
||||
loadOrders(activeTab, 1) // 刷新列表
|
||||
@@ -318,39 +435,174 @@ export default function StoreOrdersPage() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除订单 */
|
||||
const handleDelete = (order: ShopOrder) => {
|
||||
/** 打开改价弹窗 */
|
||||
const openEditPriceModal = (order: ShopOrder) => {
|
||||
setEditPriceOrder(order)
|
||||
setEditPriceValue(String(order.payPrice || order.totalPrice || ''))
|
||||
setEditPriceRemarks('')
|
||||
setShowEditPriceModal(true)
|
||||
}
|
||||
|
||||
/** 关闭改价弹窗 */
|
||||
const closeEditPriceModal = () => {
|
||||
setShowEditPriceModal(false)
|
||||
setEditPriceOrder(null)
|
||||
setEditPriceValue('')
|
||||
setEditPriceRemarks('')
|
||||
}
|
||||
|
||||
/** 提交改价 */
|
||||
const submitEditPrice = async () => {
|
||||
if (!editPriceOrder) return
|
||||
const newPrice = parseFloat(editPriceValue)
|
||||
if (isNaN(newPrice) || newPrice < 0) {
|
||||
Taro.showToast({title: '请输入有效金额', icon: 'none'})
|
||||
return
|
||||
}
|
||||
const oldPriceStr = String(editPriceOrder.payPrice || editPriceOrder.totalPrice || '0')
|
||||
const oldPrice = parseFloat(oldPriceStr)
|
||||
if (!isNaN(oldPrice) && Math.abs(newPrice - oldPrice) < 0.01) {
|
||||
Taro.showToast({title: '金额未变化', icon: 'none'})
|
||||
return
|
||||
}
|
||||
setEditingPrice(true)
|
||||
try {
|
||||
const changeRecord = `【门店修改金额】原实付¥${oldPriceStr} → 新实付¥${newPrice.toFixed(2)}`
|
||||
await updateShopOrder({
|
||||
orderId: editPriceOrder.orderId,
|
||||
payPrice: newPrice.toFixed(2),
|
||||
merchantRemarks: (editPriceOrder.merchantRemarks || '') + `\n${changeRecord}`,
|
||||
} as ShopOrder)
|
||||
Taro.showToast({title: '改价成功', icon: 'success'})
|
||||
closeEditPriceModal()
|
||||
loadOrders(activeTab, 1)
|
||||
} catch (e: any) {
|
||||
Taro.showToast({title: e.message || '改价失败', icon: 'none'})
|
||||
} finally {
|
||||
setEditingPrice(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开发货弹窗:拉取门店店员列表供选择发货人员 */
|
||||
const openShipModal = async (order: ShopOrder) => {
|
||||
setCurrentOrder(order)
|
||||
setSelectedClerkId(null)
|
||||
setClerkList([])
|
||||
setShowShipModal(true)
|
||||
setLoadingClerks(true)
|
||||
try {
|
||||
// 获取当前登录店员(含 storeId / userId),
|
||||
// 用 storeId 拉取本门店店员列表,避免 ?storeId=0 拉不到数据
|
||||
const myClerk = await getMyClerk()
|
||||
const storeId = myClerk?.storeId
|
||||
if (!storeId) {
|
||||
Taro.showToast({title: '门店信息未就绪,请稍后再试', icon: 'none'})
|
||||
return
|
||||
}
|
||||
const res = await listShopStoreUser({storeId})
|
||||
setClerkList(res || [])
|
||||
// 默认选中与当前登录用户 userId 一致的店员
|
||||
if (res && myClerk?.userId) {
|
||||
const match = res.find(c => c.userId === myClerk!.userId)
|
||||
if (match) setSelectedClerkId(match.id ?? null)
|
||||
}
|
||||
} catch (e) {
|
||||
Taro.showToast({title: '加载店员失败', icon: 'none'})
|
||||
} finally {
|
||||
setLoadingClerks(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** 确认发货:创建发货单并记录发货人员,订单置为已发货 */
|
||||
const handleConfirmShip = async () => {
|
||||
if (!currentOrder) return
|
||||
const clerk = clerkList.find(c => c.id === selectedClerkId)
|
||||
if (!clerk) {
|
||||
Taro.showToast({title: '请选择发货人员', icon: 'none'})
|
||||
return
|
||||
}
|
||||
setShipping(true)
|
||||
try {
|
||||
// 1) 创建发货单,记录发货人信息
|
||||
await saveShopOrderDelivery({
|
||||
orderId: currentOrder.orderId,
|
||||
deliveryMethod: 20, // 20=无需物流(门店自送/自提,无快递单号)
|
||||
sendName: clerk.name,
|
||||
sendPhone: clerk.phone,
|
||||
// 发货地址取门店名称(店员实体无地址字段)
|
||||
sendAddress: currentOrder.storeName || '',
|
||||
})
|
||||
// 2) 订单置为已发货(deliveryStatus=20),物流页与列表状态同步更新
|
||||
await updateShopOrder({
|
||||
orderId: currentOrder.orderId,
|
||||
deliveryStatus: 20,
|
||||
deliveryTime: formatDateTime(new Date()),
|
||||
})
|
||||
Taro.showToast({title: '发货成功', icon: 'success'})
|
||||
setShowShipModal(false)
|
||||
loadOrders(activeTab, 1) // 刷新列表(发货按钮随之隐藏)
|
||||
} catch (e: any) {
|
||||
Taro.showToast({title: e.message || '发货失败', icon: 'none'})
|
||||
} finally {
|
||||
setShipping(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** 关闭订单 */
|
||||
const handleCloseOrder = (order: ShopOrder) => {
|
||||
Taro.showModal({
|
||||
title: '提示',
|
||||
content: `确定要删除订单 ${order.orderNo} 吗?删除后无法恢复。`,
|
||||
content: `确定要关闭订单 ${order.orderNo} 吗?关闭后无法恢复。`,
|
||||
confirmColor: '#ee0a24',
|
||||
success: async (res) => {
|
||||
if (!res.confirm) return
|
||||
try {
|
||||
Taro.showLoading({title: '删除中...'})
|
||||
await removeShopOrder(order.orderId)
|
||||
Taro.showLoading({title: '关闭中...'})
|
||||
await updateShopOrder({orderId: order.orderId, orderStatus: 2} as ShopOrder)
|
||||
Taro.hideLoading()
|
||||
Taro.showToast({title: '删除成功', icon: 'success'})
|
||||
Taro.showToast({title: '关闭成功', icon: 'success'})
|
||||
loadOrders(activeTab, 1)
|
||||
} catch (e: any) {
|
||||
Taro.hideLoading()
|
||||
Taro.showToast({title: e.message || '删除失败', icon: 'none'})
|
||||
Taro.showToast({title: e.message || '关闭失败', icon: 'none'})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 一键导航到收货地址(调用微信内置地图) */
|
||||
const handleNavigate = (order: ShopOrder) => {
|
||||
const lat = parseFloat(order.addressLat || '')
|
||||
const lng = parseFloat(order.addressLng || '')
|
||||
if (isNaN(lat) || isNaN(lng)) {
|
||||
Taro.showToast({title: '该订单未记录定位信息,无法导航', icon: 'none'})
|
||||
return
|
||||
}
|
||||
Taro.openLocation({
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
name: order.realName || '收货地址',
|
||||
address: order.address || '',
|
||||
scale: 16,
|
||||
})
|
||||
}
|
||||
|
||||
/** 渲染单个订单卡片 */
|
||||
const renderOrderCard = (order: ShopOrder) => {
|
||||
const actions = getOrderActions(order)
|
||||
const canDelete = isOrderActionable(order) // 仅未完成、未关闭的订单可删除
|
||||
const canDelete = isOrderActionable(order) // 仅未完成、未关闭的订单可关闭
|
||||
const orderGoods = (order as any).orderGoods || []
|
||||
|
||||
return (
|
||||
<View key={order.orderId} className='bg-white rounded-xl mx-3 mt-3 p-4'>
|
||||
{/* 订单头部 */}
|
||||
<View className='flex justify-between items-center mb-3'>
|
||||
<Text className='text-xs text-gray-400'>订单号:{order.orderNo}</Text>
|
||||
<View className='flex justify-between items-start mb-3'>
|
||||
<View className='flex flex-col'>
|
||||
<Text className='text-xs text-gray-400'>订单号:{order.orderNo}</Text>
|
||||
{order.createTime ? (
|
||||
<Text className='text-xs text-gray-400 mt-1'>下单时间:{formatOrderTime(order.createTime)}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<Text className='text-xs' style={{color: getStatusColor(order)}}>
|
||||
{getStatusText(order)}
|
||||
</Text>
|
||||
@@ -359,30 +611,63 @@ export default function StoreOrdersPage() {
|
||||
{/* 商品列表 */}
|
||||
{orderGoods.map((goods: any, idx: number) => (
|
||||
<View key={idx} className='flex items-center gap-3 mb-3'>
|
||||
{goods.coverImage && (
|
||||
<Image className='w-16 h-16 rounded-lg bg-gray-50' src={getCompressedImageUrl(goods.coverImage)}
|
||||
{goods.image && (
|
||||
<Image className='w-16 h-16 rounded-lg bg-gray-50' src={getCompressedImageUrl(goods.image, { width: 80 })}
|
||||
mode='aspectFill'/>
|
||||
)}
|
||||
<View className='flex-1'>
|
||||
<Text className='text-sm text-gray-800 block'>{goods.goodsName}</Text>
|
||||
{goods.specInfo && (
|
||||
<Text className='text-xs text-gray-400 mt-1 block'>{goods.specInfo}</Text>
|
||||
{goods.spec && (
|
||||
<Text className='text-xs text-gray-400 mt-1 block'>{goods.spec}</Text>
|
||||
)}
|
||||
<View className='flex justify-between items-center mt-1'>
|
||||
<Text className='text-sm text-red-500 font-medium'>¥{goods.price}</Text>
|
||||
<Text className='text-xs text-gray-400'>x{goods.quantity}</Text>
|
||||
<Text className='text-xs text-gray-400'>x{goods.totalNum}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
|
||||
{/* 收货信息 */}
|
||||
{/* 收货信息(点击区域一键导航) */}
|
||||
{(order.realName || order.phone) && (
|
||||
<View className='bg-gray-50 rounded-lg p-3 mb-3'>
|
||||
<Text className='text-sm text-gray-700 block'>{order.realName} {order.phone}</Text>
|
||||
{order.address && (
|
||||
<Text className='text-xs text-gray-400 mt-1 block'>{order.address}</Text>
|
||||
)}
|
||||
<View
|
||||
className='bg-gray-50 rounded-lg p-3 mb-3 flex items-center'
|
||||
onClick={() => handleNavigate(order)}
|
||||
>
|
||||
<View className='flex-1'>
|
||||
<Text className='text-sm text-gray-700 block'>{order.realName} {order.phone}</Text>
|
||||
{order.address && (
|
||||
<Text className='text-xs text-gray-400 mt-1 block'>{order.address}</Text>
|
||||
)}
|
||||
</View>
|
||||
<View className='ml-2 flex flex-col items-center justify-center px-1'>
|
||||
<Text className='text-lg text-green-500 leading-none'>›</Text>
|
||||
<Text className='text-xs text-green-500 mt-0.5'>导航</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 买家备注 */}
|
||||
{order.buyerRemarks && (
|
||||
<View className='bg-orange-50 rounded-lg p-3 mb-3'>
|
||||
<Text className='text-xs text-orange-500 mb-1 block'>买家备注</Text>
|
||||
<Text className='text-sm text-gray-600 block'>{order.buyerRemarks}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 付款凭证(线下付款确认收款后显示) */}
|
||||
{order.paymentVoucher && (
|
||||
<View className='bg-green-50 rounded-lg p-3 mb-3'>
|
||||
<Text className='text-xs text-green-500 mb-2 block'>付款凭证:</Text>
|
||||
<Image
|
||||
className='w-20 h-20 rounded-lg bg-gray-100'
|
||||
src={getCompressedImageUrl(order.paymentVoucher)}
|
||||
mode='aspectFill'
|
||||
onClick={() => Taro.previewImage({
|
||||
current: order.paymentVoucher!,
|
||||
urls: [order.paymentVoucher!]
|
||||
})}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -413,7 +698,7 @@ export default function StoreOrdersPage() {
|
||||
{/* 金额 + 支付方式 */}
|
||||
<View className='flex justify-between items-center mb-3'>
|
||||
<Text className='text-xs text-gray-400'>
|
||||
{order.payType === 0 ? '货到付款' : order.payType === 4 ? '现金支付' : '货到付款'}
|
||||
{order.payType === 8 ? '货到付款' : order.payType === 9 ? '线下付款' : order.payType === 4 ? '现金支付' : order.payType === 1 ? '微信支付' : '其他'}
|
||||
</Text>
|
||||
<Text className='text-sm text-gray-700'>
|
||||
实付:<Text className='text-red-500 font-medium'>¥{order.payPrice || order.totalPrice}</Text>
|
||||
@@ -422,13 +707,13 @@ export default function StoreOrdersPage() {
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<View className='flex justify-between items-center border-t border-gray-50 pt-3'>
|
||||
{/* 删除按钮:仅未完成/未关闭的订单显示 */}
|
||||
{/* 关闭按钮:仅未完成/未关闭的订单显示 */}
|
||||
{canDelete ? (
|
||||
<View
|
||||
className='px-3 py-1.5'
|
||||
onClick={() => handleDelete(order)}
|
||||
onClick={() => handleCloseOrder(order)}
|
||||
>
|
||||
<Text className='text-xs text-gray-400'>删除订单</Text>
|
||||
<Text className='text-xs text-gray-400'>关闭订单</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View/>
|
||||
@@ -441,12 +726,20 @@ export default function StoreOrdersPage() {
|
||||
className={`px-4 py-2 rounded-lg ${
|
||||
act.type === 'complete'
|
||||
? 'bg-green-500'
|
||||
: act.type === 'confirmPay'
|
||||
? 'bg-blue-500'
|
||||
: act.type === 'ship'
|
||||
? 'bg-purple-500'
|
||||
: act.type === 'editPrice'
|
||||
? 'bg-orange-500'
|
||||
: 'border border-blue-500'
|
||||
}`}
|
||||
onClick={() => openModal(order, act.type)}
|
||||
onClick={() => act.type === 'ship'
|
||||
? openShipModal(order)
|
||||
: openModal(order, act.type)}
|
||||
>
|
||||
<Text className={`text-sm ${
|
||||
act.type === 'complete' ? 'text-white' : 'text-blue-500'
|
||||
act.type === 'complete' || act.type === 'confirmPay' || act.type === 'ship' || act.type === 'editPrice' ? 'text-white' : 'text-blue-500'
|
||||
}`}>{act.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
@@ -456,8 +749,38 @@ export default function StoreOrdersPage() {
|
||||
)
|
||||
}
|
||||
|
||||
// 访问校验中或已拒绝:不渲染页面内容
|
||||
if (accessChecking) {
|
||||
return <View className='min-h-full bg-gray-50' />
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='min-h-full bg-gray-50'>
|
||||
{/* 搜索栏 */}
|
||||
<View className='bg-white px-3 py-2 flex items-center'>
|
||||
<View className='flex-1 flex items-center bg-gray-100 rounded-full px-3 h-9'>
|
||||
<Input
|
||||
className='flex-1 text-sm text-gray-800'
|
||||
value={searchInput}
|
||||
onInput={(e) => setSearchInput(e.detail.value)}
|
||||
onConfirm={handleSearch}
|
||||
placeholder='搜索订单号 / 手机号 / 昵称'
|
||||
confirmType='search'
|
||||
/>
|
||||
{searchInput ? (
|
||||
<View
|
||||
className='ml-2 w-5 h-5 flex items-center justify-center'
|
||||
onClick={handleClearSearch}
|
||||
>
|
||||
<Text className='text-gray-400 text-base leading-none'>×</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
<View className='ml-1 px-2 py-1' onClick={handleSearch}>
|
||||
<Text className='text-sm text-green-500 font-medium'>搜索</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Tab 栏 */}
|
||||
<View className='bg-white flex'>
|
||||
{TABS.map(tab => (
|
||||
@@ -475,8 +798,8 @@ export default function StoreOrdersPage() {
|
||||
className={`text-sm ${activeTab === tab.key ? 'text-green-500 font-medium' : 'text-gray-500'}`}>
|
||||
{tab.label}
|
||||
</Text>
|
||||
{/* 新订单角标:在"全部"Tab 上显示 */}
|
||||
{tab.key === 'all' && newOrderCount > 0 && (
|
||||
{/* 新订单角标:在"待处理"Tab 上显示 */}
|
||||
{tab.key === 'pending' && newOrderCount > 0 && (
|
||||
<View className='absolute -top-0.5 right-2 min-w-[18px] h-[18px] rounded-full bg-red-500 flex items-center justify-center px-1'>
|
||||
<Text className='text-white text-[10px] font-bold'>
|
||||
{newOrderCount > 99 ? '99+' : newOrderCount}
|
||||
@@ -490,7 +813,7 @@ export default function StoreOrdersPage() {
|
||||
{/* 订单列表 */}
|
||||
<ScrollView
|
||||
scrollY
|
||||
style={{height: 'calc(100vh - 50px)'}}
|
||||
style={{height: 'calc(100vh - 100px)'}}
|
||||
onScrollToLower={handleLoadMore}
|
||||
lowerThreshold={100}
|
||||
>
|
||||
@@ -500,7 +823,9 @@ export default function StoreOrdersPage() {
|
||||
</View>
|
||||
) : orderList.length === 0 ? (
|
||||
<View className='flex justify-center items-center py-20'>
|
||||
<Text className='text-gray-400'>暂无订单</Text>
|
||||
<Text className='text-gray-400'>
|
||||
{searchKeyword ? `未找到与“${searchKeyword}”相关的订单` : '暂无订单'}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
orderList.map(renderOrderCard)
|
||||
@@ -543,31 +868,57 @@ export default function StoreOrdersPage() {
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 凭证上传 */}
|
||||
<Text className='text-sm text-gray-600 mb-3 block'>
|
||||
上传凭证照片{opType === 'complete' ? '(必填,配送货物到达后拍照上传,最多3张)' : '(选填,最多3张)'}
|
||||
</Text>
|
||||
<View className='flex flex-wrap gap-3 mb-6'>
|
||||
{proofImages.map((url, idx) => (
|
||||
<View key={idx} className='relative'>
|
||||
<Image className='w-20 h-20 rounded-lg bg-gray-50' src={getCompressedImageUrl(url)} mode='aspectFill'/>
|
||||
<View
|
||||
className='absolute -top-2 -right-2 w-5 h-5 bg-red-500 rounded-full flex items-center justify-center'
|
||||
onClick={() => removeProofImage(idx)}
|
||||
>
|
||||
<Text className='text-white text-xs'>×</Text>
|
||||
</View>
|
||||
{/* 凭证上传(确认完成不需要凭证) */}
|
||||
{opType !== 'complete' && (
|
||||
<>
|
||||
<Text className='text-sm text-gray-600 mb-3 block'>
|
||||
{opType === 'confirmPay'
|
||||
? '上传支付凭证(必填,如微信转账截图)'
|
||||
: opType === 'deliver'
|
||||
? '上传送达凭证(必填,配送货物到达后拍照上传,最多3张)'
|
||||
: '上传凭证照片(选填,最多3张)'}
|
||||
</Text>
|
||||
<View className='flex flex-wrap gap-3 mb-4'>
|
||||
{proofImages.map((url, idx) => (
|
||||
<View key={idx} className='relative'>
|
||||
<Image className='w-20 h-20 rounded-lg bg-gray-50' src={getCompressedImageUrl(url)} mode='aspectFill'/>
|
||||
<View
|
||||
className='absolute -top-2 -right-2 w-5 h-5 bg-red-500 rounded-full flex items-center justify-center'
|
||||
onClick={() => removeProofImage(idx)}
|
||||
>
|
||||
<Text className='text-white text-xs'>×</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
{proofImages.length < (opType === 'confirmPay' ? 1 : 3) && (
|
||||
<View
|
||||
className='w-20 h-20 rounded-lg bg-gray-50 border-2 border-dashed border-gray-200 flex items-center justify-center'
|
||||
onClick={chooseProofImage}
|
||||
>
|
||||
<Text className='text-2xl text-gray-300'>+</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
{proofImages.length < 3 && (
|
||||
<View
|
||||
className='w-20 h-20 rounded-lg bg-gray-50 border-2 border-dashed border-gray-200 flex items-center justify-center'
|
||||
onClick={chooseProofImage}
|
||||
>
|
||||
<Text className='text-2xl text-gray-300'>+</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 备注(仅确认收款时显示) */}
|
||||
{opType === 'confirmPay' && (
|
||||
<View className='mb-6'>
|
||||
<Text className='text-sm text-gray-600 mb-2 block'>备注(选填)</Text>
|
||||
<Textarea
|
||||
className='w-full px-4 py-3 rounded-xl border border-gray-200 text-sm text-gray-800'
|
||||
style={{minHeight: '60px'}}
|
||||
value={payRemarks}
|
||||
onInput={(e) => setPayRemarks(e.detail.value)}
|
||||
placeholder='可填写备注(如:微信转账已收到)'
|
||||
maxlength={200}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 确认完成不需要备注时补充间距 */}
|
||||
{opType !== 'confirmPay' && <View className='mb-2'/>}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<View className='flex gap-3'>
|
||||
@@ -590,6 +941,147 @@ export default function StoreOrdersPage() {
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 发货弹窗:选择发货人员 */}
|
||||
{showShipModal && currentOrder && (
|
||||
<View className='fixed inset-0 z-50 flex items-end justify-center'>
|
||||
<View className='absolute inset-0 bg-black/50' onClick={() => setShowShipModal(false)}/>
|
||||
<View className='relative bg-white rounded-t-2xl w-full px-5 pt-6 pb-10'>
|
||||
<Text className='text-lg font-medium text-gray-800 text-center mb-5 block'>
|
||||
选择发货人员
|
||||
</Text>
|
||||
|
||||
{/* 订单信息 */}
|
||||
<View className='bg-gray-50 rounded-xl p-4 mb-5'>
|
||||
<Text className='text-sm text-gray-700 block'>订单号:{currentOrder.orderNo}</Text>
|
||||
<Text className='text-sm text-gray-700 mt-1 block'>
|
||||
实付金额:<Text
|
||||
className='text-red-500 font-medium'>¥{currentOrder.payPrice || currentOrder.totalPrice}</Text>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 店员列表 */}
|
||||
<View className='max-h-80 overflow-y-auto mb-5'>
|
||||
{loadingClerks ? (
|
||||
<View className='py-8 flex justify-center'>
|
||||
<Text className='text-sm text-gray-400'>加载中...</Text>
|
||||
</View>
|
||||
) : clerkList.length === 0 ? (
|
||||
<View className='py-8 flex justify-center'>
|
||||
<Text className='text-sm text-gray-400'>暂无可选择的店员</Text>
|
||||
</View>
|
||||
) : (
|
||||
clerkList.map(clerk => {
|
||||
const selected = clerk.id === selectedClerkId
|
||||
return (
|
||||
<View
|
||||
key={clerk.id}
|
||||
className={`flex items-center justify-between px-4 py-3 rounded-xl mb-2 border ${
|
||||
selected ? 'border-purple-500 bg-purple-50' : 'border-gray-200'
|
||||
}`}
|
||||
onClick={() => setSelectedClerkId(clerk.id ?? null)}
|
||||
>
|
||||
<View className='flex-1'>
|
||||
<Text className='text-sm text-gray-800'>
|
||||
{clerk.name}
|
||||
<Text className='text-xs text-gray-400 ml-2'>
|
||||
{clerk.roleType === 1 ? '经理' : '店员'}
|
||||
</Text>
|
||||
</Text>
|
||||
<Text className='text-xs text-gray-500 mt-0.5 block'>{clerk.phone}</Text>
|
||||
</View>
|
||||
{selected && (
|
||||
<Text className='text-purple-500 text-sm'>✓</Text>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<View className='flex gap-3'>
|
||||
<View className='flex-1 py-3 rounded-xl bg-gray-100 text-center' onClick={() => setShowShipModal(false)}>
|
||||
<Text className='text-sm text-gray-600'>取消</Text>
|
||||
</View>
|
||||
<View
|
||||
className={`flex-1 py-3 rounded-xl text-center flex items-center justify-center ${
|
||||
selectedClerkId != null ? 'bg-purple-500' : 'bg-gray-300'
|
||||
}`}
|
||||
onClick={selectedClerkId != null && !shipping ? handleConfirmShip : undefined}
|
||||
>
|
||||
{shipping ? (
|
||||
<Text className='text-sm text-white'>发货中...</Text>
|
||||
) : (
|
||||
<Text className='text-sm text-white'>确认发货</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 改价弹窗 */}
|
||||
{showEditPriceModal && editPriceOrder && (
|
||||
<View className='fixed inset-0 z-50 flex items-end justify-center'>
|
||||
<View className='absolute inset-0 bg-black/50' onClick={closeEditPriceModal}/>
|
||||
<View className='relative bg-white rounded-t-2xl w-full px-5 pt-6 pb-10'>
|
||||
<Text className='text-lg font-medium text-gray-800 text-center mb-5 block'>
|
||||
修改订单金额
|
||||
</Text>
|
||||
|
||||
{/* 订单信息 */}
|
||||
<View className='bg-gray-50 rounded-xl p-4 mb-5'>
|
||||
<Text className='text-sm text-gray-700 block'>订单号:{editPriceOrder.orderNo}</Text>
|
||||
<Text className='text-sm text-gray-700 mt-1 block'>
|
||||
原实付金额:<Text className='text-red-500 font-medium'>¥{editPriceOrder.payPrice || editPriceOrder.totalPrice}</Text>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 新金额输入 */}
|
||||
<View className='mb-6'>
|
||||
<Text className='text-sm text-gray-600 mb-2 block'>新实付金额(元)</Text>
|
||||
<Input
|
||||
type='digit'
|
||||
className='w-full px-4 py-3 rounded-xl border border-gray-200 text-lg text-gray-800'
|
||||
value={editPriceValue}
|
||||
onInput={(e) => setEditPriceValue(e.detail.value)}
|
||||
placeholder='请输入新的实付金额'
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 改价备注 */}
|
||||
<View className='mb-6'>
|
||||
<Text className='text-sm text-gray-600 mb-2 block'>改价原因(选填)</Text>
|
||||
<Textarea
|
||||
className='w-full px-4 py-3 rounded-xl border border-gray-200 text-sm text-gray-800'
|
||||
style={{minHeight: '60px'}}
|
||||
value={editPriceRemarks}
|
||||
onInput={(e) => setEditPriceRemarks(e.detail.value)}
|
||||
placeholder='如:客户协商优惠、多收退款等'
|
||||
maxlength={200}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<View className='flex gap-3'>
|
||||
<View className='flex-1 py-3 rounded-xl bg-gray-100 text-center' onClick={closeEditPriceModal}>
|
||||
<Text className='text-sm text-gray-600'>取消</Text>
|
||||
</View>
|
||||
<View
|
||||
className='flex-1 py-3 rounded-xl bg-orange-500 text-center flex items-center justify-center'
|
||||
onClick={editingPrice ? undefined : submitEditPrice}
|
||||
>
|
||||
{editingPrice ? (
|
||||
<Text className='text-sm text-white'>提交中...</Text>
|
||||
) : (
|
||||
<Text className='text-sm text-white'>确认改价</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
445
src/pages/store/users/index.tsx
Normal file
445
src/pages/store/users/index.tsx
Normal file
@@ -0,0 +1,445 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { View, Text, Image, ScrollView, Input } from '@tarojs/components'
|
||||
import Taro, { useDidShow } from '@tarojs/taro'
|
||||
import { pageUsers, updateUserStatus } from '@/api/system/user'
|
||||
import type { User } from '@/api/system/user/model'
|
||||
import { getCompressedImageUrl } from '@/utils/image'
|
||||
|
||||
definePageConfig({
|
||||
navigationBarTitleText: '用户管理',
|
||||
})
|
||||
|
||||
// ─── Tab 配置 ──────────────────────────────────────────────────
|
||||
type TabKey = 'all' | 'normal' | 'disabled'
|
||||
|
||||
const TABS: { key: TabKey; label: string; status?: number }[] = [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'normal', label: '正常', status: 0 },
|
||||
{ key: 'disabled', label: '已禁用', status: 1 },
|
||||
]
|
||||
|
||||
// ─── 页面组件 ──────────────────────────────────────────────────────
|
||||
export default function StoreUsersPage() {
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('all')
|
||||
|
||||
// 用户列表与分页
|
||||
const [userList, setUserList] = useState<User[]>([])
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// 搜索
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [searchText, setSearchText] = useState('')
|
||||
|
||||
// 用户详情弹窗
|
||||
const [showDetail, setShowDetail] = useState(false)
|
||||
const [detailUser, setDetailUser] = useState<User | null>(null)
|
||||
|
||||
const pageSize = 20
|
||||
const loadingRef = useRef(false)
|
||||
|
||||
/** 加载用户列表 */
|
||||
const loadUsers = useCallback(async (tab: TabKey, pageNo: number = 1, append = false, kw?: string) => {
|
||||
if (loadingRef.current) return
|
||||
loadingRef.current = true
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const tabConfig = TABS.find(t => t.key === tab)
|
||||
const params: any = {
|
||||
page: pageNo,
|
||||
limit: pageSize,
|
||||
}
|
||||
if (tabConfig?.status !== undefined) {
|
||||
params.status = tabConfig.status
|
||||
}
|
||||
const effectiveKw = kw !== undefined ? kw : keyword
|
||||
if (effectiveKw) params.keywords = effectiveKw
|
||||
|
||||
const res = await pageUsers(params)
|
||||
const list = res?.list || []
|
||||
|
||||
setUserList(prev => append ? [...prev, ...list] : list)
|
||||
setPage(pageNo)
|
||||
setHasMore(list.length >= pageSize)
|
||||
} catch (e: any) {
|
||||
Taro.showToast({ title: e.message || '加载失败', icon: 'none' })
|
||||
if (!append) setUserList([])
|
||||
} finally {
|
||||
loadingRef.current = false
|
||||
setLoading(false)
|
||||
}
|
||||
}, [keyword])
|
||||
|
||||
// 切换 tab 时重新加载
|
||||
useEffect(() => {
|
||||
loadUsers(activeTab, 1)
|
||||
}, [activeTab, loadUsers])
|
||||
|
||||
// 页面重新显示时刷新
|
||||
useDidShow(() => {
|
||||
loadUsers(activeTab, 1)
|
||||
})
|
||||
|
||||
/** 加载更多 */
|
||||
const handleLoadMore = () => {
|
||||
if (hasMore && !loadingRef.current) {
|
||||
loadUsers(activeTab, page + 1, true)
|
||||
}
|
||||
}
|
||||
|
||||
/** 执行搜索 */
|
||||
const handleSearch = () => {
|
||||
setKeyword(searchText)
|
||||
loadUsers(activeTab, 1, false, searchText)
|
||||
}
|
||||
|
||||
/** 清除搜索 */
|
||||
const handleClearSearch = () => {
|
||||
setSearchText('')
|
||||
setKeyword('')
|
||||
loadUsers(activeTab, 1, false, '')
|
||||
}
|
||||
|
||||
/** 切换用户状态(禁用/启用) */
|
||||
const handleToggleStatus = (user: User) => {
|
||||
const currentStatus = user.status ?? 0
|
||||
const newStatus = currentStatus === 0 ? 1 : 0
|
||||
const actionText = newStatus === 1 ? '禁用' : '启用'
|
||||
|
||||
Taro.showModal({
|
||||
title: '确认操作',
|
||||
content: `确定要${actionText}用户「${user.nickname || user.realName || user.phone || user.userId}」吗?${newStatus === 1 ? '禁用后该用户将无法登录。' : ''}`,
|
||||
confirmColor: newStatus === 1 ? '#ee0a24' : '#0e932e',
|
||||
success: async (res) => {
|
||||
if (!res.confirm) return
|
||||
try {
|
||||
Taro.showLoading({ title: `${actionText}中...` })
|
||||
await updateUserStatus(user.userId, newStatus)
|
||||
Taro.hideLoading()
|
||||
Taro.showToast({ title: `${actionText}成功`, icon: 'success' })
|
||||
|
||||
// 更新列表和详情中的用户状态
|
||||
setUserList(prev => prev.map(u =>
|
||||
u.userId === user.userId ? { ...u, status: newStatus } : u
|
||||
))
|
||||
if (detailUser?.userId === user.userId) {
|
||||
setDetailUser(prev => prev ? { ...prev, status: newStatus } : null)
|
||||
}
|
||||
} catch (e: any) {
|
||||
Taro.hideLoading()
|
||||
Taro.showToast({ title: e.message || `${actionText}失败`, icon: 'none' })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 打开用户详情 */
|
||||
const openDetail = (user: User) => {
|
||||
setDetailUser(user)
|
||||
setShowDetail(true)
|
||||
}
|
||||
|
||||
/** 格式化时间 */
|
||||
const formatTime = (time?: string) => {
|
||||
if (!time) return '-'
|
||||
// 兼容各种时间格式
|
||||
return time.replace('T', ' ').substring(0, 19)
|
||||
}
|
||||
|
||||
/** 渲染单个用户卡片 */
|
||||
const renderUserCard = (user: User) => {
|
||||
const isDisabled = user.status === 1
|
||||
const displayName = user.nickname || user.realName || '未设置昵称'
|
||||
|
||||
return (
|
||||
<View
|
||||
key={user.userId || user.id}
|
||||
className='bg-white rounded-xl mx-3 mt-3 p-4 active:opacity-80'
|
||||
onClick={() => openDetail(user)}
|
||||
>
|
||||
{/* 用户头部 */}
|
||||
<View className='flex items-center gap-3'>
|
||||
{/* 头像 */}
|
||||
<View className='w-12 h-12 rounded-full overflow-hidden flex-shrink-0 bg-gray-100'>
|
||||
{user.avatar ? (
|
||||
<Image
|
||||
className='w-full h-full'
|
||||
src={getCompressedImageUrl(user.avatar, { width: 80, quality: 80 })}
|
||||
mode='aspectFill'
|
||||
/>
|
||||
) : (
|
||||
<View className='w-full h-full flex items-center justify-center'>
|
||||
<Text className='text-2xl'>👤</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 信息 */}
|
||||
<View className='flex-1 min-w-0'>
|
||||
<View className='flex items-center gap-2'>
|
||||
<Text className='text-sm text-gray-800 font-medium' style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{displayName}
|
||||
</Text>
|
||||
<View
|
||||
className='px-1 py-1 rounded text-xs'
|
||||
style={{
|
||||
color: isDisabled ? '#dc2626' : '#16a34a',
|
||||
background: isDisabled ? '#fef2f2' : '#f0fdf4',
|
||||
}}
|
||||
>
|
||||
{isDisabled ? '已禁用' : '正常'}
|
||||
</View>
|
||||
</View>
|
||||
<Text className='text-xs text-gray-400 mt-1 block'>
|
||||
{user.phone || user.mobile || '未绑定手机'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 禁用/启用按钮 */}
|
||||
<View
|
||||
className={`px-3 py-1 rounded-lg flex-shrink-0 ${isDisabled ? 'bg-green-50' : 'bg-red-50'}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleToggleStatus(user)
|
||||
}}
|
||||
>
|
||||
<Text className={`text-xs ${isDisabled ? 'text-green-500' : 'text-red-500'}`}>
|
||||
{isDisabled ? '启用' : '禁用'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 附加信息 */}
|
||||
<View className='flex items-center gap-4 mt-3 pt-3 border-t border-gray-50'>
|
||||
{user.memberLevelName && (
|
||||
<View className='flex items-center gap-1'>
|
||||
<Text className='text-xs text-gray-400'>会员:</Text>
|
||||
<Text className='text-xs text-purple-500'>{user.memberLevelName}</Text>
|
||||
</View>
|
||||
)}
|
||||
<View className='flex items-center gap-1'>
|
||||
<Text className='text-xs text-gray-400'>注册:</Text>
|
||||
<Text className='text-xs text-gray-500'>{formatTime(user.createTime)}</Text>
|
||||
</View>
|
||||
{user.balance !== undefined && Number(user.balance) > 0 && (
|
||||
<View className='flex items-center gap-1'>
|
||||
<Text className='text-xs text-gray-400'>余额:</Text>
|
||||
<Text className='text-xs text-orange-500'>¥{user.balance}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='min-h-full bg-gray-50'>
|
||||
{/* 搜索栏 */}
|
||||
<View className='bg-white px-3 py-2 flex items-center gap-2'>
|
||||
<View className='flex-1 flex items-center bg-gray-100 rounded-lg px-3 py-1'>
|
||||
<Input
|
||||
className='flex-1 text-sm'
|
||||
placeholder='搜索昵称 / 手机号'
|
||||
value={searchText}
|
||||
onInput={(e) => setSearchText(e.detail.value)}
|
||||
onConfirm={handleSearch}
|
||||
confirmType='search'
|
||||
/>
|
||||
{searchText ? (
|
||||
<Text
|
||||
className='text-gray-400 text-lg px-1'
|
||||
onClick={handleClearSearch}
|
||||
>×</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<View
|
||||
className='px-3 py-1 rounded-lg bg-blue-500'
|
||||
onClick={handleSearch}
|
||||
>
|
||||
<Text className='text-sm text-white'>搜索</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Tab 栏 */}
|
||||
<View className='bg-white flex border-b border-gray-50'>
|
||||
{TABS.map(tab => (
|
||||
<View
|
||||
key={tab.key}
|
||||
className={`flex-1 text-center py-3 border-b-2 ${activeTab === tab.key ? 'border-blue-500' : 'border-transparent'}`}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
>
|
||||
<Text className={`text-sm ${activeTab === tab.key ? 'text-blue-500 font-medium' : 'text-gray-500'}`}>
|
||||
{tab.label}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* 用户列表 */}
|
||||
<ScrollView
|
||||
scrollY
|
||||
style={{ height: 'calc(100vh - 100px)' }}
|
||||
onScrollToLower={handleLoadMore}
|
||||
lowerThreshold={100}
|
||||
>
|
||||
{loading && userList.length === 0 ? (
|
||||
<View className='flex justify-center items-center py-20'>
|
||||
<Text className='text-gray-400'>加载中...</Text>
|
||||
</View>
|
||||
) : userList.length === 0 ? (
|
||||
<View className='flex justify-center items-center py-20'>
|
||||
<Text className='text-gray-400'>暂无用户</Text>
|
||||
</View>
|
||||
) : (
|
||||
userList.map(renderUserCard)
|
||||
)}
|
||||
{loading && userList.length > 0 && (
|
||||
<View className='flex justify-center items-center py-4'>
|
||||
<Text className='text-gray-400 text-sm'>加载中...</Text>
|
||||
</View>
|
||||
)}
|
||||
{!hasMore && userList.length > 0 && (
|
||||
<View className='flex justify-center items-center py-4'>
|
||||
<Text className='text-gray-300 text-xs'>没有更多了</Text>
|
||||
</View>
|
||||
)}
|
||||
<View className='h-6' />
|
||||
</ScrollView>
|
||||
|
||||
{/* 用户详情弹窗 */}
|
||||
{showDetail && detailUser && (
|
||||
<View className='fixed inset-0 z-50 flex items-end justify-center'>
|
||||
{/* 遮罩 */}
|
||||
<View className='absolute inset-0 bg-black/50' onClick={() => setShowDetail(false)} />
|
||||
{/* 弹窗内容 */}
|
||||
<View className='relative bg-white rounded-t-2xl w-full px-5 pt-6 pb-10 max-h-[75vh] overflow-y-auto'>
|
||||
{/* 标题 */}
|
||||
<View className='flex justify-between items-center mb-5'>
|
||||
<Text className='text-lg font-medium text-gray-800'>用户详情</Text>
|
||||
<Text className='text-gray-400 text-lg' onClick={() => setShowDetail(false)}>×</Text>
|
||||
</View>
|
||||
|
||||
{/* 用户基本信息 */}
|
||||
<View className='flex items-center gap-4 mb-5'>
|
||||
<View className='w-16 h-16 rounded-full overflow-hidden flex-shrink-0 bg-gray-100'>
|
||||
{detailUser.avatar ? (
|
||||
<Image
|
||||
className='w-full h-full'
|
||||
src={getCompressedImageUrl(detailUser.avatar, { width: 100, quality: 90 })}
|
||||
mode='aspectFill'
|
||||
/>
|
||||
) : (
|
||||
<View className='w-full h-full flex items-center justify-center'>
|
||||
<Text className='text-3xl'>👤</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<View className='flex-1 min-w-0'>
|
||||
<View className='flex items-center gap-2'>
|
||||
<Text className='text-base font-medium text-gray-800'>
|
||||
{detailUser.nickname || detailUser.realName || '未设置昵称'}
|
||||
</Text>
|
||||
<View
|
||||
className='px-2 py-1 rounded text-xs'
|
||||
style={{
|
||||
color: detailUser.status === 1 ? '#dc2626' : '#16a34a',
|
||||
background: detailUser.status === 1 ? '#fef2f2' : '#f0fdf4',
|
||||
}}
|
||||
>
|
||||
{detailUser.status === 1 ? '已禁用' : '正常'}
|
||||
</View>
|
||||
</View>
|
||||
<Text className='text-xs text-gray-400 mt-1 block'>
|
||||
ID: {detailUser.userId || detailUser.id}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 详细信息列表 */}
|
||||
<View className='bg-gray-50 rounded-xl p-4 mb-5'>
|
||||
{/* 手机号 */}
|
||||
<View className='flex justify-between items-center py-2 border-b border-gray-100'>
|
||||
<Text className='text-sm text-gray-500'>手机号</Text>
|
||||
<Text className='text-sm text-gray-800'>{detailUser.phone || detailUser.mobile || '-'}</Text>
|
||||
</View>
|
||||
|
||||
{/* 真实姓名 */}
|
||||
{detailUser.realName && (
|
||||
<View className='flex justify-between items-center py-2 border-b border-gray-100'>
|
||||
<Text className='text-sm text-gray-500'>真实姓名</Text>
|
||||
<Text className='text-sm text-gray-800'>{detailUser.realName}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 性别 */}
|
||||
<View className='flex justify-between items-center py-2 border-b border-gray-100'>
|
||||
<Text className='text-sm text-gray-500'>性别</Text>
|
||||
<Text className='text-sm text-gray-800'>
|
||||
{detailUser.gender === 1 ? '男' : detailUser.gender === 2 ? '女' : '未知'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 会员等级 */}
|
||||
{detailUser.memberLevelName && (
|
||||
<View className='flex justify-between items-center py-2 border-b border-gray-100'>
|
||||
<Text className='text-sm text-gray-500'>会员等级</Text>
|
||||
<Text className='text-sm text-purple-500'>{detailUser.memberLevelName}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 余额 */}
|
||||
{detailUser.balance !== undefined && (
|
||||
<View className='flex justify-between items-center py-2 border-b border-gray-100'>
|
||||
<Text className='text-sm text-gray-500'>余额</Text>
|
||||
<Text className='text-sm text-orange-500'>¥{detailUser.balance || '0.00'}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 积分 */}
|
||||
{detailUser.points !== undefined && (
|
||||
<View className='flex justify-between items-center py-2 border-b border-gray-100'>
|
||||
<Text className='text-sm text-gray-500'>积分</Text>
|
||||
<Text className='text-sm text-gray-800'>{detailUser.points || 0}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 邮箱 */}
|
||||
{detailUser.email && (
|
||||
<View className='flex justify-between items-center py-2 border-b border-gray-100'>
|
||||
<Text className='text-sm text-gray-500'>邮箱</Text>
|
||||
<Text className='text-sm text-gray-800'>{detailUser.email}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 注册时间 */}
|
||||
<View className='flex justify-between items-center py-2'>
|
||||
<Text className='text-sm text-gray-500'>注册时间</Text>
|
||||
<Text className='text-sm text-gray-800'>{formatTime(detailUser.createTime)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<View className='flex gap-3'>
|
||||
<View
|
||||
className='flex-1 py-3 rounded-xl bg-gray-100 text-center'
|
||||
onClick={() => setShowDetail(false)}
|
||||
>
|
||||
<Text className='text-sm text-gray-600'>关闭</Text>
|
||||
</View>
|
||||
<View
|
||||
className={`flex-1 py-3 rounded-xl text-center ${detailUser.status === 1 ? 'bg-green-500' : 'bg-red-500'}`}
|
||||
onClick={() => handleToggleStatus(detailUser)}
|
||||
>
|
||||
<Text className='text-sm text-white'>
|
||||
{detailUser.status === 1 ? '启用用户' : '禁用用户'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,132 @@
|
||||
import React from 'react'
|
||||
import React, { useState, useRef, useEffect } from 'react'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { View, Text, ScrollView } from '@tarojs/components'
|
||||
import { Input, Button } from '@nutui/nutui-react-taro'
|
||||
import { loginBySms, sendSmsCaptcha } from '@/api/passport/login'
|
||||
import type { LoginParam } from '@/api/passport/login/model'
|
||||
import { useUserContext } from '@/contexts/UserContext'
|
||||
|
||||
definePageConfig({
|
||||
navigationBarTitleText: '关于我们',
|
||||
})
|
||||
|
||||
/** 触发隐藏登录的点击次数 */
|
||||
const DEV_LOGIN_CLICK_COUNT = 11
|
||||
|
||||
const AboutPage: React.FC = () => {
|
||||
const { syncFromStorage } = useUserContext()
|
||||
|
||||
// ---- 隐藏开发者登录相关状态 ----
|
||||
const clickCountRef = useRef(0)
|
||||
const lastClickTime = useRef(0)
|
||||
const [showLogin, setShowLogin] = useState(false)
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [sendingCode, setSendingCode] = useState(false)
|
||||
const [countdown, setCountdown] = useState(0)
|
||||
const [formData, setFormData] = useState<LoginParam>({ phone: '', code: '' })
|
||||
|
||||
// 倒计时
|
||||
useEffect(() => {
|
||||
if (countdown <= 0) return
|
||||
const timer = setTimeout(() => setCountdown(countdown - 1), 1000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [countdown])
|
||||
|
||||
/** 点击版权信息 —— 累计 11 次弹出隐藏登录 */
|
||||
const handleCopyrightClick = () => {
|
||||
const now = Date.now()
|
||||
// 超过 3 秒未点击则重新计数
|
||||
if (now - lastClickTime.current > 3000) {
|
||||
clickCountRef.current = 1
|
||||
} else {
|
||||
clickCountRef.current += 1
|
||||
}
|
||||
lastClickTime.current = now
|
||||
|
||||
const current = clickCountRef.current
|
||||
const remaining = DEV_LOGIN_CLICK_COUNT - current
|
||||
|
||||
if (current >= DEV_LOGIN_CLICK_COUNT) {
|
||||
clickCountRef.current = 0
|
||||
setShowLogin(true)
|
||||
return
|
||||
}
|
||||
|
||||
// 从第 6 次开始给步数提示
|
||||
if (current >= 6) {
|
||||
Taro.showToast({
|
||||
title: `再点击 ${remaining} 次开启开发者登录`,
|
||||
icon: 'none',
|
||||
duration: 1200,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** 校验手机号 */
|
||||
const validatePhone = (phone: string): boolean => /^1[3-9]\d{9}$/.test(phone)
|
||||
|
||||
/** 发送短信验证码 */
|
||||
const handleSendCode = async () => {
|
||||
if (!formData.phone) {
|
||||
Taro.showToast({ title: '请输入手机号码', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!validatePhone(formData.phone)) {
|
||||
Taro.showToast({ title: '请输入正确的手机号码', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (sendingCode || countdown > 0) return
|
||||
|
||||
try {
|
||||
setSendingCode(true)
|
||||
await sendSmsCaptcha({ phone: formData.phone })
|
||||
Taro.showToast({ title: '验证码已发送', icon: 'success' })
|
||||
setCountdown(60)
|
||||
} catch (error: any) {
|
||||
Taro.showToast({ title: error.message || '发送失败', icon: 'none' })
|
||||
} finally {
|
||||
setSendingCode(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** 登录提交 */
|
||||
const handleLogin = async () => {
|
||||
if (loading) return
|
||||
if (!formData.phone || !validatePhone(formData.phone)) {
|
||||
Taro.showToast({ title: '请输入正确的手机号码', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!formData.code || formData.code.length !== 6) {
|
||||
Taro.showToast({ title: '请输入6位验证码', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
await loginBySms({ phone: formData.phone, code: formData.code })
|
||||
// loginBySms 内部已写入 storage,这里同步 React 登录态
|
||||
syncFromStorage()
|
||||
Taro.showToast({ title: '登录成功', icon: 'success' })
|
||||
// 重置表单并关闭弹窗
|
||||
setFormData({ phone: '', code: '' })
|
||||
setCountdown(0)
|
||||
setTimeout(() => setShowLogin(false), 800)
|
||||
} catch (error: any) {
|
||||
Taro.showToast({ title: error.message || '登录失败', icon: 'none' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** 关闭弹窗时重置状态 */
|
||||
const handleCloseLogin = () => {
|
||||
if (loading) return
|
||||
setShowLogin(false)
|
||||
setFormData({ phone: '', code: '' })
|
||||
setCountdown(0)
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView scrollY className='min-h-screen bg-gray-50'>
|
||||
{/* 头部品牌区 */}
|
||||
@@ -13,7 +134,7 @@ const AboutPage: React.FC = () => {
|
||||
<View className='w-20 h-20 rounded-2xl bg-gradient-to-br from-blue-400 to-blue-600 flex items-center justify-center shadow-lg mb-4'>
|
||||
<Text className='text-white text-4xl font-bold'>鑫</Text>
|
||||
</View>
|
||||
<Text className='text-xl font-bold text-gray-800'>玉林市玉州区鑫龙家电经营部</Text>
|
||||
<Text className='text-xl font-bold text-gray-800'>鑫龙商贸电器</Text>
|
||||
<Text className='text-sm text-gray-400 mt-1'>鑫龙家电 v1.0.0</Text>
|
||||
</View>
|
||||
|
||||
@@ -21,7 +142,7 @@ const AboutPage: React.FC = () => {
|
||||
<View className='bg-white mx-3 mt-3 rounded-xl p-4' style={{ boxShadow: '0 2px 8px rgba(0,0,0,0.04)' }}>
|
||||
<Text className='text-base font-medium text-gray-800 mb-3 block'>公司简介</Text>
|
||||
<Text className='text-sm text-gray-600 leading-relaxed'>
|
||||
玉林市玉州区鑫龙家电经营部是一家专注于家用电器销售、安装及维修服务的本地经营部。
|
||||
鑫龙商贸电器是一家专注于家用电器销售、安装及维修服务的本地经营部。
|
||||
我们提供冰箱、洗衣机、空调、电视、厨房电器、生活小家电等多种品类,涵盖售前咨询、
|
||||
售后安装、维护保养及故障维修等一站式家电服务。
|
||||
</Text>
|
||||
@@ -60,7 +181,7 @@ const AboutPage: React.FC = () => {
|
||||
<Text className='text-base font-medium text-gray-800 mb-3 block'>联系我们</Text>
|
||||
<View className='flex flex-col gap-2'>
|
||||
{[
|
||||
{ label: '经营部名称', value: '玉林市玉州区鑫龙家电经营部' },
|
||||
{ label: '经营部名称', value: '鑫龙商贸电器' },
|
||||
{ label: '经营地址', value: '大新里南718号' },
|
||||
{ label: '联系电话', value: '18269229683' },
|
||||
{ label: '营业时间', value: '9:00 ~ 18:00' },
|
||||
@@ -74,10 +195,84 @@ const AboutPage: React.FC = () => {
|
||||
</View>
|
||||
|
||||
{/* 底部版权 */}
|
||||
<View className='flex flex-col items-center py-8'>
|
||||
<Text className='text-xs text-gray-400'>Copyright © 2024 玉林市玉州区鑫龙家电经营部</Text>
|
||||
<View className='flex flex-col items-center py-8' onClick={handleCopyrightClick}>
|
||||
<Text className='text-xs text-gray-400'>Copyright © 2024 鑫龙商贸电器</Text>
|
||||
<Text className='text-xs text-gray-400 mt-1'>All Rights Reserved</Text>
|
||||
</View>
|
||||
|
||||
{/* 隐藏的开发者登录弹窗 */}
|
||||
{showLogin && (
|
||||
<View
|
||||
className='fixed inset-0 z-[9999] flex items-center justify-center'
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}
|
||||
onClick={handleCloseLogin}
|
||||
catchMove
|
||||
>
|
||||
<View
|
||||
className='rounded-2xl bg-white'
|
||||
style={{ width: '340px', padding: '24px 20px' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<View className='mb-3 flex items-center justify-between'>
|
||||
<Text className='text-lg font-bold text-gray-800'>开发者登录</Text>
|
||||
<Text
|
||||
className='text-lg text-gray-400'
|
||||
style={{ padding: '4px 8px' }}
|
||||
onClick={handleCloseLogin}
|
||||
>
|
||||
✕
|
||||
</Text>
|
||||
</View>
|
||||
<Text className='mb-5 block text-xs text-gray-400'>
|
||||
仅限开发测试使用,通过手机号+验证码登录指定账号
|
||||
</Text>
|
||||
|
||||
<View className='mb-3'>
|
||||
<Input
|
||||
type='number'
|
||||
placeholder='请输入手机号码'
|
||||
maxLength={11}
|
||||
value={formData.phone || ''}
|
||||
onChange={(val) => setFormData({ ...formData, phone: val })}
|
||||
style={{ backgroundColor: '#f5f5f5', borderRadius: '8px' }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className='mb-6 flex items-center bg-white rounded-lg pr-2'>
|
||||
<Input
|
||||
type='number'
|
||||
placeholder='请输入6位验证码'
|
||||
maxLength={6}
|
||||
value={formData.code || ''}
|
||||
onChange={(val) => setFormData({ ...formData, code: val })}
|
||||
style={{ backgroundColor: '#f5f5f5', borderRadius: '8px' }}
|
||||
/>
|
||||
<Button
|
||||
size='small'
|
||||
type={countdown > 0 ? 'default' : 'primary'}
|
||||
loading={sendingCode}
|
||||
disabled={sendingCode || countdown > 0}
|
||||
onClick={handleSendCode}
|
||||
>
|
||||
{countdown > 0 ? `${countdown}s` : sendingCode ? '发送中' : '获取验证码'}
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<View className='flex justify-center'>
|
||||
<Button
|
||||
type='primary'
|
||||
size='large'
|
||||
className='w-full rounded-lg p-2'
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
onClick={handleLogin}
|
||||
>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, {useState, useEffect, useRef} from 'react'
|
||||
import {View, Text, Input, Textarea, ScrollView, Picker} from '@tarojs/components'
|
||||
import Taro, {useRouter} from '@tarojs/taro'
|
||||
import {getShopUserAddress, addShopUserAddress, updateShopUserAddress} from '@/api/shop/shopUserAddress'
|
||||
import {getShopUserAddress, addShopUserAddress, updateShopUserAddress, setDefaultAddress} from '@/api/shop/shopUserAddress'
|
||||
import type {ShopUserAddress} from '@/api/shop/shopUserAddress/model'
|
||||
import {parseLngLatFromText} from '@/utils/geofence'
|
||||
import RegionData from '@/api/json/regions-data.json'
|
||||
@@ -101,6 +101,7 @@ const AddressEditPage: React.FC = () => {
|
||||
const [formData, setFormData] = useState<Partial<ShopUserAddress>>({
|
||||
name: '',
|
||||
phone: '',
|
||||
country: '',
|
||||
province: '',
|
||||
city: '',
|
||||
region: '',
|
||||
@@ -148,15 +149,22 @@ const AddressEditPage: React.FC = () => {
|
||||
const parseAddressText = (text: string) => {
|
||||
const result: any = {}
|
||||
|
||||
// 手机号正则
|
||||
// 手机号正则(11 位国内手机号)
|
||||
const phoneRegex = /1[3-9]\d{9}/
|
||||
const phoneMatch = text.match(phoneRegex)
|
||||
if (phoneMatch) result.phone = phoneMatch[0]
|
||||
|
||||
// 姓名正则(2-4个中文字符,通常在开头)
|
||||
const nameRegex = /^[\u4e00-\u9fa5]{2,4}/
|
||||
const nameMatch = text.match(nameRegex)
|
||||
if (nameMatch) result.name = nameMatch[0]
|
||||
// 姓名正则:优先匹配“收件人/收货人/姓名/联系人:”后面的 2-4 个中文字符
|
||||
const nameWithPrefixRegex = /(?:收件人|收货人|姓名|联系人)[::]\s*([\u4e00-\u9fa5]{2,4})/
|
||||
const nameWithPrefixMatch = text.match(nameWithPrefixRegex)
|
||||
if (nameWithPrefixMatch) {
|
||||
result.name = nameWithPrefixMatch[1]
|
||||
} else {
|
||||
// 兜底:文本开头 2-4 个中文字符
|
||||
const nameRegex = /^[\u4e00-\u9fa5]{2,4}/
|
||||
const nameMatch = text.match(nameRegex)
|
||||
if (nameMatch) result.name = nameMatch[0]
|
||||
}
|
||||
|
||||
// 省市区识别
|
||||
const regionResult = parseRegion(text)
|
||||
@@ -166,13 +174,29 @@ const AddressEditPage: React.FC = () => {
|
||||
result.region = regionResult.region
|
||||
}
|
||||
|
||||
// 详细地址提取
|
||||
// 详细地址提取:先把常见的标签字段和已知字段去掉,再清洗标点
|
||||
let addressText = text
|
||||
|
||||
// 去除已提取的姓名、手机号
|
||||
if (result.name) addressText = addressText.replace(result.name, '')
|
||||
if (result.phone) addressText = addressText.replace(result.phone, '')
|
||||
|
||||
// 去除常见标签文案(如“收件人:”、“手机号码:”、“所在地区:”、“详细地址:”)
|
||||
addressText = addressText
|
||||
.replace(/(?:收件人|收货人|姓名|联系人)[::]\s*/g, '')
|
||||
.replace(/(?:手机号码|手机号|电话|联系电话)[::]\s*/g, '')
|
||||
.replace(/(?:所在地区|地区|省市区)[::]\s*/g, '')
|
||||
.replace(/(?:详细地址|地址|街道)[::]\s*/g, '')
|
||||
|
||||
// 去除已识别出的省市区
|
||||
if (result.province) addressText = addressText.replace(result.province, '')
|
||||
if (result.city) addressText = addressText.replace(result.city, '')
|
||||
if (result.region) addressText = addressText.replace(result.region, '')
|
||||
|
||||
// 去除零散的标签关键字(如“手机号码”、“所在地区”、“详细地址”本身)
|
||||
addressText = addressText
|
||||
.replace(/(?:收件人|收货人|手机号码|手机号|所在地区|详细地址)/g, '')
|
||||
|
||||
result.address = addressText.replace(/[,,。\s]+/g, '').trim()
|
||||
|
||||
return result
|
||||
@@ -289,11 +313,35 @@ const AddressEditPage: React.FC = () => {
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 仅在有有效经纬度时传入初始化参数,避免 undefined 触发部分机型失败
|
||||
const initLat = selectedLocation?.lat ? Number(selectedLocation.lat) : undefined
|
||||
const initLng = selectedLocation?.lng ? Number(selectedLocation.lng) : undefined
|
||||
const latitude = typeof initLat === 'number' && Number.isFinite(initLat) ? initLat : undefined
|
||||
const longitude = typeof initLng === 'number' && Number.isFinite(initLng) ? initLng : undefined
|
||||
// 3. 优先使用已有经纬度;没有则先调 getLocation 获取当前位置
|
||||
// 解决鸿蒙等机型首次打开地图时 POI 列表不显示的问题
|
||||
let latitude: number | undefined
|
||||
let longitude: number | undefined
|
||||
|
||||
if (selectedLocation?.lat && selectedLocation?.lng) {
|
||||
const initLat = Number(selectedLocation.lat)
|
||||
const initLng = Number(selectedLocation.lng)
|
||||
if (Number.isFinite(initLat) && Number.isFinite(initLng)) {
|
||||
latitude = initLat
|
||||
longitude = initLng
|
||||
}
|
||||
}
|
||||
|
||||
// 没有已有定位时,主动获取当前位置传给地图,确保地图打开就在用户位置附近
|
||||
// 这样地图的 POI 列表能立即加载,不需要用户手动拖动
|
||||
if (latitude === undefined || longitude === undefined) {
|
||||
try {
|
||||
const loc: any = await Taro.getLocation({ type: 'gcj02' })
|
||||
if (loc && Number.isFinite(loc.latitude) && Number.isFinite(loc.longitude)) {
|
||||
latitude = loc.latitude
|
||||
longitude = loc.longitude
|
||||
}
|
||||
} catch (locErr) {
|
||||
// getLocation 失败不阻断流程,chooseLocation 仍可打开(只是默认定位到北京)
|
||||
console.warn('getLocation 失败,地图将以默认位置打开:', locErr)
|
||||
}
|
||||
}
|
||||
|
||||
const params: any = {}
|
||||
if (typeof latitude === 'number') params.latitude = latitude
|
||||
if (typeof longitude === 'number') params.longitude = longitude
|
||||
@@ -384,10 +432,23 @@ const AddressEditPage: React.FC = () => {
|
||||
}
|
||||
|
||||
// 执行新增或更新
|
||||
let savedId: number | undefined = addressId
|
||||
if (isEditMode) {
|
||||
await updateShopUserAddress(submitData)
|
||||
} else {
|
||||
await addShopUserAddress(submitData)
|
||||
const res: any = await addShopUserAddress(submitData)
|
||||
// 后端新增返回可能带有 id 字段
|
||||
savedId = (res && (res.data?.id || res.id)) || addressId
|
||||
}
|
||||
|
||||
// 兜底:勾选默认时主动调用 set-default,让后端走统一的"取消其他默认"逻辑
|
||||
// 这样即使 save/update 在某些老版本后端上漏处理,前端也能保证唯一默认
|
||||
if (submitData.isDefault && savedId) {
|
||||
try {
|
||||
await setDefaultAddress(savedId)
|
||||
} catch (e) {
|
||||
console.warn('setDefaultAddress fallback failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
Taro.showToast({title: `${isEditMode ? '更新' : '保存'}成功`, icon: 'success'})
|
||||
@@ -432,12 +493,29 @@ const AddressEditPage: React.FC = () => {
|
||||
// 编辑模式:加载已有地址数据
|
||||
if (isEditMode && addressId) {
|
||||
try {
|
||||
const addr = await getShopUserAddress(addressId)
|
||||
setFormData(addr)
|
||||
setRegionText(`${addr.province || ''} ${addr.city || ''} ${addr.region || ''}`)
|
||||
if (hasValidLngLat(addr)) {
|
||||
setSelectedLocation({lng: String(addr.lng), lat: String(addr.lat)})
|
||||
setRegionLocked(true)
|
||||
const addr = (await getShopUserAddress(addressId)) as ShopUserAddress
|
||||
if (addr) {
|
||||
// 合并到 formData,保证初始字段不被 null 覆盖为 undefined
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
...addr,
|
||||
name: addr.name || prev.name || '',
|
||||
phone: addr.phone || prev.phone || '',
|
||||
address: addr.address || prev.address || '',
|
||||
province: addr.province || prev.province || '',
|
||||
city: addr.city || prev.city || '',
|
||||
region: addr.region || prev.region || '',
|
||||
country: addr.country || prev.country || '中国',
|
||||
isDefault: addr.isDefault ?? false,
|
||||
}))
|
||||
const p = String(addr?.province || '').trim()
|
||||
const c = String(addr?.city || '').trim()
|
||||
const r = String(addr?.region || '').trim()
|
||||
setRegionText([p, c, r].filter(Boolean).join(' '))
|
||||
if (hasValidLngLat(addr)) {
|
||||
setSelectedLocation({lng: String((addr as any).lng), lat: String((addr as any).lat)})
|
||||
setRegionLocked(true)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载地址失败:', error)
|
||||
@@ -465,7 +543,7 @@ const AddressEditPage: React.FC = () => {
|
||||
{/* 地址智能识别区 */}
|
||||
<View className="bg-white px-3 pt-3 pb-2">
|
||||
<View
|
||||
className="rounded-lg p-2 relative"
|
||||
className="rounded-lg p-2"
|
||||
style={{border: '1px dashed #0e932e'}}
|
||||
>
|
||||
<Textarea
|
||||
@@ -475,19 +553,20 @@ const AddressEditPage: React.FC = () => {
|
||||
placeholder="粘贴文本,点击「识别」自动识别收货人、地址、电话"
|
||||
maxlength={200}
|
||||
/>
|
||||
<View
|
||||
className="absolute right-2 bottom-2"
|
||||
style={{
|
||||
backgroundColor: '#0e932e',
|
||||
borderRadius: '12px',
|
||||
paddingLeft: '12px',
|
||||
paddingRight: '12px',
|
||||
paddingTop: '4px',
|
||||
paddingBottom: '4px'
|
||||
}}
|
||||
onClick={recognizeAddress}
|
||||
>
|
||||
<Text className="text-white text-xs">识别</Text>
|
||||
<View className="flex justify-end mt-2">
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: '#0e932e',
|
||||
borderRadius: '12px',
|
||||
paddingLeft: '12px',
|
||||
paddingRight: '12px',
|
||||
paddingTop: '4px',
|
||||
paddingBottom: '4px'
|
||||
}}
|
||||
onClick={recognizeAddress}
|
||||
>
|
||||
<Text className="text-white text-xs">识别</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react'
|
||||
import { View, Text, ScrollView } from '@tarojs/components'
|
||||
import { View, Text, ScrollView, Button } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import EmptyState from '@/components/common/EmptyState'
|
||||
|
||||
@@ -127,14 +127,25 @@ const HelpCenterPage: React.FC = () => {
|
||||
</View>
|
||||
))}
|
||||
|
||||
{/* 联系客服入口 */}
|
||||
<View
|
||||
className='bg-white rounded-lg p-4 flex items-center justify-center gap-2 mt-3'
|
||||
onClick={() => Taro.navigateTo({ url: '/pages/user/customer-service/index' })}
|
||||
{/* 联系客服入口 - 直接进入微信客服 */}
|
||||
<Button
|
||||
openType="contact"
|
||||
sessionFrom="help_center"
|
||||
showMessageCard
|
||||
sendMessageTitle="欢迎咨询"
|
||||
onContact={(e: any) => console.log('客服会话回调:', e.detail)}
|
||||
className='bg-white rounded-lg w-full mt-3 flex items-center justify-center gap-2'
|
||||
style={{
|
||||
height: 'auto',
|
||||
lineHeight: 'normal',
|
||||
padding: '16px',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
>
|
||||
<Text className='text-xl'>📞</Text>
|
||||
<Text className='text-sm text-blue-500'>联系在线客服</Text>
|
||||
</View>
|
||||
</Button>
|
||||
|
||||
<View className='h-4'></View>
|
||||
</View>
|
||||
|
||||
@@ -1,35 +1,96 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { View, Text, Image, ScrollView } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import EmptyState from '@/components/common/EmptyState'
|
||||
import LoadMore from '@/components/common/LoadMore'
|
||||
import { getCompressedImageUrl } from '@/utils/image'
|
||||
import { useUserContext } from '@/contexts/UserContext'
|
||||
import {
|
||||
pageBrowseHistory,
|
||||
deleteBrowseRecord,
|
||||
clearBrowseHistory
|
||||
} from '@/api/shop/shopGoodsBrowse'
|
||||
import type { ShopGoodsBrowseParam } from '@/api/shop/shopGoodsBrowse/model'
|
||||
|
||||
definePageConfig({
|
||||
navigationBarTitleText: '浏览历史',
|
||||
enablePullDownRefresh: true,
|
||||
})
|
||||
|
||||
interface HistoryItem {
|
||||
/** 统一展示类型(兼容服务端 + 本地兜底) */
|
||||
interface DisplayItem {
|
||||
id?: number
|
||||
goodsId: number
|
||||
name?: string
|
||||
image?: string
|
||||
price?: string
|
||||
timestamp: number
|
||||
visitCount?: number
|
||||
lastVisitTime?: string
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
const BrowseHistoryPage: React.FC = () => {
|
||||
const [list, setList] = useState<HistoryItem[]>([])
|
||||
const { isLoggedIn } = useUserContext()
|
||||
const [list, setList] = useState<DisplayItem[]>([])
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const loadHistory = useCallback(async (pageNum: number) => {
|
||||
if (loading) return
|
||||
setLoading(true)
|
||||
try {
|
||||
if (isLoggedIn) {
|
||||
// 已登录:服务端分页查询
|
||||
const params: ShopGoodsBrowseParam = { page: pageNum, limit: PAGE_SIZE }
|
||||
const res = await pageBrowseHistory(params)
|
||||
const items: DisplayItem[] = (res.list || []).map(item => ({
|
||||
id: item.id,
|
||||
goodsId: item.goodsId!,
|
||||
name: item.goodsName,
|
||||
image: item.goodsImage,
|
||||
price: item.price,
|
||||
visitCount: item.visitCount,
|
||||
lastVisitTime: item.lastVisitTime,
|
||||
}))
|
||||
setList(prev => pageNum === 1 ? items : [...prev, ...items])
|
||||
setHasMore(list.length + items.length < res.count)
|
||||
setPage(pageNum)
|
||||
} else {
|
||||
// 未登录:本地存储兜底
|
||||
const history = Taro.getStorageSync('browse_history') || []
|
||||
const items: DisplayItem[] = history.map((item: any) => ({
|
||||
goodsId: item.goodsId,
|
||||
name: item.name,
|
||||
image: item.image,
|
||||
price: item.price,
|
||||
lastVisitTime: item.timestamp ? new Date(item.timestamp).toISOString() : undefined,
|
||||
}))
|
||||
setList(items)
|
||||
setHasMore(false)
|
||||
}
|
||||
} catch {
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [isLoggedIn, loading, list.length])
|
||||
|
||||
useEffect(() => {
|
||||
loadHistory()
|
||||
}, [])
|
||||
loadHistory(1)
|
||||
}, [isLoggedIn])
|
||||
|
||||
const loadHistory = () => {
|
||||
try {
|
||||
const history = Taro.getStorageSync('browse_history') || []
|
||||
setList(history)
|
||||
} catch {
|
||||
setList([])
|
||||
// 下拉刷新
|
||||
const onPullDownRefresh = async () => {
|
||||
await loadHistory(1)
|
||||
Taro.stopPullDownRefresh()
|
||||
}
|
||||
|
||||
// 上拉加载更多
|
||||
const onScrollToLower = () => {
|
||||
if (hasMore && !loading && isLoggedIn) {
|
||||
loadHistory(page + 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,31 +98,74 @@ const BrowseHistoryPage: React.FC = () => {
|
||||
Taro.navigateTo({ url: `/pages/shop/product-detail?id=${goodsId}` })
|
||||
}
|
||||
|
||||
const handleDelete = async (item: DisplayItem) => {
|
||||
Taro.showModal({
|
||||
title: '提示',
|
||||
content: '确定要删除这条浏览记录吗?',
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
if (isLoggedIn && item.id) {
|
||||
try {
|
||||
await deleteBrowseRecord(item.id)
|
||||
} catch {
|
||||
Taro.showToast({ title: '删除失败', icon: 'none' })
|
||||
return
|
||||
}
|
||||
}
|
||||
setList(prev => prev.filter(i => i.goodsId !== item.goodsId))
|
||||
Taro.showToast({ title: '已删除', icon: 'success' })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleClearHistory = () => {
|
||||
Taro.showModal({
|
||||
title: '提示',
|
||||
content: '确定要清空浏览历史吗?',
|
||||
success: (res) => {
|
||||
content: '确定要清空全部浏览历史吗?',
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
if (isLoggedIn) {
|
||||
try {
|
||||
await clearBrowseHistory()
|
||||
} catch {
|
||||
Taro.showToast({ title: '清空失败', icon: 'none' })
|
||||
return
|
||||
}
|
||||
}
|
||||
Taro.removeStorageSync('browse_history')
|
||||
setList([])
|
||||
setHasMore(false)
|
||||
Taro.showToast({ title: '已清空', icon: 'success' })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const formatTime = (timestamp: number) => {
|
||||
const date = new Date(timestamp)
|
||||
return `${date.getMonth() + 1}/${date.getDate()} ${date.getHours()}:${String(date.getMinutes()).padStart(2, '0')}`
|
||||
const formatTime = (timeStr?: string) => {
|
||||
if (!timeStr) return ''
|
||||
const date = new Date(timeStr)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
const hours = Math.floor(diff / 3600000)
|
||||
const days = Math.floor(diff / 86400000)
|
||||
if (minutes < 1) return '刚刚'
|
||||
if (minutes < 60) return `${minutes}分钟前`
|
||||
if (hours < 24) return `${hours}小时前`
|
||||
if (days < 7) return `${days}天前`
|
||||
return `${date.getMonth() + 1}/${date.getDate()}`
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='min-h-screen bg-gray-50'>
|
||||
{/* 顶部操作栏 */}
|
||||
{list.length > 0 && (
|
||||
<View className='bg-white px-4 py-2 flex justify-end border-b border-gray-100'>
|
||||
<Text
|
||||
<View className='bg-white px-4 py-2 flex justify-between items-center border-b border-gray-100'>
|
||||
<Text className='text-xs text-gray-400'>
|
||||
{isLoggedIn ? `共 ${list.length} 条记录` : '本地记录'}
|
||||
</Text>
|
||||
<Text
|
||||
className='text-sm text-red-500'
|
||||
onClick={handleClearHistory}
|
||||
>
|
||||
@@ -69,15 +173,20 @@ const BrowseHistoryPage: React.FC = () => {
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<ScrollView scrollY className='h-screen'>
|
||||
|
||||
<ScrollView
|
||||
scrollY
|
||||
className='h-screen'
|
||||
onScrollToLower={onScrollToLower}
|
||||
lowerThreshold={80}
|
||||
>
|
||||
<View className='p-3'>
|
||||
{list.length > 0 ? (
|
||||
<View className='grid grid-cols-2 gap-3'>
|
||||
{list.map(item => (
|
||||
<View
|
||||
key={`${item.goodsId}-${item.timestamp}`}
|
||||
className='bg-white rounded-lg overflow-hidden'
|
||||
key={`${item.goodsId}-${item.id || item.lastVisitTime}`}
|
||||
className='bg-white rounded-lg overflow-hidden relative'
|
||||
onClick={() => handleItemClick(item.goodsId)}
|
||||
>
|
||||
<Image
|
||||
@@ -95,9 +204,22 @@ const BrowseHistoryPage: React.FC = () => {
|
||||
¥{item.price || '0'}
|
||||
</Text>
|
||||
<Text className='text-xs text-gray-400'>
|
||||
{formatTime(item.timestamp)}
|
||||
{formatTime(item.lastVisitTime)}
|
||||
</Text>
|
||||
</View>
|
||||
{item.visitCount && item.visitCount > 1 && (
|
||||
<Text className='text-xs text-orange-400 mt-1 block'>
|
||||
浏览 {item.visitCount} 次
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
{/* 删除按钮 */}
|
||||
<View
|
||||
className='absolute top-1 right-1 w-6 h-6 rounded-full bg-black bg-opacity-40 flex items-center justify-center'
|
||||
catchMove
|
||||
onClick={(e) => { e.stopPropagation(); handleDelete(item) }}
|
||||
>
|
||||
<Text className='text-white text-xs'>×</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
@@ -105,6 +227,9 @@ const BrowseHistoryPage: React.FC = () => {
|
||||
) : (
|
||||
<EmptyState text='暂无浏览历史' />
|
||||
)}
|
||||
{isLoggedIn && list.length > 0 && (
|
||||
<LoadMore hasMore={hasMore} loading={loading} />
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
@@ -8,7 +8,7 @@ const UserPage: React.FC = () => {
|
||||
const { user, isLoggedIn } = useUser()
|
||||
|
||||
// TabBar 页面列表
|
||||
const tabBarPages = ['/pages/index/index', '/pages/shop/index', '/pages/order/list', '/pages/user/user']
|
||||
const tabBarPages = ['/pages/index/index', '/pages/shop/index', '/pages/shop/cart', '/pages/user/user']
|
||||
|
||||
const handleNavigate = (url: string) => {
|
||||
if (tabBarPages.includes(url)) {
|
||||
@@ -155,7 +155,7 @@ const UserPage: React.FC = () => {
|
||||
</View>
|
||||
<View
|
||||
className='text-center py-2 rounded-lg'
|
||||
onClick={() => Taro.switchTab({ url: '/pages/order/list' })}
|
||||
onClick={() => Taro.navigateTo({ url: '/pages/order/list' })}
|
||||
>
|
||||
<Text className='text-xl font-bold text-transparent' style={{ background: 'linear-gradient(135deg, #4facfe, #00f2fe)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent' }}>
|
||||
0
|
||||
@@ -172,22 +172,22 @@ const UserPage: React.FC = () => {
|
||||
<View className='bg-white rounded-xl mx-3 mt-3 p-4' style={{ boxShadow: '0 2px 8px rgba(0, 0, 0, 0.05)' }}>
|
||||
<View className='flex justify-between items-center mb-3'>
|
||||
<Text className='text-base font-medium text-gray-800'>我的订单</Text>
|
||||
<Text className='text-xs text-gray-400' onClick={() => Taro.switchTab({ url: '/pages/order/list' })}>
|
||||
<Text className='text-xs text-gray-400' onClick={() => Taro.navigateTo({ url: '/pages/order/list' })}>
|
||||
全部订单 {'>'}
|
||||
</Text>
|
||||
</View>
|
||||
<View className='grid grid-cols-4 gap-2'>
|
||||
{[
|
||||
{ icon: '💳', label: '待付款', status: 0, color: '#f093fb' },
|
||||
{ icon: '📦', label: '待发货', status: 1, color: '#667eea' },
|
||||
{ icon: '🚚', label: '待收货', status: 2, color: '#4facfe' },
|
||||
{ icon: '✅', label: '已完成', status: 3, color: '#52c41a' },
|
||||
{ icon: '💳', label: '待付款', tabIndex: 1, color: '#f093fb' },
|
||||
{ icon: '📦', label: '待发货', tabIndex: 2, color: '#667eea' },
|
||||
{ icon: '🚚', label: '待收货', tabIndex: 3, color: '#4facfe' },
|
||||
{ icon: '✅', label: '已完成', tabIndex: 4, color: '#52c41a' },
|
||||
].map(item => (
|
||||
<View
|
||||
key={item.status}
|
||||
key={item.tabIndex}
|
||||
className='flex flex-col items-center py-2 rounded-lg'
|
||||
style={{ background: `${item.color}10` }}
|
||||
onClick={() => { Taro.setStorageSync('order_tab', item.status); Taro.switchTab({ url: '/pages/order/list' }) }}
|
||||
onClick={() => { Taro.setStorageSync('order_tab', item.tabIndex); Taro.navigateTo({ url: `/pages/order/list?tab=${item.tabIndex}` }) }}
|
||||
>
|
||||
<Text className='text-xl mb-1'>{item.icon}</Text>
|
||||
<Text className='text-xs text-gray-600'>{item.label}</Text>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,6 +11,8 @@ import type { UserRole } from '@/api/system/userRole/model'
|
||||
import { checkAndCacheVipStatus } from '@/utils/vip'
|
||||
import { requireLogin } from '@/utils/login-guard'
|
||||
import MemberBadge from '@/components/business/MemberBadge'
|
||||
import Badge from '@/components/common/Badge'
|
||||
import ArrowRight from '@/components/common/ArrowRight'
|
||||
|
||||
const UserPage: React.FC = () => {
|
||||
const scrollHeight = useScrollHeight()
|
||||
@@ -41,7 +43,8 @@ const UserPage: React.FC = () => {
|
||||
cacheKey: 'user_order_stats',
|
||||
cacheTime: 30 * 1000,
|
||||
onSuccess: (data) => {
|
||||
Taro.setStorageSync('user_order_stats_v2', data)
|
||||
// v3: 字段结构变更(与后端 UserOrderStats DTO 对齐),旧版 v2 缓存自动失效
|
||||
Taro.setStorageSync('user_order_stats_v3', data)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -51,7 +54,7 @@ const UserPage: React.FC = () => {
|
||||
// 冷启动优化:先从 localStorage 读取缓存即时展示,消除骨架屏
|
||||
const cachedCardStats = Taro.getStorageSync('user_card_stats_v2')
|
||||
if (cachedCardStats) mutateCardStats(cachedCardStats)
|
||||
const cachedOrderStats = Taro.getStorageSync('user_order_stats_v2')
|
||||
const cachedOrderStats = Taro.getStorageSync('user_order_stats_v3')
|
||||
if (cachedOrderStats) mutateOrderStats(cachedOrderStats)
|
||||
// 然后发起网络请求(stale-while-revalidate 后台刷新)
|
||||
runCardStats()
|
||||
@@ -78,7 +81,7 @@ const UserPage: React.FC = () => {
|
||||
if (cachedCardStats) {
|
||||
mutateCardStats(cachedCardStats)
|
||||
}
|
||||
const cachedOrderStats = Taro.getStorageSync('user_order_stats_v2')
|
||||
const cachedOrderStats = Taro.getStorageSync('user_order_stats_v3')
|
||||
if (cachedOrderStats) {
|
||||
mutateOrderStats(cachedOrderStats)
|
||||
}
|
||||
@@ -106,7 +109,7 @@ const UserPage: React.FC = () => {
|
||||
{ icon: '📍', label: '收货地址', url: '/pages/user/address-list', requireAuth: true },
|
||||
// { icon: '⭐', label: '积分明细', url: '/pages/user/points-record' },
|
||||
{ icon: '❤️', label: '我的收藏', url: '/pages/user/favorite-list/index', requireAuth: true },
|
||||
// { icon: '🕐', label: '浏览历史', url: '/pages/user/history-list/index' },
|
||||
{ icon: '🕐', label: '浏览历史', url: '/pages/user/history-list/index' },
|
||||
{ icon: '❓', label: '帮助中心', url: '/pages/user/help-center/index' },
|
||||
// { icon: '🔔', label: '消息通知', url: '/pages/index/notification' },
|
||||
// 门店管理:仅门店经理/店员显示
|
||||
@@ -135,15 +138,15 @@ const UserPage: React.FC = () => {
|
||||
}
|
||||
|
||||
// 订单快捷入口点击:需要登录
|
||||
const handleOrderTabClick = (status: number) => {
|
||||
if (!requireLogin({ action: 'viewOrder', redirect: '/pages/order/list', content: '登录后才能查看订单,是否前往登录?' })) return
|
||||
Taro.setStorageSync('order_tab', status)
|
||||
Taro.switchTab({ url: '/pages/order/list' })
|
||||
const handleOrderTabClick = (tabIndex: number) => {
|
||||
if (!requireLogin({ action: 'viewOrder', redirect: `/pages/order/list?tab=${tabIndex}`, content: '登录后才能查看订单,是否前往登录?' })) return
|
||||
Taro.setStorageSync('order_tab', tabIndex)
|
||||
Taro.navigateTo({ url: `/pages/order/list?tab=${tabIndex}` })
|
||||
}
|
||||
|
||||
const handleViewAllOrders = () => {
|
||||
if (!requireLogin({ action: 'viewOrder', redirect: '/pages/order/list', content: '登录后才能查看订单,是否前往登录?' })) return
|
||||
Taro.switchTab({ url: '/pages/order/list' })
|
||||
Taro.navigateTo({ url: '/pages/order/list' })
|
||||
}
|
||||
|
||||
const loading = cardStatsLoading || orderStatsLoading
|
||||
@@ -214,7 +217,7 @@ const UserPage: React.FC = () => {
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
{isLoggedIn && <Text className='text-white text-opacity-60 text-sm'>{'>'}</Text>}
|
||||
{isLoggedIn && <ArrowRight className='text-white text-opacity-60' />}
|
||||
</View>
|
||||
{/* 数据概览 */}
|
||||
{isLoggedIn && (
|
||||
@@ -257,30 +260,28 @@ const UserPage: React.FC = () => {
|
||||
<View className='bg-white rounded-xl mx-3 mt-3 p-4'>
|
||||
<View className='flex justify-between items-center mb-3'>
|
||||
<Text className='text-base font-medium text-gray-800'>我的订单</Text>
|
||||
<Text className='text-xs text-gray-400' onClick={handleViewAllOrders}>
|
||||
全部订单 {'>'}
|
||||
</Text>
|
||||
<View className='flex items-center text-xs text-gray-400' onClick={handleViewAllOrders}>
|
||||
<Text>全部订单</Text>
|
||||
<ArrowRight className='ml-1' />
|
||||
</View>
|
||||
</View>
|
||||
<View className='grid grid-cols-4 gap-2'>
|
||||
{[
|
||||
{ icon: '💳', label: '待付款', status: 0, count: orderStats?.pending },
|
||||
{ icon: '📦', label: '待发货', status: 1, count: orderStats?.paid },
|
||||
{ icon: '🚚', label: '待收货', status: 2, count: orderStats?.shipped },
|
||||
{ icon: '✅', label: '已完成', status: 3, count: orderStats?.completed },
|
||||
// status 与 OrderListStatus 对齐:0待付款 1待发货 3待收货 5已完成
|
||||
{ icon: '💳', label: '待付款', tabIndex: 1, count: orderStats?.waitPay },
|
||||
{ icon: '📦', label: '待发货', tabIndex: 2, count: orderStats?.waitDeliver },
|
||||
{ icon: '🚚', label: '待收货', tabIndex: 3, count: orderStats?.waitReceive },
|
||||
{ icon: '✅', label: '已完成', tabIndex: 4, count: orderStats?.completed },
|
||||
].map(item => (
|
||||
<View
|
||||
key={item.status}
|
||||
key={item.tabIndex}
|
||||
className={`flex flex-col items-center py-2 relative ${!isLoggedIn ? 'opacity-50' : ''}`}
|
||||
onClick={() => handleOrderTabClick(item.status)}
|
||||
onClick={() => handleOrderTabClick(item.tabIndex)}
|
||||
>
|
||||
<Text className='text-xl mb-1'>{item.icon}</Text>
|
||||
<Text className='text-xs text-gray-600'>{item.label}</Text>
|
||||
{/* 数量角标 */}
|
||||
{item.count && item.count > 0 && (
|
||||
<View className='absolute top-0 right-2 bg-red-500 text-white text-xs rounded-full min-w-4 h-4 flex items-center justify-center px-1'>
|
||||
{item.count > 99 ? '99+' : item.count}
|
||||
</View>
|
||||
)}
|
||||
{/* 数量角标:count 为 0 时组件内部不渲染 */}
|
||||
{item.tabIndex != 4 && <Badge count={item.count} className='absolute -top-1 -right-1' />}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
@@ -311,7 +312,7 @@ const UserPage: React.FC = () => {
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
<Text className={`text-sm ${(item as any).highlight ? 'text-amber-700' : 'text-gray-300'}`}>{'>'}</Text>
|
||||
<ArrowRight className={(item as any).highlight ? 'text-amber-700' : 'text-gray-300'} />
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
trackInviteSource,
|
||||
} from '@/utils/invite'
|
||||
|
||||
const TABBAR_URLS = ['/pages/index/index', '/pages/order/order', '/pages/user/user']
|
||||
const TABBAR_URLS = ['/pages/index/index', '/pages/shop/index', '/pages/shop/cart', '/pages/user/user']
|
||||
|
||||
export function decodeRedirect(input?: string): string {
|
||||
if (!input) return ''
|
||||
|
||||
@@ -206,6 +206,21 @@
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
// 降级登录入口(短信验证码)
|
||||
.login-methods__alt {
|
||||
text-align: center;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.login-methods__alt-link {
|
||||
display: inline-block;
|
||||
font-size: 26px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
text-decoration: underline;
|
||||
padding: 8px 16px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
// 登录按钮
|
||||
.login-btn {
|
||||
height: 96px;
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { View, Image, Text, Button } from '@tarojs/components'
|
||||
import { TenantId } from '@/config/app'
|
||||
import { getWxOpenId } from '@/api/layout'
|
||||
import { getUserInfo } from '@/api/layout'
|
||||
import { saveStorageByLoginUser } from '@/utils/server'
|
||||
import { saveStorageByLoginUser, SERVER_API_URL } from '@/utils/server'
|
||||
import { isUserDisabled } from '@/utils/auth'
|
||||
import { useUserContext } from '@/contexts/UserContext'
|
||||
import request from '@/utils/request'
|
||||
import {
|
||||
getPhoneAuthCooldownRemaining,
|
||||
isPhoneAuthCoolingDown,
|
||||
markPhoneAuthCalled,
|
||||
} from '@/utils/phoneAuth'
|
||||
import {
|
||||
checkAndHandleInviteRelation,
|
||||
hasPendingInvite,
|
||||
@@ -74,15 +82,15 @@ async function ensureWxOpenIdSaved() {
|
||||
/** 获取用户信息 */
|
||||
async function fetchUserInfo(token: string) {
|
||||
try {
|
||||
const serverUrl = 'https://server.websoft.top'
|
||||
const res: any = await Taro.request({
|
||||
url: `${serverUrl}/api/auth/user`,
|
||||
url: `${SERVER_API_URL}/auth/user`,
|
||||
method: 'GET',
|
||||
header: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
Authorization: token,
|
||||
'content-type': 'application/json',
|
||||
TenantId: String(TenantId),
|
||||
},
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
if (res.data?.code === 0 && res.data?.data) {
|
||||
@@ -98,16 +106,90 @@ async function fetchUserInfo(token: string) {
|
||||
const Login = () => {
|
||||
const [isAgree, setIsAgree] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [phoneAuthPending, setPhoneAuthPending] = useState(false)
|
||||
const [phoneAuthCooling, setPhoneAuthCooling] = useState(isPhoneAuthCoolingDown())
|
||||
const [showContent, setShowContent] = useState(false)
|
||||
const { loginUser } = useUserContext()
|
||||
const phoneAuthInFlightRef = useRef(false)
|
||||
const phoneAuthPendingRef = useRef(false)
|
||||
const phoneAuthTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const phoneAuthPendingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const router = Taro.getCurrentInstance().router
|
||||
const isWeapp = IS_WEAPP
|
||||
const canUsePhoneAuth = isAgree && !loading && !phoneAuthPending && !phoneAuthCooling
|
||||
|
||||
/** 页面加载动画 */
|
||||
useEffect(() => {
|
||||
setTimeout(() => setShowContent(true), 100)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (phoneAuthTimerRef.current) {
|
||||
clearTimeout(phoneAuthTimerRef.current)
|
||||
phoneAuthTimerRef.current = null
|
||||
}
|
||||
if (phoneAuthPendingTimerRef.current) {
|
||||
clearTimeout(phoneAuthPendingTimerRef.current)
|
||||
phoneAuthPendingTimerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startPhoneAuthCooldown = () => {
|
||||
const remaining = Math.max(getPhoneAuthCooldownRemaining(), 300)
|
||||
setPhoneAuthCooling(true)
|
||||
if (phoneAuthTimerRef.current) clearTimeout(phoneAuthTimerRef.current)
|
||||
phoneAuthTimerRef.current = setTimeout(() => {
|
||||
phoneAuthTimerRef.current = null
|
||||
setPhoneAuthCooling(false)
|
||||
}, remaining)
|
||||
}
|
||||
|
||||
const clearPhoneAuthPending = () => {
|
||||
phoneAuthPendingRef.current = false
|
||||
setPhoneAuthPending(false)
|
||||
if (phoneAuthPendingTimerRef.current) {
|
||||
clearTimeout(phoneAuthPendingTimerRef.current)
|
||||
phoneAuthPendingTimerRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const startPhoneAuthPending = () => {
|
||||
phoneAuthPendingRef.current = true
|
||||
setPhoneAuthPending(true)
|
||||
markPhoneAuthCalled()
|
||||
startPhoneAuthCooldown()
|
||||
if (phoneAuthPendingTimerRef.current) clearTimeout(phoneAuthPendingTimerRef.current)
|
||||
phoneAuthPendingTimerRef.current = setTimeout(() => {
|
||||
clearPhoneAuthPending()
|
||||
Taro.showModal({
|
||||
title: '手机号授权超时',
|
||||
content: '微信手机号授权暂时无响应,请稍后重试或改用短信验证码登录。',
|
||||
confirmText: '短信登录',
|
||||
cancelText: '稍后重试',
|
||||
confirmColor: '#07c160',
|
||||
success: (res) => {
|
||||
if (res.confirm) goSmsLogin()
|
||||
},
|
||||
})
|
||||
}, 12000)
|
||||
}
|
||||
|
||||
const handlePhoneAuthTap = () => {
|
||||
if (!isAgree) {
|
||||
Taro.showToast({ title: '请先勾选同意协议', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (loading || phoneAuthInFlightRef.current || phoneAuthPendingRef.current || isPhoneAuthCoolingDown()) {
|
||||
startPhoneAuthCooldown()
|
||||
Taro.showToast({ title: '请稍后再试', icon: 'none' })
|
||||
return
|
||||
}
|
||||
startPhoneAuthPending()
|
||||
}
|
||||
|
||||
/** 解析 redirect 参数 */
|
||||
const redirectUrl = (() => {
|
||||
const raw = (router?.params as Record<string, string> | undefined)?.redirect
|
||||
@@ -143,9 +225,8 @@ const Login = () => {
|
||||
const tabBarUrls = [
|
||||
'/pages/index/index',
|
||||
'/pages/shop/index',
|
||||
'/pages/points/index',
|
||||
'/pages/user/user',
|
||||
'/pages/order/list',
|
||||
'/pages/shop/cart',
|
||||
]
|
||||
const pure = redirectUrl.split('?')[0]
|
||||
|
||||
@@ -157,17 +238,65 @@ const Login = () => {
|
||||
await Taro.redirectTo({ url: redirectUrl })
|
||||
}
|
||||
|
||||
/** 跳转到短信验证码登录(降级方案) */
|
||||
const goSmsLogin = () => {
|
||||
const params: Record<string, string> = {}
|
||||
if (redirectUrl) params.redirect = redirectUrl
|
||||
// 透传邀请参数,避免从登录页切到短信登录时丢失 inviter
|
||||
const inviteParams = parseInviteParams({ query: router?.params })
|
||||
if (inviteParams?.inviter) params.inviter = inviteParams.inviter
|
||||
if (inviteParams?.source) params.source = inviteParams.source
|
||||
if (inviteParams?.t) params.t = inviteParams.t
|
||||
|
||||
const qs = Object.entries(params)
|
||||
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
|
||||
.join('&')
|
||||
Taro.navigateTo({ url: `/passport/sms-login${qs ? `?${qs}` : ''}` })
|
||||
}
|
||||
|
||||
/** 手机号授权失败时弹窗引导走短信登录(区分原因) */
|
||||
const showPhoneAuthFailedModal = (errMsg?: string) => {
|
||||
let content = '请尝试使用短信验证码登录'
|
||||
if (errMsg?.includes('user deny')) {
|
||||
content = '您已取消微信手机号授权,可改用短信验证码登录'
|
||||
} else if (errMsg?.includes('privacy')) {
|
||||
content = '微信未授权获取手机号,可改用短信验证码登录'
|
||||
} else if (errMsg?.includes('no permission')) {
|
||||
content = '小程序暂未获得手机号授权,可改用短信验证码登录'
|
||||
}
|
||||
Taro.showModal({
|
||||
title: '未获取到手机号',
|
||||
content,
|
||||
confirmText: '短信登录',
|
||||
cancelText: '我知道了',
|
||||
confirmColor: '#07c160',
|
||||
success: (res) => {
|
||||
if (res.confirm) goSmsLogin()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 手机号快捷登录 */
|
||||
const handleGetPhoneNumber = async ({ detail }: GetPhoneNumberEvent) => {
|
||||
clearPhoneAuthPending()
|
||||
|
||||
if (!isAgree) {
|
||||
Taro.showToast({ title: '请先勾选同意协议', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (loading) return
|
||||
if (loading || phoneAuthInFlightRef.current) {
|
||||
startPhoneAuthCooldown()
|
||||
Taro.showToast({ title: '请稍后再试', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
const { code: phoneCode, errMsg } = detail || {}
|
||||
phoneAuthInFlightRef.current = true
|
||||
startPhoneAuthCooldown()
|
||||
|
||||
const { code: phoneCode, encryptedData, iv, errMsg } = detail || {}
|
||||
if (!phoneCode || (errMsg && errMsg.includes('fail'))) {
|
||||
Taro.showToast({ title: '未授权手机号', icon: 'none' })
|
||||
phoneAuthInFlightRef.current = false
|
||||
showPhoneAuthFailedModal(errMsg)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -177,23 +306,28 @@ const Login = () => {
|
||||
const inviteParams = parseInviteParams({ query: router?.params })
|
||||
const refereeId = inviteParams?.inviter ? parseInt(inviteParams.inviter, 10) : 0
|
||||
|
||||
const serverUrl = 'https://server.websoft.top'
|
||||
const res: any = await Taro.request({
|
||||
url: `${serverUrl}/api/wx-login/loginByMpWxPhone`,
|
||||
method: 'POST',
|
||||
data: {
|
||||
const res: any = await request.post(
|
||||
`${SERVER_API_URL}/wx-login/loginByMpWxPhone`,
|
||||
{
|
||||
code: phoneCode,
|
||||
encryptedData,
|
||||
iv,
|
||||
notVerifyPhone: true,
|
||||
refereeId,
|
||||
sceneType: 'save_referee',
|
||||
tenantId: Number(TenantId),
|
||||
},
|
||||
header: { 'content-type': 'application/json', TenantId: String(TenantId) },
|
||||
})
|
||||
{
|
||||
timeout: 20000,
|
||||
retry: 0,
|
||||
showError: false,
|
||||
returnRaw: true,
|
||||
}
|
||||
)
|
||||
|
||||
if (res.data?.code === 0 && res.data?.data?.access_token) {
|
||||
const token = res.data.data.access_token
|
||||
let user = res.data.data.user
|
||||
if (res?.code === 0 && res?.data?.access_token) {
|
||||
const token = res.data.access_token
|
||||
let user = res.data.user
|
||||
|
||||
// 获取最新的用户信息
|
||||
const freshUserInfo = await fetchUserInfo(token)
|
||||
@@ -201,7 +335,21 @@ const Login = () => {
|
||||
user = freshUserInfo
|
||||
}
|
||||
|
||||
// 检查用户是否被禁用
|
||||
if (isUserDisabled(user)) {
|
||||
Taro.showModal({
|
||||
title: '账号已被禁用',
|
||||
content: '您的账号已被管理员禁用,暂时无法使用小程序。如有疑问请联系门店客服。',
|
||||
showCancel: false,
|
||||
confirmText: '我知道了',
|
||||
confirmColor: '#ee0a24',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
saveStorageByLoginUser(token, user)
|
||||
// 同步更新 UserContext 状态,确保后续页面 isLoggedIn 为 true
|
||||
loginUser(token, user)
|
||||
|
||||
// 绑定 openid + 处理邀请关系
|
||||
await ensureWxOpenIdSaved()
|
||||
@@ -212,12 +360,14 @@ const Login = () => {
|
||||
Taro.showToast({ title: '登录成功', icon: 'success' })
|
||||
setTimeout(() => navigateAfterLogin(), 800)
|
||||
} else {
|
||||
Taro.showToast({ title: res.data?.message || '登录失败', icon: 'none' })
|
||||
Taro.showToast({ title: res?.message || '登录失败', icon: 'none' })
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('微信登录失败:', e)
|
||||
Taro.showToast({ title: e?.message || '登录失败', icon: 'none' })
|
||||
} finally {
|
||||
phoneAuthInFlightRef.current = false
|
||||
clearPhoneAuthPending()
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
@@ -257,20 +407,30 @@ const Login = () => {
|
||||
color: '#ffffff',
|
||||
fontWeight: '600',
|
||||
textAlign: 'center',
|
||||
opacity: (!isAgree || loading) ? 0.5 : 1,
|
||||
opacity: canUsePhoneAuth ? 1 : 0.5,
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
}}
|
||||
openType='getPhoneNumber'
|
||||
openType={canUsePhoneAuth ? 'getPhoneNumber' : undefined}
|
||||
onClick={handlePhoneAuthTap}
|
||||
onGetPhoneNumber={handleGetPhoneNumber}
|
||||
disabled={!isAgree || loading}
|
||||
loading={loading}
|
||||
disabled={!canUsePhoneAuth}
|
||||
loading={loading || phoneAuthPending}
|
||||
>
|
||||
手机号快捷登录
|
||||
</Button>
|
||||
|
||||
<View className='login-methods__tip text-gray-200'>
|
||||
<Text>点击上方按钮,快速登录</Text>
|
||||
{/*<View className='login-methods__tip text-gray-200'>*/}
|
||||
{/* <Text>点击上方按钮,快速登录</Text>*/}
|
||||
{/*</View>*/}
|
||||
|
||||
<View className='login-methods__alt'>
|
||||
<Text
|
||||
className='login-methods__alt-link'
|
||||
onClick={() => goSmsLogin()}
|
||||
>
|
||||
使用短信验证码登录
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className='login-methods__divider'>
|
||||
|
||||
5
src/passport/pay/index.config.ts
Normal file
5
src/passport/pay/index.config.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
navigationBarTitleText: '确认支付',
|
||||
navigationBarTextStyle: 'black',
|
||||
navigationBarBackgroundColor: '#ffffff'
|
||||
}
|
||||
398
src/passport/pay/index.tsx
Normal file
398
src/passport/pay/index.tsx
Normal file
@@ -0,0 +1,398 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { View, Text } from '@tarojs/components'
|
||||
import { Button, Cell, Price, Divider } from '@nutui/nutui-react-taro'
|
||||
import { Check, Close, Tips, Clock } from '@nutui/icons-react-taro'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { request } from '@/utils/request'
|
||||
import { getWxOpenId, loginByOpenId } from '@/api/layout'
|
||||
import { saveStorageByLoginUser } from '@/utils/server'
|
||||
import { TenantId } from '@/config/app'
|
||||
|
||||
/**
|
||||
* 小程序支付页面
|
||||
* 接收参数 subscriptionNo,完成 JSAPI 支付
|
||||
*
|
||||
* 重要:判断订单是否已支付,必须用 payStatus(0=未支付 1=已支付),
|
||||
* 不能用 status(订阅生命周期:active/pending/expired/cancelled)。
|
||||
* 续费场景下后端 renewPay 会保留原 status=active、仅设 payStatus=0,
|
||||
* 若用 status 判支付会直接误判为"已支付"。
|
||||
*/
|
||||
interface SubscriptionDetail {
|
||||
id: number
|
||||
subscriptionNo: string
|
||||
productId: number
|
||||
productName: string
|
||||
productLogo?: string
|
||||
productIcon?: string
|
||||
status: string
|
||||
// 支付状态: 0-未支付 1-已支付
|
||||
payStatus?: number
|
||||
payTime?: string
|
||||
transactionId?: string
|
||||
priceType: string
|
||||
payPrice: number
|
||||
subscriptionPeriod?: string
|
||||
}
|
||||
|
||||
interface JsapiPayParams {
|
||||
timeStamp: string
|
||||
nonceStr: string
|
||||
package: string
|
||||
signType: string
|
||||
paySign: string
|
||||
outTradeNo: string
|
||||
}
|
||||
|
||||
const PayPage: React.FC = () => {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [paying, setPaying] = useState(false)
|
||||
const [detail, setDetail] = useState<SubscriptionDetail | null>(null)
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
const [paid, setPaid] = useState(false)
|
||||
|
||||
// 从页面参数中获取 subscriptionNo
|
||||
// 小程序码 scene 会作为 query.scene 传入,需解码
|
||||
const getSubscriptionNo = useCallback(() => {
|
||||
const instance = Taro.getCurrentInstance()
|
||||
const params = instance?.router?.params || {}
|
||||
console.log('[PayPage] ===== 参数调试开始 =====')
|
||||
console.log('[PayPage] 原始 params:', JSON.stringify(params))
|
||||
console.log('[PayPage] params.scene:', params.scene)
|
||||
console.log('[PayPage] params.subscriptionNo:', params.subscriptionNo)
|
||||
// 优先读取 scene(小程序码参数),再读取 subscriptionNo(普通链接参数)
|
||||
const scene = params.scene ? decodeURIComponent(params.scene) : ''
|
||||
const subscriptionNo = scene || params.subscriptionNo || ''
|
||||
console.log('[PayPage] decodeURIComponent(scene):', scene)
|
||||
console.log('[PayPage] 最终 subscriptionNo:', subscriptionNo)
|
||||
console.log('[PayPage] ===== 参数调试结束 =====')
|
||||
return subscriptionNo
|
||||
}, [])
|
||||
|
||||
// 获取订阅详情
|
||||
const fetchDetail = useCallback(async () => {
|
||||
const subscriptionNo = getSubscriptionNo()
|
||||
if (!subscriptionNo) {
|
||||
console.error('[PayPage] subscriptionNo 为空,无法请求')
|
||||
setErrorMsg('缺少订单参数')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[PayPage] ===== 请求调试开始 =====')
|
||||
console.log('[PayPage] 请求 URL:', `/app/subscription/detail-by-no/${subscriptionNo}`)
|
||||
console.log('[PayPage] 当前 token:', Taro.getStorageSync('access_token') || '(无token)')
|
||||
|
||||
try {
|
||||
// request 默认 returnRaw=false,拦截器已拆包,返回的就是 data 部分(订阅对象)
|
||||
const data: any = await request({
|
||||
url: `/app/subscription/detail-by-no/${subscriptionNo}`,
|
||||
method: 'GET',
|
||||
returnRaw: true // 先用 returnRaw=true 拿到完整响应,方便调试
|
||||
})
|
||||
|
||||
console.log('[PayPage] 接口完整返回(returnRaw=true):', JSON.stringify(data))
|
||||
console.log('[PayPage] data.code:', data?.code)
|
||||
console.log('[PayPage] data.message:', data?.message)
|
||||
console.log('[PayPage] data.data:', data?.data)
|
||||
|
||||
// 判断业务状态码
|
||||
if (data?.code === 0 || data?.code === 200) {
|
||||
const subscription = data.data || data
|
||||
console.log('[PayPage] 订阅对象:', JSON.stringify(subscription))
|
||||
console.log('[PayPage] 订阅状态 status:', subscription?.status)
|
||||
console.log('[PayPage] 支付状态 payStatus:', subscription?.payStatus)
|
||||
console.log('[PayPage] 支付时间 payTime:', subscription?.payTime)
|
||||
|
||||
// 关键修复:判断是否已支付,必须用 payStatus 而非 status
|
||||
// 续费场景下后端 renewPay 会保留原 status=active、仅设 payStatus=0
|
||||
const isPaid = subscription?.payStatus === 1
|
||||
const isCancelled = subscription?.status === 'cancelled'
|
||||
const isExpired = subscription?.status === 'expired'
|
||||
|
||||
if (isCancelled) {
|
||||
setErrorMsg('订单已取消,无法继续支付')
|
||||
} else if (isExpired) {
|
||||
setErrorMsg('订单已过期,请重新下单')
|
||||
} else if (isPaid) {
|
||||
setPaid(true)
|
||||
setDetail(subscription)
|
||||
} else {
|
||||
setDetail(subscription)
|
||||
}
|
||||
} else {
|
||||
console.error('[PayPage] 业务错误 code:', data?.code, 'message:', data?.message)
|
||||
setErrorMsg(data?.message || `业务错误(code=${data?.code})`)
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('[PayPage] ===== 请求异常 =====')
|
||||
console.error('[PayPage] err.name:', err?.name)
|
||||
console.error('[PayPage] err.type:', err?.type)
|
||||
console.error('[PayPage] err.code:', err?.code)
|
||||
console.error('[PayPage] err.message:', err?.message)
|
||||
console.error('[PayPage] err.data:', err?.data)
|
||||
console.error('[PayPage] err 完整:', JSON.stringify(err))
|
||||
setErrorMsg(err?.message || '网络异常,请重试')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
console.log('[PayPage] ===== 请求调试结束 =====')
|
||||
}
|
||||
}, [getSubscriptionNo])
|
||||
|
||||
useEffect(() => {
|
||||
fetchDetail()
|
||||
}, [fetchDetail])
|
||||
|
||||
// 执行支付
|
||||
const handlePay = async () => {
|
||||
if (!detail) return
|
||||
|
||||
setPaying(true)
|
||||
try {
|
||||
// 1. 获取微信登录凭证(code)
|
||||
const loginRes = await Taro.login()
|
||||
if (!loginRes.code) {
|
||||
throw new Error('获取登录凭证失败')
|
||||
}
|
||||
|
||||
console.log('[PayPage] Taro.login code:', loginRes.code)
|
||||
|
||||
// 2. 用 code 换取 openid(关键步骤,之前遗漏了)
|
||||
const openIdRes: any = await getWxOpenId({ code: loginRes.code })
|
||||
console.log('[PayPage] getWxOpenId 结果:', openIdRes)
|
||||
|
||||
const openid = openIdRes?.openid || openIdRes
|
||||
if (!openid) {
|
||||
throw new Error(openIdRes?.message || '获取 openid 失败,请重试')
|
||||
}
|
||||
// 缓存 openid,后续支付确认等接口可用
|
||||
Taro.setStorageSync('openid', openid)
|
||||
console.log('[PayPage] 获取到 openid:', openid)
|
||||
|
||||
// 3. 尝试自动登录(获取 token,mp-prepay 接口需要登录态)
|
||||
// 如果用户已在小程序注册过,loginByOpenId 会返回 token
|
||||
// 如果用户未注册,不影响 openid 已获取,但 mp-prepay 可能需要特殊处理
|
||||
try {
|
||||
const loginResult: any = await loginByOpenId({
|
||||
code: loginRes.code,
|
||||
tenantId: TenantId
|
||||
})
|
||||
console.log('[PayPage] loginByOpenId 结果:', loginResult)
|
||||
|
||||
if (loginResult) {
|
||||
// 登录成功,保存 token
|
||||
saveStorageByLoginUser(loginResult.access_token || '', loginResult.user)
|
||||
console.log('[PayPage] 自动登录成功,已保存 token')
|
||||
} else {
|
||||
console.warn('[PayPage] 自动登录未返回数据')
|
||||
}
|
||||
} catch (loginErr: any) {
|
||||
console.warn('[PayPage] 自动登录异常:', loginErr)
|
||||
// 登录异常但不阻断支付,openid 已拿到,尝试继续
|
||||
// 如果后端提示未注册,引导用户去登录
|
||||
if (loginErr?.message?.includes('未注册') || loginErr?.message?.includes('不存在')) {
|
||||
Taro.showModal({
|
||||
title: '需要先登录',
|
||||
content: '支付前需要先注册账号,是否前往登录?',
|
||||
confirmText: '去登录',
|
||||
cancelText: '取消'
|
||||
}).then(modalRes => {
|
||||
if (modalRes.confirm) {
|
||||
Taro.navigateTo({ url: '/passport/login' })
|
||||
}
|
||||
})
|
||||
return // 不继续支付流程
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 请求后端创建 JSAPI 预支付订单
|
||||
const prepayRes: any = await request({
|
||||
url: `/app/subscription/mp-prepay/${detail.id}`,
|
||||
method: 'POST',
|
||||
data: { openid },
|
||||
returnRaw: true
|
||||
})
|
||||
|
||||
console.log('[PayPage] mp-prepay 完整返回:', JSON.stringify(prepayRes))
|
||||
|
||||
if (prepayRes?.code !== 0 && prepayRes?.code !== 200) {
|
||||
throw new Error(prepayRes?.message || '创建支付订单失败')
|
||||
}
|
||||
|
||||
const payParams: JsapiPayParams = prepayRes.data || prepayRes
|
||||
|
||||
if (!payParams.timeStamp || !payParams.package || !payParams.paySign) {
|
||||
throw new Error('支付参数不完整')
|
||||
}
|
||||
|
||||
// 5. 调用微信支付
|
||||
await Taro.requestPayment({
|
||||
timeStamp: payParams.timeStamp,
|
||||
nonceStr: payParams.nonceStr,
|
||||
package: payParams.package,
|
||||
signType: (payParams.signType || 'RSA') as any,
|
||||
paySign: payParams.paySign
|
||||
})
|
||||
|
||||
// 6. 支付成功后通知后端确认
|
||||
try {
|
||||
await request({
|
||||
url: `/app/subscription/mp-confirm/${detail.subscriptionNo}`,
|
||||
method: 'POST',
|
||||
data: {}
|
||||
})
|
||||
} catch (confirmErr) {
|
||||
console.warn('支付确认通知失败,等待轮询:', confirmErr)
|
||||
}
|
||||
|
||||
// 7. 显示成功状态
|
||||
setPaid(true)
|
||||
Taro.showToast({ title: '支付成功', icon: 'success' })
|
||||
|
||||
// 8. 延迟自动跳转到已购产品页面
|
||||
setTimeout(() => {
|
||||
Taro.navigateTo({ url: '/user/apps/index' }).catch(() => {
|
||||
// 如果 navigateTo 失败(比如在 tab 页),回退到用户页
|
||||
Taro.switchTab({ url: '/pages/user/user' })
|
||||
})
|
||||
}, 1500)
|
||||
|
||||
} catch (err: any) {
|
||||
console.error('[PayPage] 支付失败:', err)
|
||||
console.error('[PayPage] err.name:', err?.name, 'err.type:', err?.type)
|
||||
console.error('[PayPage] err.code:', err?.code, 'err.message:', err?.message)
|
||||
|
||||
if (err?.errMsg?.includes('cancel')) {
|
||||
Taro.showToast({ title: '支付已取消', icon: 'none' })
|
||||
} else {
|
||||
const msg = err?.message || err?.errMsg || '支付失败,请重试'
|
||||
Taro.showToast({ title: msg, icon: 'error', duration: 2000 })
|
||||
}
|
||||
} finally {
|
||||
setPaying(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 返回
|
||||
const handleBack = () => {
|
||||
Taro.navigateBack({ delta: 1 }).catch(() => {
|
||||
Taro.switchTab({ url: '/pages/user/user' })
|
||||
})
|
||||
}
|
||||
|
||||
// 格式化价格描述
|
||||
const getPriceDesc = () => {
|
||||
if (!detail) return ''
|
||||
if (detail.priceType === 'one_time') return '永久买断'
|
||||
if (detail.priceType === 'subscription') {
|
||||
return detail.subscriptionPeriod === 'year' ? '按年订阅' : '按月订阅'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
// --- 加载中 ---
|
||||
if (loading) {
|
||||
return (
|
||||
<View className="pay-page min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<View className="text-center">
|
||||
<Clock className="text-blue-500 mb-4" size="48" />
|
||||
<Text className="block text-gray-500">加载订单信息...</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
// --- 错误 ---
|
||||
if (errorMsg) {
|
||||
return (
|
||||
<View className="pay-page min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<View className="text-center p-8">
|
||||
<Close className="text-red-500 mb-4" size="48" />
|
||||
<Text className="block text-gray-800 text-lg mb-2">获取订单失败</Text>
|
||||
<Text className="block text-gray-500 mb-6">{errorMsg}</Text>
|
||||
<Button type="primary" onClick={handleBack}>返回</Button>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
// --- 已支付 ---
|
||||
if (paid) {
|
||||
return (
|
||||
<View className="pay-page min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<View className="text-center p-8">
|
||||
<View className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Check className="text-green-500" size="40" />
|
||||
</View>
|
||||
<Text className="block text-green-600 text-xl font-bold mb-2">支付成功</Text>
|
||||
<Text className="block text-gray-500 mb-2">{detail?.productName || '应用订阅'}</Text>
|
||||
{detail?.payPrice ? (
|
||||
<Text className="block text-gray-400 mb-6">
|
||||
已支付 ¥{Number(detail.payPrice).toFixed(2)}
|
||||
</Text>
|
||||
) : null}
|
||||
<Button type="primary" onClick={handleBack}>完成</Button>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
// --- 支付确认 ---
|
||||
return (
|
||||
<View className="pay-page min-h-screen bg-gray-50">
|
||||
<View className="p-4">
|
||||
{/* 订单信息 */}
|
||||
<View className="bg-white rounded-lg shadow-sm p-4 mb-4">
|
||||
<Text className="block text-lg font-bold text-gray-800 mb-3">确认支付</Text>
|
||||
|
||||
<Cell title="商品名称" description={detail?.productName || '-'} />
|
||||
<Cell title="价格类型" description={getPriceDesc()} />
|
||||
|
||||
<View className="flex items-center justify-between px-4 py-3">
|
||||
<Text className="text-gray-600">支付金额</Text>
|
||||
<Price
|
||||
price={detail?.payPrice}
|
||||
size="large"
|
||||
thousands
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Divider />
|
||||
|
||||
<View className="flex items-start px-4 py-2">
|
||||
<Tips className="text-orange-500 mr-2 mt-1" size="16" />
|
||||
<Text className="text-sm text-gray-500">
|
||||
支付成功后即可在「已购产品」中使用该应用
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 支付按钮 */}
|
||||
<View className="fixed bottom-0 left-0 right-0 p-4 bg-white border-t border-gray-100">
|
||||
<View className="flex gap-3">
|
||||
<Button
|
||||
block
|
||||
onClick={handleBack}
|
||||
className="flex-1"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
block
|
||||
loading={paying}
|
||||
onClick={handlePay}
|
||||
className="flex-1"
|
||||
>
|
||||
{paying ? '支付中...' : '立即支付'}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 底部安全距离占位 */}
|
||||
<View style={{ height: '100px' }} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default PayPage
|
||||
@@ -5,6 +5,8 @@ import { Button, Checkbox } from '@nutui/nutui-react-taro'
|
||||
import { TenantId } from '@/config/app'
|
||||
import { getUserInfo, getWxOpenId } from '@/api/layout'
|
||||
import { saveStorageByLoginUser } from '@/utils/server'
|
||||
import { isUserDisabled } from '@/utils/auth'
|
||||
import { useUserContext } from '@/contexts/UserContext'
|
||||
import {
|
||||
getStoredInviteParams,
|
||||
parseInviteParams,
|
||||
@@ -88,15 +90,16 @@ function isTabBarUrl(url: string) {
|
||||
const pure = url.split('?')[0]
|
||||
return (
|
||||
pure === '/pages/index/index' ||
|
||||
pure === '/pages/cart/cart' ||
|
||||
pure === '/pages/user/user' ||
|
||||
pure === '/pages/category/index'
|
||||
pure === '/pages/shop/index' ||
|
||||
pure === '/pages/shop/cart' ||
|
||||
pure === '/pages/user/user'
|
||||
)
|
||||
}
|
||||
|
||||
const Register = () => {
|
||||
const [isAgree, setIsAgree] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { loginUser } = useUserContext()
|
||||
|
||||
// 短信验证码登录仅在非微信小程序端展示
|
||||
const isWeapp = useMemo(() => {
|
||||
@@ -109,10 +112,6 @@ const Register = () => {
|
||||
|
||||
const router = Taro.getCurrentInstance().router
|
||||
|
||||
useEffect(() => {
|
||||
// register 页面不是 tabBar 页面,无需调用 hideTabBar
|
||||
}, [])
|
||||
|
||||
const redirectUrl = useMemo(() => {
|
||||
const raw = (router?.params as any)?.redirect as string | undefined
|
||||
const decoded = safeDecodeMaybeEncoded(raw)
|
||||
@@ -149,6 +148,28 @@ const Register = () => {
|
||||
await Taro.redirectTo({ url: redirectUrl })
|
||||
}
|
||||
|
||||
/** 手机号授权失败时弹窗引导走短信注册/登录(区分原因) */
|
||||
const showPhoneAuthFailedModal = (errMsg?: string) => {
|
||||
let content = '请尝试使用短信验证码注册/登录'
|
||||
if (errMsg?.includes('user deny')) {
|
||||
content = '您已取消微信手机号授权,可改用短信验证码注册/登录'
|
||||
} else if (errMsg?.includes('privacy')) {
|
||||
content = '微信未授权获取手机号,可改用短信验证码注册/登录'
|
||||
} else if (errMsg?.includes('no permission')) {
|
||||
content = '小程序暂未获得手机号授权,可改用短信验证码注册/登录'
|
||||
}
|
||||
Taro.showModal({
|
||||
title: '未获取到手机号',
|
||||
content,
|
||||
confirmText: '短信注册/登录',
|
||||
cancelText: '我知道了',
|
||||
confirmColor: '#07c160',
|
||||
success: (res) => {
|
||||
if (res.confirm) goSmsLogin()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleGetPhoneNumber = async ({ detail }: GetPhoneNumberEvent) => {
|
||||
if (!isAgree) {
|
||||
Taro.showToast({ title: '请先勾选同意协议', icon: 'none' })
|
||||
@@ -158,7 +179,7 @@ const Register = () => {
|
||||
|
||||
const { code: phoneCode, encryptedData, iv, errMsg } = detail || {}
|
||||
if (!phoneCode || (errMsg && errMsg.includes('fail'))) {
|
||||
Taro.showToast({ title: '未授权手机号', icon: 'none' })
|
||||
showPhoneAuthFailedModal(errMsg)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -202,7 +223,21 @@ const Register = () => {
|
||||
return
|
||||
}
|
||||
|
||||
// 检查用户是否被禁用
|
||||
if (isUserDisabled(user)) {
|
||||
Taro.showModal({
|
||||
title: '账号已被禁用',
|
||||
content: '您的账号已被管理员禁用,暂时无法使用小程序。如有疑问请联系门店客服。',
|
||||
showCancel: false,
|
||||
confirmText: '我知道了',
|
||||
confirmColor: '#ee0a24',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
saveStorageByLoginUser(token, user)
|
||||
// 同步更新 UserContext 状态,确保后续页面 isLoggedIn 为 true
|
||||
loginUser(token, user)
|
||||
|
||||
// 注册/登录成功后,立即补齐 openid(JSAPI 支付必需)
|
||||
try {
|
||||
@@ -268,11 +303,9 @@ const Register = () => {
|
||||
手机号一键注册/登录
|
||||
</Button>
|
||||
|
||||
{!isWeapp && (
|
||||
<Button type='default' block disabled={!isAgree || loading} onClick={goSmsLogin}>
|
||||
短信验证码注册/登录
|
||||
</Button>
|
||||
)}
|
||||
<Button type='default' block disabled={!isAgree || loading} onClick={goSmsLogin}>
|
||||
短信验证码注册/登录
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<View className='mt-6 flex text-sm items-center px-1'>
|
||||
|
||||
332
src/passport/sms-login.scss
Normal file
332
src/passport/sms-login.scss
Normal file
@@ -0,0 +1,332 @@
|
||||
// 页面容器
|
||||
.page-sms-login {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// 背景装饰
|
||||
.sms-login-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sms-login-bg__gradient {
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
right: -50%;
|
||||
bottom: -50%;
|
||||
background: radial-gradient(circle at 30% 50%, rgba(255, 255, 255, 0.1) 0%, transparent 50%),
|
||||
radial-gradient(circle at 70% 80%, rgba(255, 255, 255, 0.08) 0%, transparent 50%);
|
||||
animation: gradientMove 15s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes gradientMove {
|
||||
0%, 100% {
|
||||
transform: translate(0, 0) rotate(0deg);
|
||||
}
|
||||
33% {
|
||||
transform: translate(30px, -30px) rotate(120deg);
|
||||
}
|
||||
66% {
|
||||
transform: translate(-20px, 20px) rotate(240deg);
|
||||
}
|
||||
}
|
||||
|
||||
.sms-login-bg__circle {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
opacity: 0.1;
|
||||
background: #ffffff;
|
||||
animation: float 20s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.sms-login-bg__circle--1 {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
top: -150px;
|
||||
right: -100px;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.sms-login-bg__circle--2 {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
bottom: 10%;
|
||||
left: -100px;
|
||||
animation-delay: -7s;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
33% {
|
||||
transform: translate(30px, -30px) scale(1.1);
|
||||
}
|
||||
66% {
|
||||
transform: translate(-20px, 20px) scale(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
// 内容区域
|
||||
.sms-login-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
padding: 0 50px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
// 头部 Logo 和标题
|
||||
.sms-login-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 120px;
|
||||
margin-bottom: 60px;
|
||||
animation: slideDown 0.8s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.sms-login-logo {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
border-radius: 35px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 36px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
animation: logoFloat 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes logoFloat {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
}
|
||||
|
||||
.sms-login-logo__icon {
|
||||
font-size: 72px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.sms-login-title {
|
||||
font-size: 52px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
margin-bottom: 18px;
|
||||
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.sms-login-subtitle {
|
||||
font-size: 26px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
// 表单卡片
|
||||
.sms-login-card {
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
border-radius: 28px;
|
||||
padding: 12px 32px;
|
||||
margin-bottom: 48px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.18);
|
||||
animation: slideUp 0.6s ease-out 0.2s both;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.sms-login-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 110px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sms-login-field--code {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sms-login-field__icon-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sms-login-field__icon {
|
||||
font-size: 36px;
|
||||
margin-right: 20px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sms-login-field__input {
|
||||
flex: 1;
|
||||
height: 110px;
|
||||
font-size: 30px;
|
||||
color: #333333;
|
||||
letter-spacing: 1px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
padding: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sms-login-field__placeholder {
|
||||
color: #b8b8b8;
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.sms-login-divider {
|
||||
height: 1px;
|
||||
background: linear-gradient(to right, transparent, #e8e8e8 20%, #e8e8e8 80%, transparent);
|
||||
margin: 0 -8px;
|
||||
}
|
||||
|
||||
// 发送验证码按钮
|
||||
.sms-login-send {
|
||||
flex-shrink: 0;
|
||||
height: 64px;
|
||||
padding: 0 22px;
|
||||
border-radius: 32px;
|
||||
background: linear-gradient(135deg, #07c160, #06ad56);
|
||||
color: #ffffff;
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: 16px;
|
||||
box-shadow: 0 6px 20px rgba(7, 193, 96, 0.35);
|
||||
letter-spacing: 0.5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sms-login-send--disabled {
|
||||
background: #e0e0e0;
|
||||
color: #999999;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
// 登录按钮
|
||||
.sms-login-submit {
|
||||
height: 100px;
|
||||
width: 100%;
|
||||
border-radius: 50px;
|
||||
background: linear-gradient(135deg, #07c160, #06ad56);
|
||||
color: #ffffff;
|
||||
font-size: 34px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
letter-spacing: 4px;
|
||||
box-shadow: 0 12px 36px rgba(7, 193, 96, 0.4);
|
||||
margin-bottom: 36px;
|
||||
animation: slideUp 0.6s ease-out 0.35s both;
|
||||
}
|
||||
|
||||
.sms-login-submit--disabled {
|
||||
background: #b8e0c5;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
box-shadow: 0 4px 12px rgba(7, 193, 96, 0.15);
|
||||
}
|
||||
|
||||
.sms-login-submit--hover {
|
||||
transform: scale(0.98);
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
// 返回链接
|
||||
.sms-login-back {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 80px;
|
||||
margin-bottom: 32px;
|
||||
animation: fadeIn 0.8s ease-out 0.5s both;
|
||||
}
|
||||
|
||||
.sms-login-back__icon {
|
||||
font-size: 30px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
margin-right: 10px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.sms-login-back__text {
|
||||
font-size: 28px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
text-decoration: underline;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
// 底部协议
|
||||
.sms-login-tips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0 40px;
|
||||
margin-top: auto;
|
||||
padding-bottom: 60px;
|
||||
animation: fadeIn 0.8s ease-out 0.6s both;
|
||||
}
|
||||
|
||||
.sms-login-tips__text {
|
||||
font-size: 22px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.sms-login-tips__link {
|
||||
font-size: 22px;
|
||||
color: #ffffff;
|
||||
text-decoration: underline;
|
||||
margin: 0 4px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
import {useEffect, useState} from "react";
|
||||
import Taro from '@tarojs/taro'
|
||||
import { View } from '@tarojs/components'
|
||||
import {Input, Button} from '@nutui/nutui-react-taro'
|
||||
import { View, Text, Input } from '@tarojs/components'
|
||||
import {loginBySms, sendSmsCaptcha} from "@/api/passport/login";
|
||||
import {LoginParam} from "@/api/passport/login/model";
|
||||
import {checkAndHandleInviteRelation, hasPendingInvite, parseInviteParams, saveInviteParams, trackInviteSource} from "@/utils/invite";
|
||||
import { useUserContext } from '@/contexts/UserContext'
|
||||
import './sms-login.scss'
|
||||
|
||||
const SmsLogin = () => {
|
||||
const [loading, setLoading] = useState<boolean>(false)
|
||||
const [sendingCode, setSendingCode] = useState<boolean>(false)
|
||||
const [countdown, setCountdown] = useState<number>(0)
|
||||
const { syncFromStorage } = useUserContext()
|
||||
const [formData, setFormData] = useState<LoginParam>({
|
||||
phone: '',
|
||||
code: ''
|
||||
@@ -37,9 +39,9 @@ const SmsLogin = () => {
|
||||
const pure = url.split('?')[0]
|
||||
return (
|
||||
pure === '/pages/index/index' ||
|
||||
pure === '/pages/cart/cart' ||
|
||||
pure === '/pages/user/user' ||
|
||||
pure === '/pages/category/index'
|
||||
pure === '/pages/shop/index' ||
|
||||
pure === '/pages/shop/cart' ||
|
||||
pure === '/pages/user/user'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -55,12 +57,8 @@ const SmsLogin = () => {
|
||||
await Taro.redirectTo({ url: redirectUrl })
|
||||
}
|
||||
|
||||
const reload = () => {
|
||||
// sms-login 页面不是 tabBar 页面,无需调用 hideTabBar
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
reload()
|
||||
// sms-login 页面不是 tabBar 页面,无需调用 hideTabBar
|
||||
}, [])
|
||||
|
||||
// 如果从分享/二维码链接进入短信登录页,先暂存邀请信息
|
||||
@@ -78,7 +76,7 @@ const SmsLogin = () => {
|
||||
|
||||
// 倒计时效果
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
if (countdown > 0) {
|
||||
timer = setTimeout(() => {
|
||||
setCountdown(countdown - 1)
|
||||
@@ -186,6 +184,9 @@ const SmsLogin = () => {
|
||||
code: formData.code
|
||||
})
|
||||
|
||||
// loginBySms 内部已调 saveStorageByLoginUser,这里同步 UserContext 状态
|
||||
syncFromStorage()
|
||||
|
||||
// 登录成功后(可能是新注册用户),检查是否存在待处理的邀请关系并尝试绑定
|
||||
if (hasPendingInvite()) {
|
||||
try {
|
||||
@@ -218,52 +219,114 @@ const SmsLogin = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const phoneValid = validatePhone(formData.phone)
|
||||
const codeValid = formData.code.length === 6
|
||||
const canSubmit = phoneValid && codeValid && !loading
|
||||
const sendDisabled = !phoneValid || sendingCode || countdown > 0
|
||||
|
||||
const sendBtnText = countdown > 0
|
||||
? `${countdown}s 后重试`
|
||||
: sendingCode
|
||||
? '发送中'
|
||||
: '获取验证码'
|
||||
|
||||
return (
|
||||
<>
|
||||
<View className='flex flex-col justify-center px-5 pt-3'>
|
||||
<View className='flex flex-col justify-between items-center my-2'>
|
||||
<Input
|
||||
type='number'
|
||||
placeholder='请输入手机号码'
|
||||
maxLength={11}
|
||||
value={formData.phone}
|
||||
onChange={(value) => setFormData({...formData, phone: value})}
|
||||
style={{backgroundColor: '#ffffff', borderRadius: '8px'}}
|
||||
/>
|
||||
<View className='page-sms-login'>
|
||||
{/* 渐变背景 */}
|
||||
<View className='sms-login-bg'>
|
||||
<View className='sms-login-bg__gradient' />
|
||||
<View className='sms-login-bg__circle sms-login-bg__circle--1' />
|
||||
<View className='sms-login-bg__circle sms-login-bg__circle--2' />
|
||||
</View>
|
||||
|
||||
<View className='sms-login-content'>
|
||||
{/* 头部 Logo 和标题 */}
|
||||
<View className='sms-login-header'>
|
||||
<View className='sms-login-logo'>
|
||||
<Text className='sms-login-logo__icon'>📱</Text>
|
||||
</View>
|
||||
<Text className='sms-login-title'>验证码登录</Text>
|
||||
<Text className='sms-login-subtitle'>未注册的手机号验证后将自动注册</Text>
|
||||
</View>
|
||||
<View className='flex justify-between items-center bg-white rounded-lg my-2 pr-2'>
|
||||
<Input
|
||||
type='number'
|
||||
placeholder='请输入6位验证码'
|
||||
maxLength={6}
|
||||
value={formData.code}
|
||||
onChange={(value) => setFormData({...formData, code: value})}
|
||||
style={{ backgroundColor: '#ffffff', borderRadius: '8px'}}
|
||||
/>
|
||||
<Button
|
||||
size='small'
|
||||
type={countdown > 0 ? "default" : "primary"}
|
||||
loading={sendingCode}
|
||||
disabled={sendingCode || countdown > 0}
|
||||
onClick={handleSendCode}
|
||||
>
|
||||
{countdown > 0 ? `${countdown}s` : sendingCode ? '发送中...' : '获取验证码'}
|
||||
</Button>
|
||||
|
||||
{/* 表单卡片 */}
|
||||
<View className='sms-login-card'>
|
||||
{/* 手机号 */}
|
||||
<View className='sms-login-field'>
|
||||
<Text className='sms-login-field__icon'>📞</Text>
|
||||
<Input
|
||||
className='sms-login-field__input'
|
||||
type='number'
|
||||
placeholder='请输入手机号码'
|
||||
placeholderClass='sms-login-field__placeholder'
|
||||
maxLength={11}
|
||||
value={formData.phone}
|
||||
onInput={(e: any) => setFormData({ ...formData, phone: e.detail.value })}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className='sms-login-divider' />
|
||||
|
||||
{/* 验证码 */}
|
||||
<View className='sms-login-field sms-login-field--code'>
|
||||
<View className='sms-login-field__icon-wrap'>
|
||||
<Text className='sms-login-field__icon'>🔐</Text>
|
||||
<Input
|
||||
className='sms-login-field__input'
|
||||
type='number'
|
||||
placeholder='请输入6位验证码'
|
||||
placeholderClass='sms-login-field__placeholder'
|
||||
maxLength={6}
|
||||
value={formData.code}
|
||||
onInput={(e: any) => setFormData({ ...formData, code: e.detail.value })}
|
||||
/>
|
||||
</View>
|
||||
<View
|
||||
className={`sms-login-send ${sendDisabled ? 'sms-login-send--disabled' : ''}`}
|
||||
onClick={handleSendCode}
|
||||
>
|
||||
{sendBtnText}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<View className='flex justify-center my-5'>
|
||||
<Button
|
||||
type='info'
|
||||
size='large'
|
||||
className='w-full rounded-lg p-2'
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
onClick={handleLogin}
|
||||
|
||||
{/* 登录按钮 */}
|
||||
<View
|
||||
className={`sms-login-submit ${canSubmit ? '' : 'sms-login-submit--disabled'}`}
|
||||
hoverClass={canSubmit ? 'sms-login-submit--hover' : 'none'}
|
||||
onClick={canSubmit ? handleLogin : undefined}
|
||||
>
|
||||
{loading ? '登录中...' : '登录 / 注册'}
|
||||
</View>
|
||||
|
||||
{/* 返回微信登录 */}
|
||||
<View
|
||||
className='sms-login-back'
|
||||
onClick={() => Taro.navigateBack({ delta: 1 })}
|
||||
>
|
||||
<Text className='sms-login-back__icon'>←</Text>
|
||||
<Text className='sms-login-back__text'>返回微信快捷登录</Text>
|
||||
</View>
|
||||
|
||||
{/* 协议 */}
|
||||
<View className='sms-login-tips'>
|
||||
<Text className='sms-login-tips__text'>登录即代表您已阅读并同意</Text>
|
||||
<Text
|
||||
className='sms-login-tips__link'
|
||||
onClick={() => Taro.navigateTo({ url: '/passport/agreement?type=terms' })}
|
||||
>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</Button>
|
||||
《服务协议》
|
||||
</Text>
|
||||
<Text className='sms-login-tips__text'>和</Text>
|
||||
<Text
|
||||
className='sms-login-tips__link'
|
||||
onClick={() => Taro.navigateTo({ url: '/passport/agreement?type=privacy' })}
|
||||
>
|
||||
《隐私政策》
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
export default SmsLogin
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Taro from '@tarojs/taro'
|
||||
import { clearStorageByLoginUser } from '@/utils/server'
|
||||
import type { User } from '@/api/system/user/model'
|
||||
|
||||
/**
|
||||
* 登录态 / 游客态 标识。
|
||||
@@ -34,3 +35,31 @@ export function ensureLoggedIn(redirect?: string): boolean {
|
||||
export function logout() {
|
||||
clearStorageByLoginUser()
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断用户是否被禁用(status === 1)
|
||||
*/
|
||||
export function isUserDisabled(user?: User | null): boolean {
|
||||
return user?.status === 1
|
||||
}
|
||||
|
||||
/**
|
||||
* 拦截被禁用的用户:清除登录态、弹窗提示、跳转登录页。
|
||||
* 返回 false 表示用户已被禁用并已处理,调用方应中止后续流程。
|
||||
*/
|
||||
export function blockDisabledUser(user?: User | null): boolean {
|
||||
if (!isUserDisabled(user)) return true
|
||||
|
||||
clearStorageByLoginUser()
|
||||
Taro.showModal({
|
||||
title: '账号已被禁用',
|
||||
content: '您的账号已被管理员禁用,暂时无法使用小程序。如有疑问请联系门店客服。',
|
||||
showCancel: false,
|
||||
confirmText: '我知道了',
|
||||
confirmColor: '#ee0a24',
|
||||
success: () => {
|
||||
Taro.reLaunch({ url: '/passport/login' })
|
||||
},
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
35
src/utils/phoneAuth.ts
Normal file
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
|
||||
}
|
||||
53
src/utils/privacy.ts
Normal file
53
src/utils/privacy.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 微信隐私协议授权全局管理器
|
||||
* 用于在基础库 3.16.1+ 下处理 onNeedPrivacyAuthorization 回调。
|
||||
* 必须在页面中渲染 <PrivacyModal /> 并绑定本管理器。
|
||||
*/
|
||||
|
||||
type PrivacyResolve = (result: { event: 'agree' | 'disagree'; buttonId: string }) => void
|
||||
|
||||
let currentResolve: PrivacyResolve | null = null
|
||||
let showCallback: ((show: boolean) => void) | null = null
|
||||
let privacyContractName = '《用户隐私保护指引》'
|
||||
|
||||
export const privacyManager = {
|
||||
/** 设置弹窗显示/隐藏回调 */
|
||||
setShowCallback(cb: (show: boolean) => void) {
|
||||
showCallback = cb
|
||||
},
|
||||
|
||||
/** 设置隐私协议名称(从 getPrivacySetting 读取) */
|
||||
setPrivacyContractName(name: string) {
|
||||
privacyContractName = name || '《用户隐私保护指引》'
|
||||
},
|
||||
|
||||
getPrivacyContractName() {
|
||||
return privacyContractName
|
||||
},
|
||||
|
||||
/** 展示隐私协议授权弹窗 */
|
||||
show(resolve: PrivacyResolve) {
|
||||
currentResolve = resolve
|
||||
showCallback?.(true)
|
||||
},
|
||||
|
||||
/** 隐私协议授权流程已结束,隐藏弹窗 */
|
||||
hide() {
|
||||
currentResolve = null
|
||||
showCallback?.(false)
|
||||
},
|
||||
|
||||
/** 用户点击同意(由 open-type=agreePrivacyAuthorization 的 button 触发) */
|
||||
agree() {
|
||||
currentResolve?.({ event: 'agree', buttonId: 'privacy-agree-btn' })
|
||||
currentResolve = null
|
||||
showCallback?.(false)
|
||||
},
|
||||
|
||||
/** 用户点击拒绝 */
|
||||
disagree() {
|
||||
currentResolve?.({ event: 'disagree', buttonId: 'privacy-disagree-btn' })
|
||||
currentResolve = null
|
||||
showCallback?.(false)
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user