mrr.sj.front/pages/my/myFavorite.vue

682 lines
20 KiB
Vue
Raw Normal View History

<template>
2026-05-26 18:06:07 +08:00
<view class="collection-page">
<custom-navbar title="我的收藏" :showBack="true" backgroundColor="#FFFFFF" :show-headle="true" borderBottom="none"
headleSrc="https://mrrplus.oss-cn-beijing.aliyuncs.com/photo/system/51ebca5d-95c5-4fb8-8aa3-560a79100d25.png" @onHeadleClick="goToSearch">
2026-05-26 18:06:07 +08:00
</custom-navbar>
<view class="nav-placeholder" :style="{ height: navHeight + 'px' }"></view>
<view class="tab-bar-wrap" :style="{ top: navHeight + 'px' }">
<view class="tab-bar">
<view class="tab-items">
<view
class="tab-item"
:class="{ active: currentTab === 'brand' }"
@tap="switchTab('brand')"
>
收藏的品牌
</view>
<view
class="tab-item"
:class="{ active: currentTab === 'course' }"
@tap="switchTab('course')"
>
收藏的课程
</view>
</view>
<view class="filter-area" @tap="toggleFilterPanel">
<text class="filter-text">筛选</text>
<image class="filter-icon" :src="filterIcon" mode="aspectFit" :class="{ 'rotate': filterVisible }"></image>
</view>
</view>
</view>
<view class="filter-panel" :class="{ 'show': filterVisible }" :style="{ top: filterTop + 'px' }" catchtouchmove="true">
<view class="filter-mask" @tap="closeFilterPanel"></view>
<view class="filter-container">
<scroll-view scroll-y class="filter-scroll" v-if="currentTab === 'brand'">
<view class="filter-group" v-for="group in brandFilterData" :key="group.id">
<view class="group-title">{{ group.title }}</view>
<view class="pill-list">
<view
class="pill-item"
v-for="sub in group.children"
:key="sub.id"
:class="{ active: selectedSecondIds.includes(sub.id) }"
@tap="toggleSecondSelect(sub.id)"
>
{{ sub.title }}
</view>
</view>
</view>
</scroll-view>
<view v-else class="course-filter">
<view
class="course-type-item"
:class="{ active: selectedCourseType === 'platform' }"
@tap="selectCourseType('platform')"
>
平台课程
</view>
<view
class="course-type-item"
:class="{ active: selectedCourseType === 'brand' }"
@tap="selectCourseType('brand')"
>
品牌课程
</view>
</view>
<view class="filter-footer">
<view class="reset-btn" @tap="resetFilter">重置</view>
<view class="confirm-btn" @tap="applyFilterAndClose">确定</view>
</view>
</view>
</view>
<view class="collection-content" v-if="currentTab === 'brand'">
<view class="brand-grid" v-if="filteredBrandList.length > 0">
<brand-card
class="brand-card-wrap"
v-for="(item, index) in filteredBrandList"
:key="index"
:item="item"
:tagKeys="item.tagKeys"
@click="goToBrandDetail(item)"
></brand-card>
</view>
<view class="empty-state" v-else>
<image class="empty-icon" src="https://mrrplus.oss-cn-beijing.aliyuncs.com/photo/system/da814ede-1adc-4f90-8cdf-5357a12fab27.png"></image>
</view>
</view>
<view class="collection-content" v-else>
<view class="course-list" v-if="filteredCourseList.length > 0">
<skill-card
v-for="(item, index) in filteredCourseList"
:key="index"
:item="item"
></skill-card>
</view>
<view class="empty-state" v-else>
<image class="empty-icon" src="https://mrrplus.oss-cn-beijing.aliyuncs.com/photo/system/da814ede-1adc-4f90-8cdf-5357a12fab27.png"></image>
</view>
</view>
<view style="height: 120rpx;"></view>
<TabBar :selected="3"></TabBar>
</view>
</template>
<script>
2026-05-26 18:06:07 +08:00
import TabBar from "@/components/tabbar/tabbar.vue"
import BrandCard from "@/pages/home/components/brand-card.vue"
import SkillCard from "@/pages/home/components/skill-card.vue"
import request from "@/utils/request"
// 数据模式开关true=调用真实接口false=使用模拟数据
const USE_REAL_API = true
export default {
components: {
TabBar,
BrandCard,
SkillCard
},
data() {
return {
navHeight: 0, // 动态计算的导航栏高度
filterTop: 0, // 动态计算的筛选框顶部起点
currentTab: 'brand',
// 筛选相关
filterVisible: false,
// 品牌筛选下拉数据 (合并一级和二级)
brandFilterData: [],
selectedSecondIds: [], // 选中的二级ID数组
// 课程筛选
selectedCourseType: '', // 'platform' 或 'brand'
// 原始数据(收藏的数据)
rawBrandList: [],
rawCourseList: [],
// 展示数据(过滤后)
filteredBrandList: [],
filteredCourseList: []
}
},
computed: {
filterIcon() {
// 这里的图标链接可以统一成一个配合CSS的旋转动画更好看
return 'https://mrrplus.oss-cn-beijing.aliyuncs.com/photo/system/e00ab2ac-bccf-42d8-820c-65727327e279.png'
}
},
onLoad() {
// 精准计算吸顶高度与下拉框起点防止蒙版遮挡白色的Tab区
const sysInfo = uni.getSystemInfoSync()
const sbh = sysInfo.statusBarHeight || 0
this.navHeight = sbh
// Tab栏自身的高度大约为 106rpx转化为 px 叠加上去
this.filterTop = this.navHeight + uni.upx2px(106)
this.loadFilterCategories()
this.loadData()
},
methods: {
// 跳转搜索页
goToSearch() {
// this.currentTab 的值刚好是 'brand' 或 'course'
uni.navigateTo({
url: `/pages/search_common/search?type=collection_${this.currentTab}`
});
},
2026-05-26 18:06:07 +08:00
goBack() {
uni.navigateBack()
},
addAddress() {
// 外部方法占位
},
switchTab(tab) {
if (this.currentTab === tab) return
this.currentTab = tab
// 重置筛选不同tab筛选条件独立
if (tab === 'brand') {
this.resetBrandFilter()
} else {
this.resetCourseFilter()
}
this.loadData()
this.closeFilterPanel()
},
// 筛选面板控制
toggleFilterPanel() {
this.filterVisible = !this.filterVisible
},
closeFilterPanel() {
this.filterVisible = false
},
// 加载一级分类并自动组装其对应的二级分类,兼容 state === 1
async loadFilterCategories() {
try {
2026-06-02 18:32:15 +08:00
const res = await request.post('/sj/firstclass', {})
2026-05-26 18:06:07 +08:00
if ((res.code === 200 || res.state === 1) && res.data) {
const firstClasses = res.data;
// 并发请求所有的二级分类
const promises = firstClasses.map(async (first) => {
try {
const subRes = await request.post('/sj/secondclass', { id: first.id })
return {
id: first.id,
title: first.title,
children: ((subRes.code === 200 || subRes.state === 1) && subRes.data) ? subRes.data : []
}
} catch(e) {
return { id: first.id, title: first.title, children: [] }
}
});
const filterData = await Promise.all(promises);
// 过滤掉没有子分类的项,避免面板出现光秃秃的标题
this.brandFilterData = filterData.filter(group => group.children.length > 0);
} else {
this.brandFilterData = []
}
} catch (error) {
console.error('获取分类字典失败', error)
}
},
toggleSecondSelect(secId) {
const index = this.selectedSecondIds.indexOf(secId)
if (index === -1) {
this.selectedSecondIds.push(secId)
} else {
this.selectedSecondIds.splice(index, 1)
}
},
// 课程筛选
selectCourseType(type) {
// 点击已选中的则取消选中
if(this.selectedCourseType === type){
this.selectedCourseType = ''
}else{
this.selectedCourseType = type
}
},
// 重置品牌筛选
resetBrandFilter() {
this.selectedSecondIds = []
this.applyBrandFilter()
},
resetCourseFilter() {
this.selectedCourseType = ''
this.applyCourseFilter()
},
resetFilter() {
if (this.currentTab === 'brand') {
this.resetBrandFilter()
} else {
this.resetCourseFilter()
}
},
applyFilterAndClose() {
if (this.currentTab === 'brand') {
this.applyBrandFilter()
} else {
this.applyCourseFilter()
}
this.closeFilterPanel()
},
// 品牌过滤逻辑根据二级分类ID相交判断
applyBrandFilter() {
if (!this.rawBrandList.length) {
this.filteredBrandList = []
return
}
if (this.selectedSecondIds.length === 0) {
this.filteredBrandList = [...this.rawBrandList]
} else {
this.filteredBrandList = this.rawBrandList.filter(brand => {
return brand.secondClassIds && brand.secondClassIds.some(id => this.selectedSecondIds.includes(id))
})
}
},
// 课程过滤逻辑(根据课程类型)
applyCourseFilter() {
if (!this.rawCourseList.length) {
this.filteredCourseList = []
return
}
if (!this.selectedCourseType) {
this.filteredCourseList = [...this.rawCourseList]
} else {
this.filteredCourseList = this.rawCourseList.filter(course => {
if (this.selectedCourseType === 'platform') return course.type === '平台课程'
if (this.selectedCourseType === 'brand') return course.type === '品牌课程'
return true
})
}
},
// 加载原始数据根据当前Tab
loadData() {
if (this.currentTab === 'brand') {
this.loadBrandList()
} else {
this.loadCourseList()
}
},
async loadBrandList() {
if (USE_REAL_API) {
await this.fetchRealBrandList()
} else {
this.mockBrandList()
}
this.applyBrandFilter()
},
async loadCourseList() {
if (USE_REAL_API) {
await this.fetchRealCourseList()
} else {
this.mockCourseList()
}
this.applyCourseFilter()
},
// ============ 真实接口调用 ============
async fetchRealBrandList() {
try {
2026-06-02 18:21:35 +08:00
// 1. 接口路径换成真实的品牌收藏列表
const res = await request.post('/sj/brand/collectList', { page: 1, limit: 100 })
2026-05-26 18:06:07 +08:00
if ((res.code === 200 || res.state === 1) && res.data && res.data.list) {
2026-06-02 18:21:35 +08:00
// 2. 不需要前端再做 filter(item => item.collect_state === 1) 了,直接用!
this.rawBrandList = res.data.list.map(item => {
2026-05-26 18:06:07 +08:00
let tags = []
2026-06-02 18:21:35 +08:00
if (item.franchise_state == 1) tags.push('franchise')
if (item.student_state == 1) tags.push('recruit')
2026-05-26 18:06:07 +08:00
if (tags.length === 0) tags.push('more')
return {
id: item.id,
name: item.name,
bgSrc: item.cover_img || 'https://mrrplus.oss-cn-beijing.aliyuncs.com/photo/system/default.jpg',
avatar: item.logo_img || 'https://dummyimage.com/100x100/ccc/fff.png&text=Avatar',
tagKeys: tags,
2026-06-02 18:21:35 +08:00
// 3. 强制转字符串,防止后端返回纯数字导致 split 报错,同时兼容后端没返回此字段的情况
secondClassIds: item.second_class_ids ? String(item.second_class_ids).split(',').map(Number) : []
2026-05-26 18:06:07 +08:00
}
})
2026-06-02 18:21:35 +08:00
console.log('成功获取品牌收藏列表:', this.rawBrandList)
2026-05-26 18:06:07 +08:00
} else {
this.rawBrandList = []
}
} catch (error) {
2026-06-02 18:21:35 +08:00
console.error('获取品牌收藏失败详情:', error)
2026-05-26 18:06:07 +08:00
}
},
async fetchRealCourseList() {
try {
2026-06-02 18:21:35 +08:00
// 1. 接口路径换成真实的技能收藏列表
const res = await request.post('/sj/skill/collectList', { page: 1, limit: 100 })
2026-05-26 18:06:07 +08:00
if ((res.code === 200 || res.state === 1) && res.data && res.data.list) {
2026-06-02 18:21:35 +08:00
// 2. 同样不需要 filter直接 map 映射数据
this.rawCourseList = res.data.list.map(item => {
2026-05-26 18:06:07 +08:00
return {
id: item.id,
title: item.name,
coverSrc: item.cover_img,
2026-06-02 18:21:35 +08:00
type: item.link_name || (item.link_type == 1 ? '平台课程' : '品牌课程'),
2026-05-26 18:06:07 +08:00
link_type: item.link_type,
views: item.browse_num || 0,
2026-06-02 18:21:35 +08:00
// 3. 使用安全的时间格式化
2026-05-26 18:06:07 +08:00
time: item.create_time ? this.formatTimeAgo(item.create_time) : '',
typeIcon: item.link_logo || ''
}
})
2026-06-02 18:21:35 +08:00
console.log('成功获取课程收藏列表:', this.rawCourseList)
2026-05-26 18:06:07 +08:00
} else {
this.rawCourseList = []
}
} catch (error) {
console.error('获取课程收藏失败', error)
}
},
formatTimeAgo(timeStr) {
2026-06-02 18:21:35 +08:00
if (!timeStr) return '';
const timeStrSafe = String(timeStr);
2026-05-26 18:06:07 +08:00
const now = new Date()
2026-06-02 18:21:35 +08:00
const time = new Date(timeStrSafe.replace(/-/g, '/'))
2026-05-26 18:06:07 +08:00
const diff = Math.floor((now - time) / 1000)
if (diff < 3600) return Math.floor(diff / 60) + '分钟前'
if (diff < 86400) return Math.floor(diff / 3600) + '小时前'
if (diff < 604800) return Math.floor(diff / 86400) + '天前'
2026-06-02 18:21:35 +08:00
return timeStrSafe.split(' ')[0]
2026-05-26 18:06:07 +08:00
},
goToBrandDetail(item) {
if (item.id) {
uni.navigateTo({ url: `/pages/brand/detail?id=${item.id}` })
}
},
// ============ 模拟数据兜底 ============
mockBrandList() {
this.rawBrandList = [
{
id: 1, name: '脸缘品牌', bgSrc: 'https://mrrplus.oss-cn-beijing.aliyuncs.com/photo/system/e2220528-fdf6-4cad-a25d-ec6cbee1501e.jpg',
avatar: 'https://mrrplus.oss-cn-beijing.aliyuncs.com/photo/system/e2220528-fdf6-4cad-a25d-ec6cbee1501e.jpg',
tagKeys: ['franchise', 'recruit'], secondClassIds: [76, 77]
},
{
id: 2, name: '美肌工坊', bgSrc: 'https://mrrplus.oss-cn-beijing.aliyuncs.com/photo/system/e2220528-fdf6-4cad-a25d-ec6cbee1501e.jpg',
avatar: 'https://mrrplus.oss-cn-beijing.aliyuncs.com/photo/system/e2220528-fdf6-4cad-a25d-ec6cbee1501e.jpg',
tagKeys: ['franchise'], secondClassIds: [78]
},
{
id: 3, name: '悦容堂', bgSrc: 'https://mrrplus.oss-cn-beijing.aliyuncs.com/photo/system/e2220528-fdf6-4cad-a25d-ec6cbee1501e.jpg',
avatar: 'https://mrrplus.oss-cn-beijing.aliyuncs.com/photo/system/e2220528-fdf6-4cad-a25d-ec6cbee1501e.jpg',
tagKeys: ['recruit'], secondClassIds: [201, 202]
}
]
},
mockCourseList() {
this.rawCourseList = [
{ id: 1, title: '穿戴甲的操作视频扫码验券操作教程', coverSrc: 'https://mrrplus.oss-cn-beijing.aliyuncs.com/photo/system/e2220528-fdf6-4cad-a25d-ec6cbee1501e.jpg', type: '平台课程', views: 836000, time: '7小时前' },
{ id: 2, title: '全身SPA的操作视频操作教程', coverSrc: 'https://mrrplus.oss-cn-beijing.aliyuncs.com/photo/system/e2220528-fdf6-4cad-a25d-ec6cbee1501e.jpg', type: '平台课程', views: 2344, time: '3天前' },
{ id: 3, title: '高级面部护理手法详解', coverSrc: 'https://mrrplus.oss-cn-beijing.aliyuncs.com/photo/system/e2220528-fdf6-4cad-a25d-ec6cbee1501e.jpg', type: '品牌课程', views: 56700, time: '1周前' }
]
}
}
}
</script>
2026-05-26 18:06:07 +08:00
<style lang="scss" scoped>
.collection-page {
min-height: 100vh;
background: #fff;
}
/* 将页面左右 padding 转移到内容区,保证 Tab 背景贯穿全屏 */
.tab-bar-wrap {
position: sticky; /* 吸顶,保证滑动页面时一直显示在最前面 */
background: #fff;
padding: 10rpx 24rpx;
z-index: 101;
}
.tab-bar {
display: flex;
align-items: center;
justify-content: space-between;
background: transparent;
padding: 10rpx 0;
.tab-items {
display: flex;
align-items: center;
.tab-item {
font-weight: 400;
font-size: 26rpx;
color: #777777;
background: #f5f5f5;
border-radius: 27rpx;
margin-right: 40rpx;
padding: 9rpx 24rpx;
transition: all 0.3s ease;
&.active {
color: #FF4767;
background: #FFECEF;
border: 1rpx solid #FFB0BD;
border-radius: 29rpx;
}
}
}
.filter-area {
display: flex;
align-items: center;
padding: 10rpx 0;
.filter-text {
font-size: 24rpx;
color: #666666;
margin-right: 6rpx;
}
.filter-icon {
width: 16rpx;
height: 10rpx;
transition: transform 0.3s ease;
/* 展开时图标倒转 */
&.rotate {
transform: rotate(180deg);
}
}
}
}
/* 核心抽屉动画布局 */
.filter-panel {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 100; /* 低于 Tab区的101保证隐藏时躲在其后 */
pointer-events: none; /* 面板关闭时,穿透点击到底部列表 */
overflow: hidden; /* 防止抽屉向上滑出时外溢 */
&.show {
pointer-events: auto; /* 打开时恢复点击拦截 */
.filter-mask {
opacity: 1;
}
.filter-container {
transform: translateY(50rpx); /* 滑出 */
}
}
.filter-mask {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.4);
opacity: 0;
transition: opacity 0.3s ease-in-out;
}
.filter-container {
position: absolute;
top: 0; left: 0; right: 0;
background: #fff;
border-radius: 0 0 30rpx 30rpx;
padding: 40rpx 30rpx;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 10rpx 20rpx rgba(0,0,0,0.05);
transform: translateY(-100%); /* 默认隐藏在上方即Tab背后 */
transition: transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
}
}
/* 垂直分组设计的样式图3 */
.filter-scroll {
max-height: 50vh;
}
.filter-group {
margin-bottom: 30rpx;
.group-title {
font-weight: 500;
font-size: 28rpx;
color: #333333;
line-height: 40rpx;
margin-bottom: 20rpx;
}
.pill-list {
display: flex;
flex-wrap: wrap;
gap: 20rpx;
}
}
/* 通用胶囊按钮 */
.pill-item {
width: calc(25% - 16rpx); /* 25%是一行四个,减去间距 */
height: 58rpx;
background: #F5F5F5;
font-weight: 400;
font-size: 26rpx;
color: #333333;
line-height: 37rpx;
/* 使用 flex 居中 */
display: flex;
align-items: center;
justify-content: center;
border-radius: 12rpx;
transition: all 0.3s;
box-sizing: border-box; /* 防止 padding 会撑大盒子 */
&.active {
background: #FFF0F2;
color: #FF4767;
border: 1rpx solid #FFB0BD;
}
}
.course-filter {
display: flex;
gap: 20rpx;
/* padding: 20rpx 0 40rpx; */
.course-type-item {
background: #F5F5F5;
color: #666666;
font-size: 26rpx;
padding: 12rpx 40rpx;
border-radius: 12rpx;
&.active {
background: #FFF0F2;
color: #FF4767;
}
}
}
.filter-footer {
display: flex;
justify-content: space-between;
margin-top: 20rpx;
padding-top: 20rpx;
/* border-top: 1rpx solid #F5F5F5; */
.reset-btn, .confirm-btn {
width: 47%;
text-align: center;
padding: 20rpx 0;
border-radius: 40rpx;
font-size: 28rpx;
}
.reset-btn {
background: #F5F5F5;
color: #666;
}
.confirm-btn {
background: #FF4767;
color: #fff;
}
}
/* 内容区缩进补充 */
.collection-content {
padding: 0 24rpx;
margin-top: 20rpx;
}
.brand-grid {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
.brand-card-wrap {
width: 49.2%;
margin-bottom: 20rpx;
}
}
.course-list {
display: flex;
flex-direction: column;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx 0;
.empty-icon {
width: 346rpx;
height: 373rpx;
}
}
2026-05-26 18:06:07 +08:00
::v-deep .icon-headle {
width: 32rpx !important;
height: 32rpx !important;
}
</style>