feat(address): 修复默认地址唯一性并新增用户浏览记录功能
- 后端新增 clearDefault 方法,保存默认地址前清除其他默认状态,避免多默认地址 - 前端地址编辑页保存成功后若为默认,调用 setDefaultAddress 触发后端清理逻辑 - useAddress hook 加入 ensureSingleDefault,前端保证地址数据中只有一个默认地址 - 新增 shop_goods_browse 表及相关实体、Mapper、服务和控制器,实现用户商品浏览记录功能 - 商品详情页接入浏览记录上报,支持登录用户浏览记录服务端同步 - 浏览历史页支持登录用户服务端分页查询,未登录用户使用本地存储兜底 - 浏览历史页支持浏览记录单条删除、清空、上拉加载和下拉刷新功能 - 调整用户中心入口,恢复浏览历史菜单项显示 - 文档和内存文件更新,包括数据库建表 SQL、接口定义和项目编译说明
This commit is contained in:
@@ -78,3 +78,41 @@
|
|||||||
- province/city/region: 广西壮族自治区/南宁市/西乡塘区 ✓
|
- province/city/region: 广西壮族自治区/南宁市/西乡塘区 ✓
|
||||||
- address: 西乡塘街道大学东路9号瀚林御景3栋1单元1101号房 ✓(街道+详细地址完整保留)
|
- address: 西乡塘街道大学东路9号瀚林御景3栋1单元1101号房 ✓(街道+详细地址完整保留)
|
||||||
- 项目 type-check (`npx tsc --noEmit`) 无报错
|
- 项目 type-check (`npx tsc --noEmit`) 无报错
|
||||||
|
|
||||||
|
## 修复默认地址唯一性 bug
|
||||||
|
- 问题:新增/编辑地址时勾选"设为默认"后,后端直接保存 `isDefault=true`,不清除其他地址的默认状态,导致出现多个默认地址
|
||||||
|
- 后端修复(`/Users/gxwebsoft/JAVA/guilixu-java`,3 个文件):
|
||||||
|
1. `ShopUserAddressService.java` — 新增 `clearDefault(userId, excludeId)` 接口方法
|
||||||
|
2. `ShopUserAddressServiceImpl.java` — 实现 `clearDefault`:查询该用户所有 `isDefault=true` 的地址(可排除指定 id),批量设为 false
|
||||||
|
3. `ShopUserAddressController.java` — `save()` 新增时若 `isDefault=true` 先调 `clearDefault(userId, null)`;`update()` 编辑时若 `isDefault=true` 先调 `clearDefault(userId, id)` 排除当前地址
|
||||||
|
- 前端修复(`src/pages/user/address-edit.tsx`):
|
||||||
|
- 保存成功后若 `isDefault=true`,再调一次 `setDefaultAddress(savedId)` 触发后端清理逻辑(双重保险)
|
||||||
|
- 新增时从返回结果中提取 savedId
|
||||||
|
- 修复 `getShopUserAddress` 返回值类型断言(`as ShopUserAddress`)
|
||||||
|
- 前端渲染兜底(`src/hooks/useAddress.ts`):
|
||||||
|
- `loadAddresses` 中加 `ensureSingleDefault` 工具函数:若后端返回多个 `isDefault=true`,只保留 createTime 最早的为默认,其余置 false
|
||||||
|
- 后端 Maven 编译命令:`"/Applications/IntelliJ IDEA Ultimate.app/Contents/plugins/maven/lib/maven3/bin/mvn" compile`(guilixu 的 mvnw 损坏,用 IntelliJ 自带 Maven)
|
||||||
|
- 编译结果:成功无报错
|
||||||
|
- 注意:之前误改了 paopao-java 和 websopy-java 两个项目(已废弃),正确后端是 guilixu-java
|
||||||
|
|
||||||
|
## 用户商品浏览记录功能(全栈实现)
|
||||||
|
- 需求:后台记录用户浏览商品的痕迹
|
||||||
|
- 方案:去重累加模式(同一用户+商品只留一条,visit_count 累加,5分钟内去重防刷)
|
||||||
|
- 后端(`/Users/gxwebsoft/JAVA/guilixu-java`,7 个文件 + 1 SQL):
|
||||||
|
1. `sql/shop_goods_browse.sql` — 建表 SQL(含3个索引:uk_user_goods 唯一 / idx_user_time / idx_goods_time)
|
||||||
|
2. `ShopGoodsBrowse.java` — Entity(参照 ShopGoodsFavorite 范式,含 visitCount/lastVisitTime/browseSource + 关联商品快照字段)
|
||||||
|
3. `ShopGoodsBrowseParam.java` — 查询参数(merchantId 用 Long 与 BaseParam 一致,含 startTime/endTime 时间范围)
|
||||||
|
4. `ShopGoodsBrowseMapper.java` + XML — Mapper(关联 shop_goods 表查询商品名称/图片/价格)
|
||||||
|
5. `ShopGoodsBrowseService.java` + Impl — Service(核心 recordBrowse 去重累加逻辑:查已有→5分钟内忽略/超过则累加→首次插入)
|
||||||
|
6. `ShopGoodsBrowseController.java` — Controller(POST 上报 / GET page 我的足迹 / DELETE 删单条 / DELETE 清空 / GET admin/page 后台查询)
|
||||||
|
- 前端(`xinlong-shop-taro`,4 个文件):
|
||||||
|
1. `src/api/shop/shopGoodsBrowse/model/index.ts` — ShopGoodsBrowse + ShopGoodsBrowseParam 类型
|
||||||
|
2. `src/api/shop/shopGoodsBrowse/index.ts` — reportBrowse(静默)/pageBrowseHistory/deleteBrowseRecord/clearBrowseHistory
|
||||||
|
3. `src/pages/shop/product-detail.tsx` — addToHistory() 已登录时异步调 reportBrowse 上报
|
||||||
|
4. `src/pages/user/history-list/index.tsx` — 改为服务端分页(登录)/ 本地兜底(未登录),支持删除单条、清空、上拉加载
|
||||||
|
5. `src/pages/user/user.tsx` — 恢复浏览历史入口(取消注释)
|
||||||
|
- 编译验证:后端 Maven compile 成功;前端 tsc --noEmit 无新增类型错误
|
||||||
|
- 编译踩坑:BaseController.success() 有 success(IPage<T>) 重载,泛型方法返回类型用 ApiResult<?> 避免 fail() 分支类型不兼容
|
||||||
|
- 待执行:数据库需手动执行 `shop_goods_browse.sql` 建表
|
||||||
|
- 后续可选:后台管理页面(`/Users/gxwebsoft/VUE/guilixu-admin`,Vue3+AntD Vue)
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
- 项目名:xinlong-shop-taro(Taro 微信小程序商城)
|
- 项目名:xinlong-shop-taro(Taro 微信小程序商城)
|
||||||
- 技术栈:Taro + React + TypeScript + Tailwind CSS
|
- 技术栈:Taro + React + TypeScript + Tailwind CSS
|
||||||
- 后端 API 前缀:`/shop/`、`/api/`
|
- 后端 API 前缀:`/shop/`、`/api/`
|
||||||
|
- **后端项目路径:`/Users/gxwebsoft/JAVA/guilixu-java`**(⚠️ 不要找 paopao-java 或 websopy-java,那是旧的)
|
||||||
|
- **后端 Maven 编译命令**:`cd /Users/gxwebsoft/JAVA/guilixu-java && "/Applications/IntelliJ IDEA Ultimate.app/Contents/plugins/maven/lib/maven3/bin/mvn" compile`(guilixu 的 mvnw 损坏,用 IntelliJ 自带的 mvn)
|
||||||
- 门店店员身份判断:通过 `getMyClerk()` API (`/shop/shop-store-user/my`) 返回值判断
|
- 门店店员身份判断:通过 `getMyClerk()` API (`/shop/shop-store-user/my`) 返回值判断
|
||||||
- VIP/分销商申请记录表:`ShopDealerApply`,状态码 10待审核/20通过/30驳回
|
- VIP/分销商申请记录表:`ShopDealerApply`,状态码 10待审核/20通过/30驳回
|
||||||
|
|
||||||
@@ -66,3 +68,16 @@
|
|||||||
- `merchantRemarks` — 商户备注:门店修改金额时填写的原因记录
|
- `merchantRemarks` — 商户备注:门店修改金额时填写的原因记录
|
||||||
- `comments` — 系统/其他备注:保留给系统内部使用
|
- `comments` — 系统/其他备注:保留给系统内部使用
|
||||||
- `ShopOrder` 和 `OrderCreateRequest` 接口均已包含 `buyerRemarks` 和 `merchantRemarks` 字段
|
- `ShopOrder` 和 `OrderCreateRequest` 接口均已包含 `buyerRemarks` 和 `merchantRemarks` 字段
|
||||||
|
|
||||||
|
## 用户浏览记录功能(2026-07-15)
|
||||||
|
- 存储模式:**去重累加**(同一用户+商品只留一条,visit_count 累加,5分钟内去重)
|
||||||
|
- 数据库表:`shop_goods_browse`(字段:id/user_id/goods_id/tenant_id/merchant_id/visit_count/last_visit_time/browse_source/create_time/update_time)
|
||||||
|
- 唯一索引:`uk_user_goods (user_id, goods_id)`;查询索引:`idx_user_time`、`idx_goods_time`
|
||||||
|
- 后端 API 路径:`/api/shop/shop-goods-browse`(POST 上报 / GET page 我的足迹 / DELETE {id} 删单条 / DELETE 清空 / GET admin/page 后台查询)
|
||||||
|
- 前端 API:`src/api/shop/shopGoodsBrowse/index.ts`(reportBrowse 静默上报 / pageBrowseHistory / deleteBrowseRecord / clearBrowseHistory)
|
||||||
|
- 商品详情页 `product-detail.tsx` 的 `addToHistory()` 已接入:已登录时异步调 reportBrowse,未登录仅本地 browse_history
|
||||||
|
- 浏览历史页 `src/pages/user/history-list/index.tsx` 已改为服务端分页(登录),未登录回退本地存储
|
||||||
|
- 后端文件:`ShopGoodsBrowse.java`(entity) / `ShopGoodsBrowseParam.java` / `ShopGoodsBrowseMapper.java`+XML / `ShopGoodsBrowseService.java`+Impl / `ShopGoodsBrowseController.java`
|
||||||
|
- 建表 SQL:`/Users/gxwebsoft/JAVA/guilixu-java/sql/shop_goods_browse.sql`
|
||||||
|
- 后台管理前端:`/Users/gxwebsoft/VUE/guilixu-admin`(Vue3 + Ant Design Vue + Vite,非 RuoYi)
|
||||||
|
- 注意:`BaseController.success()` 有 `success(IPage<T>)` 重载,泛型方法返回类型用 `ApiResult<?>` 而非 `ApiResult<PageResult<T>>`(否则 fail() 分支类型不兼容)
|
||||||
|
|||||||
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>
|
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 {
|
export function useAddress(): UseAddressReturn {
|
||||||
const [addresses, setAddresses] = useState<ShopUserAddress[]>([])
|
const [addresses, setAddresses] = useState<ShopUserAddress[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@@ -29,8 +55,8 @@ export function useAddress(): UseAddressReturn {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const list = await listShopUserAddress()
|
const list = await listShopUserAddress()
|
||||||
const actualList = Array.isArray(list) ? list : (list as any)?.data || []
|
const raw: ShopUserAddress[] = Array.isArray(list) ? list : (list as any)?.data || []
|
||||||
setAddresses(actualList)
|
setAddresses(ensureSingleDefault(raw))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Load addresses error:', error)
|
console.error('Load addresses error:', error)
|
||||||
setAddresses([])
|
setAddresses([])
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { View, Text, Image, ScrollView, Swiper, SwiperItem, RichText, Button } f
|
|||||||
import { Tag } from '@nutui/nutui-react-taro'
|
import { Tag } from '@nutui/nutui-react-taro'
|
||||||
import Taro, { useRouter, useDidShow } from '@tarojs/taro'
|
import Taro, { useRouter, useDidShow } from '@tarojs/taro'
|
||||||
import { getShopGoods } from '@/api/shop/shopGoods'
|
import { getShopGoods } from '@/api/shop/shopGoods'
|
||||||
|
import { reportBrowse } from '@/api/shop/shopGoodsBrowse'
|
||||||
import {
|
import {
|
||||||
addShopGoodsFavorite,
|
addShopGoodsFavorite,
|
||||||
removeShopGoodsFavorite,
|
removeShopGoodsFavorite,
|
||||||
@@ -146,6 +147,11 @@ const ProductDetailPage: React.FC = () => {
|
|||||||
Taro.setStorageSync('browse_history', reduced)
|
Taro.setStorageSync('browse_history', reduced)
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
// 已登录用户:异步上报服务端浏览记录(静默失败,不影响用户体验)
|
||||||
|
if (isLoggedIn) {
|
||||||
|
reportBrowse({ goodsId: id, browseSource: 'detail' })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddCart = () => {
|
const handleAddCart = () => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, {useState, useEffect, useRef} from 'react'
|
import React, {useState, useEffect, useRef} from 'react'
|
||||||
import {View, Text, Input, Textarea, ScrollView, Picker} from '@tarojs/components'
|
import {View, Text, Input, Textarea, ScrollView, Picker} from '@tarojs/components'
|
||||||
import Taro, {useRouter} from '@tarojs/taro'
|
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 type {ShopUserAddress} from '@/api/shop/shopUserAddress/model'
|
||||||
import {parseLngLatFromText} from '@/utils/geofence'
|
import {parseLngLatFromText} from '@/utils/geofence'
|
||||||
import RegionData from '@/api/json/regions-data.json'
|
import RegionData from '@/api/json/regions-data.json'
|
||||||
@@ -408,10 +408,23 @@ const AddressEditPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 执行新增或更新
|
// 执行新增或更新
|
||||||
|
let savedId: number | undefined = addressId
|
||||||
if (isEditMode) {
|
if (isEditMode) {
|
||||||
await updateShopUserAddress(submitData)
|
await updateShopUserAddress(submitData)
|
||||||
} else {
|
} 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'})
|
Taro.showToast({title: `${isEditMode ? '更新' : '保存'}成功`, icon: 'success'})
|
||||||
@@ -456,11 +469,14 @@ const AddressEditPage: React.FC = () => {
|
|||||||
// 编辑模式:加载已有地址数据
|
// 编辑模式:加载已有地址数据
|
||||||
if (isEditMode && addressId) {
|
if (isEditMode && addressId) {
|
||||||
try {
|
try {
|
||||||
const addr = await getShopUserAddress(addressId)
|
const addr = (await getShopUserAddress(addressId)) as ShopUserAddress
|
||||||
setFormData(addr)
|
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)) {
|
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)
|
setRegionLocked(true)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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 { View, Text, Image, ScrollView } from '@tarojs/components'
|
||||||
import Taro from '@tarojs/taro'
|
import Taro from '@tarojs/taro'
|
||||||
import EmptyState from '@/components/common/EmptyState'
|
import EmptyState from '@/components/common/EmptyState'
|
||||||
import LoadMore from '@/components/common/LoadMore'
|
import LoadMore from '@/components/common/LoadMore'
|
||||||
import { getCompressedImageUrl } from '@/utils/image'
|
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({
|
definePageConfig({
|
||||||
navigationBarTitleText: '浏览历史',
|
navigationBarTitleText: '浏览历史',
|
||||||
|
enablePullDownRefresh: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
interface HistoryItem {
|
/** 统一展示类型(兼容服务端 + 本地兜底) */
|
||||||
|
interface DisplayItem {
|
||||||
|
id?: number
|
||||||
goodsId: number
|
goodsId: number
|
||||||
name?: string
|
name?: string
|
||||||
image?: string
|
image?: string
|
||||||
price?: string
|
price?: string
|
||||||
timestamp: number
|
visitCount?: number
|
||||||
|
lastVisitTime?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
const BrowseHistoryPage: React.FC = () => {
|
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(() => {
|
useEffect(() => {
|
||||||
loadHistory()
|
loadHistory(1)
|
||||||
}, [])
|
}, [isLoggedIn])
|
||||||
|
|
||||||
const loadHistory = () => {
|
// 下拉刷新
|
||||||
try {
|
const onPullDownRefresh = async () => {
|
||||||
const history = Taro.getStorageSync('browse_history') || []
|
await loadHistory(1)
|
||||||
setList(history)
|
Taro.stopPullDownRefresh()
|
||||||
} catch {
|
}
|
||||||
setList([])
|
|
||||||
|
// 上拉加载更多
|
||||||
|
const onScrollToLower = () => {
|
||||||
|
if (hasMore && !loading && isLoggedIn) {
|
||||||
|
loadHistory(page + 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,30 +98,73 @@ const BrowseHistoryPage: React.FC = () => {
|
|||||||
Taro.navigateTo({ url: `/pages/shop/product-detail?id=${goodsId}` })
|
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 = () => {
|
const handleClearHistory = () => {
|
||||||
Taro.showModal({
|
Taro.showModal({
|
||||||
title: '提示',
|
title: '提示',
|
||||||
content: '确定要清空浏览历史吗?',
|
content: '确定要清空全部浏览历史吗?',
|
||||||
success: (res) => {
|
success: async (res) => {
|
||||||
if (res.confirm) {
|
if (res.confirm) {
|
||||||
|
if (isLoggedIn) {
|
||||||
|
try {
|
||||||
|
await clearBrowseHistory()
|
||||||
|
} catch {
|
||||||
|
Taro.showToast({ title: '清空失败', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
Taro.removeStorageSync('browse_history')
|
Taro.removeStorageSync('browse_history')
|
||||||
setList([])
|
setList([])
|
||||||
|
setHasMore(false)
|
||||||
Taro.showToast({ title: '已清空', icon: 'success' })
|
Taro.showToast({ title: '已清空', icon: 'success' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatTime = (timestamp: number) => {
|
const formatTime = (timeStr?: string) => {
|
||||||
const date = new Date(timestamp)
|
if (!timeStr) return ''
|
||||||
return `${date.getMonth() + 1}/${date.getDate()} ${date.getHours()}:${String(date.getMinutes()).padStart(2, '0')}`
|
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 (
|
return (
|
||||||
<View className='min-h-screen bg-gray-50'>
|
<View className='min-h-screen bg-gray-50'>
|
||||||
{/* 顶部操作栏 */}
|
{/* 顶部操作栏 */}
|
||||||
{list.length > 0 && (
|
{list.length > 0 && (
|
||||||
<View className='bg-white px-4 py-2 flex justify-end border-b border-gray-100'>
|
<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
|
<Text
|
||||||
className='text-sm text-red-500'
|
className='text-sm text-red-500'
|
||||||
onClick={handleClearHistory}
|
onClick={handleClearHistory}
|
||||||
@@ -70,14 +174,19 @@ const BrowseHistoryPage: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ScrollView scrollY className='h-screen'>
|
<ScrollView
|
||||||
|
scrollY
|
||||||
|
className='h-screen'
|
||||||
|
onScrollToLower={onScrollToLower}
|
||||||
|
lowerThreshold={80}
|
||||||
|
>
|
||||||
<View className='p-3'>
|
<View className='p-3'>
|
||||||
{list.length > 0 ? (
|
{list.length > 0 ? (
|
||||||
<View className='grid grid-cols-2 gap-3'>
|
<View className='grid grid-cols-2 gap-3'>
|
||||||
{list.map(item => (
|
{list.map(item => (
|
||||||
<View
|
<View
|
||||||
key={`${item.goodsId}-${item.timestamp}`}
|
key={`${item.goodsId}-${item.id || item.lastVisitTime}`}
|
||||||
className='bg-white rounded-lg overflow-hidden'
|
className='bg-white rounded-lg overflow-hidden relative'
|
||||||
onClick={() => handleItemClick(item.goodsId)}
|
onClick={() => handleItemClick(item.goodsId)}
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
@@ -95,9 +204,22 @@ const BrowseHistoryPage: React.FC = () => {
|
|||||||
¥{item.price || '0'}
|
¥{item.price || '0'}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className='text-xs text-gray-400'>
|
<Text className='text-xs text-gray-400'>
|
||||||
{formatTime(item.timestamp)}
|
{formatTime(item.lastVisitTime)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</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>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
@@ -105,6 +227,9 @@ const BrowseHistoryPage: React.FC = () => {
|
|||||||
) : (
|
) : (
|
||||||
<EmptyState text='暂无浏览历史' />
|
<EmptyState text='暂无浏览历史' />
|
||||||
)}
|
)}
|
||||||
|
{isLoggedIn && list.length > 0 && (
|
||||||
|
<LoadMore hasMore={hasMore} loading={loading} />
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ const UserPage: React.FC = () => {
|
|||||||
{ icon: '📍', label: '收货地址', url: '/pages/user/address-list', requireAuth: true },
|
{ icon: '📍', label: '收货地址', url: '/pages/user/address-list', requireAuth: true },
|
||||||
// { icon: '⭐', label: '积分明细', url: '/pages/user/points-record' },
|
// { icon: '⭐', label: '积分明细', url: '/pages/user/points-record' },
|
||||||
{ icon: '❤️', label: '我的收藏', url: '/pages/user/favorite-list/index', requireAuth: true },
|
{ 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/user/help-center/index' },
|
||||||
// { icon: '🔔', label: '消息通知', url: '/pages/index/notification' },
|
// { icon: '🔔', label: '消息通知', url: '/pages/index/notification' },
|
||||||
// 门店管理:仅门店经理/店员显示
|
// 门店管理:仅门店经理/店员显示
|
||||||
|
|||||||
Reference in New Issue
Block a user