feat(store): 添加商品图片及轮播图上传功能
- 商品编辑弹窗新增商品主图上传和更换功能,支持上传进度反馈 - 实现轮播图(files字段)上传、删除和显示,支持多格式files解析 - 编辑提交时携带图片和轮播图数据进行保存 - 新增图片压缩工具函数getCompressedImageUrl,用于生成缩略图压缩URL - 统一修正上传接口URL,确保图片上传服务可用
This commit is contained in:
@@ -61,11 +61,29 @@
|
|||||||
- **问题**: `handleSelectCategory` 中先 `setSelectedCategory` 再立即调用 `loadGoods`,但 `loadGoods` 通过闭包读取到的 `selectedCategory` 仍是旧值,导致第一次点击实际用的是上次选中的分类
|
- **问题**: `handleSelectCategory` 中先 `setSelectedCategory` 再立即调用 `loadGoods`,但 `loadGoods` 通过闭包读取到的 `selectedCategory` 仍是旧值,导致第一次点击实际用的是上次选中的分类
|
||||||
- **修复**: `loadGoods` 增加可选参数 `categoryId`,`handleSelectCategory` 选择分类后直接把新的 `cat?.categoryId` 传入 `loadGoods`,避免闭包取到旧状态
|
- **修复**: `loadGoods` 增加可选参数 `categoryId`,`handleSelectCategory` 选择分类后直接把新的 `cat?.categoryId` 传入 `loadGoods`,避免闭包取到旧状态
|
||||||
|
|
||||||
## 门店商品管理页编辑弹窗补充会员价字段
|
## 门店商品管理页添加图片上传/更换功能
|
||||||
- **文件**: `src/pages/store/goods/index.tsx`
|
- **文件**: `src/pages/store/goods/index.tsx`
|
||||||
- **问题**: 编辑弹窗只有到手价和市场价,缺少会员价(dealerPrice)输入框
|
- **问题**: 编辑弹窗只能修改价格库存等字段,缺少商品图片上传和更换功能
|
||||||
- **修复**:
|
- **修复**:
|
||||||
1. `editForm` state 新增 `dealerPrice: ''`
|
1. 引入 `uploadFile` from `@/api/system/file`
|
||||||
2. `openEditModal` 中填充 `dealerPrice: goods.dealerPrice || ''`
|
2. 新增 `uploadingImage` 状态追踪上传进度
|
||||||
3. 表单 UI 在市场价和库存之间新增会员价输入框
|
3. 新增 `handleUploadImage` 函数:调用 `uploadFile()` 上传图片,获取返回的 `path` 更新 `editGoods.image`
|
||||||
4. `submitEdit` 提交时携带 `dealerPrice`
|
4. 编辑弹窗商品信息区:图片改为可点击触发上传,蒙层显示「更换」/「上传中...」,无图时显示照相机占位符
|
||||||
|
5. `submitEdit` 提交时携带 `editGoods.image` 保存新图片
|
||||||
|
|
||||||
|
## 门店商品管理页添加轮播图上传/删除功能
|
||||||
|
- **文件**: `src/pages/store/goods/index.tsx`
|
||||||
|
- **问题**: 编辑弹窗缺少轮播图(`files` 字段)管理能力;实际 `files` 为对象数组 `[{uid,url,status}]`,首次实现只按字符串数组解析导致图片无法显示
|
||||||
|
- **修复**:
|
||||||
|
1. `editForm` 新增 `files` 状态,元素为 `{ uid?, url, status? }` 对象
|
||||||
|
2. `openEditModal` 解析 `goods.files`:兼容对象数组 `[{uid,url,status}]`、字符串数组 `["url"]`、逗号分隔字符串三种格式
|
||||||
|
3. 新增 `handleAddBanner` 上传新轮播图(生成 `{ uid: file.id, url, status: 'done' }`),`handleRemoveBanner` 删除指定位置轮播图
|
||||||
|
4. 编辑弹窗新增轮播图区域:显示现有图片+删除按钮,底部「上传」入口
|
||||||
|
5. `submitEdit` 提交时将 `files` 数组 `JSON.stringify` 后保存
|
||||||
|
|
||||||
|
## 新增图片压缩工具函数
|
||||||
|
- **文件**: `src/utils/image.ts`(新建)
|
||||||
|
- **函数**: `getCompressedImageUrl(url, options?)` — 给 OSS 图片 URL 拼接阿里云图片处理参数
|
||||||
|
- **默认参数**: `width=750, quality=90, enabled=true`
|
||||||
|
- **安全处理**: 空 URL 返回空串、已含 `x-oss-process` 不重复拼接、根据已有 query 参数自动选 `?`/`&` 分隔符
|
||||||
|
- **使用约定**: 列表/卡片等缩略图场景使用此函数;预览大图(`Taro.previewImage`)直接用原 URL,不调用此函数
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export async function uploadFile() {
|
|||||||
const tempFilePath = res.tempFilePaths[0];
|
const tempFilePath = res.tempFilePaths[0];
|
||||||
// 上传图片到OSS
|
// 上传图片到OSS
|
||||||
Taro.uploadFile({
|
Taro.uploadFile({
|
||||||
url: 'https://shop-api.websoft.top/api/oss/upload',
|
url: 'https://server.websoft.top/api/oss/upload',
|
||||||
filePath: tempFilePath,
|
filePath: tempFilePath,
|
||||||
name: 'file',
|
name: 'file',
|
||||||
header: {
|
header: {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { View, Text, Image, ScrollView, Input, Textarea } from '@tarojs/componen
|
|||||||
import Taro, { useDidShow } from '@tarojs/taro'
|
import Taro, { useDidShow } from '@tarojs/taro'
|
||||||
import { pageShopGoods, updateShopGoods } from '@/api/shop/shopGoods'
|
import { pageShopGoods, updateShopGoods } from '@/api/shop/shopGoods'
|
||||||
import { listShopGoodsCategory } from '@/api/shop/shopGoodsCategory'
|
import { listShopGoodsCategory } from '@/api/shop/shopGoodsCategory'
|
||||||
|
import { uploadFile } from '@/api/system/file'
|
||||||
import type { ShopGoods, ShopGoodsParam } from '@/api/shop/shopGoods/model'
|
import type { ShopGoods, ShopGoodsParam } from '@/api/shop/shopGoods/model'
|
||||||
import type { ShopGoodsCategory } from '@/api/shop/shopGoodsCategory/model'
|
import type { ShopGoodsCategory } from '@/api/shop/shopGoodsCategory/model'
|
||||||
|
|
||||||
@@ -58,8 +59,11 @@ export default function StoreGoodsPage() {
|
|||||||
stock: '',
|
stock: '',
|
||||||
sortNumber: '',
|
sortNumber: '',
|
||||||
comments: '',
|
comments: '',
|
||||||
|
files: [] as Array<{ uid?: number; url: string; status?: string }>,
|
||||||
})
|
})
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [uploadingImage, setUploadingImage] = useState(false)
|
||||||
|
const [uploadingBanner, setUploadingBanner] = useState(false)
|
||||||
|
|
||||||
const pageSize = 10
|
const pageSize = 10
|
||||||
const loadingRef = useRef(false)
|
const loadingRef = useRef(false)
|
||||||
@@ -177,6 +181,26 @@ export default function StoreGoodsPage() {
|
|||||||
/** 打开编辑弹窗 */
|
/** 打开编辑弹窗 */
|
||||||
const openEditModal = (goods: ShopGoods) => {
|
const openEditModal = (goods: ShopGoods) => {
|
||||||
setEditGoods(goods)
|
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({
|
setEditForm({
|
||||||
price: goods.price || '',
|
price: goods.price || '',
|
||||||
salePrice: goods.salePrice || '',
|
salePrice: goods.salePrice || '',
|
||||||
@@ -184,6 +208,7 @@ export default function StoreGoodsPage() {
|
|||||||
stock: String(goods.stock ?? ''),
|
stock: String(goods.stock ?? ''),
|
||||||
sortNumber: String(goods.sortNumber ?? ''),
|
sortNumber: String(goods.sortNumber ?? ''),
|
||||||
comments: goods.comments || '',
|
comments: goods.comments || '',
|
||||||
|
files: bannerFiles,
|
||||||
})
|
})
|
||||||
setShowEditModal(true)
|
setShowEditModal(true)
|
||||||
}
|
}
|
||||||
@@ -194,6 +219,53 @@ export default function StoreGoodsPage() {
|
|||||||
setEditGoods(null)
|
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 () => {
|
const submitEdit = async () => {
|
||||||
if (!editGoods) return
|
if (!editGoods) return
|
||||||
@@ -214,12 +286,14 @@ export default function StoreGoodsPage() {
|
|||||||
try {
|
try {
|
||||||
await updateShopGoods({
|
await updateShopGoods({
|
||||||
...editGoods,
|
...editGoods,
|
||||||
|
image: editGoods.image,
|
||||||
price: editForm.price,
|
price: editForm.price,
|
||||||
salePrice: editForm.salePrice || undefined,
|
salePrice: editForm.salePrice || undefined,
|
||||||
dealerPrice: editForm.dealerPrice || undefined,
|
dealerPrice: editForm.dealerPrice || undefined,
|
||||||
stock,
|
stock,
|
||||||
sortNumber: parseInt(editForm.sortNumber, 10) || 0,
|
sortNumber: parseInt(editForm.sortNumber, 10) || 0,
|
||||||
comments: editForm.comments || undefined,
|
comments: editForm.comments || undefined,
|
||||||
|
files: JSON.stringify(editForm.files),
|
||||||
})
|
})
|
||||||
Taro.showToast({ title: '保存成功', icon: 'success' })
|
Taro.showToast({ title: '保存成功', icon: 'success' })
|
||||||
closeEditModal()
|
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'>
|
<View className='bg-gray-50 rounded-xl p-4 mb-5 flex items-center gap-3'>
|
||||||
{editGoods.image && (
|
<View className='relative' onClick={handleUploadImage}>
|
||||||
<Image className='w-14 h-14 rounded-lg bg-gray-100' src={editGoods.image} mode='aspectFill' />
|
{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'>
|
<View className='flex-1 min-w-0'>
|
||||||
<Text className='text-sm text-gray-800 block' style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
<Text className='text-sm text-gray-800 block' style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
{editGoods.name}
|
{editGoods.name}
|
||||||
@@ -476,6 +561,31 @@ export default function StoreGoodsPage() {
|
|||||||
</View>
|
</View>
|
||||||
</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'>
|
<View className='mb-4'>
|
||||||
<Text className='text-sm text-gray-600 mb-2 block'>到手价 (¥)</Text>
|
<Text className='text-sm text-gray-600 mb-2 block'>到手价 (¥)</Text>
|
||||||
|
|||||||
65
src/utils/image.ts
Normal file
65
src/utils/image.ts
Normal 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}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user