feat(store): 新增门店商品管理功能页面

- 在门店中心页新增“商品管理”功能卡片入口
- 注册商品管理页面路由`pages/store/goods/index`
- 实现商品列表分页、Tab状态筛选和关键词搜索功能
- 支持分类筛选,弹窗选择商品分类
- 商品卡片显示图片、名称、状态、价格、销量、库存和标签
- 快速操作支持商品上架/下架、推荐切换和弹窗编辑
- 编辑弹窗支持修改价格、市场价、库存、排序号和备注
- 调整收藏接口获取逻辑,增强接口调用稳定性
This commit is contained in:
2026-07-06 01:08:22 +08:00
parent b07a3525db
commit ac283148f7
6 changed files with 626 additions and 4 deletions

View File

@@ -30,11 +30,19 @@ export async function getShopGoodsFavoriteStatus(params: { goodsId: number }) {
}
// 收藏列表
export function listShopGoodsFavorite(params: ShopGoodsFavoriteParam) {
return request.get<ShopGoodsFavorite[]>('/shop/goods/favorite/list', params)
export async function listShopGoodsFavorite(params: ShopGoodsFavoriteParam) {
const res = await request.get<ApiResult<ShopGoodsFavorite[]>>('/shop/goods/favorite/list', params)
if (res.code === 0) {
return res.data || []
}
return Promise.reject(new Error(res.message))
}
// 收藏列表(分页)
export function pageShopGoodsFavorite(params: ShopGoodsFavoriteParam) {
return request.get<{ list: ShopGoodsFavorite[]; total: number }>('/shop/goods/favorite/page', params)
export async function pageShopGoodsFavorite(params: ShopGoodsFavoriteParam) {
const res = await request.get<ApiResult<{ list: ShopGoodsFavorite[]; total: number }>>('/shop/goods/favorite/page', params)
if (res.code === 0) {
return res.data || { list: [], total: 0 }
}
return Promise.reject(new Error(res.message))
}

View File

@@ -90,6 +90,7 @@ export default {
'pages/store/booking/index',
'pages/store/center/index',
'pages/store/orders/index',
'pages/store/goods/index',
// 售后页面
'pages/after-sale/apply/index',
'pages/after-sale/progress/index',

View File

@@ -11,6 +11,16 @@ definePageConfig({
// 功能卡片定义(未来新增功能只需在这里加一项)
const FEATURE_CARDS = [
{
key: 'goods',
icon: '🏷️',
title: '商品管理',
desc: '管理门店商品上下架和库存',
url: '/pages/store/goods/index',
color: '#0891b2',
bgColor: '#cffafe',
showBadge: false,
},
{
key: 'orders',
icon: '📦',

View File

@@ -0,0 +1,551 @@
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 type { ShopGoods, ShopGoodsParam } from '@/api/shop/shopGoods/model'
import type { ShopGoodsCategory } from '@/api/shop/shopGoodsCategory/model'
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: '',
stock: '',
sortNumber: '',
comments: '',
})
const [submitting, setSubmitting] = useState(false)
const pageSize = 10
const loadingRef = useRef(false)
/** 加载商品列表 */
const loadGoods = useCallback(async (tab: TabKey, pageNo: number = 1, append = false) => {
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
if (selectedCategory) params.categoryId = selectedCategory
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)
}
/** 快速上架/下架 */
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)
setEditForm({
price: goods.price || '',
salePrice: goods.salePrice || '',
stock: String(goods.stock ?? ''),
sortNumber: String(goods.sortNumber ?? ''),
comments: goods.comments || '',
})
setShowEditModal(true)
}
/** 关闭编辑弹窗 */
const closeEditModal = () => {
setShowEditModal(false)
setEditGoods(null)
}
/** 提交编辑 */
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,
price: editForm.price,
salePrice: editForm.salePrice || undefined,
stock,
sortNumber: parseInt(editForm.sortNumber, 10) || 0,
comments: editForm.comments || undefined,
})
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={goods.image}
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'>
{editGoods.image && (
<Image className='w-14 h-14 rounded-lg bg-gray-100' src={editGoods.image} mode='aspectFill' />
)}
<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-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='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>
)
}