- 新增浏览记录配置文件,统一管理类型和分类枚举 - 添加可视性观察混入,支持多端元素可见性检测 - 创建浏览历史页面,展示用户浏览记录 - 移除API接口中的limit参数限制 - 优化动态列表项组件,集成浏览记录功能master
@ -0,0 +1,128 @@ | |||
/** | |||
* 浏览记录配置文件 | |||
* 统一管理浏览记录相关的API参数和配置 | |||
*/ | |||
// 浏览记录类型枚举 | |||
export const BROWSE_RECORD_TYPE = { | |||
DYNAMIC: 0, // 帖子/动态 | |||
RENTAL: 1, // 租房 | |||
JOB: 2, // 工作 | |||
SCENIC_SPOT: 3, // 景点 | |||
GOURMET: 4, // 美食 | |||
ACTIVITY: 5, // 活动 | |||
CAR_FIND_PERSON: 6, // 人找车 | |||
PERSON_FIND_CAR: 7, // 车找人 | |||
ARTICLE: 8 // 文章 | |||
} | |||
// 浏览记录分类枚举 | |||
export const BROWSE_RECORD_CATEGORY = { | |||
BROWSE: 0, // 浏览 | |||
LIKE: 1, // 点赞 | |||
FORWARD: 2, // 转发 | |||
REWARDED_VIDEO: 3, // 激励视频 | |||
COVER_AD: 4 // 封面广告 | |||
} | |||
// 可视区域检测配置 | |||
export const VIEWPORT_CONFIG = { | |||
// 触发阈值(元素可见比例) | |||
THRESHOLD: 0.8, | |||
// 停留时间(毫秒)- 确保用户真正浏览了内容 | |||
DWELL_TIME: 1000, | |||
// 参照区域边距 | |||
VIEWPORT_MARGINS: { | |||
top: 0, | |||
bottom: 0, | |||
left: 0, | |||
right: 0 | |||
} | |||
} | |||
// 浏览记录API参数配置 | |||
export const BROWSE_RECORD_CONFIG = { | |||
// 动态列表项浏览记录 | |||
DYNAMIC_ITEM: { | |||
type: BROWSE_RECORD_TYPE.DYNAMIC, | |||
category: BROWSE_RECORD_CATEGORY.BROWSE | |||
}, | |||
// 租房列表项浏览记录 | |||
RENTAL_ITEM: { | |||
type: BROWSE_RECORD_TYPE.RENTAL, | |||
category: BROWSE_RECORD_CATEGORY.BROWSE | |||
}, | |||
// 工作列表项浏览记录 | |||
JOB_ITEM: { | |||
type: BROWSE_RECORD_TYPE.JOB, | |||
category: BROWSE_RECORD_CATEGORY.BROWSE | |||
}, | |||
// 景点列表项浏览记录 | |||
SCENIC_SPOT_ITEM: { | |||
type: BROWSE_RECORD_TYPE.SCENIC_SPOT, | |||
category: BROWSE_RECORD_CATEGORY.BROWSE | |||
}, | |||
// 美食列表项浏览记录 | |||
GOURMET_ITEM: { | |||
type: BROWSE_RECORD_TYPE.GOURMET, | |||
category: BROWSE_RECORD_CATEGORY.BROWSE | |||
}, | |||
// 活动列表项浏览记录 | |||
ACTIVITY_ITEM: { | |||
type: BROWSE_RECORD_TYPE.ACTIVITY, | |||
category: BROWSE_RECORD_CATEGORY.BROWSE | |||
}, | |||
// 人找车列表项浏览记录 | |||
CAR_FIND_PERSON_ITEM: { | |||
type: BROWSE_RECORD_TYPE.CAR_FIND_PERSON, | |||
category: BROWSE_RECORD_CATEGORY.BROWSE | |||
}, | |||
// 车找人列表项浏览记录 | |||
PERSON_FIND_CAR_ITEM: { | |||
type: BROWSE_RECORD_TYPE.PERSON_FIND_CAR, | |||
category: BROWSE_RECORD_CATEGORY.BROWSE | |||
}, | |||
// 文章列表项浏览记录 | |||
ARTICLE_ITEM: { | |||
type: BROWSE_RECORD_TYPE.ARTICLE, | |||
category: BROWSE_RECORD_CATEGORY.BROWSE | |||
} | |||
} | |||
// 获取浏览记录配置的工具函数 | |||
export function getBrowseRecordConfig(itemType) { | |||
const configMap = { | |||
'dynamic': BROWSE_RECORD_CONFIG.DYNAMIC_ITEM, | |||
'rental': BROWSE_RECORD_CONFIG.RENTAL_ITEM, | |||
'job': BROWSE_RECORD_CONFIG.JOB_ITEM, | |||
'scenicSpot': BROWSE_RECORD_CONFIG.SCENIC_SPOT_ITEM, | |||
'gourmet': BROWSE_RECORD_CONFIG.GOURMET_ITEM, | |||
'activity': BROWSE_RECORD_CONFIG.ACTIVITY_ITEM, | |||
'carFindPerson': BROWSE_RECORD_CONFIG.CAR_FIND_PERSON_ITEM, | |||
'personFindCar': BROWSE_RECORD_CONFIG.PERSON_FIND_CAR_ITEM, | |||
'article': BROWSE_RECORD_CONFIG.ARTICLE_ITEM | |||
} | |||
return configMap[itemType] || BROWSE_RECORD_CONFIG.DYNAMIC_ITEM | |||
} | |||
// 创建浏览记录参数的工具函数 | |||
export function createBrowseRecordParams(orderId, itemType = 'dynamic') { | |||
const config = getBrowseRecordConfig(itemType) | |||
return { | |||
orderId: orderId, | |||
type: config.type, | |||
category: config.category | |||
} | |||
} |
@ -0,0 +1,205 @@ | |||
/** | |||
* 可见性监听混入 | |||
* 提供元素可见性监听功能,支持H5、微信小程序、App端 | |||
* 用于实现浏览记录等功能 | |||
*/ | |||
import { VIEWPORT_CONFIG, createBrowseRecordParams } from '@/config/browseConfig.js' | |||
export default { | |||
data() { | |||
return { | |||
hasRecordedView: false, // 标记是否已记录浏览 | |||
viewTimer: null, // 浏览计时器 | |||
isInViewport: false, // 是否在可视区域内 | |||
observer: null, // 观察者实例 | |||
} | |||
}, | |||
mounted() { | |||
// 组件挂载后,使用Intersection Observer监听可见性 | |||
this.observeVisibility() | |||
}, | |||
beforeDestroy() { | |||
// 组件销毁前清理observer和计时器 | |||
this.cleanupObserver() | |||
}, | |||
methods: { | |||
/** | |||
* 监听元素可见性 | |||
* @param {String} selector - 要监听的元素选择器,默认为'.works' | |||
* @param {Object} config - 配置参数 | |||
*/ | |||
observeVisibility(selector = '.works', config = {}) { | |||
const { | |||
threshold = VIEWPORT_CONFIG.THRESHOLD, | |||
dwellTime = VIEWPORT_CONFIG.DWELL_TIME, | |||
margins = VIEWPORT_CONFIG.VIEWPORT_MARGINS | |||
} = config | |||
// #ifdef H5 | |||
if (typeof IntersectionObserver !== 'undefined') { | |||
this.observer = new IntersectionObserver((entries) => { | |||
entries.forEach(entry => { | |||
if (entry.isIntersecting && !this.hasRecordedView) { | |||
// 元素进入可视区域且未记录过浏览 | |||
this.handleViewportEnter(dwellTime) | |||
} else if (!entry.isIntersecting && this.isInViewport) { | |||
// 元素离开可视区域 | |||
this.handleViewportLeave() | |||
} | |||
}) | |||
}, { | |||
threshold: threshold | |||
}) | |||
this.observer.observe(this.$el) | |||
} else { | |||
// 降级方案:延迟记录浏览 | |||
setTimeout(() => { | |||
if (!this.hasRecordedView) { | |||
this.recordBrowseView() | |||
} | |||
}, dwellTime) | |||
} | |||
// #endif | |||
// #ifdef MP-WEIXIN | |||
// 使用微信小程序原生的IntersectionObserver API | |||
this.observer = wx.createIntersectionObserver(this, { | |||
thresholds: [threshold], | |||
initialRatio: 0, | |||
observeAll: false | |||
}) | |||
// 指定页面显示区域作为参照区域 | |||
this.observer.relativeToViewport(margins) | |||
// 开始监听目标节点 | |||
this.observer.observe(selector, (res) => { | |||
if (res.intersectionRatio > threshold && !this.hasRecordedView) { | |||
// 当元素达到配置阈值以上进入可视区域且未记录过浏览时,处理进入可视区域事件 | |||
this.handleViewportEnter(dwellTime) | |||
} else if (res.intersectionRatio <= threshold && this.isInViewport) { | |||
// 元素离开可视区域 | |||
this.handleViewportLeave() | |||
} | |||
}) | |||
// #endif | |||
// #ifdef APP-PLUS | |||
// App端使用uni-app的createIntersectionObserver | |||
this.observer = uni.createIntersectionObserver(this, { | |||
thresholds: [threshold], | |||
initialRatio: 0, | |||
observeAll: false | |||
}) | |||
this.observer.relativeToViewport(margins) | |||
this.observer.observe(selector, (res) => { | |||
if (res.intersectionRatio > threshold && !this.hasRecordedView) { | |||
this.handleViewportEnter(dwellTime) | |||
} else if (res.intersectionRatio <= threshold && this.isInViewport) { | |||
this.handleViewportLeave() | |||
} | |||
}) | |||
// #endif | |||
}, | |||
/** | |||
* 处理进入可视区域事件 | |||
* @param {Number} dwellTime - 停留时间 | |||
*/ | |||
handleViewportEnter(dwellTime = VIEWPORT_CONFIG.DWELL_TIME) { | |||
if (this.hasRecordedView) return | |||
this.isInViewport = true | |||
// 设置延迟计时器,确保用户真正浏览了内容 | |||
this.viewTimer = setTimeout(() => { | |||
if (this.isInViewport && !this.hasRecordedView) { | |||
this.recordBrowseView() | |||
} | |||
}, dwellTime) | |||
}, | |||
/** | |||
* 处理离开可视区域事件 | |||
*/ | |||
handleViewportLeave() { | |||
this.isInViewport = false | |||
// 清除计时器,如果用户快速滚动过去,不记录浏览 | |||
if (this.viewTimer) { | |||
clearTimeout(this.viewTimer) | |||
this.viewTimer = null | |||
} | |||
}, | |||
/** | |||
* 记录浏览行为 | |||
* @param {String} itemType - 项目类型,默认为'dynamic' | |||
* @param {String} itemId - 项目ID,默认从this.item.id获取 | |||
*/ | |||
recordBrowseView(itemType = 'dynamic', itemId = null) { | |||
const id = itemId || (this.item && this.item.id) | |||
if (this.hasRecordedView || !id) return | |||
this.hasRecordedView = true | |||
// 清除计时器 | |||
if (this.viewTimer) { | |||
clearTimeout(this.viewTimer) | |||
this.viewTimer = null | |||
} | |||
// 使用配置文件中的参数创建API请求参数 | |||
const params = createBrowseRecordParams(id, itemType) | |||
// 调用浏览记录API | |||
this.$api('addBrowseRecord', params, res => { | |||
if (res.code === 200) { | |||
console.log('浏览记录已保存:', id) | |||
} | |||
}) | |||
}, | |||
/** | |||
* 清理观察者和计时器 | |||
*/ | |||
cleanupObserver() { | |||
if (this.observer) { | |||
// #ifdef H5 | |||
if (typeof this.observer.disconnect === 'function') { | |||
this.observer.disconnect() | |||
} | |||
// #endif | |||
// #ifdef MP-WEIXIN || APP-PLUS | |||
if (typeof this.observer.disconnect === 'function') { | |||
this.observer.disconnect() | |||
} | |||
// #endif | |||
this.observer = null | |||
} | |||
if (this.viewTimer) { | |||
clearTimeout(this.viewTimer) | |||
this.viewTimer = null | |||
} | |||
}, | |||
/** | |||
* 重置浏览记录状态 | |||
*/ | |||
resetBrowseRecord() { | |||
this.hasRecordedView = false | |||
this.isInViewport = false | |||
this.cleanupObserver() | |||
} | |||
} | |||
} |
@ -0,0 +1,255 @@ | |||
<template> | |||
<view class="browse-history"> | |||
<navbar leftClick @leftClick="$utils.navigateBack" title="浏览历史" /> | |||
<!-- 筛选标签 --> | |||
<view class="filter-tabs"> | |||
<uv-tabs | |||
:list="filterTabs" | |||
:activeStyle="{color: '#000', fontWeight: 900, fontSize: '32rpx'}" | |||
lineColor="#5baaff" | |||
lineHeight="6rpx" | |||
lineWidth="50rpx" | |||
keyName="title" | |||
@click="onFilterChange" | |||
/> | |||
</view> | |||
<!-- 浏览记录列表 --> | |||
<view class="history-list" v-if="historyList.length > 0"> | |||
<view class="history-item" | |||
v-for="(item, index) in historyList" | |||
:key="index" | |||
@click="goToDetail(item)"> | |||
<view class="item-content"> | |||
<!-- 动态内容 --> | |||
<view class="dynamic-info"> | |||
<view class="title" v-html="$utils.stringFormatHtml(item.title)"></view> | |||
<view class="meta-info"> | |||
<text class="author">{{ item.userName }}</text> | |||
<text class="time">{{ $dayjs(item.createTime).format('MM-DD HH:mm') }}</text> | |||
</view> | |||
</view> | |||
<!-- 缩略图 --> | |||
<view class="thumbnail" v-if="item.image"> | |||
<image :src="item.image.split(',')[0]" mode="aspectFill" /> | |||
</view> | |||
</view> | |||
<!-- 浏览时间 --> | |||
<view class="browse-time"> | |||
浏览于 {{ $dayjs(item.browseTime).format('YYYY-MM-DD HH:mm') }} | |||
</view> | |||
</view> | |||
</view> | |||
<!-- 空状态 --> | |||
<view class="empty-state" v-else-if="!loading"> | |||
<uv-empty | |||
text="暂无浏览记录" | |||
icon="history" | |||
iconSize="120" | |||
/> | |||
</view> | |||
<!-- 加载更多 --> | |||
<uv-load-more | |||
:status="loadStatus" | |||
@loadmore="loadMore" | |||
v-if="historyList.length > 0" | |||
/> | |||
</view> | |||
</template> | |||
<script> | |||
import navbar from '@/components/base/navbar.vue' | |||
import mixinsList from '@/mixins/loadList.js' | |||
import { BROWSE_RECORD_CATEGORY } from '@/config/browseConfig.js' | |||
export default { | |||
mixins: [mixinsList], | |||
components: { | |||
navbar | |||
}, | |||
data() { | |||
return { | |||
mixinsListApi: 'getBrowseRecordPage', | |||
filterTabs: [ | |||
{ id: '', title: '全部' }, | |||
{ id: 0, title: '动态' }, | |||
{ id: 1, title: '租房' }, | |||
{ id: 2, title: '工作' }, | |||
{ id: 5, title: '活动' }, | |||
{ id: 8, title: '文章' } | |||
], | |||
currentFilter: '', | |||
historyList: [], | |||
loading: false, | |||
loadStatus: 'loadmore' | |||
} | |||
}, | |||
onLoad() { | |||
this.loadHistoryData() | |||
}, | |||
onPullDownRefresh() { | |||
this.refreshData() | |||
}, | |||
onReachBottom() { | |||
this.loadMore() | |||
}, | |||
methods: { | |||
// 筛选类型改变 | |||
onFilterChange(item) { | |||
this.currentFilter = item.id | |||
this.queryParams.type = item.id === '' ? undefined : item.id | |||
this.refreshList() | |||
}, | |||
// 加载浏览历史数据 | |||
loadHistoryData() { | |||
this.loading = true | |||
this.$api('getBrowseRecordPage', { | |||
...this.queryParams, | |||
category: BROWSE_RECORD_CATEGORY.BROWSE // 只获取浏览记录 | |||
}, res => { | |||
this.loading = false | |||
uni.stopPullDownRefresh() | |||
if (res.code === 200) { | |||
const newData = res.result.records || res.result || [] | |||
if (this.queryParams.pageNum === 1) { | |||
this.historyList = newData | |||
} else { | |||
this.historyList = [...this.historyList, ...newData] | |||
} | |||
// 更新加载状态 | |||
if (newData.length < this.queryParams.pageSize) { | |||
this.loadStatus = 'nomore' | |||
} else { | |||
this.loadStatus = 'loadmore' | |||
} | |||
} | |||
}) | |||
}, | |||
// 刷新数据 | |||
refreshData() { | |||
this.queryParams.pageNum = 1 | |||
this.loadStatus = 'loadmore' | |||
this.loadHistoryData() | |||
}, | |||
// 加载更多 | |||
loadMore() { | |||
if (this.loadStatus === 'loadmore') { | |||
this.queryParams.pageNum++ | |||
this.loadStatus = 'loading' | |||
this.loadHistoryData() | |||
} | |||
}, | |||
// 跳转到详情页 | |||
goToDetail(item) { | |||
const typeRouteMap = { | |||
0: '/pages_order/post/postDetail', | |||
1: '/pages_order/renting/rentingDetail', | |||
2: '/pages_order/work/workDetail', | |||
5: '/pages_order/activity/activityDetail', | |||
8: '/pages_order/article/articleDetail' | |||
} | |||
const route = typeRouteMap[item.type] || '/pages_order/post/postDetail' | |||
uni.navigateTo({ | |||
url: `${route}?id=${item.orderId}` | |||
}) | |||
} | |||
} | |||
} | |||
</script> | |||
<style scoped lang="scss"> | |||
.browse-history { | |||
background-color: #f5f5f5; | |||
min-height: 100vh; | |||
} | |||
.filter-tabs { | |||
background-color: #fff; | |||
padding: 20rpx 0; | |||
margin-bottom: 20rpx; | |||
} | |||
.history-list { | |||
padding: 0 20rpx; | |||
} | |||
.history-item { | |||
background-color: #fff; | |||
border-radius: 20rpx; | |||
padding: 30rpx; | |||
margin-bottom: 20rpx; | |||
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.1); | |||
.item-content { | |||
display: flex; | |||
align-items: flex-start; | |||
.dynamic-info { | |||
flex: 1; | |||
margin-right: 20rpx; | |||
.title { | |||
font-size: 32rpx; | |||
color: #333; | |||
line-height: 1.5; | |||
margin-bottom: 15rpx; | |||
display: -webkit-box; | |||
-webkit-box-orient: vertical; | |||
-webkit-line-clamp: 2; | |||
overflow: hidden; | |||
text-overflow: ellipsis; | |||
} | |||
.meta-info { | |||
display: flex; | |||
align-items: center; | |||
font-size: 26rpx; | |||
color: #999; | |||
.author { | |||
margin-right: 20rpx; | |||
} | |||
} | |||
} | |||
.thumbnail { | |||
width: 120rpx; | |||
height: 120rpx; | |||
border-radius: 12rpx; | |||
overflow: hidden; | |||
flex-shrink: 0; | |||
image { | |||
width: 100%; | |||
height: 100%; | |||
} | |||
} | |||
} | |||
.browse-time { | |||
margin-top: 20rpx; | |||
font-size: 24rpx; | |||
color: #999; | |||
text-align: right; | |||
} | |||
} | |||
.empty-state { | |||
padding: 100rpx 0; | |||
text-align: center; | |||
} | |||
</style> |