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