feat(address): 修复默认地址唯一性并新增用户浏览记录功能
- 后端新增 clearDefault 方法,保存默认地址前清除其他默认状态,避免多默认地址 - 前端地址编辑页保存成功后若为默认,调用 setDefaultAddress 触发后端清理逻辑 - useAddress hook 加入 ensureSingleDefault,前端保证地址数据中只有一个默认地址 - 新增 shop_goods_browse 表及相关实体、Mapper、服务和控制器,实现用户商品浏览记录功能 - 商品详情页接入浏览记录上报,支持登录用户浏览记录服务端同步 - 浏览历史页支持登录用户服务端分页查询,未登录用户使用本地存储兜底 - 浏览历史页支持浏览记录单条删除、清空、上拉加载和下拉刷新功能 - 调整用户中心入口,恢复浏览历史菜单项显示 - 文档和内存文件更新,包括数据库建表 SQL、接口定义和项目编译说明
This commit is contained in:
52
src/api/shop/shopGoodsBrowse/index.ts
Normal file
52
src/api/shop/shopGoodsBrowse/index.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import request from '@/utils/request';
|
||||
import type { ApiResult, PageResult } from '@/api';
|
||||
import type { ShopGoodsBrowse, ShopGoodsBrowseParam } from './model';
|
||||
|
||||
/**
|
||||
* 上报浏览记录(异步静默,不抛错影响主流程)
|
||||
*/
|
||||
export async function reportBrowse(data: Partial<ShopGoodsBrowse>): Promise<void> {
|
||||
try {
|
||||
await request.post<ApiResult<unknown>>('/shop/shop-goods-browse', data);
|
||||
} catch {
|
||||
/* 静默失败:浏览记录上报不影响用户体验 */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询我的浏览记录
|
||||
*/
|
||||
export async function pageBrowseHistory(params: ShopGoodsBrowseParam) {
|
||||
const res = await request.get<ApiResult<PageResult<ShopGoodsBrowse>>>(
|
||||
'/shop/shop-goods-browse/page',
|
||||
params
|
||||
);
|
||||
if (res.code === 0) {
|
||||
return res.data;
|
||||
}
|
||||
return Promise.reject(new Error(res.message));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除单条浏览记录
|
||||
*/
|
||||
export async function deleteBrowseRecord(id: number) {
|
||||
const res = await request.del<ApiResult<unknown>>(
|
||||
`/shop/shop-goods-browse/${id}`
|
||||
);
|
||||
if (res.code === 0) {
|
||||
return res.message;
|
||||
}
|
||||
return Promise.reject(new Error(res.message));
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空我的浏览记录
|
||||
*/
|
||||
export async function clearBrowseHistory() {
|
||||
const res = await request.del<ApiResult<unknown>>('/shop/shop-goods-browse');
|
||||
if (res.code === 0) {
|
||||
return res.message;
|
||||
}
|
||||
return Promise.reject(new Error(res.message));
|
||||
}
|
||||
39
src/api/shop/shopGoodsBrowse/model/index.ts
Normal file
39
src/api/shop/shopGoodsBrowse/model/index.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { PageParam } from '@/api/index';
|
||||
|
||||
/**
|
||||
* 用户商品浏览记录
|
||||
*/
|
||||
export interface ShopGoodsBrowse {
|
||||
id?: number;
|
||||
userId?: number;
|
||||
goodsId?: number;
|
||||
tenantId?: number;
|
||||
merchantId?: number;
|
||||
/** 浏览次数 */
|
||||
visitCount?: number;
|
||||
/** 最后浏览时间 */
|
||||
lastVisitTime?: string;
|
||||
/** 浏览来源:home/category/search/share/detail */
|
||||
browseSource?: string;
|
||||
createTime?: string;
|
||||
updateTime?: string;
|
||||
// ========== 关联商品快照字段 ==========
|
||||
goodsName?: string;
|
||||
goodsImage?: string;
|
||||
price?: string;
|
||||
salePrice?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 浏览记录查询参数
|
||||
*/
|
||||
export interface ShopGoodsBrowseParam extends PageParam {
|
||||
id?: number;
|
||||
userId?: number;
|
||||
goodsId?: number;
|
||||
merchantId?: number;
|
||||
tenantId?: number;
|
||||
browseSource?: string;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
}
|
||||
@@ -21,6 +21,32 @@ interface UseAddressReturn {
|
||||
setDefault: (id: number) => Promise<boolean>
|
||||
}
|
||||
|
||||
/**
|
||||
* 兜底:保证列表中最多只有一个地址是 isDefault=true。
|
||||
* 兼容历史脏数据(之前没有"取消其他默认"逻辑时遗留下来的多个默认)。
|
||||
* 保留最早创建(id 最小)的为默认;其余的在前端展示时按非默认处理。
|
||||
*/
|
||||
function ensureSingleDefault(list: ShopUserAddress[]): ShopUserAddress[] {
|
||||
const defaults = list.filter(a => a.isDefault)
|
||||
if (defaults.length <= 1) return list
|
||||
// 多个默认:保留 createTime 最早的;如果都缺 createTime,则保留 id 最小的
|
||||
const sorted = [...defaults].sort((a, b) => {
|
||||
const at = a.createTime || ''
|
||||
const bt = b.createTime || ''
|
||||
if (at && bt) return at < bt ? -1 : at > bt ? 1 : 0
|
||||
if (at) return -1
|
||||
if (bt) return 1
|
||||
return (a.id || 0) - (b.id || 0)
|
||||
})
|
||||
const keepId = sorted[0].id
|
||||
return list.map(a => {
|
||||
if (a.isDefault && a.id !== keepId) {
|
||||
return {...a, isDefault: false}
|
||||
}
|
||||
return a
|
||||
})
|
||||
}
|
||||
|
||||
export function useAddress(): UseAddressReturn {
|
||||
const [addresses, setAddresses] = useState<ShopUserAddress[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -29,8 +55,8 @@ export function useAddress(): UseAddressReturn {
|
||||
setLoading(true)
|
||||
try {
|
||||
const list = await listShopUserAddress()
|
||||
const actualList = Array.isArray(list) ? list : (list as any)?.data || []
|
||||
setAddresses(actualList)
|
||||
const raw: ShopUserAddress[] = Array.isArray(list) ? list : (list as any)?.data || []
|
||||
setAddresses(ensureSingleDefault(raw))
|
||||
} catch (error) {
|
||||
console.error('Load addresses error:', error)
|
||||
setAddresses([])
|
||||
|
||||
@@ -3,6 +3,7 @@ import { View, Text, Image, ScrollView, Swiper, SwiperItem, RichText, Button } f
|
||||
import { Tag } from '@nutui/nutui-react-taro'
|
||||
import Taro, { useRouter, useDidShow } from '@tarojs/taro'
|
||||
import { getShopGoods } from '@/api/shop/shopGoods'
|
||||
import { reportBrowse } from '@/api/shop/shopGoodsBrowse'
|
||||
import {
|
||||
addShopGoodsFavorite,
|
||||
removeShopGoodsFavorite,
|
||||
@@ -146,6 +147,11 @@ const ProductDetailPage: React.FC = () => {
|
||||
Taro.setStorageSync('browse_history', reduced)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// 已登录用户:异步上报服务端浏览记录(静默失败,不影响用户体验)
|
||||
if (isLoggedIn) {
|
||||
reportBrowse({ goodsId: id, browseSource: 'detail' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddCart = () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, {useState, useEffect, useRef} from 'react'
|
||||
import {View, Text, Input, Textarea, ScrollView, Picker} from '@tarojs/components'
|
||||
import Taro, {useRouter} from '@tarojs/taro'
|
||||
import {getShopUserAddress, addShopUserAddress, updateShopUserAddress} from '@/api/shop/shopUserAddress'
|
||||
import {getShopUserAddress, addShopUserAddress, updateShopUserAddress, setDefaultAddress} from '@/api/shop/shopUserAddress'
|
||||
import type {ShopUserAddress} from '@/api/shop/shopUserAddress/model'
|
||||
import {parseLngLatFromText} from '@/utils/geofence'
|
||||
import RegionData from '@/api/json/regions-data.json'
|
||||
@@ -408,10 +408,23 @@ const AddressEditPage: React.FC = () => {
|
||||
}
|
||||
|
||||
// 执行新增或更新
|
||||
let savedId: number | undefined = addressId
|
||||
if (isEditMode) {
|
||||
await updateShopUserAddress(submitData)
|
||||
} else {
|
||||
await addShopUserAddress(submitData)
|
||||
const res: any = await addShopUserAddress(submitData)
|
||||
// 后端新增返回可能带有 id 字段
|
||||
savedId = (res && (res.data?.id || res.id)) || addressId
|
||||
}
|
||||
|
||||
// 兜底:勾选默认时主动调用 set-default,让后端走统一的"取消其他默认"逻辑
|
||||
// 这样即使 save/update 在某些老版本后端上漏处理,前端也能保证唯一默认
|
||||
if (submitData.isDefault && savedId) {
|
||||
try {
|
||||
await setDefaultAddress(savedId)
|
||||
} catch (e) {
|
||||
console.warn('setDefaultAddress fallback failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
Taro.showToast({title: `${isEditMode ? '更新' : '保存'}成功`, icon: 'success'})
|
||||
@@ -456,11 +469,14 @@ const AddressEditPage: React.FC = () => {
|
||||
// 编辑模式:加载已有地址数据
|
||||
if (isEditMode && addressId) {
|
||||
try {
|
||||
const addr = await getShopUserAddress(addressId)
|
||||
const addr = (await getShopUserAddress(addressId)) as ShopUserAddress
|
||||
setFormData(addr)
|
||||
setRegionText(`${addr.province || ''} ${addr.city || ''} ${addr.region || ''}`)
|
||||
const p = String((addr as any)?.province || '').trim()
|
||||
const c = String((addr as any)?.city || '').trim()
|
||||
const r = String((addr as any)?.region || '').trim()
|
||||
setRegionText([p, c, r].filter(Boolean).join(' '))
|
||||
if (hasValidLngLat(addr)) {
|
||||
setSelectedLocation({lng: String(addr.lng), lat: String(addr.lat)})
|
||||
setSelectedLocation({lng: String((addr as any).lng), lat: String((addr as any).lat)})
|
||||
setRegionLocked(true)
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,35 +1,96 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { View, Text, Image, ScrollView } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import EmptyState from '@/components/common/EmptyState'
|
||||
import LoadMore from '@/components/common/LoadMore'
|
||||
import { getCompressedImageUrl } from '@/utils/image'
|
||||
import { useUserContext } from '@/contexts/UserContext'
|
||||
import {
|
||||
pageBrowseHistory,
|
||||
deleteBrowseRecord,
|
||||
clearBrowseHistory
|
||||
} from '@/api/shop/shopGoodsBrowse'
|
||||
import type { ShopGoodsBrowseParam } from '@/api/shop/shopGoodsBrowse/model'
|
||||
|
||||
definePageConfig({
|
||||
navigationBarTitleText: '浏览历史',
|
||||
enablePullDownRefresh: true,
|
||||
})
|
||||
|
||||
interface HistoryItem {
|
||||
/** 统一展示类型(兼容服务端 + 本地兜底) */
|
||||
interface DisplayItem {
|
||||
id?: number
|
||||
goodsId: number
|
||||
name?: string
|
||||
image?: string
|
||||
price?: string
|
||||
timestamp: number
|
||||
visitCount?: number
|
||||
lastVisitTime?: string
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
const BrowseHistoryPage: React.FC = () => {
|
||||
const [list, setList] = useState<HistoryItem[]>([])
|
||||
const { isLoggedIn } = useUserContext()
|
||||
const [list, setList] = useState<DisplayItem[]>([])
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const loadHistory = useCallback(async (pageNum: number) => {
|
||||
if (loading) return
|
||||
setLoading(true)
|
||||
try {
|
||||
if (isLoggedIn) {
|
||||
// 已登录:服务端分页查询
|
||||
const params: ShopGoodsBrowseParam = { page: pageNum, limit: PAGE_SIZE }
|
||||
const res = await pageBrowseHistory(params)
|
||||
const items: DisplayItem[] = (res.list || []).map(item => ({
|
||||
id: item.id,
|
||||
goodsId: item.goodsId!,
|
||||
name: item.goodsName,
|
||||
image: item.goodsImage,
|
||||
price: item.price,
|
||||
visitCount: item.visitCount,
|
||||
lastVisitTime: item.lastVisitTime,
|
||||
}))
|
||||
setList(prev => pageNum === 1 ? items : [...prev, ...items])
|
||||
setHasMore(list.length + items.length < res.count)
|
||||
setPage(pageNum)
|
||||
} else {
|
||||
// 未登录:本地存储兜底
|
||||
const history = Taro.getStorageSync('browse_history') || []
|
||||
const items: DisplayItem[] = history.map((item: any) => ({
|
||||
goodsId: item.goodsId,
|
||||
name: item.name,
|
||||
image: item.image,
|
||||
price: item.price,
|
||||
lastVisitTime: item.timestamp ? new Date(item.timestamp).toISOString() : undefined,
|
||||
}))
|
||||
setList(items)
|
||||
setHasMore(false)
|
||||
}
|
||||
} catch {
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [isLoggedIn, loading, list.length])
|
||||
|
||||
useEffect(() => {
|
||||
loadHistory()
|
||||
}, [])
|
||||
loadHistory(1)
|
||||
}, [isLoggedIn])
|
||||
|
||||
const loadHistory = () => {
|
||||
try {
|
||||
const history = Taro.getStorageSync('browse_history') || []
|
||||
setList(history)
|
||||
} catch {
|
||||
setList([])
|
||||
// 下拉刷新
|
||||
const onPullDownRefresh = async () => {
|
||||
await loadHistory(1)
|
||||
Taro.stopPullDownRefresh()
|
||||
}
|
||||
|
||||
// 上拉加载更多
|
||||
const onScrollToLower = () => {
|
||||
if (hasMore && !loading && isLoggedIn) {
|
||||
loadHistory(page + 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,31 +98,74 @@ const BrowseHistoryPage: React.FC = () => {
|
||||
Taro.navigateTo({ url: `/pages/shop/product-detail?id=${goodsId}` })
|
||||
}
|
||||
|
||||
const handleDelete = async (item: DisplayItem) => {
|
||||
Taro.showModal({
|
||||
title: '提示',
|
||||
content: '确定要删除这条浏览记录吗?',
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
if (isLoggedIn && item.id) {
|
||||
try {
|
||||
await deleteBrowseRecord(item.id)
|
||||
} catch {
|
||||
Taro.showToast({ title: '删除失败', icon: 'none' })
|
||||
return
|
||||
}
|
||||
}
|
||||
setList(prev => prev.filter(i => i.goodsId !== item.goodsId))
|
||||
Taro.showToast({ title: '已删除', icon: 'success' })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleClearHistory = () => {
|
||||
Taro.showModal({
|
||||
title: '提示',
|
||||
content: '确定要清空浏览历史吗?',
|
||||
success: (res) => {
|
||||
content: '确定要清空全部浏览历史吗?',
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
if (isLoggedIn) {
|
||||
try {
|
||||
await clearBrowseHistory()
|
||||
} catch {
|
||||
Taro.showToast({ title: '清空失败', icon: 'none' })
|
||||
return
|
||||
}
|
||||
}
|
||||
Taro.removeStorageSync('browse_history')
|
||||
setList([])
|
||||
setHasMore(false)
|
||||
Taro.showToast({ title: '已清空', icon: 'success' })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const formatTime = (timestamp: number) => {
|
||||
const date = new Date(timestamp)
|
||||
return `${date.getMonth() + 1}/${date.getDate()} ${date.getHours()}:${String(date.getMinutes()).padStart(2, '0')}`
|
||||
const formatTime = (timeStr?: string) => {
|
||||
if (!timeStr) return ''
|
||||
const date = new Date(timeStr)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
const hours = Math.floor(diff / 3600000)
|
||||
const days = Math.floor(diff / 86400000)
|
||||
if (minutes < 1) return '刚刚'
|
||||
if (minutes < 60) return `${minutes}分钟前`
|
||||
if (hours < 24) return `${hours}小时前`
|
||||
if (days < 7) return `${days}天前`
|
||||
return `${date.getMonth() + 1}/${date.getDate()}`
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='min-h-screen bg-gray-50'>
|
||||
{/* 顶部操作栏 */}
|
||||
{list.length > 0 && (
|
||||
<View className='bg-white px-4 py-2 flex justify-end border-b border-gray-100'>
|
||||
<Text
|
||||
<View className='bg-white px-4 py-2 flex justify-between items-center border-b border-gray-100'>
|
||||
<Text className='text-xs text-gray-400'>
|
||||
{isLoggedIn ? `共 ${list.length} 条记录` : '本地记录'}
|
||||
</Text>
|
||||
<Text
|
||||
className='text-sm text-red-500'
|
||||
onClick={handleClearHistory}
|
||||
>
|
||||
@@ -69,15 +173,20 @@ const BrowseHistoryPage: React.FC = () => {
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<ScrollView scrollY className='h-screen'>
|
||||
|
||||
<ScrollView
|
||||
scrollY
|
||||
className='h-screen'
|
||||
onScrollToLower={onScrollToLower}
|
||||
lowerThreshold={80}
|
||||
>
|
||||
<View className='p-3'>
|
||||
{list.length > 0 ? (
|
||||
<View className='grid grid-cols-2 gap-3'>
|
||||
{list.map(item => (
|
||||
<View
|
||||
key={`${item.goodsId}-${item.timestamp}`}
|
||||
className='bg-white rounded-lg overflow-hidden'
|
||||
key={`${item.goodsId}-${item.id || item.lastVisitTime}`}
|
||||
className='bg-white rounded-lg overflow-hidden relative'
|
||||
onClick={() => handleItemClick(item.goodsId)}
|
||||
>
|
||||
<Image
|
||||
@@ -95,9 +204,22 @@ const BrowseHistoryPage: React.FC = () => {
|
||||
¥{item.price || '0'}
|
||||
</Text>
|
||||
<Text className='text-xs text-gray-400'>
|
||||
{formatTime(item.timestamp)}
|
||||
{formatTime(item.lastVisitTime)}
|
||||
</Text>
|
||||
</View>
|
||||
{item.visitCount && item.visitCount > 1 && (
|
||||
<Text className='text-xs text-orange-400 mt-1 block'>
|
||||
浏览 {item.visitCount} 次
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
{/* 删除按钮 */}
|
||||
<View
|
||||
className='absolute top-1 right-1 w-6 h-6 rounded-full bg-black bg-opacity-40 flex items-center justify-center'
|
||||
catchMove
|
||||
onClick={(e) => { e.stopPropagation(); handleDelete(item) }}
|
||||
>
|
||||
<Text className='text-white text-xs'>×</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
@@ -105,6 +227,9 @@ const BrowseHistoryPage: React.FC = () => {
|
||||
) : (
|
||||
<EmptyState text='暂无浏览历史' />
|
||||
)}
|
||||
{isLoggedIn && list.length > 0 && (
|
||||
<LoadMore hasMore={hasMore} loading={loading} />
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
@@ -106,7 +106,7 @@ const UserPage: React.FC = () => {
|
||||
{ icon: '📍', label: '收货地址', url: '/pages/user/address-list', requireAuth: true },
|
||||
// { icon: '⭐', label: '积分明细', url: '/pages/user/points-record' },
|
||||
{ icon: '❤️', label: '我的收藏', url: '/pages/user/favorite-list/index', requireAuth: true },
|
||||
// { icon: '🕐', label: '浏览历史', url: '/pages/user/history-list/index' },
|
||||
{ icon: '🕐', label: '浏览历史', url: '/pages/user/history-list/index' },
|
||||
{ icon: '❓', label: '帮助中心', url: '/pages/user/help-center/index' },
|
||||
// { icon: '🔔', label: '消息通知', url: '/pages/index/notification' },
|
||||
// 门店管理:仅门店经理/店员显示
|
||||
|
||||
Reference in New Issue
Block a user