feat(store): 添加商品图片及轮播图上传功能

- 商品编辑弹窗新增商品主图上传和更换功能,支持上传进度反馈
- 实现轮播图(files字段)上传、删除和显示,支持多格式files解析
- 编辑提交时携带图片和轮播图数据进行保存
- 新增图片压缩工具函数getCompressedImageUrl,用于生成缩略图压缩URL
- 统一修正上传接口URL,确保图片上传服务可用
This commit is contained in:
2026-07-06 11:55:31 +08:00
parent 433afb8070
commit c3dd04fa03
4 changed files with 204 additions and 11 deletions

View File

@@ -66,7 +66,7 @@ export async function uploadFile() {
const tempFilePath = res.tempFilePaths[0];
// 上传图片到OSS
Taro.uploadFile({
url: 'https://shop-api.websoft.top/api/oss/upload',
url: 'https://server.websoft.top/api/oss/upload',
filePath: tempFilePath,
name: 'file',
header: {

View File

@@ -3,6 +3,7 @@ import { View, Text, Image, ScrollView, Input, Textarea } from '@tarojs/componen
import Taro, { useDidShow } from '@tarojs/taro'
import { pageShopGoods, updateShopGoods } from '@/api/shop/shopGoods'
import { listShopGoodsCategory } from '@/api/shop/shopGoodsCategory'
import { uploadFile } from '@/api/system/file'
import type { ShopGoods, ShopGoodsParam } from '@/api/shop/shopGoods/model'
import type { ShopGoodsCategory } from '@/api/shop/shopGoodsCategory/model'
@@ -58,8 +59,11 @@ export default function StoreGoodsPage() {
stock: '',
sortNumber: '',
comments: '',
files: [] as Array<{ uid?: number; url: string; status?: string }>,
})
const [submitting, setSubmitting] = useState(false)
const [uploadingImage, setUploadingImage] = useState(false)
const [uploadingBanner, setUploadingBanner] = useState(false)
const pageSize = 10
const loadingRef = useRef(false)
@@ -177,6 +181,26 @@ export default function StoreGoodsPage() {
/** 打开编辑弹窗 */
const openEditModal = (goods: ShopGoods) => {
setEditGoods(goods)
const bannerFiles: Array<{ uid?: number; url: string; status?: string }> = []
if (goods.files) {
try {
const parsed = JSON.parse(goods.files)
if (Array.isArray(parsed)) {
bannerFiles.push(
...parsed
.map((item: any) =>
typeof item === 'string'
? { url: item }
: { uid: item.uid, url: item.url || '', status: item.status }
)
.filter((item: any) => item.url)
)
}
} catch {
// 非 JSON 数组时按逗号分隔兜底
bannerFiles.push(...goods.files.split(',').filter(Boolean).map(url => ({ url })))
}
}
setEditForm({
price: goods.price || '',
salePrice: goods.salePrice || '',
@@ -184,6 +208,7 @@ export default function StoreGoodsPage() {
stock: String(goods.stock ?? ''),
sortNumber: String(goods.sortNumber ?? ''),
comments: goods.comments || '',
files: bannerFiles,
})
setShowEditModal(true)
}
@@ -194,6 +219,53 @@ export default function StoreGoodsPage() {
setEditGoods(null)
}
/** 上传/更换商品图片 */
const handleUploadImage = async () => {
if (uploadingImage) return
setUploadingImage(true)
try {
const res = await uploadFile()
const imageUrl = res.path || ''
if (imageUrl) {
setEditGoods(prev => prev ? { ...prev, image: imageUrl } : null)
Taro.showToast({ title: '上传成功', icon: 'success' })
}
} catch (e: any) {
Taro.showToast({ title: e.message || '上传失败', icon: 'none' })
} finally {
setUploadingImage(false)
}
}
/** 上传轮播图 */
const handleAddBanner = async () => {
if (uploadingBanner) return
setUploadingBanner(true)
try {
const res = await uploadFile()
const imageUrl = res.path || ''
if (imageUrl) {
setEditForm(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 {
setUploadingBanner(false)
}
}
/** 删除轮播图 */
const handleRemoveBanner = (index: number) => {
setEditForm(prev => ({
...prev,
files: prev.files.filter((_, i) => i !== index),
}))
}
/** 提交编辑 */
const submitEdit = async () => {
if (!editGoods) return
@@ -214,12 +286,14 @@ export default function StoreGoodsPage() {
try {
await updateShopGoods({
...editGoods,
image: editGoods.image,
price: editForm.price,
salePrice: editForm.salePrice || undefined,
dealerPrice: editForm.dealerPrice || undefined,
stock,
sortNumber: parseInt(editForm.sortNumber, 10) || 0,
comments: editForm.comments || undefined,
files: JSON.stringify(editForm.files),
})
Taro.showToast({ title: '保存成功', icon: 'success' })
closeEditModal()
@@ -465,9 +539,20 @@ export default function StoreGoodsPage() {
{/* 商品信息 */}
<View className='bg-gray-50 rounded-xl p-4 mb-5 flex items-center gap-3'>
{editGoods.image && (
<Image className='w-14 h-14 rounded-lg bg-gray-100' src={editGoods.image} mode='aspectFill' />
)}
<View className='relative' onClick={handleUploadImage}>
{editGoods.image ? (
<Image className='w-16 h-16 rounded-lg' src={editGoods.image} mode='aspectFill' />
) : (
<View className='w-16 h-16 rounded-lg bg-gray-200 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'>
{uploadingImage ? '上传中...' : '更换'}
</Text>
</View>
</View>
<View className='flex-1 min-w-0'>
<Text className='text-sm text-gray-800 block' style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{editGoods.name}
@@ -476,6 +561,31 @@ export default function StoreGoodsPage() {
</View>
</View>
{/* 轮播图 */}
<View className='mb-5'>
<Text className='text-sm text-gray-600 mb-2 block'></Text>
<View className='flex flex-wrap gap-3'>
{editForm.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={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={() => handleRemoveBanner(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'>{uploadingBanner ? '...' : '+'}</Text>
<Text className='text-xs text-gray-400'>{uploadingBanner ? '上传中' : '上传'}</Text>
</View>
</View>
</View>
{/* 表单字段 */}
<View className='mb-4'>
<Text className='text-sm text-gray-600 mb-2 block'> (¥)</Text>

65
src/utils/image.ts Normal file
View File

@@ -0,0 +1,65 @@
/**
* 图片压缩参数配置
*/
export interface ImageCompressOptions {
/** 图片宽度px默认 750 */
width?: number;
/** 图片质量 0-100默认 90 */
quality?: number;
/** 是否启用压缩,默认 true */
enabled?: boolean;
}
/**
* 默认压缩参数
*/
const DEFAULT_OPTIONS: Required<ImageCompressOptions> = {
width: 750,
quality: 90,
enabled: true,
};
/**
* 获取 OSS 压缩后的图片 URL
*
* 默认给图片 URL 拼接阿里云 OSS 图片处理参数,实现等比缩放 + 质量压缩。
* 使用场景:商品列表、商品卡片等缩略图展示。
*
* 注意:预览原图时(如 Taro.previewImage不需要调用此函数直接用原始 URL 即可。
*
* @param url 原始图片 URL
* @param options 压缩选项
* @returns 处理后的图片 URL
*
* @example
* // 默认压缩宽750质量90
* getCompressedImageUrl('https://oss.wsdns.cn/xxx.jpg')
* // => 'https://oss.wsdns.cn/xxx.jpg?x-oss-process=image/resize,w_750/quality,Q_90'
*
* @example
* // 自定义尺寸和质量
* getCompressedImageUrl('https://oss.wsdns.cn/xxx.jpg', { width: 400, quality: 80 })
* // => 'https://oss.wsdns.cn/xxx.jpg?x-oss-process=image/resize,w_400/quality,Q_80'
*
* @example
* // 关闭压缩
* getCompressedImageUrl('https://oss.wsdns.cn/xxx.jpg', { enabled: false })
* // => 'https://oss.wsdns.cn/xxx.jpg'
*/
export function getCompressedImageUrl(url: string, options?: ImageCompressOptions): string {
// 空 URL 直接返回空字符串
if (!url) return '';
const { width, quality, enabled } = { ...DEFAULT_OPTIONS, ...options };
// 未启用压缩,原样返回
if (!enabled) return url;
// 已包含 OSS 处理参数,避免重复拼接
if (url.includes('x-oss-process')) return url;
// 已有其他 query 参数用 & 拼接,否则用 ?
const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}x-oss-process=image/resize,w_${width}/quality,Q_${quality}`;
}