fix(shop): 修复购物车和收藏列表图片及价格显示问题

- 购物车上下文补全映射后端返回的 product 和 sku 嵌套对象字段
- 购物车页面商品图片资源回退增加 sku.image
- 添加 ensureFullUrl 辅助函数,完善图片 URL 完整性,增强图片压缩函数健壮性
- 收藏列表扩展 ShopGoodsFavorite 模型,支持嵌套商品详情字段
- 收藏列表图片、名称、价格显示逻辑升级,优先显示嵌套商品信息
- 购物车相关接口请求参数及方法调整,修正 PUT 请求路径和请求体字段
- 收藏列表列表项 key 使用 id 或 goodsId 避免重复Key警告
This commit is contained in:
2026-07-14 00:55:35 +08:00
parent 59ebef2f22
commit 711db25093
8 changed files with 135 additions and 22 deletions

View File

@@ -23,6 +23,12 @@
- 分类页和首页一样是 tabBar 页,三按钮全亮(好友/朋友圈/复制链接) - 分类页和首页一样是 tabBar 页,三按钮全亮(好友/朋友圈/复制链接)
- 类型检查:零错误 - 类型检查:零错误
## 修复收藏列表(图文不显示、价格不对)
- `src/api/shop/shopGoodsFavorite/model.ts`:扩展 `ShopGoodsFavorite` 接口,增加 `product?: { id, name, image, price, salePrice }` 嵌套字段(与秒杀/拼团模型一致)。后端 API 返回的嵌套商品数据会带 `product` 字段。
- `src/pages/user/favorite-list/index.tsx`
- 图片:`item.goodsImage``item.goodsImage || item.product?.image`
- 价格:`item.salePrice`(市场价)→ `item.product?.price || item.salePrice`(到手价),当市场价与到手价不同时再显示划掉的市场价
- 类型检查:零错误
## 修复订单列表看到他人订单 ## 修复订单列表看到他人订单
- `src/pages/order/list.tsx`:导入 `useUser`,请求参数加 `userId: user?.id`。原代码请求 `pageShopOrder` 时没传 `userId`,导致后端返回所有人订单。 - `src/pages/order/list.tsx`:导入 `useUser`,请求参数加 `userId: user?.id`。原代码请求 `pageShopOrder` 时没传 `userId`,导致后端返回所有人订单。
- `ShopOrderParam` 接口已有 `userId` 字段(见 `src/api/shop/shopOrder/model/index.ts:225`),后端按此过滤——只需前端补传。 - `ShopOrderParam` 接口已有 `userId` 字段(见 `src/api/shop/shopOrder/model/index.ts:225`),后端按此过滤——只需前端补传。

View File

@@ -0,0 +1,57 @@
## 购物车功能修复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 兜底)

View File

@@ -62,8 +62,8 @@ export async function addToCart(data: AddToCartParam) {
*/ */
export async function updateCartNum(data: UpdateCartNumParam) { export async function updateCartNum(data: UpdateCartNumParam) {
const res = await request.put<ApiResult<unknown>>( const res = await request.put<ApiResult<unknown>>(
'/shop/shop-cart/' + data.id, '/shop/shop-cart',
{ quantity: data.num } { id: data.id, num: data.num }
); );
if (res.code === 0) { if (res.code === 0) {
return res.message || '更新成功'; return res.message || '更新成功';
@@ -76,8 +76,8 @@ export async function updateCartNum(data: UpdateCartNumParam) {
*/ */
export async function updateCartChecked(id: number, checked: boolean) { export async function updateCartChecked(id: number, checked: boolean) {
const res = await request.put<ApiResult<unknown>>( const res = await request.put<ApiResult<unknown>>(
'/shop/shop-cart/' + id, '/shop/shop-cart',
{ selected: checked } { id, checked }
); );
if (res.code === 0) { if (res.code === 0) {
return res.message || '更新成功'; return res.message || '更新成功';
@@ -89,9 +89,9 @@ export async function updateCartChecked(id: number, checked: boolean) {
* 全选/取消全选购物车 * 全选/取消全选购物车
*/ */
export async function updateCartAllChecked(checked: boolean) { export async function updateCartAllChecked(checked: boolean) {
const res = await request.put<ApiResult<unknown>>( const res = await request.post<ApiResult<unknown>>(
'/shop/shop-cart/selected', '/shop/shop-cart/selected',
{ selected: checked } { checked }
); );
if (res.code === 0) { if (res.code === 0) {
return res.message || '更新成功'; return res.message || '更新成功';

View File

@@ -1,11 +1,20 @@
export interface ShopGoodsFavorite { export interface ShopGoodsFavorite {
favoriteId?: number id?: number
userId?: number userId?: number
goodsId?: number goodsId?: number
createTime?: string createTime?: string
goodsName?: string goodsName?: string
goodsImage?: string goodsImage?: string
salePrice?: string price?: string | number
salePrice?: string | number
/** 嵌套商品详情 */
product?: {
id?: number
name?: string
image?: string
price?: string
salePrice?: string
}
} }
export interface ShopGoodsFavoriteParam { export interface ShopGoodsFavoriteParam {

View File

@@ -64,6 +64,9 @@ export const CartProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
skuPrice: item.skuPrice, skuPrice: item.skuPrice,
skuSpec: item.skuSpec, skuSpec: item.skuSpec,
stock: item.stock, stock: item.stock,
// 后端可能返回嵌套的 product/sku 对象(对应 ShopCartItem 结构)
product: (item as any).product,
sku: (item as any).sku,
checked: true, checked: true,
})) }))
setItems(mappedItems) setItems(mappedItems)

View File

@@ -170,7 +170,7 @@ const CartPage: React.FC = () => {
</View> </View>
<Image <Image
className="w-20 h-20 rounded-md bg-gray-100 flex-shrink-0" className="w-20 h-20 rounded-md bg-gray-100 flex-shrink-0"
src={getCompressedImageUrl(item.goodsImage || item.product?.image || '')} src={getCompressedImageUrl(item.goodsImage || item.product?.image || (item.sku as any)?.image || '')}
mode="aspectFill" mode="aspectFill"
/> />
<View className="flex-1 flex flex-col justify-between"> <View className="flex-1 flex flex-col justify-between">

View File

@@ -63,23 +63,30 @@ const FavoriteListPage: React.FC = () => {
<View className='grid grid-cols-2 gap-3'> <View className='grid grid-cols-2 gap-3'>
{list.map(item => ( {list.map(item => (
<View <View
key={item.favoriteId} key={item.id ?? item.goodsId}
className='bg-white rounded-lg overflow-hidden' className='bg-white rounded-lg overflow-hidden'
onClick={() => handleItemClick(item.goodsId!)} onClick={() => handleItemClick(item.goodsId!)}
> >
<Image <Image
className='w-full' className='w-full'
style={{ height: '160px' }} style={{ height: '160px' }}
src={getCompressedImageUrl(item.goodsImage)} src={getCompressedImageUrl(item.goodsImage || item.product?.image || '')}
mode='aspectFill' mode='aspectFit'
/> />
<View className='p-2'> <View className='p-2'>
<Text className='text-sm text-gray-800 line-clamp-2 block'> <Text className='text-sm text-gray-800 line-clamp-2 block'>
{item.goodsName} {item.goodsName || item.product?.name}
</Text>
<Text className='text-red-500 text-sm font-medium mt-1 block'>
¥{item.salePrice || '0'}
</Text> </Text>
<View className='flex flex-row items-baseline gap-1 mt-1'>
<Text className='text-red-500 text-sm font-medium'>
¥{item.price || item.salePrice || '0'}
</Text>
{item.salePrice && Number(item.salePrice) > Number(item.price || 0) ? (
<Text className='text-xs text-gray-400 line-through'>
¥{item.salePrice}
</Text>
) : null}
</View>
</View> </View>
</View> </View>
))} ))}

View File

@@ -19,6 +19,36 @@ const DEFAULT_OPTIONS: Required<ImageCompressOptions> = {
enabled: true, enabled: true,
}; };
/**
* OSS 域名(文件上传接口同域)
*/
const OSS_BASE_URL = 'https://oss.wsdns.cn';
/**
* 确保图片 URL 为完整路径
*
* 后端可能返回相对路径(如 /uploads/xxx.jpg需要补全 OSS 域名。
*
* @param url 原始图片 URL
* @returns 完整图片 URL
*
* @example
* ensureFullUrl('/headers/xxx.jpg')
* // => 'https://oss.wsdns.cn/headers/xxx.jpg'
*
* @example
* ensureFullUrl('https://oss.wsdns.cn/headers/xxx.jpg')
* // => 'https://oss.wsdns.cn/headers/xxx.jpg'
*/
export function ensureFullUrl(url: string): string {
if (!url) return '';
if (url.startsWith('http://') || url.startsWith('https://')) {
return url;
}
// 相对路径:补全 OSS 域名
return `${OSS_BASE_URL}${url.startsWith('/') ? '' : '/'}${url}`;
}
/** /**
* 获取 OSS 压缩后的图片 URL * 获取 OSS 压缩后的图片 URL
* *
@@ -47,19 +77,20 @@ const DEFAULT_OPTIONS: Required<ImageCompressOptions> = {
* // => 'https://oss.wsdns.cn/xxx.jpg' * // => 'https://oss.wsdns.cn/xxx.jpg'
*/ */
export function getCompressedImageUrl(url: string, options?: ImageCompressOptions): string { export function getCompressedImageUrl(url: string, options?: ImageCompressOptions): string {
// 空 URL 直接返回空字符串 // 先补全为完整 URL
if (!url) return ''; const fullUrl = ensureFullUrl(url);
if (!fullUrl) return '';
const { width, quality, enabled } = { ...DEFAULT_OPTIONS, ...options }; const { width, quality, enabled } = { ...DEFAULT_OPTIONS, ...options };
// 未启用压缩,原样返回 // 未启用压缩,原样返回
if (!enabled) return url; if (!enabled) return fullUrl;
// 已包含 OSS 处理参数,避免重复拼接 // 已包含 OSS 处理参数,避免重复拼接
if (url.includes('x-oss-process')) return url; if (fullUrl.includes('x-oss-process')) return fullUrl;
// 已有其他 query 参数用 & 拼接,否则用 ? // 已有其他 query 参数用 & 拼接,否则用 ?
const separator = url.includes('?') ? '&' : '?'; const separator = fullUrl.includes('?') ? '&' : '?';
return `${url}${separator}x-oss-process=image/resize,w_${width}/quality,Q_${quality}`; return `${fullUrl}${separator}x-oss-process=image/resize,w_${width}/quality,Q_${quality}`;
} }