Files
xinlong-shop-taro/src/pages/store/goods/index.tsx
赵忠林 082646a613 feat(image): 全项目应用图片压缩优化
- 将图片显示统一替换为 getCompressedImageUrl 函数处理压缩
- 覆盖 35 个文件,包括 4 个组件和 31 个页面
- 主要组件:LazyImage、ProductCard、OrderCard、SkuSelector 内部自动压缩图片
- 主要页面:购物车、订单、积分、活动、拼团、秒杀、收藏、浏览历史等
- 跳过用户头像、二维码、商品详情大图及所有 Taro.previewImage 调用
- 调整压缩默认宽度为 300,保持质量90,启用压缩功能
2026-07-06 12:21:39 +08:00

678 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { 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'
import { getCompressedImageUrl } from '@/utils/image'
definePageConfig({
navigationBarTitleText: '商品管理',
})
// ─── Tab 配置 ──────────────────────────────────────────────────
type TabKey = 'all' | 'on_sale' | 'pending' | 'sold_out'
const TABS: { key: TabKey; label: string; param: Partial<ShopGoodsParam> }[] = [
{ key: 'all', label: '全部', param: {} },
{ key: 'on_sale', label: '已上架', param: { status: 0 } },
{ key: 'pending', label: '待上架', param: { status: 1 } },
{ key: 'sold_out', label: '已售罄', param: { stock: 0 } },
]
// ─── 状态辅助函数 ──────────────────────────────────────────────────
function getStatusText(status?: number): string {
const map: Record<number, string> = { 0: '已上架', 1: '待上架', 2: '待审核', 3: '审核不通过' }
return map[status ?? 0] || '未知'
}
function getStatusColor(status?: number): string {
const map: Record<number, string> = { 0: '#16a34a', 1: '#ea580c', 2: '#9333ea', 3: '#dc2626' }
return map[status ?? 0] || '#999'
}
// ─── 页面组件 ──────────────────────────────────────────────────────
export default function StoreGoodsPage() {
const [activeTab, setActiveTab] = useState<TabKey>('all')
// 商品列表与分页
const [goodsList, setGoodsList] = useState<ShopGoods[]>([])
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true)
const [loading, setLoading] = useState(false)
// 搜索与筛选
const [keyword, setKeyword] = useState('')
const [searchText, setSearchText] = useState('')
const [categories, setCategories] = useState<ShopGoodsCategory[]>([])
const [selectedCategory, setSelectedCategory] = useState<number | undefined>(undefined)
const [showCategoryPicker, setShowCategoryPicker] = useState(false)
// 编辑弹窗
const [showEditModal, setShowEditModal] = useState(false)
const [editGoods, setEditGoods] = useState<ShopGoods | null>(null)
const [editForm, setEditForm] = useState({
price: '',
salePrice: '',
dealerPrice: '',
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)
/** 加载商品列表 */
const loadGoods = useCallback(async (tab: TabKey, pageNo: number = 1, append = false, categoryId?: number) => {
if (loadingRef.current) return
loadingRef.current = true
setLoading(true)
try {
const tabConfig = TABS.find(t => t.key === tab)
if (!tabConfig) return
const params: ShopGoodsParam = {
...tabConfig.param,
page: pageNo,
limit: pageSize,
}
if (keyword) params.keywords = keyword
const effectiveCategoryId = categoryId !== undefined ? categoryId : selectedCategory
if (effectiveCategoryId) params.categoryId = effectiveCategoryId
const res = await pageShopGoods(params)
const list = res?.list || []
// 已售罄 Tab 需要客户端二次过滤 stock <= 0
const filtered = tab === 'sold_out' ? list.filter(g => (g.stock ?? 0) <= 0) : list
setGoodsList(prev => append ? [...prev, ...filtered] : filtered)
setPage(pageNo)
setHasMore(list.length >= pageSize)
} catch (e: any) {
Taro.showToast({ title: e.message || '加载失败', icon: 'none' })
if (!append) setGoodsList([])
} finally {
loadingRef.current = false
setLoading(false)
}
}, [keyword, selectedCategory])
// 加载分类列表
useEffect(() => {
listShopGoodsCategory({})
.then(data => setCategories(data || []))
.catch(() => {})
}, [])
// 切换 tab 时重新加载
useEffect(() => {
loadGoods(activeTab, 1)
}, [activeTab, loadGoods])
// 页面重新显示时刷新
useDidShow(() => {
loadGoods(activeTab, 1)
})
/** 加载更多 */
const handleLoadMore = () => {
if (hasMore && !loadingRef.current) {
loadGoods(activeTab, page + 1, true)
}
}
/** 执行搜索 */
const handleSearch = () => {
setKeyword(searchText)
loadGoods(activeTab, 1)
}
/** 选择分类 */
const handleSelectCategory = (cat: ShopGoodsCategory | undefined) => {
setSelectedCategory(cat?.categoryId)
setShowCategoryPicker(false)
loadGoods(activeTab, 1, false, cat?.categoryId)
}
/** 快速上架/下架 */
const handleToggleStatus = async (goods: ShopGoods) => {
const newStatus = goods.status === 0 ? 1 : 0
const actionText = newStatus === 0 ? '上架' : '下架'
Taro.showModal({
title: '提示',
content: `确定要${actionText}商品「${goods.name}」吗?`,
success: async (res) => {
if (!res.confirm) return
try {
Taro.showLoading({ title: `${actionText}中...` })
await updateShopGoods({ ...goods, status: newStatus })
Taro.hideLoading()
Taro.showToast({ title: `${actionText}成功`, icon: 'success' })
loadGoods(activeTab, 1)
} catch (e: any) {
Taro.hideLoading()
Taro.showToast({ title: e.message || `${actionText}失败`, icon: 'none' })
}
},
})
}
/** 切换推荐 */
const handleToggleRecommend = async (goods: ShopGoods) => {
const newRecommend = goods.recommend === 1 ? 0 : 1
try {
await updateShopGoods({ ...goods, recommend: newRecommend })
Taro.showToast({ title: newRecommend === 1 ? '已推荐' : '已取消推荐', icon: 'none' })
loadGoods(activeTab, 1)
} catch (e: any) {
Taro.showToast({ title: e.message || '操作失败', icon: 'none' })
}
}
/** 打开编辑弹窗 */
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 || '',
dealerPrice: goods.dealerPrice || '',
stock: String(goods.stock ?? ''),
sortNumber: String(goods.sortNumber ?? ''),
comments: goods.comments || '',
files: bannerFiles,
})
setShowEditModal(true)
}
/** 关闭编辑弹窗 */
const closeEditModal = () => {
setShowEditModal(false)
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
const price = parseFloat(editForm.price)
if (isNaN(price) || price < 0) {
Taro.showToast({ title: '请输入有效的价格', icon: 'none' })
return
}
const stock = parseInt(editForm.stock, 10)
if (isNaN(stock) || stock < 0) {
Taro.showToast({ title: '请输入有效的库存', icon: 'none' })
return
}
setSubmitting(true)
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()
loadGoods(activeTab, 1)
} catch (e: any) {
Taro.showToast({ title: e.message || '保存失败', icon: 'none' })
} finally {
setSubmitting(false)
}
}
/** 渲染单个商品卡片 */
const renderGoodsCard = (goods: ShopGoods) => {
const isSoldOut = (goods.stock ?? 0) <= 0
const isOnSale = goods.status === 0
return (
<View key={goods.goodsId} className='bg-white rounded-xl mx-3 mt-3 p-4'>
{/* 商品头部:图片 + 名称 + 状态 */}
<View className='flex items-start gap-3 mb-3'>
{goods.image ? (
<Image
className='w-20 h-20 rounded-lg bg-gray-50 flex-shrink-0'
src={getCompressedImageUrl(goods.image,{ width: 120, quality: 90 })}
mode='aspectFill'
onClick={() => Taro.previewImage({ urls: [goods.image!] })}
/>
) : (
<View className='w-20 h-20 rounded-lg bg-gray-100 flex items-center justify-center flex-shrink-0'>
<Text className='text-2xl text-gray-300'>📦</Text>
</View>
)}
<View className='flex-1 min-w-0'>
<View className='flex items-center gap-2 mb-1'>
<Text
className='text-xs px-1.5 py-0.5 rounded'
style={{ color: getStatusColor(goods.status), background: getStatusColor(goods.status) + '15' }}
>
{getStatusText(goods.status)}
</Text>
{isSoldOut && isOnSale && (
<Text className='text-xs px-1.5 py-0.5 rounded bg-red-50 text-red-500'></Text>
)}
{goods.recommend === 1 && (
<Text className='text-xs px-1.5 py-0.5 rounded bg-amber-50 text-amber-600'></Text>
)}
</View>
<Text className='text-sm text-gray-800 block' style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{goods.name}
</Text>
{goods.categoryName && (
<Text className='text-xs text-gray-400 mt-1 block'>{goods.categoryName}</Text>
)}
</View>
</View>
{/* 价格信息 */}
<View className='flex items-center gap-4 mb-3 bg-gray-50 rounded-lg p-3'>
<View className='flex-1'>
<Text className='text-xs text-gray-400 block'></Text>
<Text className='text-base text-red-500 font-medium'>¥{goods.price || '0'}</Text>
</View>
{goods.salePrice && (
<View className='flex-1'>
<Text className='text-xs text-gray-400 block'></Text>
<Text className='text-sm text-gray-400 line-through'>¥{goods.salePrice}</Text>
</View>
)}
{goods.dealerPrice && (
<View className='flex-1'>
<Text className='text-xs text-gray-400 block'>VIP价</Text>
<Text className='text-sm text-purple-500'>¥{goods.dealerPrice}</Text>
</View>
)}
<View className='flex-1'>
<Text className='text-xs text-gray-400 block'></Text>
<Text className='text-sm text-gray-700'>{goods.sales || 0}</Text>
</View>
<View className='flex-1'>
<Text className='text-xs text-gray-400 block'></Text>
<Text className={`text-sm ${isSoldOut ? 'text-red-500' : 'text-gray-700'}`}>{goods.stock ?? 0}</Text>
</View>
</View>
{/* 操作按钮 */}
<View className='flex items-center gap-2 border-t border-gray-50 pt-3'>
{/* 上架/下架 */}
<View
className={`flex-1 py-2 rounded-lg text-center ${isOnSale ? 'bg-orange-50' : 'bg-green-50'}`}
onClick={() => handleToggleStatus(goods)}
>
<Text className={`text-sm ${isOnSale ? 'text-orange-500' : 'text-green-500'}`}>
{isOnSale ? '下架' : '上架'}
</Text>
</View>
{/* 推荐 */}
<View
className={`flex-1 py-2 rounded-lg text-center ${goods.recommend === 1 ? 'bg-amber-50' : 'bg-gray-50'}`}
onClick={() => handleToggleRecommend(goods)}
>
<Text className={`text-sm ${goods.recommend === 1 ? 'text-amber-600' : 'text-gray-500'}`}>
{goods.recommend === 1 ? '取消推荐' : '推荐'}
</Text>
</View>
{/* 编辑 */}
<View
className='flex-1 py-2 rounded-lg text-center bg-blue-50'
onClick={() => openEditModal(goods)}
>
<Text className='text-sm text-blue-500'></Text>
</View>
</View>
</View>
)
}
return (
<View className='min-h-full bg-gray-50'>
{/* 搜索栏 */}
<View className='bg-white px-3 py-2 flex items-center gap-2'>
<View className='flex-1 flex items-center bg-gray-100 rounded-lg px-3 py-1.5'>
<Input
className='flex-1 text-sm'
placeholder='搜索商品名称'
value={searchText}
onInput={(e) => setSearchText(e.detail.value)}
onConfirm={handleSearch}
confirmType='search'
/>
{searchText ? (
<Text
className='text-gray-400 text-lg px-1'
onClick={() => { setSearchText(''); setKeyword(''); loadGoods(activeTab, 1) }}
>×</Text>
) : null}
</View>
<View
className='px-3 py-1.5 rounded-lg bg-gray-100 flex items-center'
onClick={() => setShowCategoryPicker(true)}
>
<Text className='text-sm text-gray-600'>
{selectedCategory
? categories.find(c => c.categoryId === selectedCategory)?.title || '分类'
: '分类'}
</Text>
<Text className='text-gray-400 text-xs ml-1'></Text>
</View>
</View>
{/* Tab 栏 */}
<View className='bg-white flex border-b border-gray-50'>
{TABS.map(tab => (
<View
key={tab.key}
className={`flex-1 text-center py-3 border-b-2 ${
activeTab === tab.key ? 'border-cyan-500' : 'border-transparent'
}`}
onClick={() => setActiveTab(tab.key)}
>
<Text
className={`text-sm ${activeTab === tab.key ? 'text-cyan-500 font-medium' : 'text-gray-500'}`}
>
{tab.label}
</Text>
</View>
))}
</View>
{/* 商品列表 */}
<ScrollView
scrollY
style={{ height: 'calc(100vh - 100px)' }}
onScrollToLower={handleLoadMore}
lowerThreshold={100}
>
{loading && goodsList.length === 0 ? (
<View className='flex justify-center items-center py-20'>
<Text className='text-gray-400'>...</Text>
</View>
) : goodsList.length === 0 ? (
<View className='flex justify-center items-center py-20'>
<Text className='text-gray-400'></Text>
</View>
) : (
goodsList.map(renderGoodsCard)
)}
{loading && goodsList.length > 0 && (
<View className='flex justify-center items-center py-4'>
<Text className='text-gray-400 text-sm'>...</Text>
</View>
)}
{!hasMore && goodsList.length > 0 && (
<View className='flex justify-center items-center py-4'>
<Text className='text-gray-300 text-xs'></Text>
</View>
)}
<View className='h-6' />
</ScrollView>
{/* 分类选择弹窗 */}
{showCategoryPicker && (
<View className='fixed inset-0 z-50' onClick={() => setShowCategoryPicker(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={() => setShowCategoryPicker(false)}>×</Text>
</View>
<View
className='px-5 py-3.5 border-b border-gray-50'
onClick={() => handleSelectCategory(undefined)}
>
<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={() => handleSelectCategory(cat)}
>
<Text className='text-sm text-gray-700'>{cat.title}</Text>
{selectedCategory === cat.categoryId && (
<Text className='text-cyan-500'></Text>
)}
</View>
))}
<View className='h-8' />
</View>
</View>
)}
{/* 编辑弹窗 */}
{showEditModal && editGoods && (
<View className='fixed inset-0 z-50 flex items-end justify-center'>
<View className='absolute inset-0 bg-black/50' onClick={closeEditModal} />
<View className='relative bg-white rounded-t-2xl w-full px-5 pt-6 pb-10 max-h-[80vh] overflow-y-auto'>
<Text className='text-lg font-medium text-gray-800 text-center mb-5 block'></Text>
{/* 商品信息 */}
<View className='bg-gray-50 rounded-xl p-4 mb-5 flex items-center gap-3'>
<View className='relative' onClick={handleUploadImage}>
{editGoods.image ? (
<Image className='w-16 h-16 rounded-lg' src={getCompressedImageUrl(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}
</Text>
<Text className='text-xs text-gray-400 mt-1 block'>ID: {editGoods.goodsId}</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'>
{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={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={() => 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>
<Input
className='bg-gray-50 rounded-lg px-4 py-3 text-sm'
type='digit'
placeholder='请输入价格'
value={editForm.price}
onInput={(e) => setEditForm(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={editForm.salePrice}
onInput={(e) => setEditForm(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={editForm.dealerPrice}
onInput={(e) => setEditForm(prev => ({ ...prev, dealerPrice: 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={editForm.stock}
onInput={(e) => setEditForm(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={editForm.sortNumber}
onInput={(e) => setEditForm(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={editForm.comments}
onInput={(e) => setEditForm(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={closeEditModal}>
<Text className='text-sm text-gray-600'></Text>
</View>
<View
className='flex-1 py-3 rounded-xl bg-cyan-500 text-center'
onClick={submitting ? undefined : submitEdit}
>
<Text className='text-sm text-white'>
{submitting ? '保存中...' : '保存'}
</Text>
</View>
</View>
</View>
</View>
)}
</View>
)
}