feat(user): 新增收货地址管理及售后申请页面

- 新增地址类型定义,增强前端地址数据结构
- 新增地址编辑页面,支持地址智能识别和定位选点功能
- 地址编辑支持省市区选择及默认地址设置
- 新增地址列表页面,支持地址展示、删除、编辑和选择功能
- 实现售后申请页面,支持选择售后类型和退款原因
- 售后申请支持商品选择、退款金额计算和凭证上传
- 新增售后详情页面,支持售后状态展示及申请取消
- 优化页面加载和用户交互体验,增加错误提示和权限处理
This commit is contained in:
2026-07-01 12:11:56 +08:00
parent bf6ed504cc
commit 1fa58040f3
636 changed files with 58878 additions and 716 deletions

View File

@@ -0,0 +1,28 @@
import React from 'react'
import { View, Text, RichText, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useScrollHeight } from '@/hooks/useScrollHeight'
definePageConfig({
navigationBarTitleText: '文章详情',
})
const content = '<p style="color:#666;font-size:14px;line-height:1.8;">文章内容区域后续对接CMS文章API后自动渲染。</p>'
const ArticleDetailPage: React.FC = () => {
const scrollHeight = useScrollHeight(44)
return (
<View className='min-h-screen bg-white'>
<ScrollView scrollY style={{ height: scrollHeight }}>
<View className='p-4'>
<Text className='text-lg font-bold text-gray-800 block mb-2'></Text>
<Text className='text-xs text-gray-400 block mb-4'>2026-05-11</Text>
<RichText nodes={content} />
</View>
</ScrollView>
</View>
)
}
export default ArticleDetailPage

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '活动列表',
}

View File

@@ -0,0 +1,125 @@
import React, { useState, useEffect } from 'react'
import { View, Text, ScrollView, Image } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { listCmsArticle } from '@/api/cms/cmsArticle'
import type { CmsArticle } from '@/api/cms/cmsArticle/model'
import EmptyState from '@/components/common/EmptyState'
import { useScrollHeight } from '@/hooks/useScrollHeight'
definePageConfig({
navigationBarTitleText: '活动列表',
})
const ArticleListPage: React.FC = () => {
const [articles, setArticles] = useState<CmsArticle[]>([])
const [loading, setLoading] = useState(true)
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true)
const scrollHeight = useScrollHeight(44)
const type = (Taro.getCurrentInstance().router?.params as any)?.type || 'activity'
useEffect(() => {
fetchArticles()
}, [])
const fetchArticles = async (loadMore = false) => {
try {
const currentPage = loadMore ? page + 1 : 1
const data = await listCmsArticle({
category: type === 'activity' ? 'activity' : 'notice',
status: 1,
page: currentPage,
limit: 10,
})
if (data) {
if (loadMore) {
setArticles(prev => [...prev, ...data])
setPage(currentPage)
} else {
setArticles(data)
}
setHasMore(data.length >= 10)
}
} catch (e) {
console.error('获取文章列表失败:', e)
} finally {
setLoading(false)
}
}
const handleLoadMore = () => {
if (!hasMore || loading) return
fetchArticles(true)
}
const handleArticleClick = (id: number) => {
Taro.navigateTo({ url: `/pages/index/article-detail?id=${id}` })
}
return (
<View className='min-h-screen bg-gray-50'>
<ScrollView
scrollY
style={{ height: scrollHeight }}
onScrollToLower={handleLoadMore}
>
{loading ? (
<View className='flex items-center justify-center py-10'>
<Text className='text-gray-400'>...</Text>
</View>
) : articles.length === 0 ? (
<EmptyState text='暂无活动' />
) : (
<View className='p-3'>
{articles.map(item => (
<View
key={item.id}
className='bg-white rounded-lg mb-3 overflow-hidden'
onClick={() => handleArticleClick(item.id)}
>
{item.coverImage && (
<Image
className='w-full'
src={item.coverImage}
mode='aspectFill'
style={{ height: '160px' }}
/>
)}
<View className='p-3'>
<Text className='text-base font-medium text-gray-800 block mb-1'>
{item.title}
</Text>
{item.summary && (
<Text className='text-sm text-gray-500 mb-2 block' numberOfLines={2}>
{item.summary}
</Text>
)}
<View className='flex justify-between items-center'>
<Text className='text-xs text-gray-400'>
{item.createTime}
</Text>
{item.isHot && (
<View className='px-2 py-0 bg-red-500 rounded-full'>
<Text className='text-xs text-white'></Text>
</View>
)}
</View>
</View>
</View>
))}
{hasMore && (
<View className='py-3 text-center'>
<Text className='text-gray-400 text-sm'>...</Text>
</View>
)}
</View>
)}
</ScrollView>
</View>
)
}
export default ArticleListPage

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '领券中心',
}

View File

@@ -0,0 +1,125 @@
import React, { useState, useEffect } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useUser } from '@/hooks/useUser'
import { useScrollHeight } from '@/hooks/useScrollHeight'
import { listShopCoupon } from '@/api/shop/shopCoupon'
import type { ShopCoupon } from '@/api/shop/shopCoupon/model'
import EmptyState from '@/components/common/EmptyState'
definePageConfig({
navigationBarTitleText: '领券中心',
})
const CouponCenterPage: React.FC = () => {
const { isLoggedIn } = useUser()
const [coupons, setCoupons] = useState<ShopCoupon[]>([])
const [loading, setLoading] = useState(true)
const scrollHeight = useScrollHeight(44)
useEffect(() => {
if (!isLoggedIn) {
Taro.navigateTo({ url: '/passport/login' })
return
}
fetchCoupons()
}, [isLoggedIn])
const fetchCoupons = async () => {
try {
const data = await listShopCoupon({ status: 1 })
setCoupons(data || [])
} catch (e) {
console.error('获取优惠券失败:', e)
} finally {
setLoading(false)
}
}
const handleClaimCoupon = (id: number) => {
Taro.showModal({
title: '领取优惠券',
content: '确定要领取这张优惠券吗?',
success: (res) => {
if (res.confirm) {
Taro.showToast({ title: '领取成功', icon: 'success' })
fetchCoupons()
}
},
})
}
const getCouponTypeText = (type: number) => {
const typeMap: Record<number, string> = {
1: '无门槛券',
2: '满减券',
3: '折扣券',
4: '运费券',
}
return typeMap[type] || '优惠券'
}
if (!isLoggedIn) {
return null
}
return (
<View className='min-h-screen bg-gray-50'>
<ScrollView scrollY style={{ height: scrollHeight }}>
{loading ? (
<View className='flex items-center justify-center py-10'>
<Text className='text-gray-400'>...</Text>
</View>
) : coupons.length === 0 ? (
<EmptyState text='暂无可用优惠券' />
) : (
<View className='p-3'>
{coupons.map((item) => (
<View
key={item.id}
className='bg-white rounded-lg mb-3 overflow-hidden'
>
<View className='flex'>
<View
className='p-4 text-white flex flex-col items-center justify-center'
style={{ width: '120px', backgroundColor: '#0e932e' }}
>
<Text className='text-2xl font-bold'>
{item.discountType === 3 ? `${item.discountValue}` : `¥${item.amount}`}
</Text>
<Text className='text-xs mt-1'>
{item.minAmount ? `${item.minAmount}可用` : '无门槛'}
</Text>
</View>
<View className='flex-1 p-3'>
<Text className='text-sm font-medium text-gray-800 block mb-1'>
{getCouponTypeText(item.couponType)}
</Text>
<Text className='text-xs text-gray-500 mb-1 block'>
{item.name}
</Text>
<Text className='text-xs text-gray-400 block'>
{item.expireTime}
</Text>
</View>
<View className='flex items-center pr-3'>
<View
className='px-3 py-1 rounded-full'
style={{ backgroundColor: '#0e932e' }}
onClick={() => handleClaimCoupon(item.id!)}
>
<Text className='text-xs text-white'></Text>
</View>
</View>
</View>
</View>
))}
</View>
)}
</ScrollView>
</View>
)
}
export default CouponCenterPage

View File

@@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '领券中心',
}

View File

@@ -0,0 +1,164 @@
import React, { useState, useEffect } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useUser } from '@/hooks/useUser'
import { useScrollHeight } from '@/hooks/useScrollHeight'
import { listCouponCenter } from '@/api/shop/shopCoupon'
import { takeCoupon } from '@/api/shop/shopUserCoupon'
import type { ShopCouponWithTake } from '@/api/shop/shopCoupon/model'
import EmptyState from '@/components/common/EmptyState'
definePageConfig({
navigationBarTitleText: '领券中心',
})
const CouponCenterPage: React.FC = () => {
const { isLoggedIn } = useUser()
const [coupons, setCoupons] = useState<ShopCouponWithTake[]>([])
const [loading, setLoading] = useState(true)
const [claiming, setClaiming] = useState<number | null>(null)
const scrollHeight = useScrollHeight(44)
useEffect(() => {
if (!isLoggedIn) {
Taro.navigateTo({ url: '/passport/login' })
return
}
fetchCoupons()
}, [isLoggedIn])
const fetchCoupons = async () => {
setLoading(true)
try {
const data = await listCouponCenter({ status: 0, isExpire: 0 })
setCoupons(data || [])
} catch (e) {
console.error('获取优惠券失败:', e)
} finally {
setLoading(false)
}
}
const handleClaimCoupon = async (id: number) => {
if (claiming) return
setClaiming(id)
try {
await takeCoupon(id)
Taro.showToast({ title: '领取成功', icon: 'success' })
// 更新本地状态,避免重新请求
setCoupons(prev =>
prev.map(c => c.id === id ? { ...c, hasTake: true, userTakeNum: (c.userTakeNum || 0) + 1 } : c)
)
} catch (e: any) {
Taro.showToast({ title: e?.message || '领取失败', icon: 'none' })
} finally {
setClaiming(null)
}
}
const getCouponValueText = (item: ShopCouponWithTake) => {
switch (item.type) {
case 20: return `${item.discount}`
case 40: return `¥${item.reducePrice}`
case 50: return item.useCount && item.useCount > 0 ? `${item.useCount}` : '不限次'
default: return `¥${item.reducePrice}`
}
}
const getCouponConditionText = (item: ShopCouponWithTake) => {
if (item.type === 40) return '无门槛'
if (item.type === 50) {
const parts: string[] = []
if (item.venueType !== undefined && item.venueType !== null) parts.push(`场地类型${item.venueType}`)
if (item.useDuration && item.useDuration > 0) parts.push(`${item.useDuration}分钟`)
return parts.length > 0 ? parts.join(' | ') : '场地使用'
}
if (item.minPrice && Number(item.minPrice) > 0) return `${item.minPrice}可用`
return '无门槛'
}
const getCouponExpireText = (item: ShopCouponWithTake) => {
if (item.expireType === 10) return `领取后${item.expireDay}天内有效`
if (item.endTime) return `有效期至:${item.endTime.slice(0, 10)}`
return ''
}
const getCouponTypeText = (type?: number) => {
const map: Record<number, string> = { 10: '满减券', 20: '折扣券', 30: '免费券', 40: '无门槛券', 50: '场地使用券' }
return map[type || 0] || '优惠券'
}
const isClaimable = (item: ShopCouponWithTake) => {
if (item.hasTake) return false
if (item.limitPerUser !== -1 && (item.userTakeNum || 0) >= (item.limitPerUser || 1)) return false
if (item.totalCount !== -1 && (item.issuedCount || 0) >= (item.totalCount || 0)) return false
return true
}
if (!isLoggedIn) return null
return (
<View className='min-h-screen bg-gray-50'>
<ScrollView scrollY style={{ height: scrollHeight }}>
{loading ? (
<View className='flex items-center justify-center py-10'>
<Text className='text-gray-400'>...</Text>
</View>
) : coupons.length === 0 ? (
<EmptyState text='暂无可领取的优惠券' />
) : (
<View className='p-3'>
{coupons.map((item) => {
const canClaim = isClaimable(item)
return (
<View
key={item.id}
className='bg-white rounded-lg mb-3 overflow-hidden'
style={{ opacity: canClaim ? 1 : 0.6 }}
>
<View className='flex'>
<View
className='p-4 text-white flex flex-col items-center justify-center'
style={{ width: '120px', backgroundColor: canClaim ? (
item.type === 40 ? '#3b82f6' :
item.type === 50 ? '#06b6d4' :
'#0e932e'
) : '#999' }}
>
<Text className='text-2xl font-bold'>{getCouponValueText(item)}</Text>
<Text className='text-xs mt-1'>{getCouponConditionText(item)}</Text>
</View>
<View className='flex-1 p-3'>
<Text className='text-sm font-medium text-gray-800 block mb-1'>
{getCouponTypeText(item.type)}
</Text>
<Text className='text-xs text-gray-500 mb-1 block'>{item.name}</Text>
<Text className='text-xs text-gray-400 block'>{getCouponExpireText(item)}</Text>
</View>
<View className='flex items-center pr-3'>
<View
className='px-3 py-1 rounded-full'
style={{ backgroundColor: canClaim ? (
item.type === 40 ? '#3b82f6' :
item.type === 50 ? '#06b6d4' :
'#0e932e'
) : '#ccc' }}
onClick={() => canClaim && handleClaimCoupon(item.id!)}
>
<Text className='text-xs text-white'>
{claiming === item.id ? '领取中' : item.hasTake ? '已领取' : '领取'}
</Text>
</View>
</View>
</View>
</View>
)
})}
</View>
)}
</ScrollView>
</View>
)
}
export default CouponCenterPage

View File

@@ -0,0 +1,6 @@
export default {
navigationBarTitleText: '鑫龙家电',
navigationBarBackgroundColor: '#ffffff',
navigationBarTextStyle: 'black',
backgroundColor: '#f8f8f8'
}

View File

@@ -0,0 +1,9 @@
.index-page {
min-height: 100vh;
padding: 32px;
box-sizing: border-box;
.nut-button {
margin-bottom: 16px;
}
}

View File

@@ -0,0 +1,270 @@
import React, { useState, useEffect } from 'react'
import { View, Text, ScrollView, Swiper, SwiperItem, Image } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useUser } from '@/hooks/useUser'
import { useScrollHeight } from '@/hooks/useScrollHeight'
import { listCmsAd } from '@/api/cms/cmsAd'
import { listCmsArticle } from '@/api/cms/cmsArticle'
import { pageShopGoods } from '@/api/shop/shopGoods'
import type { CmsAd } from '@/api/cms/cmsAd/model'
import type { CmsArticle } from '@/api/cms/cmsArticle/model'
import type { ShopGoods } from '@/api/shop/shopGoods/model'
import Price from '@/components/common/Price'
definePageConfig({
navigationBarTitleText: '首页',
})
const IndexPage: React.FC = () => {
const { user, isLoggedIn } = useUser()
const [banners, setBanners] = useState<CmsAd[]>([])
const [announcements, setAnnouncements] = useState<CmsArticle[]>([])
const [hotProducts, setHotProducts] = useState<ShopGoods[]>([])
const [loading, setLoading] = useState(true)
const scrollHeight = useScrollHeight(44)
const categories = ['全部', '球拍', '球鞋', '服装', '配件']
// 功能入口8个
const featureEntries = [
{ icon: '🎁', label: '全部商品', url: '/pages/shop/index' },
{ icon: '🎯', label: '限时秒杀', url: '/pages/shop/seckill-list/index' },
{ icon: '🎫', label: '领券中心', url: '/pages/index/coupon-center/index' },
{ icon: '⭐', label: '我的收藏', url: '/pages/user/favorite-list/index' },
{ icon: '👑', label: '会员中心', url: '/pages/user/member/index' },
{ icon: '💰', label: '积分商城', url: '/pages/points/index' },
{ icon: '🏪', label: '门店信息', url: '/pages/store/list/index' },
{ icon: '❓', label: '帮助中心', url: '/pages/user/help-center/index' },
]
// 获取轮播图
useEffect(() => {
const fetchBanners = async () => {
try {
const data = await listCmsAd({ adType: 'banner', status: 0 })
setBanners(data || [])
} catch (e) {
console.error('获取轮播图失败:', e)
}
}
const fetchAnnouncements = async () => {
try {
const data = await listCmsArticle({ category: 'notice', status: 1 })
setAnnouncements(data || [])
} catch (e) {
console.error('获取公告失败:', e)
} finally {
setLoading(false)
}
}
fetchBanners()
fetchAnnouncements()
fetchHotProducts()
}, [])
// 获取热销商品
const fetchHotProducts = async () => {
try {
const res = await pageShopGoods({ page: 1, limit: 4, status: 0, recommend: 1 })
if (res?.list) {
setHotProducts(res.list)
}
} catch (e) {
console.error('获取热销商品失败:', e)
}
}
const handleMessageClick = () => {
if (!isLoggedIn) {
Taro.navigateTo({ url: '/passport/login' })
return
}
Taro.navigateTo({ url: '/pages/index/notification' })
}
// tabBar 页面列表
const tabBarPages = ['/pages/index/index', '/pages/shop/index', '/pages/user/user']
const handleFeatureClick = (url: string) => {
if (tabBarPages.includes(url)) {
Taro.switchTab({ url })
} else {
Taro.navigateTo({ url })
}
}
return (
<View className='min-h-screen bg-gray-50'>
{/* 顶部搜索栏 */}
<View className='flex items-center px-3 py-2 bg-white'>
<View className='flex-1 mx-2'>
<View
className='flex items-center bg-gray-100 rounded-full px-3 py-2'
onClick={() => Taro.navigateTo({ url: '/pages/index/search' })}
>
<Text className='text-gray-400 text-sm mr-2'>🔍</Text>
<Text className='text-gray-400 text-sm flex-1'></Text>
</View>
</View>
<View onClick={handleMessageClick}>
<Text className='text-xl'>🔔</Text>
{isLoggedIn && (
<View className='absolute top-0 right-0 w-2 h-2 bg-red-500 rounded-full' />
)}
</View>
</View>
<ScrollView scrollY style={{ height: scrollHeight }}>
{/* 轮播图 */}
<View className='mx-3 mt-2 rounded-lg overflow-hidden'>
{banners.length > 0 ? (
<Swiper
autoplay
interval={3000}
className='rounded-lg'
style={{ height: '160px' }}
>
{banners.map(item => (
<SwiperItem key={item.adId}>
<Image
className='w-full h-full'
src={item.imageList?.[0]?.url || item.image || ''}
mode='aspectFill'
onClick={() => {
if (item.path) Taro.navigateTo({ url: item.path })
}}
/>
</SwiperItem>
))}
</Swiper>
) : (
<View className='w-full bg-green-50 flex items-center justify-center' style={{ height: '160px' }}>
<Text className='text-gray-400 text-sm'></Text>
</View>
)}
</View>
{/* 功能入口网格 */}
<View className='mx-3 mt-3 p-3 bg-white rounded-lg'>
<View className='grid grid-cols-4 gap-2'>
{featureEntries.map(item => (
<View
key={item.label}
className='flex flex-col items-center py-2'
onClick={() => handleFeatureClick(item.url)}
>
<Text className='text-2xl mb-1'>{item.icon}</Text>
<Text className='text-xs text-gray-600'>{item.label}</Text>
</View>
))}
</View>
</View>
{/* 公告栏 */}
{announcements.length > 0 && (
<View className='mx-3 mt-3 px-3 py-2 bg-yellow-50 rounded-lg flex items-center' style={{ display: 'none' }}>
<Text className='text-xs text-yellow-600 font-medium mr-2'></Text>
<Swiper
autoplay
direction='vertical'
interval={3000}
className='flex-1'
style={{ height: '20px' }}
>
{announcements.map(item => (
<SwiperItem key={item.articleId}>
<Text
className='text-xs text-gray-600 truncate'
onClick={() => Taro.navigateTo({ url: `/pages/index/article-detail?id=${item.articleId}` })}
>
{item.title}
</Text>
</SwiperItem>
))}
</Swiper>
</View>
)}
{/* 分类导航 */}
<View className='mx-3 mt-3 p-3 bg-white rounded-lg' style={{ display: 'none' }}>
<Text className='text-base font-medium text-gray-800 mb-3 block'></Text>
<View className='flex justify-around'>
{categories.map((item) => (
<View
key={item}
className='flex flex-col items-center'
onClick={() => Taro.navigateTo({ url: '/pages/shop/category' })}
>
<View className='w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center mb-1'>
<Text className='text-xs text-gray-500'>{item.slice(0, 1)}</Text>
</View>
<Text className='text-xs text-gray-600'>{item}</Text>
</View>
))}
</View>
</View>
{/* 热销推荐 */}
<View className='mx-3 mt-3 mb-4'>
<View className='flex justify-between items-center mb-3'>
<Text className='text-base font-medium text-gray-800 block'></Text>
<Text
className='text-xs text-gray-400 block'
onClick={() => Taro.switchTab({ url: '/pages/shop/index' })}
>
{'>'}
</Text>
</View>
<View className='grid grid-cols-2 gap-2'>
{hotProducts.length > 0 ? (
hotProducts.map((item) => (
<View
key={item.goodsId}
className='bg-white rounded-lg overflow-hidden'
onClick={() => Taro.navigateTo({ url: `/pages/shop/product-detail?id=${item.goodsId}` })}
>
<View className='w-full' style={{ paddingTop: '100%', position: 'relative' }}>
{item.image ? (
<Image
style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%' }}
src={item.image}
mode='aspectFit'
/>
) : (
<View style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%' }} className='bg-gray-100 flex items-center justify-center'>
<Text className='text-xs text-gray-300'></Text>
</View>
)}
</View>
<View className='p-2'>
<Text className='text-sm text-gray-700 truncate block'>{item.goodsName || item.name}</Text>
<View className='flex items-center mt-1'>
<Price
price={item.price || '0'}
original={item.salePrice && item.salePrice !== item.price ? item.salePrice : undefined}
size='small'
loginMask
/>
</View>
{item.sales !== undefined && item.sales > 0 && (
<Text className='text-xs text-gray-400 mt-1'> {item.sales}</Text>
)}
</View>
</View>
))
) : (
<View className='col-span-2 py-8 text-center'>
<Text className='text-sm text-gray-400'></Text>
</View>
)}
</View>
</View>
</ScrollView>
</View>
)
}
export default IndexPage

View File

@@ -0,0 +1,87 @@
import React, { useState, useEffect } from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { listShopMessage } from '@/api/shop/shopMessage'
import type { ShopMessage } from '@/api/shop/shopMessage'
import EmptyState from '@/components/common/EmptyState'
import LoadMore from '@/components/common/LoadMore'
definePageConfig({
navigationBarTitleText: '消息通知',
})
const NotificationPage: React.FC = () => {
const [list, setList] = useState<ShopMessage[]>([])
const [loading, setLoading] = useState(false)
const [finished, setFinished] = useState(false)
const [page, setPage] = useState(1)
useEffect(() => {
loadList(1)
}, [])
const loadList = async (p: number) => {
if (loading) return
setLoading(true)
try {
const res = await listShopMessage({
page: p,
limit: 10,
})
if (res?.list) {
if (p === 1) {
setList(res.list)
} else {
setList(prev => [...prev, ...res.list])
}
setFinished(res.list.length < 10)
setPage(p)
}
} catch (err) {
console.error('加载通知失败', err)
Taro.showToast({ title: '加载失败', icon: 'none' })
} finally {
setLoading(false)
}
}
const handleLoadMore = () => {
if (!finished && !loading) {
loadList(page + 1)
}
}
return (
<View className='min-h-screen bg-gray-50 flex flex-col'>
<ScrollView scrollY className='flex-1' onScrollToLower={handleLoadMore}>
{list.length === 0 ? (
<EmptyState text='暂无消息' />
) : (
<View className='p-3'>
{list.map(item => (
<View
key={item.id}
className='bg-white rounded-lg p-3 mb-2'
style={{ opacity: item.isRead ? 0.7 : 1 }}
>
<View className='flex justify-between items-center mb-1'>
<View className='flex items-center gap-2'>
{!item.isRead && <View className='w-1 h-1 rounded-full bg-red-500' />}
<Text className='text-sm font-medium text-gray-800'>{item.title}</Text>
</View>
<Text className='text-xs text-gray-400'>{item.createdAt}</Text>
</View>
<Text className='text-xs text-gray-500'>{item.content}</Text>
</View>
))}
</View>
)}
<LoadMore loading={loading} finished={finished} />
</ScrollView>
</View>
)
}
export default NotificationPage

View File

@@ -0,0 +1,115 @@
import React from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useState, useEffect } from 'react'
import { Input } from '@nutui/nutui-react-taro'
import ProductCard from '@/components/common/ProductCard'
import EmptyState from '@/components/common/EmptyState'
import type { ShopGoods, ShopGoodsParam } from '@/api/shop/shopGoods/model'
import { pageShopGoods } from '@/api/shop/shopGoods'
import { useScrollHeight } from '@/hooks/useScrollHeight'
definePageConfig({
navigationBarTitleText: '搜索',
})
const SearchPage: React.FC = () => {
const [keyword, setKeyword] = useState('')
const [list, setList] = useState<ShopGoods[]>([])
const [loading, setLoading] = useState(false)
const scrollHeight = useScrollHeight(44)
const [history, setHistory] = useState<string[]>([])
useEffect(() => {
const saved = Taro.getStorageSync('search_history')
if (saved) setHistory(JSON.parse(saved))
}, [])
const doSearch = async (kw?: string) => {
const q = kw || keyword
if (!q.trim()) return
setLoading(true)
try {
const params: ShopGoodsParam = { keywords: q, status: 0, page: 1, limit: 20 }
const res = await pageShopGoods(params)
setList(res?.list || [])
// 保存搜索历史
const newHistory = [q, ...history.filter(h => h !== q)].slice(0, 10)
setHistory(newHistory)
Taro.setStorageSync('search_history', JSON.stringify(newHistory))
} catch { /* ignore */ }
setLoading(false)
}
const clearHistory = () => {
setHistory([])
Taro.removeStorageSync('search_history')
}
return (
<View className='min-h-screen bg-white'>
{/* 搜索栏 */}
<View className='flex items-center gap-2 px-3 py-2'>
<Input
className='flex-1 bg-gray-100 rounded-full px-3 py-1 text-sm'
placeholder='搜索商品'
value={keyword}
onChange={val => setKeyword(val)}
onConfirm={() => doSearch()}
/>
<Text className='text-sm text-green-600' onClick={() => doSearch()}></Text>
</View>
<ScrollView scrollY style={{ height: scrollHeight }}>
{list.length > 0 ? (
<View className='grid grid-cols-2 gap-2 px-3 py-2'>
{list.map(item => (
<ProductCard key={item.goodsId} product={item} />
))}
</View>
) : !loading && keyword === '' ? (
<View className='px-3 pt-4'>
{history.length > 0 && (
<View className='mb-4'>
<View className='flex justify-between items-center mb-2'>
<Text className='text-sm font-medium text-gray-700'></Text>
<Text className='text-xs text-gray-400' onClick={clearHistory}></Text>
</View>
<View className='flex flex-wrap gap-2'>
{history.map((h, i) => (
<View
key={i}
className='px-3 py-1 bg-gray-100 rounded-full'
onClick={() => { setKeyword(h); doSearch(h) }}
>
<Text className='text-xs text-gray-600'>{h}</Text>
</View>
))}
</View>
</View>
)}
<View className='mb-2'>
<Text className='text-sm font-medium text-gray-700 mb-2 block'></Text>
<View className='flex flex-wrap gap-2'>
{['羽毛球拍', '球鞋', '运动服', '手胶'].map((h) => (
<View
key={h}
className='px-3 py-1 bg-gray-100 rounded-full'
onClick={() => { setKeyword(h); doSearch(h) }}
>
<Text className='text-xs text-gray-600'>{h}</Text>
</View>
))}
</View>
</View>
</View>
) : (
<EmptyState text='未找到相关商品' />
)}
</ScrollView>
</View>
)
}
export default SearchPage