diff --git a/.workbuddy/memory/2026-07-06.md b/.workbuddy/memory/2026-07-06.md index c2b3cfd..3325b68 100644 --- a/.workbuddy/memory/2026-07-06.md +++ b/.workbuddy/memory/2026-07-06.md @@ -61,11 +61,29 @@ - **问题**: `handleSelectCategory` 中先 `setSelectedCategory` 再立即调用 `loadGoods`,但 `loadGoods` 通过闭包读取到的 `selectedCategory` 仍是旧值,导致第一次点击实际用的是上次选中的分类 - **修复**: `loadGoods` 增加可选参数 `categoryId`,`handleSelectCategory` 选择分类后直接把新的 `cat?.categoryId` 传入 `loadGoods`,避免闭包取到旧状态 -## 门店商品管理页编辑弹窗补充会员价字段 +## 门店商品管理页添加图片上传/更换功能 - **文件**: `src/pages/store/goods/index.tsx` -- **问题**: 编辑弹窗只有到手价和市场价,缺少会员价(dealerPrice)输入框 -- **修复**: - 1. `editForm` state 新增 `dealerPrice: ''` - 2. `openEditModal` 中填充 `dealerPrice: goods.dealerPrice || ''` - 3. 表单 UI 在市场价和库存之间新增会员价输入框 - 4. `submitEdit` 提交时携带 `dealerPrice` +- **问题**: 编辑弹窗只能修改价格库存等字段,缺少商品图片上传和更换功能 +- **修复**: + 1. 引入 `uploadFile` from `@/api/system/file` + 2. 新增 `uploadingImage` 状态追踪上传进度 + 3. 新增 `handleUploadImage` 函数:调用 `uploadFile()` 上传图片,获取返回的 `path` 更新 `editGoods.image` + 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,不调用此函数 diff --git a/src/api/system/file/index.ts b/src/api/system/file/index.ts index b392097..61334e5 100644 --- a/src/api/system/file/index.ts +++ b/src/api/system/file/index.ts @@ -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: { diff --git a/src/pages/store/goods/index.tsx b/src/pages/store/goods/index.tsx index 4df032d..8fd1e4e 100644 --- a/src/pages/store/goods/index.tsx +++ b/src/pages/store/goods/index.tsx @@ -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() { {/* 商品信息 */} - {editGoods.image && ( - - )} + + {editGoods.image ? ( + + ) : ( + + 📷 + + )} + + + {uploadingImage ? '上传中...' : '更换'} + + + {editGoods.name} @@ -476,6 +561,31 @@ export default function StoreGoodsPage() { + {/* 轮播图 */} + + 轮播图 + + {editForm.files.map((file, index) => ( + + + handleRemoveBanner(index)} + > + × + + + ))} + + {uploadingBanner ? '...' : '+'} + {uploadingBanner ? '上传中' : '上传'} + + + + {/* 表单字段 */} 到手价 (¥) diff --git a/src/utils/image.ts b/src/utils/image.ts new file mode 100644 index 0000000..56d3c17 --- /dev/null +++ b/src/utils/image.ts @@ -0,0 +1,65 @@ +/** + * 图片压缩参数配置 + */ +export interface ImageCompressOptions { + /** 图片宽度(px),默认 750 */ + width?: number; + /** 图片质量 0-100,默认 90 */ + quality?: number; + /** 是否启用压缩,默认 true */ + enabled?: boolean; +} + +/** + * 默认压缩参数 + */ +const DEFAULT_OPTIONS: Required = { + 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}`; +}