diff --git a/.workbuddy/memory/2026-07-15.md b/.workbuddy/memory/2026-07-15.md index 2e90cc7..c510058 100644 --- a/.workbuddy/memory/2026-07-15.md +++ b/.workbuddy/memory/2026-07-15.md @@ -78,3 +78,41 @@ - province/city/region: 广西壮族自治区/南宁市/西乡塘区 ✓ - address: 西乡塘街道大学东路9号瀚林御景3栋1单元1101号房 ✓(街道+详细地址完整保留) - 项目 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) 重载,泛型方法返回类型用 ApiResult 避免 fail() 分支类型不兼容 +- 待执行:数据库需手动执行 `shop_goods_browse.sql` 建表 +- 后续可选:后台管理页面(`/Users/gxwebsoft/VUE/guilixu-admin`,Vue3+AntD Vue) + diff --git a/.workbuddy/memory/MEMORY.md b/.workbuddy/memory/MEMORY.md index 4c999ce..c2ad52b 100644 --- a/.workbuddy/memory/MEMORY.md +++ b/.workbuddy/memory/MEMORY.md @@ -4,6 +4,8 @@ - 项目名:xinlong-shop-taro(Taro 微信小程序商城) - 技术栈:Taro + React + TypeScript + Tailwind CSS - 后端 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`) 返回值判断 - VIP/分销商申请记录表:`ShopDealerApply`,状态码 10待审核/20通过/30驳回 @@ -66,3 +68,16 @@ - `merchantRemarks` — 商户备注:门店修改金额时填写的原因记录 - `comments` — 系统/其他备注:保留给系统内部使用 - `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)` 重载,泛型方法返回类型用 `ApiResult` 而非 `ApiResult>`(否则 fail() 分支类型不兼容) diff --git a/src/api/shop/shopGoodsBrowse/index.ts b/src/api/shop/shopGoodsBrowse/index.ts new file mode 100644 index 0000000..e05ad03 --- /dev/null +++ b/src/api/shop/shopGoodsBrowse/index.ts @@ -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): Promise { + try { + await request.post>('/shop/shop-goods-browse', data); + } catch { + /* 静默失败:浏览记录上报不影响用户体验 */ + } +} + +/** + * 分页查询我的浏览记录 + */ +export async function pageBrowseHistory(params: ShopGoodsBrowseParam) { + const res = await request.get>>( + '/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>( + `/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>('/shop/shop-goods-browse'); + if (res.code === 0) { + return res.message; + } + return Promise.reject(new Error(res.message)); +} diff --git a/src/api/shop/shopGoodsBrowse/model/index.ts b/src/api/shop/shopGoodsBrowse/model/index.ts new file mode 100644 index 0000000..fda6ac6 --- /dev/null +++ b/src/api/shop/shopGoodsBrowse/model/index.ts @@ -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; +} diff --git a/src/hooks/useAddress.ts b/src/hooks/useAddress.ts index 93c6891..237141f 100644 --- a/src/hooks/useAddress.ts +++ b/src/hooks/useAddress.ts @@ -21,6 +21,32 @@ interface UseAddressReturn { setDefault: (id: number) => Promise } +/** + * 兜底:保证列表中最多只有一个地址是 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([]) 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([]) diff --git a/src/pages/shop/product-detail.tsx b/src/pages/shop/product-detail.tsx index a6e55bf..b97ca9f 100644 --- a/src/pages/shop/product-detail.tsx +++ b/src/pages/shop/product-detail.tsx @@ -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 = () => { diff --git a/src/pages/user/address-edit.tsx b/src/pages/user/address-edit.tsx index 9f247d2..496c272 100644 --- a/src/pages/user/address-edit.tsx +++ b/src/pages/user/address-edit.tsx @@ -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) { diff --git a/src/pages/user/history-list/index.tsx b/src/pages/user/history-list/index.tsx index fe12d52..ac494ba 100644 --- a/src/pages/user/history-list/index.tsx +++ b/src/pages/user/history-list/index.tsx @@ -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([]) + const { isLoggedIn } = useUserContext() + const [list, setList] = useState([]) + 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 ( {/* 顶部操作栏 */} {list.length > 0 && ( - - + + {isLoggedIn ? `共 ${list.length} 条记录` : '本地记录'} + + @@ -69,15 +173,20 @@ const BrowseHistoryPage: React.FC = () => { )} - - + + {list.length > 0 ? ( {list.map(item => ( handleItemClick(item.goodsId)} > { ¥{item.price || '0'} - {formatTime(item.timestamp)} + {formatTime(item.lastVisitTime)} + {item.visitCount && item.visitCount > 1 && ( + + 浏览 {item.visitCount} 次 + + )} + + {/* 删除按钮 */} + { e.stopPropagation(); handleDelete(item) }} + > + × ))} @@ -105,6 +227,9 @@ const BrowseHistoryPage: React.FC = () => { ) : ( )} + {isLoggedIn && list.length > 0 && ( + + )} diff --git a/src/pages/user/user.tsx b/src/pages/user/user.tsx index d6886e2..18a930e 100644 --- a/src/pages/user/user.tsx +++ b/src/pages/user/user.tsx @@ -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' }, // 门店管理:仅门店经理/店员显示