- 新增浏览记录配置文件,统一管理类型和分类枚举 - 添加可视性观察混入,支持多端元素可见性检测 - 创建浏览历史页面,展示用户浏览记录 - 移除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> |