feat(address): 修复默认地址唯一性并新增用户浏览记录功能

- 后端新增 clearDefault 方法,保存默认地址前清除其他默认状态,避免多默认地址
- 前端地址编辑页保存成功后若为默认,调用 setDefaultAddress 触发后端清理逻辑
- useAddress hook 加入 ensureSingleDefault,前端保证地址数据中只有一个默认地址
- 新增 shop_goods_browse 表及相关实体、Mapper、服务和控制器,实现用户商品浏览记录功能
- 商品详情页接入浏览记录上报,支持登录用户浏览记录服务端同步
- 浏览历史页支持登录用户服务端分页查询,未登录用户使用本地存储兜底
- 浏览历史页支持浏览记录单条删除、清空、上拉加载和下拉刷新功能
- 调整用户中心入口,恢复浏览历史菜单项显示
- 文档和内存文件更新,包括数据库建表 SQL、接口定义和项目编译说明
This commit is contained in:
2026-07-15 12:52:25 +08:00
parent 3955e64844
commit 3f1931e420
9 changed files with 349 additions and 32 deletions

View 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));
}

View 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;
}

View File

@@ -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([])

View File

@@ -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 = () => {

View File

@@ -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) {

View File

@@ -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>

View File

@@ -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' },
// 门店管理:仅门店经理/店员显示