Files
graduation-design/music-qianduan/src/views/Singer.vue
ikmkj f67c6f2262 feat(singer): 优化歌手搜索功能
- 修改了前端 Singer.vue 文件,增加了对 keyword 的判断,以显示不同的标题
- 更新了后端 SingerServiceImpl 文件,改进了搜索逻辑,支持多字段查询
2025-07-29 19:56:08 +08:00

524 lines
12 KiB
Vue

<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '../stores/user'
import { ElMessage } from 'element-plus'
import { Search } from '@element-plus/icons-vue'
import { getSingerList, getHotSinger, isSingerCollected, collectSinger, uncollectSinger } from '../api/singer'
import { getSingerTypeList } from '../api/singerType'
import { processImageUrl } from '../utils/imageUtils'
const router = useRouter()
const userStore = useUserStore()
// 歌手类型列表
const singerTypes = ref([{ id: null, name: '全部' }])
// 当前选中的歌手类型
const activeType = ref(null)
// 歌手列表
const singers = ref([])
// 分页参数
const currentPage = ref(1)
const pageSize = ref(20)
const total = ref(0)
// 加载状态
const loading = ref(false)
// 搜索关键词
const keyword = ref('')
// 热门歌手
const hotSingers = ref([])
const loadingHot = ref(false)
// 获取歌手类型列表
const fetchSingerTypes = async () => {
try {
const res = await getSingerTypeList()
if (res.code === 200) {
// 将获取到的类型添加到列表中
singerTypes.value = [
{ id: null, name: '全部' },
...res.data
]
} else {
ElMessage.error(res.message || '获取歌手类型失败')
}
} catch (error) {
console.error('获取歌手类型失败:', error)
ElMessage.error('获取歌手类型失败')
}
}
// 切换歌手类型
const changeType = (typeId) => {
activeType.value = typeId
currentPage.value = 1
fetchSingers()
}
// 获取歌手列表
const fetchSingers = async () => {
loading.value = true
try {
const res = await getSingerList(
currentPage.value,
pageSize.value,
keyword.value,
activeType.value
)
if (res.code === 200) {
singers.value = res.data.list || res.data.records || []
total.value = res.data.total || 0
// 处理图片URL
singers.value = singers.value.map(singer => ({
...singer,
avatar: processImageUrl(singer.avatar)
}))
updateSingerStatuses(singers.value)
} else {
ElMessage.error(res.message || '获取歌手列表失败')
}
} catch (error) {
console.error('获取歌手列表失败:', error)
ElMessage.error('获取歌手列表失败')
} finally {
loading.value = false
}
}
// 获取热门歌手
const fetchHotSingers = async () => {
loadingHot.value = true
try {
const res = await getHotSinger(8) // 获取8个热门歌手
if (res.code === 200) {
hotSingers.value = res.data || []
// 处理图片URL
hotSingers.value = hotSingers.value.map(singer => ({
...singer,
avatar: processImageUrl(singer.avatar)
}))
updateSingerStatuses(hotSingers.value)
} else {
ElMessage.error(res.message || '获取热门歌手失败')
}
} catch (error) {
console.error('获取热门歌手失败:', error)
ElMessage.error('获取热门歌手失败')
} finally {
loadingHot.value = false
}
}
// 处理搜索
const handleSearch = () => {
currentPage.value = 1
fetchSingers()
}
// 处理分页变化
const handlePageChange = (page) => {
currentPage.value = page
fetchSingers()
}
// 处理每页数量变化
const handleSizeChange = (size) => {
pageSize.value = size
currentPage.value = 1
fetchSingers()
}
// 查看歌手详情
const viewSingerDetail = (id) => {
router.push(`/singer/${id}`)
}
// 收藏/取消收藏歌手
const toggleCollect = async (singer, event) => {
event.stopPropagation()
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录')
router.push('/login')
return
}
try {
let res
if (singer.collected) {
res = await uncollectSinger(singer.id)
} else {
res = await collectSinger(singer.id)
}
if (res.code === 200) {
singer.collected = !singer.collected
ElMessage.success(singer.collected ? '收藏成功' : '已取消收藏')
} else {
ElMessage.error(res.message || '操作失败')
}
} catch (error) {
console.error('收藏操作失败:', error)
ElMessage.error('操作失败,请稍后重试')
}
}
// 更新歌手收藏状态
const updateSingerStatuses = async (list) => {
if (!userStore.isLoggedIn || !list || list.length === 0) {
return
}
const promises = list.map(singer => isSingerCollected(singer.id))
try {
const results = await Promise.all(promises)
results.forEach((res, index) => {
if (res.code === 200) {
list[index].collected = res.data
}
})
} catch (error) {
console.error('批量获取歌手收藏状态失败:', error)
}
}
// 格式化粉丝数
const formatFans = (count) => {
if (count < 10000) {
return count.toString()
} else if (count < 100000000) {
return (count / 10000).toFixed(1) + '万'
} else {
return (count / 100000000).toFixed(1) + '亿'
}
}
onMounted(() => {
fetchSingerTypes()
fetchSingers()
fetchHotSingers()
})
</script>
<template>
<div class="singer-container">
<div class="singer-header">
<h1>歌手</h1>
<div class="search-container">
<el-input
v-model="keyword"
placeholder="搜索歌手"
clearable
@keyup.enter="handleSearch"
>
<template #suffix>
<el-icon class="el-input__icon" @click="handleSearch">
<Search />
</el-icon>
</template>
</el-input>
</div>
</div>
<!-- 歌手类型选择 -->
<div class="singer-types">
<el-radio-group v-model="activeType" @change="changeType">
<el-radio-button
v-for="type in singerTypes"
:key="type.id"
:label="type.id"
>
{{ type.name }}
</el-radio-button>
</el-radio-group>
</div>
<!-- 热门歌手 -->
<div class="hot-singers" v-if="!keyword && activeType === null && currentPage === 1">
<h2>热门歌手</h2>
<div class="singer-list" v-loading="loadingHot">
<div
v-for="singer in hotSingers"
:key="singer.id"
class="singer-item"
@click="viewSingerDetail(singer.id)"
>
<div class="singer-avatar">
<img :src="singer.avatar" :alt="singer.name">
<div class="singer-overlay">
<el-button
circle
:icon="singer.collected ? 'StarFilled' : 'Star'"
:type="singer.collected ? 'warning' : 'primary'"
@click="(event) => toggleCollect(singer, event)"
></el-button>
</div>
</div>
<div class="singer-info">
<div class="singer-name">{{ singer.name }}</div>
<div class="singer-stats">
<span>{{ singer.musicCount || 0 }}首歌曲</span>
<span>{{ formatFans(singer.fansCount || 0) }}粉丝</span>
</div>
</div>
</div>
</div>
</div>
<!-- 歌手列表 -->
<div class="all-singers">
<h2 v-if="keyword">搜索结果</h2>
<h2 v-else-if="activeType === null && currentPage === 1">全部歌手</h2>
<div class="singer-list" v-loading="loading">
<div
v-for="singer in singers"
:key="singer.id"
class="singer-item"
@click="viewSingerDetail(singer.id)"
>
<div class="singer-avatar">
<img :src="singer.avatar" :alt="singer.name">
<div class="singer-overlay">
<el-button
circle
:icon="singer.collected ? 'StarFilled' : 'Star'"
:type="singer.collected ? 'warning' : 'primary'"
@click="(event) => toggleCollect(singer, event)"
></el-button>
</div>
</div>
<div class="singer-info">
<div class="singer-name">{{ singer.name }}</div>
<div class="singer-stats">
<span>{{ singer.musicCount || 0 }}首歌曲</span>
<span>{{ formatFans(singer.fansCount || 0) }}粉丝</span>
</div>
</div>
</div>
</div>
<!-- 空状态 -->
<el-empty
v-if="singers.length === 0 && !loading"
description="暂无歌手"
></el-empty>
<!-- 分页 -->
<div class="pagination" v-if="total > 0">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[20, 40, 60, 80]"
layout="total, sizes, prev, pager, next, jumper"
:total="total"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
</div>
</div>
</template>
<style scoped>
.singer-container {
padding: 20px 20px 80px 20px;
min-height: calc(100vh - 140px);
background-color: transparent;
border-radius: 15px;
}
.singer-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
padding: 20px;
background-color: rgba(255, 255, 255, 0.85);
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.singer-header h1 {
margin: 0;
font-size: 1.8rem;
color: #ff6b81;
font-weight: 600;
}
.search-container {
width: 300px;
}
.search-container :deep(.el-input__wrapper) {
border-radius: 20px;
}
.search-container .el-icon {
color: #ff6b81;
cursor: pointer;
transition: all 0.3s;
}
.search-container .el-icon:hover {
color: #ff4757;
transform: scale(1.1);
}
.hot-singers,
.all-singers {
margin-top: 30px;
}
.hot-singers h2,
.all-singers h2 {
margin-bottom: 20px;
font-size: 1.5rem;
color: #ff6b81;
}
.pagination {
margin-top: 30px;
display: flex;
justify-content: center;
}
.pagination :deep(.el-pagination.is-background .btn-next),
.pagination :deep(.el-pagination.is-background .btn-prev),
.pagination :deep(.el-pagination.is-background .el-pager li) {
background-color: #fff9f9;
border-radius: 50%;
margin: 0 5px;
}
.pagination :deep(.el-pagination.is-background .el-pager li:not(.is-disabled).is-active) {
background-color: #ffb6c1;
color: white;
}
.singer-types {
margin-bottom: 30px;
overflow-x: auto;
white-space: nowrap;
padding-bottom: 10px;
}
.singer-types :deep(.el-radio-button__inner) {
border-radius: 20px !important;
margin-right: 10px;
border: none;
background-color: #fff9f9;
color: #666;
}
.singer-types :deep(.el-radio-button__orig-radio:checked + .el-radio-button__inner) {
background-color: #ffb6c1;
color: white;
box-shadow: none;
}
.singer-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 25px;
}
.singer-item {
background-color: #fff9f9;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 12px rgba(255, 107, 129, 0.1);
transition: all 0.3s;
cursor: pointer;
}
.singer-item:hover {
transform: translateY(-8px);
box-shadow: 0 8px 20px rgba(255, 107, 129, 0.2);
}
.singer-avatar {
position: relative;
width: 100%;
padding-top: 100%;
overflow: hidden;
}
.singer-avatar img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s;
}
.singer-item:hover .singer-avatar img {
transform: scale(1.1);
}
.singer-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(to bottom, rgba(0,0,0,0.1), rgba(255, 182, 193, 0.7));
opacity: 0;
transition: opacity 0.3s;
display: flex;
align-items: center;
justify-content: center;
}
.singer-item:hover .singer-overlay {
opacity: 1;
}
.singer-overlay .el-button {
background-color: rgba(255, 255, 255, 0.8);
color: #ff6b81;
transition: all 0.3s;
}
.singer-overlay .el-button:hover {
background-color: white;
transform: scale(1.1);
}
.singer-info {
padding: 15px;
}
.singer-name {
font-weight: 600;
margin-bottom: 8px;
text-align: center;
color: #333;
}
.singer-stats {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 10px;
font-size: 0.8rem;
color: #666;
}
.singer-stats span {
white-space: nowrap;
}
</style>