<template>
|
<view class="container">
|
<view class="custom-navbar" :style="{ paddingTop: statusBarHeight + 'px' }">
|
<view class="nav-content">
|
<image class="back-icon" src="/static/image/home/left.png" mode="aspectFit" @tap="goBack"></image>
|
<text class="nav-title">收藏课程</text>
|
<text class="nav-right-text" @tap="toggleBatchEdit">{{ isBatchMode ? '确定' : '批量取消' }}</text>
|
</view>
|
</view>
|
|
<scroll-view scroll-y class="favorites-list">
|
<view v-if="favoriteList.length === 0" class="empty-box">
|
<text class="empty-text">暂无收藏课程</text>
|
</view>
|
<block v-for="(item, index) in favoriteList" :key="item.courseId || index">
|
<view class="list-item-wrapper" @tap="handleBatchItemClick(index)">
|
<view v-if="isBatchMode" class="checkbox-col">
|
<image
|
class="check-icon"
|
:src="item.selected ? '/static/image/course/check.png' : '/static/image/course/uncheck.png'"
|
mode="aspectFit"
|
></image>
|
</view>
|
|
<view class="swipe-wrap" :class="{ 'in-batch-mode': isBatchMode }">
|
<u-swipe-action
|
:options="swipeOptions"
|
:index="index"
|
@click="handleDelete(index)"
|
:disabled="isBatchMode"
|
>
|
<view class="course-item-container" @tap.stop="handleCourseItemClick(item)">
|
<view class="course-item">
|
<view class="img-wrap">
|
<image class="course-img" :src="resolveCourseImage(item.coverImage)" mode="aspectFill"></image>
|
<image class="heart-icon" src="/static/image/course/live.png" mode="aspectFit"></image>
|
</view>
|
|
<view class="course-info">
|
<view class="title-wrap">
|
<view class="tag-list">
|
<view class="tag red-tag">养生</view>
|
<view v-if="item.difficultyName" class="tag red-tag">{{ item.difficultyName }}</view>
|
</view>
|
<text class="course-title">{{ item.title }}</text>
|
</view>
|
|
<view class="course-bottom">
|
<view class="teacher-info">
|
<image class="teacher-avatar" :src="resolveTeacherAvatar(item.teacherAvatar)" mode="aspectFill"></image>
|
<text class="teacher-name">{{ item.teacherName || '-' }}</text>
|
</view>
|
<view class="student-count">
|
<wd-icon name="user" size="12px" color="#999" custom-class="user-icon"></wd-icon>
|
<text>{{ formatLearnUserCount(item.studyingCount) }}人正在学</text>
|
</view>
|
</view>
|
</view>
|
</view>
|
</view>
|
</u-swipe-action>
|
</view>
|
</view>
|
</block>
|
</scroll-view>
|
</view>
|
</template>
|
|
<script setup lang="ts">
|
import { ref } from 'vue'
|
import { onLoad, onShow } from '@dcloudio/uni-app'
|
import { batchCancelFavoriteCourses, cancelFavoriteCourse, getFavoriteCourses } from './service'
|
|
interface FavoriteCourseItem {
|
courseId?: number | null
|
title?: string
|
coverImage?: string
|
levelName?: string
|
difficultyName?: string
|
teacherName?: string
|
teacherAvatar?: string
|
learnUserCount?: number | null
|
/** 正在学习人数:收藏接口返回,用于「X人正在学」展示 */
|
studyingCount?: number | null
|
selected?: boolean
|
}
|
|
const DEFAULT_COURSE_IMAGE = '/static/image/home/class.png'
|
const DEFAULT_TEACHER_AVATAR = '/static/image/home/doctor.png'
|
|
const statusBarHeight = ref(20)
|
const isBatchMode = ref(false)
|
const favoriteList = ref<FavoriteCourseItem[]>([])
|
|
const swipeOptions = ref([
|
{
|
text: '删除',
|
style: {
|
backgroundColor: '#F53F3F',
|
color: '#fff',
|
fontSize: '28rpx',
|
width: '120rpx',
|
height: '120rpx',
|
borderRadius: '50%',
|
display: 'flex',
|
justifyContent: 'center',
|
alignItems: 'center',
|
margin: '0 30rpx',
|
},
|
},
|
])
|
|
/**
|
* 页面加载时初始化状态栏高度。
|
*/
|
onLoad(() => {
|
const systemInfo = uni.getSystemInfoSync()
|
statusBarHeight.value = systemInfo.statusBarHeight || 20
|
})
|
|
/**
|
* 页面回显时刷新收藏列表。
|
*/
|
onShow(() => {
|
loadFavoriteCourses()
|
})
|
|
/**
|
* 拉取当前登录用户的收藏课程列表。
|
*/
|
async function loadFavoriteCourses() {
|
try {
|
const response = await getFavoriteCourses()
|
if (response?.code !== 0) {
|
return
|
}
|
favoriteList.value = normalizeFavoriteList(response.data)
|
} catch {
|
// 请求层会统一处理未登录跳转和异常提示,这里不重复 toast。
|
}
|
}
|
|
/**
|
* 将接口返回的收藏列表归一化为页面渲染结构。
|
*/
|
function normalizeFavoriteList(value: unknown): FavoriteCourseItem[] {
|
if (!Array.isArray(value)) {
|
return []
|
}
|
return value.map((item) => {
|
const record = (item && typeof item === 'object' ? item : {}) as Partial<FavoriteCourseItem>
|
return {
|
courseId: normalizeOptionalNumber(record.courseId),
|
title: normalizeOptionalText(record.title) || '课程名称',
|
coverImage: normalizeOptionalText(record.coverImage),
|
levelName: normalizeOptionalText(record.levelName),
|
difficultyName: normalizeOptionalText(record.difficultyName),
|
teacherName: normalizeOptionalText(record.teacherName),
|
teacherAvatar: normalizeOptionalText(record.teacherAvatar),
|
learnUserCount: normalizeOptionalNumber(record.learnUserCount),
|
studyingCount: normalizeOptionalNumber(record.studyingCount),
|
selected: false,
|
}
|
})
|
}
|
|
/**
|
* 返回上一页。
|
*/
|
function goBack() {
|
uni.navigateBack()
|
}
|
|
/**
|
* 切换批量模式;确认时执行批量取消。
|
*/
|
function toggleBatchEdit() {
|
if (!isBatchMode.value) {
|
isBatchMode.value = true
|
return
|
}
|
handleBatchConfirm()
|
}
|
|
/**
|
* 批量模式下切换勾选;普通模式下保持空操作,避免外层事件与滑动容器冲突。
|
*/
|
function handleBatchItemClick(index: number) {
|
const currentItem = favoriteList.value[index]
|
if (!currentItem) {
|
return
|
}
|
if (isBatchMode.value) {
|
currentItem.selected = !currentItem.selected
|
}
|
}
|
|
/**
|
* 普通模式下跳转课程详情;批量模式下由外层统一处理勾选。
|
*/
|
function handleCourseItemClick(item: FavoriteCourseItem) {
|
if (isBatchMode.value || !item.courseId) {
|
return
|
}
|
uni.navigateTo({
|
url: `/pages/courseDetail/courseDetail?id=${item.courseId}`,
|
})
|
}
|
|
/**
|
* 确认批量取消收藏;未勾选时直接退出批量模式。
|
*/
|
async function handleBatchConfirm() {
|
const selectedCourseIds = favoriteList.value
|
.filter((item) => item.selected && item.courseId)
|
.map((item) => Number(item.courseId))
|
if (selectedCourseIds.length === 0) {
|
resetBatchMode()
|
return
|
}
|
try {
|
const response = await batchCancelFavoriteCourses(selectedCourseIds)
|
if (response?.code !== 0) {
|
return
|
}
|
favoriteList.value = favoriteList.value.filter((item) => !item.selected)
|
resetBatchMode()
|
uni.showToast({
|
title: '已取消收藏',
|
icon: 'none',
|
})
|
} catch {
|
// 请求层会统一处理异常提示,这里不重复 toast。
|
}
|
}
|
|
/**
|
* 单条左滑取消收藏。
|
*/
|
function handleDelete(index: number) {
|
const currentItem = favoriteList.value[index]
|
if (!currentItem || !currentItem.courseId) {
|
return
|
}
|
uni.showModal({
|
title: '提示',
|
content: '确认取消收藏该课程?',
|
success: async (res) => {
|
if (!res.confirm) {
|
return
|
}
|
try {
|
const response = await cancelFavoriteCourse(currentItem.courseId as number)
|
if (response?.code !== 0) {
|
return
|
}
|
favoriteList.value.splice(index, 1)
|
uni.showToast({
|
title: '已取消收藏',
|
icon: 'none',
|
})
|
} catch {
|
// 请求层会统一处理异常提示,这里不重复 toast。
|
}
|
},
|
})
|
}
|
|
/**
|
* 退出批量模式并清空全部勾选状态。
|
*/
|
function resetBatchMode() {
|
isBatchMode.value = false
|
favoriteList.value.forEach((item) => {
|
item.selected = false
|
})
|
}
|
|
/**
|
* 解析课程封面图。
|
*/
|
function resolveCourseImage(value?: string) {
|
return normalizeOptionalText(value) || DEFAULT_COURSE_IMAGE
|
}
|
|
/**
|
* 解析讲师头像。
|
*/
|
function resolveTeacherAvatar(value?: string) {
|
return normalizeOptionalText(value) || DEFAULT_TEACHER_AVATAR
|
}
|
|
/**
|
* 格式化正在学习人数。
|
*/
|
function formatLearnUserCount(value?: number | null) {
|
const count = Number(value || 0)
|
if (!Number.isFinite(count)) {
|
return 0
|
}
|
return count
|
}
|
|
/**
|
* 归一化可选数字字段。
|
*/
|
function normalizeOptionalNumber(value: unknown) {
|
const numberValue = Number(value)
|
if (!Number.isFinite(numberValue)) {
|
return null
|
}
|
return numberValue
|
}
|
|
/**
|
* 将可选文本归一化为空字符串。
|
*/
|
function normalizeOptionalText(value: unknown) {
|
if (typeof value !== 'string') {
|
return ''
|
}
|
return value.trim()
|
}
|
</script>
|
|
<style>
|
page,
|
html {
|
background-color: #fff;
|
}
|
</style>
|
|
<style lang="scss" scoped>
|
.container {
|
width: 100vw;
|
height: 100vh;
|
display: flex;
|
flex-direction: column;
|
}
|
|
.custom-navbar {
|
width: 100%;
|
background-color: #FFFFFF;
|
position: relative;
|
z-index: 100;
|
}
|
|
.nav-content {
|
height: 88rpx;
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
padding: 0 32rpx;
|
}
|
|
.back-icon {
|
width: 40rpx;
|
height: 40rpx;
|
}
|
|
.nav-title {
|
font-size: 36rpx;
|
color: #333333;
|
font-weight: bold;
|
position: absolute;
|
left: 50%;
|
transform: translateX(-50%);
|
}
|
|
.nav-right-text {
|
font-size: 28rpx;
|
color: #333333;
|
}
|
|
.favorites-list {
|
flex: 1;
|
height: 0;
|
padding: 24rpx 32rpx;
|
box-sizing: border-box;
|
width: 100%;
|
}
|
|
.empty-box {
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
padding-top: 200rpx;
|
}
|
|
.empty-text {
|
font-size: 28rpx;
|
color: #999999;
|
}
|
|
.list-item-wrapper {
|
display: flex;
|
align-items: flex-start;
|
width: 100%;
|
}
|
|
.checkbox-col {
|
width: 60rpx;
|
display: flex;
|
justify-content: flex-start;
|
align-items: center;
|
flex-shrink: 0;
|
height: 200rpx;
|
margin-bottom: 24rpx;
|
}
|
|
.check-icon {
|
width: 40rpx;
|
height: 40rpx;
|
}
|
|
.swipe-wrap {
|
margin-bottom: 24rpx;
|
flex: 1;
|
min-width: 0;
|
transition: all 0.3s;
|
}
|
|
:deep(.u-swipe-action) {
|
background-color: transparent !important;
|
}
|
|
:deep(.u-swipe-action-item) {
|
background-color: transparent !important;
|
}
|
|
:deep(.u-swipe-del) {
|
background-color: transparent !important;
|
display: flex;
|
align-items: center;
|
height: 100%;
|
width: 192rpx !important;
|
height: 204rpx !important;
|
margin: unset !important;
|
background-color: #fff !important;
|
border-radius: unset !important;
|
}
|
|
:deep(.u-btn-text) {
|
display: none;
|
}
|
|
:deep(.u-swipe-del::after) {
|
content: '';
|
width: 120rpx;
|
height: 120rpx;
|
background-color: transparent;
|
display: flex;
|
justify-content: center;
|
align-items: center;
|
margin: 0 32rpx;
|
background-image: url('/static/image/course/delete.png');
|
background-size: 120rpx 120rpx;
|
background-repeat: no-repeat;
|
background-position: center;
|
}
|
|
.course-item-container {
|
width: 100%;
|
background-color: transparent;
|
}
|
|
.course-item {
|
width: 100%;
|
height: 200rpx;
|
background: linear-gradient(270deg, #F9F6EF 0%, #FFFFFF 100%);
|
padding: 24rpx;
|
box-sizing: border-box;
|
display: flex;
|
align-items: center;
|
border-radius: 24rpx;
|
}
|
|
.img-wrap {
|
position: relative;
|
width: 152rpx;
|
height: 152rpx;
|
border-radius: 16rpx;
|
margin-right: 24rpx;
|
flex-shrink: 0;
|
background-color: #BDBDBD;
|
overflow: visible;
|
}
|
|
.course-img {
|
width: 100%;
|
height: 100%;
|
border-radius: 16rpx;
|
}
|
|
.heart-icon {
|
position: absolute;
|
top: 12rpx;
|
right: 12rpx;
|
width: 32rpx;
|
height: 32rpx;
|
z-index: 10;
|
}
|
|
.course-info {
|
flex: 1;
|
height: 152rpx;
|
display: flex;
|
flex-direction: column;
|
justify-content: space-between;
|
min-width: 0;
|
}
|
|
.title-wrap {
|
display: flex;
|
align-items: center;
|
flex-wrap: wrap;
|
}
|
|
.tag-list {
|
display: flex;
|
align-items: center;
|
}
|
|
.tag {
|
font-size: 20rpx;
|
padding: 2rpx 10rpx;
|
border-radius: 8rpx;
|
margin-right: 12rpx;
|
color: #FFFFFF;
|
}
|
|
.red-tag {
|
background-color: #D32E29;
|
}
|
|
.course-title {
|
font-size: 32rpx;
|
color: #1A1A1A;
|
font-weight: bold;
|
flex: 1;
|
white-space: nowrap;
|
overflow: hidden;
|
text-overflow: ellipsis;
|
}
|
|
.course-bottom {
|
display: flex;
|
justify-content: space-between;
|
align-items: center;
|
}
|
|
.teacher-info {
|
display: flex;
|
align-items: center;
|
min-width: 0;
|
}
|
|
.teacher-avatar {
|
width: 40rpx;
|
height: 40rpx;
|
border-radius: 50%;
|
margin-right: 12rpx;
|
background-color: #f5f5f5;
|
}
|
|
.teacher-name {
|
font-size: 26rpx;
|
color: #86909C;
|
max-width: 160rpx;
|
white-space: nowrap;
|
overflow: hidden;
|
text-overflow: ellipsis;
|
}
|
|
.student-count {
|
display: flex;
|
align-items: center;
|
font-size: 24rpx;
|
color: #999999;
|
}
|
|
.user-icon {
|
margin-right: 8rpx;
|
}
|
</style>
|