feat(store): 新增门店商品管理及订单图片修复

- 门店商品页新增商品新增功能,包括名称、分类、图片、价格、库存等信息录入
- 支持新增商品轮播图上传、删除及表单校验
- 获取当前登录店员门店ID并绑定新增商品,实现门店商品的创建
- 门店订单页修复商品图片字段,调整字段名为模型匹配的image、spec和totalNum
- 订单页商品图片添加压缩处理,优化图片展示尺寸与加载速度
This commit is contained in:
2026-07-14 22:50:12 +08:00
parent 8d6c0fda69
commit 711e588cfe
6 changed files with 417 additions and 350 deletions

View File

@@ -1,326 +1,7 @@
## 购物车功能修复(2026-07-14
# 2026-07-14 工作日志
### 问题
购物车页面pages/shop/cart商品显示异常
- 商品名称/图片/价格信息缺失
- 加入购物车后无法正确显示
### 根因
`CartContext.refresh()` 方法在将后端 API 返回的购物车项映射为前端 `CartItem` 时,**遗漏了 `product`(商品详情)和 `sku`SKU 详情)嵌套对象的映射**。导致页面中 `item.product?.name/image/price``item.sku?.price/image` 均为 `undefined`UI 只能显示硬编码的默认值。
### 修改
1. **`src/contexts/CartContext.tsx`**(核心修复)
- `refresh()``mappedItems` 映射中补充 `product``sku` 字段
- 使用 `(item as any).product` / `(item as any).sku` 获取后端返回的嵌套对象
2. **`src/utils/image.ts`**(增强健壮性)
- 新增 `ensureFullUrl()` 辅助函数:相对路径自动补全 OSS 域名 `https://oss.wsdns.cn`
- `getCompressedImageUrl()` 内部调用 `ensureFullUrl` 确保 URL 完整性
3. **`src/pages/shop/cart.tsx`**(补充图片回退源)
- 图片 `src` 回退链增加 `(item.sku as any)?.image`
- 完整回退链:`goodsImage -> product.image -> sku.image -> ''`
## 收藏列表图片/价格不显示修复2026-07-14
### 问题
`pages/user/favorite-list/index` 收藏列表:商品图片、价格、名称均空白。
### 根因
后端 `/api/shop/goods/favorite/list`(前端 `/shop/goods/favorite/list`)走 `ShopGoodsFavoriteServiceImpl.listRel``selectListRel`SQL 只 `SELECT a.*` 未关联商品表,返回字段仅 `id/userId/goodsId/merchantId/tenantId/createTime`,不含商品信息。前端依赖 `goodsImage/goodsName/price/salePrice` 均为 undefined。
### 修改方案1后端返回商品信息
1. **后端** `guilixu-java`
- `ShopGoodsFavorite.java`:新增 4 个 `@TableField(exist=false)` 非持久化字段 `goodsName(String)``goodsImage(String)``price(BigDecimal)``salePrice(BigDecimal)`,并补 `BigDecimal`/`TableField` import。
- `ShopGoodsFavoriteMapper.xml``selectSql``SELECT a.*, g.name AS goods_name, g.image AS goods_image, g.price AS price, g.sale_price AS sale_price``LEFT JOIN shop_goods g ON a.goods_id = g.goods_id`(已确认 `map-underscore-to-camel-case: true`,别名可映射)。
- 注意:本机无 maven 无法编译,改动为常规 MyBatis/MP 写法,需用户自行编译部署。
2. **前端**
- `src/api/shop/shopGoodsFavorite/model.ts``favoriteId` 改为 `id`(后端实际返回 `id`);新增 `price?: string|number``salePrice` 改为 `string|number`
- `pages/user/favorite-list/index.tsx`:图片用 `item.goodsImage`;价格改为优先 `item.price`(到手价),`salePrice` 作为划线价(`salePrice > price` 时显示);`key` 改用 `item.id ?? item.goodsId`(原 `favoriteId` 始终 undefined 会导致 key 重复)。
- 注:`src/pages/favorite-list.tsx` 为未注册路由的重复文件,未改动。
### 遗留(非本次范围,供参考)
adapter `/list` 接口忽略分页,一次返回全部收藏;前端 `listShopGoodsFavorite({page,limit:20})` 的无限滚动在收藏>20条时会重复追加。如需彻底解决建议改前端走 `/page` 接口或后端 `/list` 支持分页。
## 购物车更新 API 请求方式修复2026-07-14
### 问题
`PUT /api/shop/shop-cart/{id}` 返回 `HttpRequestMethodNotSupportedException: Request method 'PUT' not supported`
### 根因
`src/api/shop/shopCart/index.ts` 中 3 个函数用了非标准 URL 模式 `PUT /shop/shop-cart/{id}`,而项目所有其他 APIshopOrder、shopArticle 等)的更新操作统一走 `PUT /shop/shop-cart`(基础 URLid 放 body
### 修改
1. `updateCartNum``PUT /shop/shop-cart/{id}` + `{quantity}``PUT /shop/shop-cart` + `{id, num}`
2. `updateCartChecked``PUT /shop/shop-cart/{id}` + `{selected}``PUT /shop/shop-cart` + `{id, checked}`(同时修正字段名 `selected``checked` 与 model 一致)
3. `updateCartAllChecked``PUT /shop/shop-cart/selected` + `{selected}``POST /shop/shop-cart/selected` + `{checked}`(改用 POST自定义批量操作更可能支持 POSTCartContext 已有静默 catch 兜底)
## 购物车后端 SQL 关联查询修复2026-07-14
### 问题
`GET /api/shop/shop-cart` 返回的数据没有商品图片、价格等信息。前端 `CartContext.refresh()` 已映射 `goodsName/goodsImage/skuPrice/skuSpec/stock` 等字段,但后端根本没返回这些字段。
### 根因
后端 `ShopCartMapper.xml` 的 SQL 只做 `SELECT a.* FROM shop_cart a`,没有 JOIN 商品表和 SKU 表。
### 后端修改(`guilixu-java` 项目)
1. **`ShopCart.java`**:新增 7 个 `@TableField(exist = false)` 非持久化字段:`goodsName``goodsImage``skuPrice``skuSpec``stock``salePrice``dealerPrice`;补充 `TableField` import
2. **`ShopCartMapper.xml`**SQL 改为 `LEFT JOIN shop_goods g ON a.goods_id = g.goods_id` + `LEFT JOIN shop_goods_sku s ON a.sku_id = s.id`SELECT 别名映射到新字段(`COALESCE(s.price, g.price) AS sku_price``COALESCE(s.stock, g.stock) AS stock`);同时修复 `userId/goodsId/merchantId``LIKE` 改为 `=`(原 LIKE 会导致 userId=1 匹配到 10/11/21 等)
3. **`ShopCartController.java`**`list()` 方法自动按当前登录用户过滤(`param.setUserId(loginUser.getUserId().longValue())`
### 前端修改
4. **`src/api/shop/shopCart/model/index.ts`**`ShopCart` 接口新增 `salePrice``dealerPrice` 字段
5. **`src/contexts/CartContext.tsx`**`CartItem` 新增 `salePrice``dealerPrice``refresh()` 映射这两个字段;`calcPrice()` VIP 优先用扁平 `i.dealerPrice`(之前只看 `i.product?.dealerPrice`
6. **`src/pages/shop/cart.tsx`** + **`src/pages/shop/checkout.tsx`**:价格显示逻辑同步更新,优先使用扁平 `item.dealerPrice` / `item.salePrice`
## 购物车前后端字段名对齐修复2026-07-14
### 问题
修改购物车数量时后端报 `UPDATE shop_cart WHERE id=?`SET 子句为空MyBatis-Plus 找不到要更新的字段。
### 根因
前后端字段名不匹配:
- 前端发送 `num`,后端实体字段是 `cartNum` → Jackson 反序列化时 `cartNum` 为 null → 空 UPDATE
- 前端发送 `checked`,后端实体字段是 `selected` → 同样不匹配
- 列表返回 `cartNum`,前端读 `item.num` → 数量永远为默认值 1
### 修改文件
1. **`src/api/shop/shopCart/model/index.ts`**`ShopCart.num``cartNum`;新增 `selected` 字段;`AddToCartParam.num``cartNum``UpdateCartNumParam.num``cartNum`
2. **`src/api/shop/shopCart/index.ts`**`updateCartNum``{id, cartNum}``updateCartChecked``{id, selected}`
3. **`src/contexts/CartContext.tsx`**`refresh()``item.cartNum``item.selected``addItem()``cartNum``updateQuantity` 调用 `{id, cartNum}`
4. **`src/pages/shop/index.tsx`** + **`src/pages/shop/category.tsx`**`addToCart` 参数 `num``cartNum`
## 禁用 loginByOpenId 静默登录2026-07-14
### 改动
- **`src/contexts/UserContext.tsx`**:移除"情况3"静默登录块(`Taro.login` + `loginByOpenId`),无 token 时直接保持未登录状态;清理 `loginByOpenId``TenantId` 无用导入
- **`src/hooks/useUser.ts`**:移除降级方案中的 `loginByOpenId` 调用,仅从 storage 读取;清理 `loginByOpenId``TenantId` 无用导入
- `src/api/layout/index.ts``loginByOpenId` 函数定义保留,仅不再被调用
## 商城分类页价格登录可见2026-07-14
### 改动
- **`src/pages/shop/index.tsx`**:价格显示从手写 `Text` 改为 `Price` 组件 + `loginMask` 属性(与首页一致),游客看到"登录后查看价格"VIP 划线价逻辑保留在 `original` prop 中;单位 `unitName` 仅登录后显示
## 订单列表页未登录可查看订单 Bug 修复2026-07-14
### 问题
`pages/order/list` 未登录用户也能看到订单数据。
### 根因
页面缺少登录校验——`useEffect` 直接调用 `loadOrders`,未登录时 `user?.userId``undefined`,后端未按用户过滤直接返回了订单。
### 修改
- **`src/pages/order/list.tsx`**
1.`useUser()` 获取 `isLoggedIn`
2. `useEffect` 中未登录时调用 `requireLogin({ action: 'viewOrder', redirect: '/pages/order/list', content: '登录后才能查看订单,是否前往登录?' })` 弹窗引导,而不是直接跳转
3. render 中 `if (!isLoggedIn) return null` 兜底,避免未登录时渲染订单内容
- **`src/passport/login.tsx`**:登录成功后的 `tabBarUrls` 白名单补全 `pages/order/list`,确保从订单入口登录后能 `switchTab` 回订单页
## 我的页面未登录可点击需登录功能 Bug 修复2026-07-14
### 问题
`pages/user/user` 未登录时,点击「我的订单」下的按钮(待付款/待发货/待收货/已完成、「全部订单」、以及菜单中的「升级VIP会员」「我的钱包」「收货地址」「我的收藏」都能直接跳转页面。
### 修改
- **`src/pages/user/user.tsx`**
1. 导入 `requireLogin` from `@/utils/login-guard`
2. 给需要登录的菜单项加 `requireAuth: true` 标记升级VIP会员/门店中心/我的钱包/收货地址/我的收藏)
3. 新增 `handleMenuClick(item)``requireAuth` 项调用 `requireLogin` 弹窗拦截,已登录才 `navigateTo`
4. 新增 `handleOrderTabClick(status)``handleViewAllOrders()`:订单入口 `requireLogin` 拦截,并加 `redirect: '/pages/order/list'` 让登录后回跳订单页
5. 未登录时需登录的菜单项和订单按钮加 `opacity-50` 视觉降级
6. 「帮助中心」「设置」不需要登录,保持原样
## 订单列表页登录后仍看到别人订单 Bug 修复2026-07-14
### 问题
`pages/order/list` 刚登录成功后仍能看到别人的订单,刷新重进后才正常。
### 根因
`loadOrders``useCallback` 依赖数组为 `[updateTabState]`,未包含 `user`,导致**闭包过期**——登录后 `isLoggedIn``true` 触发 effect 调用 `loadOrders`,但闭包中的 `user` 仍是初始渲染时的 `null``userId: user?.userId` 传了 `undefined`,后端未按用户过滤返回了全部订单。
### 修改
- **`src/pages/order/list.tsx`**
1. 新增 `userIdRef``useRef(user?.userId)`),每渲染同步 `userIdRef.current = user?.userId`,避免闭包过期
2. `loadOrders` 开头从 `userIdRef.current` 取 userId`if (!userId) return` 硬拦截——没有当前登录用户 userId 就禁止加载
3. `useEffect` 中增加 `if (!user?.userId) return` 判断,已登录但 userId 还没拿到时等待下次渲染
4. `useEffect` 依赖数组加 `user?.userId`userId 变化时重新触发加载
## 商城列表页浮动购物车按钮2026-07-14
### 需求
`pages/shop/index` 页面右下角添加浮动购物车按钮,显示购物车商品数量角标。
### 实现
- **`src/pages/shop/index.tsx`**
1.`useCartContext()` 额外解构 `totalCount`
2. 页面末尾添加 fixed 定位浮动按钮绿色圆形48px`bottom:80px right:16px`(避开 tabBar
3. `totalCount > 0` 时右上角显示红色角标,超过 99 显示 `99+`
4. 点击 `Taro.navigateTo` 跳转购物车页cart 非 tabBar 页)
5. 数量实时更新:`addItem` 内部调 `refresh()``totalCount` 派生值自动重渲染
## 购物车页面进入不刷新 Bug 修复2026-07-14
### 问题
从商品列表页/分类页加入购物车后,进入购物车页面,新加的商品不显示,只有之前已在购物车里的商品。
### 根因
1. **购物车页面缺少 `useDidShow`**`cart.tsx` 仅在 `useEffect([isLoggedIn])` 中调用 `refresh()`,登录状态不变时不会重新拉取数据。从其他页面返回购物车时,页面不刷新。
2. **商品列表页/分类页直接调 API 加购**`shop/index.tsx``shop/category.tsx` 直接调用 `addToCart` API未通过 `CartContext.addItem`,导致 CartContext 状态不同步(`addItem` 内部会调 `refresh()`)。
### 修改
1. **`src/pages/shop/cart.tsx`**:导入 `useDidShow`,添加 `useDidShow(() => { if (isLoggedIn) refresh() })`,页面每次显示时从服务端拉取最新购物车数据
2. **`src/pages/shop/index.tsx`**:导入 `useCartContext``handleAddToCart` 改用 `addItem(product, undefined, product.step || 1)` 代替直接调 `addToCart` API移除 `addToCart` 导入
3. **`src/pages/shop/category.tsx`**:同上,改用 `useCartContext``addItem`
### 需求
首页顶部搜索栏右侧铃铛图标,门店店员有新订单时能收到提醒。
### 改动
- **`src/pages/index/index.tsx`**
1. 导入 `useNewOrderDetector``getMyClerk``pageShopOrder``useDidShow``useCallback``useRef`
2. 登录后调 `getMyClerk()` 检测店员身份,存 `isClerk` state + `isClerkRef` refref 用于轮询回调中读取最新值,避免闭包过期)
3. `useDidShow` 时也刷新店员身份
4. 接入 `useNewOrderDetector`30秒轮询 `pageShopOrder({page:1,limit:5})``fetchLatestOrders` 内用 `isClerkRef.current` 网关——非店员直接返回空数组(不建 baseline无副作用
5. 新订单回调:`Taro.vibrateShort` 震动 + `Taro.showToast` 提示"您有 N 条新订单"
6. 铃铛角标从静态红点改为动态:`newOrderCount > 0` 显示数字角标(>99 显示 99+`=== 0` 时显示小圆点
7. 点击处理:店员 → `navigateTo` 门店订单页(有新订单时先 `clearUnread()`);普通用户 → 通知页
### 设计要点
- `isClerkRef` 解决 hooks 闭包过期问题——`fetchLatestOrders``useCallback` 依赖空数组,但通过 ref 读取最新店员状态
- 非店员时 `fetchLatestOrders` 返回 `[]`hook 内 `doCheck``list.length === 0` 时 return`knownOrderIdsRef` 保持 `null` 不建 baseline店员身份确认后首次真实请求才建 baseline不会误报
- `useNewOrderDetector` 内部已处理 `useDidShow`/`useDidHide` 生命周期tabBar 页切走自动停轮询
## 商品分类页浮动购物车图标优化2026-07-14
### 问题
商品分类页pages/shop/index右下角绿色圆形浮动购物车按钮使用 emoji `🛒` 作为图标,在绿色背景上对比度不足、线条不清晰。
### 修改
1. 新增白色线条购物车 SVG 图标:`src/assets/icons/cart.svg`
2. 导出 base64 数据 URI 常量:`src/assets/icons/index.ts``CART_ICON_WHITE`
3. `src/pages/shop/index.tsx` 浮动购物车按钮改用 `Image` 组件加载 `CART_ICON_WHITE`28×28px`mode='aspectFit'`),图标更清晰
## 商品详情页出现孤立 "0" 排查修复2026-07-14
### 问题
`pages/shop/product-detail` 价格区域下方出现一个不归属任何标签的孤立 `0`
### 根因
`src/pages/shop/product-detail.tsx` 中赚取积分行的条件渲染写成:
```tsx
{product.gainIntegral && Number(product.gainIntegral) > 0 && (...)}
```
当后端返回 `gainIntegral` 为数字 `0`JSX 短路与运算直接返回 `0`React 会把它渲染成一个纯文本节点。这就是页面上那个孤立的 `0`
### 修改
- **`src/pages/shop/product-detail.tsx`**
- 去掉条件里的 `product.gainIntegral &&`,改成 `{Number(product.gainIntegral) > 0 && (...)}`,避免 `0` 泄漏。
- 顺手把配送区域 `goodsWeight` 的同类写法也改成 `{Number(product.goodsWeight) > 0 && (...)}`,防止重量为 0 时同样泄漏。
- 另按用户要求「是 0 就不显示」,把销量行也改为 `{Number(product.sales) > 0 && (...)}`,销量为 0 时整条「销量: 0」不渲染。
## 订单列表页下单后不自动刷新修复2026-07-14
### 问题
`pages/order/list` 下单后从 checkout 页 `Taro.switchTab` 跳过来,订单列表不刷新,看不到新订单。
### 根因
订单列表页是 tabBar 页,`switchTab` 不会重新挂载组件。`useEffect` 依赖 `[tabIndex, isLoggedIn, user?.userId]`,这些值不变就不会重新触发;且 effect 内有 `if (!tabStates[tabIndex].initialized)` 守卫,页面已初始化过就直接跳过。
### 修改
- **`src/pages/order/list.tsx`**:新增 `Taro.useDidShow` 生命周期钩子
-`isFirstShow` ref 跳过首次显示(由 `useEffect` 处理初始加载)
- 后续每次显示时:标记所有 tab 为 `initialized: false`(切 tab 时会重新加载),并立即 `loadOrders(tabIndex, 1)` 刷新当前 tab
- 与之前购物车页 `useDidShow` 刷新是同一模式
## 商品详情页底部购物车角标2026-07-14
### 需求
`pages/shop/product-detail` 底部操作栏的购物车入口需要显示当前购物车商品数量角标。
### 修改
- **`src/pages/shop/product-detail.tsx`**
1.`useCartContext` 额外解构 `totalCount``refresh`
2. 引入 `useDidShow`,页面显示时(已登录)调 `refresh()` 拉取最新购物车,保证角标准确
3. 购物车图标外包一层 `relative` 容器,`totalCount > 0` 时右上角显示红色圆角角标(>99 显示 `99+`),样式与商城列表页浮动购物车角标一致
## 在线客服功能修复2026-07-14
### 问题
`pages/user/customer-service/index` 在线客服功能使用不了,用户已在微信后台添加客服人员。
### 根因(多个问题叠加)
1. **`openType="contact"` 按钮缺少关键属性**:缺少 `sessionFrom`(会话来源标识)、`onContact`(客服回调)、`showMessageCard`/`sendMessageTitle`(客服消息卡片),且按钮被 Tailwind `border-0` 类可能干扰原生渲染
2. **开发者工具限制**`open-type="contact"` 在微信开发者工具中不生效,只能在真机上使用,页面缺少提示
3. **自定义聊天系统消息发送缺失 `conversationId`**`addShopChatMessage` 调用未传 `conversationId`,消息无法关联到会话;`ShopChatMessage` 模型本身也缺少该字段
4. **消息发送缺失 `formUserId`**:发送人 ID 未设置
5. **`conv.lastMessage` 字段不存在**`ShopChatConversation` 模型只有 `content` 字段,代码引用了不存在的 `lastMessage`
6. **`useReachBottom` 用错场景**:页面使用 `ScrollView` 组件,`useReachBottom` 是页面级滚动钩子,对 ScrollView 无效,应改用 `onScrollToLower`
7. **`scrollToBottom` ref 设了但从未使用**:死代码
### 修改
1. **`src/api/shop/shopChatMessage/model/index.ts`**
- `ShopChatMessage` 接口新增 `conversationId?: number`
- `ShopChatMessageParam` 接口新增 `conversationId?: number`
2. **`src/pages/user/customer-service/index.tsx`**(全面修复):
- 微信客服按钮补全 `sessionFrom="customer_service"``onContact``showMessageCard``sendMessageTitle`
- 按钮样式从 Tailwind 类改为 inline style避免 `border-0` 干扰原生按钮),显式设置 `border: 'none'`
- 添加"需真机预览测试"提示文字
- `addShopChatMessage` 调用补全 `conversationId``formUserId`(从 `Taro.getStorageSync('UserId')` 获取)
- `conv.lastMessage` 全部改为 `conv.content`
- 会话列表更新 `lastMessage: text` 改为 `content: text`
- 移除 `useReachBottom`,改用 ScrollView `onScrollToLower` + `lowerThreshold={50}`
- 移除未使用的 `scrollToBottom` ref
- `pageShopChatMessage` 参数去掉 `as any` 断言(模型已支持 `conversationId`
- 新增 `getCurrentUserId()` 辅助函数
- `handleScrollToLower` 增加 `showHistory` 条件守卫(仅展开历史时才加载更多)
3. **`src/components/NavBar/index.tsx`**(关键修复,导致白屏):
- Taro 4.1.11 没有 `useNavigate` hook原代码 `import { useNavigate } from '@tarojs/taro'` 运行时为 `undefined`
- 改为 `import Taro from '@tarojs/taro'`,回退逻辑用 `Taro.navigateBack({ delta: 1 })`
4. **删除未注册的死代码**
- `src/pages/customer-service.tsx`(重复且仍引用 `useReachBottom`,干扰编译/热更新)
- `src/pages/customer-service.config.ts`
### 验证
- 全局搜索确认无其他 `useNavigate``useReachBottom` 引用
- TypeScript 检查修改文件无错误
### 客服功能简化21:00
- 用户反馈客服功能太复杂用不了,改为直接用微信原生 `open-type="contact"` 按钮
- `src/pages/shop/product-detail.tsx`:客服图标从 `View + onClick navigateTo` 改为 `Button openType="contact" sessionFrom="product_detail"`
- 删除了 `handleContactService` 函数和跳转到 `/pages/user/customer-service/index` 的逻辑
- **注意:`open-type="contact"` 必须真机预览测试,开发者工具中点击无反应**
- `pages/user/customer-service/index` 页面保留但不再从商品详情页跳转
## 地址编辑页识别按钮点不中修复2026-07-14
### 问题
`pages/user/address-edit` 智能识别区的「识别」按钮点击无反应。
### 根因
按钮使用 `absolute right-2 bottom-2` 定位叠在 `Textarea` 上方。微信小程序中 `Textarea` 是原生组件,层级高于普通 `View`,会遮挡 `absolute` 叠加的按钮,`z-index` 也无效,导致按钮点击事件被 Textarea 拦截。
### 修改
- **`src/pages/user/address-edit.tsx`**:识别区布局从 `relative` + `absolute` 叠加改为 flex 纵向布局——Textarea 在上,识别按钮 `flex justify-end mt-2` 在下方右对齐。去掉 `relative` class按钮不再叠加在 Textarea 上。
## 订单列表页待发货增加关闭订单功能2026-07-14
### 需求
`pages/order/list` 待发货状态的订单卡片需要支持客户关闭订单。
### 实现
1. **`src/components/common/OrderCard/index.tsx`**
- 新增可选 prop `onCloseOrder?: (order: ShopOrder) => void`
-`deliveryStatus === 10`(未发货)且订单未取消/未取消中时,在卡片底部显示「关闭订单」按钮
- 点击按钮阻止事件冒泡,避免触发卡片跳转详情
2. **`src/pages/order/list.tsx`**
- 导入 `updateShopOrder``OrderStatus`
- 实现 `handleCloseOrder`:弹窗确认后调用 `updateShopOrder({ ...order, orderStatus: OrderStatus.Cancelled })`,成功后 toast 并刷新当前 tab
-`OrderCard` 传入 `onCloseOrder` 回调
### 说明
复用订单详情页已有的取消订单逻辑(通过 `updateShopOrder``orderStatus` 改为 2
### 补充修复
关闭订单后只刷新了当前 tab「全部」tab 如果已加载过不会重新请求,导致看不到已取消的订单。修复:`handleCloseOrder` 成功后先 `setTabStates(prev => prev.map(s => ({ ...s, initialized: false })))` 标记所有 tab 为未初始化,再 `loadOrders(tabIndex, 1)` 刷新当前 tab这样切到「全部」时会重新加载并显示已取消的订单。
## 门店订单页商品图片修复
- 文件:`src/pages/store/orders/index.tsx`
- 问题:订单商品图片不显示,字段名与模型不匹配
- 修复:`coverImage``image``specInfo``spec``quantity``totalNum`(按 `ShopOrderGoods` 模型定义)
- 图片压缩:`getCompressedImageUrl(goods.image, { width: 80 })`,展示尺寸 w-16 h-1664px压缩宽度 80 够用

View File

@@ -132,6 +132,8 @@ export interface ShopGoods {
activityType?: number;
// 配送方式0送上门 1限自提
deliveryMode?: number;
// 门店ID
storeId?: number;
}
export interface BathSet {

View File

@@ -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 handleAddBanner = 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={handleAddBanner}
>
<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>
)
}

View File

@@ -29,6 +29,23 @@ function formatDistance(km: number) {
return km < 1 ? `${Math.round(km * 1000)}m` : `${km.toFixed(1)}km`
}
/** 解析 lngAndLat 字符串,自动识别 "lat,lng" / "lng,lat" 两种格式 */
function parseLngLat(str: string): { lat: number; lng: number } | null {
const parts = str.split(',')
if (parts.length !== 2) return null
const a = parseFloat(parts[0])
const b = parseFloat(parts[1])
if (isNaN(a) || isNaN(b)) return null
// 纬度范围 -90~90经度范围 -180~180通过值域区分两者
if (Math.abs(a) <= 90 && Math.abs(b) <= 180) {
return { lat: a, lng: b } // "lat,lng"
}
if (Math.abs(b) <= 90 && Math.abs(a) <= 180) {
return { lat: b, lng: a } // "lng,lat"
}
return null
}
const StoreListPage: React.FC = () => {
const params = Taro.getCurrentInstance().router?.params || {}
// selectMode=1 时表示从预约页跳来,选中后回传
@@ -110,13 +127,9 @@ const StoreListPage: React.FC = () => {
list = list
.map((store) => {
if (store.lngAndLat) {
const parts = store.lngAndLat.split(',')
if (parts.length === 2) {
const sLng = parseFloat(parts[0])
const sLat = parseFloat(parts[1])
if (sLat && sLng) {
return { ...store, distance: calcDistance(userLat, userLng, sLat, sLng) }
}
const parsed = parseLngLat(store.lngAndLat)
if (parsed) {
return { ...store, distance: calcDistance(userLat, userLng, parsed.lat, parsed.lng) }
}
}
return store
@@ -163,20 +176,14 @@ const StoreListPage: React.FC = () => {
Taro.showToast({ title: '暂无位置信息', icon: 'none' })
return
}
const parts = store.lngAndLat.split(',')
if (parts.length !== 2) {
const parsed = parseLngLat(store.lngAndLat)
if (!parsed) {
Taro.showToast({ title: '位置信息格式有误', icon: 'none' })
return
}
const longitude = parseFloat(parts[0])
const latitude = parseFloat(parts[1])
if (!latitude || !longitude) {
Taro.showToast({ title: '暂无位置信息', icon: 'none' })
return
}
Taro.openLocation({
latitude,
longitude,
latitude: parsed.lat,
longitude: parsed.lng,
name: store.name || '',
address: store.address || '',
})

View File

@@ -363,18 +363,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>

View File

@@ -60,7 +60,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' },