Compare commits
41 Commits
货到付款版
...
8d46016d8b
| Author | SHA1 | Date | |
|---|---|---|---|
| 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` 判断(店员→订单管理,普通用户→消息通知页),无需修改
|
||||
@@ -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`(已应用到工作区,未提交)。
|
||||
|
||||
@@ -4,14 +4,26 @@
|
||||
- 项目名: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)
|
||||
- 门店店员身份判断:通过 `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 +39,7 @@
|
||||
- 门店中心:`src/pages/store/center/index.tsx`
|
||||
- 购物车 API:`src/api/shop/shopCart/index.ts`(批量删除接口 `/shop/shop-cart/batch` 后端期望直接接收 `ArrayList<Integer>` 即 `[1,2,3]`,不要用 `{ ids }` 包裹)
|
||||
- 购物车 Context:`src/contexts/CartContext.tsx`
|
||||
- 购物车合并加购:后端 `ShopCartServiceImpl.addToCart()` 按 user_id+goods_id+sku_id 判重,已存在则累加 cart_num,不存在才新增;skuId 为 null/0(单规格)匹配 IS NULL 或 =0。`ShopCart.userId` 是 Integer(非 Long)。历史重复数据清理脚本 `sql/shop_cart_merge_duplicates.sql`
|
||||
|
||||
## 订阅消息
|
||||
- 模板 ID:`sh1K9iK7vZjebUNFu6OsMsnsJxm4whThWGrhN7I4zVg`(交易提醒)
|
||||
@@ -34,6 +47,28 @@
|
||||
- 场景说明:下单提醒
|
||||
- 新订单检测 Hook:`src/hooks/useNewOrderDetector.ts`(30秒轮询,页面隐藏暂停)
|
||||
|
||||
## 微信隐私协议(基础库 3.16.1+ 强制)
|
||||
- 报错特征:`chooseImage:fail api scope is not declared in the privacy agreement` / `getPhoneNumber:fail ... privacy ...` + `errno:112`
|
||||
- 代码修复:
|
||||
1. 调用 `Taro.chooseImage` / `chooseMedia` / `getPhoneNumber` 等敏感 API 前,先 `getPrivacySetting` → `requirePrivacyAuthorize` 预检(封装在 `src/api/system/file/index.ts` 的 `ensurePrivacyAuthorized()` 里,已 export)。**注意**:不应在登录页 `useDidShow` 中主动预检,避免用户一进入登录页就弹框;应由微信在用户点击 `getPhoneNumber` 时自行触发 `onNeedPrivacyAuthorization`。
|
||||
2. `src/app.tsx` 的 `useLaunch` 中注册 `Taro.onNeedPrivacyAuthorization` 回调,并通过 `src/components/PrivacyModal` 展示隐私协议授权弹窗。弹窗内的 Button 必须设置 `open-type="agreePrivacyAuthorization"`,用户点击后触发 `onAgreePrivacyAuthorization` 再 `resolve({ event: 'agree', button: 'agree' })`;`Taro.showModal` 的按钮无法被微信识别为有效的隐私授权
|
||||
3. 门店上传图片页面在调用 `chooseImage` 前主动预检:`src/pages/store/orders/index.tsx` 的 `chooseProofImage` 已加 `ensurePrivacyAuthorized()`;`src/pages/store/goods/index.tsx` 通过 `uploadFile()` 上传,`uploadFile()` 内部已预检。
|
||||
- 手动配置:**小程序管理后台** → 设置 → 第三方设置 → 用户隐私保护指引 → 添加「开发者收集你的相册/摄像头」和「手机号」声明
|
||||
- ⚠️ **`requiredPrivateInfos` 字段只接受位置类 API 白名单**(chooseAddress/chooseLocation/choosePoi/getFuzzyLocation/getLocation/onLocationChange/startLocationUpdate/startLocationUpdateBackground),不要加 `chooseImage`/`chooseMedia`/`getPhoneNumber` 等非位置类,会导致 app.json 解析失败
|
||||
- 直接调 `Taro.chooseImage` 的页面:`pages/order/evaluate/index.tsx`、`pages/after-sale/apply/index.tsx`、`pages/store/orders/index.tsx` —— 这些页面绕过了 `uploadFile`,需要单独加 `ensurePrivacyAuthorized` 预检
|
||||
- 直接调 `getPhoneNumber` 的页面:`passport/login.tsx`、`passport/register.tsx` —— 不再主动预检,依赖微信点击按钮时自动触发 `onNeedPrivacyAuthorization`;保留降级短信登录
|
||||
- 隐私授权弹窗组件:`src/components/PrivacyModal/index.tsx`(全局单例,通过 `src/utils/privacy.ts` 管理显隐与 resolve)
|
||||
|
||||
## 手机号授权登录降级方案(2026-07-15 ~ 07-16 修复)
|
||||
- **业务现实**:`getPhoneNumber` 按钮被用户拒绝后,微信会短期"记住"用户的选择,再次点击会直接 fail(`errMsg: getPhoneNumber:fail user deny`)。一旦进入这个状态,前端必须提供降级入口
|
||||
- **统一模式**:`passport/login.tsx` 和 `passport/register.tsx` 都有 `getPhoneNumber` 回调,**不允许**只 toast 一句"未授权手机号"就完事
|
||||
- 必做:失败时调 `showPhoneAuthFailedModal(errMsg)`,按 errMsg 区分场景弹窗(user deny / privacy / no permission / 通用)
|
||||
- 弹窗带"短信登录"按钮自动跳 `/passport/sms-login`(透传 redirect + 邀请参数 inviter/source/t)
|
||||
- 必做:主登录按钮**长期可见**地提供"使用短信验证码登录"链接,不要依赖弹窗
|
||||
- **接口地址统一**:login/register 的手机号登录接口统一使用 `SERVER_API_URL + '/wx-login/loginByMpWxPhone'`(register 之前误写 `https://shop-api.websoft.top`)
|
||||
- **短信登录页**:`/passport/sms-login`(路由表里已有),调 `loginBySms` + `sendSmsCaptcha` 接口
|
||||
- **已落地页面**:`passport/login.tsx`、`passport/register.tsx`(隐私协议预检 + 统一 request + 降级弹窗)
|
||||
|
||||
## 分享 / 朋友圈能力(2026-07-13 接入)
|
||||
- 统一封装:`src/hooks/useShare.ts`(`useShare({title, path?, query?, imageUrl?, enableTimeline?, enableCopyUrl?})`,一次注册 `useShareAppMessage` + `useShareTimeline` + 可选 `onCopyUrl` 复制链接;自动在 path/query 追加 `inviter=${当前用户id}` 做裂变归因;`isTimelineSinglePage()` 判断朋友圈单页模式 scene===1154)
|
||||
- 海报组件:`src/components/SharePoster/index.tsx`(Canvas 2D 绘制封面+标题+价格+小程序码,返回临时图路径作 imageUrl)
|
||||
@@ -41,3 +76,28 @@
|
||||
- 裂变约定:分享链接 query 用 `inviter`,与 `src/utils/invite.ts` 的 `parseInviteParams` 读取字段一致
|
||||
- 已接入页面:商品详情、积分商品、拼团、秒杀、活动、赛事(带 canvas 海报);邀请下级、分销推广、升级VIP、分享赚佣金(仅 enableShare + useShare,无海报);首页(好友+朋友圈+复制链接三按钮全亮)
|
||||
- 注意:tabBar 页**也支持朋友圈分享**(之前误判已纠正)——只要 config 开 enableShareTimeline + 定义 onShareTimeline 即显示按钮;"tabBar 不渲染"仅指朋友圈打开后的单页模式里底部 tabBar 不显示。朋友圈/复制链接开发者工具可能看不到入口,必须真机测试。复制链接是 Beta(基础库 2.14.3+)。
|
||||
|
||||
## 订单备注字段约定(2026-07-15)
|
||||
- `buyerRemarks` — 买家备注:用户下单时填写的订单备注(checkout 页提交)
|
||||
- `merchantRemarks` — 商户备注:门店修改金额时填写的原因记录
|
||||
- `comments` — 系统/其他备注:保留给系统内部使用
|
||||
- `ShopOrder` 和 `OrderCreateRequest` 接口均已包含 `buyerRemarks` 和 `merchantRemarks` 字段
|
||||
|
||||
## 用户浏览记录功能(2026-07-15)
|
||||
- 存储模式:**去重累加**(同一用户+商品只留一条,visit_count 累加,5分钟内去重)
|
||||
- 数据库表:`shop_goods_browse`(字段:id/user_id/goods_id/tenant_id/merchant_id/visit_count/last_visit_time/browse_source/create_time/update_time)
|
||||
- 唯一索引:`uk_user_goods (user_id, goods_id)`;查询索引:`idx_user_time`、`idx_goods_time`
|
||||
- 后端 API 路径:`/api/shop/shop-goods-browse`(POST 上报 / GET page 我的足迹 / DELETE {id} 删单条 / DELETE 清空 / GET admin/page 后台查询)
|
||||
- 前端 API:`src/api/shop/shopGoodsBrowse/index.ts`(reportBrowse 静默上报 / pageBrowseHistory / deleteBrowseRecord / clearBrowseHistory)
|
||||
- 商品详情页 `product-detail.tsx` 的 `addToHistory()` 已接入:已登录时异步调 reportBrowse,未登录仅本地 browse_history
|
||||
- 浏览历史页 `src/pages/user/history-list/index.tsx` 已改为服务端分页(登录),未登录回退本地存储
|
||||
- 后端文件:`ShopGoodsBrowse.java`(entity) / `ShopGoodsBrowseParam.java` / `ShopGoodsBrowseMapper.java`+XML / `ShopGoodsBrowseService.java`+Impl / `ShopGoodsBrowseController.java`
|
||||
- 建表 SQL:`/Users/gxwebsoft/JAVA/guilixu-java/sql/shop_goods_browse.sql`
|
||||
- 后台管理前端:`/Users/gxwebsoft/VUE/guilixu-admin`(Vue3 + Ant Design Vue + Vite,非 RuoYi)
|
||||
- 注意:`BaseController.success()` 有 `success(IPage<T>)` 重载,泛型方法返回类型用 `ApiResult<?>` 而非 `ApiResult<PageResult<T>>`(否则 fail() 分支类型不兼容)
|
||||
|
||||
## tabBar 配置(2026-07-15)
|
||||
- 当前 4 个 tabBar:`/pages/index/index`(首页)、`/pages/shop/index`(分类)、`/pages/shop/cart`(购物车)、`/pages/user/user`(我的)
|
||||
- 「订单」(pages/order/list) 已从 tabBar 移除,改为普通页,所有进入订单列表的地方用 `navigateTo` 而非 `switchTab`
|
||||
- 多处 tabBar 判断列表(login/register/sms-login/auth-flow/user/index/index/index)已统一为实际 4 个 tabBar 路径
|
||||
- ⚠️ pages/user/index.tsx:140 仍用 `switchTab(points/index)`,积分页非 tabBar,待修
|
||||
|
||||
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>
|
||||
90
login_recover.patch
Normal file
90
login_recover.patch
Normal file
@@ -0,0 +1,90 @@
|
||||
--- a/src/passport/login.tsx 2026-07-16 15:54:54.000000000 +0800
|
||||
+++ b/src/passport/login.tsx 2026-07-16 15:53:09.000000000 +0800
|
||||
@@ -4,9 +4,8 @@
|
||||
import { TenantId } from '@/config/app'
|
||||
import { getWxOpenId } from '@/api/layout'
|
||||
import { getUserInfo } from '@/api/layout'
|
||||
-import { saveStorageByLoginUser, SERVER_API_URL } from '@/utils/server'
|
||||
+import { saveStorageByLoginUser } from '@/utils/server'
|
||||
import { isUserDisabled } from '@/utils/auth'
|
||||
-import request from '@/utils/request'
|
||||
import {
|
||||
checkAndHandleInviteRelation,
|
||||
hasPendingInvite,
|
||||
@@ -73,21 +72,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
-/** 获取用户信息(使用临时 token,避免本地 storage 尚未写入) */
|
||||
+/** 获取用户信息 */
|
||||
async function fetchUserInfo(token: string) {
|
||||
try {
|
||||
- const res: any = await request.get(
|
||||
- `${SERVER_API_URL}/auth/user`,
|
||||
- {},
|
||||
- {
|
||||
- header: {
|
||||
- Authorization: `Bearer ${token}`,
|
||||
- },
|
||||
- showError: false,
|
||||
- }
|
||||
- )
|
||||
- if (res?.code === 0 && res?.data) {
|
||||
- return res.data
|
||||
+ const serverUrl = 'https://server.websoft.top'
|
||||
+ const res: any = await Taro.request({
|
||||
+ url: `${serverUrl}/api/auth/user`,
|
||||
+ method: 'GET',
|
||||
+ header: {
|
||||
+ Authorization: `Bearer ${token}`,
|
||||
+ 'content-type': 'application/json',
|
||||
+ TenantId: String(TenantId),
|
||||
+ },
|
||||
+ })
|
||||
+
|
||||
+ if (res.data?.code === 0 && res.data?.data) {
|
||||
+ return res.data.data
|
||||
}
|
||||
return null
|
||||
} catch (e) {
|
||||
@@ -215,21 +215,23 @@
|
||||
const inviteParams = parseInviteParams({ query: router?.params })
|
||||
const refereeId = inviteParams?.inviter ? parseInt(inviteParams.inviter, 10) : 0
|
||||
|
||||
- const res: any = await request.post(
|
||||
- `${SERVER_API_URL}/wx-login/loginByMpWxPhone`,
|
||||
- {
|
||||
+ const serverUrl = 'https://server.websoft.top'
|
||||
+ const res: any = await Taro.request({
|
||||
+ url: `${serverUrl}/api/wx-login/loginByMpWxPhone`,
|
||||
+ method: 'POST',
|
||||
+ data: {
|
||||
code: phoneCode,
|
||||
notVerifyPhone: true,
|
||||
refereeId,
|
||||
sceneType: 'save_referee',
|
||||
tenantId: Number(TenantId),
|
||||
},
|
||||
- { showError: false }
|
||||
- )
|
||||
+ header: { 'content-type': 'application/json', TenantId: String(TenantId) },
|
||||
+ })
|
||||
|
||||
- if (res?.code === 0 && res?.data?.access_token) {
|
||||
- const token = res.data.access_token
|
||||
- let user = res.data.user
|
||||
+ if (res.data?.code === 0 && res.data?.data?.access_token) {
|
||||
+ const token = res.data.data.access_token
|
||||
+ let user = res.data.data.user
|
||||
|
||||
// 获取最新的用户信息
|
||||
const freshUserInfo = await fetchUserInfo(token)
|
||||
@@ -260,7 +262,7 @@
|
||||
Taro.showToast({ title: '登录成功', icon: 'success' })
|
||||
setTimeout(() => navigateAfterLogin(), 800)
|
||||
} else {
|
||||
- Taro.showToast({ title: res?.message || '登录失败', icon: 'none' })
|
||||
+ Taro.showToast({ title: res.data?.message || '登录失败', icon: 'none' })
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('微信登录失败:', e)
|
||||
316
models.json
Normal file
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
|
||||
}
|
||||
]
|
||||
}
|
||||
41
overview.md
Normal file
41
overview.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# 手机号授权登录修复概述
|
||||
|
||||
## 修复内容
|
||||
|
||||
1. **隐私协议授权流程修复**(`src/app.tsx`)
|
||||
- 原代码在 `onNeedPrivacyAuthorization` 中直接 `resolve({ event: 'agree' })`,在基础库 3.16.1+ 下无法通过微信校验。
|
||||
- 新增 `src/components/PrivacyModal`,使用原生 Button 的 `open-type="agreePrivacyAuthorization"` 触发真正的隐私授权;`Taro.showModal` 的按钮无法被微信识别。
|
||||
|
||||
2. **登录/注册页不再主动预检隐私协议**
|
||||
- 移除 `src/passport/login.tsx` 和 `src/passport/register.tsx` 中 `useDidShow` 主动调用 `ensurePrivacyAuthorized()` 的逻辑。
|
||||
- 进入登录页本身不再主动弹出隐私授权框;仅在用户点击手机号登录按钮,且微信检测到未授权时,才由微信强制触发 `onNeedPrivacyAuthorization`。
|
||||
|
||||
3. **门店中心上传图片前预检隐私协议**
|
||||
- 门店商品管理(`src/pages/store/goods/index.tsx`)通过 `uploadFile()` 上传图片,该接口内部已调用 `ensurePrivacyAuthorized()`,点击上传时自动触发隐私授权。
|
||||
- 门店订单管理(`src/pages/store/orders/index.tsx`)直接调用 `Taro.chooseImage` 上传凭证,已在其 `chooseProofImage` 中增加 `ensurePrivacyAuthorized()` 预检。
|
||||
|
||||
4. **统一接口调用**
|
||||
- 登录/注册页统一使用 `request.post` + `SERVER_API_URL` 调用 `/wx-login/loginByMpWxPhone`。
|
||||
- 修复 `register.tsx` 原本错误使用 `https://shop-api.websoft.top` 的问题。
|
||||
|
||||
5. **顺手修复构建错误**
|
||||
- `src/passport/pay/index.tsx` 引用了不存在的 `@/api/passport/wx-login`,改为使用 `@/api/layout` 中的 `getWxOpenId` / `loginByOpenId`。
|
||||
|
||||
## 验证
|
||||
|
||||
`npx taro build --type weapp` 构建成功。
|
||||
|
||||
## 需要你确认的后台配置
|
||||
|
||||
- 小程序管理后台 → 设置 → 第三方设置 → 用户隐私保护指引 → 添加「开发者收集你的手机号」声明。
|
||||
- 确认小程序已开通「手机号快速验证组件」能力,且调用额度充足。
|
||||
|
||||
## 测试前清理
|
||||
|
||||
由于之前 `Taro.showModal` 的授权无效,微信可能已缓存该状态。重新上传后请先在开发者工具或真机中清理小程序缓存,再重新进入登录页,触发新的 PrivacyModal。
|
||||
|
||||
## 重要说明
|
||||
|
||||
- 微信基础库 3.16.1+ 强制要求:调用 `getPhoneNumber`、`chooseImage` 等敏感 API 前,用户必须先同意《用户隐私保护指引》。
|
||||
- 即使登录页不再主动预检,当用户点击「手机号快捷登录」且未授权时,微信仍会强制弹出隐私授权框。这是微信机制,无法绕过。
|
||||
- 如果用户已同意隐私协议但手机号登录仍失败,请重点检查后台是否已开通「手机号快速验证组件」能力,以及隐私指引中是否包含「手机号」声明。
|
||||
71
register_recover.patch
Normal file
71
register_recover.patch
Normal file
@@ -0,0 +1,71 @@
|
||||
--- a/src/passport/register.tsx 2026-07-16 15:55:24.000000000 +0800
|
||||
+++ b/src/passport/register.tsx 2026-07-16 15:54:30.000000000 +0800
|
||||
@@ -4,9 +4,8 @@
|
||||
import { Button, Checkbox } from '@nutui/nutui-react-taro'
|
||||
import { TenantId } from '@/config/app'
|
||||
import { getUserInfo, getWxOpenId } from '@/api/layout'
|
||||
-import { saveStorageByLoginUser, SERVER_API_URL } from '@/utils/server'
|
||||
+import { saveStorageByLoginUser } from '@/utils/server'
|
||||
import { isUserDisabled } from '@/utils/auth'
|
||||
-import request from '@/utils/request'
|
||||
import {
|
||||
getStoredInviteParams,
|
||||
parseInviteParams,
|
||||
@@ -26,6 +25,17 @@
|
||||
detail: GetPhoneNumberDetail
|
||||
}
|
||||
|
||||
+interface LoginResponse {
|
||||
+ data: {
|
||||
+ code?: number
|
||||
+ message?: string
|
||||
+ data?: {
|
||||
+ access_token: string
|
||||
+ user: any
|
||||
+ }
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
async function getWeappLoginCode(): Promise<string | undefined> {
|
||||
try {
|
||||
const res = await new Promise<{ code?: string }>((resolve, reject) => {
|
||||
@@ -181,9 +191,10 @@
|
||||
// 获取小程序登录 code(用于后续绑定 openid)
|
||||
const wxLoginCode = await getWeappLoginCode()
|
||||
|
||||
- const res: any = await request.post(
|
||||
- `${SERVER_API_URL}/wx-login/loginByMpWxPhone`,
|
||||
- {
|
||||
+ const res = (await Taro.request({
|
||||
+ url: 'https://shop-api.websoft.top/api/wx-login/loginByMpWxPhone',
|
||||
+ method: 'POST',
|
||||
+ data: {
|
||||
code: phoneCode,
|
||||
encryptedData,
|
||||
iv,
|
||||
@@ -192,16 +203,19 @@
|
||||
sceneType: 'save_referee',
|
||||
tenantId: TenantId,
|
||||
},
|
||||
- { showError: false }
|
||||
- )
|
||||
+ header: {
|
||||
+ 'content-type': 'application/json',
|
||||
+ TenantId,
|
||||
+ },
|
||||
+ })) as unknown as LoginResponse
|
||||
|
||||
- if (res?.code === 1) {
|
||||
- Taro.showToast({ title: res.message || '登录失败', icon: 'none' })
|
||||
+ if ((res as any)?.data?.code === 1) {
|
||||
+ Taro.showToast({ title: res.data.message || '登录失败', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
- const token = res?.data?.access_token
|
||||
- const user = res?.data?.user
|
||||
+ const token = res?.data?.data?.access_token
|
||||
+ const user = res?.data?.data?.user
|
||||
if (!token || !user?.userId) {
|
||||
Taro.showToast({ title: '登录失败,请重试', icon: 'none' })
|
||||
return
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -1,6 +1,42 @@
|
||||
import type { PageParam } from '@/api/index';
|
||||
import type { ShopOrderGoods } from '@/api/shop/shopOrderGoods/model';
|
||||
|
||||
/**
|
||||
* 发货单(与后端 ShopOrderDelivery 对应)
|
||||
*/
|
||||
export interface ShopOrderDelivery {
|
||||
// 发货单ID
|
||||
deliveryId?: number;
|
||||
// 订单ID
|
||||
orderId?: number;
|
||||
// 发货方式(10手动录入 20无需物流 30电子面单)
|
||||
deliveryMethod?: number;
|
||||
// 物流公司ID
|
||||
expressId?: number;
|
||||
// 物流公司名称(关联查询,非数据库字段)
|
||||
expressName?: string;
|
||||
// 发货人
|
||||
sendName?: string;
|
||||
// 发货人联系方式
|
||||
sendPhone?: string;
|
||||
// 发货地址
|
||||
sendAddress?: string;
|
||||
// 物流单号
|
||||
expressNo?: string;
|
||||
// 电子面单模板内容
|
||||
eorderHtml?: string;
|
||||
// 排序号
|
||||
sortNumber?: number;
|
||||
// 是否删除, 0否, 1是
|
||||
deleted?: number;
|
||||
// 租户id
|
||||
tenantId?: number;
|
||||
// 创建时间
|
||||
createTime?: string;
|
||||
// 修改时间
|
||||
updateTime?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 订单
|
||||
*/
|
||||
@@ -97,7 +133,7 @@ export interface ShopOrder {
|
||||
formId?: number;
|
||||
// 支付的用户id
|
||||
payUserId?: number;
|
||||
// 支付方式:0余额支付, 1微信支付, 2支付宝, 3银联, 4现金, 5POS机, 6免费, 7积分支付, 8货到付款
|
||||
// 支付方式:0余额支付, 1微信支付, 2支付宝, 3银联, 4现金, 5POS机, 6免费, 7积分支付, 8货到付款, 9线下付款
|
||||
payType?: number;
|
||||
// 代付支付方式
|
||||
friendPayType?: number;
|
||||
@@ -143,6 +179,10 @@ export interface ShopOrder {
|
||||
userId?: number;
|
||||
// 备注
|
||||
comments?: string;
|
||||
// 买家备注(下单时用户填写的订单备注)
|
||||
buyerRemarks?: string;
|
||||
// 商户备注(门店修改金额原因等)
|
||||
merchantRemarks?: string;
|
||||
// 排序号
|
||||
sortNumber?: number;
|
||||
// 是否删除, 0否, 1是
|
||||
@@ -159,6 +199,8 @@ export interface ShopOrder {
|
||||
hasTakeGift?: string;
|
||||
// 订单商品项
|
||||
orderGoods?: ShopOrderGoods[];
|
||||
// 发货单信息(详情接口关联查询返回,仅快递配送且已发货时有值)
|
||||
shopOrderDelivery?: ShopOrderDelivery | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -169,6 +211,8 @@ export interface OrderGoodsItem {
|
||||
quantity: number;
|
||||
skuId?: number;
|
||||
specInfo?: string;
|
||||
// 单价(VIP 会员传 dealerPrice,普通用户不传由后端按商品价计算)
|
||||
price?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -187,7 +231,7 @@ export interface OrderCreateRequest {
|
||||
warehouseId?: number;
|
||||
// 收货地址ID
|
||||
addressId?: number;
|
||||
// 支付方式:0余额支付, 1微信支付, 2支付宝, 3银联, 4现金, 5POS机, 6免费, 7积分支付, 8货到付款
|
||||
// 支付方式:0余额支付, 1微信支付, 2支付宝, 3银联, 4现金, 5POS机, 6免费, 7积分支付, 8货到付款, 9线下付款
|
||||
payType: number;
|
||||
// 支付状态(货到付款由后端自动设置,前端无需传递)
|
||||
payStatus?: boolean;
|
||||
@@ -197,6 +241,8 @@ export interface OrderCreateRequest {
|
||||
couponId?: number;
|
||||
// 备注
|
||||
comments?: string;
|
||||
// 买家备注(下单时用户填写的订单备注)
|
||||
buyerRemarks?: string;
|
||||
// 配送开始时间(用于预约/配送时间)
|
||||
sendStartTime?: string;
|
||||
// 配送方式 0快递 1自提
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -15,6 +15,8 @@ export default {
|
||||
'passport/qr-login/index',
|
||||
'passport/qr-confirm/index',
|
||||
'passport/unified-qr/index',
|
||||
// 支付页面
|
||||
'passport/pay/index',
|
||||
// 首页子页面
|
||||
'pages/index/search',
|
||||
'pages/index/notification',
|
||||
@@ -91,6 +93,7 @@ export default {
|
||||
'pages/store/center/index',
|
||||
'pages/store/orders/index',
|
||||
'pages/store/goods/index',
|
||||
'pages/store/users/index',
|
||||
// 售后页面
|
||||
'pages/after-sale/apply/index',
|
||||
'pages/after-sale/progress/index',
|
||||
@@ -159,10 +162,10 @@ export default {
|
||||
text: '分类',
|
||||
},
|
||||
{
|
||||
pagePath: 'pages/order/list',
|
||||
iconPath: 'assets/tabbar/order.png',
|
||||
selectedIconPath: 'assets/tabbar/order-active.png',
|
||||
text: '订单',
|
||||
pagePath: 'pages/shop/cart',
|
||||
iconPath: 'assets/tabbar/cart.png',
|
||||
selectedIconPath: 'assets/tabbar/cart-active.png',
|
||||
text: '购物车',
|
||||
},
|
||||
{
|
||||
pagePath: 'pages/user/user',
|
||||
|
||||
21
src/app.tsx
21
src/app.tsx
@@ -7,6 +7,8 @@ import AppContext from './contexts/AppContext'
|
||||
import { UserProvider } from './contexts/UserContext'
|
||||
import { CartProvider } from './contexts/CartContext'
|
||||
import ErrorBoundary from './components/ErrorBoundary'
|
||||
import PrivacyModal from './components/PrivacyModal'
|
||||
import { privacyManager } from './utils/privacy'
|
||||
import './app.scss'
|
||||
|
||||
type AppProps = {
|
||||
@@ -22,6 +24,24 @@ function App(props: AppProps) {
|
||||
if (savedTheme === 'light' || savedTheme === 'dark') {
|
||||
setTheme(savedTheme)
|
||||
}
|
||||
|
||||
// 微信隐私协议(基础库 3.16.1+ 强制)
|
||||
// getPhoneNumber 等敏感能力必须由用户点击 agreePrivacyAuthorization 按钮授权。
|
||||
const wxAny: any = Taro
|
||||
if (typeof wxAny.getPrivacySetting === 'function') {
|
||||
wxAny.getPrivacySetting({
|
||||
success: (res: any) => {
|
||||
if (res?.privacyContractName) {
|
||||
privacyManager.setPrivacyContractName(res.privacyContractName)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
if (typeof wxAny.onNeedPrivacyAuthorization === 'function') {
|
||||
wxAny.onNeedPrivacyAuthorization((resolve: any) => {
|
||||
privacyManager.show(resolve)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
useDidShow(() => {})
|
||||
@@ -45,6 +65,7 @@ function App(props: AppProps) {
|
||||
<View className={theme === 'dark' ? 'dark' : ''}>
|
||||
{props.children}
|
||||
</View>
|
||||
<PrivacyModal />
|
||||
</ErrorBoundary>
|
||||
</ConfigProvider>
|
||||
</CartProvider>
|
||||
|
||||
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,9 +25,9 @@ const getCardStatus = (order: ShopOrder): { text: string; color: string } => {
|
||||
// 终态:orderStatus=1 必须最先判断,盖过 deliveryStatus/payStatus
|
||||
if (orderStatus === 1) return { text: '已完成', color: '#0e932e' }
|
||||
|
||||
// 货到付款:payStatus=true 直接进入待发货
|
||||
// 货到付款(8):下单时后端已 setPayStatus(true),不会走到这里
|
||||
// 线下付款(9):保持 payStatus=false(待商家确认收款),应显示"待付款"与 Tab 一致
|
||||
if (!payStatus && !isCod) return { text: '待付款', color: '#ee0a24' }
|
||||
if (isCod && deliveryStatus === 10) return { text: '货到付款·待发货', color: '#4b9cf5' }
|
||||
if (deliveryStatus === 10) return { text: '待发货', color: '#4b9cf5' }
|
||||
if (deliveryStatus === 20) return { text: '待收货', color: '#4b9cf5' }
|
||||
if (deliveryStatus === 30) return { text: '已收货', color: '#0e932e' }
|
||||
@@ -34,7 +35,7 @@ const getCardStatus = (order: ShopOrder): { text: string; color: string } => {
|
||||
return { text: '已付款', color: '#0e932e' }
|
||||
}
|
||||
|
||||
const OrderCard: React.FC<OrderCardProps> = ({ order, onClick }) => {
|
||||
const OrderCard: React.FC<OrderCardProps> = ({ order, onClick, onCloseOrder }) => {
|
||||
const handleClick = () => {
|
||||
if (onClick) {
|
||||
onClick()
|
||||
@@ -43,8 +44,17 @@ const OrderCard: React.FC<OrderCardProps> = ({ order, onClick }) => {
|
||||
Taro.navigateTo({ url: `/pages/order/detail?id=${order.orderId}` })
|
||||
}
|
||||
|
||||
const handleClose = (e: any) => {
|
||||
e?.stopPropagation?.()
|
||||
onCloseOrder?.(order)
|
||||
}
|
||||
|
||||
const { text: statusText, color: statusColor } = getCardStatus(order)
|
||||
|
||||
// 仅未付款/待确认收款的订单(payStatus=false)才显示取消按钮
|
||||
// 已付款的待发货订单需到详情页申请退款
|
||||
const showCloseButton = order.deliveryStatus === 10 && !order.payStatus && order.orderStatus !== 2 && order.orderStatus !== 3
|
||||
|
||||
// 获取首个商品图片作为封面,或显示默认占位
|
||||
const coverImage = order.orderGoods?.[0]?.image || ''
|
||||
const hasGoods = (order.orderGoods?.length || 0) > 0
|
||||
@@ -124,7 +134,7 @@ const OrderCard: React.FC<OrderCardProps> = ({ order, onClick }) => {
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 底部:日期 + 金额汇总 */}
|
||||
{/* 底部:日期 + 金额汇总 + 操作按钮 */}
|
||||
<View className='flex justify-between items-center pt-2 border-t border-gray-50'>
|
||||
<Text className='text-xs text-gray-500'>
|
||||
{order.createTime?.slice(0, 10)}
|
||||
@@ -137,6 +147,18 @@ const OrderCard: React.FC<OrderCardProps> = ({ order, onClick }) => {
|
||||
<Price price={order.payPrice || order.totalPrice || '0'} size='small' />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 操作按钮区 */}
|
||||
{showCloseButton && onCloseOrder && (
|
||||
<View className='flex justify-end mt-3 pt-2 border-t border-gray-50'>
|
||||
<View
|
||||
className='px-3 py-1 rounded-full border border-gray-300'
|
||||
onClick={handleClose}
|
||||
>
|
||||
<Text className='text-xs text-gray-600'>取消订单</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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([])
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'
|
||||
import { View, Text, ScrollView, Swiper, SwiperItem, Image } from '@tarojs/components'
|
||||
import Taro, { useDidShow } from '@tarojs/taro'
|
||||
import { useUser } from '@/hooks/useUser'
|
||||
import { useShare } from '@/hooks/useShare'
|
||||
import { useScrollHeight } from '@/hooks/useScrollHeight'
|
||||
import { useNewOrderDetector } from '@/hooks/useNewOrderDetector'
|
||||
import { useVipStatus } from '@/hooks/useVipStatus'
|
||||
import { getMyClerk } from '@/api/shop/shopStoreUser'
|
||||
import { pageShopOrder } from '@/api/shop/shopOrder'
|
||||
import { listCmsAd } from '@/api/cms/cmsAd'
|
||||
@@ -14,6 +15,7 @@ import type { CmsAd } from '@/api/cms/cmsAd/model'
|
||||
import type { CmsArticle } from '@/api/cms/cmsArticle/model'
|
||||
import type { ShopGoods } from '@/api/shop/shopGoods/model'
|
||||
import Price from '@/components/common/Price'
|
||||
import Badge from '@/components/common/Badge'
|
||||
import { getCompressedImageUrl } from '@/utils/image'
|
||||
|
||||
definePageConfig({
|
||||
@@ -25,10 +27,13 @@ definePageConfig({
|
||||
const IndexPage: React.FC = () => {
|
||||
const { user, isLoggedIn } = useUser()
|
||||
const [banners, setBanners] = useState<CmsAd[]>([])
|
||||
const [bannerWrapWidth, setBannerWrapWidth] = useState(0)
|
||||
const [announcements, setAnnouncements] = useState<CmsArticle[]>([])
|
||||
const [hotProducts, setHotProducts] = useState<ShopGoods[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const scrollHeight = useScrollHeight(44)
|
||||
// VIP 状态:异步校验并更新缓存,isVip 变化时触发重渲染
|
||||
const { isVip } = useVipStatus()
|
||||
|
||||
// 首页分享:好友 + 朋友圈 + 复制链接(右上角三个按钮全亮)
|
||||
useShare({
|
||||
@@ -85,6 +90,8 @@ const IndexPage: React.FC = () => {
|
||||
return pageShopOrder({ page: 1, limit: 5 }).then(r => r?.list || [])
|
||||
}, []),
|
||||
onNewOrders: useCallback((count) => {
|
||||
// 非门店店员不弹新订单提醒
|
||||
if (!isClerkRef.current) return
|
||||
Taro.vibrateShort({ type: 'medium' })
|
||||
Taro.showToast({
|
||||
title: `您有 ${count} 条新订单`,
|
||||
@@ -163,8 +170,19 @@ const IndexPage: React.FC = () => {
|
||||
Taro.navigateTo({ url: '/pages/index/notification' })
|
||||
}
|
||||
|
||||
/**
|
||||
* VIP 会员显示 dealerPrice(同时把 price 作为划线原价),非 VIP 维持原行为
|
||||
*/
|
||||
const getHotDisplayPrice = (item: ShopGoods): { price: string; original?: string } => {
|
||||
if (isVip && item.dealerPrice) {
|
||||
return { price: item.dealerPrice, original: item.price }
|
||||
}
|
||||
const original = item.salePrice && item.salePrice !== item.price ? item.salePrice : undefined
|
||||
return { price: item.price || '0', original }
|
||||
}
|
||||
|
||||
// tabBar 页面列表
|
||||
const tabBarPages = ['/pages/index/index', '/pages/shop/index', '/pages/order/list', '/pages/user/user']
|
||||
const tabBarPages = ['/pages/index/index', '/pages/shop/index', '/pages/shop/cart', '/pages/user/user']
|
||||
|
||||
const handleFeatureClick = (url: string) => {
|
||||
if (tabBarPages.includes(url)) {
|
||||
@@ -174,6 +192,44 @@ const IndexPage: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 获取轮播容器实际宽度,用于根据后台 width/height 计算比例高度
|
||||
useEffect(() => {
|
||||
if (bannerWrapWidth > 0) return
|
||||
const timer = setTimeout(() => {
|
||||
const query = Taro.createSelectorQuery()
|
||||
query.select('.index-banner-wrap').boundingClientRect((rect) => {
|
||||
if (rect && rect.width > 0) {
|
||||
setBannerWrapWidth(rect.width)
|
||||
}
|
||||
}).exec()
|
||||
}, 0)
|
||||
return () => clearTimeout(timer)
|
||||
}, [bannerWrapWidth])
|
||||
|
||||
// 根据广告位后台返回的 width/height 计算轮播区域高度(无值则 fallback 160px)
|
||||
const bannerHeight = useMemo(() => {
|
||||
if (bannerWrapWidth <= 0) return 160
|
||||
const firstAd = banners.find(b => b.width && b.height)
|
||||
if (!firstAd) return 160
|
||||
const w = parseInt(firstAd.width || '0', 10)
|
||||
const h = parseInt(firstAd.height || '0', 10)
|
||||
if (!w || !h || w <= 0 || h <= 0) return 160
|
||||
return Math.round(bannerWrapWidth * (h / w))
|
||||
}, [bannerWrapWidth, banners])
|
||||
|
||||
// 轮播图数据:每个广告位下的所有图片都作为独立轮播项
|
||||
const bannerSlides = banners.flatMap(item =>
|
||||
(item.imageList && item.imageList.length > 0)
|
||||
? item.imageList.map((img, idx) => ({
|
||||
key: `${item.adId}-${img.uid || idx}`,
|
||||
src: img.url || '',
|
||||
path: item.path,
|
||||
}))
|
||||
: item.image
|
||||
? [{ key: `${item.adId}-0`, src: item.image, path: item.path }]
|
||||
: []
|
||||
)
|
||||
|
||||
return (
|
||||
<View className='min-h-screen bg-gray-50'>
|
||||
{/* 顶部搜索栏 */}
|
||||
@@ -189,14 +245,10 @@ const IndexPage: React.FC = () => {
|
||||
</View>
|
||||
<View onClick={handleMessageClick} className='relative'>
|
||||
<Text className='text-xl'>🔔</Text>
|
||||
{isLoggedIn && newOrderCount > 0 && (
|
||||
<View className='absolute -top-1 -right-1 min-w-[16px] h-4 px-1 bg-red-500 rounded-full flex items-center justify-center'>
|
||||
<Text className='text-white text-[10px] leading-none font-medium'>
|
||||
{newOrderCount > 99 ? '99+' : newOrderCount}
|
||||
</Text>
|
||||
</View>
|
||||
{isClerk && newOrderCount > 0 && (
|
||||
<Badge count={newOrderCount} className='absolute -top-1 -right-1' />
|
||||
)}
|
||||
{isLoggedIn && newOrderCount === 0 && (
|
||||
{isClerk && newOrderCount === 0 && (
|
||||
<View className='absolute top-0 right-0 w-2 h-2 bg-red-500 rounded-full' />
|
||||
)}
|
||||
</View>
|
||||
@@ -204,29 +256,29 @@ const IndexPage: React.FC = () => {
|
||||
|
||||
<ScrollView scrollY style={{ height: scrollHeight }}>
|
||||
{/* 轮播图 */}
|
||||
<View className='mx-3 mt-2 rounded-lg overflow-hidden'>
|
||||
{banners.length > 0 ? (
|
||||
<View className='mx-3 mt-2 rounded-lg overflow-hidden index-banner-wrap'>
|
||||
{bannerSlides.length > 0 ? (
|
||||
<Swiper
|
||||
autoplay
|
||||
interval={3000}
|
||||
className='rounded-lg'
|
||||
style={{ height: '160px' }}
|
||||
style={{ height: `${bannerHeight}px` }}
|
||||
>
|
||||
{banners.map(item => (
|
||||
<SwiperItem key={item.adId}>
|
||||
{bannerSlides.map(slide => (
|
||||
<SwiperItem key={slide.key}>
|
||||
<Image
|
||||
className='w-full h-full'
|
||||
src={getCompressedImageUrl(item.imageList?.[0]?.url || item.image || '', { width: 750, quality: 90})}
|
||||
src={getCompressedImageUrl(slide.src, { width: 750, quality: 90 })}
|
||||
mode='aspectFill'
|
||||
onClick={() => {
|
||||
if (item.path) Taro.navigateTo({ url: item.path })
|
||||
if (slide.path) Taro.navigateTo({ url: slide.path })
|
||||
}}
|
||||
/>
|
||||
</SwiperItem>
|
||||
))}
|
||||
</Swiper>
|
||||
) : (
|
||||
<View className='w-full bg-green-50 flex items-center justify-center' style={{ height: '160px' }}>
|
||||
<View className='w-full bg-green-50 flex items-center justify-center' style={{ height: `${bannerHeight}px` }}>
|
||||
<Text className='text-gray-400 text-sm'>暂无轮播图</Text>
|
||||
</View>
|
||||
)}
|
||||
@@ -328,11 +380,14 @@ const IndexPage: React.FC = () => {
|
||||
<Text className='text-sm text-gray-700 truncate block'>{item.goodsName || item.name}</Text>
|
||||
<View className='flex items-center mt-1'>
|
||||
<Price
|
||||
price={item.price || '0'}
|
||||
original={item.salePrice && item.salePrice !== item.price ? item.salePrice : undefined}
|
||||
price={getHotDisplayPrice(item).price}
|
||||
original={getHotDisplayPrice(item).original}
|
||||
size='small'
|
||||
loginMask
|
||||
/>
|
||||
{isVip && item.dealerPrice ? (
|
||||
<Text className='text-xs text-amber-600 ml-1'>VIP</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{item.sales !== undefined && item.sales > 0 && (
|
||||
<Text className='text-xs text-gray-400 mt-1'>已售 {item.sales}</Text>
|
||||
|
||||
@@ -24,6 +24,7 @@ const PAY_TYPE_MAP: Record<number, string> = {
|
||||
6: '免费',
|
||||
7: '积分支付',
|
||||
8: '货到付款',
|
||||
9: '线下付款',
|
||||
}
|
||||
|
||||
// 发票状态映射
|
||||
@@ -44,6 +45,7 @@ const DELIVERY_STATUS_MAP: Record<number, string> = {
|
||||
const getOrderDisplayStatus = (order: ShopOrder): { title: string; bgColor: string; subtitle: string } => {
|
||||
const { payStatus, deliveryStatus, orderStatus, payType } = order
|
||||
const isCod = payType === 8 // 货到付款
|
||||
const isOffline = payType === 9 // 线下付款
|
||||
|
||||
// 退款/取消相关状态
|
||||
if (orderStatus === OrderStatus.Cancelled) {
|
||||
@@ -63,14 +65,17 @@ const getOrderDisplayStatus = (order: ShopOrder): { title: string; bgColor: stri
|
||||
}
|
||||
|
||||
// 正常订单流程
|
||||
if (!payStatus && !isCod) {
|
||||
if (!payStatus && !isCod && !isOffline) {
|
||||
return { bgColor: '#ff7d00', title: '待付款', subtitle: '请尽快完成支付' }
|
||||
}
|
||||
if (isOffline && !payStatus) {
|
||||
return { bgColor: '#4b9cf5', title: '待发货', subtitle: '请通过微信转账完成付款,商家确认后发货' }
|
||||
}
|
||||
if (isCod && !payStatus) {
|
||||
return { bgColor: '#4b9cf5', title: '货到付款·待发货', subtitle: '送达时支付' }
|
||||
return { bgColor: '#4b9cf5', title: '待发货', subtitle: '送达时支付' }
|
||||
}
|
||||
if (deliveryStatus === 10) {
|
||||
return { bgColor: '#4b9cf5', title: isCod ? '货到付款·待发货' : '待发货', subtitle: isCod ? '送达时支付' : '商家正在准备商品' }
|
||||
return { bgColor: '#4b9cf5', title: '待发货', subtitle: '商家正在准备商品' }
|
||||
}
|
||||
if (deliveryStatus === 20 || deliveryStatus === 30) {
|
||||
return { bgColor: '#4b9cf5', title: '待收货', subtitle: '商品运输中,请注意查收' }
|
||||
@@ -87,17 +92,20 @@ const getOrderDisplayStatus = (order: ShopOrder): { title: string; bgColor: stri
|
||||
}
|
||||
|
||||
/** 根据订单状态判断当前操作阶段 */
|
||||
type OrderPhase = 'unpaid' | 'unshipped' | 'shipped' | 'completed' | 'cancelled' | 'refund'
|
||||
type OrderPhase = 'unpaid' | 'offline_pending' | 'unshipped' | 'shipped' | 'completed' | 'cancelled' | 'refund'
|
||||
|
||||
const getOrderPhase = (order: ShopOrder): OrderPhase => {
|
||||
const { payStatus, deliveryStatus, orderStatus, payType } = order
|
||||
const isCod = payType === 8 // 货到付款
|
||||
const isOffline = payType === 9 // 线下付款
|
||||
|
||||
// 退款/取消
|
||||
if ([OrderStatus.Cancelled, OrderStatus.Cancelling, OrderStatus.RefundSuccess, OrderStatus.RefundApply, OrderStatus.ClientRefundApply, OrderStatus.RefundRejected].includes(orderStatus as OrderStatus)) {
|
||||
return orderStatus === OrderStatus.Cancelled || orderStatus === OrderStatus.RefundSuccess ? 'cancelled' : 'refund'
|
||||
}
|
||||
|
||||
// 线下付款且未确认收款:等待商家确认,不显示"立即支付"按钮
|
||||
if (isOffline && !payStatus) return 'offline_pending'
|
||||
// 货到付款订单:payStatus=true 但还未实际付款,直接进入 unshipped 阶段(不需要"立即支付"按钮)
|
||||
if (!payStatus && !isCod) return 'unpaid'
|
||||
if (deliveryStatus === 10) return 'unshipped'
|
||||
@@ -445,6 +453,28 @@ const OrderDetailPage: React.FC = () => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 线下付款·待确认: 取消 + 联系客服 */}
|
||||
{phase === 'offline_pending' && (
|
||||
<>
|
||||
<Button
|
||||
size='small'
|
||||
className='flex-1'
|
||||
style={{ borderColor: '#ddd', color: '#666' }}
|
||||
onClick={handleCancelOrder}
|
||||
>
|
||||
取消订单
|
||||
</Button>
|
||||
<Button
|
||||
size='small'
|
||||
className='flex-1'
|
||||
style={{ backgroundColor: '#4b9cf5' }}
|
||||
onClick={() => Taro.makePhoneCall({ phoneNumber: '400-000-0000' })}
|
||||
>
|
||||
联系客服
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 待发货: 退款 + 联系客服 */}
|
||||
{phase === 'unshipped' && (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -10,7 +10,7 @@ import Loading from '@/components/common/Loading'
|
||||
import { isGuest } from '@/utils/auth'
|
||||
import { getCompressedImageUrl } from '@/utils/image'
|
||||
import { requireLogin } from '@/utils/login-guard'
|
||||
import { isVipMember } from '@/utils/vip'
|
||||
import { useVipStatus } from '@/hooks/useVipStatus'
|
||||
|
||||
definePageConfig({
|
||||
navigationBarTitleText: '购物车',
|
||||
@@ -19,6 +19,8 @@ definePageConfig({
|
||||
const CartPage: React.FC = () => {
|
||||
const { items, selectedCount, selectedPrice, updateQuantity, toggleSelect, selectAll, removeItem, refresh, loading, removeSelected, addItem } = useCartContext()
|
||||
const { isLoggedIn } = useUserContext()
|
||||
// VIP 状态:异步校验并更新缓存,isVip 变化时触发重渲染
|
||||
const { isVip } = useVipStatus()
|
||||
const [recommendGoods, setRecommendGoods] = useState<ShopGoods[]>([])
|
||||
const [recommendPage, setRecommendPage] = useState(1)
|
||||
const [refreshingRecommend, setRefreshingRecommend] = useState(false)
|
||||
@@ -110,7 +112,7 @@ const CartPage: React.FC = () => {
|
||||
>
|
||||
<Image
|
||||
className='w-full rounded-md bg-gray-100'
|
||||
src={getCompressedImageUrl(goods.image || '')}
|
||||
src={getCompressedImageUrl(goods.image || '', { width: 90 })}
|
||||
mode='aspectFill'
|
||||
style={{ height: '90px' }}
|
||||
/>
|
||||
@@ -177,7 +179,7 @@ const CartPage: React.FC = () => {
|
||||
</View>
|
||||
<Image
|
||||
className="w-20 h-20 rounded-md bg-gray-100 flex-shrink-0"
|
||||
src={getCompressedImageUrl(item.goodsImage || item.product?.image || (item.sku as any)?.image || '')}
|
||||
src={getCompressedImageUrl(item.goodsImage || item.product?.image || (item.sku as any)?.image || '',{ width: 90 })}
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<View className="flex-1 flex flex-col justify-between">
|
||||
@@ -189,7 +191,7 @@ const CartPage: React.FC = () => {
|
||||
)}
|
||||
<View className="flex justify-between items-center">
|
||||
<Text className="text-sm font-bold text-red-500">
|
||||
¥{isVipMember() && (item.dealerPrice || (item.product as any)?.dealerPrice) ? (item.dealerPrice || (item.product as any).dealerPrice) : (item.skuPrice || item.sku?.price || item.salePrice || item.product?.salePrice || item.product?.price || '0')}
|
||||
¥{isVip && (item.dealerPrice || (item.product as any)?.dealerPrice) ? (item.dealerPrice || (item.product as any).dealerPrice) : (item.skuPrice || item.sku?.price || item.salePrice || item.product?.salePrice || item.product?.price || '0')}
|
||||
</Text>
|
||||
<View className="flex items-center gap-3">
|
||||
<View className="w-6 h-6 rounded bg-gray-100 flex items-center justify-center" onClick={() => updateQuantity(item.goodsId, item.skuId, item.quantity - 1)}>
|
||||
@@ -229,7 +231,7 @@ const CartPage: React.FC = () => {
|
||||
>
|
||||
<Image
|
||||
className="w-full rounded-md bg-gray-100"
|
||||
src={getCompressedImageUrl(goods.image || '')}
|
||||
src={getCompressedImageUrl(goods.image || '',{ width: 90 })}
|
||||
mode="aspectFill"
|
||||
style={{ height: '90px' }}
|
||||
/>
|
||||
@@ -238,7 +240,7 @@ const CartPage: React.FC = () => {
|
||||
</Text>
|
||||
<View className="flex items-center justify-between mt-1">
|
||||
<Text className="text-xs font-bold text-red-500">
|
||||
¥{isVipMember() && goods.dealerPrice ? goods.dealerPrice : (goods.salePrice || goods.price || '0')}
|
||||
¥{isVip && goods.dealerPrice ? goods.dealerPrice : (goods.salePrice || goods.price || '0')}
|
||||
</Text>
|
||||
<View
|
||||
className="w-5 h-5 rounded-full flex items-center justify-center"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,6 +3,7 @@ import { View, Text, Image, ScrollView, Swiper, SwiperItem, RichText, Button } f
|
||||
import { Tag } from '@nutui/nutui-react-taro'
|
||||
import Taro, { useRouter, useDidShow } from '@tarojs/taro'
|
||||
import { getShopGoods } from '@/api/shop/shopGoods'
|
||||
import { reportBrowse } from '@/api/shop/shopGoodsBrowse'
|
||||
import {
|
||||
addShopGoodsFavorite,
|
||||
removeShopGoodsFavorite,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
} from '@/api/shop/shopGoodsFavorite'
|
||||
import type { ShopGoods, ShopGoodsSku } from '@/api/shop/shopGoods/model'
|
||||
import Price from '@/components/common/Price'
|
||||
import Badge from '@/components/common/Badge'
|
||||
import SkuSelector from '@/components/business/SkuSelector'
|
||||
import { useCartContext } from '@/contexts/CartContext'
|
||||
import { useUserContext } from '@/contexts/UserContext'
|
||||
@@ -18,7 +20,7 @@ import { useShare } from '@/hooks/useShare'
|
||||
import SharePoster, { SharePosterHandle } from '@/components/SharePoster'
|
||||
import { isGuest } from '@/utils/auth'
|
||||
import { requireLogin } from '@/utils/login-guard'
|
||||
import { isVipMember } from '@/utils/vip'
|
||||
import { useVipStatus } from '@/hooks/useVipStatus'
|
||||
|
||||
definePageConfig({
|
||||
navigationBarTitleText: '商品详情',
|
||||
@@ -36,6 +38,8 @@ const ProductDetailPage: React.FC = () => {
|
||||
const [isFavorite, setIsFavorite] = useState(false)
|
||||
const { addItem, totalCount, refresh } = useCartContext()
|
||||
const { isLoggedIn } = useUserContext()
|
||||
// VIP 状态:异步校验并更新缓存,isVip 变化时触发重渲染
|
||||
const { isVip } = useVipStatus()
|
||||
// 底部操作栏高度约 90px(含按钮 + 安全区),动态计算 ScrollView 可用高度
|
||||
const scrollHeight = useScrollHeight(44)
|
||||
|
||||
@@ -72,7 +76,7 @@ const ProductDetailPage: React.FC = () => {
|
||||
cover: product.image,
|
||||
title: product.name || product.goodsName || '优质商品推荐',
|
||||
price:
|
||||
product.dealerPrice && isVipMember() ? product.dealerPrice : product.price,
|
||||
product.dealerPrice && isVip ? product.dealerPrice : product.price,
|
||||
page: 'pages/shop/product-detail',
|
||||
})
|
||||
.then(setPosterPath)
|
||||
@@ -89,7 +93,7 @@ const ProductDetailPage: React.FC = () => {
|
||||
|
||||
// VIP 会员显示 dealerPrice,普通用户显示 price
|
||||
const getDisplayPrice = (): string => {
|
||||
if (isVipMember() && product?.dealerPrice) {
|
||||
if (isVip && product?.dealerPrice) {
|
||||
return product.dealerPrice
|
||||
}
|
||||
return product?.price || '0'
|
||||
@@ -144,6 +148,11 @@ const ProductDetailPage: React.FC = () => {
|
||||
Taro.setStorageSync('browse_history', reduced)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// 已登录用户:异步上报服务端浏览记录(静默失败,不影响用户体验)
|
||||
if (isLoggedIn) {
|
||||
reportBrowse({ goodsId: id, browseSource: 'detail' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddCart = () => {
|
||||
@@ -172,7 +181,7 @@ const ProductDetailPage: React.FC = () => {
|
||||
}
|
||||
} else {
|
||||
// VIP 会员使用 dealerPrice 作为结算价
|
||||
const vipPrice = isVipMember() && product.dealerPrice ? product.dealerPrice : undefined
|
||||
const vipPrice = isVip && product.dealerPrice ? product.dealerPrice : undefined
|
||||
const buyNowData = [{
|
||||
goodsId: product.goodsId!,
|
||||
skuId: sku?.id || 0,
|
||||
@@ -292,7 +301,7 @@ const ProductDetailPage: React.FC = () => {
|
||||
<Price price={getDisplayPrice()} size='large' color='#ee0a24' loginMask />
|
||||
<Tag>到手价</Tag>
|
||||
{/* VIP 会员价 */}
|
||||
{!isGuest() && isVipMember() && Number(product.dealerPrice) > 0 ? (
|
||||
{!isGuest() && isVip && Number(product.dealerPrice) > 0 ? (
|
||||
<View className='ml-auto inline-flex items-center gap-1 bg-amber-50 rounded px-2 py-1'>
|
||||
<Text className='text-xs text-amber-600 font-medium'>VIP专享</Text>
|
||||
<Text className='text-sm text-amber-700 font-bold'>¥{product.dealerPrice}</Text>
|
||||
@@ -304,7 +313,7 @@ const ProductDetailPage: React.FC = () => {
|
||||
)}
|
||||
</View>
|
||||
{/* 会员价 */}
|
||||
{!isGuest() && !isVipMember() && Number(product.memberStorePrice) > 0 && product.memberStorePrice !== product.price && (
|
||||
{!isGuest() && !isVip && Number(product.memberStorePrice) > 0 && product.memberStorePrice !== product.price && (
|
||||
<View className='mt-2 inline-block bg-orange-50 rounded px-2 py-1'>
|
||||
<Text className='text-xs text-orange-500'>会员价: ¥{product.memberStorePrice}</Text>
|
||||
</View>
|
||||
@@ -425,28 +434,8 @@ const ProductDetailPage: React.FC = () => {
|
||||
<View className='flex-1 flex flex-col items-center' onClick={handleGoCart}>
|
||||
<View className='relative inline-flex items-center justify-center'>
|
||||
<Text className='text-lg'>🛒</Text>
|
||||
{totalCount > 0 && (
|
||||
<View
|
||||
className='flex items-center justify-center'
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-6px',
|
||||
right: '-10px',
|
||||
minWidth: '16px',
|
||||
height: '16px',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: '#ef4444',
|
||||
borderWidth: '1.5px',
|
||||
borderStyle: 'solid',
|
||||
borderColor: '#ffffff',
|
||||
paddingHorizontal: '3px',
|
||||
}}
|
||||
>
|
||||
<Text className='text-white text-[10px] font-bold leading-none'>
|
||||
{totalCount > 99 ? '99+' : totalCount}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{/* 购物车数量角标:count 为 0 时组件内部不渲染 */}
|
||||
<Badge count={totalCount} className='absolute -top-1 -right-1' />
|
||||
</View>
|
||||
<Text className='text-xs text-gray-500 mt-1 whitespace-nowrap'>购物车</Text>
|
||||
</View>
|
||||
|
||||
@@ -4,6 +4,7 @@ import Taro from '@tarojs/taro'
|
||||
import { getMyClerk } from '@/api/shop/shopStoreUser'
|
||||
import { listShopDealerApply } from '@/api/shop/shopDealerApply'
|
||||
import { pageShopOrder } from '@/api/shop/shopOrder'
|
||||
import Badge from '@/components/common/Badge'
|
||||
|
||||
definePageConfig({
|
||||
navigationBarTitleText: '门店中心',
|
||||
@@ -32,6 +33,16 @@ const FEATURE_CARDS = [
|
||||
// 显示待处理订单数量角标
|
||||
showBadge: true,
|
||||
},
|
||||
{
|
||||
key: 'users',
|
||||
icon: '👥',
|
||||
title: '用户管理',
|
||||
desc: '查看用户信息、禁用/启用用户',
|
||||
url: '/pages/store/users/index',
|
||||
color: '#2563eb',
|
||||
bgColor: '#dbeafe',
|
||||
showBadge: false,
|
||||
},
|
||||
{
|
||||
key: 'vip-review',
|
||||
icon: '👑',
|
||||
@@ -204,12 +215,8 @@ export default function StoreCenterPage() {
|
||||
</View>
|
||||
|
||||
{/* 待处理角标 */}
|
||||
{card.showBadge && getBadgeCount(card.key) > 0 && (
|
||||
<View className='absolute -top-1 -right-1 min-w-[20px] h-[20px] rounded-full bg-red-500 flex items-center justify-center px-1.5'>
|
||||
<Text className='text-white text-[11px] font-bold'>
|
||||
{getBadgeCount(card.key) > 99 ? '99+' : getBadgeCount(card.key)}
|
||||
</Text>
|
||||
</View>
|
||||
{card.showBadge && (
|
||||
<Badge count={getBadgeCount(card.key)} className='absolute -top-1 -right-1' size={20} fontSize={11} fontWeight='bold' />
|
||||
)}
|
||||
|
||||
{/* 箭头 */}
|
||||
@@ -220,7 +227,7 @@ export default function StoreCenterPage() {
|
||||
</View>
|
||||
|
||||
{/* 底部提示 */}
|
||||
<View className='mx-3 mt-6 mb-4'>
|
||||
<View className='mx-3 mt-6 mb-6'>
|
||||
{/* 订阅消息提醒 */}
|
||||
<View
|
||||
className='bg-white rounded-xl p-4 flex items-center gap-3 active:opacity-80 mb-3'
|
||||
@@ -238,9 +245,9 @@ export default function StoreCenterPage() {
|
||||
<Text className='text-orange-500 text-sm flex-shrink-0'>去开启</Text>
|
||||
</View>
|
||||
|
||||
<Text className='text-gray-300 text-xs text-center block'>
|
||||
未来更多功能将持续上线
|
||||
</Text>
|
||||
{/*<Text className='text-gray-300 text-xs text-center block'>*/}
|
||||
{/* 未来更多功能将持续上线*/}
|
||||
{/*</Text>*/}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { View, Text, Image, ScrollView, Input, Textarea } from '@tarojs/components'
|
||||
import Taro, { useDidShow } from '@tarojs/taro'
|
||||
import { pageShopGoods, updateShopGoods } from '@/api/shop/shopGoods'
|
||||
import { pageShopGoods, updateShopGoods, addShopGoods } from '@/api/shop/shopGoods'
|
||||
import { listShopGoodsCategory } from '@/api/shop/shopGoodsCategory'
|
||||
import { getMyClerk } from '@/api/shop/shopStoreUser'
|
||||
import { uploadFile } from '@/api/system/file'
|
||||
import type { ShopGoods, ShopGoodsParam } from '@/api/shop/shopGoods/model'
|
||||
import type { ShopGoodsCategory } from '@/api/shop/shopGoodsCategory/model'
|
||||
@@ -66,6 +67,28 @@ export default function StoreGoodsPage() {
|
||||
const [uploadingImage, setUploadingImage] = useState(false)
|
||||
const [uploadingBanner, setUploadingBanner] = useState(false)
|
||||
|
||||
// 当前登录店员的门店ID
|
||||
const [storeId, setStoreId] = useState<number | undefined>(undefined)
|
||||
|
||||
// 新增商品弹窗
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [addForm, setAddForm] = useState({
|
||||
name: '',
|
||||
categoryId: undefined as number | undefined,
|
||||
image: '',
|
||||
price: '',
|
||||
salePrice: '',
|
||||
dealerPrice: '',
|
||||
stock: '',
|
||||
sortNumber: '',
|
||||
comments: '',
|
||||
files: [] as Array<{ uid?: number; url: string; status?: string }>,
|
||||
})
|
||||
const [addSubmitting, setAddSubmitting] = useState(false)
|
||||
const [addUploadingImage, setAddUploadingImage] = useState(false)
|
||||
const [addUploadingBanner, setAddUploadingBanner] = useState(false)
|
||||
const [showAddCategoryPicker, setShowAddCategoryPicker] = useState(false)
|
||||
|
||||
const pageSize = 10
|
||||
const loadingRef = useRef(false)
|
||||
|
||||
@@ -113,6 +136,15 @@ export default function StoreGoodsPage() {
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
// 获取当前登录店员的门店ID
|
||||
useEffect(() => {
|
||||
getMyClerk()
|
||||
.then(data => {
|
||||
if (data?.storeId) setStoreId(data.storeId)
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
// 切换 tab 时重新加载
|
||||
useEffect(() => {
|
||||
loadGoods(activeTab, 1)
|
||||
@@ -306,6 +338,131 @@ export default function StoreGoodsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 新增商品相关 ──────────────────────────────────────────────
|
||||
|
||||
/** 打开新增弹窗 */
|
||||
const openAddModal = () => {
|
||||
setAddForm({
|
||||
name: '',
|
||||
categoryId: undefined,
|
||||
image: '',
|
||||
price: '',
|
||||
salePrice: '',
|
||||
dealerPrice: '',
|
||||
stock: '',
|
||||
sortNumber: '',
|
||||
comments: '',
|
||||
files: [],
|
||||
})
|
||||
setShowAddModal(true)
|
||||
}
|
||||
|
||||
/** 关闭新增弹窗 */
|
||||
const closeAddModal = () => {
|
||||
setShowAddModal(false)
|
||||
}
|
||||
|
||||
/** 上传商品图片(新增) */
|
||||
const handleAddUploadImage = async () => {
|
||||
if (addUploadingImage) return
|
||||
setAddUploadingImage(true)
|
||||
try {
|
||||
const res = await uploadFile()
|
||||
const imageUrl = res.path || ''
|
||||
if (imageUrl) {
|
||||
setAddForm(prev => ({ ...prev, image: imageUrl }))
|
||||
Taro.showToast({ title: '上传成功', icon: 'success' })
|
||||
}
|
||||
} catch (e: any) {
|
||||
Taro.showToast({ title: e.message || '上传失败', icon: 'none' })
|
||||
} finally {
|
||||
setAddUploadingImage(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** 上传轮播图(新增) */
|
||||
const handleAddUploadBanner = async () => {
|
||||
if (addUploadingBanner) return
|
||||
setAddUploadingBanner(true)
|
||||
try {
|
||||
const res = await uploadFile()
|
||||
const imageUrl = res.path || ''
|
||||
if (imageUrl) {
|
||||
setAddForm(prev => ({
|
||||
...prev,
|
||||
files: [...prev.files, { uid: res.id, url: imageUrl, status: 'done' }],
|
||||
}))
|
||||
Taro.showToast({ title: '上传成功', icon: 'success' })
|
||||
}
|
||||
} catch (e: any) {
|
||||
Taro.showToast({ title: e.message || '上传失败', icon: 'none' })
|
||||
} finally {
|
||||
setAddUploadingBanner(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除轮播图(新增) */
|
||||
const handleAddRemoveBanner = (index: number) => {
|
||||
setAddForm(prev => ({
|
||||
...prev,
|
||||
files: prev.files.filter((_, i) => i !== index),
|
||||
}))
|
||||
}
|
||||
|
||||
/** 提交新增商品 */
|
||||
const submitAdd = async () => {
|
||||
if (!addForm.name.trim()) {
|
||||
Taro.showToast({ title: '请输入商品名称', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!addForm.image) {
|
||||
Taro.showToast({ title: '请上传商品图片', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const price = parseFloat(addForm.price)
|
||||
if (isNaN(price) || price < 0) {
|
||||
Taro.showToast({ title: '请输入有效的到手价', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const stock = parseInt(addForm.stock, 10)
|
||||
if (isNaN(stock) || stock < 0) {
|
||||
Taro.showToast({ title: '请输入有效的库存', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!storeId) {
|
||||
Taro.showToast({ title: '未获取到门店信息,请重试', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
setAddSubmitting(true)
|
||||
try {
|
||||
await addShopGoods({
|
||||
name: addForm.name.trim(),
|
||||
categoryId: addForm.categoryId,
|
||||
image: addForm.image,
|
||||
price: addForm.price,
|
||||
salePrice: addForm.salePrice || undefined,
|
||||
dealerPrice: addForm.dealerPrice || undefined,
|
||||
stock,
|
||||
sortNumber: parseInt(addForm.sortNumber, 10) || 0,
|
||||
comments: addForm.comments || undefined,
|
||||
files: JSON.stringify(addForm.files),
|
||||
storeId,
|
||||
status: 1, // 新增默认待上架
|
||||
recommend: 0,
|
||||
type: 1, // 实物商品
|
||||
})
|
||||
Taro.showToast({ title: '添加成功', icon: 'success' })
|
||||
closeAddModal()
|
||||
loadGoods(activeTab, 1)
|
||||
} catch (e: any) {
|
||||
Taro.showToast({ title: e.message || '添加失败', icon: 'none' })
|
||||
} finally {
|
||||
setAddSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** 渲染单个商品卡片 */
|
||||
const renderGoodsCard = (goods: ShopGoods) => {
|
||||
const isSoldOut = (goods.stock ?? 0) <= 0
|
||||
@@ -493,9 +650,18 @@ export default function StoreGoodsPage() {
|
||||
<Text className='text-gray-300 text-xs'>没有更多了</Text>
|
||||
</View>
|
||||
)}
|
||||
<View className='h-6' />
|
||||
<View className='h-20' />
|
||||
</ScrollView>
|
||||
|
||||
{/* 新增商品浮动按钮 */}
|
||||
<View
|
||||
className='fixed right-4 bottom-8 w-14 h-14 rounded-full bg-cyan-500 flex items-center justify-center active:opacity-80'
|
||||
style={{ boxShadow: '0 4px 12px rgba(8, 145, 178, 0.4)' }}
|
||||
onClick={openAddModal}
|
||||
>
|
||||
<Text className='text-white text-3xl leading-none' style={{ marginTop: '-2px' }}>+</Text>
|
||||
</View>
|
||||
|
||||
{/* 分类选择弹窗 */}
|
||||
{showCategoryPicker && (
|
||||
<View className='fixed inset-0 z-50' onClick={() => setShowCategoryPicker(false)}>
|
||||
@@ -672,6 +838,217 @@ export default function StoreGoodsPage() {
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 新增商品弹窗 */}
|
||||
{showAddModal && (
|
||||
<View className='fixed inset-0 z-50 flex items-end justify-center'>
|
||||
<View className='absolute inset-0 bg-black/50' onClick={closeAddModal} />
|
||||
<View className='relative bg-white rounded-t-2xl w-full px-5 pt-6 pb-10 max-h-[85vh] overflow-y-auto'>
|
||||
<Text className='text-lg font-medium text-gray-800 text-center mb-5 block'>新增商品</Text>
|
||||
|
||||
{/* 商品图片 */}
|
||||
<View className='mb-4'>
|
||||
<Text className='text-sm text-gray-600 mb-2 block'>商品图片 <Text className='text-red-500'>*</Text></Text>
|
||||
<View className='relative w-20 h-20' onClick={handleAddUploadImage}>
|
||||
{addForm.image ? (
|
||||
<Image className='w-20 h-20 rounded-lg' src={getCompressedImageUrl(addForm.image)} mode='aspectFill' />
|
||||
) : (
|
||||
<View className='w-20 h-20 rounded-lg bg-gray-100 flex items-center justify-center'>
|
||||
<Text className='text-2xl text-gray-300'>📷</Text>
|
||||
</View>
|
||||
)}
|
||||
<View className='absolute inset-0 rounded-lg bg-black/30 flex items-center justify-center'>
|
||||
<Text className='text-white text-xs'>
|
||||
{addUploadingImage ? '上传中...' : '上传'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 商品名称 */}
|
||||
<View className='mb-4'>
|
||||
<Text className='text-sm text-gray-600 mb-2 block'>商品名称 <Text className='text-red-500'>*</Text></Text>
|
||||
<Input
|
||||
className='bg-gray-50 rounded-lg px-4 py-3 text-sm'
|
||||
placeholder='请输入商品名称'
|
||||
value={addForm.name}
|
||||
onInput={(e) => setAddForm(prev => ({ ...prev, name: e.detail.value }))}
|
||||
maxlength={100}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 商品分类 */}
|
||||
<View className='mb-4'>
|
||||
<Text className='text-sm text-gray-600 mb-2 block'>商品分类</Text>
|
||||
<View
|
||||
className='bg-gray-50 rounded-lg px-4 py-3 flex items-center justify-between'
|
||||
onClick={() => setShowAddCategoryPicker(true)}
|
||||
>
|
||||
<Text className={`text-sm ${addForm.categoryId ? 'text-gray-800' : 'text-gray-400'}`}>
|
||||
{addForm.categoryId
|
||||
? categories.find(c => c.categoryId === addForm.categoryId)?.title || '选择分类'
|
||||
: '选择分类(选填)'}
|
||||
</Text>
|
||||
<Text className='text-gray-400 text-xs'>▾</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 轮播图 */}
|
||||
<View className='mb-5'>
|
||||
<Text className='text-sm text-gray-600 mb-2 block'>轮播图</Text>
|
||||
<View className='flex flex-wrap gap-3'>
|
||||
{addForm.files.map((file, index) => (
|
||||
<View key={file.url + index} className='relative w-20 h-20'>
|
||||
<Image className='w-20 h-20 rounded-lg bg-gray-100' src={getCompressedImageUrl(file.url)} mode='aspectFill' />
|
||||
<View
|
||||
className='absolute -top-1.5 -right-1.5 w-5 h-5 bg-red-500 rounded-full flex items-center justify-center'
|
||||
onClick={() => handleAddRemoveBanner(index)}
|
||||
>
|
||||
<Text className='text-white text-xs'>×</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
<View
|
||||
className='w-20 h-20 rounded-lg border border-dashed border-gray-300 flex flex-col items-center justify-center'
|
||||
onClick={handleAddUploadBanner}
|
||||
>
|
||||
<Text className='text-2xl text-gray-300 mb-0.5'>{addUploadingBanner ? '...' : '+'}</Text>
|
||||
<Text className='text-xs text-gray-400'>{addUploadingBanner ? '上传中' : '上传'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 到手价 */}
|
||||
<View className='mb-4'>
|
||||
<Text className='text-sm text-gray-600 mb-2 block'>到手价 (¥) <Text className='text-red-500'>*</Text></Text>
|
||||
<Input
|
||||
className='bg-gray-50 rounded-lg px-4 py-3 text-sm'
|
||||
type='digit'
|
||||
placeholder='请输入价格'
|
||||
value={addForm.price}
|
||||
onInput={(e) => setAddForm(prev => ({ ...prev, price: e.detail.value }))}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 市场价 */}
|
||||
<View className='mb-4'>
|
||||
<Text className='text-sm text-gray-600 mb-2 block'>市场价 (¥)</Text>
|
||||
<Input
|
||||
className='bg-gray-50 rounded-lg px-4 py-3 text-sm'
|
||||
type='digit'
|
||||
placeholder='选填,划线价'
|
||||
value={addForm.salePrice}
|
||||
onInput={(e) => setAddForm(prev => ({ ...prev, salePrice: e.detail.value }))}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 会员价 */}
|
||||
<View className='mb-4'>
|
||||
<Text className='text-sm text-gray-600 mb-2 block'>会员价 (¥)</Text>
|
||||
<Input
|
||||
className='bg-gray-50 rounded-lg px-4 py-3 text-sm'
|
||||
type='digit'
|
||||
placeholder='VIP/经销商专享价'
|
||||
value={addForm.dealerPrice}
|
||||
onInput={(e) => setAddForm(prev => ({ ...prev, dealerPrice: e.detail.value }))}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 库存 */}
|
||||
<View className='mb-4'>
|
||||
<Text className='text-sm text-gray-600 mb-2 block'>库存 <Text className='text-red-500'>*</Text></Text>
|
||||
<Input
|
||||
className='bg-gray-50 rounded-lg px-4 py-3 text-sm'
|
||||
type='number'
|
||||
placeholder='请输入库存数量'
|
||||
value={addForm.stock}
|
||||
onInput={(e) => setAddForm(prev => ({ ...prev, stock: e.detail.value }))}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 排序号 */}
|
||||
<View className='mb-4'>
|
||||
<Text className='text-sm text-gray-600 mb-2 block'>排序号</Text>
|
||||
<Input
|
||||
className='bg-gray-50 rounded-lg px-4 py-3 text-sm'
|
||||
type='number'
|
||||
placeholder='数字越小越靠前'
|
||||
value={addForm.sortNumber}
|
||||
onInput={(e) => setAddForm(prev => ({ ...prev, sortNumber: e.detail.value }))}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 备注 */}
|
||||
<View className='mb-6'>
|
||||
<Text className='text-sm text-gray-600 mb-2 block'>备注</Text>
|
||||
<Textarea
|
||||
className='bg-gray-50 rounded-lg px-4 py-3 text-sm w-full'
|
||||
placeholder='选填'
|
||||
value={addForm.comments}
|
||||
onInput={(e) => setAddForm(prev => ({ ...prev, comments: e.detail.value }))}
|
||||
maxlength={200}
|
||||
style={{ minHeight: '60px' }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<View className='flex gap-3'>
|
||||
<View className='flex-1 py-3 rounded-xl bg-gray-100 text-center' onClick={closeAddModal}>
|
||||
<Text className='text-sm text-gray-600'>取消</Text>
|
||||
</View>
|
||||
<View
|
||||
className='flex-1 py-3 rounded-xl bg-cyan-500 text-center'
|
||||
onClick={addSubmitting ? undefined : submitAdd}
|
||||
>
|
||||
<Text className='text-sm text-white'>
|
||||
{addSubmitting ? '添加中...' : '添加'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 新增商品分类选择弹窗 */}
|
||||
{showAddCategoryPicker && (
|
||||
<View className='fixed inset-0 z-[60]' onClick={() => setShowAddCategoryPicker(false)}>
|
||||
<View className='absolute inset-0 bg-black/50' />
|
||||
<View
|
||||
className='absolute bottom-0 left-0 right-0 bg-white rounded-t-2xl max-h-[60vh] overflow-y-auto'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<View className='sticky top-0 bg-white border-b border-gray-50 px-5 py-4 flex justify-between items-center'>
|
||||
<Text className='text-base font-medium text-gray-800'>选择分类</Text>
|
||||
<Text className='text-gray-400 text-lg' onClick={() => setShowAddCategoryPicker(false)}>×</Text>
|
||||
</View>
|
||||
<View
|
||||
className='px-5 py-3.5 border-b border-gray-50'
|
||||
onClick={() => {
|
||||
setAddForm(prev => ({ ...prev, categoryId: undefined }))
|
||||
setShowAddCategoryPicker(false)
|
||||
}}
|
||||
>
|
||||
<Text className='text-sm text-gray-600'>不选分类</Text>
|
||||
</View>
|
||||
{categories.map(cat => (
|
||||
<View
|
||||
key={cat.categoryId}
|
||||
className='px-5 py-3.5 border-b border-gray-50 flex justify-between items-center'
|
||||
onClick={() => {
|
||||
setAddForm(prev => ({ ...prev, categoryId: cat.categoryId }))
|
||||
setShowAddCategoryPicker(false)
|
||||
}}
|
||||
>
|
||||
<Text className='text-sm text-gray-700'>{cat.title}</Text>
|
||||
{addForm.categoryId === cat.categoryId && (
|
||||
<Text className='text-cyan-500'>✓</Text>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
<View className='h-8' />
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,11 +1,13 @@
|
||||
import React, {useState, useEffect, useCallback, useRef} from 'react'
|
||||
import {View, Text, Image, ScrollView} from '@tarojs/components'
|
||||
import {View, Text, Image, ScrollView, Input, Textarea} from '@tarojs/components'
|
||||
import Taro, {useDidShow} from '@tarojs/taro'
|
||||
import {pageShopOrder, updateShopOrder, removeShopOrder} from '@/api/shop/shopOrder'
|
||||
import type {ShopOrder, ShopOrderParam} from '@/api/shop/shopOrder/model'
|
||||
import {TenantId} from '@/config/app'
|
||||
import {useNewOrderDetector} from '@/hooks/useNewOrderDetector'
|
||||
import { getCompressedImageUrl } from '@/utils/image'
|
||||
import { getMyClerk } from '@/api/shop/shopStoreUser'
|
||||
import { ensurePrivacyAuthorized } from '@/api/system/file'
|
||||
|
||||
definePageConfig({
|
||||
navigationBarTitleText: '订单管理',
|
||||
@@ -24,16 +26,18 @@ const TABS: { key: TabKey; label: string; params: Partial<ShopOrderParam>[] }[]
|
||||
]
|
||||
|
||||
// ─── 操作类型 ─────────────────────────────────────────────────────
|
||||
type OpType = 'pay' | 'complete'
|
||||
type OpType = 'pay' | 'complete' | 'editPrice'
|
||||
|
||||
const OP_LABEL: Record<OpType, string> = {
|
||||
pay: '已收款',
|
||||
complete: '已完成',
|
||||
editPrice: '金额',
|
||||
}
|
||||
|
||||
const OP_DESC: Record<OpType, string> = {
|
||||
pay: '变更支付状态为已付款',
|
||||
complete: '同时变更支付状态为已付款、收货状态为已收货、订单状态为已完成',
|
||||
editPrice: '修改订单实付金额',
|
||||
}
|
||||
|
||||
// ─── 图片上传 ─────────────────────────────────────────────────────
|
||||
@@ -92,7 +96,11 @@ function parseSendEndImg(raw: string): string[] {
|
||||
function getStatusText(order: ShopOrder): string {
|
||||
if (order.orderStatus === 1) return '已完成'
|
||||
if (order.orderStatus === 2) return '已关闭'
|
||||
if (order.payStatus === false || order.payStatus === null) return '待收款'
|
||||
if (order.payStatus === false || order.payStatus === null) {
|
||||
// 线下付款待确认收款
|
||||
if (order.payType === 9) return '待确认收款'
|
||||
return '待收款'
|
||||
}
|
||||
if (order.payStatus && order.deliveryStatus === 10) return '待发货'
|
||||
if (order.payStatus && order.deliveryStatus === 20) return '待收货'
|
||||
return '进行中'
|
||||
@@ -115,6 +123,8 @@ function isOrderActionable(order: ShopOrder): boolean {
|
||||
function getOrderActions(order: ShopOrder): { label: string; type: OpType }[] {
|
||||
if (!isOrderActionable(order)) return []
|
||||
const actions: { label: string; type: OpType }[] = []
|
||||
// 修改金额(门店权限)
|
||||
actions.push({label: '修改金额', type: 'editPrice'})
|
||||
// 货到付款:未付款 / 已付款 都直接"确认完成",
|
||||
// 确认完成会同时设置 payStatus=true,无需单独的"确认收款"按钮
|
||||
if (order.orderStatus !== 1 && order.orderStatus !== 2) {
|
||||
@@ -140,9 +150,33 @@ export default function StoreOrdersPage() {
|
||||
const [proofImages, setProofImages] = useState<string[]>([])
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
// 修改金额弹窗状态
|
||||
const [showEditPriceModal, setShowEditPriceModal] = useState(false)
|
||||
const [editPayPrice, setEditPayPrice] = useState('')
|
||||
const [editReason, setEditReason] = useState('')
|
||||
|
||||
const pageSize = 10
|
||||
const loadingRef = useRef(false)
|
||||
|
||||
// ─── 访问控制:仅门店店员可访问此页面 ──────────────────────
|
||||
const [accessChecking, setAccessChecking] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
getMyClerk()
|
||||
.then(data => {
|
||||
if (!data) {
|
||||
Taro.showToast({ title: '无权访问', icon: 'none', duration: 1500 })
|
||||
setTimeout(() => Taro.navigateBack(), 1500)
|
||||
} else {
|
||||
setAccessChecking(false)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
Taro.showToast({ title: '无权访问', icon: 'none', duration: 1500 })
|
||||
setTimeout(() => Taro.navigateBack(), 1500)
|
||||
})
|
||||
}, [])
|
||||
|
||||
// ─── 新订单轮询检测 ──────────────────────────────────────────
|
||||
const { newOrderCount, clearUnread } = useNewOrderDetector({
|
||||
interval: 30000, // 30 秒轮询一次
|
||||
@@ -242,12 +276,70 @@ export default function StoreOrdersPage() {
|
||||
setProofImages([])
|
||||
}
|
||||
|
||||
/** 打开修改金额弹窗 */
|
||||
const openEditPriceModal = (order: ShopOrder) => {
|
||||
setCurrentOrder(order)
|
||||
setEditPayPrice(String(order.payPrice || order.totalPrice || ''))
|
||||
setEditReason('')
|
||||
setShowEditPriceModal(true)
|
||||
}
|
||||
|
||||
/** 关闭修改金额弹窗 */
|
||||
const closeEditPriceModal = () => {
|
||||
setShowEditPriceModal(false)
|
||||
setCurrentOrder(null)
|
||||
setEditPayPrice('')
|
||||
setEditReason('')
|
||||
}
|
||||
|
||||
/** 提交修改金额 */
|
||||
const submitEditPrice = async () => {
|
||||
if (!currentOrder) return
|
||||
|
||||
const newPrice = parseFloat(editPayPrice)
|
||||
if (isNaN(newPrice) || newPrice < 0) {
|
||||
Taro.showToast({title: '请输入有效的金额', icon: 'none'})
|
||||
return
|
||||
}
|
||||
if (!editReason.trim()) {
|
||||
Taro.showToast({title: '请输入修改原因', icon: 'none'})
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const oldPrice = currentOrder.payPrice || currentOrder.totalPrice || '0'
|
||||
const changeRecord = `【门店修改金额】原实付¥${oldPrice} → 新实付¥${newPrice.toFixed(2)},原因:${editReason.trim()}`
|
||||
|
||||
await updateShopOrder({
|
||||
orderId: currentOrder.orderId,
|
||||
payPrice: newPrice.toFixed(2),
|
||||
merchantRemarks: (currentOrder.merchantRemarks || '') + `\n${changeRecord}`,
|
||||
} as ShopOrder)
|
||||
|
||||
Taro.showToast({title: '修改成功', icon: 'success'})
|
||||
closeEditPriceModal()
|
||||
loadOrders(activeTab, 1)
|
||||
} catch (e: any) {
|
||||
Taro.showToast({title: e.message || '修改失败', icon: 'none'})
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** 选择并上传凭证图片 */
|
||||
const chooseProofImage = () => {
|
||||
const chooseProofImage = async () => {
|
||||
const maxCount = 3
|
||||
const remaining = maxCount - proofImages.length
|
||||
if (remaining <= 0) return
|
||||
|
||||
// 门店上传凭证需要访问相册/摄像头,先预检隐私协议授权
|
||||
try {
|
||||
await ensurePrivacyAuthorized()
|
||||
} catch {
|
||||
// 授权失败由 chooseImage 自行处理
|
||||
}
|
||||
|
||||
Taro.chooseImage({
|
||||
count: remaining,
|
||||
sizeType: ['compressed'],
|
||||
@@ -359,18 +451,18 @@ export default function StoreOrdersPage() {
|
||||
{/* 商品列表 */}
|
||||
{orderGoods.map((goods: any, idx: number) => (
|
||||
<View key={idx} className='flex items-center gap-3 mb-3'>
|
||||
{goods.coverImage && (
|
||||
<Image className='w-16 h-16 rounded-lg bg-gray-50' src={getCompressedImageUrl(goods.coverImage)}
|
||||
{goods.image && (
|
||||
<Image className='w-16 h-16 rounded-lg bg-gray-50' src={getCompressedImageUrl(goods.image, { width: 80 })}
|
||||
mode='aspectFill'/>
|
||||
)}
|
||||
<View className='flex-1'>
|
||||
<Text className='text-sm text-gray-800 block'>{goods.goodsName}</Text>
|
||||
{goods.specInfo && (
|
||||
<Text className='text-xs text-gray-400 mt-1 block'>{goods.specInfo}</Text>
|
||||
{goods.spec && (
|
||||
<Text className='text-xs text-gray-400 mt-1 block'>{goods.spec}</Text>
|
||||
)}
|
||||
<View className='flex justify-between items-center mt-1'>
|
||||
<Text className='text-sm text-red-500 font-medium'>¥{goods.price}</Text>
|
||||
<Text className='text-xs text-gray-400'>x{goods.quantity}</Text>
|
||||
<Text className='text-xs text-gray-400'>x{goods.totalNum}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -386,6 +478,14 @@ export default function StoreOrdersPage() {
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 买家备注 */}
|
||||
{order.buyerRemarks && (
|
||||
<View className='bg-orange-50 rounded-lg p-3 mb-3'>
|
||||
<Text className='text-xs text-orange-500 mb-1 block'>买家备注</Text>
|
||||
<Text className='text-sm text-gray-600 block'>{order.buyerRemarks}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 送达凭证(已完成订单) */}
|
||||
{order.sendEndImg && (() => {
|
||||
const imgs = parseSendEndImg(order.sendEndImg)
|
||||
@@ -413,7 +513,7 @@ export default function StoreOrdersPage() {
|
||||
{/* 金额 + 支付方式 */}
|
||||
<View className='flex justify-between items-center mb-3'>
|
||||
<Text className='text-xs text-gray-400'>
|
||||
{order.payType === 0 ? '货到付款' : order.payType === 4 ? '现金支付' : '货到付款'}
|
||||
{order.payType === 8 ? '货到付款' : order.payType === 9 ? '线下付款' : order.payType === 4 ? '现金支付' : order.payType === 1 ? '微信支付' : '其他'}
|
||||
</Text>
|
||||
<Text className='text-sm text-gray-700'>
|
||||
实付:<Text className='text-red-500 font-medium'>¥{order.payPrice || order.totalPrice}</Text>
|
||||
@@ -441,12 +541,14 @@ export default function StoreOrdersPage() {
|
||||
className={`px-4 py-2 rounded-lg ${
|
||||
act.type === 'complete'
|
||||
? 'bg-green-500'
|
||||
: act.type === 'editPrice'
|
||||
? 'bg-orange-500'
|
||||
: 'border border-blue-500'
|
||||
}`}
|
||||
onClick={() => openModal(order, act.type)}
|
||||
onClick={() => act.type === 'editPrice' ? openEditPriceModal(order) : openModal(order, act.type)}
|
||||
>
|
||||
<Text className={`text-sm ${
|
||||
act.type === 'complete' ? 'text-white' : 'text-blue-500'
|
||||
act.type === 'complete' || act.type === 'editPrice' ? 'text-white' : 'text-blue-500'
|
||||
}`}>{act.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
@@ -456,6 +558,11 @@ export default function StoreOrdersPage() {
|
||||
)
|
||||
}
|
||||
|
||||
// 访问校验中或已拒绝:不渲染页面内容
|
||||
if (accessChecking) {
|
||||
return <View className='min-h-full bg-gray-50' />
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='min-h-full bg-gray-50'>
|
||||
{/* Tab 栏 */}
|
||||
@@ -590,6 +697,70 @@ export default function StoreOrdersPage() {
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 修改金额弹窗 */}
|
||||
{showEditPriceModal && currentOrder && (
|
||||
<View className='fixed inset-0 z-50 flex items-end justify-center'>
|
||||
{/* 遮罩 */}
|
||||
<View className='absolute inset-0 bg-black/50' onClick={closeEditPriceModal}/>
|
||||
{/* 弹窗内容 */}
|
||||
<View className='relative bg-white rounded-t-2xl w-full px-5 pt-6 pb-10'>
|
||||
<Text className='text-lg font-medium text-gray-800 text-center mb-5 block'>
|
||||
修改订单金额
|
||||
</Text>
|
||||
|
||||
{/* 订单信息 */}
|
||||
<View className='bg-gray-50 rounded-xl p-4 mb-5'>
|
||||
<Text className='text-sm text-gray-700 block'>订单号:{currentOrder.orderNo}</Text>
|
||||
<Text className='text-sm text-gray-700 mt-1 block'>
|
||||
当前实付:<Text className='text-red-500 font-medium'>¥{currentOrder.payPrice || currentOrder.totalPrice}</Text>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 新金额输入 */}
|
||||
<View className='mb-5'>
|
||||
<Text className='text-sm text-gray-600 mb-2 block'>新实付金额(元)</Text>
|
||||
<Input
|
||||
type='digit'
|
||||
className='w-full px-4 py-3 rounded-xl border border-gray-200 text-lg text-gray-800'
|
||||
value={editPayPrice}
|
||||
onInput={(e) => setEditPayPrice(e.detail.value)}
|
||||
placeholder='请输入新的实付金额'
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 修改原因 */}
|
||||
<View className='mb-6'>
|
||||
<Text className='text-sm text-gray-600 mb-2 block'>修改原因(必填)</Text>
|
||||
<Textarea
|
||||
className='w-full px-4 py-3 rounded-xl border border-gray-200 text-sm text-gray-800'
|
||||
style={{minHeight: '60px'}}
|
||||
value={editReason}
|
||||
onInput={(e) => setEditReason(e.detail.value)}
|
||||
placeholder='请输入修改原因,如:商品缺货调整、协商降价等'
|
||||
maxlength={100}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<View className='flex gap-3'>
|
||||
<View className='flex-1 py-3 rounded-xl bg-gray-100 text-center' onClick={closeEditPriceModal}>
|
||||
<Text className='text-sm text-gray-600'>取消</Text>
|
||||
</View>
|
||||
<View
|
||||
className='flex-1 py-3 rounded-xl bg-orange-500 text-center flex items-center justify-center'
|
||||
onClick={submitting ? undefined : submitEditPrice}
|
||||
>
|
||||
{submitting ? (
|
||||
<Text className='text-sm text-white'>提交中...</Text>
|
||||
) : (
|
||||
<Text className='text-sm text-white'>确认修改</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
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.5 py-0.5 rounded text-[10px]'
|
||||
style={{
|
||||
color: isDisabled ? '#dc2626' : '#16a34a',
|
||||
background: isDisabled ? '#fef2f2' : '#f0fdf4',
|
||||
}}
|
||||
>
|
||||
{isDisabled ? '已禁用' : '正常'}
|
||||
</View>
|
||||
</View>
|
||||
<Text className='text-xs text-gray-400 mt-0.5 block'>
|
||||
{user.phone || user.mobile || '未绑定手机'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 禁用/启用按钮 */}
|
||||
<View
|
||||
className={`px-3 py-1.5 rounded-lg flex-shrink-0 ${isDisabled ? 'bg-green-50' : 'bg-red-50'}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleToggleStatus(user)
|
||||
}}
|
||||
>
|
||||
<Text className={`text-xs ${isDisabled ? 'text-green-500' : 'text-red-500'}`}>
|
||||
{isDisabled ? '启用' : '禁用'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 附加信息 */}
|
||||
<View className='flex items-center gap-4 mt-3 pt-3 border-t border-gray-50'>
|
||||
{user.memberLevelName && (
|
||||
<View className='flex items-center gap-1'>
|
||||
<Text className='text-[10px] text-gray-400'>会员:</Text>
|
||||
<Text className='text-xs text-purple-500'>{user.memberLevelName}</Text>
|
||||
</View>
|
||||
)}
|
||||
<View className='flex items-center gap-1'>
|
||||
<Text className='text-[10px] text-gray-400'>注册:</Text>
|
||||
<Text className='text-xs text-gray-500'>{formatTime(user.createTime)}</Text>
|
||||
</View>
|
||||
{user.balance !== undefined && Number(user.balance) > 0 && (
|
||||
<View className='flex items-center gap-1'>
|
||||
<Text className='text-[10px] text-gray-400'>余额:</Text>
|
||||
<Text className='text-xs text-orange-500'>¥{user.balance}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='min-h-full bg-gray-50'>
|
||||
{/* 搜索栏 */}
|
||||
<View className='bg-white px-3 py-2 flex items-center gap-2'>
|
||||
<View className='flex-1 flex items-center bg-gray-100 rounded-lg px-3 py-1.5'>
|
||||
<Input
|
||||
className='flex-1 text-sm'
|
||||
placeholder='搜索昵称 / 手机号'
|
||||
value={searchText}
|
||||
onInput={(e) => setSearchText(e.detail.value)}
|
||||
onConfirm={handleSearch}
|
||||
confirmType='search'
|
||||
/>
|
||||
{searchText ? (
|
||||
<Text
|
||||
className='text-gray-400 text-lg px-1'
|
||||
onClick={handleClearSearch}
|
||||
>×</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<View
|
||||
className='px-3 py-1.5 rounded-lg bg-blue-500'
|
||||
onClick={handleSearch}
|
||||
>
|
||||
<Text className='text-sm text-white'>搜索</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Tab 栏 */}
|
||||
<View className='bg-white flex border-b border-gray-50'>
|
||||
{TABS.map(tab => (
|
||||
<View
|
||||
key={tab.key}
|
||||
className={`flex-1 text-center py-3 border-b-2 ${activeTab === tab.key ? 'border-blue-500' : 'border-transparent'}`}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
>
|
||||
<Text className={`text-sm ${activeTab === tab.key ? 'text-blue-500 font-medium' : 'text-gray-500'}`}>
|
||||
{tab.label}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* 用户列表 */}
|
||||
<ScrollView
|
||||
scrollY
|
||||
style={{ height: 'calc(100vh - 100px)' }}
|
||||
onScrollToLower={handleLoadMore}
|
||||
lowerThreshold={100}
|
||||
>
|
||||
{loading && userList.length === 0 ? (
|
||||
<View className='flex justify-center items-center py-20'>
|
||||
<Text className='text-gray-400'>加载中...</Text>
|
||||
</View>
|
||||
) : userList.length === 0 ? (
|
||||
<View className='flex justify-center items-center py-20'>
|
||||
<Text className='text-gray-400'>暂无用户</Text>
|
||||
</View>
|
||||
) : (
|
||||
userList.map(renderUserCard)
|
||||
)}
|
||||
{loading && userList.length > 0 && (
|
||||
<View className='flex justify-center items-center py-4'>
|
||||
<Text className='text-gray-400 text-sm'>加载中...</Text>
|
||||
</View>
|
||||
)}
|
||||
{!hasMore && userList.length > 0 && (
|
||||
<View className='flex justify-center items-center py-4'>
|
||||
<Text className='text-gray-300 text-xs'>没有更多了</Text>
|
||||
</View>
|
||||
)}
|
||||
<View className='h-6' />
|
||||
</ScrollView>
|
||||
|
||||
{/* 用户详情弹窗 */}
|
||||
{showDetail && detailUser && (
|
||||
<View className='fixed inset-0 z-50 flex items-end justify-center'>
|
||||
{/* 遮罩 */}
|
||||
<View className='absolute inset-0 bg-black/50' onClick={() => setShowDetail(false)} />
|
||||
{/* 弹窗内容 */}
|
||||
<View className='relative bg-white rounded-t-2xl w-full px-5 pt-6 pb-10 max-h-[75vh] overflow-y-auto'>
|
||||
{/* 标题 */}
|
||||
<View className='flex justify-between items-center mb-5'>
|
||||
<Text className='text-lg font-medium text-gray-800'>用户详情</Text>
|
||||
<Text className='text-gray-400 text-lg' onClick={() => setShowDetail(false)}>×</Text>
|
||||
</View>
|
||||
|
||||
{/* 用户基本信息 */}
|
||||
<View className='flex items-center gap-4 mb-5'>
|
||||
<View className='w-16 h-16 rounded-full overflow-hidden flex-shrink-0 bg-gray-100'>
|
||||
{detailUser.avatar ? (
|
||||
<Image
|
||||
className='w-full h-full'
|
||||
src={getCompressedImageUrl(detailUser.avatar, { width: 100, quality: 90 })}
|
||||
mode='aspectFill'
|
||||
/>
|
||||
) : (
|
||||
<View className='w-full h-full flex items-center justify-center'>
|
||||
<Text className='text-3xl'>👤</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<View className='flex-1 min-w-0'>
|
||||
<View className='flex items-center gap-2'>
|
||||
<Text className='text-base font-medium text-gray-800'>
|
||||
{detailUser.nickname || detailUser.realName || '未设置昵称'}
|
||||
</Text>
|
||||
<View
|
||||
className='px-2 py-0.5 rounded text-xs'
|
||||
style={{
|
||||
color: detailUser.status === 1 ? '#dc2626' : '#16a34a',
|
||||
background: detailUser.status === 1 ? '#fef2f2' : '#f0fdf4',
|
||||
}}
|
||||
>
|
||||
{detailUser.status === 1 ? '已禁用' : '正常'}
|
||||
</View>
|
||||
</View>
|
||||
<Text className='text-xs text-gray-400 mt-1 block'>
|
||||
ID: {detailUser.userId || detailUser.id}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 详细信息列表 */}
|
||||
<View className='bg-gray-50 rounded-xl p-4 mb-5'>
|
||||
{/* 手机号 */}
|
||||
<View className='flex justify-between items-center py-2 border-b border-gray-100'>
|
||||
<Text className='text-sm text-gray-500'>手机号</Text>
|
||||
<Text className='text-sm text-gray-800'>{detailUser.phone || detailUser.mobile || '-'}</Text>
|
||||
</View>
|
||||
|
||||
{/* 真实姓名 */}
|
||||
{detailUser.realName && (
|
||||
<View className='flex justify-between items-center py-2 border-b border-gray-100'>
|
||||
<Text className='text-sm text-gray-500'>真实姓名</Text>
|
||||
<Text className='text-sm text-gray-800'>{detailUser.realName}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 性别 */}
|
||||
<View className='flex justify-between items-center py-2 border-b border-gray-100'>
|
||||
<Text className='text-sm text-gray-500'>性别</Text>
|
||||
<Text className='text-sm text-gray-800'>
|
||||
{detailUser.gender === 1 ? '男' : detailUser.gender === 2 ? '女' : '未知'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 会员等级 */}
|
||||
{detailUser.memberLevelName && (
|
||||
<View className='flex justify-between items-center py-2 border-b border-gray-100'>
|
||||
<Text className='text-sm text-gray-500'>会员等级</Text>
|
||||
<Text className='text-sm text-purple-500'>{detailUser.memberLevelName}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 余额 */}
|
||||
{detailUser.balance !== undefined && (
|
||||
<View className='flex justify-between items-center py-2 border-b border-gray-100'>
|
||||
<Text className='text-sm text-gray-500'>余额</Text>
|
||||
<Text className='text-sm text-orange-500'>¥{detailUser.balance || '0.00'}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 积分 */}
|
||||
{detailUser.points !== undefined && (
|
||||
<View className='flex justify-between items-center py-2 border-b border-gray-100'>
|
||||
<Text className='text-sm text-gray-500'>积分</Text>
|
||||
<Text className='text-sm text-gray-800'>{detailUser.points || 0}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 邮箱 */}
|
||||
{detailUser.email && (
|
||||
<View className='flex justify-between items-center py-2 border-b border-gray-100'>
|
||||
<Text className='text-sm text-gray-500'>邮箱</Text>
|
||||
<Text className='text-sm text-gray-800'>{detailUser.email}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 注册时间 */}
|
||||
<View className='flex justify-between items-center py-2'>
|
||||
<Text className='text-sm text-gray-500'>注册时间</Text>
|
||||
<Text className='text-sm text-gray-800'>{formatTime(detailUser.createTime)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<View className='flex gap-3'>
|
||||
<View
|
||||
className='flex-1 py-3 rounded-xl bg-gray-100 text-center'
|
||||
onClick={() => setShowDetail(false)}
|
||||
>
|
||||
<Text className='text-sm text-gray-600'>关闭</Text>
|
||||
</View>
|
||||
<View
|
||||
className={`flex-1 py-3 rounded-xl text-center ${detailUser.status === 1 ? 'bg-green-500' : 'bg-red-500'}`}
|
||||
onClick={() => handleToggleStatus(detailUser)}
|
||||
>
|
||||
<Text className='text-sm text-white'>
|
||||
{detailUser.status === 1 ? '启用用户' : '禁用用户'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -384,10 +408,23 @@ const AddressEditPage: React.FC = () => {
|
||||
}
|
||||
|
||||
// 执行新增或更新
|
||||
let savedId: number | undefined = addressId
|
||||
if (isEditMode) {
|
||||
await updateShopUserAddress(submitData)
|
||||
} else {
|
||||
await addShopUserAddress(submitData)
|
||||
const res: any = await addShopUserAddress(submitData)
|
||||
// 后端新增返回可能带有 id 字段
|
||||
savedId = (res && (res.data?.id || res.id)) || addressId
|
||||
}
|
||||
|
||||
// 兜底:勾选默认时主动调用 set-default,让后端走统一的"取消其他默认"逻辑
|
||||
// 这样即使 save/update 在某些老版本后端上漏处理,前端也能保证唯一默认
|
||||
if (submitData.isDefault && savedId) {
|
||||
try {
|
||||
await setDefaultAddress(savedId)
|
||||
} catch (e) {
|
||||
console.warn('setDefaultAddress fallback failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
Taro.showToast({title: `${isEditMode ? '更新' : '保存'}成功`, icon: 'success'})
|
||||
@@ -432,11 +469,14 @@ const AddressEditPage: React.FC = () => {
|
||||
// 编辑模式:加载已有地址数据
|
||||
if (isEditMode && addressId) {
|
||||
try {
|
||||
const addr = await getShopUserAddress(addressId)
|
||||
const addr = (await getShopUserAddress(addressId)) as ShopUserAddress
|
||||
setFormData(addr)
|
||||
setRegionText(`${addr.province || ''} ${addr.city || ''} ${addr.region || ''}`)
|
||||
const p = String((addr as any)?.province || '').trim()
|
||||
const c = String((addr as any)?.city || '').trim()
|
||||
const r = String((addr as any)?.region || '').trim()
|
||||
setRegionText([p, c, r].filter(Boolean).join(' '))
|
||||
if (hasValidLngLat(addr)) {
|
||||
setSelectedLocation({lng: String(addr.lng), lat: String(addr.lat)})
|
||||
setSelectedLocation({lng: String((addr as any).lng), lat: String((addr as any).lat)})
|
||||
setRegionLocked(true)
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -465,7 +505,7 @@ const AddressEditPage: React.FC = () => {
|
||||
{/* 地址智能识别区 */}
|
||||
<View className="bg-white px-3 pt-3 pb-2">
|
||||
<View
|
||||
className="rounded-lg p-2 relative"
|
||||
className="rounded-lg p-2"
|
||||
style={{border: '1px dashed #0e932e'}}
|
||||
>
|
||||
<Textarea
|
||||
@@ -475,19 +515,20 @@ const AddressEditPage: React.FC = () => {
|
||||
placeholder="粘贴文本,点击「识别」自动识别收货人、地址、电话"
|
||||
maxlength={200}
|
||||
/>
|
||||
<View
|
||||
className="absolute right-2 bottom-2"
|
||||
style={{
|
||||
backgroundColor: '#0e932e',
|
||||
borderRadius: '12px',
|
||||
paddingLeft: '12px',
|
||||
paddingRight: '12px',
|
||||
paddingTop: '4px',
|
||||
paddingBottom: '4px'
|
||||
}}
|
||||
onClick={recognizeAddress}
|
||||
>
|
||||
<Text className="text-white text-xs">识别</Text>
|
||||
<View className="flex justify-end mt-2">
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: '#0e932e',
|
||||
borderRadius: '12px',
|
||||
paddingLeft: '12px',
|
||||
paddingRight: '12px',
|
||||
paddingTop: '4px',
|
||||
paddingBottom: '4px'
|
||||
}}
|
||||
onClick={recognizeAddress}
|
||||
>
|
||||
<Text className="text-white text-xs">识别</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -11,6 +11,8 @@ import type { UserRole } from '@/api/system/userRole/model'
|
||||
import { checkAndCacheVipStatus } from '@/utils/vip'
|
||||
import { requireLogin } from '@/utils/login-guard'
|
||||
import MemberBadge from '@/components/business/MemberBadge'
|
||||
import Badge from '@/components/common/Badge'
|
||||
import ArrowRight from '@/components/common/ArrowRight'
|
||||
|
||||
const UserPage: React.FC = () => {
|
||||
const scrollHeight = useScrollHeight()
|
||||
@@ -41,7 +43,8 @@ const UserPage: React.FC = () => {
|
||||
cacheKey: 'user_order_stats',
|
||||
cacheTime: 30 * 1000,
|
||||
onSuccess: (data) => {
|
||||
Taro.setStorageSync('user_order_stats_v2', data)
|
||||
// v3: 字段结构变更(与后端 UserOrderStats DTO 对齐),旧版 v2 缓存自动失效
|
||||
Taro.setStorageSync('user_order_stats_v3', data)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -51,7 +54,7 @@ const UserPage: React.FC = () => {
|
||||
// 冷启动优化:先从 localStorage 读取缓存即时展示,消除骨架屏
|
||||
const cachedCardStats = Taro.getStorageSync('user_card_stats_v2')
|
||||
if (cachedCardStats) mutateCardStats(cachedCardStats)
|
||||
const cachedOrderStats = Taro.getStorageSync('user_order_stats_v2')
|
||||
const cachedOrderStats = Taro.getStorageSync('user_order_stats_v3')
|
||||
if (cachedOrderStats) mutateOrderStats(cachedOrderStats)
|
||||
// 然后发起网络请求(stale-while-revalidate 后台刷新)
|
||||
runCardStats()
|
||||
@@ -78,7 +81,7 @@ const UserPage: React.FC = () => {
|
||||
if (cachedCardStats) {
|
||||
mutateCardStats(cachedCardStats)
|
||||
}
|
||||
const cachedOrderStats = Taro.getStorageSync('user_order_stats_v2')
|
||||
const cachedOrderStats = Taro.getStorageSync('user_order_stats_v3')
|
||||
if (cachedOrderStats) {
|
||||
mutateOrderStats(cachedOrderStats)
|
||||
}
|
||||
@@ -106,7 +109,7 @@ const UserPage: React.FC = () => {
|
||||
{ icon: '📍', label: '收货地址', url: '/pages/user/address-list', requireAuth: true },
|
||||
// { icon: '⭐', label: '积分明细', url: '/pages/user/points-record' },
|
||||
{ icon: '❤️', label: '我的收藏', url: '/pages/user/favorite-list/index', requireAuth: true },
|
||||
// { icon: '🕐', label: '浏览历史', url: '/pages/user/history-list/index' },
|
||||
{ icon: '🕐', label: '浏览历史', url: '/pages/user/history-list/index' },
|
||||
{ icon: '❓', label: '帮助中心', url: '/pages/user/help-center/index' },
|
||||
// { icon: '🔔', label: '消息通知', url: '/pages/index/notification' },
|
||||
// 门店管理:仅门店经理/店员显示
|
||||
@@ -135,15 +138,15 @@ const UserPage: React.FC = () => {
|
||||
}
|
||||
|
||||
// 订单快捷入口点击:需要登录
|
||||
const handleOrderTabClick = (status: number) => {
|
||||
if (!requireLogin({ action: 'viewOrder', redirect: '/pages/order/list', content: '登录后才能查看订单,是否前往登录?' })) return
|
||||
Taro.setStorageSync('order_tab', status)
|
||||
Taro.switchTab({ url: '/pages/order/list' })
|
||||
const handleOrderTabClick = (tabIndex: number) => {
|
||||
if (!requireLogin({ action: 'viewOrder', redirect: `/pages/order/list?tab=${tabIndex}`, content: '登录后才能查看订单,是否前往登录?' })) return
|
||||
Taro.setStorageSync('order_tab', tabIndex)
|
||||
Taro.navigateTo({ url: `/pages/order/list?tab=${tabIndex}` })
|
||||
}
|
||||
|
||||
const handleViewAllOrders = () => {
|
||||
if (!requireLogin({ action: 'viewOrder', redirect: '/pages/order/list', content: '登录后才能查看订单,是否前往登录?' })) return
|
||||
Taro.switchTab({ url: '/pages/order/list' })
|
||||
Taro.navigateTo({ url: '/pages/order/list' })
|
||||
}
|
||||
|
||||
const loading = cardStatsLoading || orderStatsLoading
|
||||
@@ -214,7 +217,7 @@ const UserPage: React.FC = () => {
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
{isLoggedIn && <Text className='text-white text-opacity-60 text-sm'>{'>'}</Text>}
|
||||
{isLoggedIn && <ArrowRight className='text-white text-opacity-60' />}
|
||||
</View>
|
||||
{/* 数据概览 */}
|
||||
{isLoggedIn && (
|
||||
@@ -257,30 +260,28 @@ const UserPage: React.FC = () => {
|
||||
<View className='bg-white rounded-xl mx-3 mt-3 p-4'>
|
||||
<View className='flex justify-between items-center mb-3'>
|
||||
<Text className='text-base font-medium text-gray-800'>我的订单</Text>
|
||||
<Text className='text-xs text-gray-400' onClick={handleViewAllOrders}>
|
||||
全部订单 {'>'}
|
||||
</Text>
|
||||
<View className='flex items-center text-xs text-gray-400' onClick={handleViewAllOrders}>
|
||||
<Text>全部订单</Text>
|
||||
<ArrowRight className='ml-1' />
|
||||
</View>
|
||||
</View>
|
||||
<View className='grid grid-cols-4 gap-2'>
|
||||
{[
|
||||
{ icon: '💳', label: '待付款', status: 0, count: orderStats?.pending },
|
||||
{ icon: '📦', label: '待发货', status: 1, count: orderStats?.paid },
|
||||
{ icon: '🚚', label: '待收货', status: 2, count: orderStats?.shipped },
|
||||
{ icon: '✅', label: '已完成', status: 3, count: orderStats?.completed },
|
||||
// status 与 OrderListStatus 对齐:0待付款 1待发货 3待收货 5已完成
|
||||
{ icon: '💳', label: '待付款', tabIndex: 1, count: orderStats?.waitPay },
|
||||
{ icon: '📦', label: '待发货', tabIndex: 2, count: orderStats?.waitDeliver },
|
||||
{ icon: '🚚', label: '待收货', tabIndex: 3, count: orderStats?.waitReceive },
|
||||
{ icon: '✅', label: '已完成', tabIndex: 4, count: orderStats?.completed },
|
||||
].map(item => (
|
||||
<View
|
||||
key={item.status}
|
||||
key={item.tabIndex}
|
||||
className={`flex flex-col items-center py-2 relative ${!isLoggedIn ? 'opacity-50' : ''}`}
|
||||
onClick={() => handleOrderTabClick(item.status)}
|
||||
onClick={() => handleOrderTabClick(item.tabIndex)}
|
||||
>
|
||||
<Text className='text-xl mb-1'>{item.icon}</Text>
|
||||
<Text className='text-xs text-gray-600'>{item.label}</Text>
|
||||
{/* 数量角标 */}
|
||||
{item.count && item.count > 0 && (
|
||||
<View className='absolute top-0 right-2 bg-red-500 text-white text-xs rounded-full min-w-4 h-4 flex items-center justify-center px-1'>
|
||||
{item.count > 99 ? '99+' : item.count}
|
||||
</View>
|
||||
)}
|
||||
{/* 数量角标:count 为 0 时组件内部不渲染 */}
|
||||
<Badge count={item.count} className='absolute -top-1 -right-1' />
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
@@ -311,7 +312,7 @@ const UserPage: React.FC = () => {
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
<Text className={`text-sm ${(item as any).highlight ? 'text-amber-700' : 'text-gray-300'}`}>{'>'}</Text>
|
||||
<ArrowRight className={(item as any).highlight ? 'text-amber-700' : 'text-gray-300'} />
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
@@ -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,17 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { View, Image, Text, Button } from '@tarojs/components'
|
||||
import { TenantId } from '@/config/app'
|
||||
import { getWxOpenId } from '@/api/layout'
|
||||
import { getUserInfo } from '@/api/layout'
|
||||
import { saveStorageByLoginUser } from '@/utils/server'
|
||||
import { saveStorageByLoginUser, SERVER_API_URL } from '@/utils/server'
|
||||
import { isUserDisabled } from '@/utils/auth'
|
||||
import request from '@/utils/request'
|
||||
import {
|
||||
getPhoneAuthCooldownRemaining,
|
||||
isPhoneAuthCoolingDown,
|
||||
markPhoneAuthCalled,
|
||||
} from '@/utils/phoneAuth'
|
||||
import {
|
||||
checkAndHandleInviteRelation,
|
||||
hasPendingInvite,
|
||||
@@ -74,15 +81,15 @@ async function ensureWxOpenIdSaved() {
|
||||
/** 获取用户信息 */
|
||||
async function fetchUserInfo(token: string) {
|
||||
try {
|
||||
const serverUrl = 'https://server.websoft.top'
|
||||
const res: any = await Taro.request({
|
||||
url: `${serverUrl}/api/auth/user`,
|
||||
url: `${SERVER_API_URL}/auth/user`,
|
||||
method: 'GET',
|
||||
header: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
Authorization: token,
|
||||
'content-type': 'application/json',
|
||||
TenantId: String(TenantId),
|
||||
},
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
if (res.data?.code === 0 && res.data?.data) {
|
||||
@@ -98,16 +105,89 @@ async function fetchUserInfo(token: string) {
|
||||
const Login = () => {
|
||||
const [isAgree, setIsAgree] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [phoneAuthPending, setPhoneAuthPending] = useState(false)
|
||||
const [phoneAuthCooling, setPhoneAuthCooling] = useState(isPhoneAuthCoolingDown())
|
||||
const [showContent, setShowContent] = useState(false)
|
||||
const phoneAuthInFlightRef = useRef(false)
|
||||
const phoneAuthPendingRef = useRef(false)
|
||||
const phoneAuthTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const phoneAuthPendingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const router = Taro.getCurrentInstance().router
|
||||
const isWeapp = IS_WEAPP
|
||||
const canUsePhoneAuth = isAgree && !loading && !phoneAuthPending && !phoneAuthCooling
|
||||
|
||||
/** 页面加载动画 */
|
||||
useEffect(() => {
|
||||
setTimeout(() => setShowContent(true), 100)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (phoneAuthTimerRef.current) {
|
||||
clearTimeout(phoneAuthTimerRef.current)
|
||||
phoneAuthTimerRef.current = null
|
||||
}
|
||||
if (phoneAuthPendingTimerRef.current) {
|
||||
clearTimeout(phoneAuthPendingTimerRef.current)
|
||||
phoneAuthPendingTimerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startPhoneAuthCooldown = () => {
|
||||
const remaining = Math.max(getPhoneAuthCooldownRemaining(), 300)
|
||||
setPhoneAuthCooling(true)
|
||||
if (phoneAuthTimerRef.current) clearTimeout(phoneAuthTimerRef.current)
|
||||
phoneAuthTimerRef.current = setTimeout(() => {
|
||||
phoneAuthTimerRef.current = null
|
||||
setPhoneAuthCooling(false)
|
||||
}, remaining)
|
||||
}
|
||||
|
||||
const clearPhoneAuthPending = () => {
|
||||
phoneAuthPendingRef.current = false
|
||||
setPhoneAuthPending(false)
|
||||
if (phoneAuthPendingTimerRef.current) {
|
||||
clearTimeout(phoneAuthPendingTimerRef.current)
|
||||
phoneAuthPendingTimerRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const startPhoneAuthPending = () => {
|
||||
phoneAuthPendingRef.current = true
|
||||
setPhoneAuthPending(true)
|
||||
markPhoneAuthCalled()
|
||||
startPhoneAuthCooldown()
|
||||
if (phoneAuthPendingTimerRef.current) clearTimeout(phoneAuthPendingTimerRef.current)
|
||||
phoneAuthPendingTimerRef.current = setTimeout(() => {
|
||||
clearPhoneAuthPending()
|
||||
Taro.showModal({
|
||||
title: '手机号授权超时',
|
||||
content: '微信手机号授权暂时无响应,请稍后重试或改用短信验证码登录。',
|
||||
confirmText: '短信登录',
|
||||
cancelText: '稍后重试',
|
||||
confirmColor: '#07c160',
|
||||
success: (res) => {
|
||||
if (res.confirm) goSmsLogin()
|
||||
},
|
||||
})
|
||||
}, 12000)
|
||||
}
|
||||
|
||||
const handlePhoneAuthTap = () => {
|
||||
if (!isAgree) {
|
||||
Taro.showToast({ title: '请先勾选同意协议', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (loading || phoneAuthInFlightRef.current || phoneAuthPendingRef.current || isPhoneAuthCoolingDown()) {
|
||||
startPhoneAuthCooldown()
|
||||
Taro.showToast({ title: '请稍后再试', icon: 'none' })
|
||||
return
|
||||
}
|
||||
startPhoneAuthPending()
|
||||
}
|
||||
|
||||
/** 解析 redirect 参数 */
|
||||
const redirectUrl = (() => {
|
||||
const raw = (router?.params as Record<string, string> | undefined)?.redirect
|
||||
@@ -143,9 +223,8 @@ const Login = () => {
|
||||
const tabBarUrls = [
|
||||
'/pages/index/index',
|
||||
'/pages/shop/index',
|
||||
'/pages/points/index',
|
||||
'/pages/user/user',
|
||||
'/pages/order/list',
|
||||
'/pages/shop/cart',
|
||||
]
|
||||
const pure = redirectUrl.split('?')[0]
|
||||
|
||||
@@ -157,17 +236,65 @@ const Login = () => {
|
||||
await Taro.redirectTo({ url: redirectUrl })
|
||||
}
|
||||
|
||||
/** 跳转到短信验证码登录(降级方案) */
|
||||
const goSmsLogin = () => {
|
||||
const params: Record<string, string> = {}
|
||||
if (redirectUrl) params.redirect = redirectUrl
|
||||
// 透传邀请参数,避免从登录页切到短信登录时丢失 inviter
|
||||
const inviteParams = parseInviteParams({ query: router?.params })
|
||||
if (inviteParams?.inviter) params.inviter = inviteParams.inviter
|
||||
if (inviteParams?.source) params.source = inviteParams.source
|
||||
if (inviteParams?.t) params.t = inviteParams.t
|
||||
|
||||
const qs = Object.entries(params)
|
||||
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
|
||||
.join('&')
|
||||
Taro.navigateTo({ url: `/passport/sms-login${qs ? `?${qs}` : ''}` })
|
||||
}
|
||||
|
||||
/** 手机号授权失败时弹窗引导走短信登录(区分原因) */
|
||||
const showPhoneAuthFailedModal = (errMsg?: string) => {
|
||||
let content = '请尝试使用短信验证码登录'
|
||||
if (errMsg?.includes('user deny')) {
|
||||
content = '您已取消微信手机号授权,可改用短信验证码登录'
|
||||
} else if (errMsg?.includes('privacy')) {
|
||||
content = '微信未授权获取手机号,可改用短信验证码登录'
|
||||
} else if (errMsg?.includes('no permission')) {
|
||||
content = '小程序暂未获得手机号授权,可改用短信验证码登录'
|
||||
}
|
||||
Taro.showModal({
|
||||
title: '未获取到手机号',
|
||||
content,
|
||||
confirmText: '短信登录',
|
||||
cancelText: '我知道了',
|
||||
confirmColor: '#07c160',
|
||||
success: (res) => {
|
||||
if (res.confirm) goSmsLogin()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 手机号快捷登录 */
|
||||
const handleGetPhoneNumber = async ({ detail }: GetPhoneNumberEvent) => {
|
||||
clearPhoneAuthPending()
|
||||
|
||||
if (!isAgree) {
|
||||
Taro.showToast({ title: '请先勾选同意协议', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (loading) return
|
||||
if (loading || phoneAuthInFlightRef.current) {
|
||||
startPhoneAuthCooldown()
|
||||
Taro.showToast({ title: '请稍后再试', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
const { code: phoneCode, errMsg } = detail || {}
|
||||
phoneAuthInFlightRef.current = true
|
||||
startPhoneAuthCooldown()
|
||||
|
||||
const { code: phoneCode, encryptedData, iv, errMsg } = detail || {}
|
||||
if (!phoneCode || (errMsg && errMsg.includes('fail'))) {
|
||||
Taro.showToast({ title: '未授权手机号', icon: 'none' })
|
||||
phoneAuthInFlightRef.current = false
|
||||
showPhoneAuthFailedModal(errMsg)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -177,23 +304,28 @@ const Login = () => {
|
||||
const inviteParams = parseInviteParams({ query: router?.params })
|
||||
const refereeId = inviteParams?.inviter ? parseInt(inviteParams.inviter, 10) : 0
|
||||
|
||||
const serverUrl = 'https://server.websoft.top'
|
||||
const res: any = await Taro.request({
|
||||
url: `${serverUrl}/api/wx-login/loginByMpWxPhone`,
|
||||
method: 'POST',
|
||||
data: {
|
||||
const res: any = await request.post(
|
||||
`${SERVER_API_URL}/wx-login/loginByMpWxPhone`,
|
||||
{
|
||||
code: phoneCode,
|
||||
encryptedData,
|
||||
iv,
|
||||
notVerifyPhone: true,
|
||||
refereeId,
|
||||
sceneType: 'save_referee',
|
||||
tenantId: Number(TenantId),
|
||||
},
|
||||
header: { 'content-type': 'application/json', TenantId: String(TenantId) },
|
||||
})
|
||||
{
|
||||
timeout: 20000,
|
||||
retry: 0,
|
||||
showError: false,
|
||||
returnRaw: true,
|
||||
}
|
||||
)
|
||||
|
||||
if (res.data?.code === 0 && res.data?.data?.access_token) {
|
||||
const token = res.data.data.access_token
|
||||
let user = res.data.data.user
|
||||
if (res?.code === 0 && res?.data?.access_token) {
|
||||
const token = res.data.access_token
|
||||
let user = res.data.user
|
||||
|
||||
// 获取最新的用户信息
|
||||
const freshUserInfo = await fetchUserInfo(token)
|
||||
@@ -201,6 +333,18 @@ const Login = () => {
|
||||
user = freshUserInfo
|
||||
}
|
||||
|
||||
// 检查用户是否被禁用
|
||||
if (isUserDisabled(user)) {
|
||||
Taro.showModal({
|
||||
title: '账号已被禁用',
|
||||
content: '您的账号已被管理员禁用,暂时无法使用小程序。如有疑问请联系门店客服。',
|
||||
showCancel: false,
|
||||
confirmText: '我知道了',
|
||||
confirmColor: '#ee0a24',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
saveStorageByLoginUser(token, user)
|
||||
|
||||
// 绑定 openid + 处理邀请关系
|
||||
@@ -212,12 +356,14 @@ const Login = () => {
|
||||
Taro.showToast({ title: '登录成功', icon: 'success' })
|
||||
setTimeout(() => navigateAfterLogin(), 800)
|
||||
} else {
|
||||
Taro.showToast({ title: res.data?.message || '登录失败', icon: 'none' })
|
||||
Taro.showToast({ title: res?.message || '登录失败', icon: 'none' })
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('微信登录失败:', e)
|
||||
Taro.showToast({ title: e?.message || '登录失败', icon: 'none' })
|
||||
} finally {
|
||||
phoneAuthInFlightRef.current = false
|
||||
clearPhoneAuthPending()
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
@@ -257,20 +403,30 @@ const Login = () => {
|
||||
color: '#ffffff',
|
||||
fontWeight: '600',
|
||||
textAlign: 'center',
|
||||
opacity: (!isAgree || loading) ? 0.5 : 1,
|
||||
opacity: canUsePhoneAuth ? 1 : 0.5,
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
}}
|
||||
openType='getPhoneNumber'
|
||||
openType={canUsePhoneAuth ? 'getPhoneNumber' : undefined}
|
||||
onClick={handlePhoneAuthTap}
|
||||
onGetPhoneNumber={handleGetPhoneNumber}
|
||||
disabled={!isAgree || loading}
|
||||
loading={loading}
|
||||
disabled={!canUsePhoneAuth}
|
||||
loading={loading || phoneAuthPending}
|
||||
>
|
||||
手机号快捷登录
|
||||
</Button>
|
||||
|
||||
<View className='login-methods__tip text-gray-200'>
|
||||
<Text>点击上方按钮,快速登录</Text>
|
||||
{/*<View className='login-methods__tip text-gray-200'>*/}
|
||||
{/* <Text>点击上方按钮,快速登录</Text>*/}
|
||||
{/*</View>*/}
|
||||
|
||||
<View className='login-methods__alt'>
|
||||
<Text
|
||||
className='login-methods__alt-link'
|
||||
onClick={() => goSmsLogin()}
|
||||
>
|
||||
使用短信验证码登录
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className='login-methods__divider'>
|
||||
|
||||
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,7 @@ import { Button, Checkbox } from '@nutui/nutui-react-taro'
|
||||
import { TenantId } from '@/config/app'
|
||||
import { getUserInfo, getWxOpenId } from '@/api/layout'
|
||||
import { saveStorageByLoginUser } from '@/utils/server'
|
||||
import { isUserDisabled } from '@/utils/auth'
|
||||
import {
|
||||
getStoredInviteParams,
|
||||
parseInviteParams,
|
||||
@@ -88,9 +89,9 @@ function isTabBarUrl(url: string) {
|
||||
const pure = url.split('?')[0]
|
||||
return (
|
||||
pure === '/pages/index/index' ||
|
||||
pure === '/pages/cart/cart' ||
|
||||
pure === '/pages/user/user' ||
|
||||
pure === '/pages/category/index'
|
||||
pure === '/pages/shop/index' ||
|
||||
pure === '/pages/shop/cart' ||
|
||||
pure === '/pages/user/user'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -109,10 +110,6 @@ const Register = () => {
|
||||
|
||||
const router = Taro.getCurrentInstance().router
|
||||
|
||||
useEffect(() => {
|
||||
// register 页面不是 tabBar 页面,无需调用 hideTabBar
|
||||
}, [])
|
||||
|
||||
const redirectUrl = useMemo(() => {
|
||||
const raw = (router?.params as any)?.redirect as string | undefined
|
||||
const decoded = safeDecodeMaybeEncoded(raw)
|
||||
@@ -149,6 +146,28 @@ const Register = () => {
|
||||
await Taro.redirectTo({ url: redirectUrl })
|
||||
}
|
||||
|
||||
/** 手机号授权失败时弹窗引导走短信注册/登录(区分原因) */
|
||||
const showPhoneAuthFailedModal = (errMsg?: string) => {
|
||||
let content = '请尝试使用短信验证码注册/登录'
|
||||
if (errMsg?.includes('user deny')) {
|
||||
content = '您已取消微信手机号授权,可改用短信验证码注册/登录'
|
||||
} else if (errMsg?.includes('privacy')) {
|
||||
content = '微信未授权获取手机号,可改用短信验证码注册/登录'
|
||||
} else if (errMsg?.includes('no permission')) {
|
||||
content = '小程序暂未获得手机号授权,可改用短信验证码注册/登录'
|
||||
}
|
||||
Taro.showModal({
|
||||
title: '未获取到手机号',
|
||||
content,
|
||||
confirmText: '短信注册/登录',
|
||||
cancelText: '我知道了',
|
||||
confirmColor: '#07c160',
|
||||
success: (res) => {
|
||||
if (res.confirm) goSmsLogin()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleGetPhoneNumber = async ({ detail }: GetPhoneNumberEvent) => {
|
||||
if (!isAgree) {
|
||||
Taro.showToast({ title: '请先勾选同意协议', icon: 'none' })
|
||||
@@ -158,7 +177,7 @@ const Register = () => {
|
||||
|
||||
const { code: phoneCode, encryptedData, iv, errMsg } = detail || {}
|
||||
if (!phoneCode || (errMsg && errMsg.includes('fail'))) {
|
||||
Taro.showToast({ title: '未授权手机号', icon: 'none' })
|
||||
showPhoneAuthFailedModal(errMsg)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -202,6 +221,18 @@ const Register = () => {
|
||||
return
|
||||
}
|
||||
|
||||
// 检查用户是否被禁用
|
||||
if (isUserDisabled(user)) {
|
||||
Taro.showModal({
|
||||
title: '账号已被禁用',
|
||||
content: '您的账号已被管理员禁用,暂时无法使用小程序。如有疑问请联系门店客服。',
|
||||
showCancel: false,
|
||||
confirmText: '我知道了',
|
||||
confirmColor: '#ee0a24',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
saveStorageByLoginUser(token, user)
|
||||
|
||||
// 注册/登录成功后,立即补齐 openid(JSAPI 支付必需)
|
||||
@@ -268,11 +299,9 @@ const Register = () => {
|
||||
手机号一键注册/登录
|
||||
</Button>
|
||||
|
||||
{!isWeapp && (
|
||||
<Button type='default' block disabled={!isAgree || loading} onClick={goSmsLogin}>
|
||||
短信验证码注册/登录
|
||||
</Button>
|
||||
)}
|
||||
<Button type='default' block disabled={!isAgree || loading} onClick={goSmsLogin}>
|
||||
短信验证码注册/登录
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<View className='mt-6 flex text-sm items-center px-1'>
|
||||
|
||||
332
src/passport/sms-login.scss
Normal file
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,10 +1,10 @@
|
||||
import {useEffect, useState} from "react";
|
||||
import Taro from '@tarojs/taro'
|
||||
import { View } from '@tarojs/components'
|
||||
import {Input, Button} from '@nutui/nutui-react-taro'
|
||||
import { View, Text, Input } from '@tarojs/components'
|
||||
import {loginBySms, sendSmsCaptcha} from "@/api/passport/login";
|
||||
import {LoginParam} from "@/api/passport/login/model";
|
||||
import {checkAndHandleInviteRelation, hasPendingInvite, parseInviteParams, saveInviteParams, trackInviteSource} from "@/utils/invite";
|
||||
import './sms-login.scss'
|
||||
|
||||
const SmsLogin = () => {
|
||||
const [loading, setLoading] = useState<boolean>(false)
|
||||
@@ -37,9 +37,9 @@ const SmsLogin = () => {
|
||||
const pure = url.split('?')[0]
|
||||
return (
|
||||
pure === '/pages/index/index' ||
|
||||
pure === '/pages/cart/cart' ||
|
||||
pure === '/pages/user/user' ||
|
||||
pure === '/pages/category/index'
|
||||
pure === '/pages/shop/index' ||
|
||||
pure === '/pages/shop/cart' ||
|
||||
pure === '/pages/user/user'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -55,12 +55,8 @@ const SmsLogin = () => {
|
||||
await Taro.redirectTo({ url: redirectUrl })
|
||||
}
|
||||
|
||||
const reload = () => {
|
||||
// sms-login 页面不是 tabBar 页面,无需调用 hideTabBar
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
reload()
|
||||
// sms-login 页面不是 tabBar 页面,无需调用 hideTabBar
|
||||
}, [])
|
||||
|
||||
// 如果从分享/二维码链接进入短信登录页,先暂存邀请信息
|
||||
@@ -78,7 +74,7 @@ const SmsLogin = () => {
|
||||
|
||||
// 倒计时效果
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
if (countdown > 0) {
|
||||
timer = setTimeout(() => {
|
||||
setCountdown(countdown - 1)
|
||||
@@ -218,52 +214,114 @@ const SmsLogin = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const phoneValid = validatePhone(formData.phone)
|
||||
const codeValid = formData.code.length === 6
|
||||
const canSubmit = phoneValid && codeValid && !loading
|
||||
const sendDisabled = !phoneValid || sendingCode || countdown > 0
|
||||
|
||||
const sendBtnText = countdown > 0
|
||||
? `${countdown}s 后重试`
|
||||
: sendingCode
|
||||
? '发送中'
|
||||
: '获取验证码'
|
||||
|
||||
return (
|
||||
<>
|
||||
<View className='flex flex-col justify-center px-5 pt-3'>
|
||||
<View className='flex flex-col justify-between items-center my-2'>
|
||||
<Input
|
||||
type='number'
|
||||
placeholder='请输入手机号码'
|
||||
maxLength={11}
|
||||
value={formData.phone}
|
||||
onChange={(value) => setFormData({...formData, phone: value})}
|
||||
style={{backgroundColor: '#ffffff', borderRadius: '8px'}}
|
||||
/>
|
||||
<View className='page-sms-login'>
|
||||
{/* 渐变背景 */}
|
||||
<View className='sms-login-bg'>
|
||||
<View className='sms-login-bg__gradient' />
|
||||
<View className='sms-login-bg__circle sms-login-bg__circle--1' />
|
||||
<View className='sms-login-bg__circle sms-login-bg__circle--2' />
|
||||
</View>
|
||||
|
||||
<View className='sms-login-content'>
|
||||
{/* 头部 Logo 和标题 */}
|
||||
<View className='sms-login-header'>
|
||||
<View className='sms-login-logo'>
|
||||
<Text className='sms-login-logo__icon'>📱</Text>
|
||||
</View>
|
||||
<Text className='sms-login-title'>验证码登录</Text>
|
||||
<Text className='sms-login-subtitle'>未注册的手机号验证后将自动注册</Text>
|
||||
</View>
|
||||
<View className='flex justify-between items-center bg-white rounded-lg my-2 pr-2'>
|
||||
<Input
|
||||
type='number'
|
||||
placeholder='请输入6位验证码'
|
||||
maxLength={6}
|
||||
value={formData.code}
|
||||
onChange={(value) => setFormData({...formData, code: value})}
|
||||
style={{ backgroundColor: '#ffffff', borderRadius: '8px'}}
|
||||
/>
|
||||
<Button
|
||||
size='small'
|
||||
type={countdown > 0 ? "default" : "primary"}
|
||||
loading={sendingCode}
|
||||
disabled={sendingCode || countdown > 0}
|
||||
onClick={handleSendCode}
|
||||
>
|
||||
{countdown > 0 ? `${countdown}s` : sendingCode ? '发送中...' : '获取验证码'}
|
||||
</Button>
|
||||
|
||||
{/* 表单卡片 */}
|
||||
<View className='sms-login-card'>
|
||||
{/* 手机号 */}
|
||||
<View className='sms-login-field'>
|
||||
<Text className='sms-login-field__icon'>📞</Text>
|
||||
<Input
|
||||
className='sms-login-field__input'
|
||||
type='number'
|
||||
placeholder='请输入手机号码'
|
||||
placeholderClass='sms-login-field__placeholder'
|
||||
maxLength={11}
|
||||
value={formData.phone}
|
||||
onInput={(e: any) => setFormData({ ...formData, phone: e.detail.value })}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className='sms-login-divider' />
|
||||
|
||||
{/* 验证码 */}
|
||||
<View className='sms-login-field sms-login-field--code'>
|
||||
<View className='sms-login-field__icon-wrap'>
|
||||
<Text className='sms-login-field__icon'>🔐</Text>
|
||||
<Input
|
||||
className='sms-login-field__input'
|
||||
type='number'
|
||||
placeholder='请输入6位验证码'
|
||||
placeholderClass='sms-login-field__placeholder'
|
||||
maxLength={6}
|
||||
value={formData.code}
|
||||
onInput={(e: any) => setFormData({ ...formData, code: e.detail.value })}
|
||||
/>
|
||||
</View>
|
||||
<View
|
||||
className={`sms-login-send ${sendDisabled ? 'sms-login-send--disabled' : ''}`}
|
||||
onClick={handleSendCode}
|
||||
>
|
||||
{sendBtnText}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<View className='flex justify-center my-5'>
|
||||
<Button
|
||||
type='info'
|
||||
size='large'
|
||||
className='w-full rounded-lg p-2'
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
onClick={handleLogin}
|
||||
|
||||
{/* 登录按钮 */}
|
||||
<View
|
||||
className={`sms-login-submit ${canSubmit ? '' : 'sms-login-submit--disabled'}`}
|
||||
hoverClass={canSubmit ? 'sms-login-submit--hover' : 'none'}
|
||||
onClick={canSubmit ? handleLogin : undefined}
|
||||
>
|
||||
{loading ? '登录中...' : '登录 / 注册'}
|
||||
</View>
|
||||
|
||||
{/* 返回微信登录 */}
|
||||
<View
|
||||
className='sms-login-back'
|
||||
onClick={() => Taro.navigateBack({ delta: 1 })}
|
||||
>
|
||||
<Text className='sms-login-back__icon'>←</Text>
|
||||
<Text className='sms-login-back__text'>返回微信快捷登录</Text>
|
||||
</View>
|
||||
|
||||
{/* 协议 */}
|
||||
<View className='sms-login-tips'>
|
||||
<Text className='sms-login-tips__text'>登录即代表您已阅读并同意</Text>
|
||||
<Text
|
||||
className='sms-login-tips__link'
|
||||
onClick={() => Taro.navigateTo({ url: '/passport/agreement?type=terms' })}
|
||||
>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</Button>
|
||||
《服务协议》
|
||||
</Text>
|
||||
<Text className='sms-login-tips__text'>和</Text>
|
||||
<Text
|
||||
className='sms-login-tips__link'
|
||||
onClick={() => Taro.navigateTo({ url: '/passport/agreement?type=privacy' })}
|
||||
>
|
||||
《隐私政策》
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
export default SmsLogin
|
||||
|
||||
@@ -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