Browse Source

feat(浏览记录): 实现浏览记录功能及相关配置

- 新增浏览记录配置文件,统一管理类型和分类枚举
- 添加可视性观察混入,支持多端元素可见性检测
- 创建浏览历史页面,展示用户浏览记录
- 移除API接口中的limit参数限制
- 优化动态列表项组件,集成浏览记录功能
master
主管理员 2 days ago
parent
commit
a6634d61a8
6 changed files with 599 additions and 4 deletions
  1. +0
    -1
      api/model/browseRecord.js
  2. +3
    -3
      components/list/dynamic/dynamicItem.vue
  3. +128
    -0
      config/browseConfig.js
  4. +205
    -0
      mixins/visibilityObserver.js
  5. +8
    -0
      pages.json
  6. +255
    -0
      pages_order/mine/browseHistory.vue

+ 0
- 1
api/model/browseRecord.js View File

@ -5,7 +5,6 @@ const api = {
addBrowseRecord: { addBrowseRecord: {
url: '/city/browseRecord/addBrowseRecord', url: '/city/browseRecord/addBrowseRecord',
method: 'POST', method: 'POST',
limit : 1000,
auth : true, auth : true,
showLoading : true, showLoading : true,
}, },


+ 3
- 3
components/list/dynamic/dynamicItem.vue View File

@ -30,7 +30,10 @@
import dynamicToShop from '@/components/list/dynamic/dynamicToShop.vue' import dynamicToShop from '@/components/list/dynamic/dynamicToShop.vue'
import statisticalDataInfo from '@/components/list/statisticalDataInfo.vue' import statisticalDataInfo from '@/components/list/statisticalDataInfo.vue'
import commentList from '@/components/list/dynamic/commentList.vue' import commentList from '@/components/list/dynamic/commentList.vue'
import visibilityObserver from '@/mixins/visibilityObserver.js'
export default { export default {
mixins: [visibilityObserver],
components: { components: {
userHeadItem, userHeadItem,
daynamicInfo, daynamicInfo,
@ -41,9 +44,6 @@
props: { props: {
item: {}, item: {},
}, },
data() {
return {}
},
methods: { methods: {
handleGoToDetail() { handleGoToDetail() {
// item // item


+ 128
- 0
config/browseConfig.js View File

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

+ 205
- 0
mixins/visibilityObserver.js View File

@ -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()
}
}
}

+ 8
- 0
pages.json View File

@ -209,6 +209,14 @@
"enablePullDownRefresh": false "enablePullDownRefresh": false
} }
}, },
{
"path": "mine/browseHistory",
"style": {
"navigationBarTitleText": "浏览历史",
"enablePullDownRefresh": true,
"navigationStyle": "custom"
}
},
{ {
"path": "profile/userProfile", "path": "profile/userProfile",
"style": { "style": {


+ 255
- 0
pages_order/mine/browseHistory.vue View File

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

Loading…
Cancel
Save