feat: 初始化音乐项目后台管理系统- 新增后台管理相关页面和功能组件

- 实现管理员评论管理、数据统计等功能
- 添加后台布局和路由管理
-配置数据库和Redis连接
- 实现文件上传和访问路径配置
- 添加Sa-Token安全配置
This commit is contained in:
ikmkj
2025-04-26 19:32:45 +08:00
commit b275ffab5a
185 changed files with 32763 additions and 0 deletions

80
music-qianduan/.gitignore vendored Normal file
View File

@@ -0,0 +1,80 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
### Example user template template
### Example user template
# IntelliJ project files
.log
*.gz
.gz
*/.gz
*/.log
target
*.iml
out
gen
*/.vscode
.vscode
#忽略所有.svn目录
.svn/
#忽略所有target目录
target/
#忽略所有.idea目录及其内容
.idea/
.idea/*
#忽略所有.ipr文件
*.ipr
#忽略所有.iws文件
*.iws
#忽略所有.gradle目录
.gradle/
#忽略所有.settings目录
.settings/
#忽略所有.classpath文件
.classpath
#忽略所有.project文件
.project
#忽略所有.pydevproject文件
.pydevproject
#忽略所有.DS_Store文件
#忽略所有.class文件
*.class
#忽略所有.log文件
#忽略所有.tmp文件
*.tmp
#忽略所有.swp文件
*.swp
node_modules/
/dist/
.git
# Editor directories and files
dist/
npm-debug.log
.vscode/
README.md

13
music-qianduan/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Vue</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

2075
music-qianduan/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
{
"name": "music-qianduan",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"@stomp/stompjs": "^7.1.1",
"axios": "^1.8.4",
"echarts": "^5.6.0",
"element-plus": "^2.9.8",
"pinia": "^3.0.2",
"sockjs-client": "^1.6.1",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.2",
"vite": "^6.3.1"
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,66 @@
<script setup>
import { onMounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import MainLayout from './layouts/MainLayout.vue'
import { useUserStore } from './stores/user'
const route = useRoute()
const userStore = useUserStore()
// 判断是否是后台管理页面
const isAdminPage = computed(() => {
return route.path.startsWith('/admin')
})
onMounted(() => {
// 在应用启动时获取用户信息
const token = localStorage.getItem('token')
if (token) {
console.log('应用启动时获取用户信息token:', token)
userStore.token = token // 确保在获取用户信息前设置token
userStore.fetchCurrentUser()
} else {
console.log('未找到token不获取用户信息')
}
})
</script>
<template>
<!-- 根据路由选择不同的布局 -->
<MainLayout v-if="!isAdminPage" />
<router-view v-else />
</template>
<style>
/* 全局样式 */
html, body {
margin: 0;
padding: 0;
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif;
width: 100%;
height: 100%;
overflow: hidden; /* 禁止整个页面滚动 */
}
#app {
width: 100%;
height: 100%;
overflow: hidden; /* 禁止整个页面滚动 */
position: fixed; /* 固定应用容器 */
top: 0;
left: 0;
right: 0;
bottom: 0;
}
/* 图片预览相关样式 */
.el-image-viewer__wrapper {
z-index: 2050 !important; /* 确保预览层级足够高 */
}
.el-image-viewer__img {
max-width: 80% !important;
max-height: 80% !important;
object-fit: contain !important;
}
</style>

View File

@@ -0,0 +1,43 @@
import api from '../index'
/**
* 分页获取评论列表
* @param {number} page 页码
* @param {number} size 每页大小
* @param {string} keyword 关键字
* @param {number} targetType 目标类型1-音乐2-歌单3-歌手
* @param {number} status 状态0-禁用1-正常
* @returns {Promise<any>}
*/
export function getCommentList(page = 1, size = 10, keyword = '', targetType = null, status = null) {
return api.get('/admin/comment/list', {
params: {
page,
size,
keyword,
targetType,
status
}
})
}
/**
* 更新评论状态
* @param {number} id 评论ID
* @param {number} status 状态0-禁用1-正常
* @returns {Promise<any>}
*/
export function updateCommentStatus(id, status) {
return api.put(`/admin/comment/${id}/status`, null, {
params: { status }
})
}
/**
* 删除评论
* @param {number} id 评论ID
* @returns {Promise<any>}
*/
export function deleteComment(id) {
return api.delete(`/admin/comment/${id}`)
}

View File

@@ -0,0 +1,9 @@
import api from '../index'
/**
* 获取控制台统计数据
* @returns {Promise<any>}
*/
export function getDashboardStats() {
return api.get('/admin/dashboard/stats')
}

View File

@@ -0,0 +1,106 @@
import api from '../index'
/**
* 音乐类型相关接口
*/
// 获取音乐类型列表
export function getMusicTypeList() {
return api.get('/type/music')
}
// 分页获取音乐类型列表
export function getMusicTypePage(page = 1, size = 7, keyword = '') {
return api.get('/type/music/page', {
params: {
page,
size,
keyword
}
})
}
// 添加音乐类型
export function addMusicType(data) {
return api.post('/type/music/add', data)
}
// 更新音乐类型
export function updateMusicType(id, data) {
return api.put(`/type/music/${id}`, data)
}
// 删除音乐类型
export function deleteMusicType(id) {
return api.delete(`/type/music/${id}`)
}
/**
* 歌手类型相关接口
*/
// 获取歌手类型列表
export function getSingerTypeList() {
return api.get('/type/singer')
}
// 分页获取歌手类型列表
export function getSingerTypePage(page = 1, size = 7, keyword = '') {
return api.get('/type/singer/page', {
params: {
page,
size,
keyword
}
})
}
// 添加歌手类型
export function addSingerType(data) {
return api.post('/type/singer/add', data)
}
// 更新歌手类型
export function updateSingerType(id, data) {
return api.put(`/type/singer/${id}`, data)
}
// 删除歌手类型
export function deleteSingerType(id) {
return api.delete(`/type/singer/${id}`)
}
/**
* 歌单类型相关接口
*/
// 获取歌单类型列表
export function getPlaylistTypeList() {
return api.get('/type/playlist')
}
// 分页获取歌单类型列表
export function getPlaylistTypePage(page = 1, size = 7, keyword = '') {
return api.get('/type/playlist/page', {
params: {
page,
size,
keyword
}
})
}
// 添加歌单类型
export function addPlaylistType(data) {
return api.post('/type/playlist/add', data)
}
// 更新歌单类型
export function updatePlaylistType(id, data) {
return api.put(`/type/playlist/${id}`, data)
}
// 删除歌单类型
export function deletePlaylistType(id) {
return api.delete(`/type/playlist/${id}`)
}

View File

@@ -0,0 +1,69 @@
import api from '../index'
/**
* 分页获取用户列表
* @param {number} page 页码
* @param {number} size 每页大小
* @param {string} keyword 搜索关键字
* @returns {Promise<any>}
*/
export function getUserList(page = 1, size = 10, keyword = '') {
return api.get('/user/list', {
params: {
page,
size,
keyword
}
})
}
/**
* 根据ID获取用户
* @param {number} id 用户ID
* @returns {Promise<any>}
*/
export function getUserById(id) {
return api.get(`/user/${id}`)
}
/**
* 删除用户
* @param {number} id 用户ID
* @returns {Promise<any>}
*/
export function deleteUser(id) {
return api.delete(`/user/${id}`)
}
/**
* 更新用户状态
* @param {number} id 用户ID
* @param {number} status 状态0-禁用1-启用
* @returns {Promise<any>}
*/
export function updateUserStatus(id, status) {
return api.put(`/user/${id}/status`, null, {
params: {
status
}
})
}
/**
* 更新用户信息
* @param {Object} data 用户信息
* @returns {Promise<any>}
*/
export function updateUser(data) {
return api.put('/user/update', data)
}
/**
* 管理员更新用户信息
* @param {number} id 用户ID
* @param {Object} data 用户信息
* @returns {Promise<any>}
*/
export function adminUpdateUser(id, data) {
return api.put(`/user/${id}`, data)
}

View File

@@ -0,0 +1,162 @@
import api from './index'
import { processImageUrl } from '../utils/imageUtils'
/**
* 获取轮播图列表
* @returns {Promise<any>}
*/
export function getBannerList() {
return api.get('/banner/list').then(res => {
if (res.code === 200 && res.data) {
// 处理数据格式,适配后端返回的数据结构
// 如果有 data.list则使用 data.list 作为记录列表
const banners = res.data.list || res.data;
// 处理图片URL
const processedBanners = Array.isArray(banners) ? banners.map(banner => ({
...banner,
imageUrl: processImageUrl(banner.imageUrl)
})) : [];
// 替换原来的数据
if (res.data.list) {
res.data.list = processedBanners;
} else {
res.data = processedBanners;
}
}
return res;
});
}
/**
* 分页获取轮播图列表
* @param {number} page 页码
* @param {number} size 每页大小
* @returns {Promise<any>}
*/
export function getBannerPage(page = 1, size = 10) {
return api.get('/banner/page', {
params: {
page,
size
}
}).then(res => {
if (res.code === 200 && res.data) {
// 处理数据格式,适配后端返回的数据结构
// 如果有 data.list则使用 data.list 作为记录列表
const records = res.data.list || res.data.records || [];
const total = res.data.total || 0;
// 处理图片URL
const processedRecords = records.map(banner => ({
...banner,
imageUrl: processImageUrl(banner.imageUrl)
}));
// 构造标准的分页响应格式
res.data = {
records: processedRecords,
total: total,
current: res.data.page || page,
size: res.data.size || size,
pages: res.data.totalPages || Math.ceil(total / size)
};
}
return res;
});
}
/**
* 根据ID获取轮播图
* @param {number} id 轮播图ID
* @returns {Promise<any>}
*/
export function getBannerById(id) {
return api.get(`/banner/${id}`).then(res => {
if (res.code === 200 && res.data) {
// 处理图片URL
res.data.imageUrl = processImageUrl(res.data.imageUrl)
}
return res
})
}
/**
* 添加轮播图
* @param {Object} data 轮播图数据
* @param {File} image 图片文件
* @returns {Promise<any>}
*/
export function addBanner(data, image) {
const formData = new FormData()
formData.append('banner', new Blob([JSON.stringify(data)], { type: 'application/json' }))
formData.append('image', image)
return api.post('/banner/add', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
}
/**
* 更新轮播图
* @param {number} id 轮播图ID
* @param {Object} data 轮播图数据
* @param {File} image 图片文件
* @returns {Promise<any>}
*/
export function updateBanner(id, data, image) {
const formData = new FormData()
formData.append('banner', new Blob([JSON.stringify(data)], { type: 'application/json' }))
if (image) {
formData.append('image', image)
}
return api.put(`/banner/${id}`, formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
}
/**
* 删除轮播图
* @param {number} id 轮播图ID
* @returns {Promise<any>}
*/
export function deleteBanner(id) {
return api.delete(`/banner/${id}`)
}
/**
* 更新轮播图状态
* @param {number} id 轮播图ID
* @param {number} status 状态0-禁用1-启用
* @returns {Promise<any>}
*/
export function updateBannerStatus(id, status) {
return api.put(`/banner/${id}/status`, null, {
params: {
status
}
})
}
/**
* 更新轮播图排序
* @param {number} id 轮播图ID
* @param {number} orderNum 排序号
* @returns {Promise<any>}
*/
export function updateBannerOrder(id, orderNum) {
return api.put(`/banner/${id}/order`, null, {
params: {
orderNum
}
})
}

View File

@@ -0,0 +1,57 @@
import api from './index'
/**
* 分页获取评论列表
* @param {number} targetId 目标ID
* @param {number} targetType 目标类型1-音乐2-歌单3-歌手
* @param {number} page 页码
* @param {number} size 每页大小
* @returns {Promise<any>}
*/
export function getCommentList(targetId, targetType, page = 1, size = 10) {
return api.get('/comment/list', {
params: {
targetId,
targetType,
page,
size
}
})
}
/**
* 添加评论
* @param {Object} data 评论数据
* @param {number} data.targetId 目标ID
* @param {number} data.targetType 目标类型1-音乐2-歌单3-歌手
* @param {string} data.content 评论内容
* @param {number} [data.parentId] 父评论ID可选用于回复评论
* @returns {Promise<any>}
*/
export function addComment(data) {
return api.post('/comment/add', data)
}
/**
* 删除评论
* @param {number} id 评论ID
* @returns {Promise<any>}
*/
export function deleteComment(id) {
return api.delete(`/comment/${id}`)
}
/**
* 获取用户的评论列表
* @param {number} page 页码
* @param {number} size 每页大小
* @returns {Promise<any>}
*/
export function getUserCommentList(page = 1, size = 10) {
return api.get('/comment/user', {
params: {
page,
size
}
})
}

View File

@@ -0,0 +1,123 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '../router'
// 创建axios实例
const api = axios.create({
baseURL: '/api', // 基础URL
timeout: 10000 // 请求超时时间
})
// 请求拦截器
api.interceptors.request.use(
config => {
// 打印请求URL和方法
console.log(`发送请求: ${config.method.toUpperCase()} ${config.url}`)
// 从localStorage获取token相关信息
const token = localStorage.getItem('token')
const tokenName = localStorage.getItem('tokenName') || 'satoken'
const tokenPrefix = localStorage.getItem('tokenPrefix') || ''
if (token) {
// 将token添加到请求头
// Sa-Token默认从请求头的tokenName字段中读取token
config.headers[tokenName] = tokenPrefix + token
// 添加到Authorization头中以防万一
config.headers['Authorization'] = tokenPrefix + token
// 打印请求头信息,便于调试
console.log('请求头信息:', {
url: config.url,
method: config.method,
tokenName,
tokenValue: tokenPrefix + token,
headers: config.headers
})
} else {
console.warn('未找到token请求可能会被拒绝')
}
return config
},
error => {
console.error('请求错误:', error)
return Promise.reject(error)
}
)
// 响应拦截器
api.interceptors.response.use(
response => {
const res = response.data
const url = response.config.url
const method = response.config.method
// 打印响应信息
console.log(`收到响应: ${method.toUpperCase()} ${url}`, res)
// 如果返回的状态码不是200说明接口请求失败
if (res.code !== 200) {
// 根据不同的错误码进行不同的处理
if (res.code === 401) {
// 未登录或token过期
console.warn('响应拦截器: 登录已过期,请重新登录', {
url,
method,
response: res,
headers: response.config.headers,
token: localStorage.getItem('token'),
tokenName: localStorage.getItem('tokenName'),
tokenPrefix: localStorage.getItem('tokenPrefix')
})
ElMessage.error('登录已过期,请重新登录')
// 清除本地存储的所有信息
localStorage.removeItem('token')
localStorage.removeItem('tokenName')
localStorage.removeItem('tokenPrefix')
localStorage.removeItem('user')
localStorage.removeItem('isAdmin')
// 跳转到登录页面
router.push('/login?redirect=' + encodeURIComponent(window.location.pathname))
} else if (res.code === 403) {
// 无权限
ElMessage.error('无权限访问')
} else {
// 其他错误
ElMessage.error(res.msg || '未知错误')
}
return Promise.reject(new Error(res.msg || '未知错误'))
} else {
return res
}
},
error => {
console.error('响应错误:', error)
// 尝试获取请求配置
const config = error.config || {}
console.error('错误请求配置:', {
url: config.url,
method: config.method,
headers: config.headers,
token: localStorage.getItem('token'),
tokenName: localStorage.getItem('tokenName'),
tokenPrefix: localStorage.getItem('tokenPrefix')
})
// 处理网络错误
if (error.message && error.message.includes('timeout')) {
ElMessage.error('请求超时,请稍后再试')
} else if (error.message && error.message.includes('Network Error')) {
ElMessage.error('网络错误,请检查网络连接')
} else {
ElMessage.error(error.message || '未知错误')
}
return Promise.reject(error)
}
)
export default api

View File

@@ -0,0 +1,242 @@
import api from './index'
/**
* 获取音乐列表
* @param {number} page 页码
* @param {number} size 每页大小
* @param {string} keyword 关键字
* @param {number} typeId 类型ID
* @returns {Promise<any>}
*/
export function getMusicList(page = 1, size = 10, keyword = '', typeId = null) {
return api.get('/music/list', {
params: {
page,
size,
keyword,
typeId
}
}).then(res => {
if (res.code === 200 && res.data) {
// 处理数据结构
const data = res.data
if (data.list) {
// 处理歌手和类型信息
data.list.forEach(item => {
// 处理音乐URL
if (item.url) {
item.url = processMusicUrl(item.url)
}
// 处理封面URL
if (item.cover) {
item.cover = processMusicUrl(item.cover)
}
})
}
}
return res
})
}
/**
* 根据ID获取音乐
* @param {number} id 音乐ID
* @returns {Promise<any>}
*/
export function getMusicById(id) {
return api.get(`/music/${id}`).then(res => {
if (res.code === 200 && res.data) {
// 处理音乐URL
if (res.data.url) {
res.data.url = processMusicUrl(res.data.url)
}
// 处理封面URL
if (res.data.cover) {
res.data.cover = processMusicUrl(res.data.cover)
}
}
return res
})
}
/**
* 获取热门音乐
* @param {number} limit 限制数量
* @returns {Promise<any>}
*/
export function getHotMusic(limit = 10) {
return api.get('/music/hot', {
params: { limit }
})
}
/**
* 获取最新音乐
* @param {number} limit 限制数量
* @returns {Promise<any>}
*/
export function getLatestMusic(limit = 10) {
return api.get('/music/latest', {
params: { limit }
})
}
/**
* 根据歌手ID获取音乐列表
* @param {number} singerId 歌手ID
* @param {number} page 页码
* @param {number} size 每页大小
* @returns {Promise<any>}
*/
export function getMusicBySinger(singerId, page = 1, size = 10) {
return api.get(`/music/singer/${singerId}`, {
params: {
page,
size
}
})
}
/**
* 播放音乐
* @param {number} id 音乐ID
* @returns {Promise<any>}
*/
export function playMusic(id) {
return api.post(`/music/${id}/play`)
}
/**
* 收藏/取消收藏音乐
* @param {number} id 音乐ID
* @returns {Promise<any>}
*/
export function toggleCollectMusic(id) {
return api.post(`/music/${id}/collect`)
}
/**
* 获取收藏的音乐列表
* @param {number} page 页码
* @param {number} size 每页大小
* @returns {Promise<any>}
*/
export function getCollectedMusic(page = 1, size = 10) {
return api.get('/music/collect', {
params: {
page,
size
}
})
}
/**
* 搜索音乐
* @param {string} keyword 搜索关键词
* @param {number} page 页码
* @param {number} size 每页大小
* @returns {Promise<any>}
*/
export function searchMusic(keyword, page = 1, size = 10) {
return api.get('/music/search', {
params: {
keyword,
page,
size
}
})
}
/**
* 添加音乐(管理员)
* @param {Object} data 音乐数据
* @param {File} file 音乐文件
* @param {File} cover 封面文件
* @returns {Promise<any>}
*/
export function addMusic(data, file, cover) {
const formData = new FormData()
formData.append('music', new Blob([JSON.stringify(data)], { type: 'application/json' }))
if (file) {
formData.append('file', file)
}
if (cover) {
formData.append('cover', cover)
}
return api.post('/music/add', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
}
/**
* 更新音乐(管理员)
* @param {number} id 音乐ID
* @param {Object} data 音乐数据
* @param {File} file 音乐文件
* @param {File} cover 封面文件
* @param {File} lyric 歌词文件
* @returns {Promise<any>}
*/
export function updateMusic(id, data, file, cover, lyric) {
const formData = new FormData()
formData.append('music', new Blob([JSON.stringify(data)], { type: 'application/json' }))
if (file) {
formData.append('file', file)
}
if (cover) {
formData.append('cover', cover)
}
if (lyric) {
formData.append('lyric', lyric)
}
return api.put(`/music/${id}`, formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
}
/**
* 删除音乐(管理员)
* @param {number} id 音乐ID
* @returns {Promise<any>}
*/
export function deleteMusic(id) {
return api.delete(`/music/${id}`)
}
/**
* 处理音乐URL
* @param {string} url 音乐URL
* @returns {string} 处理后的URL
*/
export function processMusicUrl(url) {
if (!url) {
return '';
}
// 如果是完整URL直接返回
if (url.startsWith('http://') || url.startsWith('https://')) {
return url;
}
// 如果是相对路径添加API基础URL
// 使用后端服务器地址
const baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8085';
// 确保路径以/开头
if (!url.startsWith('/')) {
url = '/' + url;
}
return `${baseUrl}${url}`;
}

View File

@@ -0,0 +1,63 @@
import api from './index'
/**
* 获取音乐类型列表
* @returns {Promise<any>}
*/
export function getMusicTypeList() {
return api.get('/type/music')
}
/**
* 分页获取音乐类型列表
* @param {number} page 页码
* @param {number} size 每页大小
* @param {string} keyword 关键字
* @returns {Promise<any>}
*/
export function getMusicTypePage(page = 1, size = 10, keyword = '') {
return api.get('/type/music/page', {
params: {
page,
size,
keyword
}
})
}
/**
* 根据ID获取音乐类型
* @param {number} id 音乐类型ID
* @returns {Promise<any>}
*/
export function getMusicTypeById(id) {
return api.get(`/type/music/${id}`)
}
/**
* 添加音乐类型(管理员)
* @param {Object} data 音乐类型数据
* @returns {Promise<any>}
*/
export function addMusicType(data) {
return api.post('/type/music/add', data)
}
/**
* 更新音乐类型(管理员)
* @param {number} id 音乐类型ID
* @param {Object} data 音乐类型数据
* @returns {Promise<any>}
*/
export function updateMusicType(id, data) {
return api.put(`/type/music/${id}`, data)
}
/**
* 删除音乐类型(管理员)
* @param {number} id 音乐类型ID
* @returns {Promise<any>}
*/
export function deleteMusicType(id) {
return api.delete(`/type/music/${id}`)
}

View File

@@ -0,0 +1,362 @@
import api from './index'
import { processMusicUrl } from './music'
import { processImageUrl } from '../utils/imageUtils'
/**
* 获取歌单列表
* @param {number} page 页码
* @param {number} size 每页大小
* @param {string} keyword 关键字
* @param {number} typeId 类型ID
* @returns {Promise<any>}
*/
export function getPlaylistList(page = 1, size = 10, keyword = '', typeId = null) {
return api.get('/playlist/list', {
params: {
page,
size,
keyword,
typeId
}
}).then(res => {
if (res.code === 200 && res.data) {
// 处理数据结构
const records = res.data.records || res.data.list || []
// 处理封面URL
const processedRecords = records.map(item => ({
...item,
cover: processImageUrl(item.cover)
}))
if (res.data.records) {
res.data.records = processedRecords
} else if (res.data.list) {
res.data.list = processedRecords
} else {
res.data = processedRecords
}
}
return res
})
}
/**
* 根据ID获取歌单
* @param {number} id 歌单ID
* @returns {Promise<any>}
*/
export function getPlaylistById(id) {
return api.get(`/playlist/${id}`).then(res => {
if (res.code === 200 && res.data) {
// 处理封面URL
res.data.cover = processImageUrl(res.data.cover)
// 处理音乐列表
if (res.data.musics && res.data.musics.length > 0) {
res.data.musics = res.data.musics.map(music => ({
...music,
cover: processMusicUrl(music.cover),
url: processMusicUrl(music.url)
}))
}
}
return res
})
}
/**
* 获取热门歌单
* @param {number} limit 限制数量
* @returns {Promise<any>}
*/
export function getHotPlaylist(limit = 10) {
return api.get('/playlist/hot', {
params: { limit }
}).then(res => {
if (res.code === 200 && res.data) {
// 处理封面URL
res.data = res.data.map(item => ({
...item,
cover: processImageUrl(item.cover)
}))
}
return res
})
}
/**
* 获取最新歌单
* @param {number} limit 限制数量
* @returns {Promise<any>}
*/
export function getLatestPlaylist(limit = 10) {
return api.get('/playlist/latest', {
params: { limit }
}).then(res => {
if (res.code === 200 && res.data) {
// 处理封面URL
res.data = res.data.map(item => ({
...item,
cover: processImageUrl(item.cover)
}))
}
return res
})
}
/**
* 获取官方歌单
* @param {number} page 页码
* @param {number} size 每页大小
* @returns {Promise<any>}
*/
export function getOfficialPlaylist(page = 1, size = 10) {
return api.get('/playlist/official', {
params: {
page,
size
}
}).then(res => {
if (res.code === 200 && res.data) {
// 处理数据结构
const records = res.data.records || res.data.list || []
// 处理封面URL
const processedRecords = records.map(item => ({
...item,
cover: processImageUrl(item.cover)
}))
if (res.data.records) {
res.data.records = processedRecords
} else if (res.data.list) {
res.data.list = processedRecords
} else {
res.data = processedRecords
}
}
return res
})
}
/**
* 获取用户创建的歌单
* @param {number} page 页码
* @param {number} size 每页大小
* @returns {Promise<any>}
*/
export function getUserPlaylist(page = 1, size = 10) {
return api.get('/playlist/user', {
params: {
page,
size
}
}).then(res => {
if (res.code === 200 && res.data) {
// 处理数据结构
const records = res.data.records || res.data.list || []
// 处理封面URL
const processedRecords = records.map(item => ({
...item,
cover: processImageUrl(item.cover)
}))
if (res.data.records) {
res.data.records = processedRecords
} else if (res.data.list) {
res.data.list = processedRecords
} else {
res.data = processedRecords
}
}
return res
})
}
/**
* 获取用户收藏的歌单
* @param {number} page 页码
* @param {number} size 每页大小
* @returns {Promise<any>}
*/
export function getCollectedPlaylist(page = 1, size = 10) {
return api.get('/playlist/collect', {
params: {
page,
size
}
}).then(res => {
if (res.code === 200 && res.data) {
// 处理数据结构
const records = res.data.records || res.data.list || []
// 处理封面URL
const processedRecords = records.map(item => ({
...item,
cover: processImageUrl(item.cover)
}))
if (res.data.records) {
res.data.records = processedRecords
} else if (res.data.list) {
res.data.list = processedRecords
} else {
res.data = processedRecords
}
}
return res
})
}
/**
* 添加歌单
* @param {Object} data 歌单数据
* @param {File} cover 封面文件
* @returns {Promise<any>}
*/
export function addPlaylist(data, cover) {
// 确保数据中包含 type 对象
const playlistData = {
...data,
type: data.typeId ? { id: data.typeId } : null,
// 确保 isOfficial 字段存在且为数字
isOfficial: data.isOfficial !== undefined ? Number(data.isOfficial) : 0
}
console.log('添加歌单数据:', playlistData)
const formData = new FormData()
formData.append('playlist', new Blob([JSON.stringify(playlistData)], { type: 'application/json' }))
if (cover) {
formData.append('cover', cover)
}
return api.post('/playlist/add', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
}
/**
* 更新歌单
* @param {number} id 歌单ID
* @param {Object} data 歌单数据
* @param {File} cover 封面文件
* @returns {Promise<any>}
*/
export function updatePlaylist(id, data, cover) {
console.log('原始数据:', data)
console.log('isOfficial类型:', typeof data.isOfficial, 'isOfficial值:', data.isOfficial)
// 确保数据中包含 type 对象
const playlistData = {
...data,
type: data.typeId ? { id: data.typeId } : null,
// 显式设置 isOfficial 字段,确保它是数字类型
isOfficial: data.isOfficial === 1 ? 1 : 0
}
console.log('处理后的数据:', playlistData)
console.log('处理后 isOfficial类型:', typeof playlistData.isOfficial, 'isOfficial值:', playlistData.isOfficial)
const formData = new FormData()
formData.append('playlist', new Blob([JSON.stringify(playlistData)], { type: 'application/json' }))
if (cover) {
formData.append('cover', cover)
}
return api.put(`/playlist/${id}`, formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
}
/**
* 删除歌单
* @param {number} id 歌单ID
* @returns {Promise<any>}
*/
export function deletePlaylist(id) {
return api.delete(`/playlist/${id}`)
}
/**
* 收藏/取消收藏歌单
* @param {number} id 歌单ID
* @returns {Promise<any>}
*/
export function toggleCollectPlaylist(id) {
return api.post(`/playlist/${id}/collect`)
}
/**
* 向歌单添加音乐
* @param {number} id 歌单ID
* @param {number} musicId 音乐ID
* @returns {Promise<any>}
*/
export function addMusicToPlaylist(id, musicId) {
return api.post(`/playlist/${id}/music/${musicId}`)
}
/**
* 从歌单移除音乐
* @param {number} id 歌单ID
* @param {number} musicId 音乐ID
* @returns {Promise<any>}
*/
export function removeMusicFromPlaylist(id, musicId) {
return api.delete(`/playlist/${id}/music/${musicId}`)
}
/**
* 播放歌单
* @param {number} id 歌单ID
* @returns {Promise<any>}
*/
export function playPlaylist(id) {
return api.post(`/playlist/${id}/play`)
}
/**
* 搜索歌单
* @param {string} keyword 搜索关键词
* @param {number} page 页码
* @param {number} size 每页大小
* @returns {Promise<any>}
*/
export function searchPlaylist(keyword, page = 1, size = 10) {
return api.get('/playlist/search', {
params: {
keyword,
page,
size
}
}).then(res => {
if (res.code === 200 && res.data) {
// 处理数据结构
const records = res.data.records || res.data.list || []
// 处理封面URL
const processedRecords = records.map(item => ({
...item,
cover: processImageUrl(item.cover)
}))
if (res.data.records) {
res.data.records = processedRecords
} else if (res.data.list) {
res.data.list = processedRecords
} else {
res.data = processedRecords
}
}
return res
})
}

View File

@@ -0,0 +1,63 @@
import api from './index'
/**
* 获取歌单类型列表
* @returns {Promise<any>}
*/
export function getPlaylistTypeList() {
return api.get('/type/playlist')
}
/**
* 分页获取歌单类型列表
* @param {number} page 页码
* @param {number} size 每页大小
* @param {string} keyword 关键字
* @returns {Promise<any>}
*/
export function getPlaylistTypePage(page = 1, size = 10, keyword = '') {
return api.get('/type/playlist/page', {
params: {
page,
size,
keyword
}
})
}
/**
* 根据ID获取歌单类型
* @param {number} id 歌单类型ID
* @returns {Promise<any>}
*/
export function getPlaylistTypeById(id) {
return api.get(`/type/playlist/${id}`)
}
/**
* 添加歌单类型(管理员)
* @param {Object} data 歌单类型数据
* @returns {Promise<any>}
*/
export function addPlaylistType(data) {
return api.post('/type/playlist/add', data)
}
/**
* 更新歌单类型(管理员)
* @param {number} id 歌单类型ID
* @param {Object} data 歌单类型数据
* @returns {Promise<any>}
*/
export function updatePlaylistType(id, data) {
return api.put(`/type/playlist/${id}`, data)
}
/**
* 删除歌单类型(管理员)
* @param {number} id 歌单类型ID
* @returns {Promise<any>}
*/
export function deletePlaylistType(id) {
return api.delete(`/type/playlist/${id}`)
}

View File

@@ -0,0 +1,244 @@
import api from './index'
import { processImageUrl } from '../utils/imageUtils'
/**
* 获取歌手列表
* @param {number} page 页码
* @param {number} size 每页大小
* @param {string} keyword 关键字
* @param {number} typeId 类型ID
* @returns {Promise<any>}
*/
export function getSingerList(page = 1, size = 10, keyword = '', typeId = null) {
return api.get('/singer/list', {
params: {
page,
size,
keyword,
typeId
}
}).then(res => {
if (res.code === 200 && res.data && res.data.list) {
// 处理歌手头像URL
res.data.list.forEach(singer => {
if (singer.avatar) {
singer.avatar = processImageUrl(singer.avatar)
}
})
}
return res
})
}
/**
* 根据ID获取歌手
* @param {number} id 歌手ID
* @returns {Promise<any>}
*/
export function getSingerById(id) {
return api.get(`/singer/${id}`).then(res => {
if (res.code === 200 && res.data) {
// 处理歌手头像URL
if (res.data.avatar) {
res.data.avatar = processImageUrl(res.data.avatar)
}
}
return res
})
}
/**
* 获取最新歌手
* @param {number} limit 限制数量
* @returns {Promise<any>}
*/
export function getLatestSinger(limit = 10) {
return api.get('/singer/latest', {
params: { limit }
})
}
/**
* 添加歌手(管理员)
* @param {Object} data 歌手数据
* @param {File} avatar 头像文件
* @returns {Promise<any>}
*/
export function addSinger(data, avatar) {
const formData = new FormData()
formData.append('singer', new Blob([JSON.stringify(data)], { type: 'application/json' }))
if (avatar) {
formData.append('avatar', avatar)
}
return api.post('/singer/add', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
}
/**
* 更新歌手(管理员)
* @param {number} id 歌手ID
* @param {Object} data 歌手数据
* @param {File} avatar 头像文件
* @returns {Promise<any>}
*/
export function updateSinger(id, data, avatar) {
const formData = new FormData()
formData.append('singer', new Blob([JSON.stringify(data)], { type: 'application/json' }))
if (avatar) {
formData.append('avatar', avatar)
}
return api.put(`/singer/${id}`, formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
}
/**
* 删除歌手(管理员)
* @param {number} id 歌手ID
* @returns {Promise<any>}
*/
export function deleteSinger(id) {
return api.delete(`/singer/${id}`)
}
/**
* 收藏歌手
* @param {number} id 歌手ID
* @returns {Promise<any>}
*/
export function collectSinger(id) {
return api.post(`/singer/${id}/collect`)
}
/**
* 取消收藏歌手
* @param {number} id 歌手ID
* @returns {Promise<any>}
*/
export function uncollectSinger(id) {
return api.delete(`/singer/${id}/collect`)
}
/**
* 获取收藏的歌手列表
* @param {number} page 页码
* @param {number} size 每页大小
* @returns {Promise<any>}
*/
export function getCollectedSinger(page = 1, size = 10) {
return api.get('/singer/collect', {
params: {
page,
size
}
})
}
/**
* 搜索歌手
* @param {string} keyword 搜索关键词
* @param {number} page 页码
* @param {number} size 每页大小
* @returns {Promise<any>}
*/
export function searchSinger(keyword, page = 1, size = 10) {
return api.get('/singer/search', {
params: {
keyword,
page,
size
}
}).then(res => {
if (res.code === 200 && res.data) {
// 处理数据结构
const records = res.data.records || res.data.list || []
// 处理头像URL
const processedRecords = records.map(item => ({
...item,
avatar: processImageUrl(item.avatar)
}))
if (res.data.records) {
res.data.records = processedRecords
} else if (res.data.list) {
res.data.list = processedRecords
} else {
res.data = processedRecords
}
}
return res
})
}
/**
* 获取热门歌手
* @param {number} limit 限制数量
* @returns {Promise<any>}
*/
export function getHotSinger(limit = 10) {
return api.get('/singer/hot-list', {
params: { limit }
}).then(res => {
if (res.code === 200 && res.data) {
// 处理头像URL
res.data = res.data.map(item => ({
...item,
avatar: processImageUrl(item.avatar)
}))
}
return res
})
}
/**
* 收藏/取消收藏歌手
* @param {number} id 歌手ID
* @returns {Promise<any>}
*/
export function toggleCollectSinger(id) {
// 检查是否已收藏
return api.get(`/singer/${id}`).then(res => {
if (res.code === 200) {
const isCollected = res.data.isCollected === true
// 根据当前收藏状态决定调用收藏或取消收藏接口
if (isCollected) {
return api.delete(`/singer/${id}/collect`)
} else {
return api.post(`/singer/${id}/collect`)
}
} else {
return Promise.reject(new Error(res.message || '获取歌手信息失败'))
}
})
}
/**
* 添加音乐到歌手
* @param {number} singerId 歌手ID
* @param {number} musicId 音乐ID
* @returns {Promise<any>}
*/
export function addMusicToSinger(singerId, musicId) {
return api.post(`/singer/${singerId}/music/${musicId}`)
}
/**
* 从歌手移除音乐
* @param {number} singerId 歌手ID
* @param {number} musicId 音乐ID
* @returns {Promise<any>}
*/
export function removeMusicFromSinger(singerId, musicId) {
return api.delete(`/singer/${singerId}/music/${musicId}`)
}

View File

@@ -0,0 +1,63 @@
import api from './index'
/**
* 获取歌手类型列表
* @returns {Promise<any>}
*/
export function getSingerTypeList() {
return api.get('/type/singer')
}
/**
* 分页获取歌手类型列表
* @param {number} page 页码
* @param {number} size 每页大小
* @param {string} keyword 关键字
* @returns {Promise<any>}
*/
export function getSingerTypePage(page = 1, size = 10, keyword = '') {
return api.get('/type/singer/page', {
params: {
page,
size,
keyword
}
})
}
/**
* 根据ID获取歌手类型
* @param {number} id 歌手类型ID
* @returns {Promise<any>}
*/
export function getSingerTypeById(id) {
return api.get(`/type/singer/${id}`)
}
/**
* 添加歌手类型(管理员)
* @param {Object} data 歌手类型数据
* @returns {Promise<any>}
*/
export function addSingerType(data) {
return api.post('/type/singer/add', data)
}
/**
* 更新歌手类型(管理员)
* @param {number} id 歌手类型ID
* @param {Object} data 歌手类型数据
* @returns {Promise<any>}
*/
export function updateSingerType(id, data) {
return api.put(`/type/singer/${id}`, data)
}
/**
* 删除歌手类型(管理员)
* @param {number} id 歌手类型ID
* @returns {Promise<any>}
*/
export function deleteSingerType(id) {
return api.delete(`/type/singer/${id}`)
}

View File

@@ -0,0 +1,121 @@
import api from './index'
/**
* 用户登录
* @param {Object} data 登录数据
* @returns {Promise} 请求结果
*/
export function login(data) {
return api.post('/user/login', data)
}
/**
* 用户注册
* @param {Object} data 注册数据
* @returns {Promise} 请求结果
*/
export function register(data) {
return api.post('/user/register', data)
}
/**
* 用户登出
* @returns {Promise} 请求结果
*/
export function logout() {
return api.post('/user/logout')
}
/**
* 获取当前登录用户信息
* @returns {Promise} 请求结果
*/
export function getCurrentUser() {
return api.get('/user/current')
}
/**
* 获取用户信息
* @returns {Promise} 请求结果
*/
export function getUserInfo() {
return api.get('/user/profile')
}
/**
* 更新用户信息
* @param {Object} data 用户信息
* @returns {Promise} 请求结果
*/
export function updateUserInfo(data) {
return api.put('/user/update', data)
}
/**
* 上传用户头像
* @param {File} file 头像文件
* @returns {Promise} 请求结果
*/
export function uploadAvatar(file) {
const formData = new FormData()
formData.append('file', file)
return api.post('/user/upload/avatar', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
}
/**
* 修改密码
* @param {Object} data 密码数据
* @returns {Promise} 请求结果
*/
export function changePassword(data) {
return api.put('/user/password', data)
}
/**
* 获取用户统计数据
* @returns {Promise} 请求结果
*/
export function getUserStats() {
return api.get('/user/stats')
}
/**
* 分页获取用户列表
* @param {Object} params 查询参数
* @returns {Promise} 请求结果
*/
export function getUserList(params) {
return api.get('/user/list', { params })
}
/**
* 根据ID获取用户
* @param {Number} id 用户ID
* @returns {Promise} 请求结果
*/
export function getUserById(id) {
return api.get(`/user/${id}`)
}
/**
* 删除用户
* @param {Number} id 用户ID
* @returns {Promise} 请求结果
*/
export function deleteUser(id) {
return api.delete(`/user/${id}`)
}
/**
* 更新用户状态
* @param {Number} id 用户ID
* @param {Number} status 状态
* @returns {Promise} 请求结果
*/
export function updateUserStatus(id, status) {
return api.put(`/user/${id}/status?status=${status}`)
}

View File

@@ -0,0 +1,76 @@
/* 全局样式 */
/* 确保所有菜单项显示在同一层级 */
.el-menu--horizontal {
display: flex !important;
flex-wrap: nowrap !important;
overflow: visible !important;
border-bottom: none !important;
background-color: transparent !important;
}
.el-menu--horizontal > .el-menu-item {
display: block !important;
visibility: visible !important;
opacity: 1 !important;
pointer-events: auto !important;
flex-shrink: 0 !important;
border-bottom: none !important;
height: 70px !important;
line-height: 70px !important;
}
/* 菜单项样式 */
.el-menu-demo.main-nav {
border-bottom: none !important;
background-color: transparent !important;
height: 70px !important;
}
/* 确保所有菜单项都显示 */
.el-menu-item[index="/"],
.el-menu-item[index="/music"],
.el-menu-item[index="/playlist"],
.el-menu-item[index="/singer"] {
display: block !important;
visibility: visible !important;
opacity: 1 !important;
height: 70px !important;
line-height: 70px !important;
font-size: 16px !important;
font-weight: 500 !important;
padding: 0 20px !important;
min-width: 80px !important;
}
/* 移除更多菜单按钮,但保留菜单项 */
.el-menu--horizontal .el-sub-menu__icon-more,
.el-menu--horizontal .el-icon--more {
display: none !important;
visibility: hidden !important;
opacity: 0 !important;
pointer-events: none !important;
}
/* 防止双滚动条 */
html, body {
overflow: hidden; /* 禁止整个页面滚动 */
}
/* 确保内容区域只有一个滚动条 */
.el-main {
overflow-y: hidden !important;
overflow-x: hidden !important;
}
/* 确保所有页面内容区域适应高度 */
.home-container,
.music-page,
.playlist-container,
.singer-container,
.search-container,
.user-container,
.singer-detail-container,
.playlist-detail-container {
height: 100%;
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@@ -0,0 +1,43 @@
<script setup>
import { ref } from 'vue'
defineProps({
msg: String,
})
const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p>
Check out
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
>create-vue</a
>, the official Vue + Vite starter
</p>
<p>
Learn more about IDE Support for Vue in the
<a
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
target="_blank"
>Vue Docs Scaling up Guide</a
>.
</p>
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>

View File

@@ -0,0 +1,122 @@
<template>
<div class="home-banner">
<el-carousel :interval="4000" type="card" height="200px" :autoplay="true">
<el-carousel-item v-for="banner in banners" :key="banner.id">
<div class="banner-item" @click="handleBannerClick(banner)">
<ImagePreview
:src="banner.imageUrl"
fit="cover"
class="banner-image"
:preview-src-list="[]"
/>
<div class="banner-title" v-if="banner.title">{{ banner.title }}</div>
</div>
</el-carousel-item>
</el-carousel>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getBannerList } from '../api/banner'
import ImagePreview from './ImagePreview.vue'
const router = useRouter()
const banners = ref([])
const loading = ref(false)
// 获取轮播图列表
const fetchBanners = async () => {
loading.value = true
try {
const res = await getBannerList()
console.log('获取首页轮播图响应:', res)
if (res.code === 200) {
// 适配不同的数据结构
if (res.data.list) {
banners.value = res.data.list
} else {
banners.value = Array.isArray(res.data) ? res.data : []
}
console.log('首页轮播图数据:', banners.value)
} else {
ElMessage.error(res.message || '获取轮播图失败')
}
} catch (error) {
console.error('获取轮播图失败:', error)
} finally {
loading.value = false
}
}
// 处理轮播图点击
const handleBannerClick = (banner) => {
if (banner.linkUrl) {
// 如果有外部链接,则打开外部链接
window.open(banner.linkUrl, '_blank')
} else if (banner.targetId && banner.targetType) {
// 根据目标类型和ID跳转到相应页面
switch (banner.targetType) {
case 1: // 音乐
router.push(`/music/${banner.targetId}`)
break
case 2: // 歌单
router.push(`/playlist/${banner.targetId}`)
break
case 3: // 歌手
router.push(`/singer/${banner.targetId}`)
break
default:
break
}
}
}
// 在组件挂载时获取轮播图数据
onMounted(() => {
fetchBanners()
})
</script>
<style scoped>
.home-banner {
width: 100%;
margin: 0 0 20px 0;
}
.banner-item {
height: 100%;
width: 100%;
border-radius: 4px;
overflow: hidden;
position: relative;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: transform 0.3s;
}
.banner-item:hover {
transform: translateY(-5px);
}
.banner-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.banner-title {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 10px 15px;
background-color: rgba(0, 0, 0, 0.5);
color: #fff;
font-size: 16px;
font-weight: bold;
text-align: center;
}
</style>

View File

@@ -0,0 +1,98 @@
<template>
<div class="image-preview-container">
<el-image
:style="imageStyle"
:src="src"
:fit="fit"
:preview-src-list="previewSrcList"
:initial-index="initialIndex"
:preview-teleported="true"
:z-index="zIndex"
@error="onError"
>
<template #error>
<slot name="error">
<div class="image-error">
<el-icon><Picture /></el-icon>
<div>图片加载失败</div>
</div>
</slot>
</template>
</el-image>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { Picture } from '@element-plus/icons-vue'
const props = defineProps({
src: {
type: String,
required: true
},
width: {
type: [String, Number],
default: '100%'
},
height: {
type: [String, Number],
default: 'auto'
},
fit: {
type: String,
default: 'cover'
},
previewSrcList: {
type: Array,
default: () => []
},
initialIndex: {
type: Number,
default: 0
},
zIndex: {
type: Number,
default: 2000
}
})
const emit = defineEmits(['error'])
const imageStyle = computed(() => {
const width = typeof props.width === 'number' ? `${props.width}px` : props.width
const height = typeof props.height === 'number' ? `${props.height}px` : props.height
return {
width,
height
}
})
const onError = (e) => {
emit('error', e)
}
</script>
<style scoped>
.image-preview-container {
display: inline-block;
}
.image-error {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: #fef0f0;
color: #f56c6c;
font-size: 12px;
border-radius: 4px;
}
.image-error .el-icon {
font-size: 24px;
margin-bottom: 5px;
}
</style>

View File

@@ -0,0 +1,384 @@
<template>
<div class="music-list">
<div class="list-header">
<div class="header-item index">#</div>
<div class="header-item title">歌曲</div>
<div class="header-item singer">歌手</div>
<div class="header-item album">专辑</div>
<div class="header-item duration">时长</div>
<div class="header-item actions">操作</div>
</div>
<el-empty v-if="!loading && (!list || list.length === 0)" description="暂无音乐" />
<el-skeleton v-if="loading" :rows="5" animated />
<template v-else>
<div
v-for="(item, index) in list"
:key="item.id"
class="list-item"
:class="{ 'active': isPlaying(item.id) }"
@dblclick="playMusic(item)"
>
<div class="item-index">
<span v-if="!isPlaying(item.id)">{{ index + 1 }}</span>
<el-icon v-else class="playing-icon"><VideoPlay /></el-icon>
</div>
<div class="item-title">
<div class="music-cover">
<img :src="item.cover || '/default-cover.jpg'" alt="封面">
</div>
<div class="music-info">
<div class="music-name">{{ item.name }}</div>
<div class="music-type" v-if="item.typeName">{{ item.typeName }}</div>
</div>
</div>
<div class="item-singer">
<template v-if="item.singerNames && item.singerNames.length">
<span v-for="(singer, idx) in item.singerNames" :key="idx">
{{ singer }}{{ idx < item.singerNames.length - 1 ? '' : '' }}
</span>
</template>
<span v-else>{{ item.singer || '未知歌手' }}</span>
</div>
<div class="item-album">{{ item.album || '-' }}</div>
<div class="item-duration">{{ formatDuration(item.duration) }}</div>
<div class="item-actions">
<el-button
type="primary"
circle
size="small"
@click="playMusic(item)"
:title="isPlaying(item.id) ? '暂停' : '播放'"
>
<el-icon>
<component :is="isPlaying(item.id) ? 'VideoPause' : 'VideoPlay'" />
</el-icon>
</el-button>
<el-button
:type="item.collected ? 'danger' : 'default'"
circle
size="small"
@click="toggleCollect(item)"
:title="item.collected ? '取消收藏' : '收藏'"
>
<el-icon><Star /></el-icon>
</el-button>
<el-dropdown trigger="click" @command="handleCommand($event, item)">
<el-button circle size="small">
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="addToPlaylist">添加到播放列表</el-dropdown-item>
<el-dropdown-item command="playNext">下一首播放</el-dropdown-item>
<el-dropdown-item command="share" v-if="false">分享</el-dropdown-item>
<el-dropdown-item command="download" v-if="false">下载</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</template>
<div class="pagination-container" v-if="showPagination && total > 0">
<el-pagination
v-model:current-page="localCurrentPage"
v-model:page-size="localPageSize"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
:total="total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { VideoPlay, VideoPause, Star, More } from '@element-plus/icons-vue'
import { usePlayerStore } from '../stores/player'
import { toggleCollectMusic } from '../api/music'
import { ElMessage } from 'element-plus'
const props = defineProps({
list: {
type: Array,
default: () => []
},
loading: {
type: Boolean,
default: false
},
showPagination: {
type: Boolean,
default: true
},
total: {
type: Number,
default: 0
},
currentPage: {
type: Number,
default: 1
},
pageSize: {
type: Number,
default: 10
}
})
const emit = defineEmits(['update:currentPage', 'update:pageSize', 'page-change', 'collect-change'])
// 使用本地状态来代替直接绑定到props
const localCurrentPage = ref(props.currentPage)
const localPageSize = ref(props.pageSize)
// 监听props变化更新本地状态
watch(() => props.currentPage, (newVal) => {
localCurrentPage.value = newVal
})
watch(() => props.pageSize, (newVal) => {
localPageSize.value = newVal
})
const playerStore = usePlayerStore()
// 检查音乐是否正在播放
const isPlaying = (id) => {
return playerStore.getCurrentMusic?.id === id && playerStore.getIsPlaying
}
// 播放音乐
const playMusic = (music) => {
if (isPlaying(music.id)) {
playerStore.togglePlay()
} else {
playerStore.setCurrentMusic(music)
}
}
// 切换收藏状态
const toggleCollect = async (music) => {
try {
const res = await toggleCollectMusic(music.id)
if (res.code === 200) {
music.collected = !music.collected
ElMessage.success(music.collected ? '收藏成功' : '已取消收藏')
emit('collect-change', music)
} else {
ElMessage.error(res.message || '操作失败')
}
} catch (error) {
console.error('收藏操作失败:', error)
ElMessage.error('操作失败,请稍后重试')
}
}
// 处理下拉菜单命令
const handleCommand = (command, music) => {
switch (command) {
case 'addToPlaylist':
playerStore.addToPlaylist(music)
ElMessage.success('已添加到播放列表')
break
case 'playNext':
// 添加到当前播放音乐的后面
const currentIndex = playerStore.getCurrentIndex
if (currentIndex !== -1) {
const playlist = [...playerStore.getPlaylist]
// 先移除如果已存在的相同音乐
const existingIndex = playlist.findIndex(item => item.id === music.id)
if (existingIndex !== -1) {
playlist.splice(existingIndex, 1)
}
// 插入到当前播放音乐的后面
playlist.splice(currentIndex + 1, 0, music)
playerStore.$patch({ playlist })
ElMessage.success('已添加到下一首播放')
} else {
playerStore.addToPlaylist(music)
ElMessage.success('已添加到播放列表')
}
break
case 'share':
ElMessage.info('分享功能开发中')
break
case 'download':
ElMessage.info('下载功能开发中')
break
}
}
// 格式化时长
const formatDuration = (seconds) => {
if (!seconds) return '00:00'
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
// 处理分页大小变化
const handleSizeChange = (size) => {
localPageSize.value = size
emit('update:pageSize', size)
emit('page-change', { page: localCurrentPage.value, size })
}
// 处理页码变化
const handleCurrentChange = (page) => {
localCurrentPage.value = page
emit('update:currentPage', page)
emit('page-change', { page, size: localPageSize.value })
}
</script>
<style scoped>
.music-list {
width: 100%;
border-radius: 8px;
overflow: hidden;
background-color: #fff;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
}
.list-header {
display: flex;
align-items: center;
padding: 15px;
background-color: #f5f7fa;
border-bottom: 1px solid #ebeef5;
font-weight: bold;
color: #606266;
}
.list-item {
display: flex;
align-items: center;
padding: 12px 15px;
border-bottom: 1px solid #ebeef5;
transition: all 0.3s;
}
.list-item:hover {
background-color: #f5f7fa;
}
.list-item.active {
background-color: #ecf5ff;
color: #409eff;
}
.header-item, .item-index, .item-title, .item-singer, .item-album, .item-duration, .item-actions {
padding: 0 10px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.index, .item-index {
width: 50px;
text-align: center;
}
.title, .item-title {
flex: 2;
display: flex;
align-items: center;
}
.singer, .item-singer {
flex: 1;
}
.album, .item-album {
flex: 1;
}
.duration, .item-duration {
width: 80px;
text-align: center;
}
.actions, .item-actions {
width: 150px;
text-align: right;
display: flex;
justify-content: flex-end;
gap: 5px;
}
.music-cover {
width: 40px;
height: 40px;
border-radius: 4px;
overflow: hidden;
margin-right: 10px;
}
.music-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.music-info {
display: flex;
flex-direction: column;
justify-content: center;
}
.music-name {
font-weight: bold;
margin-bottom: 5px;
}
.music-type {
font-size: 12px;
color: #909399;
}
.playing-icon {
color: #409eff;
animation: pulse 1s infinite;
}
.pagination-container {
padding: 15px;
display: flex;
justify-content: center;
}
@keyframes pulse {
0% {
opacity: 0.5;
}
50% {
opacity: 1;
}
100% {
opacity: 0.5;
}
}
@media (max-width: 768px) {
.item-album, .header-item.album {
display: none;
}
.item-actions {
width: 100px;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,336 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '../stores/user'
import { ElMessage } from 'element-plus'
import {
Setting,
Fold,
Expand,
ArrowDown,
DataBoard,
User,
Headset,
Files,
Avatar,
Picture,
ChatDotRound,
Collection
} from '@element-plus/icons-vue'
const router = useRouter()
const userStore = useUserStore()
const isCollapse = ref(false)
// 检查用户是否是管理员
const isAdmin = computed(() => {
return userStore.user && userStore.user.role === 1
})
// 在组件挂载后检查权限
onMounted(async () => {
// 检查本地存储的权限信息
const token = localStorage.getItem('token')
const isAdmin = localStorage.getItem('isAdmin')
console.log('后台管理权限检查:', {
token,
isAdmin,
userStore: userStore.user
})
// 如果没有token或不是管理员重定向到首页
if (!token) {
ElMessage.error('请先登录')
router.push('/login')
return
}
if (isAdmin !== 'true') {
ElMessage.error('您没有管理员权限')
router.push('/')
return
}
// 移除测试权限接口的调用
// 如果没有用户信息,尝试获取
if (!userStore.user) {
try {
await userStore.fetchCurrentUser()
} catch (error) {
console.error('获取用户信息失败:', error)
ElMessage.error('获取用户信息失败,请重新登录')
router.push('/login')
}
}
})
// 退出登录
const logout = () => {
userStore.logout()
router.push('/login')
}
// 返回前台
const goToFrontend = () => {
router.push('/')
}
// 菜单项
const menuItems = [
{
index: '/admin',
icon: 'DataBoard',
title: '控制台'
},
{
index: '/admin/user',
icon: 'User',
title: '用户管理'
},
{
index: '/admin/music',
icon: 'Headset',
title: '音乐管理'
},
{
index: '/admin/playlist',
icon: 'Files',
title: '歌单管理'
},
{
index: '/admin/singer',
icon: 'Avatar',
title: '歌手管理'
},
{
index: '/admin/banner',
icon: 'Picture',
title: '轮播图管理'
},
{
index: '/admin/comment',
icon: 'ChatDotRound',
title: '评论管理'
},
{
index: '/admin/type',
icon: 'Collection',
title: '类型管理'
}
]
</script>
<template>
<div class="admin-layout">
<el-container class="admin-container">
<!-- 侧边栏 -->
<el-aside :width="isCollapse ? '64px' : '200px'" class="admin-aside">
<div class="logo-container">
<h2 v-if="!isCollapse">后台管理</h2>
<el-icon v-else><Setting /></el-icon>
</div>
<el-menu
:default-active="$route.path"
class="admin-menu"
:collapse="isCollapse"
router
background-color="transparent"
text-color="#d81b60"
active-text-color="#ff4081"
>
<el-menu-item v-for="item in menuItems" :key="item.index" :index="item.index">
<el-icon><component :is="item.icon" /></el-icon>
<template #title>{{ item.title }}</template>
</el-menu-item>
</el-menu>
</el-aside>
<!-- 主要内容区域 -->
<el-container>
<!-- 头部 -->
<el-header class="admin-header">
<div class="header-left">
<el-button type="text" @click="isCollapse = !isCollapse">
<el-icon><Fold v-if="!isCollapse" /><Expand v-else /></el-icon>
</el-button>
<el-breadcrumb separator="/">
<el-breadcrumb-item :to="{ path: '/admin' }">首页</el-breadcrumb-item>
<el-breadcrumb-item v-if="$route.meta.title">{{ $route.meta.title }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="header-right">
<el-button type="primary" size="small" @click="goToFrontend">
返回前台
</el-button>
<el-dropdown trigger="click">
<span class="el-dropdown-link">
{{ userStore.user?.nickname || userStore.user?.username }}
<el-icon class="el-icon--right"><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<!-- 内容区域 -->
<el-main class="admin-main">
<router-view />
</el-main>
<!-- 页脚 -->
<el-footer class="admin-footer">
<div>© 2025 音乐应用后台管理系统</div>
</el-footer>
</el-container>
</el-container>
</div>
</template>
<style scoped>
.admin-layout {
height: 100vh;
width: 100%;
}
.admin-container {
height: 100%;
}
.admin-aside {
background: linear-gradient(to bottom, #e3f2fd 0%, #bbdefb 100%);
transition: width 0.3s;
overflow: hidden;
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.1);
}
.logo-container {
height: 70px;
display: flex;
align-items: center;
justify-content: center;
color: #1976d2;
background-color: #e1f5fe;
font-family: 'Comic Sans MS', cursive, sans-serif;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
.logo-container h2 {
margin: 0;
font-size: 20px;
font-weight: bold;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.1);
}
.admin-menu {
border-right: none;
--el-menu-bg-color: transparent;
--el-menu-text-color: #0d47a1;
--el-menu-active-color: #2196f3;
--el-menu-hover-bg-color: rgba(255, 255, 255, 0.3);
}
.admin-menu .el-menu-item {
margin: 5px 10px;
border-radius: 8px;
transition: all 0.3s;
}
.admin-menu .el-menu-item.is-active {
background-color: rgba(255, 255, 255, 0.5) !important;
color: #ff4081 !important;
font-weight: bold;
}
.admin-menu .el-menu-item:hover {
transform: translateX(5px);
}
.admin-header {
background-color: #fff;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
}
.header-left {
display: flex;
align-items: center;
}
.header-left .el-button {
margin-right: 15px;
}
.header-right {
display: flex;
align-items: center;
}
.header-right .el-button {
margin-right: 15px;
}
.el-dropdown-link {
cursor: pointer;
display: flex;
align-items: center;
}
.admin-main {
background: linear-gradient(135deg, #f8bbd0 0%, #e1bee7 50%, #bbdefb 100%);
padding: 20px;
overflow-y: auto;
flex: 1;
}
/* 修改卡片样式 */
.el-card {
border-radius: 12px !important;
border: 2px solid #b3e5fc !important;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1) !important;
transition: all 0.3s ease !important;
}
.el-card:hover {
transform: translateY(-5px) !important;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15) !important;
}
/* 修改按钮样式 */
.el-button {
border-radius: 8px !important;
font-weight: bold !important;
}
.el-button--primary {
background-color: #4dd0e1 !important;
border-color: #4dd0e1 !important;
}
.el-button--primary:hover {
background-color: #26c6da !important;
border-color: #26c6da !important;
}
.admin-footer {
text-align: center;
color: #666;
font-size: 14px;
background-color: #fff;
border-top: 1px solid #e6e6e6;
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@@ -0,0 +1,384 @@
<script setup>
import { ref, computed, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '../stores/user'
import { usePlayerStore } from '../stores/player'
import { Search } from '@element-plus/icons-vue'
import MusicPlayer from '../components/MusicPlayer.vue'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const playerStore = usePlayerStore()
const activeIndex = ref('/')
const searchKeyword = ref('')
// 监听路由变化,更新激活的菜单项
watch(() => route.path, (newPath) => {
activeIndex.value = newPath
}, { immediate: true })
const isLoggedIn = computed(() => userStore.isLoggedIn)
// 处理搜索
const handleSearch = () => {
if (!searchKeyword.value.trim()) return
router.push({
path: '/search',
query: { keyword: searchKeyword.value }
})
// 搜索后清空搜索框
searchKeyword.value = ''
}
// 跳转到登录页面
const goToLogin = () => {
router.push('/login')
}
// 导航到个人中心
const goToUserCenter = () => {
router.push('/user')
}
// 导航到后台管理
const goToAdmin = () => {
router.push('/admin')
}
// 退出登录
const logout = () => {
userStore.logout()
router.push('/')
}
</script>
<template>
<div class="main-layout">
<el-container>
<el-header>
<div class="header-content">
<div class="logo">音乐应用</div>
<!-- 自定义导航菜单 -->
<div class="custom-nav">
<router-link to="/" class="nav-item" :class="{ 'active': activeIndex === '/' }">首页</router-link>
<router-link to="/music" class="nav-item" :class="{ 'active': activeIndex === '/music' }">音乐</router-link>
<router-link to="/playlist" class="nav-item" :class="{ 'active': activeIndex === '/playlist' }">歌单</router-link>
<router-link to="/singer" class="nav-item" :class="{ 'active': activeIndex === '/singer' }">歌手</router-link>
<router-link to="/user" class="nav-item" :class="{ 'active': activeIndex === '/user' }" v-if="isLoggedIn">个人中心</router-link>
</div>
<div class="header-right">
<div class="search-box">
<el-input
v-model="searchKeyword"
placeholder="搜索音乐、歌单、歌手"
clearable
@keyup.enter="handleSearch"
>
<template #suffix>
<el-icon class="el-input__icon" @click="handleSearch">
<Search />
</el-icon>
</template>
</el-input>
</div>
<div class="user-actions">
<template v-if="isLoggedIn">
<el-dropdown>
<span class="el-dropdown-link">
{{ userStore.user?.nickname || userStore.user?.username }}
<el-icon class="el-icon--right"><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="userStore.user?.role === 1" @click="goToAdmin">后台管理</el-dropdown-item>
<el-dropdown-item @click="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<template v-else>
<el-button type="primary" size="small" @click="goToLogin">登录</el-button>
</template>
</div>
</div>
</div>
</el-header>
<el-main>
<div class="content-container">
<router-view />
</div>
</el-main>
<!-- 音乐播放器 -->
<div class="player-container">
<MusicPlayer />
</div>
</el-container>
</div>
</template>
<style scoped>
.main-layout {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden; /* 防止出现双滚动条 */
}
.el-container {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden; /* 防止出现双滚动条 */
}
.el-header {
padding: 0;
/* background-color: rgba(255, 255, 255, 0.8); /* 半透明白色背景 */
background: linear-gradient(to bottom, rgba(255, 240, 245, 0.9), rgba(230, 247, 255, 0.9)); /* 淡粉到淡蓝的半透明渐变 */
backdrop-filter: blur(5px); /* 添加模糊效果 */
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.08); /* 调整阴影 */
height: auto !important;
min-height: 70px;
position: sticky; /* 使 header 固定在顶部 */
top: 0;
z-index: 100; /* 确保在最上层 */
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 30px; /* 增加左右内边距 */
position: relative;
height: 70px;
}
.logo {
font-size: 1.6rem; /* 稍大字体 */
font-weight: 600;
color: #ff6b81; /* 可爱粉色 */
z-index: 20;
text-shadow: 1px 1px 2px rgba(255, 255, 255, 0.5); /* 添加轻微文字阴影 */
}
/* 自定义导航菜单样式 */
.custom-nav {
position: absolute;
left: 50%;
transform: translateX(-50%);
display: flex;
justify-content: center;
z-index: 10;
height: 70px;
line-height: 70px;
}
.custom-nav .nav-item {
display: inline-block;
padding: 0 20px; /* 调整内边距 */
font-size: 1rem; /* 调整字体大小 */
font-weight: 500;
color: #555; /* 深灰色 */
text-decoration: none;
transition: all 0.3s ease;
height: 70px;
line-height: 70px;
min-width: 80px; /* 调整最小宽度 */
text-align: center;
position: relative; /* 为下划线定位 */
border-radius: 5px; /* 添加轻微圆角 */
}
.custom-nav .nav-item::after { /* 添加下划线动画效果 */
content: '';
position: absolute;
width: 0;
height: 3px;
bottom: 15px; /* 调整下划线位置 */
left: 50%;
transform: translateX(-50%);
background-color: #ffb6c1; /* 淡粉色 */
transition: width 0.3s ease;
border-radius: 2px;
}
.custom-nav .nav-item:hover {
color: #ff6b81; /* 悬停时粉色 */
background-color: rgba(255, 255, 255, 0.3); /* 悬停时轻微背景 */
}
.custom-nav .nav-item:hover::after {
width: 60%; /* 悬停时显示下划线 */
}
.custom-nav .nav-item.active {
color: #ff6b81; /* 激活时粉色 */
font-weight: 600; /* 激活时加粗 */
}
.custom-nav .nav-item.active::after {
width: 60%; /* 激活时显示下划线 */
background-color: #ff6b81; /* 激活时下划线颜色加深 */
}
/* 移除旧的 Element Plus 导航样式 (如果不再使用) */
.main-nav .el-menu-item { display: none !important; }
.header-right {
display: flex;
align-items: center;
gap: 25px; /* 增加间距 */
z-index: 20;
}
.search-box .el-input__wrapper { /* 美化搜索框 */
border-radius: 20px !important;
background-color: rgba(255, 255, 255, 0.7) !important;
box-shadow: none !important;
border: 1px solid #eee;
}
.search-box .el-input__inner {
color: #555;
}
.search-box .el-input__icon {
color: #aaa;
cursor: pointer;
}
.search-box .el-input__icon:hover {
color: #ff6b81;
}
.user-actions .el-button { /* 美化登录按钮 */
border-radius: 20px;
background-color: #ffb6c1;
border-color: #ffb6c1;
color: white;
}
.user-actions .el-button:hover {
background-color: #ff8a9b;
border-color: #ff8a9b;
}
.el-dropdown-link {
cursor: pointer;
display: flex;
align-items: center;
color: #555; /* 用户名颜色 */
font-weight: 500;
}
.el-dropdown-link:hover {
color: #ff6b81; /* 悬停时粉色 */
}
.el-dropdown-menu__item:hover {
background-color: #fff0f5 !important; /* 下拉菜单悬停背景 */
color: #ff6b81 !important; /* 下拉菜单悬停文字颜色 */
}
.el-main {
padding: 10px;
background: linear-gradient(to bottom, #fff0f5, #e6f7ff); /* 淡粉到淡蓝的渐变 */
flex: 1;
margin-bottom: 0; /* 移除底部边距 */
padding-bottom: 100px; /* 增加底部填充,为播放器留出空间 */
overflow: hidden; /* 禁用外层滚动 */
height: calc(100vh - 70px - 80px); /* 减去头部和播放器高度 */
position: relative;
}
/* 内容容器样式 - 将内容限制在70%宽度并居中 */
.content-container {
width: 70%;
margin: 0 auto;
height: 100%;
background-color: transparent;
box-sizing: border-box;
overflow-y: auto;
overflow-x: hidden;
/* 隐藏滚动条 */
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
}
/* 隐藏Webkit浏览器的滚动条 */
.content-container::-webkit-scrollbar {
display: none;
}
/* 音乐播放器容器样式 */
.player-container {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 80px;
z-index: 1000;
background-color: #fff; /* 添加背景色 */
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1); /* 添加阴影效果 */
}
/* 响应式样式 */
@media screen and (max-width: 1200px) {
.custom-nav .nav-item {
padding: 0 12px;
min-width: 60px;
font-size: 15px;
}
.content-container {
width: 85%;
}
}
@media screen and (max-width: 992px) {
.custom-nav {
position: relative;
left: auto;
transform: none;
justify-content: center;
width: 100%;
margin: 10px 0;
flex-wrap: wrap; /* 允许菜单项换行 */
}
.custom-nav .nav-item {
padding: 0 10px;
min-width: 50px;
font-size: 14px;
height: 40px;
line-height: 40px;
}
.header-content {
flex-direction: column;
padding: 10px 20px;
}
.logo {
margin-bottom: 10px;
}
.header-right {
margin-top: 10px;
width: 100%;
justify-content: center;
}
.content-container {
width: 95%;
}
}
</style>

View File

@@ -0,0 +1,27 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import './style.css'
import './assets/css/global.css' // 导入全局样式文件
import App from './App.vue'
import router from './router'
import pinia from './stores'
const app = createApp(App)
// 注册所有图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
// 使用插件
app.use(ElementPlus, {
locale: zhCn,
})
app.use(router)
app.use(pinia)
// 挂载应用
app.mount('#app')

View File

@@ -0,0 +1,312 @@
import { createRouter, createWebHistory } from 'vue-router'
import AdminLayout from '../layouts/AdminLayout.vue'
// 导入视图组件
const Home = () => import('../views/Home.vue')
const NotFound = () => import('../views/NotFound.vue')
const Login = () => import('../views/Login.vue')
const Register = () => import('../views/Register.vue')
const Music = () => import('../views/Music.vue')
const MusicDetail = () => import('../views/MusicDetail.vue')
const Playlist = () => import('../views/Playlist.vue')
const PlaylistDetail = () => import('../views/PlaylistDetail.vue')
const Singer = () => import('../views/Singer.vue')
const SingerDetail = () => import('../views/SingerDetail.vue')
const Search = () => import('../views/Search.vue')
const UserCenter = () => import('../views/UserCenter.vue')
const UserCollectMusic = () => import('../views/user/CollectMusic.vue')
const UserCollectPlaylist = () => import('../views/user/CollectPlaylist.vue')
const UserCollectSinger = () => import('../views/user/CollectSinger.vue')
const UserPlaylist = () => import('../views/user/Playlist.vue')
const UserPassword = () => import('../views/user/Password.vue')
const ImageTest = () => import('../views/ImageTest.vue')
// 后台管理页面
const Admin = () => import('../views/admin/Index.vue')
const AdminDashboard = () => import('../views/admin/Dashboard.vue')
const AdminUser = () => import('../views/admin/UserManagement.vue')
const AdminMusic = () => import('../views/admin/Music.vue')
const AdminPlaylist = () => import('../views/admin/Playlist.vue')
const AdminSinger = () => import('../views/admin/Singer.vue')
const AdminBanner = () => import('../views/admin/BannerManagement.vue')
const AdminType = () => import('../views/admin/TypeManagement.vue')
const AdminComment = () => import('../views/admin/CommentManagement.vue')
// 定义路由
const routes = [
{
path: '/',
name: 'home',
component: Home,
meta: {
title: '首页'
}
},
{
path: '/login',
name: 'login',
component: Login,
meta: {
title: '登录'
}
},
{
path: '/register',
name: 'register',
component: Register,
meta: {
title: '注册'
}
},
{
path: '/music',
name: 'music',
component: Music,
meta: {
title: '音乐'
}
},
{
path: '/music/:id',
name: 'music-detail',
component: MusicDetail,
meta: {
title: '音乐详情'
}
},
{
path: '/playlist',
name: 'playlist',
component: Playlist,
meta: {
title: '歌单'
}
},
{
path: '/playlist/:id',
name: 'playlist-detail',
component: PlaylistDetail,
meta: {
title: '歌单详情'
}
},
{
path: '/singer',
name: 'singer',
component: Singer,
meta: {
title: '歌手'
}
},
{
path: '/singer/:id',
name: 'singer-detail',
component: SingerDetail,
meta: {
title: '歌手详情'
}
},
{
path: '/search',
name: 'search',
component: Search,
meta: {
title: '搜索'
}
},
{
path: '/image-test',
name: 'image-test',
component: ImageTest,
meta: {
title: '图片URL测试'
}
},
{
path: '/user',
name: 'user-center',
component: UserCenter,
meta: {
title: '个人中心',
requiresAuth: true
},
children: [
{
path: 'collect/music',
name: 'user-collect-music',
component: UserCollectMusic,
meta: {
title: '我收藏的音乐',
requiresAuth: true
}
},
{
path: 'collect/playlist',
name: 'user-collect-playlist',
component: UserCollectPlaylist,
meta: {
title: '我收藏的歌单',
requiresAuth: true
}
},
{
path: 'collect/singer',
name: 'user-collect-singer',
component: UserCollectSinger,
meta: {
title: '我收藏的歌手',
requiresAuth: true
}
},
{
path: 'playlist',
name: 'user-playlist',
component: UserPlaylist,
meta: {
title: '我的歌单',
requiresAuth: true
}
},
{
path: 'password',
name: 'user-password',
component: UserPassword,
meta: {
title: '修改密码',
requiresAuth: true
}
}
]
},
{
path: '/admin',
name: 'admin',
component: AdminLayout,
meta: {
title: '后台管理',
requiresAuth: true,
requiresAdmin: true
},
children: [
{
path: '',
name: 'admin-dashboard',
component: AdminDashboard,
meta: {
title: '控制面板',
requiresAuth: true,
requiresAdmin: true
}
},
{
path: 'user',
name: 'admin-user',
component: AdminUser,
meta: {
title: '用户管理',
requiresAuth: true,
requiresAdmin: true
}
},
{
path: 'music',
name: 'admin-music',
component: AdminMusic,
meta: {
title: '音乐管理',
requiresAuth: true,
requiresAdmin: true
}
},
{
path: 'playlist',
name: 'admin-playlist',
component: AdminPlaylist,
meta: {
title: '歌单管理',
requiresAuth: true,
requiresAdmin: true
}
},
{
path: 'singer',
name: 'admin-singer',
component: AdminSinger,
meta: {
title: '歌手管理',
requiresAuth: true,
requiresAdmin: true
}
},
{
path: 'banner',
name: 'admin-banner',
component: AdminBanner,
meta: {
title: '轮播图管理',
requiresAuth: true,
requiresAdmin: true
}
},
{
path: 'type',
name: 'admin-type',
component: AdminType,
meta: {
title: '类型管理',
requiresAuth: true,
requiresAdmin: true
}
},
{
path: 'comment',
name: 'admin-comment',
component: AdminComment,
meta: {
title: '评论管理',
requiresAuth: true,
requiresAdmin: true
}
}
]
},
{
path: '/:pathMatch(.*)*',
name: 'not-found',
component: NotFound,
meta: {
title: '页面未找到'
}
}
]
// 创建路由实例
const router = createRouter({
history: createWebHistory(),
routes
})
// 全局前置守卫 - 设置页面标题和权限验证
router.beforeEach((to, from, next) => {
document.title = to.meta.title || '音乐应用'
// 从 store 获取登录状态
const isLoggedIn = localStorage.getItem('token') // 临时使用localStorage后续会改为store
const isAdmin = localStorage.getItem('isAdmin') === 'true' // 临时使用localStorage后续会改为store
// 需要登录权限但未登录
if (to.meta.requiresAuth && !isLoggedIn) {
next({ name: 'login', query: { redirect: to.fullPath } })
return
}
// 需要管理员权限但不是管理员
if (to.meta.requiresAdmin && !isAdmin) {
next({ name: 'home' })
return
}
next()
})
export default router

View File

@@ -0,0 +1,5 @@
import { createPinia } from 'pinia'
const pinia = createPinia()
export default pinia

View File

@@ -0,0 +1,285 @@
import { defineStore } from 'pinia'
import { playMusic } from '../api/music'
export const usePlayerStore = defineStore('player', {
state: () => ({
currentMusic: null,
playlist: [],
isPlaying: false,
currentTime: 0,
duration: 0,
volume: 80,
showPlaylist: false,
loop: 'none', // none, single, list
audio: null,
currentIndex: -1 // 当前播放的索引
}),
getters: {
getCurrentMusic: (state) => state.currentMusic,
getPlaylist: (state) => state.playlist,
getIsPlaying: (state) => state.isPlaying,
getCurrentTime: (state) => state.currentTime,
getDuration: (state) => state.duration,
getVolume: (state) => state.volume,
getShowPlaylist: (state) => state.showPlaylist,
getLoop: (state) => state.loop,
getCurrentIndex: (state) => state.currentIndex
},
actions: {
// 初始化音频对象
initAudio() {
if (!this.audio) {
this.audio = new Audio()
// 监听事件
this.audio.addEventListener('timeupdate', this.handleTimeUpdate)
this.audio.addEventListener('ended', this.handleEnded)
this.audio.addEventListener('loadedmetadata', this.handleLoadedMetadata)
this.audio.addEventListener('error', this.handleError)
this.audio.volume = this.volume / 100
}
},
// 设置当前音乐
async setCurrentMusic(music) {
if (!music) return
this.initAudio()
this.currentMusic = music
// 检查播放列表中是否已存在该音乐
const index = this.playlist.findIndex(item => item.id === music.id)
if (index !== -1) {
this.currentIndex = index
} else {
this.addToPlaylist(music)
this.currentIndex = this.playlist.length - 1
}
// 设置音频源并播放
if (music.url) {
this.audio.src = music.url
this.play()
// 调用播放API记录播放次数
try {
await playMusic(music.id)
} catch (error) {
console.error('记录播放失败:', error)
}
} else {
console.error('音乐URL不存在')
}
},
// 播放
play() {
if (this.currentMusic) {
this.initAudio()
const playPromise = this.audio.play()
if (playPromise !== undefined) {
playPromise.then(() => {
this.isPlaying = true
}).catch(error => {
console.error('播放失败:', error)
this.isPlaying = false
})
}
}
},
// 暂停
pause() {
if (this.audio) {
this.audio.pause()
this.isPlaying = false
}
},
// 切换播放状态
togglePlay() {
if (this.isPlaying) {
this.pause()
} else {
this.play()
}
},
// 设置音量
setVolume(volume) {
this.volume = volume
if (this.audio) {
this.audio.volume = volume / 100
}
},
// 设置当前播放时间
setCurrentTime(time) {
this.currentTime = time
if (this.audio) {
try {
this.audio.currentTime = time
// 如果当前是暂停状态,尝试播放一小段时间再暂停,以确保进度条变化生效
if (!this.isPlaying) {
const wasPlaying = false
this.audio.play().then(() => {
setTimeout(() => {
if (!wasPlaying) {
this.audio.pause()
}
}, 100)
}).catch(err => console.error('Temporary play failed:', err))
}
} catch (err) {
console.error('Failed to set currentTime:', err)
}
}
},
// 添加到播放列表
addToPlaylist(music) {
// 检查是否已存在
const exists = this.playlist.some(item => item.id === music.id)
if (!exists) {
this.playlist.push(music)
}
},
// 从播放列表中移除
removeFromPlaylist(musicId) {
const index = this.playlist.findIndex(item => item.id === musicId)
if (index !== -1) {
this.playlist.splice(index, 1)
// 如果移除的是当前播放的音乐,则播放下一首
if (this.currentMusic && this.currentMusic.id === musicId) {
this.playNext()
}
}
},
// 清空播放列表
clearPlaylist() {
this.playlist = []
this.setCurrentMusic(null)
},
// 播放上一首
playPrev() {
if (this.playlist.length === 0) return
const currentIndex = this.playlist.findIndex(item => this.currentMusic && item.id === this.currentMusic.id)
let prevIndex = currentIndex - 1
if (prevIndex < 0) {
prevIndex = this.playlist.length - 1
}
this.setCurrentMusic(this.playlist[prevIndex])
},
// 播放下一首
playNext() {
if (this.playlist.length === 0) return
const currentIndex = this.playlist.findIndex(item => this.currentMusic && item.id === this.currentMusic.id)
let nextIndex = currentIndex + 1
if (nextIndex >= this.playlist.length) {
nextIndex = 0
}
this.setCurrentMusic(this.playlist[nextIndex])
},
// 切换播放列表显示状态
togglePlaylist() {
this.showPlaylist = !this.showPlaylist
},
// 设置循环模式
setLoop(mode) {
this.loop = mode
},
// 处理时间更新事件
handleTimeUpdate() {
this.currentTime = this.audio.currentTime
},
// 处理播放结束事件
handleEnded() {
if (this.loop === 'single') {
// 单曲循环
this.audio.currentTime = 0
this.play()
} else if (this.loop === 'list') {
// 列表循环
this.playNext()
} else {
// 不循环,播放下一首
this.playNext()
}
},
// 处理元数据加载事件
handleLoadedMetadata() {
this.duration = this.audio.duration
},
// 处理错误事件
handleError(e) {
console.error('音频加载错误:', e)
// 如果当前音乐加载失败,尝试播放下一首
if (this.playlist.length > 1) {
this.playNext()
}
},
// 设置播放列表显示状态
setShowPlaylist(show) {
this.showPlaylist = show
},
// 从播放列表中播放
playFromPlaylist(index) {
if (index >= 0 && index < this.playlist.length) {
this.setCurrentMusic(this.playlist[index])
}
},
// 更新音乐URL
updateMusicUrl(id, url) {
// 更新当前音乐的URL
if (this.currentMusic && this.currentMusic.id === id) {
this.currentMusic.url = url
if (this.audio) {
this.audio.src = url
}
}
// 更新播放列表中的URL
const index = this.playlist.findIndex(item => item.id === id)
if (index !== -1) {
this.playlist[index].url = url
}
},
// 更新音乐封面
updateMusicCover(id, cover) {
// 更新当前音乐的封面
if (this.currentMusic && this.currentMusic.id === id) {
this.currentMusic.cover = cover
}
// 更新播放列表中的封面
const index = this.playlist.findIndex(item => item.id === id)
if (index !== -1) {
this.playlist[index].cover = cover
}
}
}
})

View File

@@ -0,0 +1,145 @@
import { defineStore } from 'pinia'
import { getCurrentUser, logout } from '../api/user'
import { ElMessage } from 'element-plus'
export const useUserStore = defineStore('user', {
state: () => {
// 从本地存储中获取用户信息
const storedUser = localStorage.getItem('user')
const token = localStorage.getItem('token')
return {
user: storedUser ? JSON.parse(storedUser) : null,
isLoggedIn: !!token,
token: token || ''
}
},
getters: {
getUser: (state) => state.user,
getLoginStatus: (state) => state.isLoggedIn,
isAdmin: (state) => state.user?.role === 1
},
actions: {
// 登录
login(userData) {
// 确保 userData 包含 user 属性
if (userData && userData.user) {
this.user = userData.user
this.isLoggedIn = true
this.token = userData.token || ''
// 打印登录数据,便于调试
console.log('登录数据:', userData)
// 存储到本地
localStorage.setItem('user', JSON.stringify(userData.user))
localStorage.setItem('token', this.token)
localStorage.setItem('tokenName', userData.tokenName || 'satoken')
localStorage.setItem('tokenPrefix', userData.tokenPrefix || '')
localStorage.setItem('isAdmin', userData.user.role === 1 ? 'true' : 'false')
// 打印存储的信息,便于调试
console.log('存储的token:', localStorage.getItem('token'))
console.log('存储的tokenName:', localStorage.getItem('tokenName'))
console.log('存储的tokenPrefix:', localStorage.getItem('tokenPrefix'))
console.log('存储的isAdmin:', localStorage.getItem('isAdmin'))
} else {
console.error('登录数据格式错误:', userData)
}
},
// 登出
async logout() {
try {
// 调用登出API
await logout()
// 重置状态
this.user = null
this.isLoggedIn = false
this.token = ''
// 清除本地存储
localStorage.removeItem('user')
localStorage.removeItem('token')
localStorage.removeItem('tokenName')
localStorage.removeItem('tokenPrefix')
localStorage.removeItem('isAdmin')
ElMessage.success('登出成功')
} catch (error) {
console.error('登出失败:', error)
}
},
// 获取当前用户信息
async fetchCurrentUser() {
const token = localStorage.getItem('token')
if (!token) {
console.warn('没有token无法获取用户信息')
return
}
// 确保存储在store中的token与localStorage中的一致
this.token = token
console.log('开始获取用户信息token:', token)
try {
// 尝试从本地存储中获取用户信息
const userStr = localStorage.getItem('user')
if (userStr) {
try {
const user = JSON.parse(userStr)
if (user && user.id) {
this.user = user
this.isLoggedIn = true
console.log('从本地存储中获取用户信息成功:', user)
return
}
} catch (e) {
console.warn('解析本地存储的用户信息失败:', e)
}
}
// 如果本地存储中没有用户信息,则从服务器获取
const res = await getCurrentUser()
console.log('获取用户信息响应:', res)
if (res.code === 200) {
// 如果返回的数据中有user属性说明是登录接口返回的数据格式
if (res.data && res.data.user) {
this.user = res.data.user
} else {
// 否则是获取当前用户接口返回的数据格式
this.user = res.data
}
this.isLoggedIn = true
localStorage.setItem('user', JSON.stringify(this.user))
localStorage.setItem('isAdmin', this.user.role === 1 ? 'true' : 'false')
console.log('用户信息已更新:', this.user)
} else {
console.error('获取用户信息失败响应码非200:', res.code, res.msg)
}
} catch (error) {
console.error('获取用户信息失败:', error)
// 如果获取用户信息失败可能是token已过期清除本地存储
if (error.message && (error.message.includes('token') || error.message.includes('登录'))) {
console.warn('可能是token已过期清除本地存储')
this.user = null
this.isLoggedIn = false
this.token = ''
localStorage.removeItem('user')
localStorage.removeItem('token')
localStorage.removeItem('tokenName')
localStorage.removeItem('tokenPrefix')
localStorage.removeItem('isAdmin')
}
throw error // 将错误向上抛出,以便调用者可以处理
}
}
}
})

View File

@@ -0,0 +1,84 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
.card {
padding: 2em;
}
#app {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
}
/* 确保歌手菜单项显示 */
.el-menu-item[index="/singer"] {
display: block !important;
visibility: visible !important;
opacity: 1 !important;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

View File

@@ -0,0 +1,12 @@
/**
* 清理本地存储
*/
export function clearStorage() {
localStorage.removeItem('token')
localStorage.removeItem('tokenName')
localStorage.removeItem('tokenPrefix')
localStorage.removeItem('user')
localStorage.removeItem('isAdmin')
console.log('本地存储已清理')
}

View File

@@ -0,0 +1,42 @@
/**
* 处理图片URL确保它是完整的URL
* @param {string} url 图片URL
* @returns {string} 完整的图片URL
*/
export function processImageUrl(url) {
if (!url) {
return 'https://via.placeholder.com/150?text=无图片';
}
// 如果是完整URL直接返回
if (url.startsWith('http://') || url.startsWith('https://')) {
return url;
}
// 如果是相对路径添加API基础URL
// 使用后端服务器地址
const baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8085';
// 确保路径以/开头
if (!url.startsWith('/')) {
url = '/' + url;
}
return `${baseUrl}${url}`;
}
/**
* 格式化播放次数
* @param {number} count 播放次数
* @returns {string} 格式化后的播放次数
*/
export function formatPlayCount(count) {
if (!count) return '0';
if (count < 10000) {
return count.toString();
} else if (count < 100000000) {
return (count / 10000).toFixed(1) + '万';
} else {
return (count / 100000000).toFixed(1) + '亿';
}
}

View File

@@ -0,0 +1,95 @@
import SockJS from 'sockjs-client/dist/sockjs.min.js'; // 确保导入正确的 SockJS 文件
import { Client } from '@stomp/stompjs';
let stompClient = null;
let subscriptions = {}; // 用于存储订阅,方便取消
const connect = (onConnectedCallback, onErrorCallback) => {
// 后端 WebSocket 端点 URL (根据实际部署情况可能需要修改)
// 注意:如果后端运行在不同端口,需要写完整 URL例如 'http://localhost:8080/ws'
// 如果前后端同源,可以直接用 '/ws'
const socket = new SockJS('http://localhost:8085/ws'); // 使用后端配置的端点 (修改为绝对路径)
stompClient = new Client({
webSocketFactory: () => socket,
reconnectDelay: 5000, // 自动重连延迟
heartbeatIncoming: 4000,
heartbeatOutgoing: 4000,
onConnect: (frame) => {
console.log('WebSocket Connected: ' + frame);
if (onConnectedCallback) {
onConnectedCallback(frame);
}
},
onStompError: (frame) => {
console.error('Broker reported error: ' + frame.headers['message']);
console.error('Additional details: ' + frame.body);
if (onErrorCallback) {
onErrorCallback(frame);
}
},
onWebSocketError: (error) => {
console.error('WebSocket Error: ', error);
if (onErrorCallback) {
onErrorCallback(error);
}
},
onDisconnect: () => {
console.log('WebSocket Disconnected');
// 清理所有订阅
subscriptions = {};
}
});
stompClient.activate();
};
const subscribe = (destination, callback) => {
if (stompClient && stompClient.connected) {
console.log(`Subscribing to ${destination}`);
const subscription = stompClient.subscribe(destination, (message) => {
console.log(`Message received from ${destination}:`, message.body);
if (callback) {
try {
callback(JSON.parse(message.body));
} catch (e) {
console.error("Error parsing message body:", e);
callback(message.body); // 如果解析失败,返回原始 body
}
}
});
subscriptions[destination] = subscription; // 存储订阅
return subscription;
} else {
console.error('Cannot subscribe, Stomp client not connected.');
return null;
}
};
const unsubscribe = (destination) => {
if (subscriptions[destination]) {
console.log(`Unsubscribing from ${destination}`);
subscriptions[destination].unsubscribe();
delete subscriptions[destination]; // 从存储中移除
}
};
const disconnect = () => {
if (stompClient && stompClient.connected) {
stompClient.deactivate();
console.log('WebSocket connection deactivated.');
}
// 即使未连接,也尝试清理 stompClient 实例
stompClient = null;
subscriptions = {}; // 清空订阅记录
};
// 可选:发送消息的方法 (如果需要前端主动发消息)
const sendMessage = (destination, body) => {
if (stompClient && stompClient.connected) {
stompClient.publish({ destination: destination, body: JSON.stringify(body) });
} else {
console.error('Cannot send message, Stomp client not connected.');
}
};
export { connect, subscribe, unsubscribe, disconnect, sendMessage };

View File

@@ -0,0 +1,80 @@
<script setup>
import { ref } from 'vue'
</script>
<template>
<div class="about-container">
<h1>关于我们</h1>
<el-card class="about-card">
<template #header>
<div class="card-header">
<span>项目信息</span>
</div>
</template>
<div class="card-content">
<p>这是一个基于Vue 3Element PlusPinia和Vue Router的音乐应用</p>
<p>技术栈:</p>
<el-tag class="tech-tag" type="success">Vue 3</el-tag>
<el-tag class="tech-tag" type="warning">Element Plus</el-tag>
<el-tag class="tech-tag" type="danger">Pinia</el-tag>
<el-tag class="tech-tag" type="info">Vue Router</el-tag>
</div>
</el-card>
</div>
</template>
<style scoped>
.about-container {
padding: 20px 20px 80px 20px;
min-height: calc(100vh - 140px);
background-color: transparent;
border-radius: 15px;
}
.about-card {
max-width: 800px;
margin: 30px auto;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
border: none;
background-color: rgba(255, 255, 255, 0.9);
transition: all 0.3s ease;
}
.about-card:hover {
transform: translateY(-5px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 20px;
background-color: #ffb6c1;
color: white;
border-radius: 12px 12px 0 0;
}
.card-header span {
font-size: 1.2rem;
font-weight: 600;
}
.card-content {
text-align: center;
padding: 30px;
}
.tech-tag {
margin: 8px;
font-size: 1rem;
padding: 8px 15px;
border-radius: 20px;
transition: all 0.3s ease;
}
.tech-tag:hover {
transform: scale(1.1);
}
</style>

View File

@@ -0,0 +1,502 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { usePlayerStore } from '../stores/player'
import { ElMessage } from 'element-plus'
import HomeBanner from '../components/HomeBanner.vue'
import { getLatestMusic } from '../api/music'
import { getLatestPlaylist } from '../api/playlist'
import { getLatestSinger } from '../api/singer'
import { processMusicUrl } from '../api/music'
import { processImageUrl } from '../utils/imageUtils'
const router = useRouter()
const playerStore = usePlayerStore()
// 新歌推荐
const newSongs = ref([])
const loadingNewSongs = ref(false)
// 新歌单推荐
const newPlaylists = ref([])
const loadingNewPlaylists = ref(false)
// 新歌手推荐
const newSingers = ref([])
const loadingNewSingers = ref(false)
// 获取最新音乐
const fetchLatestMusic = async () => {
loadingNewSongs.value = true
try {
const res = await getLatestMusic(8) // 获取8首最新音乐
if (res.code === 200) {
newSongs.value = res.data.map(item => ({
...item,
cover: processMusicUrl(item.cover),
url: processMusicUrl(item.url)
}))
console.log('最新音乐数据:', newSongs.value)
} else {
ElMessage.error(res.message || '获取最新音乐失败')
}
} catch (error) {
console.error('获取最新音乐失败:', error)
ElMessage.error('获取最新音乐失败')
} finally {
loadingNewSongs.value = false
}
}
// 获取最新歌单
const fetchLatestPlaylist = async () => {
loadingNewPlaylists.value = true
try {
const res = await getLatestPlaylist(8) // 获取8个最新歌单
if (res.code === 200) {
newPlaylists.value = res.data
console.log('最新歌单数据:', newPlaylists.value)
} else {
ElMessage.error(res.message || '获取最新歌单失败')
}
} catch (error) {
console.error('获取最新歌单失败:', error)
ElMessage.error('获取最新歌单失败')
} finally {
loadingNewPlaylists.value = false
}
}
// 获取最新歌手
const fetchLatestSinger = async () => {
loadingNewSingers.value = true
try {
const res = await getLatestSinger(8) // 获取8个最新歌手
if (res.code === 200) {
newSingers.value = res.data.map(item => ({
...item,
avatar: processImageUrl(item.avatar)
}))
console.log('最新歌手数据:', newSingers.value)
} else {
ElMessage.error(res.message || '获取最新歌手失败')
}
} catch (error) {
console.error('获取最新歌手失败:', error)
ElMessage.error('获取最新歌手失败')
} finally {
loadingNewSingers.value = false
}
}
// 播放音乐
const playSong = (song) => {
playerStore.setCurrentMusic(song)
}
// 跳转到歌单详情
const goToPlaylist = (id) => {
router.push(`/playlist/${id}`)
}
// 跳转到歌手详情
const goToSinger = (id) => {
router.push(`/singer/${id}`)
}
// 格式化播放次数
const formatPlayCount = (count) => {
if (!count) return '0'
if (count < 10000) {
return count.toString()
} else if (count < 100000000) {
return (count / 10000).toFixed(1) + '万'
} else {
return (count / 100000000).toFixed(1) + '亿'
}
}
onMounted(() => {
// 获取数据
fetchLatestMusic()
fetchLatestPlaylist()
fetchLatestSinger()
})
</script>
<template>
<div class="home-container">
<!-- 轮播图 -->
<div class="banner-section">
<HomeBanner />
</div>
<!-- 新歌推荐 -->
<div class="section new-songs-section" v-loading="loadingNewSongs">
<div class="section-header">
<h2>新歌推荐</h2>
<el-button text @click="router.push('/music')">查看更多</el-button>
</div>
<div class="song-list">
<div v-for="song in newSongs" :key="song.id" class="song-item" @dblclick="playSong(song)">
<div class="song-cover">
<img :src="song.cover" :alt="song.name">
<div class="play-icon" @click="playSong(song)">
<el-icon><video-play /></el-icon>
</div>
</div>
<div class="song-info">
<div class="song-name">{{ song.name }}</div>
<div class="song-singer">{{ song.singer || (song.singers && song.singers.map(s => s.name).join(', ')) }}</div>
</div>
</div>
</div>
</div>
<!-- 新歌单推荐 -->
<div class="section new-playlists-section" v-loading="loadingNewPlaylists">
<div class="section-header">
<h2>新歌单推荐</h2>
<el-button text @click="router.push('/playlist')">查看更多</el-button>
</div>
<div class="playlist-list">
<div v-for="playlist in newPlaylists" :key="playlist.id" class="playlist-item" @click="goToPlaylist(playlist.id)">
<div class="playlist-cover">
<img :src="playlist.cover" :alt="playlist.name">
<div class="playlist-info-overlay">
<div class="playlist-play-count" v-if="playlist.playCount">
<el-icon><video-play /></el-icon>
{{ formatPlayCount(playlist.playCount) }}
</div>
</div>
</div>
<div class="playlist-info">
<div class="playlist-name">{{ playlist.name }}</div>
<div class="playlist-desc">{{ playlist.description }}</div>
</div>
</div>
</div>
</div>
<!-- 新歌手推荐 -->
<div class="section new-singers-section" v-loading="loadingNewSingers">
<div class="section-header">
<h2>新歌手推荐</h2>
<el-button text @click="router.push('/singer')">查看更多</el-button>
</div>
<div class="singer-list">
<div v-for="singer in newSingers" :key="singer.id" class="singer-item" @click="goToSinger(singer.id)">
<div class="singer-avatar">
<img :src="singer.avatar" :alt="singer.name">
</div>
<div class="singer-name">{{ singer.name }}</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.home-container {
padding: 20px 20px 80px 20px; /* 增加左右内边距,底部填充为播放器留出空间 */
min-height: calc(100vh - 140px); /* 减去顶部导航和底部播放器的高度 */
background-color: transparent; /* 设置为透明,让布局背景透出来 */
border-radius: 15px; /* 更大的圆角 */
box-shadow: none; /* 移除阴影,因为背景是渐变 */
}
/* 轮播图样式 */
.banner-section {
margin-bottom: 30px; /* 增加间距 */
border-radius: 10px; /* 添加圆角 */
overflow: hidden; /* 确保轮播图内容也在圆角内 */
}
/* 各类推荐区域样式 */
.section {
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); /* 稍微调整阴影 */
border: none; /* 移除底部边框 */
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 25px; /* 增加间距 */
padding: 0; /* 移除内边距,因为 section 已经有内边距 */
}
.section-header h2 {
margin: 0;
font-size: 1.6rem; /* 稍大字体 */
color: #ff6b81; /* 可爱的粉色 */
font-weight: 600; /* 加粗一点 */
}
/* 修改 Element Plus 按钮样式 */
.section-header .el-button {
background-color: #ffb6c1; /* 淡粉色背景 */
color: white;
border: none;
border-radius: 20px; /* 圆润按钮 */
padding: 8px 15px;
transition: background-color 0.3s;
}
.section-header .el-button:hover {
background-color: #ff8a9b; /* 悬停时深一点的粉色 */
}
/* 音乐列表样式 */
.song-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); /* 稍微增大最小宽度 */
gap: 20px; /* 增加间隙 */
padding: 0; /* 移除内边距 */
}
.song-item {
cursor: pointer;
transition: all 0.3s ease-in-out;
background-color: #fff9f9; /* 非常淡的粉色背景 */
border-radius: 10px; /* 圆角 */
padding: 10px;
box-shadow: 0 2px 5px rgba(255, 107, 129, 0.1); /* 粉色阴影 */
}
.song-item:hover {
transform: translateY(-6px) scale(1.03); /* 悬停效果更明显 */
box-shadow: 0 4px 10px rgba(255, 107, 129, 0.2);
}
.song-cover {
position: relative;
width: 100%;
padding-top: 100%;
border-radius: 8px; /* 圆角 */
overflow: hidden;
margin-bottom: 12px; /* 增加间距 */
}
.song-cover img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease; /* 图片缩放过渡 */
}
.song-item:hover .song-cover img {
transform: scale(1.1); /* 悬停时图片放大 */
}
.play-icon {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 182, 193, 0.5); /* 淡粉色遮罩 */
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.3s;
border-radius: 8px; /* 保持圆角一致 */
}
.play-icon .el-icon {
font-size: 3.5rem; /* 稍大图标 */
color: white; /* 白色图标 */
}
.song-cover:hover .play-icon {
opacity: 1;
}
.song-info {
padding: 0; /* 移除内边距 */
text-align: center; /* 居中显示 */
}
.song-name {
font-weight: 600; /* 加粗 */
margin-bottom: 6px; /* 增加间距 */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: #333; /* 深灰色 */
font-size: 0.95rem;
}
.song-singer {
font-size: 0.8rem;
color: #888; /* 浅灰色 */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 歌单列表样式 */
.playlist-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 20px;
padding: 0;
}
.playlist-item {
cursor: pointer;
transition: all 0.3s ease-in-out;
border-radius: 10px;
overflow: hidden; /* 隐藏溢出的内容 */
box-shadow: 0 2px 5px rgba(173, 216, 230, 0.2); /* 淡蓝色阴影 */
background-color: #f0f8ff; /* 淡蓝色背景 */
}
.playlist-item:hover {
transform: translateY(-6px) scale(1.03);
box-shadow: 0 4px 10px rgba(173, 216, 230, 0.4);
}
.playlist-cover {
position: relative;
width: 100%;
padding-top: 100%;
border-radius: 0; /* 移除这里的圆角让item控制 */
overflow: hidden;
margin-bottom: 0; /* 移除间距 */
}
.playlist-cover img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.playlist-item:hover .playlist-cover img {
transform: scale(1.1);
}
.playlist-info-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: linear-gradient(to bottom, rgba(0,0,0,0), rgba(0,0,0,0.6)); /* 渐变效果 */
opacity: 1; /* 默认显示 */
transition: background-color 0.3s;
display: flex;
flex-direction: column;
justify-content: flex-end; /* 播放次数放底部 */
padding: 10px;
box-sizing: border-box;
}
.playlist-cover:hover .playlist-info-overlay {
background-color: rgba(0, 0, 0, 0.2); /* 悬停时加深一点 */
}
.playlist-play-count {
color: white;
font-size: 0.85rem; /* 稍大字体 */
display: flex;
align-items: center;
text-shadow: 1px 1px 2px rgba(0,0,0,0.5); /* 添加文字阴影 */
}
.playlist-play-count .el-icon {
margin-right: 4px; /* 调整间距 */
font-size: 1rem; /* 调整图标大小 */
}
.playlist-info {
padding: 10px; /* 增加内边距 */
background-color: #f0f8ff; /* 与 item 背景色一致 */
}
.playlist-name {
font-weight: 600;
margin-bottom: 5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: #333;
font-size: 0.95rem;
}
.playlist-desc {
font-size: 0.8rem;
color: #777; /* 稍深的灰色 */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 歌手列表样式 */
.singer-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); /* 调整大小 */
gap: 25px; /* 增加间隙 */
padding: 0;
}
.singer-item {
cursor: pointer;
transition: all 0.3s ease-in-out;
text-align: center;
}
.singer-item:hover {
transform: translateY(-6px) scale(1.05); /* 悬停效果 */
}
.singer-avatar {
width: 100%;
padding-top: 100%;
border-radius: 50%; /* 圆形头像 */
overflow: hidden;
position: relative;
margin-bottom: 12px; /* 增加间距 */
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); /* 添加阴影 */
border: 3px solid #fff; /* 添加白色边框 */
}
.singer-avatar img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.singer-item:hover .singer-avatar img {
transform: scale(1.15); /* 悬停时放大 */
}
.singer-name {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: #555; /* 稍深颜色 */
font-size: 0.9rem;
}
/* Specific section backgrounds - REMOVED */
</style>

View File

@@ -0,0 +1,212 @@
<template>
<div class="image-test-container">
<h1>图片URL测试</h1>
<div class="test-section">
<h2>测试图片URL</h2>
<div class="input-section">
<el-input v-model="imageUrl" placeholder="输入图片URL" clearable></el-input>
<el-button type="primary" @click="processUrl">处理URL</el-button>
</div>
<div class="result-section">
<h3>处理结果</h3>
<div class="url-display">
<p><strong>原始URL:</strong> {{ imageUrl }}</p>
<p><strong>处理后URL:</strong> {{ processedUrl }}</p>
</div>
<div class="image-display">
<h3>图片预览</h3>
<div v-if="processedUrl" class="image-container">
<img :src="processedUrl" alt="测试图片" @error="handleImageError" />
<p v-if="imageError" class="error-message">图片加载失败: {{ imageError }}</p>
</div>
<p v-else>请输入图片URL并点击处理</p>
</div>
</div>
</div>
<div class="test-section">
<h2>测试示例</h2>
<div class="examples">
<div v-for="(example, index) in examples" :key="index" class="example-item">
<p>{{ example.description }}</p>
<el-button @click="testExample(example.url)">测试 {{ example.url }}</el-button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { processImageUrl } from '../utils/imageUtils'
import { ElMessage } from 'element-plus'
const imageUrl = ref('')
const processedUrl = ref('')
const imageError = ref('')
// 示例URL
const examples = [
{
description: '相对路径 - 不带前导斜杠',
url: 'upload/images/569f8ce5-09d2-4151-a6b1-f56a4d0b2c84.png'
},
{
description: '相对路径 - 带前导斜杠',
url: '/upload/images/362d1d68-fcc2-4a74-a0f1-44bd38e9cb6d.jpg'
},
{
description: '完整URL - localhost:8080',
url: 'http://localhost:8080/upload/images/3efa486a-610d-4745-a54c-61f55ab3f1d5.jpg'
},
{
description: '完整URL - localhost:8085',
url: 'http://localhost:8085/upload/images/6c8c8d04-e583-4f12-8c56-d618b2bb06f4.jpg'
},
{
description: '外部URL - 占位图片',
url: 'https://via.placeholder.com/150?text=测试图片'
}
]
// 处理URL
const processUrl = () => {
if (!imageUrl.value) {
ElMessage.warning('请输入图片URL')
return
}
imageError.value = ''
processedUrl.value = processImageUrl(imageUrl.value)
}
// 测试示例URL
const testExample = (url) => {
imageUrl.value = url
processUrl()
}
// 处理图片加载错误
const handleImageError = (e) => {
imageError.value = `无法加载图片 (${e.type})`
console.error('图片加载失败:', e)
}
</script>
<style scoped>
.image-test-container {
padding: 20px 20px 80px 20px;
min-height: calc(100vh - 140px);
background-color: transparent;
border-radius: 15px;
}
.test-section {
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);
transition: all 0.3s ease;
}
.test-section:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
h1, h2, h3 {
color: #ff6b81;
}
.input-section {
display: flex;
gap: 15px;
margin-bottom: 20px;
}
.input-section .el-button {
background-color: #ffb6c1;
border: none;
border-radius: 20px;
color: white;
transition: background-color 0.3s;
}
.input-section .el-button:hover {
background-color: #ff8a9b;
}
.result-section {
margin-top: 25px;
}
.url-display {
background-color: #fff9f9;
padding: 15px;
border-radius: 8px;
border: none;
margin-bottom: 20px;
word-break: break-all;
box-shadow: 0 2px 5px rgba(255, 107, 129, 0.1);
}
.image-container {
margin-top: 15px;
padding: 15px;
border-radius: 8px;
background-color: #fff9f9;
box-shadow: 0 2px 5px rgba(255, 107, 129, 0.1);
}
.image-container img {
max-width: 100%;
max-height: 300px;
display: block;
margin: 0 auto;
border-radius: 8px;
}
.error-message {
color: #f56c6c;
margin-top: 10px;
}
.examples {
display: flex;
flex-direction: column;
gap: 15px;
}
.example-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px;
background-color: #fff9f9;
border-radius: 8px;
border: none;
box-shadow: 0 2px 5px rgba(255, 107, 129, 0.1);
transition: all 0.3s ease;
}
.example-item:hover {
transform: translateY(-3px);
box-shadow: 0 4px 8px rgba(255, 107, 129, 0.15);
}
.example-item .el-button {
background-color: #ffb6c1;
border: none;
border-radius: 20px;
color: white;
transition: all 0.3s;
}
.example-item .el-button:hover {
background-color: #ff8a9b;
transform: scale(1.05);
}
</style>

View File

@@ -0,0 +1,227 @@
<script setup>
import { ref, reactive } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '../stores/user'
import { ElMessage } from 'element-plus'
import { login } from '../api/user'
import { clearStorage } from '../utils/clearStorage'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
// 登录表单
const loginForm = reactive({
username: '',
password: ''
})
// 登录表单验证规则
const loginRules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 20, message: '用户名长度应为3-20个字符', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, max: 20, message: '密码长度应为6-20个字符', trigger: 'blur' }
]
}
const loginFormRef = ref(null)
const loading = ref(false)
// 登录方法
const handleLogin = () => {
// 清理本地存储
clearStorage()
loginFormRef.value.validate(async (valid) => {
if (valid) {
loading.value = true
try {
// 调用登录API
const res = await login(loginForm)
if (res.code === 200) {
const userData = res.data
// 存储用户信息到状态管理
userStore.login(userData)
// 存储token和相关信息已经在userStore.login中完成
// 这里不需要重复存储
// 打印登录成功信息
console.log('登录成功,完整响应数据:', res)
console.log('登录成功,用户数据:', userData)
console.log('登录成功,用户信息:', userData.user)
console.log('是否为管理员:', userData.user.role === 1)
// 检查本地存储的token
setTimeout(() => {
console.log('登录后的本地存储:', {
token: localStorage.getItem('token'),
tokenName: localStorage.getItem('tokenName'),
tokenPrefix: localStorage.getItem('tokenPrefix'),
isAdmin: localStorage.getItem('isAdmin'),
user: localStorage.getItem('user')
})
}, 100)
ElMessage.success(res.msg || '登录成功')
// 如果是管理员,跳转到后台管理页面
if (userData.user.role === 1) {
router.push('/admin')
} else {
// 如果有重定向地址,则跳转到重定向地址
const redirect = route.query.redirect || '/'
router.push(redirect)
}
}
} catch (error) {
console.error('登录失败:', error)
ElMessage.error(error.message || '登录失败,请检查用户名和密码')
} finally {
loading.value = false
}
}
})
}
// 跳转到注册页面
const goToRegister = () => {
router.push('/register')
}
</script>
<template>
<div class="login-container">
<div class="login-box">
<div class="login-header">
<h2>用户登录</h2>
</div>
<el-form
ref="loginFormRef"
:model="loginForm"
:rules="loginRules"
label-width="0"
class="login-form"
>
<el-form-item prop="username">
<el-input
v-model="loginForm.username"
placeholder="用户名"
prefix-icon="User"
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="loginForm.password"
type="password"
placeholder="密码"
prefix-icon="Lock"
show-password
@keyup.enter="handleLogin"
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:loading="loading"
class="login-button"
@click="handleLogin"
>
登录
</el-button>
</el-form-item>
</el-form>
<div class="login-actions">
<el-button text @click="goToRegister">没有账号立即注册</el-button>
</div>
</div>
</div>
</template>
<style scoped>
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: calc(100vh - 140px);
padding: 20px;
background-color: transparent;
}
.login-box {
width: 450px;
padding: 40px;
background-color: rgba(255, 255, 255, 0.9);
border-radius: 15px;
box-shadow: 0 4px 20px rgba(255, 107, 129, 0.15);
transition: all 0.3s ease;
}
.login-box:hover {
transform: translateY(-5px);
box-shadow: 0 6px 25px rgba(255, 107, 129, 0.2);
}
.login-header {
text-align: center;
margin-bottom: 35px;
}
.login-header h2 {
margin: 0;
color: #ff6b81;
font-size: 1.8rem;
font-weight: 600;
}
.login-form {
margin-bottom: 25px;
}
.login-form :deep(.el-input__wrapper) {
border-radius: 20px;
padding: 0 15px;
}
.login-form :deep(.el-input__inner) {
padding: 0 5px;
}
.login-button {
width: 100%;
background-color: #ffb6c1;
border: none;
border-radius: 20px;
height: 45px;
font-size: 1rem;
transition: all 0.3s;
}
.login-button:hover {
background-color: #ff8a9b;
transform: scale(1.02);
}
.login-actions {
display: flex;
justify-content: center;
}
.login-actions .el-button {
color: #ff6b81;
font-weight: 500;
}
.login-actions .el-button:hover {
color: #ff4757;
}
</style>

View File

@@ -0,0 +1,336 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { Search } from '@element-plus/icons-vue'
import { getMusicList, getHotMusic, getLatestMusic, processMusicUrl } from '../api/music'
import { getMusicTypeList } from '../api/musicType'
import MusicList from '../components/MusicList.vue'
import { ElMessage } from 'element-plus'
const router = useRouter()
// 音乐列表数据
const musicList = ref([])
const loading = ref(false)
const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(10)
// 筛选条件
const keyword = ref('')
const selectedType = ref(null)
const musicTypes = ref([])
// 标签页
const activeTab = ref('all')
const hotMusicList = ref([])
const latestMusicList = ref([])
const loadingHot = ref(false)
const loadingLatest = ref(false)
// 获取音乐列表
const fetchMusicList = async (params = {}) => {
loading.value = true
try {
const page = params.page || currentPage.value
const size = params.size || pageSize.value
const res = await getMusicList(page, size, keyword.value, selectedType.value)
if (res.code === 200) {
// 兼容不同的数据结构,后端可能返回 list 或 records 字段
musicList.value = res.data.list || res.data.records || []
total.value = res.data.total || 0
console.log('音乐列表数据:', res.data)
// 处理收藏状态
musicList.value.forEach(music => {
if (music.collected === undefined) {
music.collected = false
}
// 确保音乐URL和封面URL正确处理
if (music.url) {
music.url = processMusicUrl(music.url)
}
if (music.cover) {
music.cover = processMusicUrl(music.cover)
}
})
} else {
ElMessage.error(res.message || '获取音乐列表失败')
}
} catch (error) {
console.error('获取音乐列表失败:', error)
ElMessage.error('获取音乐列表失败,请稍后重试')
} finally {
loading.value = false
}
}
// 获取音乐类型列表
const fetchMusicTypes = async () => {
try {
const res = await getMusicTypeList()
if (res.code === 200) {
musicTypes.value = res.data || []
}
} catch (error) {
console.error('获取音乐类型失败:', error)
}
}
// 处理搜索
const handleSearch = () => {
currentPage.value = 1
fetchMusicList()
}
// 处理类型变化
const handleTypeChange = () => {
currentPage.value = 1
fetchMusicList()
}
// 处理收藏变化
const handleCollectChange = (music) => {
// 可以在这里处理收藏状态变化后的逻辑
console.log('收藏状态变化:', music)
}
// 获取热门音乐
const fetchHotMusic = async () => {
loadingHot.value = true
try {
const res = await getHotMusic(20) // 获取20首热门音乐
if (res.code === 200) {
hotMusicList.value = res.data || []
// 处理音乐URL和封面URL
hotMusicList.value.forEach(music => {
if (music.url) {
music.url = processMusicUrl(music.url)
}
if (music.cover) {
music.cover = processMusicUrl(music.cover)
}
if (music.collected === undefined) {
music.collected = false
}
})
} else {
ElMessage.error(res.message || '获取热门音乐失败')
}
} catch (error) {
console.error('获取热门音乐失败:', error)
ElMessage.error('获取热门音乐失败')
} finally {
loadingHot.value = false
}
}
// 获取最新音乐
const fetchLatestMusic = async () => {
loadingLatest.value = true
try {
const res = await getLatestMusic(20) // 获取20首最新音乐
if (res.code === 200) {
latestMusicList.value = res.data || []
// 处理音乐URL和封面URL
latestMusicList.value.forEach(music => {
if (music.url) {
music.url = processMusicUrl(music.url)
}
if (music.cover) {
music.cover = processMusicUrl(music.cover)
}
if (music.collected === undefined) {
music.collected = false
}
})
} else {
ElMessage.error(res.message || '获取最新音乐失败')
}
} catch (error) {
console.error('获取最新音乐失败:', error)
ElMessage.error('获取最新音乐失败')
} finally {
loadingLatest.value = false
}
}
// 切换标签页
const handleTabChange = (tab) => {
activeTab.value = tab
if (tab === 'hot' && hotMusicList.value.length === 0) {
fetchHotMusic()
} else if (tab === 'latest' && latestMusicList.value.length === 0) {
fetchLatestMusic()
}
}
// 初始化
onMounted(() => {
fetchMusicList()
fetchMusicTypes()
})
</script>
<template>
<div class="music-page">
<div class="page-header">
<h1>音乐库</h1>
<div class="filter-container">
<el-select v-model="selectedType" placeholder="音乐类型" clearable @change="handleTypeChange">
<el-option
v-for="item in musicTypes"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
<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>
<el-tabs v-model="activeTab" @tab-click="handleTabChange">
<el-tab-pane label="全部音乐" name="all">
<MusicList
:list="musicList"
:loading="loading"
:total="total"
v-model:current-page="currentPage"
v-model:page-size="pageSize"
@page-change="fetchMusicList"
@collect-change="handleCollectChange"
/>
</el-tab-pane>
<el-tab-pane label="热门音乐" name="hot">
<MusicList
:list="hotMusicList"
:loading="loadingHot"
:showPagination="false"
@collect-change="handleCollectChange"
/>
</el-tab-pane>
<el-tab-pane label="最新音乐" name="latest">
<MusicList
:list="latestMusicList"
:loading="loadingLatest"
:showPagination="false"
@collect-change="handleCollectChange"
/>
</el-tab-pane>
</el-tabs>
</div>
</template>
<style scoped>
.music-page {
padding: 20px 20px 80px 20px;
min-height: calc(100vh - 140px);
background-color: transparent;
border-radius: 15px;
}
.page-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);
}
.page-header h1 {
margin: 0;
font-size: 1.8rem;
color: #ff6b81;
font-weight: 600;
}
.filter-container {
display: flex;
gap: 15px;
}
.filter-container .el-select {
width: 180px;
}
.filter-container .el-input {
width: 250px;
}
.filter-container :deep(.el-input__wrapper) {
border-radius: 20px;
}
.filter-container .el-icon {
cursor: pointer;
color: #ff6b81;
transition: all 0.3s;
}
.filter-container .el-icon:hover {
color: #ff4757;
transform: scale(1.1);
}
.el-tabs {
margin-top: 20px;
}
.el-tabs :deep(.el-tabs__item) {
font-size: 1rem;
font-weight: 500;
color: #666;
}
.el-tabs :deep(.el-tabs__item.is-active) {
color: #ff6b81;
}
.el-tabs :deep(.el-tabs__active-bar) {
background-color: #ff6b81;
}
.el-tabs :deep(.el-tabs__nav-wrap::after) {
background-color: #ffb6c1;
}
@media (max-width: 768px) {
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 15px;
}
.filter-container {
width: 100%;
flex-direction: column;
}
.filter-container .el-select,
.filter-container .el-input {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,448 @@
<template>
<div class="music-detail-page">
<el-skeleton :loading="loading" animated>
<template #template>
<div class="skeleton-container">
<el-skeleton-item variant="image" style="width: 240px; height: 240px; border-radius: 8px;" />
<div class="skeleton-content">
<el-skeleton-item variant="h1" style="width: 50%;" />
<el-skeleton-item variant="text" style="width: 30%; margin-top: 16px;" />
<el-skeleton-item variant="text" style="width: 40%; margin-top: 16px;" />
<el-skeleton-item variant="text" style="width: 60%; margin-top: 16px;" />
</div>
</div>
</template>
<template #default>
<div v-if="music" class="music-info">
<div class="music-cover">
<img :src="music.cover || '/default-cover.jpg'" alt="封面">
</div>
<div class="music-content">
<h1 class="music-name">{{ music.name }}</h1>
<div class="music-meta">
<div class="meta-item">
<span class="label">歌手</span>
<span class="value">
<template v-if="music.singerNames && music.singerNames.length">
<span v-for="(singer, idx) in music.singerNames" :key="idx">
{{ singer }}{{ idx < music.singerNames.length - 1 ? '' : '' }}
</span>
</template>
<span v-else>{{ music.singer || '未知歌手' }}</span>
</span>
</div>
<div class="meta-item">
<span class="label">专辑</span>
<span class="value">{{ music.album || '-' }}</span>
</div>
<div class="meta-item">
<span class="label">类型</span>
<span class="value">{{ music.typeName || '-' }}</span>
</div>
<div class="meta-item">
<span class="label">时长</span>
<span class="value">{{ formatDuration(music.duration) }}</span>
</div>
<div class="meta-item">
<span class="label">发行日期</span>
<span class="value">{{ music.releaseDate || '-' }}</span>
</div>
</div>
<div class="music-actions">
<el-button
type="primary"
@click="playMusic"
:icon="isPlaying ? 'VideoPause' : 'VideoPlay'"
>
{{ isPlaying ? '暂停' : '播放' }}
</el-button>
<el-button
:type="music.collected ? 'danger' : 'default'"
@click="toggleCollect"
:icon="music.collected ? 'Star' : 'StarFilled'"
>
{{ music.collected ? '已收藏' : '收藏' }}
</el-button>
<el-button
@click="addToPlaylist"
icon="Plus"
>
添加到播放列表
</el-button>
</div>
</div>
</div>
<div v-if="music && music.description" class="music-description">
<h2>歌曲简介</h2>
<p>{{ music.description }}</p>
</div>
<div v-if="music && music.lyric" class="music-lyric">
<h2>歌词</h2>
<pre>{{ music.lyric }}</pre>
</div>
<div v-if="relatedMusic.length > 0" class="related-music">
<h2>相关推荐</h2>
<el-row :gutter="20">
<el-col
v-for="item in relatedMusic"
:key="item.id"
:xs="12"
:sm="8"
:md="6"
:lg="4"
>
<div class="music-card" @click="goToMusic(item.id)">
<div class="card-cover">
<img :src="item.cover || '/default-cover.jpg'" alt="封面">
<div class="play-icon" @click.stop="playRelatedMusic(item)">
<el-icon><VideoPlay /></el-icon>
</div>
</div>
<div class="card-info">
<div class="card-name">{{ item.name }}</div>
<div class="card-singer">{{ getSingerNames(item) }}</div>
</div>
</div>
</el-col>
</el-row>
</div>
</template>
</el-skeleton>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { usePlayerStore } from '../stores/player'
import { getMusicById, toggleCollectMusic, getHotMusic } from '../api/music'
import { ElMessage } from 'element-plus'
import { VideoPlay, VideoPause, Star, StarFilled, Plus } from '@element-plus/icons-vue'
const route = useRoute()
const router = useRouter()
const playerStore = usePlayerStore()
const music = ref(null)
const loading = ref(true)
const relatedMusic = ref([])
// 计算属性
const isPlaying = computed(() => {
return playerStore.getCurrentMusic?.id === music.value?.id && playerStore.getIsPlaying
})
// 获取音乐详情
const fetchMusicDetail = async () => {
loading.value = true
try {
const id = route.params.id
if (!id) {
ElMessage.error('无效的音乐ID')
router.push('/music')
return
}
const res = await getMusicById(id)
if (res.code === 200) {
if (res.data) {
music.value = res.data
} else {
// 如果音乐不存在,显示提示并返回音乐列表页
ElMessage.warning('音乐不存在或已被删除')
router.push('/music')
}
} else {
ElMessage.error(res.message || '获取音乐详情失败')
router.push('/music')
}
} catch (error) {
console.error('获取音乐详情失败:', error)
ElMessage.error('获取音乐详情失败,请稍后重试')
router.push('/music')
} finally {
loading.value = false
}
}
// 获取相关音乐
const fetchRelatedMusic = async () => {
try {
const res = await getHotMusic(6)
if (res.code === 200) {
// 过滤掉当前音乐
relatedMusic.value = (res.data || []).filter(item => item.id !== music.value?.id)
}
} catch (error) {
console.error('获取相关音乐失败:', error)
}
}
// 播放音乐
const playMusic = () => {
if (isPlaying.value) {
playerStore.togglePlay()
} else {
playerStore.setCurrentMusic(music.value)
}
}
// 播放相关音乐
const playRelatedMusic = (item) => {
playerStore.setCurrentMusic(item)
}
// 切换收藏状态
const toggleCollect = async () => {
try {
const res = await toggleCollectMusic(music.value.id)
if (res.code === 200) {
music.value.collected = !music.value.collected
ElMessage.success(music.value.collected ? '收藏成功' : '已取消收藏')
} else {
ElMessage.error(res.message || '操作失败')
}
} catch (error) {
console.error('收藏操作失败:', error)
ElMessage.error('操作失败,请稍后重试')
}
}
// 添加到播放列表
const addToPlaylist = () => {
playerStore.addToPlaylist(music.value)
ElMessage.success('已添加到播放列表')
}
// 跳转到音乐详情
const goToMusic = (id) => {
router.push(`/music/${id}`)
}
// 获取歌手名称
const getSingerNames = (item) => {
if (!item) return ''
if (item.singerNames && item.singerNames.length > 0) {
return item.singerNames.join('、')
}
return item.singer || '未知歌手'
}
// 格式化时长
const formatDuration = (seconds) => {
if (!seconds) return '00:00'
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
// 监听路由变化,重新获取音乐详情
watch(() => route.params.id, (newId, oldId) => {
if (newId !== oldId) {
fetchMusicDetail()
fetchRelatedMusic()
}
})
onMounted(() => {
fetchMusicDetail()
fetchRelatedMusic()
})
</script>
<style scoped>
.music-detail-page {
padding: 20px;
}
.skeleton-container {
display: flex;
gap: 30px;
}
.skeleton-content {
flex: 1;
}
.music-info {
display: flex;
gap: 30px;
margin-bottom: 30px;
}
.music-cover {
width: 240px;
height: 240px;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.music-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.music-content {
flex: 1;
}
.music-name {
font-size: 28px;
margin: 0 0 20px 0;
color: #303133;
}
.music-meta {
margin-bottom: 30px;
}
.meta-item {
margin-bottom: 10px;
font-size: 16px;
color: #606266;
}
.label {
font-weight: bold;
margin-right: 5px;
}
.music-actions {
display: flex;
gap: 15px;
}
.music-description, .music-lyric {
margin-bottom: 30px;
}
.music-description h2, .music-lyric h2, .related-music h2 {
font-size: 20px;
margin-bottom: 15px;
color: #303133;
}
.music-description p {
line-height: 1.6;
color: #606266;
}
.music-lyric pre {
white-space: pre-wrap;
line-height: 1.8;
color: #606266;
font-family: inherit;
}
.related-music {
margin-top: 30px;
}
.music-card {
background-color: #fff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
cursor: pointer;
transition: all 0.3s;
margin-bottom: 20px;
}
.music-card:hover {
transform: translateY(-5px);
box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.1);
}
.card-cover {
position: relative;
height: 0;
padding-bottom: 100%;
overflow: hidden;
}
.card-cover img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.play-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 50px;
height: 50px;
background-color: rgba(0, 0, 0, 0.5);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.3s;
}
.play-icon .el-icon {
font-size: 24px;
color: #fff;
}
.card-cover:hover .play-icon {
opacity: 1;
}
.card-info {
padding: 10px;
}
.card-name {
font-weight: bold;
margin-bottom: 5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-singer {
font-size: 12px;
color: #909399;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 768px) {
.music-info {
flex-direction: column;
}
.music-cover {
width: 100%;
height: auto;
aspect-ratio: 1;
max-width: 240px;
margin: 0 auto 20px;
}
}
</style>

View File

@@ -0,0 +1,32 @@
<script setup>
import { useRouter } from 'vue-router'
const router = useRouter()
const goHome = () => {
router.push('/')
}
</script>
<template>
<div class="not-found-container">
<el-result
icon="error"
title="404"
sub-title="抱歉您访问的页面不存在"
>
<template #extra>
<el-button type="primary" @click="goHome">返回首页</el-button>
</template>
</el-result>
</div>
</template>
<style scoped>
.not-found-container {
display: flex;
justify-content: center;
align-items: center;
height: 80vh;
}
</style>

View File

@@ -0,0 +1,547 @@
<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 { getPlaylistList, getHotPlaylist, toggleCollectPlaylist } from '../api/playlist'
import { getPlaylistTypeList } from '../api/playlistType'
const router = useRouter()
const userStore = useUserStore()
// 歌单类型列表
const playlistTypes = ref([
{ id: null, name: '全部' }
])
// 实际的歌单类型列表
const actualPlaylistTypes = ref([
{ id: 0, name: '全部' },
{ id: 1, name: '流行' },
{ id: 2, name: '摇滚' },
{ id: 3, name: '民谣' },
{ id: 4, name: '电子' },
{ id: 5, name: '古典' },
{ id: 6, name: '嘻哈' },
{ id: 7, name: '爵士' },
{ id: 8, name: '乡村' }
])
// 当前选中的歌单类型
const activeType = ref(null)
// 歌单列表
const playlists = ref([])
// 分页参数
const currentPage = ref(1)
const pageSize = ref(12)
const total = ref(0)
// 加载状态
const loading = ref(false)
// 搜索关键词
const keyword = ref('')
// 热门歌单
const hotPlaylists = ref([])
const loadingHot = ref(false)
// 获取歌单类型列表
const fetchPlaylistTypes = async () => {
try {
const res = await getPlaylistTypeList()
if (res.code === 200) {
// 将获取到的类型添加到列表中
playlistTypes.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
fetchPlaylists()
}
// 获取歌单列表
const fetchPlaylists = async () => {
loading.value = true
try {
const res = await getPlaylistList(
currentPage.value,
pageSize.value,
keyword.value,
activeType.value
)
if (res.code === 200) {
playlists.value = res.data.list || res.data.records || []
total.value = res.data.total || 0
} else {
ElMessage.error(res.message || '获取歌单列表失败')
}
} catch (error) {
console.error('获取歌单列表失败:', error)
ElMessage.error('获取歌单列表失败')
} finally {
loading.value = false
}
}
// 获取热门歌单
const fetchHotPlaylists = async () => {
loadingHot.value = true
try {
const res = await getHotPlaylist(8) // 获取8个热门歌单
if (res.code === 200) {
hotPlaylists.value = res.data || []
} else {
ElMessage.error(res.message || '获取热门歌单失败')
}
} catch (error) {
console.error('获取热门歌单失败:', error)
ElMessage.error('获取热门歌单失败')
} finally {
loadingHot.value = false
}
}
// 处理搜索
const handleSearch = () => {
currentPage.value = 1
fetchPlaylists()
}
// 处理分页变化
const handlePageChange = (page) => {
currentPage.value = page
fetchPlaylists()
}
// 处理每页数量变化
const handleSizeChange = (size) => {
pageSize.value = size
currentPage.value = 1
fetchPlaylists()
}
// 查看歌单详情
const viewPlaylistDetail = (id) => {
router.push(`/playlist/${id}`)
}
// 收藏/取消收藏歌单
const toggleCollect = async (id) => {
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录')
router.push('/login')
return
}
try {
const res = await toggleCollectPlaylist(id)
if (res.code === 200) {
// 更新收藏状态
const playlist = playlists.value.find(p => p.id === id)
if (playlist) {
playlist.collected = !playlist.collected
ElMessage.success(playlist.collected ? '收藏成功' : '已取消收藏')
}
} else {
ElMessage.error(res.message || '操作失败')
}
} catch (error) {
console.error('收藏操作失败:', error)
ElMessage.error('操作失败,请稍后重试')
}
}
// 格式化播放次数
const formatPlayCount = (count) => {
if (count < 10000) {
return count.toString()
} else if (count < 100000000) {
return (count / 10000).toFixed(1) + '万'
} else {
return (count / 100000000).toFixed(1) + '亿'
}
}
onMounted(() => {
fetchPlaylistTypes()
fetchPlaylists()
fetchHotPlaylists()
})
</script>
<template>
<div class="playlist-container">
<div class="playlist-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="playlist-types">
<el-radio-group v-model="activeType" @change="changeType">
<el-radio-button
v-for="type in playlistTypes"
:key="type.id"
:label="type.id"
>
{{ type.name }}
</el-radio-button>
</el-radio-group>
</div>
<!-- 热门歌单 -->
<div class="hot-playlists" v-if="activeType === null && currentPage === 1">
<h2>热门歌单</h2>
<div class="playlist-list" v-loading="loadingHot">
<div
v-for="playlist in hotPlaylists"
:key="playlist.id"
class="playlist-item"
@click="viewPlaylistDetail(playlist.id)"
>
<div class="playlist-cover">
<img :src="playlist.cover" :alt="playlist.name">
<div class="playlist-info-overlay">
<div class="playlist-play-count">
<el-icon><video-play /></el-icon>
{{ formatPlayCount(playlist.playCount) }}
</div>
<div class="playlist-actions">
<el-button
circle
:icon="'VideoPlay'"
@click.stop="viewPlaylistDetail(playlist.id)"
></el-button>
<el-button
circle
:icon="playlist.collected ? 'StarFilled' : 'Star'"
:type="playlist.collected ? 'warning' : ''"
@click.stop="toggleCollect(playlist.id)"
></el-button>
</div>
</div>
</div>
<div class="playlist-info">
<div class="playlist-name">{{ playlist.name }}</div>
<div class="playlist-desc">{{ playlist.description }}</div>
<div class="playlist-creator">
<span>by {{ playlist.creator?.username || '未知用户' }}</span>
<span>{{ playlist.songCount || 0 }}首歌曲</span>
</div>
</div>
</div>
</div>
</div>
<!-- 歌单列表 -->
<div class="all-playlists">
<h2 v-if="activeType === null && currentPage === 1">全部歌单</h2>
<div class="playlist-list" v-loading="loading">
<div
v-for="playlist in playlists"
:key="playlist.id"
class="playlist-item"
@click="viewPlaylistDetail(playlist.id)"
>
<div class="playlist-cover">
<img :src="playlist.cover" :alt="playlist.name">
<div class="playlist-info-overlay">
<div class="playlist-play-count">
<el-icon><video-play /></el-icon>
{{ formatPlayCount(playlist.playCount) }}
</div>
<div class="playlist-actions">
<el-button
circle
:icon="'VideoPlay'"
@click.stop="viewPlaylistDetail(playlist.id)"
></el-button>
<el-button
circle
:icon="playlist.collected ? 'StarFilled' : 'Star'"
:type="playlist.collected ? 'warning' : ''"
@click.stop="toggleCollect(playlist.id)"
></el-button>
</div>
</div>
</div>
<div class="playlist-info">
<div class="playlist-name">{{ playlist.name }}</div>
<div class="playlist-desc">{{ playlist.description }}</div>
<div class="playlist-creator">
<span>by {{ playlist.creator?.username || '未知用户' }}</span>
<span>{{ playlist.songCount || 0 }}首歌曲</span>
</div>
</div>
</div>
</div>
<!-- 空状态 -->
<el-empty
v-if="playlists.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="[12, 24, 36, 48]"
layout="total, sizes, prev, pager, next, jumper"
:total="total"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
</div>
</div>
</template>
<style scoped>
.playlist-container {
padding: 20px 20px 80px 20px;
min-height: calc(100vh - 140px);
background-color: transparent;
border-radius: 15px;
}
.playlist-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);
}
.playlist-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-playlists,
.all-playlists {
margin-top: 30px;
}
.hot-playlists h2,
.all-playlists 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;
}
.playlist-types {
margin-bottom: 30px;
}
.playlist-types :deep(.el-radio-button__inner) {
border-radius: 20px !important;
margin-right: 10px;
border: none;
background-color: #fff9f9;
color: #666;
}
.playlist-types :deep(.el-radio-button__orig-radio:checked + .el-radio-button__inner) {
background-color: #ffb6c1;
color: white;
box-shadow: none;
}
.playlist-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 25px;
}
.playlist-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;
}
.playlist-item:hover {
transform: translateY(-8px);
box-shadow: 0 8px 20px rgba(255, 107, 129, 0.2);
}
.playlist-cover {
position: relative;
width: 100%;
padding-top: 100%;
overflow: hidden;
}
.playlist-cover img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s;
}
.playlist-item:hover .playlist-cover img {
transform: scale(1.1);
}
.playlist-info-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;
flex-direction: column;
justify-content: space-between;
padding: 15px;
}
.playlist-item:hover .playlist-info-overlay {
opacity: 1;
}
.playlist-play-count {
color: white;
display: flex;
align-items: center;
font-size: 0.9rem;
text-shadow: 1px 1px 2px rgba(0,0,0,0.5);
}
.playlist-play-count .el-icon {
margin-right: 5px;
font-size: 1rem;
}
.playlist-actions {
display: flex;
justify-content: center;
gap: 15px;
margin-bottom: 10px;
}
.playlist-actions .el-button {
background-color: rgba(255, 255, 255, 0.8);
color: #ff6b81;
transition: all 0.3s;
}
.playlist-actions .el-button:hover {
background-color: white;
transform: scale(1.1);
}
.playlist-info {
padding: 15px;
}
.playlist-name {
font-weight: 600;
margin-bottom: 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: #333;
}
.playlist-desc {
font-size: 0.9rem;
color: #666;
margin-bottom: 8px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
height: 40px;
}
.playlist-creator {
display: flex;
justify-content: space-between;
font-size: 0.8rem;
color: #999;
}
</style>

View File

@@ -0,0 +1,659 @@
<script setup>
import { ref, onMounted, computed, onUnmounted } from 'vue' // 添加 onUnmounted
import { useRoute, useRouter } from 'vue-router'
import { usePlayerStore } from '../stores/player'
import { useUserStore } from '../stores/user'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getPlaylistById, toggleCollectPlaylist, deletePlaylist as apiDeletePlaylist } from '../api/playlist'
import { getCommentList, addComment, deleteComment as apiDeleteComment } from '../api/comment'
import { connect, subscribe, unsubscribe, disconnect } from '../utils/websocket' // 导入 WebSocket 工具
const route = useRoute()
const router = useRouter()
const playerStore = usePlayerStore()
const userStore = useUserStore()
// 歌单ID
const playlistId = computed(() => route.params.id)
// 歌单详情
const playlist = ref(null)
// 评论列表
const comments = ref([])
// 评论内容
const commentContent = ref('')
// 加载状态
const loading = ref(false)
// 收藏状态
const isCollected = ref(false)
// 是否是创建者
const isCreator = ref(false)
// 获取歌单详情
const fetchPlaylistDetail = async () => {
loading.value = true
try {
const res = await getPlaylistById(playlistId.value)
if (res.code === 200) {
playlist.value = res.data
// 处理歌单中的音乐
if (playlist.value.musics) {
playlist.value.songs = playlist.value.musics
} else if (!playlist.value.songs) {
playlist.value.songs = []
}
// 判断是否是创建者
isCreator.value = playlist.value.creator?.id === userStore.user?.id
// 获取收藏状态
isCollected.value = playlist.value.collected === true
} else {
ElMessage.error(res.message || '获取歌单详情失败')
}
} catch (error) {
console.error('获取歌单详情失败:', error)
ElMessage.error('获取歌单详情失败')
} finally {
loading.value = false
}
}
// 获取评论列表
const fetchComments = async () => {
try {
const res = await getCommentList(playlistId.value, 2, 1, 20) // targetType 2 表示歌单
if (res.code === 200) {
comments.value = res.data.list || res.data.records || []
} else {
ElMessage.error(res.message || '获取评论列表失败')
}
} catch (error) {
console.error('获取评论列表失败:', error)
ElMessage.error('获取评论列表失败')
}
}
// 播放全部
const playAll = () => {
if (!playlist.value || playlist.value.songs.length === 0) {
ElMessage.warning('没有可播放的音乐')
return
}
// 清空当前播放列表
playerStore.clearPlaylist()
// 添加所有音乐到播放列表
playlist.value.songs.forEach(song => {
playerStore.addToPlaylist(song)
})
// 播放第一首
playerStore.setCurrentMusic(playlist.value.songs[0])
ElMessage.success('开始播放全部歌曲')
}
// 播放音乐
const playMusic = (song) => {
playerStore.setCurrentMusic(song)
}
// 添加到播放列表
const addToPlaylist = (song) => {
playerStore.addToPlaylist(song)
ElMessage.success('已添加到播放列表')
}
// 收藏/取消收藏歌单
const toggleCollect = async () => {
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录')
router.push('/login')
return
}
try {
const res = await toggleCollectPlaylist(playlistId.value)
if (res.code === 200) {
isCollected.value = !isCollected.value
ElMessage.success(isCollected.value ? '收藏成功' : '已取消收藏')
} else {
ElMessage.error(res.message || '操作失败')
}
} catch (error) {
console.error('收藏操作失败:', error)
ElMessage.error('操作失败,请稍后重试')
}
}
// 删除评论
const deleteComment = async (commentId) => {
try {
await ElMessageBox.confirm('确定要删除这条评论吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
const res = await apiDeleteComment(commentId)
if (res.code === 200) {
comments.value = comments.value.filter(c => c.id !== commentId)
ElMessage.success('评论已删除')
} else {
ElMessage.error(res.message || '删除失败')
}
} catch (error) {
if (error !== 'cancel') {
console.error('删除评论失败:', error)
ElMessage.error('删除失败,请稍后重试')
}
}
}
// 提交评论
const submitComment = async () => {
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录')
router.push('/login')
return
}
if (!commentContent.value.trim()) {
ElMessage.warning('评论内容不能为空')
return
}
try {
const res = await addComment({
targetId: playlistId.value,
targetType: 2, // 2表示歌单
content: commentContent.value
})
if (res.code === 200) {
// 添加成功,评论将通过 WebSocket 推送,无需手动刷新
// await fetchComments() // 移除手动刷新
commentContent.value = ''
ElMessage.success('评论已发送') // 提示语可以调整
} else {
ElMessage.error(res.message || '评论失败')
}
} catch (error) {
console.error('提交评论失败:', error)
ElMessage.error('评论失败,请稍后重试')
}
}
// 编辑歌单
const editPlaylist = () => {
if (!isCreator.value) {
ElMessage.warning('只有创建者才能编辑歌单')
return
}
// 实际应用中,这里应该跳转到编辑页面或打开编辑对话框
ElMessage.info('编辑歌单功能开发中')
}
// 删除歌单
const deletePlaylist = () => {
if (!isCreator.value) {
ElMessage.warning('只有创建者才能删除歌单')
return
}
ElMessageBox.confirm('确定要删除该歌单吗?此操作不可恢复', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await apiDeletePlaylist(playlistId.value)
if (res.code === 200) {
ElMessage.success('歌单删除成功')
router.push('/playlist')
} else {
ElMessage.error(res.message || '删除失败')
}
} catch (error) {
console.error('删除歌单失败:', error)
ElMessage.error('删除失败,请稍后重试')
}
}).catch(() => {})
}
// 格式化时间
const formatTime = (seconds) => {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
// 格式化播放次数
const formatPlayCount = (count) => {
if (count < 10000) {
return count.toString()
} else if (count < 100000000) {
return (count / 10000).toFixed(1) + '万'
} else {
return (count / 100000000).toFixed(1) + '亿'
}
}
// WebSocket 订阅实例
let commentSubscription = null;
onMounted(() => {
fetchPlaylistDetail()
fetchComments()
// 连接 WebSocket 并订阅评论
connect(() => {
const destination = `/topic/comments/2/${playlistId.value}`; // 2 代表歌单类型
commentSubscription = subscribe(destination, (newComment) => {
// 收到新评论,添加到列表开头
if (newComment && newComment.id) {
// 检查是否已存在(防止重复添加,虽然理论上不应发生)
if (!comments.value.some(c => c.id === newComment.id)) {
comments.value.unshift(newComment); // 添加到开头
console.log('New comment received via WebSocket:', newComment);
}
} else {
console.warn("Received invalid comment data via WebSocket:", newComment);
}
});
}, (error) => {
console.error("WebSocket connection error:", error);
// 可以添加一些错误提示给用户
// ElMessage.error('评论服务连接失败,实时更新不可用');
});
})
onUnmounted(() => {
// 取消订阅并断开连接
if (commentSubscription) {
const destination = `/topic/comments/2/${playlistId.value}`;
unsubscribe(destination);
commentSubscription = null; // 清理订阅实例
}
disconnect();
})
</script>
<template>
<div class="playlist-detail-container" v-loading="loading">
<template v-if="playlist">
<!-- 歌单信息 -->
<div class="playlist-info">
<div class="playlist-cover">
<img :src="playlist.cover" :alt="playlist.name">
</div>
<div class="playlist-meta">
<h1 class="playlist-name">{{ playlist.name }}</h1>
<div class="playlist-creator">
<el-avatar :src="playlist.creator.avatar" :size="30" />
<span>{{ playlist.creator.username }}</span>
</div>
<div class="playlist-time">创建时间{{ playlist.createTime }}</div>
<div class="playlist-count">播放次数{{ formatPlayCount(playlist.playCount) }}</div>
<div class="playlist-desc">{{ playlist.description }}</div>
<div class="playlist-actions">
<el-button type="primary" @click="playAll" :disabled="!playlist.songs.length">
<el-icon><video-play /></el-icon>
播放全部
</el-button>
<el-button
:type="isCollected ? 'warning' : 'default'"
@click="toggleCollect"
>
<el-icon><star /></el-icon>
{{ isCollected ? '已收藏' : '收藏' }}
</el-button>
<template v-if="isCreator">
<el-button @click="editPlaylist">
<el-icon><edit /></el-icon>
编辑
</el-button>
<el-button type="danger" @click="deletePlaylist">
<el-icon><delete /></el-icon>
删除
</el-button>
</template>
</div>
</div>
</div>
<!-- 歌曲列表和评论 -->
<el-tabs class="playlist-tabs">
<el-tab-pane label="歌曲列表">
<el-table
:data="playlist.songs"
stripe
style="width: 100%"
@row-dblclick="playMusic"
>
<el-table-column width="80" align="center">
<template #default="scope">
<el-button
circle
:icon="'VideoPlay'"
size="small"
@click="playMusic(scope.row)"
></el-button>
</template>
</el-table-column>
<el-table-column label="封面" width="80" align="center">
<template #default="scope">
<img
:src="scope.row.cover"
:alt="scope.row.name"
class="song-cover"
>
</template>
</el-table-column>
<el-table-column prop="name" label="歌曲名" min-width="200"></el-table-column>
<el-table-column label="歌手" min-width="150">
<template #default="scope">
<span v-if="scope.row.singer">{{ scope.row.singer }}</span>
<span v-else-if="scope.row.singers && scope.row.singers.length">
{{ scope.row.singers.map(s => s.name).join(', ') }}
</span>
<span v-else>未知歌手</span>
</template>
</el-table-column>
<el-table-column prop="album" label="专辑" min-width="150"></el-table-column>
<el-table-column prop="duration" label="时长" width="100" align="center">
<template #default="scope">
{{ formatTime(scope.row.duration) }}
</template>
</el-table-column>
<el-table-column label="操作" width="100" align="center">
<template #default="scope">
<el-button
icon="Plus"
circle
size="small"
@click.stop="addToPlaylist(scope.row)"
></el-button>
</template>
</el-table-column>
</el-table>
<!-- 空状态 -->
<el-empty
v-if="!playlist.songs.length"
description="歌单中暂无歌曲"
></el-empty>
</el-tab-pane>
<el-tab-pane label="评论">
<!-- 评论输入框 -->
<div class="comment-input">
<el-input
v-model="commentContent"
type="textarea"
:rows="3"
placeholder="发表你的评论..."
/>
<el-button type="primary" @click="submitComment">发表评论</el-button>
</div>
<!-- 评论列表 -->
<div class="comment-list">
<div v-for="comment in comments" :key="comment.id" class="comment-item">
<div class="comment-avatar">
<el-avatar :src="comment.user.avatar" :size="40" />
</div>
<div class="comment-content">
<div class="comment-header">
<div class="comment-user">{{ comment.user.username }}</div>
<div class="comment-actions">
<el-button
v-if="userStore.isAdmin || comment.user.id === userStore.user?.id"
type="danger"
size="small"
@click="deleteComment(comment.id)"
>
删除
</el-button>
</div>
</div>
<div class="comment-text">{{ comment.content }}</div>
<div class="comment-time">{{ comment.createTime }}</div>
</div>
</div>
</div>
</el-tab-pane>
</el-tabs>
</template>
</div>
</template>
<style scoped>
.playlist-detail-container {
padding: 20px;
min-height: calc(100vh - 140px);
background-color: transparent;
border-radius: 15px;
}
.playlist-info {
display: flex;
margin-bottom: 30px;
padding: 30px;
background-color: rgba(255, 255, 255, 0.85);
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.playlist-cover {
width: 220px;
height: 220px;
margin-right: 40px;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(255, 107, 129, 0.1);
}
.playlist-cover img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s;
}
.playlist-cover:hover img {
transform: scale(1.05);
}
.playlist-meta {
flex: 1;
display: flex;
flex-direction: column;
}
.playlist-name {
font-size: 2.2rem;
margin: 0 0 15px 0;
color: #ff6b81;
font-weight: 600;
}
.playlist-creator {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.playlist-creator span {
margin-left: 10px;
color: #666;
}
.playlist-time,
.playlist-count {
margin-bottom: 10px;
color: #666;
font-size: 1rem;
}
.playlist-desc {
margin-bottom: 20px;
color: #666;
font-size: 1rem;
line-height: 1.6;
max-height: 100px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
}
.playlist-actions {
margin-top: auto;
display: flex;
gap: 15px;
}
.playlist-actions .el-button {
border-radius: 20px;
padding: 12px 24px;
}
.playlist-tabs {
margin: 0 0 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);
}
.playlist-tabs :deep(.el-tabs__item) {
font-size: 1rem;
font-weight: 500;
color: #666;
}
.playlist-tabs :deep(.el-tabs__item.is-active) {
color: #ff6b81;
}
.playlist-tabs :deep(.el-tabs__active-bar) {
background-color: #ff6b81;
}
.playlist-tabs :deep(.el-tabs__nav-wrap::after) {
background-color: #ffb6c1;
}
.song-cover {
width: 50px;
height: 50px;
border-radius: 8px;
object-fit: cover;
transition: transform 0.3s;
}
.song-cover:hover {
transform: scale(1.1);
}
.comment-input {
margin-bottom: 20px;
display: flex;
flex-direction: column;
gap: 15px;
align-items: flex-end;
}
.comment-input .el-button {
background-color: #ff6b81;
color: white;
border-radius: 20px;
padding: 10px 20px;
transition: all 0.3s;
}
.comment-input .el-button:hover {
background-color: #ff4757;
transform: translateY(-2px);
}
.comment-list {
margin-top: 20px;
}
.comment-item {
display: flex;
margin-bottom: 20px;
padding: 15px;
background-color: #fff9f9;
border-radius: 12px;
transition: all 0.3s;
}
.comment-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(255, 107, 129, 0.1);
}
.comment-avatar {
margin-right: 15px;
}
.comment-content {
flex: 1;
}
.comment-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 5px;
}
.comment-user {
font-weight: 600;
color: #ff6b81;
}
.comment-text {
margin-bottom: 5px;
color: #333;
line-height: 1.5;
}
.comment-time {
font-size: 0.8rem;
color: #999;
}
.comment-actions .el-button {
border-radius: 20px;
padding: 5px 10px;
transition: all 0.3s;
}
.comment-actions .el-button:hover {
transform: scale(1.05);
}
</style>

View File

@@ -0,0 +1,207 @@
<script setup>
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { register } from '../api/user'
const router = useRouter()
// 注册表单
const registerForm = reactive({
username: '',
password: '',
confirmPassword: '',
email: '',
nickname: '',
gender: 0
})
// 注册表单验证规则
const registerRules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 4, max: 20, message: '用户名长度应为4-20个字符', trigger: 'blur' },
{ pattern: /^[a-zA-Z0-9_]+$/, message: '用户名只能包含字母、数字和下划线', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, max: 20, message: '密码长度应为6-20个字符', trigger: 'blur' }
],
confirmPassword: [
{ required: true, message: '请确认密码', trigger: 'blur' },
{
validator: (rule, value, callback) => {
if (value !== registerForm.password) {
callback(new Error('两次输入的密码不一致'))
} else {
callback()
}
},
trigger: 'blur'
}
],
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '请输入正确的邮箱格式', trigger: 'blur' }
],
nickname: [
{ max: 20, message: '昵称长度不能超过20个字符', trigger: 'blur' }
]
}
const registerFormRef = ref(null)
const loading = ref(false)
// 注册方法
const handleRegister = () => {
registerFormRef.value.validate(async (valid) => {
if (valid) {
loading.value = true
try {
// 调用注册API
const res = await register(registerForm)
if (res.code === 200) {
ElMessage.success(res.msg || '注册成功,请登录')
router.push('/login')
}
} catch (error) {
console.error('注册失败:', error)
ElMessage.error(error.message || '注册失败,请稍后再试')
} finally {
loading.value = false
}
}
})
}
// 跳转到登录页面
const goToLogin = () => {
router.push('/login')
}
</script>
<template>
<div class="register-container">
<div class="register-box">
<div class="register-header">
<h2>用户注册</h2>
</div>
<el-form
ref="registerFormRef"
:model="registerForm"
:rules="registerRules"
label-width="0"
class="register-form"
>
<el-form-item prop="username">
<el-input
v-model="registerForm.username"
placeholder="用户名"
prefix-icon="User"
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="registerForm.password"
type="password"
placeholder="密码"
prefix-icon="Lock"
show-password
/>
</el-form-item>
<el-form-item prop="confirmPassword">
<el-input
v-model="registerForm.confirmPassword"
type="password"
placeholder="确认密码"
prefix-icon="Lock"
show-password
/>
</el-form-item>
<el-form-item prop="email">
<el-input
v-model="registerForm.email"
placeholder="邮箱"
prefix-icon="Message"
/>
</el-form-item>
<el-form-item prop="nickname">
<el-input
v-model="registerForm.nickname"
placeholder="昵称(选填)"
prefix-icon="User"
/>
</el-form-item>
<el-form-item prop="gender">
<el-radio-group v-model="registerForm.gender">
<el-radio :label="0">保密</el-radio>
<el-radio :label="1"></el-radio>
<el-radio :label="2"></el-radio>
</el-radio-group>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:loading="loading"
class="register-button"
@click="handleRegister"
>
注册
</el-button>
</el-form-item>
</el-form>
<div class="register-actions">
<el-button text @click="goToLogin">已有账号立即登录</el-button>
</div>
</div>
</div>
</template>
<style scoped>
.register-container {
display: flex;
justify-content: center;
align-items: center;
min-height: calc(100vh - 200px);
background-color: #f5f7fa;
}
.register-box {
width: 400px;
padding: 30px;
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
.register-header {
text-align: center;
margin-bottom: 30px;
}
.register-header h2 {
margin: 0;
color: #409eff;
}
.register-form {
margin-bottom: 20px;
}
.register-button {
width: 100%;
}
.register-actions {
display: flex;
justify-content: center;
}
</style>

View File

@@ -0,0 +1,692 @@
<template>
<div class="search-container">
<div class="search-header">
<h1>搜索结果: "{{ keyword }}"</h1>
<div class="search-input">
<el-input
v-model="searchKeyword"
placeholder="搜索音乐、歌单、歌手"
clearable
@keyup.enter="handleSearch"
>
<template #suffix>
<el-icon class="el-input__icon" @click="handleSearch">
<Search />
</el-icon>
</template>
</el-input>
</div>
</div>
<el-tabs v-model="activeTab" @tab-click="handleTabChange">
<el-tab-pane label="音乐" name="music">
<div class="search-result" v-loading="loadingMusic">
<MusicList
:list="musicList"
:loading="loadingMusic"
:total="musicTotal"
v-model:current-page="musicPage"
v-model:page-size="musicPageSize"
@page-change="fetchMusicList"
@collect-change="handleCollectChange"
/>
<el-empty
v-if="musicList.length === 0 && !loadingMusic"
description="未找到相关音乐"
></el-empty>
</div>
</el-tab-pane>
<el-tab-pane label="歌单" name="playlist">
<div class="search-result" v-loading="loadingPlaylist">
<div class="playlist-list">
<div
v-for="playlist in playlistList"
:key="playlist.id"
class="playlist-item"
@click="goToPlaylist(playlist.id)"
>
<div class="playlist-cover">
<img :src="playlist.cover" :alt="playlist.name">
<div class="playlist-info-overlay">
<div class="playlist-play-count">
<el-icon><video-play /></el-icon>
{{ formatPlayCount(playlist.playCount) }}
</div>
<div class="playlist-actions">
<el-button
circle
:icon="playlist.collected ? 'StarFilled' : 'Star'"
:type="playlist.collected ? 'warning' : ''"
@click.stop="handleCollectPlaylist(playlist.id)"
></el-button>
</div>
</div>
</div>
<div class="playlist-info">
<div class="playlist-name">{{ playlist.name }}</div>
<div class="playlist-desc">{{ playlist.description }}</div>
<div class="playlist-creator">
<span>by {{ playlist.creator?.username || '未知用户' }}</span>
<span>{{ playlist.songCount || 0 }}首歌曲</span>
</div>
</div>
</div>
</div>
<el-empty
v-if="playlistList.length === 0 && !loadingPlaylist"
description="未找到相关歌单"
></el-empty>
<div class="pagination" v-if="playlistTotal > 0">
<el-pagination
v-model:current-page="playlistPage"
v-model:page-size="playlistPageSize"
:page-sizes="[12, 24, 36, 48]"
layout="total, sizes, prev, pager, next, jumper"
:total="playlistTotal"
@size-change="handlePlaylistSizeChange"
@current-change="handlePlaylistPageChange"
/>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="歌手" name="singer">
<div class="search-result" v-loading="loadingSinger">
<div class="singer-list">
<div
v-for="singer in singerList"
:key="singer.id"
class="singer-item"
@click="goToSinger(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.stop="handleCollectSinger(singer.id, $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="singerList.length === 0 && !loadingSinger"
description="未找到相关歌手"
></el-empty>
<div class="pagination" v-if="singerTotal > 0">
<el-pagination
v-model:current-page="singerPage"
v-model:page-size="singerPageSize"
:page-sizes="[20, 40, 60, 80]"
layout="total, sizes, prev, pager, next, jumper"
:total="singerTotal"
@size-change="handleSingerSizeChange"
@current-change="handleSingerPageChange"
/>
</div>
</div>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '../stores/user'
import { ElMessage } from 'element-plus'
import { Search } from '@element-plus/icons-vue'
import { searchMusic, toggleCollectMusic } from '../api/music'
import { searchPlaylist, toggleCollectPlaylist } from '../api/playlist'
import { searchSinger, toggleCollectSinger } from '../api/singer'
import { processImageUrl } from '../utils/imageUtils'
import { processMusicUrl } from '../api/music'
import MusicList from '../components/MusicList.vue'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
// 搜索关键词
const keyword = computed(() => route.query.keyword || '')
const searchKeyword = ref(keyword.value)
// 当前激活的标签页
const activeTab = ref('music')
// 音乐搜索结果
const musicList = ref([])
const musicPage = ref(1)
const musicPageSize = ref(10)
const musicTotal = ref(0)
const loadingMusic = ref(false)
// 歌单搜索结果
const playlistList = ref([])
const playlistPage = ref(1)
const playlistPageSize = ref(12)
const playlistTotal = ref(0)
const loadingPlaylist = ref(false)
// 歌手搜索结果
const singerList = ref([])
const singerPage = ref(1)
const singerPageSize = ref(20)
const singerTotal = ref(0)
const loadingSinger = ref(false)
// 处理搜索
const handleSearch = () => {
if (!searchKeyword.value.trim()) {
ElMessage.warning('请输入搜索关键词')
return
}
router.push({
path: '/search',
query: { keyword: searchKeyword.value }
})
}
// 获取音乐搜索结果
const fetchMusicList = async () => {
if (!keyword.value) return
loadingMusic.value = true
try {
const res = await searchMusic(keyword.value, musicPage.value, musicPageSize.value)
if (res.code === 200) {
musicList.value = res.data.list || res.data.records || []
musicTotal.value = res.data.total || 0
// 处理音乐URL和封面URL
musicList.value = musicList.value.map(item => ({
...item,
cover: processMusicUrl(item.cover),
url: processMusicUrl(item.url)
}))
} else {
ElMessage.error(res.message || '搜索音乐失败')
}
} catch (error) {
console.error('搜索音乐失败:', error)
ElMessage.error('搜索音乐失败')
} finally {
loadingMusic.value = false
}
}
// 获取歌单搜索结果
const fetchPlaylistList = async () => {
if (!keyword.value) return
loadingPlaylist.value = true
try {
const res = await searchPlaylist(keyword.value, playlistPage.value, playlistPageSize.value)
if (res.code === 200) {
playlistList.value = res.data.list || res.data.records || []
playlistTotal.value = res.data.total || 0
// 处理封面URL
playlistList.value = playlistList.value.map(item => ({
...item,
cover: processImageUrl(item.cover)
}))
} else {
ElMessage.error(res.message || '搜索歌单失败')
}
} catch (error) {
console.error('搜索歌单失败:', error)
ElMessage.error('搜索歌单失败')
} finally {
loadingPlaylist.value = false
}
}
// 获取歌手搜索结果
const fetchSingerList = async () => {
if (!keyword.value) return
loadingSinger.value = true
try {
const res = await searchSinger(keyword.value, singerPage.value, singerPageSize.value)
if (res.code === 200) {
singerList.value = res.data.list || res.data.records || []
singerTotal.value = res.data.total || 0
// 处理头像URL
singerList.value = singerList.value.map(item => ({
...item,
avatar: processImageUrl(item.avatar)
}))
} else {
ElMessage.error(res.message || '搜索歌手失败')
}
} catch (error) {
console.error('搜索歌手失败:', error)
ElMessage.error('搜索歌手失败')
} finally {
loadingSinger.value = false
}
}
// 处理标签页切换
const handleTabChange = () => {
// 无论列表是否为空,都加载对应标签页的数据
if (activeTab.value === 'music') {
fetchMusicList()
} else if (activeTab.value === 'playlist') {
fetchPlaylistList()
} else if (activeTab.value === 'singer') {
fetchSingerList()
}
}
// 处理音乐分页变化
const handleMusicPageChange = (page) => {
musicPage.value = page
fetchMusicList()
}
// 处理音乐每页数量变化
const handleMusicSizeChange = (size) => {
musicPageSize.value = size
musicPage.value = 1
fetchMusicList()
}
// 处理歌单分页变化
const handlePlaylistPageChange = (page) => {
playlistPage.value = page
fetchPlaylistList()
}
// 处理歌单每页数量变化
const handlePlaylistSizeChange = (size) => {
playlistPageSize.value = size
playlistPage.value = 1
fetchPlaylistList()
}
// 处理歌手分页变化
const handleSingerPageChange = (page) => {
singerPage.value = page
fetchSingerList()
}
// 处理歌手每页数量变化
const handleSingerSizeChange = (size) => {
singerPageSize.value = size
singerPage.value = 1
fetchSingerList()
}
// 收藏/取消收藏音乐
const handleCollectChange = async (id) => {
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录')
router.push('/login')
return
}
try {
const res = await toggleCollectMusic(id)
if (res.code === 200) {
const music = musicList.value.find(m => m.id === id)
if (music) {
music.collected = !music.collected
ElMessage.success(music.collected ? '收藏成功' : '已取消收藏')
}
} else {
ElMessage.error(res.message || '操作失败')
}
} catch (error) {
console.error('收藏操作失败:', error)
ElMessage.error('操作失败,请稍后重试')
}
}
// 收藏/取消收藏歌单
const handleCollectPlaylist = async (id) => {
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录')
router.push('/login')
return
}
try {
const res = await toggleCollectPlaylist(id)
if (res.code === 200) {
const playlist = playlistList.value.find(p => p.id === id)
if (playlist) {
playlist.collected = !playlist.collected
ElMessage.success(playlist.collected ? '收藏成功' : '已取消收藏')
}
} else {
ElMessage.error(res.message || '操作失败')
}
} catch (error) {
console.error('收藏操作失败:', error)
ElMessage.error('操作失败,请稍后重试')
}
}
// 收藏/取消收藏歌手
const handleCollectSinger = async (id, event) => {
if (event) event.stopPropagation()
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录')
router.push('/login')
return
}
try {
const res = await toggleCollectSinger(id)
if (res.code === 200) {
const singer = singerList.value.find(s => s.id === id)
if (singer) {
singer.collected = !singer.collected
ElMessage.success(singer.collected ? '收藏成功' : '已取消收藏')
}
} else {
ElMessage.error(res.message || '操作失败')
}
} catch (error) {
console.error('收藏操作失败:', error)
ElMessage.error('操作失败,请稍后重试')
}
}
// 跳转到歌单详情
const goToPlaylist = (id) => {
router.push(`/playlist/${id}`)
}
// 跳转到歌手详情
const goToSinger = (id) => {
router.push(`/singer/${id}`)
}
// 格式化播放次数
const formatPlayCount = (count) => {
if (!count) return '0'
if (count < 10000) {
return count.toString()
} else if (count < 100000000) {
return (count / 10000).toFixed(1) + '万'
} else {
return (count / 100000000).toFixed(1) + '亿'
}
}
// 格式化粉丝数
const formatFans = (count) => {
if (count < 10000) {
return count.toString()
} else if (count < 100000000) {
return (count / 10000).toFixed(1) + '万'
} else {
return (count / 100000000).toFixed(1) + '亿'
}
}
// 监听关键词变化
watch(() => route.query.keyword, (newKeyword) => {
searchKeyword.value = newKeyword
// 重置分页
musicPage.value = 1
playlistPage.value = 1
singerPage.value = 1
// 无论当前标签页是什么,都获取所有类型的搜索结果
fetchMusicList()
fetchPlaylistList()
fetchSingerList()
}, { immediate: true })
onMounted(() => {
// 初始获取所有类型的搜索结果
if (keyword.value) {
// 预加载所有标签页的数据
fetchMusicList()
fetchPlaylistList()
fetchSingerList()
}
})
</script>
<style scoped>
.search-container {
padding: 20px 20px 100px 20px; /* 增加底部填充,为播放器留出空间 */
}
.search-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.search-header h1 {
margin: 0;
font-size: 1.8rem;
color: #333;
}
.search-input {
width: 300px;
}
.search-result {
margin-top: 20px;
min-height: 300px;
}
.el-tabs {
margin-bottom: 20px;
}
/* 歌单列表样式 */
.playlist-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 15px;
padding: 0 20px;
}
.playlist-item {
cursor: pointer;
transition: all 0.3s;
}
.playlist-item:hover {
transform: translateY(-5px);
}
.playlist-cover {
position: relative;
width: 100%;
padding-top: 100%;
border-radius: 8px;
overflow: hidden;
margin-bottom: 10px;
}
.playlist-cover img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.playlist-info-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: linear-gradient(to bottom, rgba(0,0,0,0.1), rgba(0,0,0,0.5));
opacity: 0;
transition: opacity 0.3s;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 10px;
box-sizing: border-box;
}
.playlist-cover:hover .playlist-info-overlay {
opacity: 1;
}
.playlist-play-count {
color: white;
font-size: 0.8rem;
display: flex;
align-items: center;
align-self: flex-end;
}
.playlist-play-count .el-icon {
margin-right: 5px;
}
.playlist-actions {
display: flex;
justify-content: center;
}
.playlist-name {
font-weight: bold;
margin-bottom: 5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.playlist-desc {
font-size: 0.8rem;
color: #666;
margin-bottom: 5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.playlist-creator {
font-size: 0.8rem;
color: #999;
display: flex;
justify-content: space-between;
}
/* 歌手列表样式 */
.singer-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 20px;
}
.singer-item {
background-color: #fff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
transition: all 0.3s;
cursor: pointer;
}
.singer-item:hover {
transform: translateY(-5px);
box-shadow: 0 5px 15px 0 rgba(0, 0, 0, 0.1);
}
.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.05);
}
.singer-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(to bottom, rgba(0,0,0,0.1), rgba(0,0,0,0.7));
opacity: 0;
transition: opacity 0.3s;
display: flex;
align-items: center;
justify-content: center;
}
.singer-item:hover .singer-overlay {
opacity: 1;
}
.singer-info {
padding: 15px;
}
.singer-name {
font-weight: bold;
margin-bottom: 8px;
text-align: center;
}
.singer-stats {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 10px;
font-size: 0.8rem;
color: #999;
}
.singer-stats span {
white-space: nowrap;
}
.pagination {
margin-top: 30px;
display: flex;
justify-content: center;
}
</style>

View File

@@ -0,0 +1,499 @@
<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, toggleCollectSinger } 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)
}))
} 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)
}))
} 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 (id, event) => {
event.stopPropagation()
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录')
router.push('/login')
return
}
try {
const res = await toggleCollectSinger(id)
if (res.code === 200) {
// 更新收藏状态
const singer = singers.value.find(s => s.id === id)
if (singer) {
singer.collected = !singer.collected
ElMessage.success(singer.collected ? '收藏成功' : '已取消收藏')
}
} else {
ElMessage.error(res.message || '操作失败')
}
} catch (error) {
console.error('收藏操作失败:', error)
ElMessage.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="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.id, 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="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.id, 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>

View File

@@ -0,0 +1,576 @@
<script setup>
import { ref, onMounted, computed, onUnmounted } from 'vue' // 添加 onUnmounted
import { useRoute, useRouter } from 'vue-router'
import { usePlayerStore } from '../stores/player'
import { useUserStore } from '../stores/user'
import { ElMessage } from 'element-plus'
import { getSingerById, toggleCollectSinger } from '../api/singer'
import { getCommentList, addComment } from '../api/comment'
import { processImageUrl } from '../utils/imageUtils'
import { processMusicUrl, getMusicBySinger } from '../api/music'
import { connect, subscribe, unsubscribe, disconnect } from '../utils/websocket' // 导入 WebSocket 工具
const route = useRoute()
const router = useRouter()
const playerStore = usePlayerStore()
const userStore = useUserStore()
// 歌手ID
const singerId = computed(() => route.params.id)
// 歌手详情
const singer = ref(null)
// 歌手的音乐列表
const songs = ref([])
// 歌手的音乐列表
// 评论列表
const comments = ref([])
// 评论内容
const commentContent = ref('')
// 加载状态
const loading = ref(false)
// 收藏状态
const isCollected = ref(false)
// 获取歌手详情
const fetchSingerDetail = async () => {
loading.value = true
try {
const res = await getSingerById(singerId.value)
if (res.code === 200) {
singer.value = res.data
// 处理头像图片URL
if (singer.value.avatar) {
singer.value.avatar = processImageUrl(singer.value.avatar)
}
// 获取歌手的音乐列表
await fetchSingerMusic()
// 获取收藏状态
isCollected.value = singer.value.collected === true
} else {
ElMessage.error(res.message || '获取歌手详情失败')
}
} catch (error) {
console.error('获取歌手详情失败:', error)
ElMessage.error('获取歌手详情失败')
} finally {
loading.value = false
}
}
// 获取歌手的音乐列表
const fetchSingerMusic = async () => {
try {
const res = await getMusicBySinger(singerId.value, 1, 20)
if (res.code === 200) {
songs.value = res.data.list || res.data.records || []
// 处理音乐URL和封面URL
songs.value = songs.value.map(song => ({
...song,
cover: processMusicUrl(song.cover),
url: processMusicUrl(song.url)
}))
} else {
ElMessage.error(res.message || '获取歌手音乐失败')
}
} catch (error) {
console.error('获取歌手音乐失败:', error)
ElMessage.error('获取歌手音乐失败')
}
}
// 获取评论列表
const fetchComments = async () => {
try {
const res = await getCommentList(singerId.value, 3, 1, 20) // targetType 3 表示歌手
if (res.code === 200) {
comments.value = res.data.list || res.data.records || []
// 处理用户头像
comments.value.forEach(comment => {
if (comment.user && comment.user.avatar) {
comment.user.avatar = processImageUrl(comment.user.avatar)
}
})
} else {
ElMessage.error(res.message || '获取评论列表失败')
}
} catch (error) {
console.error('获取评论列表失败:', error)
ElMessage.error('获取评论列表失败')
}
}
// 播放全部
const playAll = () => {
if (songs.value.length === 0) {
ElMessage.warning('没有可播放的音乐')
return
}
// 清空当前播放列表
playerStore.clearPlaylist()
// 添加所有音乐到播放列表
songs.value.forEach(song => {
const musicObj = {
...song,
singer: singer.value.name
}
playerStore.addToPlaylist(musicObj)
})
// 播放第一首
playerStore.setCurrentMusic({
...songs.value[0],
singer: singer.value.name
})
ElMessage.success('开始播放全部歌曲')
}
// 播放音乐
const playMusic = (song) => {
playerStore.setCurrentMusic({
...song,
singer: singer.value.name
})
}
// 添加到播放列表
const addToPlaylist = (song) => {
playerStore.addToPlaylist({
...song,
singer: singer.value.name
})
ElMessage.success('已添加到播放列表')
}
// 收藏/取消收藏歌手
const toggleCollect = async () => {
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录')
router.push('/login')
return
}
try {
const res = await toggleCollectSinger(singerId.value)
if (res.code === 200) {
isCollected.value = !isCollected.value
ElMessage.success(isCollected.value ? '收藏成功' : '已取消收藏')
} else {
ElMessage.error(res.message || '操作失败')
}
} catch (error) {
console.error('收藏操作失败:', error)
ElMessage.error('操作失败,请稍后重试')
}
}
// 提交评论
const submitComment = async () => {
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录')
router.push('/login')
return
}
if (!commentContent.value.trim()) {
ElMessage.warning('评论内容不能为空')
return
}
try {
const res = await addComment({
targetId: singerId.value,
targetType: 3, // 3表示歌手
content: commentContent.value
})
if (res.code === 200) {
// 添加成功,评论将通过 WebSocket 推送,无需手动刷新
// await fetchComments() // 移除手动刷新
commentContent.value = ''
ElMessage.success('评论已发送') // 提示语可以调整
} else {
ElMessage.error(res.message || '评论失败')
}
} catch (error) {
console.error('提交评论失败:', error)
ElMessage.error('评论失败,请稍后重试')
}
}
// 格式化时间
const formatTime = (seconds) => {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
// 格式化粉丝数
const formatFans = (count) => {
if (count < 10000) {
return count.toString()
} else if (count < 100000000) {
return (count / 10000).toFixed(1) + '万'
} else {
return (count / 100000000).toFixed(1) + '亿'
}
}
// 获取歌手类型名称
const getSingerTypeName = (type) => {
const typeMap = {
1: '华语男歌手',
2: '华语女歌手',
3: '华语组合',
4: '欧美男歌手',
5: '欧美女歌手',
6: '欧美组合',
7: '日韩男歌手',
8: '日韩女歌手',
9: '日韩组合'
}
return typeMap[type] || '未知'
}
// WebSocket 订阅实例
let commentSubscription = null;
onMounted(() => {
fetchSingerDetail()
fetchComments()
// 连接 WebSocket 并订阅评论
connect(() => {
const destination = `/topic/comments/3/${singerId.value}`; // 3 代表歌手类型
commentSubscription = subscribe(destination, (newComment) => {
// 收到新评论,添加到列表开头
if (newComment && newComment.id) {
// 处理新评论的用户头像
if (newComment.user && newComment.user.avatar) {
newComment.user.avatar = processImageUrl(newComment.user.avatar);
}
// 检查是否已存在
if (!comments.value.some(c => c.id === newComment.id)) {
comments.value.unshift(newComment); // 添加到开头
console.log('New comment received via WebSocket:', newComment);
}
} else {
console.warn("Received invalid comment data via WebSocket:", newComment);
}
});
}, (error) => {
console.error("WebSocket connection error:", error);
// ElMessage.error('评论服务连接失败,实时更新不可用');
});
})
onUnmounted(() => {
// 取消订阅并断开连接
if (commentSubscription) {
const destination = `/topic/comments/3/${singerId.value}`;
unsubscribe(destination);
commentSubscription = null; // 清理订阅实例
}
disconnect();
})
</script>
<template>
<div class="singer-detail-container" v-loading="loading">
<template v-if="singer">
<!-- 歌手信息 -->
<div class="singer-info">
<div class="singer-avatar">
<img :src="singer.avatar" :alt="singer.name">
</div>
<div class="singer-meta">
<h1 class="singer-name">{{ singer.name }}</h1>
<div class="singer-type">类型{{ getSingerTypeName(singer.type) }}</div>
<div class="singer-stats">
<div class="stat-item">
<div class="stat-value">{{ singer.musicCount }}</div>
<div class="stat-label">单曲数</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ formatFans(singer.fansCount || 0) }}</div>
<div class="stat-label">粉丝数</div>
</div>
</div>
<div class="singer-intro">{{ singer.introduction }}</div>
<div class="singer-actions">
<el-button type="primary" @click="playAll" :disabled="!songs.length">
<el-icon><video-play /></el-icon>
播放全部
</el-button>
<el-button
:type="isCollected ? 'warning' : 'default'"
@click="toggleCollect"
>
<el-icon><star /></el-icon>
{{ isCollected ? '已收藏' : '收藏' }}
</el-button>
</div>
</div>
</div>
<!-- 歌曲列表专辑和评论 -->
<el-tabs class="singer-tabs">
<el-tab-pane label="热门歌曲">
<el-table
:data="songs"
stripe
style="width: 100%"
@row-dblclick="playMusic"
>
<el-table-column width="80" align="center">
<template #default="scope">
<el-button
circle
:icon="'VideoPlay'"
size="small"
@click="playMusic(scope.row)"
></el-button>
</template>
</el-table-column>
<el-table-column label="封面" width="80" align="center">
<template #default="scope">
<img
:src="scope.row.cover"
:alt="scope.row.name"
class="song-cover"
>
</template>
</el-table-column>
<el-table-column prop="name" label="歌曲名" min-width="200"></el-table-column>
<el-table-column label="专辑" min-width="150">
<template #default="scope">
<span v-if="scope.row.album">{{ scope.row.album }}</span>
<span v-else>无专辑信息</span>
</template>
</el-table-column>
<el-table-column prop="duration" label="时长" width="100" align="center">
<template #default="scope">
{{ formatTime(scope.row.duration) }}
</template>
</el-table-column>
<el-table-column label="操作" width="100" align="center">
<template #default="scope">
<el-button
icon="Plus"
circle
size="small"
@click.stop="addToPlaylist(scope.row)"
></el-button>
</template>
</el-table-column>
</el-table>
<!-- 空状态 -->
<el-empty
v-if="!songs.length"
description="暂无歌曲"
></el-empty>
</el-tab-pane>
<el-tab-pane label="评论">
<!-- 评论输入框 -->
<div class="comment-input">
<el-input
v-model="commentContent"
type="textarea"
:rows="3"
placeholder="发表你的评论..."
/>
<el-button type="primary" @click="submitComment">发表评论</el-button>
</div>
<!-- 评论列表 -->
<div class="comment-list">
<div v-for="comment in comments" :key="comment.id" class="comment-item">
<div class="comment-avatar">
<el-avatar :src="comment.user.avatar" :size="40" />
</div>
<div class="comment-content">
<div class="comment-user">{{ comment.user.username }}</div>
<div class="comment-text">{{ comment.content }}</div>
<div class="comment-time">{{ comment.createTime }}</div>
</div>
</div>
</div>
</el-tab-pane>
</el-tabs>
</template>
</div>
</template>
<style scoped>
.singer-detail-container {
padding: 0;
height: 100%;
background-color: #fff;
border-radius: 8px;
}
.singer-info {
display: flex;
margin-bottom: 30px;
padding: 20px;
border-bottom: 1px solid #eee;
}
.singer-avatar {
width: 180px;
height: 180px;
margin-right: 30px;
border-radius: 4px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.singer-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.singer-meta {
flex: 1;
display: flex;
flex-direction: column;
}
.singer-name {
font-size: 2rem;
margin: 0 0 15px 0;
color: #333;
}
.singer-type {
margin-bottom: 15px;
color: #666;
font-size: 1rem;
}
.singer-stats {
display: flex;
margin-bottom: 15px;
}
.stat-item {
margin-right: 30px;
text-align: center;
}
.stat-value {
font-size: 1.2rem;
font-weight: bold;
color: #333;
}
.stat-label {
font-size: 0.8rem;
color: #999;
}
.singer-intro {
margin-bottom: 20px;
color: #666;
font-size: 1rem;
line-height: 1.6;
max-height: 120px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 5;
-webkit-box-orient: vertical;
}
.singer-actions {
margin-top: auto;
display: flex;
gap: 10px;
}
.singer-tabs {
margin: 0 20px 30px;
padding: 20px;
}
.song-cover {
width: 50px;
height: 50px;
border-radius: 4px;
object-fit: cover;
}
.comment-input {
margin-bottom: 20px;
display: flex;
flex-direction: column;
gap: 10px;
align-items: flex-end;
}
.comment-list {
margin-top: 20px;
}
.comment-item {
display: flex;
margin-bottom: 20px;
padding-bottom: 20px;
border-bottom: 1px solid #eee;
}
.comment-avatar {
margin-right: 15px;
}
.comment-content {
flex: 1;
}
.comment-user {
font-weight: bold;
margin-bottom: 5px;
}
.comment-text {
margin-bottom: 5px;
color: #333;
line-height: 1.5;
}
.comment-time {
font-size: 0.8rem;
color: #999;
}
</style>

View File

@@ -0,0 +1,493 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '../stores/user'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getCurrentUser, updateUserInfo, uploadAvatar } from '../api/user'
import { getCollectedMusic } from '../api/music'
import { getCollectedPlaylist, getUserPlaylist } from '../api/playlist'
import { getCollectedSinger } from '../api/singer'
import { processImageUrl } from '../utils/imageUtils'
const router = useRouter()
const userStore = useUserStore()
// 用户信息
const userInfo = ref(null)
// 用户统计数据
const userStats = ref({
collectMusicCount: 0,
collectPlaylistCount: 0,
collectSingerCount: 0,
createPlaylistCount: 0
})
// 加载状态
const loading = ref(false)
// 编辑表单
const editForm = ref({
nickname: '',
gender: 0,
email: '',
phone: '',
introduction: ''
})
// 是否显示编辑对话框
const showEditDialog = ref(false)
// 头像上传URL
const avatarUploadUrl = ref(`${import.meta.env.VITE_API_BASE_URL || ''}/api/user/upload/avatar`)
// 头像上传headers
const uploadHeaders = ref({
[localStorage.getItem('tokenName') || 'satoken']: userStore.token
})
// 当前激活的菜单
const activeMenu = computed(() => {
const path = router.currentRoute.value.path
if (path.includes('/user/collect/music')) return 'collect-music'
if (path.includes('/user/collect/playlist')) return 'collect-playlist'
if (path.includes('/user/collect/singer')) return 'collect-singer'
if (path.includes('/user/playlist')) return 'playlist'
if (path.includes('/user/password')) return 'password'
return 'profile'
})
// 打开编辑对话框
const openEditDialog = () => {
if (!userInfo.value) return
editForm.value = {
nickname: userInfo.value.nickname || '',
gender: userInfo.value.gender || 0,
email: userInfo.value.email || '',
phone: userInfo.value.phone || '',
introduction: userInfo.value.introduction || ''
}
showEditDialog.value = true
}
// 提交编辑表单
const submitEditForm = async () => {
try {
const res = await updateUserInfo(editForm.value)
if (res.code === 200) {
// 更新成功,重新获取用户信息
await fetchUserInfo()
showEditDialog.value = false
ElMessage.success('个人信息更新成功')
} else {
ElMessage.error(res.message || '更新失败')
}
} catch (error) {
console.error('更新用户信息失败:', error)
ElMessage.error('更新失败,请稍后重试')
}
}
// 头像上传成功
const handleAvatarSuccess = async (response) => {
if (response.code === 200) {
// 上传成功,重新获取用户信息
await fetchUserInfo()
ElMessage.success('头像上传成功')
} else {
ElMessage.error(response.message || '头像上传失败')
}
}
// 头像上传前的处理
const beforeAvatarUpload = (file) => {
const isJPG = file.type === 'image/jpeg'
const isPNG = file.type === 'image/png'
const isLt2M = file.size / 1024 / 1024 < 2
if (!isJPG && !isPNG) {
ElMessage.error('头像只能是JPG或PNG格式!')
return false
}
if (!isLt2M) {
ElMessage.error('头像大小不能超过2MB!')
return false
}
return true
}
// 导航到子页面
const navigateTo = (path) => {
router.push(path)
}
// 退出登录
const logout = () => {
ElMessageBox.confirm('确定要退出登录吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
userStore.logout()
router.push('/')
ElMessage.success('已退出登录')
}).catch(() => {})
}
// 获取用户信息
const fetchUserInfo = async () => {
loading.value = true
try {
const res = await getCurrentUser()
if (res.code === 200) {
userInfo.value = res.data
// 处理头像图片URL
if (userInfo.value.avatar) {
userInfo.value.avatar = processImageUrl(userInfo.value.avatar)
}
// 更新用户存储
userStore.user = userInfo.value
localStorage.setItem('user', JSON.stringify(userInfo.value))
} else {
ElMessage.error(res.message || '获取用户信息失败')
}
} catch (error) {
console.error('获取用户信息失败:', error)
ElMessage.error('获取用户信息失败')
} finally {
loading.value = false
}
}
// 获取用户统计数据
const fetchUserStats = async () => {
try {
// 获取收藏的音乐数量
const musicRes = await getCollectedMusic()
if (musicRes.code === 200) {
userStats.value.collectMusicCount = musicRes.data.total || musicRes.data.length || 0
}
// 获取收藏的歌单数量
const playlistRes = await getCollectedPlaylist()
if (playlistRes.code === 200) {
userStats.value.collectPlaylistCount = playlistRes.data.total || playlistRes.data.length || 0
}
// 获取收藏的歌手数量
const singerRes = await getCollectedSinger()
if (singerRes.code === 200) {
userStats.value.collectSingerCount = singerRes.data.total || singerRes.data.length || 0
}
// 获取创建的歌单数量
const userPlaylistRes = await getUserPlaylist()
if (userPlaylistRes.code === 200) {
userStats.value.createPlaylistCount = userPlaylistRes.data.total || userPlaylistRes.data.length || 0
}
} catch (error) {
console.error('获取用户统计数据失败:', error)
}
}
onMounted(async () => {
await fetchUserInfo()
await fetchUserStats()
})
</script>
<template>
<div class="user-center-container" v-loading="loading">
<el-row :gutter="20" style="margin-bottom: 0;">
<!-- 左侧菜单 -->
<el-col :span="6">
<div class="user-sidebar">
<div class="user-avatar-container">
<el-avatar :size="100" :src="userInfo?.avatar || 'https://via.placeholder.com/150?text=用户'" />
<h3 class="user-nickname">{{ userInfo?.nickname || '用户' }}</h3>
</div>
<el-menu
:default-active="activeMenu"
class="user-menu"
>
<el-menu-item index="profile" @click="navigateTo('/user')">
<el-icon><user /></el-icon>
<span>个人资料</span>
</el-menu-item>
<el-menu-item index="collect-music" @click="navigateTo('/user/collect/music')">
<el-icon><headset /></el-icon>
<span>我收藏的音乐</span>
</el-menu-item>
<el-menu-item index="collect-playlist" @click="navigateTo('/user/collect/playlist')">
<el-icon><files /></el-icon>
<span>我收藏的歌单</span>
</el-menu-item>
<el-menu-item index="collect-singer" @click="navigateTo('/user/collect/singer')">
<el-icon><avatar /></el-icon>
<span>我收藏的歌手</span>
</el-menu-item>
<el-menu-item index="playlist" @click="navigateTo('/user/playlist')">
<el-icon><folder /></el-icon>
<span>我的歌单</span>
</el-menu-item>
<el-menu-item index="password" @click="navigateTo('/user/password')">
<el-icon><lock /></el-icon>
<span>修改密码</span>
</el-menu-item>
<el-menu-item index="logout" @click="logout">
<el-icon><switch-button /></el-icon>
<span>退出登录</span>
</el-menu-item>
</el-menu>
</div>
</el-col>
<!-- 右侧内容 -->
<el-col :span="18">
<div class="user-content">
<!-- 如果没有子路由显示个人资料 -->
<div v-if="$route.path === '/user'" class="user-profile">
<div class="profile-header">
<h2>个人资料</h2>
<el-button type="primary" @click="openEditDialog">编辑资料</el-button>
</div>
<el-row :gutter="20" class="profile-stats">
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-item">
<div class="stat-value">{{ userStats.collectMusicCount }}</div>
<div class="stat-label">收藏音乐</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-item">
<div class="stat-value">{{ userStats.collectPlaylistCount }}</div>
<div class="stat-label">收藏歌单</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-item">
<div class="stat-value">{{ userStats.collectSingerCount }}</div>
<div class="stat-label">收藏歌手</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-item">
<div class="stat-value">{{ userStats.createPlaylistCount }}</div>
<div class="stat-label">创建歌单</div>
</div>
</el-card>
</el-col>
</el-row>
<el-card class="profile-info" shadow="never">
<template #header>
<div class="card-header">
<span>基本信息</span>
</div>
</template>
<el-descriptions :column="1" border>
<el-descriptions-item label="用户名">{{ userInfo?.username || '未设置' }}</el-descriptions-item>
<el-descriptions-item label="昵称">{{ userInfo?.nickname || '未设置' }}</el-descriptions-item>
<el-descriptions-item label="性别">
<el-tag size="small" :type="userInfo?.gender === 1 ? 'primary' : userInfo?.gender === 2 ? 'danger' : 'info'">
{{ userInfo?.gender === 1 ? '男' : userInfo?.gender === 2 ? '女' : '未知' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="邮箱">{{ userInfo?.email || '未设置' }}</el-descriptions-item>
<el-descriptions-item label="手机">{{ userInfo?.phone || '未设置' }}</el-descriptions-item>
<el-descriptions-item label="个人简介">{{ userInfo?.introduction || '这个人很懒,什么都没留下' }}</el-descriptions-item>
<el-descriptions-item label="注册时间">{{ userInfo?.createTime || '未知' }}</el-descriptions-item>
</el-descriptions>
</el-card>
</div>
<!-- 子路由内容 -->
<router-view v-else></router-view>
</div>
</el-col>
</el-row>
<!-- 编辑资料对话框 -->
<el-dialog
v-model="showEditDialog"
title="编辑个人资料"
width="500px"
>
<el-form :model="editForm" label-width="80px">
<el-form-item label="头像">
<el-upload
class="avatar-uploader"
:action="avatarUploadUrl"
:headers="uploadHeaders"
:show-file-list="false"
:on-success="handleAvatarSuccess"
:before-upload="beforeAvatarUpload"
>
<img v-if="userInfo?.avatar" :src="userInfo.avatar" class="avatar" />
<el-icon v-else class="avatar-uploader-icon"><plus /></el-icon>
<div class="avatar-tip">点击上传</div>
</el-upload>
</el-form-item>
<el-form-item label="昵称">
<el-input v-model="editForm.nickname" placeholder="请输入昵称" />
</el-form-item>
<el-form-item label="性别">
<el-radio-group v-model="editForm.gender">
<el-radio :label="1"></el-radio>
<el-radio :label="2"></el-radio>
<el-radio :label="0">保密</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="editForm.email" placeholder="请输入邮箱" />
</el-form-item>
<el-form-item label="手机">
<el-input v-model="editForm.phone" placeholder="请输入手机号" />
</el-form-item>
<el-form-item label="个人简介">
<el-input
v-model="editForm.introduction"
type="textarea"
:rows="4"
placeholder="请输入个人简介"
/>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="showEditDialog = false">取消</el-button>
<el-button type="primary" @click="submitEditForm">确定</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.user-center-container {
padding: 20px 20px 100px 20px; /* 增加底部填充,为播放器留出空间 */
}
.user-sidebar {
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
overflow: hidden;
margin-bottom: 20px;
}
.user-avatar-container {
padding: 30px 0;
text-align: center;
background-color: #f5f7fa;
}
.user-nickname {
margin-top: 15px;
margin-bottom: 0;
font-size: 1.2rem;
}
.user-menu {
border-right: none;
margin-bottom: 0;
}
.user-content {
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
padding: 20px;
min-height: 500px;
margin-bottom: 20px;
}
.profile-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.profile-header h2 {
margin: 0;
font-size: 1.5rem;
color: #333;
}
.profile-stats {
margin-bottom: 30px;
}
.stat-item {
text-align: center;
padding: 10px 0;
}
.stat-value {
font-size: 1.8rem;
font-weight: bold;
color: #409eff;
margin-bottom: 5px;
}
.stat-label {
font-size: 0.9rem;
color: #666;
}
.profile-info {
margin-bottom: 20px;
}
.card-header {
font-weight: bold;
}
.avatar-uploader {
text-align: center;
}
.avatar {
width: 100px;
height: 100px;
border-radius: 50%;
object-fit: cover;
}
.avatar-uploader-icon {
font-size: 28px;
color: #8c939d;
width: 100px;
height: 100px;
line-height: 100px;
text-align: center;
border: 1px dashed #d9d9d9;
border-radius: 50%;
}
.avatar-tip {
margin-top: 5px;
font-size: 0.8rem;
color: #999;
}
</style>

View File

@@ -0,0 +1,414 @@
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
// 轮播图列表
const bannerList = ref([])
// 加载状态
const loading = ref(false)
// 分页信息
const pagination = ref({
currentPage: 1,
pageSize: 10,
total: 0
})
// 添加/编辑对话框
const dialogVisible = ref(false)
// 对话框标题
const dialogTitle = ref('添加轮播图')
// 当前编辑的轮播图
const currentBanner = ref(null)
// 表单数据
const form = ref({
title: '',
url: '',
targetType: 1, // 1-音乐2-歌单3-歌手4-外部链接
targetId: '',
orderNum: 0,
status: 1, // 0-禁用1-启用
image: null
})
// 获取轮播图列表
const fetchBannerList = async () => {
loading.value = true
try {
// 实际应用中这里应该调用API获取轮播图列表
// 模拟获取轮播图列表
setTimeout(() => {
const mockBanners = []
const total = 20
const { currentPage, pageSize } = pagination.value
const start = (currentPage - 1) * pageSize
const end = Math.min(start + pageSize, total)
for (let i = start + 1; i <= end; i++) {
mockBanners.push({
id: i,
title: `轮播图${i}`,
url: `https://via.placeholder.com/1200x300?text=Banner${i}`,
targetType: i % 4 + 1,
targetId: i * 10,
orderNum: i,
status: i % 5 === 0 ? 0 : 1,
createTime: new Date(Date.now() - i * 86400000).toISOString().split('T')[0]
})
}
bannerList.value = mockBanners
pagination.value.total = total
loading.value = false
}, 500)
} catch (error) {
ElMessage.error('获取轮播图列表失败')
loading.value = false
}
}
// 处理页码变化
const handleCurrentChange = (page) => {
pagination.value.currentPage = page
fetchBannerList()
}
// 处理每页条数变化
const handleSizeChange = (size) => {
pagination.value.pageSize = size
pagination.value.currentPage = 1
fetchBannerList()
}
// 添加轮播图
const addBanner = () => {
dialogTitle.value = '添加轮播图'
currentBanner.value = null
form.value = {
title: '',
url: '',
targetType: 1,
targetId: '',
orderNum: bannerList.value.length + 1,
status: 1,
image: null
}
dialogVisible.value = true
}
// 编辑轮播图
const editBanner = (row) => {
dialogTitle.value = '编辑轮播图'
currentBanner.value = row
form.value = {
title: row.title,
url: row.url,
targetType: row.targetType,
targetId: row.targetId,
orderNum: row.orderNum,
status: row.status,
image: null
}
dialogVisible.value = true
}
// 删除轮播图
const deleteBanner = (row) => {
ElMessageBox.confirm(`确定要删除轮播图 ${row.title} 吗?此操作不可恢复`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
// 实际应用中这里应该调用API删除轮播图
bannerList.value = bannerList.value.filter(item => item.id !== row.id)
ElMessage.success('删除成功')
}).catch(() => {})
}
// 更新轮播图状态
const updateBannerStatus = (row) => {
const newStatus = row.status === 1 ? 0 : 1
const statusText = newStatus === 1 ? '启用' : '禁用'
ElMessageBox.confirm(`确定要${statusText}轮播图 ${row.title} 吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
// 实际应用中这里应该调用API更新轮播图状态
const index = bannerList.value.findIndex(item => item.id === row.id)
if (index !== -1) {
bannerList.value[index].status = newStatus
ElMessage.success(`${statusText}成功`)
}
}).catch(() => {})
}
// 提交表单
const submitForm = () => {
// 实际应用中这里应该调用API添加/编辑轮播图
if (currentBanner.value) {
// 编辑轮播图
const index = bannerList.value.findIndex(item => item.id === currentBanner.value.id)
if (index !== -1) {
bannerList.value[index] = {
...bannerList.value[index],
title: form.value.title,
targetType: form.value.targetType,
targetId: form.value.targetId,
orderNum: form.value.orderNum,
status: form.value.status
}
ElMessage.success('编辑成功')
}
} else {
// 添加轮播图
const newBanner = {
id: Date.now(),
title: form.value.title,
url: 'https://via.placeholder.com/1200x300?text=NewBanner',
targetType: form.value.targetType,
targetId: form.value.targetId,
orderNum: form.value.orderNum,
status: form.value.status,
createTime: new Date().toISOString().split('T')[0]
}
bannerList.value.unshift(newBanner)
ElMessage.success('添加成功')
}
dialogVisible.value = false
}
// 格式化目标类型
const formatTargetType = (type) => {
const typeMap = {
1: '音乐',
2: '歌单',
3: '歌手',
4: '外部链接'
}
return typeMap[type] || '未知'
}
// 格式化状态
const formatStatus = (status) => {
const statusMap = {
0: '禁用',
1: '启用'
}
return statusMap[status] || '未知'
}
// 处理图片上传
const handleImageChange = (file) => {
form.value.image = file
}
onMounted(() => {
fetchBannerList()
})
</script>
<template>
<div class="admin-banner-container">
<!-- 操作栏 -->
<div class="action-bar">
<el-button type="primary" @click="addBanner">
<el-icon><plus /></el-icon>
添加轮播图
</el-button>
</div>
<!-- 轮播图列表 -->
<el-table
:data="bannerList"
stripe
style="width: 100%"
v-loading="loading"
>
<el-table-column prop="id" label="ID" width="80"></el-table-column>
<el-table-column label="图片" width="200" align="center">
<template #default="scope">
<img
:src="scope.row.url"
:alt="scope.row.title"
class="banner-image"
>
</template>
</el-table-column>
<el-table-column prop="title" label="标题" min-width="150"></el-table-column>
<el-table-column prop="targetType" label="目标类型" width="100">
<template #default="scope">
{{ formatTargetType(scope.row.targetType) }}
</template>
</el-table-column>
<el-table-column prop="targetId" label="目标ID" width="100"></el-table-column>
<el-table-column prop="orderNum" label="排序" width="80" align="center"></el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="scope">
<el-tag :type="scope.row.status === 1 ? 'success' : 'danger'">
{{ formatStatus(scope.row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" width="120"></el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="scope">
<el-button
size="small"
type="primary"
@click="editBanner(scope.row)"
>
编辑
</el-button>
<el-button
size="small"
:type="scope.row.status === 1 ? 'warning' : 'success'"
@click="updateBannerStatus(scope.row)"
>
{{ scope.row.status === 1 ? '禁用' : '启用' }}
</el-button>
<el-button
size="small"
type="danger"
@click="deleteBanner(scope.row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination">
<el-pagination
v-model:current-page="pagination.currentPage"
v-model:page-size="pagination.pageSize"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
:total="pagination.total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
<!-- 添加/编辑对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="600px"
>
<el-form
:model="form"
label-width="100px"
>
<el-form-item label="标题" required>
<el-input v-model="form.title" placeholder="请输入轮播图标题" />
</el-form-item>
<el-form-item label="目标类型" required>
<el-select
v-model="form.targetType"
placeholder="请选择目标类型"
style="width: 100%"
>
<el-option :label="'音乐'" :value="1" />
<el-option :label="'歌单'" :value="2" />
<el-option :label="'歌手'" :value="3" />
<el-option :label="'外部链接'" :value="4" />
</el-select>
</el-form-item>
<el-form-item label="目标ID" v-if="form.targetType !== 4" required>
<el-input v-model="form.targetId" placeholder="请输入目标ID" />
</el-form-item>
<el-form-item label="外部链接" v-if="form.targetType === 4" required>
<el-input v-model="form.targetId" placeholder="请输入外部链接" />
</el-form-item>
<el-form-item label="排序" required>
<el-input-number v-model="form.orderNum" :min="0" />
</el-form-item>
<el-form-item label="状态">
<el-switch
v-model="form.status"
:active-value="1"
:inactive-value="0"
active-text="启用"
inactive-text="禁用"
/>
</el-form-item>
<el-form-item label="图片" required>
<el-upload
action="#"
:auto-upload="false"
:on-change="handleImageChange"
:limit="1"
list-type="picture-card"
>
<el-icon><plus /></el-icon>
</el-upload>
<div class="el-upload__tip">
建议尺寸1200 x 300 像素
</div>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm">确定</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.admin-banner-container {
padding: 20px;
}
.action-bar {
margin-bottom: 20px;
display: flex;
justify-content: flex-start;
}
.pagination {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
.banner-image {
width: 180px;
height: 60px;
object-fit: cover;
border-radius: 4px;
}
.el-table :deep(.el-button) {
margin-left: 0;
margin-right: 5px;
padding: 4px 8px;
}
</style>

View File

@@ -0,0 +1,673 @@
<template>
<div class="banner-management">
<div class="page-header">
<h2>轮播图管理</h2>
<el-button type="primary" @click="openAddDialog">
<el-icon><Plus /></el-icon>添加轮播图
</el-button>
</div>
<el-table
:data="bannerList"
border
stripe
style="width: 100%"
v-loading="loading"
>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column label="图片" width="180">
<template #default="scope">
<div>
<ImagePreview
v-if="scope.row.imageUrl"
:src="scope.row.imageUrl"
:width="120"
:height="60"
fit="cover"
:preview-src-list="scope.row.imageUrl ? [scope.row.imageUrl] : []"
:z-index="3000"
@error="() => console.error('图片加载失败:', scope.row.imageUrl)"
/>
<div v-else class="no-image">无图片</div>
<div class="image-url-debug">
<el-tooltip :content="scope.row.imageUrl || '无图片URL'" placement="top">
<small>{{ scope.row.imageUrl ? scope.row.imageUrl.substring(0, 20) + '...' : '无图片URL' }}</small>
</el-tooltip>
</div>
</div>
</template>
</el-table-column>
<el-table-column prop="title" label="标题" width="180" />
<el-table-column label="目标类型" width="120">
<template #default="scope">
<el-tag v-if="scope.row.targetType === 1" type="success">音乐</el-tag>
<el-tag v-else-if="scope.row.targetType === 2" type="warning">歌单</el-tag>
<el-tag v-else-if="scope.row.targetType === 3" type="info">歌手</el-tag>
<el-tag v-else type="danger">未知</el-tag>
</template>
</el-table-column>
<el-table-column prop="targetId" label="目标ID" width="100" />
<el-table-column prop="orderNum" label="排序" width="80" sortable />
<el-table-column label="状态" width="100">
<template #default="scope">
<el-switch
v-model="scope.row.status"
:active-value="1"
:inactive-value="0"
@change="handleStatusChange(scope.row)"
/>
</template>
</el-table-column>
<el-table-column label="创建时间" width="180">
<template #default="scope">
{{ formatDateTime(scope.row.createTime) }}
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="scope">
<el-button
type="primary"
size="small"
@click="openEditDialog(scope.row)"
>
编辑
</el-button>
<el-button
type="danger"
size="small"
@click="handleDelete(scope.row.id)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-container">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[3, 6, 9, 12]"
layout="total, sizes, prev, pager, next, jumper"
:total="total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
<!-- 添加/编辑轮播图对话框 -->
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑轮播图' : '添加轮播图'"
width="600px"
:close-on-click-modal="false"
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="100px"
v-loading="formLoading"
>
<el-form-item label="标题" prop="title">
<el-input v-model="form.title" placeholder="请输入轮播图标题" />
</el-form-item>
<el-form-item label="图片">
<el-upload
class="banner-upload"
action="#"
:auto-upload="false"
:show-file-list="false"
:on-change="handleImageChange"
>
<ImagePreview
v-if="imageUrl"
:src="imageUrl"
:width="400"
:height="150"
fit="cover"
:preview-src-list="imageUrl ? [imageUrl] : []"
:z-index="3000"
class="banner-image"
/>
<div v-else class="banner-upload-placeholder">
<el-icon><Plus /></el-icon>
<div class="el-upload__text">点击上传图片</div>
</div>
</el-upload>
<div class="upload-tip">建议尺寸1200 x 400 像素JPG/PNG 格式</div>
</el-form-item>
<el-form-item label="目标类型" prop="targetType">
<el-select v-model="form.targetType" placeholder="请选择目标类型">
<el-option label="音乐" :value="1" />
<el-option label="歌单" :value="2" />
<el-option label="歌手" :value="3" />
</el-select>
</el-form-item>
<el-form-item label="目标ID" prop="targetId">
<el-input v-model.number="form.targetId" placeholder="请输入目标ID" />
</el-form-item>
<el-form-item label="外部链接" prop="linkUrl">
<el-input v-model="form.linkUrl" placeholder="请输入外部链接(可选)" />
</el-form-item>
<el-form-item label="排序" prop="orderNum">
<el-input-number v-model="form.orderNum" :min="1" :max="999" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="form.status">
<el-radio :label="1">启用</el-radio>
<el-radio :label="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm" :loading="formLoading">确定</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted, reactive } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Picture, Search } from '@element-plus/icons-vue'
import ImagePreview from '../../components/ImagePreview.vue'
import {
getBannerPage,
getBannerById,
addBanner,
updateBanner,
deleteBanner,
updateBannerStatus,
updateBannerOrder
} from '../../api/banner'
// 轮播图列表数据
const bannerList = ref([])
const loading = ref(false)
const currentPage = ref(1)
const pageSize = ref(3)
const total = ref(0)
// 对话框相关
const dialogVisible = ref(false)
const isEdit = ref(false)
const formRef = ref(null)
const formLoading = ref(false)
const form = reactive({
id: null,
title: '',
targetType: 1,
targetId: null,
linkUrl: '',
orderNum: 1,
status: 1
})
const imageUrl = ref('')
const imageFile = ref(null)
// 表单验证规则
const rules = {
title: [
{ required: true, message: '请输入轮播图标题', trigger: 'blur' },
{ min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' }
],
// 移除 image 的验证规则,在提交时手动验证
targetType: [
{ required: true, message: '请选择目标类型', trigger: 'change' }
],
targetId: [
{ required: true, message: '请输入目标ID', trigger: 'blur' },
{ type: 'number', message: '目标ID必须为数字', trigger: 'blur' }
],
linkUrl: [
{ pattern: /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w.-]*)*\/?$/, message: '请输入有效的URL', trigger: 'blur' }
],
orderNum: [
{ required: true, message: '请输入排序号', trigger: 'blur' }
]
}
// 格式化日期时间
const formatDateTime = (dateTime) => {
if (!dateTime) return ''
const date = new Date(dateTime)
return date.toLocaleString()
}
// 获取轮播图列表
const fetchBannerList = async () => {
loading.value = true
try {
const res = await getBannerPage(currentPage.value, pageSize.value)
console.log('获取轮播图列表响应:', res)
if (res.code === 200) {
// 适配不同的数据结构
if (res.data.records) {
bannerList.value = res.data.records
total.value = res.data.total
} else if (res.data.list) {
bannerList.value = res.data.list
total.value = res.data.total
} else {
bannerList.value = Array.isArray(res.data) ? res.data : []
total.value = bannerList.value.length
}
console.log('轮播图列表数据:', bannerList.value)
} else {
ElMessage.error(res.message || '获取轮播图列表失败')
}
} catch (error) {
console.error('获取轮播图列表失败:', error)
ElMessage.error('获取轮播图列表失败,请稍后再试')
} finally {
loading.value = false
}
}
// 处理页码变化
const handleCurrentChange = (page) => {
currentPage.value = page
fetchBannerList()
}
// 处理每页条数变化
const handleSizeChange = (size) => {
pageSize.value = size
currentPage.value = 1
fetchBannerList()
}
// 处理状态变化
const handleStatusChange = async (row) => {
try {
const res = await updateBannerStatus(row.id, row.status)
if (res.code === 200) {
ElMessage.success('状态更新成功')
} else {
// 恢复原状态
row.status = row.status === 1 ? 0 : 1
ElMessage.error(res.message || '状态更新失败')
}
} catch (error) {
// 恢复原状态
row.status = row.status === 1 ? 0 : 1
console.error('更新轮播图状态失败:', error)
ElMessage.error('更新轮播图状态失败,请稍后再试')
}
}
// 打开添加对话框
const openAddDialog = () => {
isEdit.value = false
resetForm()
dialogVisible.value = true
}
// 打开编辑对话框
const openEditDialog = async (row) => {
isEdit.value = true
resetForm()
try {
const res = await getBannerById(row.id)
if (res.code === 200) {
const bannerData = res.data
form.id = bannerData.id
form.title = bannerData.title
form.targetType = bannerData.targetType
form.targetId = bannerData.targetId
form.linkUrl = bannerData.linkUrl || ''
form.orderNum = bannerData.orderNum
form.status = bannerData.status
// 设置图片URL
if (bannerData.imageUrl) {
imageUrl.value = bannerData.imageUrl
console.log('编辑模式设置图片URL:', imageUrl.value)
}
dialogVisible.value = true
} else {
ElMessage.error(res.message || '获取轮播图详情失败')
}
} catch (error) {
console.error('获取轮播图详情失败:', error)
ElMessage.error('获取轮播图详情失败,请稍后再试')
}
}
// 重置表单
const resetForm = () => {
if (formRef.value) {
formRef.value.resetFields()
}
form.id = null
form.title = ''
form.targetType = 1
form.targetId = null
form.linkUrl = ''
form.orderNum = 1
form.status = 1
imageUrl.value = ''
imageFile.value = null
}
// 处理图片变化
const handleImageChange = (file) => {
if (file) {
// 检查文件类型
const isImage = file.raw.type.startsWith('image/')
if (!isImage) {
ElMessage.error('只能上传图片文件!')
return
}
// 检查文件大小
const isLt2M = file.raw.size / 1024 / 1024 < 2
if (!isLt2M) {
ElMessage.error('图片大小不能超过 2MB!')
return
}
// 设置图片预览
imageFile.value = file.raw
imageUrl.value = URL.createObjectURL(file.raw)
console.log('图片已选择:', imageFile.value, imageUrl.value)
} else {
console.log('没有选择图片')
}
}
// 提交表单
const submitForm = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (valid) {
// 检查是否上传了图片
if (!isEdit.value && !imageFile.value) {
ElMessage.error('请上传轮播图图片')
return
}
// 编辑模式下如果没有选择新图片也没有原图片URL则提示上传图片
if (isEdit.value && !imageFile.value && !imageUrl.value) {
ElMessage.error('请上传轮播图图片')
return
}
formLoading.value = true
try {
let res
if (isEdit.value) {
// 编辑轮播图
console.log('提交编辑表单:', form, imageFile.value, imageUrl.value)
res = await updateBanner(form.id, form, imageFile.value)
} else {
// 添加轮播图
console.log('提交添加表单:', form, imageFile.value)
res = await addBanner(form, imageFile.value)
}
if (res.code === 200) {
ElMessage.success(isEdit.value ? '更新成功' : '添加成功')
dialogVisible.value = false
fetchBannerList()
} else {
ElMessage.error(res.message || (isEdit.value ? '更新失败' : '添加失败'))
}
} catch (error) {
console.error(isEdit.value ? '更新轮播图失败:' : '添加轮播图失败:', error)
ElMessage.error((isEdit.value ? '更新' : '添加') + '轮播图失败,请稍后再试')
} finally {
formLoading.value = false
}
}
})
}
// 处理删除
const handleDelete = (id) => {
ElMessageBox.confirm('确定要删除这个轮播图吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await deleteBanner(id)
if (res.code === 200) {
ElMessage.success('删除成功')
// 如果当前页只有一条数据,且不是第一页,则返回上一页
if (bannerList.value.length === 1 && currentPage.value > 1) {
currentPage.value--
}
fetchBannerList()
} else {
ElMessage.error(res.message || '删除失败')
}
} catch (error) {
console.error('删除轮播图失败:', error)
ElMessage.error('删除轮播图失败,请稍后再试')
}
}).catch(() => {
// 取消删除
})
}
// 在组件挂载时获取数据
onMounted(() => {
fetchBannerList()
})
</script>
<style scoped>
.banner-management {
padding: 20px;
background: linear-gradient(135deg, #e1f5fe 0%, #b3e5fc 100%);
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
background: rgba(255, 255, 255, 0.9);
padding: 15px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.page-header h2 {
color: #0d47a1;
margin: 0;
}
.el-button {
border-radius: 8px !important;
font-weight: bold !important;
transition: all 0.3s !important;
}
.el-button--primary {
background: linear-gradient(135deg, #4dd0e1 0%, #26c6da 100%) !important;
border: none !important;
}
.el-button--primary:hover {
transform: translateY(-2px) !important;
box-shadow: 0 4px 8px rgba(38, 198, 218, 0.3) !important;
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
background: rgba(255, 255, 255, 0.9);
padding: 15px;
border-radius: 12px;
}
.el-table {
--el-table-border-color: #b3e5fc;
--el-table-header-bg-color: #e1f5fe;
--el-table-row-hover-bg-color: #b3e5fc;
border-radius: 8px;
overflow: hidden;
}
.el-table th.el-table__cell {
background-color: #e1f5fe !important;
color: #0d47a1 !important;
font-weight: bold !important;
}
.el-table .el-table__row {
background-color: rgba(255, 255, 255, 0.9) !important;
transition: all 0.3s;
}
.el-table .el-table__row:hover {
background-color: #b3e5fc !important;
transform: scale(1.01);
}
.el-tag {
border-radius: 8px !important;
}
.el-dialog {
border-radius: 16px !important;
}
.el-dialog :deep(.el-dialog__header) {
background: linear-gradient(135deg, #e1f5fe 0%, #b3e5fc 100%);
border-radius: 16px 16px 0 0 !important;
padding: 15px 20px;
}
.el-dialog :deep(.el-dialog__title) {
color: #0d47a1;
font-weight: bold;
}
.el-form-item :deep(.el-form-item__label) {
color: #4a4a4a;
font-weight: bold;
}
.el-input :deep(.el-input__inner) {
border-radius: 8px;
border: 1px solid #b3e5fc;
}
.el-input :deep(.el-input__inner):focus {
border-color: #4dd0e1;
box-shadow: 0 0 0 2px rgba(77, 208, 225, 0.2);
}
.banner-upload {
width: 100%;
}
.banner-image {
border-radius: 8px;
overflow: hidden;
transition: all 0.3s;
}
.banner-image:hover {
transform: scale(1.02);
}
.banner-upload-placeholder {
width: 100%;
max-width: 400px;
height: 150px;
border: 1px dashed #b3e5fc;
border-radius: 8px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
cursor: pointer;
transition: all 0.3s;
}
.banner-upload-placeholder:hover {
border-color: #4dd0e1;
transform: scale(1.01);
}
.banner-upload-placeholder .el-icon {
font-size: 28px;
color: #4dd0e1;
}
.upload-tip {
font-size: 12px;
color: #4a4a4a;
margin-top: 5px;
}
.no-image {
width: 120px;
height: 60px;
display: flex;
justify-content: center;
align-items: center;
background-color: #e1f5fe;
color: #4a4a4a;
font-size: 12px;
border-radius: 8px;
transition: all 0.3s;
}
.no-image:hover {
transform: scale(1.05);
}
.image-url-debug {
margin-top: 5px;
font-size: 8px;
color: #4a4a4a;
word-break: break-all;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.image-error {
width: 120px;
height: 60px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: #ffebee;
color: #f44336;
font-size: 12px;
border-radius: 8px;
}
.image-error .el-icon {
font-size: 24px;
margin-bottom: 5px;
}
.el-switch :deep(.el-switch__core) {
border-radius: 12px !important;
}
.el-switch :deep(.el-switch__action) {
border-radius: 50% !important;
}
</style>

View File

@@ -0,0 +1,440 @@
<template>
<div class="comment-management">
<div class="page-header">
<h2>评论管理</h2>
</div>
<!-- 搜索和筛选 -->
<div class="search-bar">
<el-input
v-model="searchKeyword"
placeholder="搜索评论内容"
class="search-input"
clearable
@keyup.enter="handleSearch"
>
<template #prefix>
<el-icon><search /></el-icon>
</template>
</el-input>
<el-select
v-model="selectedTargetType"
placeholder="目标类型"
clearable
@change="handleSearch"
>
<el-option label="音乐" :value="1" />
<el-option label="歌单" :value="2" />
<el-option label="歌手" :value="3" />
</el-select>
<el-select
v-model="selectedStatus"
placeholder="状态"
clearable
@change="handleSearch"
>
<el-option label="正常" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="resetSearch">重置</el-button>
</div>
<!-- 评论列表 -->
<el-table
:data="commentList"
style="width: 100%"
border
v-loading="loading"
>
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column label="评论内容" min-width="300">
<template #default="scope">
<div class="comment-content">
<div v-if="scope.row.parentId" class="reply-tag">回复</div>
{{ scope.row.content }}
</div>
</template>
</el-table-column>
<el-table-column label="评论用户" width="150">
<template #default="scope">
<div class="user-info">
<el-avatar :size="30" :src="scope.row.user.avatar || 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png'" />
<span>{{ scope.row.user.nickname || scope.row.user.username }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="目标类型" width="100" align="center">
<template #default="scope">
{{ formatTargetType(scope.row.targetType) }}
</template>
</el-table-column>
<el-table-column label="目标ID" width="100" align="center" prop="targetId" />
<el-table-column label="状态" width="100" align="center">
<template #default="scope">
<el-tag :type="scope.row.status === 1 ? 'success' : 'danger'">
{{ scope.row.status === 1 ? '正常' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="创建时间" width="180" align="center">
<template #default="scope">
{{ formatDate(scope.row.createTime) }}
</template>
</el-table-column>
<el-table-column label="操作" width="200" align="center">
<template #default="scope">
<el-button
v-if="scope.row.status === 1"
size="small"
type="warning"
@click="handleStatusChange(scope.row, 0)"
>
禁用
</el-button>
<el-button
v-else
size="small"
type="success"
@click="handleStatusChange(scope.row, 1)"
>
启用
</el-button>
<el-button
size="small"
type="danger"
@click="handleDelete(scope.row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="pagination.currentPage"
v-model:page-size="pagination.pageSize"
:page-sizes="[9, 18, 27, 36]"
layout="total, sizes, prev, pager, next, jumper"
:total="pagination.total"
:page-count="Math.ceil(pagination.total / pagination.pageSize)"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search } from '@element-plus/icons-vue'
import { getCommentList, updateCommentStatus, deleteComment } from '../../api/admin/comment'
// 评论列表
const commentList = ref([])
// 加载状态
const loading = ref(false)
// 分页信息
const pagination = ref({
currentPage: 1,
pageSize: 9,
total: 0
})
// 搜索关键字
const searchKeyword = ref('')
// 选中的目标类型
const selectedTargetType = ref(null)
// 选中的状态
const selectedStatus = ref(null)
// 获取评论列表
const fetchCommentList = async () => {
loading.value = true
try {
const res = await getCommentList(
pagination.value.currentPage,
pagination.value.pageSize,
searchKeyword.value,
selectedTargetType.value,
selectedStatus.value
)
if (res.code === 200) {
commentList.value = res.data.list
pagination.value.total = res.data.total
} else {
ElMessage.error(res.message || '获取评论列表失败')
}
} catch (error) {
console.error('获取评论列表错误:', error)
ElMessage.error('获取评论列表失败')
} finally {
loading.value = false
}
}
// 处理搜索
const handleSearch = () => {
pagination.value.currentPage = 1
fetchCommentList()
}
// 重置搜索
const resetSearch = () => {
searchKeyword.value = ''
selectedTargetType.value = null
selectedStatus.value = null
pagination.value.currentPage = 1
fetchCommentList()
}
// 处理页码变化
const handleCurrentChange = (page) => {
pagination.value.currentPage = page
fetchCommentList()
}
// 处理每页大小变化
const handleSizeChange = (size) => {
pagination.value.pageSize = size
pagination.value.currentPage = 1
fetchCommentList()
}
// 处理状态变更
const handleStatusChange = (row, status) => {
const statusText = status === 1 ? '启用' : '禁用'
ElMessageBox.confirm(`确定要${statusText}该评论吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await updateCommentStatus(row.id, status)
if (res.code === 200) {
ElMessage.success(`${statusText}成功`)
// 更新本地数据
row.status = status
} else {
ElMessage.error(res.message || `${statusText}失败`)
}
} catch (error) {
console.error(`${statusText}评论错误:`, error)
ElMessage.error(`${statusText}失败`)
}
}).catch(() => {})
}
// 处理删除
const handleDelete = (row) => {
ElMessageBox.confirm('确定要删除该评论吗?此操作不可恢复', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await deleteComment(row.id)
if (res.code === 200) {
ElMessage.success('删除成功')
// 重新获取列表
fetchCommentList()
} else {
ElMessage.error(res.message || '删除失败')
}
} catch (error) {
console.error('删除评论错误:', error)
ElMessage.error('删除失败')
}
}).catch(() => {})
}
// 格式化目标类型
const formatTargetType = (type) => {
switch (type) {
case 1: return '音乐'
case 2: return '歌单'
case 3: return '歌手'
default: return '未知'
}
}
// 格式化日期
const formatDate = (dateString) => {
if (!dateString) return ''
const date = new Date(dateString)
return date.toLocaleString()
}
// 页面加载时获取评论列表
onMounted(() => {
fetchCommentList()
})
</script>
<style scoped>
.comment-management {
padding: 20px;
background: linear-gradient(135deg, #e1f5fe 0%, #b3e5fc 100%);
}
.page-header {
margin-bottom: 20px;
background: rgba(255, 255, 255, 0.9);
padding: 15px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.page-header h2 {
color: #0d47a1;
margin: 0;
}
.search-bar {
display: flex;
margin-bottom: 20px;
gap: 10px;
align-items: center;
background: rgba(255, 255, 255, 0.9);
padding: 15px;
border-radius: 12px;
}
.search-input {
width: 300px;
}
.search-input :deep(.el-input__inner) {
border-radius: 8px;
border: 1px solid #b3e5fc;
}
.search-input :deep(.el-input__inner):focus {
border-color: #4dd0e1;
box-shadow: 0 0 0 2px rgba(77, 208, 225, 0.2);
}
.el-select :deep(.el-input__inner) {
border-radius: 8px;
border: 1px solid #b3e5fc;
}
.el-button {
border-radius: 8px !important;
font-weight: bold !important;
transition: all 0.3s !important;
}
.el-button--primary {
background: linear-gradient(135deg, #4dd0e1 0%, #26c6da 100%) !important;
border: none !important;
}
.el-button--primary:hover {
transform: translateY(-2px) !important;
box-shadow: 0 4px 8px rgba(38, 198, 218, 0.3) !important;
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
background: rgba(255, 255, 255, 0.9);
padding: 15px;
border-radius: 12px;
}
.el-table {
--el-table-border-color: #b3e5fc;
--el-table-header-bg-color: #e1f5fe;
--el-table-row-hover-bg-color: #b3e5fc;
border-radius: 8px;
overflow: hidden;
}
.el-table th.el-table__cell {
background-color: #e1f5fe !important;
color: #0d47a1 !important;
font-weight: bold !important;
}
.el-table .el-table__row {
background-color: rgba(255, 255, 255, 0.9) !important;
transition: all 0.3s;
}
.el-table .el-table__row:hover {
background-color: #b3e5fc !important;
transform: scale(1.01);
}
.user-info {
display: flex;
align-items: center;
gap: 10px;
}
.user-info .el-avatar {
transition: all 0.3s;
}
.user-info .el-avatar:hover {
transform: scale(1.1);
}
.comment-content {
display: flex;
align-items: center;
gap: 5px;
padding: 8px;
background-color: rgba(255, 255, 255, 0.7);
border-radius: 8px;
transition: all 0.3s;
}
.comment-content:hover {
background-color: rgba(255, 255, 255, 0.9);
transform: scale(1.01);
}
.reply-tag {
background-color: #e8f5e9;
color: #2e7d32;
padding: 2px 6px;
border-radius: 8px;
font-size: 12px;
font-weight: bold;
}
.el-tag {
border-radius: 8px !important;
}
.el-tag--success {
background-color: #e8f5e9 !important;
color: #2e7d32 !important;
}
.el-tag--danger {
background-color: #ffebee !important;
color: #c62828 !important;
}
.el-tag--warning {
background-color: #fff8e1 !important;
color: #f57f17 !important;
}
</style>

View File

@@ -0,0 +1,707 @@
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { ElLoading, ElMessage } from 'element-plus'
import * as echarts from 'echarts/core'
import { BarChart, LineChart, PieChart } from 'echarts/charts'
import {
TitleComponent,
TooltipComponent,
GridComponent,
LegendComponent
} from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
import { getDashboardStats } from '../../api/admin/dashboard'
import {
Setting,
Fold,
Expand,
ArrowDown,
DataBoard,
User,
Headset,
Files,
Avatar,
Picture,
ChatDotRound,
Collection,
VideoPlay
} from '@element-plus/icons-vue'
// 注册必要的组件
echarts.use([
TitleComponent,
TooltipComponent,
GridComponent,
LegendComponent,
BarChart,
LineChart,
PieChart,
CanvasRenderer
])
// 统计数据
const stats = ref({
userCount: 0,
musicCount: 0,
playlistCount: 0,
singerCount: 0,
playCount: 0
})
// 最新用户和音乐
const latestUsers = ref([])
const latestMusics = ref([])
// 图表数据
const userGrowthData = ref([])
const musicTypeData = ref([])
const playCountData = ref([])
// 加载状态
const loading = ref(false)
// 图表实例
let userChart = null
let musicChart = null
let playlistChart = null
// 获取统计数据
const fetchStats = async () => {
const loadingInstance = ElLoading.service({
target: '.dashboard-container',
text: '正在加载数据...',
background: 'rgba(255, 255, 255, 0.7)'
})
try {
loading.value = true
console.log('开始获取控制台统计数据')
const res = await getDashboardStats()
console.log('获取控制台统计数据成功:', res)
if (res.code === 200) {
const data = res.data || {}
// 设置基础统计数据
stats.value = {
userCount: data.userCount || 0,
musicCount: data.musicCount || 0,
playlistCount: data.playlistCount || 0,
singerCount: data.singerCount || 0,
playCount: data.playCount || 0
}
// 设置图表数据
userGrowthData.value = data.userGrowth || []
musicTypeData.value = data.musicTypeDistribution || []
playCountData.value = data.playCountData || []
// 设置最新用户和音乐
latestUsers.value = data.latestUsers || []
latestMusics.value = data.latestMusics || []
// 初始化图表
setTimeout(() => {
try {
initUserChart()
initMusicChart()
initPlaylistChart()
} catch (error) {
console.error('初始化图表失败:', error)
}
}, 100)
} else {
console.error('获取统计数据失败, 响应码非200:', res.code, res.message)
ElMessage.error(res.message || '获取统计数据失败')
}
} catch (error) {
console.error('获取统计数据失败:', error)
ElMessage.error('获取统计数据失败,请稍后再试')
} finally {
loading.value = false
loadingInstance.close()
}
}
// 初始化用户增长图表
const initUserChart = () => {
const chartDom = document.getElementById('userChart')
if (!chartDom) return
userChart = echarts.init(chartDom)
// 准备数据
const xAxisData = userGrowthData.value.map(item => item.name)
const seriesData = userGrowthData.value.map(item => item.value)
const option = {
title: {
text: '用户增长趋势'
},
tooltip: {
trigger: 'axis'
},
xAxis: {
type: 'category',
data: xAxisData.length > 0 ? xAxisData : ['无数据']
},
yAxis: {
type: 'value'
},
series: [
{
name: '新增用户',
type: 'line',
data: seriesData.length > 0 ? seriesData : [0],
smooth: true,
lineStyle: {
width: 3,
color: '#409eff'
},
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{
offset: 0,
color: 'rgba(64, 158, 255, 0.5)'
},
{
offset: 1,
color: 'rgba(64, 158, 255, 0.1)'
}
]
}
}
}
]
}
userChart.setOption(option)
}
// 初始化音乐类型分布图表
const initMusicChart = () => {
const chartDom = document.getElementById('musicChart')
if (!chartDom) return
musicChart = echarts.init(chartDom)
// 准备数据
const typeData = musicTypeData.value || []
// 如果没有数据,显示空图表
if (typeData.length === 0) {
const option = {
title: {
text: '类型分布',
left: 'center'
},
tooltip: {
trigger: 'item'
},
series: [
{
type: 'pie',
radius: ['50%', '70%'],
data: [{ value: 100, name: '无数据' }]
}
]
}
musicChart.setOption(option)
return
}
// 准备图表数据
const series = typeData.map((category, index) => {
const children = category.children || []
return {
name: category.name,
type: 'pie',
radius: [index === 0 ? '20%' : '50%', (index + 1) * 20 + '%'],
center: ['50%', '50%'],
roseType: index === 1 ? 'radius' : undefined,
itemStyle: {
borderRadius: 5,
borderColor: '#fff',
borderWidth: 2
},
label: {
show: index === 2
},
emphasis: {
label: {
show: true,
fontSize: '14',
fontWeight: 'bold'
}
},
data: children.map(item => ({
value: item.value,
name: item.name
}))
}
})
const option = {
title: {
text: '类型分布',
left: 'center'
},
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b}: {c} ({d}%)'
},
legend: {
orient: 'vertical',
right: 10,
top: 'center',
type: 'scroll'
},
series: series
}
musicChart.setOption(option)
}
// 初始化播放量统计图表
const initPlaylistChart = () => {
const chartDom = document.getElementById('playlistChart')
if (!chartDom) return
playlistChart = echarts.init(chartDom)
// 准备数据
const xAxisData = playCountData.value.map(item => item.name)
const seriesData = playCountData.value.map(item => ({
value: item.value,
// 根据数值动态设置颜色
itemStyle: {
color: function() {
// 找出最大值
const maxValue = Math.max(...playCountData.value.map(item => item.value))
// 如果是最大值的 80% 以上,使用黄色
if (item.value > maxValue * 0.8) {
return '#fac858'
}
// 如果是最大值的 50% 以上,使用淡绿色
else if (item.value > maxValue * 0.5) {
return '#91cc75'
}
// 否则使用淡蓝色
return '#5470c6'
}()
}
}))
const option = {
title: {
text: '近7天播放量统计',
left: 'center'
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: [
{
type: 'category',
data: xAxisData.length > 0 ? xAxisData : ['无数据'],
axisTick: {
alignWithLabel: true
}
}
],
yAxis: [
{
type: 'value',
name: '播放次数'
}
],
series: [
{
name: '播放量',
type: 'bar',
barWidth: '60%',
data: seriesData.length > 0 ? seriesData : [{ value: 0, itemStyle: { color: '#91cc75' } }],
// 添加标签显示数值
label: {
show: true,
position: 'top',
formatter: '{c}'
}
}
]
}
playlistChart.setOption(option)
}
// 处理窗口大小变化
const handleResize = () => {
userChart && userChart.resize()
musicChart && musicChart.resize()
playlistChart && playlistChart.resize()
}
onMounted(() => {
fetchStats()
// 监听窗口大小变化
window.addEventListener('resize', handleResize)
})
// 组件卸载时移除事件监听
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
// 销毁图表实例
userChart && userChart.dispose()
musicChart && musicChart.dispose()
playlistChart && playlistChart.dispose()
})
</script>
<template>
<div class="dashboard-container">
<!-- 统计卡片 -->
<el-row :gutter="20" class="stat-cards">
<el-col :span="4">
<el-card shadow="hover" class="stat-card">
<div class="stat-icon user-icon">
<el-icon><User /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.userCount }}</div>
<div class="stat-label">用户数量</div>
</div>
</el-card>
</el-col>
<el-col :span="4">
<el-card shadow="hover" class="stat-card">
<div class="stat-icon music-icon">
<el-icon><Headset /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.musicCount }}</div>
<div class="stat-label">音乐数量</div>
</div>
</el-card>
</el-col>
<el-col :span="4">
<el-card shadow="hover" class="stat-card">
<div class="stat-icon playlist-icon">
<el-icon><Files /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.playlistCount }}</div>
<div class="stat-label">歌单数量</div>
</div>
</el-card>
</el-col>
<el-col :span="4">
<el-card shadow="hover" class="stat-card">
<div class="stat-icon singer-icon">
<el-icon><Avatar /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.singerCount }}</div>
<div class="stat-label">歌手数量</div>
</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card shadow="hover" class="stat-card">
<div class="stat-icon play-icon">
<el-icon><VideoPlay /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.playCount }}</div>
<div class="stat-label">总播放量</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 图表区域 -->
<el-row :gutter="20" class="chart-row">
<el-col :span="12">
<el-card shadow="hover" class="chart-card">
<template #header>
<div class="card-header">
<span>用户增长趋势</span>
</div>
</template>
<div id="userChart" class="chart"></div>
</el-card>
</el-col>
<el-col :span="12">
<el-card shadow="hover" class="chart-card">
<template #header>
<div class="card-header">
<span>音乐/歌单/歌手类型分布</span>
</div>
</template>
<div id="musicChart" class="chart"></div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="20" class="chart-row">
<el-col :span="24">
<el-card shadow="hover" class="chart-card">
<template #header>
<div class="card-header">
<span>近7天播放量统计</span>
</div>
</template>
<div id="playlistChart" class="chart"></div>
</el-card>
</el-col>
</el-row>
<!-- 最近活动 -->
<el-row :gutter="20" class="activity-row">
<el-col :span="12">
<el-card shadow="hover" class="activity-card">
<template #header>
<div class="card-header">
<span>最新用户</span>
<el-button text>查看更多</el-button>
</div>
</template>
<el-table :data="latestUsers" style="width: 100%">
<el-table-column prop="username" label="用户名" width="120"></el-table-column>
<el-table-column prop="nickname" label="昵称"></el-table-column>
<el-table-column prop="createTime" label="注册时间" width="180"></el-table-column>
</el-table>
</el-card>
</el-col>
<el-col :span="12">
<el-card shadow="hover" class="activity-card">
<template #header>
<div class="card-header">
<span>最新音乐</span>
<el-button text>查看更多</el-button>
</div>
</template>
<el-table :data="latestMusics" style="width: 100%">
<el-table-column prop="name" label="音乐名称" width="120"></el-table-column>
<el-table-column prop="singerName" label="歌手"></el-table-column>
<el-table-column prop="createTime" label="上传时间" width="180"></el-table-column>
</el-table>
</el-card>
</el-col>
</el-row>
</div>
</template>
<style scoped>
.dashboard-container {
padding: 20px;
height: calc(100vh - 60px);
overflow-y: auto;
background: linear-gradient(135deg, #e1f5fe 0%, #b3e5fc 100%);
}
/* 自定义滚动条样式 */
.dashboard-container::-webkit-scrollbar {
width: 8px;
}
.dashboard-container::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.3);
border-radius: 4px;
}
.dashboard-container::-webkit-scrollbar-thumb {
background: rgba(0, 131, 143, 0.5);
border-radius: 4px;
}
.dashboard-container::-webkit-scrollbar-thumb:hover {
background: rgba(0, 131, 143, 0.7);
}
.stat-cards {
margin-bottom: 20px;
}
.stat-card {
height: 120px;
display: flex;
align-items: center;
padding: 20px;
background: rgba(255, 255, 255, 0.9) !important;
border-radius: 12px !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1) !important;
transition: all 0.3s;
}
.stat-card:hover {
transform: translateY(-5px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15) !important;
}
.stat-icon {
width: 60px;
height: 60px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 15px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transition: all 0.3s;
}
.stat-icon:hover {
transform: scale(1.1);
}
.stat-icon .el-icon {
font-size: 30px;
color: white;
}
.user-icon {
background: linear-gradient(135deg, #4dd0e1 0%, #26c6da 100%);
}
.music-icon {
background: linear-gradient(135deg, #80deea 0%, #4dd0e1 100%);
}
.playlist-icon {
background: linear-gradient(135deg, #b2ebf2 0%, #80deea 100%);
}
.singer-icon {
background: linear-gradient(135deg, #e0f7fa 0%, #b2ebf2 100%);
color: #4dd0e1 !important;
}
.play-icon {
background: linear-gradient(135deg, #26c6da 0%, #00838f 100%);
}
.stat-info {
flex: 1;
}
.stat-value {
font-size: 28px;
font-weight: bold;
margin-bottom: 5px;
color: #0d47a1;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.stat-label {
font-size: 14px;
color: #4a4a4a;
font-weight: 500;
}
.chart-row {
margin-bottom: 20px;
}
.chart-card {
height: 400px;
background: rgba(255, 255, 255, 0.9) !important;
border-radius: 12px !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1) !important;
}
.chart {
width: 100%;
height: 350px;
}
.activity-row {
margin-bottom: 20px;
}
.activity-card {
height: 400px;
background: rgba(255, 255, 255, 0.9) !important;
border-radius: 12px !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1) !important;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 20px;
background: linear-gradient(135deg, #e1f5fe 0%, #b3e5fc 100%) !important;
border-radius: 12px 12px 0 0 !important;
}
.card-header span {
font-weight: bold;
color: #0d47a1;
font-size: 16px;
}
.el-table {
--el-table-border-color: #b3e5fc;
--el-table-header-bg-color: #e1f5fe;
--el-table-row-hover-bg-color: #b3e5fc;
border-radius: 8px;
overflow: hidden;
}
.el-table th.el-table__cell {
background-color: #e1f5fe !important;
color: #0d47a1 !important;
font-weight: bold !important;
}
.el-table .el-table__row {
background-color: rgba(255, 255, 255, 0.9) !important;
transition: all 0.3s;
}
.el-table .el-table__row:hover {
background-color: #b3e5fc !important;
transform: scale(1.01);
}
.el-button {
border-radius: 8px !important;
font-weight: bold !important;
transition: all 0.3s !important;
}
.el-button--primary {
background: linear-gradient(135deg, #4dd0e1 0%, #26c6da 100%) !important;
border: none !important;
}
.el-button--primary:hover {
transform: translateY(-2px) !important;
box-shadow: 0 4px 8px rgba(38, 198, 218, 0.3) !important;
}
</style>

View File

@@ -0,0 +1,257 @@
<script setup>
import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '../../stores/user'
import { ElMessage, ElMessageBox } from 'element-plus'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
// 是否折叠侧边栏
const isCollapse = ref(false)
// 当前激活的菜单
const activeMenu = computed(() => {
const path = route.path
if (path === '/admin') return 'dashboard'
if (path.includes('/admin/user')) return 'user'
if (path.includes('/admin/music')) return 'music'
if (path.includes('/admin/playlist')) return 'playlist'
if (path.includes('/admin/singer')) return 'singer'
if (path.includes('/admin/banner')) return 'banner'
if (path.includes('/admin/type')) return 'type'
return 'dashboard'
})
// 切换侧边栏折叠状态
const toggleSidebar = () => {
isCollapse.value = !isCollapse.value
}
// 退出后台
const exitAdmin = () => {
router.push('/')
}
// 退出登录
const logout = () => {
ElMessageBox.confirm('确定要退出登录吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
userStore.logout()
router.push('/')
ElMessage.success('已退出登录')
}).catch(() => {})
}
</script>
<template>
<div class="admin-container" :class="{ 'is-collapse': isCollapse }">
<!-- 侧边栏 -->
<div class="admin-sidebar">
<div class="sidebar-header">
<div class="logo">
<img src="https://via.placeholder.com/40" alt="Logo">
<span v-show="!isCollapse">音乐后台管理</span>
</div>
<el-icon class="collapse-icon" @click="toggleSidebar">
<fold v-if="isCollapse" />
<expand v-else />
</el-icon>
</div>
<el-menu
:default-active="activeMenu"
class="sidebar-menu"
:collapse="isCollapse"
:collapse-transition="false"
router
>
<el-menu-item index="dashboard" @click="router.push('/admin')">
<el-icon><data-analysis /></el-icon>
<template #title>控制面板</template>
</el-menu-item>
<el-menu-item index="user" @click="router.push('/admin/user')">
<el-icon><user /></el-icon>
<template #title>用户管理</template>
</el-menu-item>
<el-menu-item index="music" @click="router.push('/admin/music')">
<el-icon><headset /></el-icon>
<template #title>音乐管理</template>
</el-menu-item>
<el-menu-item index="playlist" @click="router.push('/admin/playlist')">
<el-icon><files /></el-icon>
<template #title>歌单管理</template>
</el-menu-item>
<el-menu-item index="singer" @click="router.push('/admin/singer')">
<el-icon><avatar /></el-icon>
<template #title>歌手管理</template>
</el-menu-item>
<el-menu-item index="banner" @click="router.push('/admin/banner')">
<el-icon><picture /></el-icon>
<template #title>轮播图管理</template>
</el-menu-item>
<el-menu-item index="type" @click="router.push('/admin/type')">
<el-icon><collection-tag /></el-icon>
<template #title>类型管理</template>
</el-menu-item>
</el-menu>
</div>
<!-- 主内容区 -->
<div class="admin-main">
<!-- 顶部导航 -->
<div class="admin-header">
<div class="header-left">
<el-breadcrumb separator="/">
<el-breadcrumb-item :to="{ path: '/admin' }">首页</el-breadcrumb-item>
<el-breadcrumb-item v-if="activeMenu !== 'dashboard'">
{{
activeMenu === 'user' ? '用户管理' :
activeMenu === 'music' ? '音乐管理' :
activeMenu === 'playlist' ? '歌单管理' :
activeMenu === 'singer' ? '歌手管理' :
activeMenu === 'banner' ? '轮播图管理' :
activeMenu === 'type' ? '类型管理' : ''
}}
</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="header-right">
<el-dropdown>
<span class="el-dropdown-link">
{{ userStore.user?.nickname || userStore.user?.username }}
<el-icon class="el-icon--right"><arrow-down /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="exitAdmin">返回前台</el-dropdown-item>
<el-dropdown-item @click="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<!-- 内容区 -->
<div class="admin-content">
<router-view />
</div>
</div>
</div>
</template>
<style scoped>
.admin-container {
display: flex;
height: 100vh;
overflow: hidden;
}
.admin-sidebar {
width: 220px;
height: 100%;
background-color: #304156;
transition: width 0.3s;
display: flex;
flex-direction: column;
}
.admin-container.is-collapse .admin-sidebar {
width: 64px;
}
.sidebar-header {
height: 60px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
color: #fff;
border-bottom: 1px solid #1f2d3d;
}
.logo {
display: flex;
align-items: center;
}
.logo img {
width: 32px;
height: 32px;
margin-right: 10px;
}
.collapse-icon {
cursor: pointer;
font-size: 20px;
}
.sidebar-menu {
flex: 1;
border-right: none;
background-color: #304156;
}
.sidebar-menu :deep(.el-menu-item) {
color: #bfcbd9;
}
.sidebar-menu :deep(.el-menu-item.is-active) {
color: #409eff;
background-color: #263445;
}
.sidebar-menu :deep(.el-menu-item:hover) {
background-color: #263445;
}
.admin-main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.admin-header {
height: 60px;
background-color: #fff;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
}
.header-left {
display: flex;
align-items: center;
}
.header-right {
display: flex;
align-items: center;
}
.el-dropdown-link {
cursor: pointer;
display: flex;
align-items: center;
}
.admin-content {
flex: 1;
padding: 20px;
background-color: #f0f2f5;
}
</style>

View File

@@ -0,0 +1,788 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getMusicList, addMusic as apiAddMusic, updateMusic as apiUpdateMusic, deleteMusic as apiDeleteMusic, processMusicUrl } from '../../api/music'
import { getMusicTypeList } from '../../api/musicType'
// 音乐列表
const musicList = ref([])
// 加载状态
const loading = ref(false)
// 分页信息
const pagination = ref({
currentPage: 1,
pageSize: 5,
total: 0
})
// 搜索关键字
const searchKeyword = ref('')
// 类型筛选
const selectedType = ref('')
// 类型列表
const typeList = ref([])
// 添加/编辑对话框
const dialogVisible = ref(false)
// 对话框标题
const dialogTitle = ref('添加音乐')
// 当前编辑的音乐
const currentMusic = ref(null)
// 表单数据
const form = ref({
name: '',
singer: '',
album: '',
types: [],
duration: 0,
file: null,
cover: null,
lyric: ''
})
// 获取音乐列表
const fetchMusicList = async () => {
loading.value = true
try {
const { currentPage, pageSize } = pagination.value
const res = await getMusicList(
currentPage,
pageSize,
searchKeyword.value,
selectedType.value || null
)
console.log('音乐列表响应:', res)
if (res.code === 200) {
// 处理音乐列表数据
const data = res.data
// 兼容不同的数据结构
const records = data.records || data.list || []
musicList.value = records.map(item => {
// 处理歌手信息
let singer = ''
if (item.singers && item.singers.length > 0) {
singer = item.singers.map(s => s.name).join(', ')
} else if (item.singerNames && item.singerNames.length > 0) {
singer = item.singerNames.join(', ')
} else if (item.singer) {
singer = item.singer
}
// 处理类型信息
let types = []
if (item.types && item.types.length > 0) {
types = item.types.map(t => t.id)
} else if (item.typeIds && item.typeIds.length > 0) {
types = item.typeIds
} else if (item.typeId) {
types = [item.typeId]
}
// 处理时长
let duration = item.duration || 0
return {
...item,
// 处理封面URL
cover: processMusicUrl(item.cover),
// 处理音乐URL
url: processMusicUrl(item.url),
// 设置歌手名称
singer,
// 设置类型数组
types,
// 设置时长
duration
}
})
pagination.value.total = data.total || 0
console.log('处理后的音乐列表:', musicList.value)
} else {
ElMessage.error(res.message || '获取音乐列表失败')
}
} catch (error) {
console.error('获取音乐列表失败:', error)
ElMessage.error('获取音乐列表失败')
} finally {
loading.value = false
}
}
// 获取类型列表
const fetchTypeList = async () => {
try {
const res = await getMusicTypeList()
if (res.code === 200) {
typeList.value = res.data || []
} else {
ElMessage.error(res.message || '获取类型列表失败')
}
} catch (error) {
console.error('获取类型列表失败:', error)
ElMessage.error('获取类型列表失败')
}
}
// 处理页码变化
const handleCurrentChange = (page) => {
pagination.value.currentPage = page
fetchMusicList()
}
// 处理每页条数变化
const handleSizeChange = (size) => {
pagination.value.pageSize = size
pagination.value.currentPage = 1
fetchMusicList()
}
// 搜索音乐
const searchMusic = () => {
pagination.value.currentPage = 1
fetchMusicList()
}
// 重置搜索
const resetSearch = () => {
searchKeyword.value = ''
selectedType.value = ''
pagination.value.currentPage = 1
fetchMusicList()
}
// 添加音乐
const addMusic = () => {
dialogTitle.value = '添加音乐'
currentMusic.value = null
form.value = {
name: '',
singer: '',
album: '',
types: [],
duration: 0,
file: null,
cover: null,
lyric: ''
}
dialogVisible.value = true
}
// 编辑音乐
const editMusic = (row) => {
dialogTitle.value = '编辑音乐'
currentMusic.value = row
// 获取歌手信息
let singer = row.singer || ''
if (!singer && row.singers && row.singers.length > 0) {
singer = row.singers.map(s => s.name).join(', ')
} else if (!singer && row.singerNames && row.singerNames.length > 0) {
singer = row.singerNames.join(', ')
}
// 获取类型信息
let types = row.types || []
if ((!types || types.length === 0) && row.typeIds && row.typeIds.length > 0) {
types = row.typeIds
} else if ((!types || types.length === 0) && row.typeId) {
types = [row.typeId]
}
form.value = {
name: row.name,
singer: singer,
album: row.album || '',
types: types,
duration: row.duration || 0,
file: null,
cover: null,
lyric: row.lyric || ''
}
console.log('编辑音乐数据:', {
original: row,
processed: form.value
})
dialogVisible.value = true
}
// 删除音乐
const deleteMusic = (row) => {
ElMessageBox.confirm(`确定要删除音乐 ${row.name} 吗?此操作不可恢复`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await apiDeleteMusic(row.id)
if (res.code === 200) {
ElMessage.success('删除成功')
fetchMusicList() // 重新加载列表
} else {
ElMessage.error(res.message || '删除失败')
}
} catch (error) {
console.error('删除音乐失败:', error)
ElMessage.error('删除失败,请稍后重试')
}
}).catch(() => {})
}
// 提交表单
const submitForm = async () => {
// 表单验证
if (!form.value.name) {
ElMessage.warning('请输入音乐名称')
return
}
if (!form.value.singer) {
ElMessage.warning('请输入歌手名称')
return
}
// 添加音乐时必须上传音乐文件
if (!currentMusic.value && !form.value.file) {
ElMessage.warning('请上传音乐文件')
return
}
try {
let res
if (currentMusic.value) {
// 编辑音乐
res = await apiUpdateMusic(
currentMusic.value.id,
{
name: form.value.name,
singer: form.value.singer,
album: form.value.album,
typeIds: form.value.types,
lyric: form.value.lyric
},
form.value.file,
form.value.cover
)
} else {
// 添加音乐
res = await apiAddMusic(
{
name: form.value.name,
singer: form.value.singer,
album: form.value.album,
typeIds: form.value.types,
lyric: form.value.lyric
},
form.value.file,
form.value.cover
)
}
if (res.code === 200) {
ElMessage.success(currentMusic.value ? '编辑成功' : '添加成功')
dialogVisible.value = false
fetchMusicList() // 重新加载列表
} else {
ElMessage.error(res.message || (currentMusic.value ? '编辑失败' : '添加失败'))
}
} catch (error) {
console.error(currentMusic.value ? '编辑音乐失败:' : '添加音乐失败:', error)
ElMessage.error(currentMusic.value ? '编辑失败,请稍后重试' : '添加失败,请稍后重试')
}
}
// 格式化时间
const formatTime = (seconds) => {
if (!seconds) return '00:00'
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
// 格式化类型
const formatTypes = (types) => {
if (!types || !types.length) return '-'
return types.map(typeId => {
const type = typeList.value.find(t => t.id === typeId)
return type ? type.name : ''
}).filter(Boolean).join(', ')
}
// 处理文件上传
const handleFileChange = (file) => {
if (file && file.raw) {
// 检查文件类型
const isAudio = file.raw.type.startsWith('audio/')
if (!isAudio) {
ElMessage.error('只能上传音频文件!')
return
}
// 检查文件大小 (100MB)
const isLt100M = file.raw.size / 1024 / 1024 < 100
if (!isLt100M) {
ElMessage.error('音频文件大小不能超过 100MB!')
return
}
form.value.file = file.raw
}
}
// 处理封面上传
const handleCoverChange = (file) => {
if (file && file.raw) {
// 检查文件类型
const isImage = file.raw.type.startsWith('image/')
if (!isImage) {
ElMessage.error('只能上传图片文件!')
return
}
// 检查文件大小 (2MB)
const isLt2M = file.raw.size / 1024 / 1024 < 2
if (!isLt2M) {
ElMessage.error('图片大小不能超过 2MB!')
return
}
form.value.cover = file.raw
}
}
// 处理歌词上传
const handleLyricChange = (file) => {
if (file && file.raw) {
// 检查文件类型
const isText = file.raw.type === 'text/plain' || file.raw.name.endsWith('.lrc')
if (!isText) {
ElMessage.error('只能上传文本文件或LRC文件!')
return
}
// 检查文件大小 (1MB)
const isLt1M = file.raw.size / 1024 / 1024 < 1
if (!isLt1M) {
ElMessage.error('歌词文件大小不能超过 1MB!')
return
}
// 读取歌词文件内容
const reader = new FileReader()
reader.onload = (e) => {
form.value.lyric = e.target.result
}
reader.readAsText(file.raw)
}
}
onMounted(() => {
fetchMusicList()
fetchTypeList()
})
</script>
<template>
<div class="admin-music-container">
<!-- 搜索栏 -->
<div class="search-bar">
<el-input
v-model="searchKeyword"
placeholder="请输入音乐名称/歌手/专辑"
clearable
@clear="resetSearch"
@keyup.enter="searchMusic"
class="search-input"
>
<template #append>
<el-button @click="searchMusic">
<el-icon><search /></el-icon>
搜索
</el-button>
</template>
</el-input>
<el-select
v-model="selectedType"
placeholder="选择类型"
clearable
@change="searchMusic"
class="type-select"
>
<el-option
v-for="type in typeList"
:key="type.id"
:label="type.name"
:value="type.id"
/>
</el-select>
<el-button type="primary" @click="addMusic">
<el-icon><plus /></el-icon>
添加音乐
</el-button>
</div>
<!-- 音乐列表 -->
<el-table
:data="musicList"
stripe
style="width: 100%"
v-loading="loading"
>
<el-table-column prop="id" label="ID" width="80"></el-table-column>
<el-table-column label="封面" width="80" align="center">
<template #default="scope">
<el-image
:src="scope.row.cover || '/default-cover.jpg'"
:alt="scope.row.name"
class="music-cover"
fit="cover"
:preview-src-list="scope.row.cover ? [scope.row.cover] : []"
:initial-index="0"
preview-teleported
>
<template #error>
<div class="image-error">
<el-icon><picture /></el-icon>
</div>
</template>
</el-image>
</template>
</el-table-column>
<el-table-column prop="name" label="音乐名称" min-width="150"></el-table-column>
<el-table-column prop="singer" label="歌手" min-width="120"></el-table-column>
<el-table-column prop="album" label="专辑" min-width="120"></el-table-column>
<el-table-column prop="types" label="类型" min-width="120">
<template #default="scope">
{{ formatTypes(scope.row.types) }}
</template>
</el-table-column>
<el-table-column prop="duration" label="时长" width="100" align="center">
<template #default="scope">
{{ formatTime(scope.row.duration || 0) }}
</template>
</el-table-column>
<el-table-column prop="playCount" label="播放次数" width="100" align="center"></el-table-column>
<el-table-column prop="createTime" label="上传时间" width="120"></el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="scope">
<el-button
size="small"
type="primary"
@click="editMusic(scope.row)"
>
编辑
</el-button>
<el-button
size="small"
type="danger"
@click="deleteMusic(scope.row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination">
<el-pagination
v-model:current-page="pagination.currentPage"
v-model:page-size="pagination.pageSize"
:page-sizes="[5, 10, 15, 20]"
layout="total, sizes, prev, pager, next, jumper"
:total="pagination.total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
<!-- 添加/编辑对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="600px"
>
<el-form
:model="form"
label-width="100px"
>
<el-form-item label="音乐名称" required>
<el-input v-model="form.name" placeholder="请输入音乐名称" />
</el-form-item>
<el-form-item label="歌手" required>
<el-input v-model="form.singer" placeholder="请输入歌手名称" />
</el-form-item>
<el-form-item label="专辑">
<el-input v-model="form.album" placeholder="请输入专辑名称" />
</el-form-item>
<el-form-item label="类型">
<el-select
v-model="form.types"
multiple
placeholder="请选择音乐类型"
style="width: 100%"
>
<el-option
v-for="type in typeList"
:key="type.id"
:label="type.name"
:value="type.id"
/>
</el-select>
</el-form-item>
<el-form-item label="音乐文件">
<el-upload
action="#"
:auto-upload="false"
:on-change="handleFileChange"
:limit="1"
>
<el-button type="primary">选择文件</el-button>
<template #tip>
<div class="el-upload__tip">
只能上传mp3/wav文件
</div>
</template>
</el-upload>
</el-form-item>
<el-form-item label="封面">
<el-upload
action="#"
:auto-upload="false"
:on-change="handleCoverChange"
:limit="1"
list-type="picture-card"
>
<el-icon><plus /></el-icon>
</el-upload>
</el-form-item>
<el-form-item label="歌词">
<el-upload
action="#"
:auto-upload="false"
:on-change="handleLyricChange"
:limit="1"
>
<el-button type="primary">选择歌词文件</el-button>
<template #tip>
<div class="el-upload__tip">
只能上传lrc/txt文件
</div>
</template>
</el-upload>
<el-input
v-model="form.lyric"
type="textarea"
:rows="5"
placeholder="或直接输入歌词"
style="margin-top: 10px"
/>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm">确定</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.admin-music-container {
padding: 20px;
background: rgba(255, 255, 255, 0.8);
border-radius: 16px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
}
.search-bar {
margin-bottom: 20px;
display: flex;
gap: 10px;
align-items: center;
background: rgba(255, 255, 255, 0.9);
padding: 15px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.search-input {
width: 300px;
}
.search-input :deep(.el-input__inner) {
border-radius: 8px;
border: 1px solid #b3e5fc;
transition: all 0.3s;
}
.search-input :deep(.el-input__inner):focus {
border-color: #4dd0e1;
box-shadow: 0 0 0 2px rgba(77, 208, 225, 0.2);
}
.type-select {
width: 150px;
}
.type-select :deep(.el-input__inner) {
border-radius: 8px;
border: 1px solid #b3e5fc;
}
.el-button {
border-radius: 8px !important;
font-weight: bold !important;
transition: all 0.3s !important;
}
.el-button--primary {
background: linear-gradient(135deg, #4dd0e1 0%, #26c6da 100%) !important;
border: none !important;
}
.el-button--primary:hover {
transform: translateY(-2px) !important;
box-shadow: 0 4px 8px rgba(38, 198, 218, 0.3) !important;
}
.el-table {
border-radius: 12px;
overflow: hidden;
}
.el-table :deep(th) {
background-color: #e1f5fe !important;
color: #0d47a1;
}
.el-table :deep(.el-table__row) {
transition: all 0.3s;
}
.el-table :deep(.el-table__row:hover) {
background-color: #f5f5f5 !important;
transform: scale(1.01);
}
.pagination {
margin-top: 20px;
display: flex;
justify-content: flex-end;
background: rgba(255, 255, 255, 0.9);
padding: 15px;
border-radius: 12px;
}
.music-cover {
width: 50px;
height: 50px;
border-radius: 8px;
object-fit: cover;
transition: all 0.3s;
}
.music-cover:hover {
transform: scale(1.1);
}
.image-error {
width: 50px;
height: 50px;
display: flex;
justify-content: center;
align-items: center;
background-color: #f5f7fa;
color: #909399;
font-size: 20px;
border-radius: 8px;
}
.el-dialog {
border-radius: 16px !important;
}
.el-dialog :deep(.el-dialog__header) {
background: linear-gradient(135deg, #e1f5fe 0%, #b3e5fc 100%);
border-radius: 16px 16px 0 0 !important;
padding: 15px 20px;
}
.el-dialog :deep(.el-dialog__title) {
color: #0d47a1;
font-weight: bold;
}
.el-form-item :deep(.el-form-item__label) {
color: #4a4a4a;
font-weight: bold;
}
.el-input :deep(.el-input__inner) {
border-radius: 8px;
border: 1px solid #b3e5fc;
}
.el-input :deep(.el-input__inner):focus {
border-color: #4dd0e1;
box-shadow: 0 0 0 2px rgba(77, 208, 225, 0.2);
}
.el-upload :deep(.el-upload-dragger) {
border-radius: 8px;
border: 1px dashed #b3e5fc;
}
.el-upload :deep(.el-upload-dragger:hover) {
border-color: #4dd0e1;
}
.el-upload-list :deep(.el-upload-list__item) {
transition: all 0.3s;
}
.el-upload-list :deep(.el-upload-list__item:hover) {
background-color: #f5f5f5;
}
.el-table :deep(.el-button) {
margin-left: 0;
margin-right: 5px;
padding: 4px 8px;
border-radius: 8px !important;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,333 @@
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getPlaylistTypePage, addPlaylistType, updatePlaylistType, deletePlaylistType } from '../../api/playlistType'
// 类型列表
const typeList = ref([])
// 加载状态
const loading = ref(false)
// 分页信息
const pagination = ref({
currentPage: 1,
pageSize: 7, // 每页显示7个项目
total: 0
})
// 搜索关键字
const searchKeyword = ref('')
// 添加/编辑对话框
const dialogVisible = ref(false)
// 对话框标题
const dialogTitle = ref('添加歌单类型')
// 当前编辑的类型
const currentType = ref(null)
// 表单数据
const form = ref({
name: '',
description: ''
})
// 获取类型列表
const fetchTypeList = async () => {
loading.value = true
try {
const { currentPage, pageSize } = pagination.value
const res = await getPlaylistTypePage(currentPage, pageSize, searchKeyword.value)
console.log('歌单类型列表响应:', res)
if (res.code === 200) {
// 处理类型列表数据
const data = res.data
// 兼容不同的数据结构
typeList.value = data.records || data.list || []
pagination.value.total = data.total || 0
} else {
ElMessage.error(res.message || '获取歌单类型列表失败')
}
} catch (error) {
console.error('获取歌单类型列表失败:', error)
ElMessage.error('获取歌单类型列表失败')
} finally {
loading.value = false
}
}
// 处理页码变化
const handleCurrentChange = (page) => {
pagination.value.currentPage = page
fetchTypeList()
}
// 处理每页条数变化
const handleSizeChange = (size) => {
pagination.value.pageSize = size
pagination.value.currentPage = 1
fetchTypeList()
}
// 搜索类型
const searchType = () => {
pagination.value.currentPage = 1
fetchTypeList()
}
// 重置搜索
const resetSearch = () => {
searchKeyword.value = ''
pagination.value.currentPage = 1
fetchTypeList()
}
// 添加类型
const addType = () => {
dialogTitle.value = '添加歌单类型'
currentType.value = null
form.value = {
name: '',
description: ''
}
dialogVisible.value = true
}
// 编辑类型
const editType = (row) => {
dialogTitle.value = '编辑歌单类型'
currentType.value = row
form.value = {
name: row.name,
description: row.description || ''
}
dialogVisible.value = true
}
// 删除类型
const deleteType = (row) => {
ElMessageBox.confirm(`确定要删除歌单类型 ${row.name} 吗?此操作不可恢复`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await deletePlaylistType(row.id)
if (res.code === 200) {
ElMessage.success('删除成功')
fetchTypeList() // 重新加载列表
} else {
ElMessage.error(res.message || '删除失败')
}
} catch (error) {
console.error('删除歌单类型失败:', error)
ElMessage.error('删除失败,请稍后重试')
}
}).catch(() => {})
}
// 提交表单
const submitForm = async () => {
// 表单验证
if (!form.value.name) {
ElMessage.warning('请输入类型名称')
return
}
try {
let res
const formData = {
name: form.value.name,
description: form.value.description
}
if (currentType.value) {
// 编辑类型
res = await updatePlaylistType(currentType.value.id, formData)
} else {
// 添加类型
res = await addPlaylistType(formData)
}
if (res.code === 200) {
ElMessage.success(currentType.value ? '编辑成功' : '添加成功')
dialogVisible.value = false
fetchTypeList() // 重新加载列表
} else {
ElMessage.error(res.message || (currentType.value ? '编辑失败' : '添加失败'))
}
} catch (error) {
console.error(currentType.value ? '编辑歌单类型失败:' : '添加歌单类型失败:', error)
ElMessage.error(currentType.value ? '编辑失败,请稍后重试' : '添加失败,请稍后重试')
}
}
onMounted(() => {
fetchTypeList()
})
</script>
<template>
<div class="admin-playlist-type-container">
<!-- 搜索栏 -->
<div class="search-bar">
<el-input
v-model="searchKeyword"
placeholder="请输入类型名称/描述"
clearable
@clear="resetSearch"
@keyup.enter="searchType"
class="search-input"
>
<template #append>
<el-button @click="searchType">
<el-icon><search /></el-icon>
搜索
</el-button>
</template>
</el-input>
<el-button type="primary" @click="addType">
<el-icon><plus /></el-icon>
添加类型
</el-button>
</div>
<!-- 类型列表 -->
<el-table
:data="typeList"
stripe
style="width: 100%"
v-loading="loading"
>
<el-table-column prop="id" label="ID" width="80"></el-table-column>
<el-table-column prop="name" label="类型名称" min-width="150"></el-table-column>
<el-table-column prop="description" label="描述" min-width="200">
<template #default="scope">
<el-tooltip
:content="scope.row.description"
placement="top"
:show-after="500"
>
<div class="description-text">{{ scope.row.description || '-' }}</div>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="scope">
<el-button
size="small"
type="primary"
@click="editType(scope.row)"
>
编辑
</el-button>
<el-button
size="small"
type="danger"
@click="deleteType(scope.row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination">
<el-pagination
v-model:current-page="pagination.currentPage"
v-model:page-size="pagination.pageSize"
:page-sizes="[7, 14, 21, 28]"
layout="total, sizes, prev, pager, next, jumper"
:total="pagination.total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
<!-- 添加/编辑对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="500px"
>
<el-form
:model="form"
label-width="100px"
>
<el-form-item label="类型名称" required>
<el-input v-model="form.name" placeholder="请输入类型名称" />
</el-form-item>
<el-form-item label="描述">
<el-input
v-model="form.description"
type="textarea"
:rows="3"
placeholder="请输入类型描述"
/>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm">确定</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.admin-playlist-type-container {
padding: 20px;
height: calc(100vh - 120px);
display: flex;
flex-direction: column;
}
.search-bar {
margin-bottom: 20px;
display: flex;
gap: 10px;
align-items: center;
}
.search-input {
width: 300px;
}
.el-table {
flex: 1;
overflow: hidden;
}
.pagination {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
.description-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
}
.el-table :deep(.el-button) {
margin-left: 0;
margin-right: 5px;
padding: 4px 8px;
}
</style>

View File

@@ -0,0 +1,852 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getSingerList, addSinger as apiAddSinger, updateSinger, deleteSinger } from '../../api/singer'
import { getSingerTypeList } from '../../api/admin/type'
import { getMusicList } from '../../api/music'
import { addMusicToSinger, removeMusicFromSinger } from '../../api/singer'
// 歌手列表
const singerList = ref([])
// 加载状态
const loading = ref(false)
// 分页信息
const pagination = ref({
currentPage: 1,
pageSize: 5,
total: 0
})
// 搜索关键字
const searchKeyword = ref('')
// 类型筛选
const selectedType = ref('')
// 类型列表
const typeList = ref([])
// 添加/编辑对话框
const dialogVisible = ref(false)
// 对话框标题
const dialogTitle = ref('添加歌手')
// 当前编辑的歌手
const currentSinger = ref(null)
// 表单数据
const form = ref({
name: '',
typeId: '',
introduction: '',
avatar: null
})
// 获取歌手列表
const fetchSingerList = async () => {
loading.value = true
try {
const params = {
page: pagination.value.currentPage,
size: pagination.value.pageSize,
keyword: searchKeyword.value,
typeId: selectedType.value || null
}
const res = await getSingerList(params.page, params.size, params.keyword, params.typeId)
if (res.code === 200) {
singerList.value = res.data.list
pagination.value.total = res.data.total
} else {
ElMessage.error(res.message || '获取歌手列表失败')
}
} catch (error) {
console.error('获取歌手列表错误:', error)
ElMessage.error('获取歌手列表失败')
} finally {
loading.value = false
}
}
// 获取类型列表
const fetchTypeList = async () => {
try {
const res = await getSingerTypeList()
if (res.code === 200) {
typeList.value = res.data
} else {
ElMessage.error(res.message || '获取歌手类型列表失败')
}
} catch (error) {
console.error('获取歌手类型列表错误:', error)
ElMessage.error('获取歌手类型列表失败')
}
}
// 处理页码变化
const handleCurrentChange = (page) => {
pagination.value.currentPage = page
fetchSingerList()
}
// 处理每页条数变化
const handleSizeChange = (size) => {
pagination.value.pageSize = size
pagination.value.currentPage = 1
fetchSingerList()
}
// 搜索歌手
const searchSinger = () => {
pagination.value.currentPage = 1
fetchSingerList()
}
// 重置搜索
const resetSearch = () => {
searchKeyword.value = ''
selectedType.value = ''
pagination.value.currentPage = 1
fetchSingerList()
}
// 添加歌手
const addSinger = () => {
dialogTitle.value = '添加歌手'
currentSinger.value = null
form.value = {
name: '',
typeId: '',
introduction: '',
avatar: null
}
dialogVisible.value = true
}
// 编辑歌手
const editSinger = (row) => {
dialogTitle.value = '编辑歌手'
currentSinger.value = row
form.value = {
name: row.name,
typeId: row.type ? row.type.id : row.typeId,
introduction: row.introduction || '',
avatar: null
}
dialogVisible.value = true
}
// 删除歌手
const handleDeleteSinger = (row) => {
ElMessageBox.confirm(`确定要删除歌手 ${row.name} 吗?此操作不可恢复`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await deleteSinger(row.id)
if (res.code === 200) {
ElMessage.success(res.message || '删除成功')
// 刷新歌手列表
fetchSingerList()
} else {
ElMessage.error(res.message || '删除失败')
}
} catch (error) {
console.error('删除歌手错误:', error)
ElMessage.error('删除歌手失败')
}
}).catch(() => {})
}
// 管理音乐对话框相关状态
const musicDialogVisible = ref(false)
const currentSingerId = ref(null)
const currentSingerName = ref('')
const musicList = ref([])
const selectedMusicIds = ref([])
const musicSearchKeyword = ref('')
const musicLoading = ref(false)
// 获取音乐列表
const fetchMusicList = async () => {
musicLoading.value = true
try {
const res = await getMusicList(1, 100, musicSearchKeyword.value)
if (res.code === 200) {
musicList.value = res.data.list || res.data.records || []
} else {
ElMessage.error(res.message || '获取音乐列表失败')
}
} catch (error) {
console.error('获取音乐列表失败:', error)
ElMessage.error('获取音乐列表失败')
} finally {
musicLoading.value = false
}
}
// 获取歌手关联的音乐
const fetchSingerMusic = async (singerId) => {
try {
const res = await getSingerList(1, 1, '', singerId)
if (res.code === 200 && res.data.list[0].musics) {
selectedMusicIds.value = res.data.list[0].musics.map(m => m.id)
}
} catch (error) {
console.error('获取歌手音乐失败:', error)
}
}
// 管理歌手音乐
const manageSingerMusic = async (row) => {
currentSingerId.value = row.id
currentSingerName.value = row.name
musicSearchKeyword.value = ''
musicDialogVisible.value = true
// 加载音乐列表和已选音乐
await Promise.all([
fetchMusicList(),
fetchSingerMusic(row.id)
])
}
// 添加音乐到歌手
const addMusicToSingerLocal = async (musicId) => {
try {
const res = await addMusicToSinger(currentSingerId.value, musicId)
if (res.code === 200) {
ElMessage.success('添加成功')
await fetchSingerMusic(currentSingerId.value)
} else {
ElMessage.error(res.message || '添加失败')
}
} catch (error) {
console.error('添加音乐失败:', error)
ElMessage.error('添加失败')
}
}
// 从歌手移除音乐
const removeMusicFromSingerLocal = async (musicId) => {
try {
const res = await removeMusicFromSinger(currentSingerId.value, musicId)
if (res.code === 200) {
ElMessage.success('移除成功')
await fetchSingerMusic(currentSingerId.value)
} else {
ElMessage.error(res.message || '移除失败')
}
} catch (error) {
console.error('移除音乐失败:', error)
ElMessage.error('移除失败')
}
}
// 搜索音乐
const searchMusic = () => {
fetchMusicList()
}
// 提交表单
const submitForm = async () => {
try {
// 构建歌手数据
const singerData = {
name: form.value.name,
introduction: form.value.introduction,
type: form.value.typeId ? { id: form.value.typeId } : null
}
let res
if (currentSinger.value) {
// 编辑歌手
res = await updateSinger(currentSinger.value.id, singerData, form.value.avatar)
} else {
// 添加歌手
res = await apiAddSinger(singerData, form.value.avatar)
}
if (res.code === 200) {
ElMessage.success(res.message || (currentSinger.value ? '编辑成功' : '添加成功'))
dialogVisible.value = false
// 刷新歌手列表
fetchSingerList()
} else {
ElMessage.error(res.message || '操作失败')
}
} catch (error) {
console.error('提交歌手表单错误:', error)
ElMessage.error('操作失败')
}
}
// 格式化类型
const formatType = (typeId) => {
const type = typeList.value.find(t => t.id === typeId)
return type ? type.name : '-'
}
// 格式化粉丝数
const formatFans = (count) => {
if (count < 10000) {
return count.toString()
} else if (count < 100000000) {
return (count / 10000).toFixed(1) + '万'
} else {
return (count / 100000000).toFixed(1) + '亿'
}
}
// 处理头像上传
const handleAvatarChange = (file) => {
if (file && file.raw) {
// 验证文件类型
const isImage = file.raw.type.startsWith('image/')
if (!isImage) {
ElMessage.error('只能上传图片文件!')
return
}
// 验证文件大小
const isLt2M = file.raw.size / 1024 / 1024 < 2
if (!isLt2M) {
ElMessage.error('图片大小不能超过 2MB!')
return
}
form.value.avatar = file.raw
}
}
onMounted(() => {
fetchSingerList()
fetchTypeList()
})
</script>
<template>
<div class="admin-singer-container">
<!-- 搜索栏 -->
<div class="search-bar">
<el-input
v-model="searchKeyword"
placeholder="请输入歌手名称/简介"
clearable
@clear="resetSearch"
@keyup.enter="searchSinger"
class="search-input"
>
<template #append>
<el-button @click="searchSinger">
<el-icon><search /></el-icon>
搜索
</el-button>
</template>
</el-input>
<el-select
v-model="selectedType"
placeholder="选择类型"
clearable
@change="searchSinger"
class="type-select"
>
<el-option
v-for="type in typeList"
:key="type.id"
:label="type.name"
:value="type.id"
/>
</el-select>
<el-button type="primary" @click="addSinger">
<el-icon><plus /></el-icon>
添加歌手
</el-button>
</div>
<!-- 歌手列表 -->
<el-table
:data="singerList"
stripe
style="width: 100%"
v-loading="loading"
>
<el-table-column prop="id" label="ID" width="80"></el-table-column>
<el-table-column label="头像" width="80" align="center">
<template #default="scope">
<img
:src="scope.row.avatar"
:alt="scope.row.name"
class="singer-avatar"
>
</template>
</el-table-column>
<el-table-column prop="name" label="歌手名称" min-width="150"></el-table-column>
<el-table-column label="类型" width="120">
<template #default="scope">
{{ formatType(scope.row.type ? scope.row.type.id : scope.row.typeId) }}
</template>
</el-table-column>
<el-table-column prop="introduction" label="简介" min-width="200">
<template #default="scope">
<el-tooltip
:content="scope.row.introduction"
placement="top"
:show-after="500"
>
<div class="introduction-text">{{ scope.row.introduction }}</div>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="musicCount" label="单曲数" width="80" align="center"></el-table-column>
<el-table-column prop="fansCount" label="粉丝数" width="100" align="center">
<template #default="scope">
{{ formatFans(scope.row.fansCount || 0) }}
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" width="120"></el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="scope">
<el-button
size="small"
@click="manageSingerMusic(scope.row)"
>
管理音乐
</el-button>
<el-button
size="small"
type="primary"
@click="editSinger(scope.row)"
>
编辑
</el-button>
<el-button
size="small"
type="danger"
@click="handleDeleteSinger(scope.row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination">
<el-pagination
v-model:current-page="pagination.currentPage"
v-model:page-size="pagination.pageSize"
:page-sizes="[5, 10, 15, 20]"
layout="total, sizes, prev, pager, next, jumper"
:total="pagination.total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
<!-- 添加/编辑对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="600px"
>
<el-form
:model="form"
label-width="100px"
>
<el-form-item label="歌手名称" required>
<el-input v-model="form.name" placeholder="请输入歌手名称" />
</el-form-item>
<el-form-item label="类型" required>
<el-select
v-model="form.typeId"
placeholder="请选择歌手类型"
style="width: 100%"
>
<el-option
v-for="type in typeList"
:key="type.id"
:label="type.name"
:value="type.id"
/>
</el-select>
</el-form-item>
<el-form-item label="简介">
<el-input
v-model="form.introduction"
type="textarea"
:rows="3"
placeholder="请输入歌手简介"
/>
</el-form-item>
<el-form-item label="头像">
<!-- 当前头像预览 -->
<div v-if="currentSinger && currentSinger.avatar" class="current-avatar">
<el-image
:src="currentSinger.avatar"
fit="cover"
class="avatar-preview"
:preview-src-list="[currentSinger.avatar]"
:initial-index="0"
preview-teleported
/>
<div class="avatar-tip">当前头像</div>
</div>
<el-upload
action="#"
:auto-upload="false"
:on-change="handleAvatarChange"
:limit="1"
list-type="picture-card"
>
<el-icon><plus /></el-icon>
</el-upload>
<div class="el-upload__tip">
点击上传新头像不上传则保持原头像
</div>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm">确定</el-button>
</span>
</template>
</el-dialog>
<!-- 管理音乐对话框 -->
<el-dialog
v-model="musicDialogVisible"
:title="`管理歌手 ${currentSingerName} 的音乐`"
width="800px"
>
<div class="music-management-container">
<!-- 搜索栏 -->
<div class="search-bar">
<el-input
v-model="musicSearchKeyword"
placeholder="搜索音乐名称"
clearable
@keyup.enter="searchMusic"
class="search-input"
>
<template #append>
<el-button @click="searchMusic">
<el-icon><search /></el-icon>
搜索
</el-button>
</template>
</el-input>
</div>
<!-- 音乐列表 -->
<div class="music-list-container">
<el-table
:data="musicList"
stripe
style="width: 100%"
v-loading="musicLoading"
>
<el-table-column prop="id" label="ID" width="80"></el-table-column>
<el-table-column prop="name" label="音乐名称" min-width="150"></el-table-column>
<el-table-column label="操作" width="120">
<template #default="scope">
<el-button
size="small"
type="primary"
@click="addMusicToSingerLocal(scope.row.id)"
:disabled="selectedMusicIds.includes(scope.row.id)"
>
{{ selectedMusicIds.includes(scope.row.id) ? '已添加' : '添加' }}
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- 已添加音乐 -->
<div class="selected-music-container">
<h3>已添加的音乐</h3>
<el-table
:data="musicList.filter(m => selectedMusicIds.includes(m.id))"
stripe
style="width: 100%"
empty-text="暂无音乐"
>
<el-table-column prop="id" label="ID" width="80"></el-table-column>
<el-table-column prop="name" label="音乐名称" min-width="150"></el-table-column>
<el-table-column label="操作" width="120">
<template #default="scope">
<el-button
size="small"
type="danger"
@click="removeMusicFromSingerLocal(scope.row.id)"
>
移除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="musicDialogVisible = false">关闭</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.music-management-container {
padding: 10px;
}
.search-bar {
margin-bottom: 20px;
}
.search-input {
width: 300px;
}
.music-list-container,
.selected-music-container {
margin-bottom: 20px;
}
.selected-music-container h3 {
margin-bottom: 10px;
color: #606266;
}
</style>
<style scoped>
.admin-singer-container {
padding: 20px;
background: linear-gradient(135deg, #e1f5fe 0%, #b3e5fc 100%);
}
.search-bar {
margin-bottom: 20px;
display: flex;
gap: 10px;
align-items: center;
background: rgba(255, 255, 255, 0.9);
padding: 15px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.search-input {
width: 300px;
}
.search-input :deep(.el-input__inner) {
border-radius: 8px;
border: 1px solid #b3e5fc;
transition: all 0.3s;
}
.search-input :deep(.el-input__inner):focus {
border-color: #4dd0e1;
box-shadow: 0 0 0 2px rgba(77, 208, 225, 0.2);
}
.type-select {
width: 150px;
}
.type-select :deep(.el-input__inner) {
border-radius: 8px;
border: 1px solid #b3e5fc;
}
.el-button {
border-radius: 8px !important;
font-weight: bold !important;
transition: all 0.3s !important;
}
.el-button--primary {
background: linear-gradient(135deg, #4dd0e1 0%, #26c6da 100%) !important;
border: none !important;
}
.el-button--primary:hover {
transform: translateY(-2px) !important;
box-shadow: 0 4px 8px rgba(38, 198, 218, 0.3) !important;
}
.pagination {
margin-top: 20px;
display: flex;
justify-content: flex-end;
background: rgba(255, 255, 255, 0.9);
padding: 15px;
border-radius: 12px;
}
.singer-avatar {
width: 50px;
height: 50px;
border-radius: 50%;
object-fit: cover;
transition: all 0.3s;
}
.singer-avatar:hover {
transform: scale(1.1);
}
.introduction-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
}
.el-table {
--el-table-border-color: #b3e5fc;
--el-table-header-bg-color: #e1f5fe;
--el-table-row-hover-bg-color: #b3e5fc;
border-radius: 8px;
overflow: hidden;
}
.el-table th.el-table__cell {
background-color: #e1f5fe !important;
color: #0d47a1 !important;
font-weight: bold !important;
}
.el-table .el-table__row {
background-color: rgba(255, 255, 255, 0.9) !important;
transition: all 0.3s;
}
.el-table .el-table__row:hover {
background-color: #b3e5fc !important;
transform: scale(1.01);
}
.el-table :deep(.el-button) {
margin-left: 0;
margin-right: 5px;
padding: 4px 8px;
border-radius: 8px !important;
}
.el-dialog {
border-radius: 16px !important;
}
.el-dialog :deep(.el-dialog__header) {
background: linear-gradient(135deg, #e1f5fe 0%, #b3e5fc 100%);
border-radius: 16px 16px 0 0 !important;
padding: 15px 20px;
}
.el-dialog :deep(.el-dialog__title) {
color: #0d47a1;
font-weight: bold;
}
.el-form-item :deep(.el-form-item__label) {
color: #4a4a4a;
font-weight: bold;
}
.el-input :deep(.el-input__inner) {
border-radius: 8px;
border: 1px solid #b3e5fc;
}
.el-input :deep(.el-input__inner):focus {
border-color: #4dd0e1;
box-shadow: 0 0 0 2px rgba(77, 208, 225, 0.2);
}
.current-avatar {
margin-bottom: 10px;
display: flex;
flex-direction: column;
align-items: center;
}
.avatar-preview {
width: 100px;
height: 100px;
border-radius: 50%;
object-fit: cover;
margin-bottom: 5px;
border: 1px solid #b3e5fc;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: all 0.3s;
}
.avatar-preview:hover {
transform: scale(1.05);
}
.avatar-tip {
font-size: 12px;
color: #4a4a4a;
}
.el-upload :deep(.el-upload--picture-card) {
border-radius: 8px;
border: 1px dashed #b3e5fc;
transition: all 0.3s;
}
.el-upload :deep(.el-upload--picture-card:hover) {
border-color: #4dd0e1;
}
.music-management-container {
padding: 10px;
background: rgba(255, 255, 255, 0.9);
border-radius: 12px;
}
.music-management-container .search-bar {
background: transparent;
padding: 0;
box-shadow: none;
}
.music-list-container,
.selected-music-container {
margin-bottom: 20px;
}
.selected-music-container h3 {
margin-bottom: 10px;
color: #4a4a4a;
}
</style>

View File

@@ -0,0 +1,357 @@
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
// 当前选中的类型分类
const activeTab = ref('music')
// 音乐类型列表
const musicTypeList = ref([])
// 歌手类型列表
const singerTypeList = ref([])
// 歌单类型列表
const playlistTypeList = ref([])
// 加载状态
const loading = ref({
music: false,
singer: false,
playlist: false
})
// 添加/编辑对话框
const dialogVisible = ref(false)
// 对话框标题
const dialogTitle = ref('添加类型')
// 当前编辑的类型
const currentType = ref(null)
// 表单数据
const form = ref({
name: '',
description: ''
})
// 获取音乐类型列表
const fetchMusicTypeList = async () => {
loading.value.music = true
try {
// 实际应用中这里应该调用API获取音乐类型列表
// 模拟获取音乐类型列表
setTimeout(() => {
musicTypeList.value = [
{ id: 1, name: '流行', description: '流行音乐', count: 120 },
{ id: 2, name: '摇滚', description: '摇滚音乐', count: 85 },
{ id: 3, name: '民谣', description: '民谣音乐', count: 64 },
{ id: 4, name: '电子', description: '电子音乐', count: 42 },
{ id: 5, name: '古典', description: '古典音乐', count: 36 },
{ id: 6, name: '嘻哈', description: '嘻哈音乐', count: 28 },
{ id: 7, name: '爵士', description: '爵士音乐', count: 15 },
{ id: 8, name: '乡村', description: '乡村音乐', count: 10 }
]
loading.value.music = false
}, 500)
} catch (error) {
ElMessage.error('获取音乐类型列表失败')
loading.value.music = false
}
}
// 获取歌手类型列表
const fetchSingerTypeList = async () => {
loading.value.singer = true
try {
// 实际应用中这里应该调用API获取歌手类型列表
// 模拟获取歌手类型列表
setTimeout(() => {
singerTypeList.value = [
{ id: 1, name: '华语男歌手', description: '华语男歌手', count: 85 },
{ id: 2, name: '华语女歌手', description: '华语女歌手', count: 72 },
{ id: 3, name: '华语组合', description: '华语组合', count: 28 },
{ id: 4, name: '欧美男歌手', description: '欧美男歌手', count: 45 },
{ id: 5, name: '欧美女歌手', description: '欧美女歌手', count: 38 },
{ id: 6, name: '欧美组合', description: '欧美组合', count: 32 },
{ id: 7, name: '日韩男歌手', description: '日韩男歌手', count: 25 },
{ id: 8, name: '日韩女歌手', description: '日韩女歌手', count: 20 },
{ id: 9, name: '日韩组合', description: '日韩组合', count: 15 }
]
loading.value.singer = false
}, 500)
} catch (error) {
ElMessage.error('获取歌手类型列表失败')
loading.value.singer = false
}
}
// 获取歌单类型列表
const fetchPlaylistTypeList = async () => {
loading.value.playlist = true
try {
// 实际应用中这里应该调用API获取歌单类型列表
// 模拟获取歌单类型列表
setTimeout(() => {
playlistTypeList.value = [
{ id: 1, name: '流行', description: '流行歌单', count: 95 },
{ id: 2, name: '摇滚', description: '摇滚歌单', count: 65 },
{ id: 3, name: '民谣', description: '民谣歌单', count: 48 },
{ id: 4, name: '电子', description: '电子歌单', count: 32 },
{ id: 5, name: '古典', description: '古典歌单', count: 25 },
{ id: 6, name: '嘻哈', description: '嘻哈歌单', count: 18 },
{ id: 7, name: '爵士', description: '爵士歌单', count: 12 },
{ id: 8, name: '乡村', description: '乡村歌单', count: 8 },
{ id: 9, name: '轻音乐', description: '轻音乐歌单', count: 22 },
{ id: 10, name: 'ACG', description: 'ACG歌单', count: 30 }
]
loading.value.playlist = false
}, 500)
} catch (error) {
ElMessage.error('获取歌单类型列表失败')
loading.value.playlist = false
}
}
// 添加类型
const addType = () => {
dialogTitle.value = `添加${getTypeText()}类型`
currentType.value = null
form.value = {
name: '',
description: ''
}
dialogVisible.value = true
}
// 编辑类型
const editType = (row) => {
dialogTitle.value = `编辑${getTypeText()}类型`
currentType.value = row
form.value = {
name: row.name,
description: row.description
}
dialogVisible.value = true
}
// 删除类型
const deleteType = (row) => {
ElMessageBox.confirm(`确定要删除${getTypeText()}类型 ${row.name} 吗?此操作不可恢复`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
// 实际应用中这里应该调用API删除类型
if (activeTab.value === 'music') {
musicTypeList.value = musicTypeList.value.filter(item => item.id !== row.id)
} else if (activeTab.value === 'singer') {
singerTypeList.value = singerTypeList.value.filter(item => item.id !== row.id)
} else if (activeTab.value === 'playlist') {
playlistTypeList.value = playlistTypeList.value.filter(item => item.id !== row.id)
}
ElMessage.success('删除成功')
}).catch(() => {})
}
// 提交表单
const submitForm = () => {
// 实际应用中这里应该调用API添加/编辑类型
if (currentType.value) {
// 编辑类型
let index = -1
let list = []
if (activeTab.value === 'music') {
list = musicTypeList.value
index = list.findIndex(item => item.id === currentType.value.id)
} else if (activeTab.value === 'singer') {
list = singerTypeList.value
index = list.findIndex(item => item.id === currentType.value.id)
} else if (activeTab.value === 'playlist') {
list = playlistTypeList.value
index = list.findIndex(item => item.id === currentType.value.id)
}
if (index !== -1) {
list[index] = {
...list[index],
name: form.value.name,
description: form.value.description
}
ElMessage.success('编辑成功')
}
} else {
// 添加类型
const newType = {
id: Date.now(),
name: form.value.name,
description: form.value.description,
count: 0
}
if (activeTab.value === 'music') {
musicTypeList.value.push(newType)
} else if (activeTab.value === 'singer') {
singerTypeList.value.push(newType)
} else if (activeTab.value === 'playlist') {
playlistTypeList.value.push(newType)
}
ElMessage.success('添加成功')
}
dialogVisible.value = false
}
// 处理标签页切换
const handleTabChange = (tab) => {
activeTab.value = tab
if (tab === 'music' && !musicTypeList.value.length) {
fetchMusicTypeList()
} else if (tab === 'singer' && !singerTypeList.value.length) {
fetchSingerTypeList()
} else if (tab === 'playlist' && !playlistTypeList.value.length) {
fetchPlaylistTypeList()
}
}
// 获取当前类型文本
const getTypeText = () => {
const typeTextMap = {
music: '音乐',
singer: '歌手',
playlist: '歌单'
}
return typeTextMap[activeTab.value] || ''
}
// 获取当前类型列表
const getCurrentTypeList = () => {
if (activeTab.value === 'music') {
return musicTypeList.value
} else if (activeTab.value === 'singer') {
return singerTypeList.value
} else if (activeTab.value === 'playlist') {
return playlistTypeList.value
}
return []
}
// 获取当前加载状态
const getCurrentLoading = () => {
return loading.value[activeTab.value] || false
}
onMounted(() => {
fetchMusicTypeList()
})
</script>
<template>
<div class="admin-type-container">
<!-- 类型分类标签页 -->
<el-tabs v-model="activeTab" @tab-change="handleTabChange">
<el-tab-pane label="音乐类型" name="music"></el-tab-pane>
<el-tab-pane label="歌手类型" name="singer"></el-tab-pane>
<el-tab-pane label="歌单类型" name="playlist"></el-tab-pane>
</el-tabs>
<!-- 操作栏 -->
<div class="action-bar">
<el-button type="primary" @click="addType">
<el-icon><plus /></el-icon>
添加{{ getTypeText() }}类型
</el-button>
</div>
<!-- 类型列表 -->
<el-table
:data="getCurrentTypeList()"
stripe
style="width: 100%"
v-loading="getCurrentLoading()"
>
<el-table-column prop="id" label="ID" width="80"></el-table-column>
<el-table-column prop="name" label="名称" min-width="150"></el-table-column>
<el-table-column prop="description" label="描述" min-width="200"></el-table-column>
<el-table-column prop="count" label="使用数量" width="100" align="center"></el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="scope">
<el-button
size="small"
type="primary"
@click="editType(scope.row)"
>
编辑
</el-button>
<el-button
size="small"
type="danger"
@click="deleteType(scope.row)"
:disabled="scope.row.count > 0"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 添加/编辑对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="500px"
>
<el-form
:model="form"
label-width="100px"
>
<el-form-item label="名称" required>
<el-input v-model="form.name" placeholder="请输入类型名称" />
</el-form-item>
<el-form-item label="描述">
<el-input
v-model="form.description"
type="textarea"
:rows="3"
placeholder="请输入类型描述"
/>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm">确定</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.admin-type-container {
padding: 20px;
}
.action-bar {
margin-bottom: 20px;
display: flex;
justify-content: flex-start;
}
.el-table :deep(.el-button) {
margin-left: 0;
margin-right: 5px;
padding: 4px 8px;
}
</style>

View File

@@ -0,0 +1,780 @@
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Plus } from '@element-plus/icons-vue'
import {
getMusicTypePage,
addMusicType,
updateMusicType,
deleteMusicType,
getSingerTypePage,
addSingerType,
updateSingerType,
deleteSingerType,
getPlaylistTypePage,
addPlaylistType,
updatePlaylistType,
deletePlaylistType
} from '../../api/admin/type'
// 当前激活的标签页
const activeTab = ref('music')
// 音乐类型相关数据
const musicTypeList = ref([])
const musicTypeTotal = ref(0)
const musicTypeLoading = ref(false)
const musicTypeCurrentPage = ref(1)
const musicTypePageSize = ref(5)
const musicTypeKeyword = ref('')
// 歌手类型相关数据
const singerTypeList = ref([])
const singerTypeTotal = ref(0)
const singerTypeLoading = ref(false)
const singerTypeCurrentPage = ref(1)
const singerTypePageSize = ref(5)
const singerTypeKeyword = ref('')
// 歌单类型相关数据
const playlistTypeList = ref([])
const playlistTypeTotal = ref(0)
const playlistTypeLoading = ref(false)
const playlistTypeCurrentPage = ref(1)
const playlistTypePageSize = ref(5)
const playlistTypeKeyword = ref('')
// 对话框相关数据
const dialogVisible = ref(false)
const dialogTitle = ref('')
const dialogType = ref('') // music, singer, playlist
const dialogAction = ref('') // add, edit
const formData = ref({
id: null,
name: '',
description: ''
})
const formRules = {
name: [
{ required: true, message: '请输入类型名称', trigger: 'blur' },
{ min: 1, max: 50, message: '长度在 1 到 50 个字符', trigger: 'blur' }
],
description: [
{ max: 200, message: '长度不能超过 200 个字符', trigger: 'blur' }
]
}
const formRef = ref(null)
// 获取音乐类型列表
const fetchMusicTypeList = async () => {
console.log('获取音乐类型列表')
musicTypeLoading.value = true
try {
const res = await getMusicTypePage(
musicTypeCurrentPage.value,
musicTypePageSize.value,
musicTypeKeyword.value
)
if (res.code === 200) {
musicTypeList.value = res.data.list
musicTypeTotal.value = res.data.total
} else {
ElMessage.error(res.message || '获取音乐类型列表失败')
}
} catch (error) {
console.error('获取音乐类型列表失败:', error)
ElMessage.error('获取音乐类型列表失败,请稍后再试')
} finally {
musicTypeLoading.value = false
}
}
// 获取歌手类型列表
const fetchSingerTypeList = async () => {
console.log('获取歌手类型列表')
singerTypeLoading.value = true
try {
const res = await getSingerTypePage(
singerTypeCurrentPage.value,
singerTypePageSize.value,
singerTypeKeyword.value
)
if (res.code === 200) {
singerTypeList.value = res.data.list
singerTypeTotal.value = res.data.total
} else {
ElMessage.error(res.message || '获取歌手类型列表失败')
}
} catch (error) {
console.error('获取歌手类型列表失败:', error)
ElMessage.error('获取歌手类型列表失败,请稍后再试')
} finally {
singerTypeLoading.value = false
}
}
// 获取歌单类型列表
const fetchPlaylistTypeList = async () => {
console.log('获取歌单类型列表')
playlistTypeLoading.value = true
try {
const res = await getPlaylistTypePage(
playlistTypeCurrentPage.value,
playlistTypePageSize.value,
playlistTypeKeyword.value
)
if (res.code === 200) {
playlistTypeList.value = res.data.list
playlistTypeTotal.value = res.data.total
} else {
ElMessage.error(res.message || '获取歌单类型列表失败')
}
} catch (error) {
console.error('获取歌单类型列表失败:', error)
ElMessage.error('获取歌单类型列表失败,请稍后再试')
} finally {
playlistTypeLoading.value = false
}
}
// 处理标签页切换
const handleTabChange = (tab) => {
console.log('切换到标签页:', tab)
// 如果是从 el-radio-group 传来的,已经是值了
// 如果是从旧的 el-tabs 传来的,需要获取 tab.props.name
const tabName = typeof tab === 'object' && tab.props ? tab.props.name : tab
// 无论如何,都加载对应类型的数据
if (tabName === 'music') {
fetchMusicTypeList()
} else if (tabName === 'singer') {
fetchSingerTypeList()
} else if (tabName === 'playlist') {
fetchPlaylistTypeList()
}
}
// 处理页码变化
const handleCurrentChange = (page, type) => {
if (type === 'music') {
musicTypeCurrentPage.value = page
fetchMusicTypeList()
} else if (type === 'singer') {
singerTypeCurrentPage.value = page
fetchSingerTypeList()
} else if (type === 'playlist') {
playlistTypeCurrentPage.value = page
fetchPlaylistTypeList()
}
}
// 处理每页大小变化
const handleSizeChange = (size, type) => {
if (type === 'music') {
musicTypePageSize.value = size
musicTypeCurrentPage.value = 1
fetchMusicTypeList()
} else if (type === 'singer') {
singerTypePageSize.value = size
singerTypeCurrentPage.value = 1
fetchSingerTypeList()
} else if (type === 'playlist') {
playlistTypePageSize.value = size
playlistTypeCurrentPage.value = 1
fetchPlaylistTypeList()
}
}
// 处理搜索
const handleSearch = (type) => {
if (type === 'music') {
musicTypeCurrentPage.value = 1
fetchMusicTypeList()
} else if (type === 'singer') {
singerTypeCurrentPage.value = 1
fetchSingerTypeList()
} else if (type === 'playlist') {
playlistTypeCurrentPage.value = 1
fetchPlaylistTypeList()
}
}
// 处理重置搜索
const handleResetSearch = (type) => {
if (type === 'music') {
musicTypeKeyword.value = ''
musicTypeCurrentPage.value = 1
fetchMusicTypeList()
} else if (type === 'singer') {
singerTypeKeyword.value = ''
singerTypeCurrentPage.value = 1
fetchSingerTypeList()
} else if (type === 'playlist') {
playlistTypeKeyword.value = ''
playlistTypeCurrentPage.value = 1
fetchPlaylistTypeList()
}
}
// 打开添加对话框
const openAddDialog = (type) => {
dialogType.value = type
dialogAction.value = 'add'
dialogTitle.value = type === 'music' ? '添加音乐类型' : type === 'singer' ? '添加歌手类型' : '添加歌单类型'
formData.value = {
id: null,
name: '',
description: ''
}
dialogVisible.value = true
}
// 打开编辑对话框
const openEditDialog = (row, type) => {
dialogType.value = type
dialogAction.value = 'edit'
dialogTitle.value = type === 'music' ? '编辑音乐类型' : type === 'singer' ? '编辑歌手类型' : '编辑歌单类型'
formData.value = {
id: row.id,
name: row.name,
description: row.description || ''
}
dialogVisible.value = true
}
// 处理表单提交
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (valid) {
try {
let res
if (dialogAction.value === 'add') {
if (dialogType.value === 'music') {
res = await addMusicType(formData.value)
} else if (dialogType.value === 'singer') {
res = await addSingerType(formData.value)
} else if (dialogType.value === 'playlist') {
res = await addPlaylistType(formData.value)
}
} else if (dialogAction.value === 'edit') {
if (dialogType.value === 'music') {
res = await updateMusicType(formData.value.id, formData.value)
} else if (dialogType.value === 'singer') {
res = await updateSingerType(formData.value.id, formData.value)
} else if (dialogType.value === 'playlist') {
res = await updatePlaylistType(formData.value.id, formData.value)
}
}
if (res.code === 200) {
ElMessage.success(res.message || '操作成功')
dialogVisible.value = false
// 刷新列表
if (dialogType.value === 'music') {
fetchMusicTypeList()
} else if (dialogType.value === 'singer') {
fetchSingerTypeList()
} else if (dialogType.value === 'playlist') {
fetchPlaylistTypeList()
}
} else {
ElMessage.error(res.message || '操作失败')
}
} catch (error) {
console.error('操作失败:', error)
ElMessage.error('操作失败,请稍后再试')
}
}
})
}
// 处理删除
const handleDelete = (id, type) => {
ElMessageBox.confirm('确定要删除该类型吗?此操作不可逆', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
let res
if (type === 'music') {
res = await deleteMusicType(id)
} else if (type === 'singer') {
res = await deleteSingerType(id)
} else if (type === 'playlist') {
res = await deletePlaylistType(id)
}
if (res.code === 200) {
ElMessage.success(res.message || '删除成功')
// 刷新列表
if (type === 'music') {
fetchMusicTypeList()
} else if (type === 'singer') {
fetchSingerTypeList()
} else if (type === 'playlist') {
fetchPlaylistTypeList()
}
} else {
ElMessage.error(res.message || '删除失败')
}
} catch (error) {
console.error('删除失败:', error)
ElMessage.error('删除失败,请稍后再试')
}
}).catch(() => {
// 取消删除
})
}
// 在组件挂载时获取数据
onMounted(() => {
console.log('组件挂载,加载所有类型数据')
// 加载所有类型的数据
fetchMusicTypeList()
fetchSingerTypeList()
fetchPlaylistTypeList()
})
</script>
<template>
<div class="type-management">
<div class="page-header">
<h2>类型管理</h2>
</div>
<!-- 音乐类型标签页 -->
<div v-if="activeTab === 'music'">
<div class="tab-header">
<div class="search-container">
<el-input
v-model="musicTypeKeyword"
placeholder="搜索类型名称"
clearable
@keyup.enter="handleSearch('music')"
>
<template #append>
<el-button @click="handleSearch('music')">
<el-icon><Search /></el-icon>
</el-button>
</template>
</el-input>
<el-button type="primary" @click="handleSearch('music')">搜索</el-button>
<el-button @click="handleResetSearch('music')">重置</el-button>
</div>
<el-button type="primary" @click="openAddDialog('music')">
<el-icon><Plus /></el-icon>添加音乐类型
</el-button>
</div>
<!-- 类型选择器 -->
<div class="type-selector">
<el-radio-group v-model="activeTab" @change="handleTabChange">
<el-radio-button label="music">音乐类型</el-radio-button>
<el-radio-button label="singer">歌手类型</el-radio-button>
<el-radio-button label="playlist">歌单类型</el-radio-button>
</el-radio-group>
</div>
<el-table
:data="musicTypeList"
border
stripe
style="width: 100%"
v-loading="musicTypeLoading"
>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="类型名称" width="200" />
<el-table-column prop="description" label="描述" />
<el-table-column label="操作" width="200" fixed="right">
<template #default="scope">
<el-button
type="primary"
size="small"
@click="openEditDialog(scope.row, 'music')"
>
编辑
</el-button>
<el-button
type="danger"
size="small"
@click="handleDelete(scope.row.id, 'music')"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-container">
<el-pagination
v-model:current-page="musicTypeCurrentPage"
v-model:page-size="musicTypePageSize"
:page-sizes="[5, 10, 15, 20]"
layout="total, sizes, prev, pager, next, jumper"
:total="musicTypeTotal"
@size-change="(size) => handleSizeChange(size, 'music')"
@current-change="(page) => handleCurrentChange(page, 'music')"
/>
</div>
</div>
<!-- 歌手类型标签页 -->
<div v-if="activeTab === 'singer'">
<div class="tab-header">
<div class="search-container">
<el-input
v-model="singerTypeKeyword"
placeholder="搜索类型名称"
clearable
@keyup.enter="handleSearch('singer')"
>
<template #append>
<el-button @click="handleSearch('singer')">
<el-icon><Search /></el-icon>
</el-button>
</template>
</el-input>
<el-button type="primary" @click="handleSearch('singer')">搜索</el-button>
<el-button @click="handleResetSearch('singer')">重置</el-button>
</div>
<el-button type="primary" @click="openAddDialog('singer')">
<el-icon><Plus /></el-icon>添加歌手类型
</el-button>
</div>
<!-- 类型选择器 -->
<div class="type-selector">
<el-radio-group v-model="activeTab" @change="handleTabChange">
<el-radio-button label="music">音乐类型</el-radio-button>
<el-radio-button label="singer">歌手类型</el-radio-button>
<el-radio-button label="playlist">歌单类型</el-radio-button>
</el-radio-group>
</div>
<el-table
:data="singerTypeList"
border
stripe
style="width: 100%"
v-loading="singerTypeLoading"
>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="类型名称" width="200" />
<el-table-column prop="description" label="描述" />
<el-table-column label="操作" width="200" fixed="right">
<template #default="scope">
<el-button
type="primary"
size="small"
@click="openEditDialog(scope.row, 'singer')"
>
编辑
</el-button>
<el-button
type="danger"
size="small"
@click="handleDelete(scope.row.id, 'singer')"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-container">
<el-pagination
v-model:current-page="singerTypeCurrentPage"
v-model:page-size="singerTypePageSize"
:page-sizes="[5, 10, 15, 20]"
layout="total, sizes, prev, pager, next, jumper"
:total="singerTypeTotal"
@size-change="(size) => handleSizeChange(size, 'singer')"
@current-change="(page) => handleCurrentChange(page, 'singer')"
/>
</div>
</div>
<!-- 歌单类型标签页 -->
<div v-if="activeTab === 'playlist'">
<div class="tab-header">
<div class="search-container">
<el-input
v-model="playlistTypeKeyword"
placeholder="搜索类型名称"
clearable
@keyup.enter="handleSearch('playlist')"
>
<template #append>
<el-button @click="handleSearch('playlist')">
<el-icon><Search /></el-icon>
</el-button>
</template>
</el-input>
<el-button type="primary" @click="handleSearch('playlist')">搜索</el-button>
<el-button @click="handleResetSearch('playlist')">重置</el-button>
</div>
<el-button type="primary" @click="openAddDialog('playlist')">
<el-icon><Plus /></el-icon>添加歌单类型
</el-button>
</div>
<!-- 类型选择器 -->
<div class="type-selector">
<el-radio-group v-model="activeTab" @change="handleTabChange">
<el-radio-button label="music">音乐类型</el-radio-button>
<el-radio-button label="singer">歌手类型</el-radio-button>
<el-radio-button label="playlist">歌单类型</el-radio-button>
</el-radio-group>
</div>
<el-table
:data="playlistTypeList"
border
stripe
style="width: 100%"
v-loading="playlistTypeLoading"
>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="类型名称" width="200" />
<el-table-column prop="description" label="描述" />
<el-table-column label="操作" width="200" fixed="right">
<template #default="scope">
<el-button
type="primary"
size="small"
@click="openEditDialog(scope.row, 'playlist')"
>
编辑
</el-button>
<el-button
type="danger"
size="small"
@click="handleDelete(scope.row.id, 'playlist')"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-container">
<el-pagination
v-model:current-page="playlistTypeCurrentPage"
v-model:page-size="playlistTypePageSize"
:page-sizes="[5, 10, 15, 20]"
layout="total, sizes, prev, pager, next, jumper"
:total="playlistTypeTotal"
@size-change="(size) => handleSizeChange(size, 'playlist')"
@current-change="(page) => handleCurrentChange(page, 'playlist')"
/>
</div>
</div>
<!-- 添加/编辑对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="500px"
:close-on-click-modal="false"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="100px"
>
<el-form-item label="类型名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入类型名称" />
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input
v-model="formData.description"
type="textarea"
:rows="4"
placeholder="请输入描述"
/>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.type-management {
padding: 20px;
background: linear-gradient(135deg, #e1f5fe 0%, #b3e5fc 100%);
min-height: 100%;
}
.page-header {
margin-bottom: 20px;
background: rgba(255, 255, 255, 0.9);
padding: 15px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.page-header h2 {
color: #0d47a1;
margin: 0;
}
.tab-header {
display: flex;
justify-content: space-between;
margin-bottom: 20px;
background: rgba(255, 255, 255, 0.9);
padding: 15px;
border-radius: 12px;
}
.search-container {
display: flex;
gap: 10px;
align-items: center;
}
.search-container .el-input {
width: 300px;
}
.search-container :deep(.el-input__inner) {
border-radius: 8px;
border: 1px solid #b3e5fc;
}
.search-container :deep(.el-input__inner):focus {
border-color: #4dd0e1;
box-shadow: 0 0 0 2px rgba(77, 208, 225, 0.2);
}
.el-button {
border-radius: 8px !important;
font-weight: bold !important;
transition: all 0.3s !important;
}
.el-button--primary {
background: linear-gradient(135deg, #4dd0e1 0%, #26c6da 100%) !important;
border: none !important;
}
.el-button--primary:hover {
transform: translateY(-2px) !important;
box-shadow: 0 4px 8px rgba(38, 198, 218, 0.3) !important;
}
.el-table {
--el-table-border-color: #b3e5fc;
--el-table-header-bg-color: #e1f5fe;
--el-table-row-hover-bg-color: #b3e5fc;
border-radius: 8px;
overflow: hidden;
background-color: rgba(255, 255, 255, 0.9);
}
.el-table th.el-table__cell {
background-color: #e1f5fe !important;
color: #0d47a1 !important;
font-weight: bold !important;
}
.el-table .el-table__row {
background-color: rgba(255, 255, 255, 0.9) !important;
transition: all 0.3s;
}
.el-table .el-table__row:hover {
background-color: #b3e5fc !important;
transform: scale(1.01);
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
background: rgba(255, 255, 255, 0.9);
padding: 15px;
border-radius: 12px;
}
.type-selector {
margin: 20px 0;
display: flex;
justify-content: center;
}
.type-selector .el-radio-group {
display: flex;
justify-content: center;
width: 100%;
background: rgba(255, 255, 255, 0.9);
padding: 10px;
border-radius: 12px;
}
.type-selector .el-radio-button {
flex: 1;
max-width: 200px;
}
.type-selector :deep(.el-radio-button__inner) {
border-radius: 8px !important;
transition: all 0.3s !important;
}
.type-selector :deep(.el-radio-button__inner:hover) {
transform: scale(1.05);
}
.el-dialog {
border-radius: 16px !important;
}
.el-dialog :deep(.el-dialog__header) {
background: linear-gradient(135deg, #e1f5fe 0%, #b3e5fc 100%);
border-radius: 16px 16px 0 0 !important;
padding: 15px 20px;
}
.el-dialog :deep(.el-dialog__title) {
color: #0d47a1;
font-weight: bold;
}
.el-form-item :deep(.el-form-item__label) {
color: #4a4a4a;
font-weight: bold;
}
.el-input :deep(.el-input__inner) {
border-radius: 8px;
border: 1px solid #b3e5fc;
}
.el-input :deep(.el-input__inner):focus {
border-color: #4dd0e1;
box-shadow: 0 0 0 2px rgba(77, 208, 225, 0.2);
}
.el-textarea :deep(.el-textarea__inner) {
border-radius: 8px;
border: 1px solid #b3e5fc;
}
.el-textarea :deep(.el-textarea__inner):focus {
border-color: #4dd0e1;
box-shadow: 0 0 0 2px rgba(77, 208, 225, 0.2);
}
</style>

View File

@@ -0,0 +1,294 @@
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
// 用户列表
const userList = ref([])
// 加载状态
const loading = ref(false)
// 分页信息
const pagination = ref({
currentPage: 1,
pageSize: 10,
total: 0
})
// 搜索关键字
const searchKeyword = ref('')
// 获取用户列表
const fetchUserList = async () => {
loading.value = true
try {
// 实际应用中这里应该调用API获取用户列表
// 模拟获取用户列表
setTimeout(() => {
const mockUsers = []
const total = 100
const { currentPage, pageSize } = pagination.value
const start = (currentPage - 1) * pageSize
const end = Math.min(start + pageSize, total)
for (let i = start + 1; i <= end; i++) {
mockUsers.push({
id: i,
username: `user${i}`,
nickname: `用户${i}`,
email: `user${i}@example.com`,
phone: `1380000${i.toString().padStart(4, '0')}`,
gender: i % 3, // 0-未知1-男2-女
role: i % 10 === 0 ? 1 : 0, // 0-普通用户1-管理员
status: i % 20 === 0 ? 0 : 1, // 0-禁用1-正常
createTime: new Date(Date.now() - i * 86400000).toISOString().split('T')[0]
})
}
userList.value = mockUsers
pagination.value.total = total
loading.value = false
}, 500)
} catch (error) {
ElMessage.error('获取用户列表失败')
loading.value = false
}
}
// 处理页码变化
const handleCurrentChange = (page) => {
pagination.value.currentPage = page
fetchUserList()
}
// 处理每页条数变化
const handleSizeChange = (size) => {
pagination.value.pageSize = size
pagination.value.currentPage = 1
fetchUserList()
}
// 搜索用户
const searchUser = () => {
pagination.value.currentPage = 1
fetchUserList()
}
// 重置搜索
const resetSearch = () => {
searchKeyword.value = ''
pagination.value.currentPage = 1
fetchUserList()
}
// 查看用户详情
const viewUserDetail = (row) => {
ElMessage.info(`查看用户详情:${row.username}`)
}
// 编辑用户
const editUser = (row) => {
ElMessage.info(`编辑用户:${row.username}`)
}
// 删除用户
const deleteUser = (row) => {
ElMessageBox.confirm(`确定要删除用户 ${row.username} 吗?此操作不可恢复`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
// 实际应用中这里应该调用API删除用户
userList.value = userList.value.filter(item => item.id !== row.id)
ElMessage.success('删除成功')
}).catch(() => {})
}
// 更新用户状态
const updateUserStatus = (row) => {
const newStatus = row.status === 1 ? 0 : 1
const statusText = newStatus === 1 ? '启用' : '禁用'
ElMessageBox.confirm(`确定要${statusText}用户 ${row.username} 吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
// 实际应用中这里应该调用API更新用户状态
const index = userList.value.findIndex(item => item.id === row.id)
if (index !== -1) {
userList.value[index].status = newStatus
ElMessage.success(`${statusText}成功`)
}
}).catch(() => {})
}
// 格式化性别
const formatGender = (gender) => {
const genderMap = {
0: '未知',
1: '男',
2: '女'
}
return genderMap[gender] || '未知'
}
// 格式化角色
const formatRole = (role) => {
const roleMap = {
0: '普通用户',
1: '管理员'
}
return roleMap[role] || '未知'
}
// 格式化状态
const formatStatus = (status) => {
const statusMap = {
0: '禁用',
1: '正常'
}
return statusMap[status] || '未知'
}
onMounted(() => {
fetchUserList()
})
</script>
<template>
<div class="admin-user-container">
<!-- 搜索栏 -->
<div class="search-bar">
<el-input
v-model="searchKeyword"
placeholder="请输入用户名/昵称/邮箱"
clearable
@clear="resetSearch"
@keyup.enter="searchUser"
>
<template #append>
<el-button @click="searchUser">
<el-icon><search /></el-icon>
搜索
</el-button>
</template>
</el-input>
</div>
<!-- 用户列表 -->
<el-table
:data="userList"
stripe
style="width: 100%"
v-loading="loading"
>
<el-table-column prop="id" label="ID" width="80"></el-table-column>
<el-table-column prop="username" label="用户名" width="120"></el-table-column>
<el-table-column prop="nickname" label="昵称" width="120"></el-table-column>
<el-table-column prop="email" label="邮箱" min-width="180"></el-table-column>
<el-table-column prop="phone" label="手机号" width="120"></el-table-column>
<el-table-column prop="gender" label="性别" width="80">
<template #default="scope">
{{ formatGender(scope.row.gender) }}
</template>
</el-table-column>
<el-table-column prop="role" label="角色" width="100">
<template #default="scope">
<el-tag :type="scope.row.role === 1 ? 'danger' : 'info'">
{{ formatRole(scope.row.role) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="scope">
<el-tag :type="scope.row.status === 1 ? 'success' : 'danger'">
{{ formatStatus(scope.row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="注册时间" width="120"></el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="scope">
<el-button
size="small"
@click="viewUserDetail(scope.row)"
>
查看
</el-button>
<el-button
size="small"
type="primary"
@click="editUser(scope.row)"
>
编辑
</el-button>
<el-button
size="small"
:type="scope.row.status === 1 ? 'warning' : 'success'"
@click="updateUserStatus(scope.row)"
>
{{ scope.row.status === 1 ? '禁用' : '启用' }}
</el-button>
<el-button
size="small"
type="danger"
@click="deleteUser(scope.row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination">
<el-pagination
v-model:current-page="pagination.currentPage"
v-model:page-size="pagination.pageSize"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
:total="pagination.total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</div>
</template>
<style scoped>
.admin-user-container {
padding: 20px;
}
.search-bar {
margin-bottom: 20px;
display: flex;
justify-content: flex-start;
max-width: 500px;
}
.pagination {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
.el-table :deep(.el-button) {
margin-left: 0;
margin-right: 5px;
padding: 4px 8px;
}
</style>

View File

@@ -0,0 +1,513 @@
<script setup>
import { ref, onMounted, reactive } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Edit } from '@element-plus/icons-vue'
import { getUserList, deleteUser, updateUserStatus, getUserById, adminUpdateUser } from '../../api/admin/user'
// 用户列表
const userList = ref([])
// 总数
const total = ref(0)
// 加载状态
const loading = ref(false)
// 当前页码
const currentPage = ref(1)
// 每页大小
const pageSize = ref(5)
// 搜索关键字
const keyword = ref('')
// 编辑对话框
const editDialogVisible = ref(false)
const editForm = reactive({
id: null,
username: '',
nickname: '',
email: '',
phone: '',
avatar: '',
gender: 0,
introduction: ''
})
const editFormRules = {
nickname: [
{ required: true, message: '请输入昵称', trigger: 'blur' },
{ min: 2, max: 20, message: '长度在 2 到 20 个字符', trigger: 'blur' }
],
email: [
{ pattern: /^\S+@\S+\.\S+$/, message: '请输入正确的邮箱地址', trigger: 'blur' }
],
avatar: [
{ pattern: /^(https?:\/\/)?[\w\-]+(\.[\w\-]+)+([\w\-.,@?^=%&:/~+#]*[\w\-@?^=%&/~+#])?$/, message: '请输入正确的URL地址', trigger: 'blur' }
]
}
const editFormRef = ref(null)
const editLoading = ref(false)
// 获取用户列表
const fetchUserList = async () => {
loading.value = true
try {
const res = await getUserList(currentPage.value, pageSize.value, keyword.value)
if (res.code === 200) {
userList.value = res.data.list
total.value = res.data.total
} else {
ElMessage.error(res.message || '获取用户列表失败')
}
} catch (error) {
console.error('获取用户列表失败:', error)
ElMessage.error('获取用户列表失败,请稍后再试')
} finally {
loading.value = false
}
}
// 处理页码变化
const handleCurrentChange = (page) => {
currentPage.value = page
fetchUserList()
}
// 处理每页大小变化
const handleSizeChange = (size) => {
pageSize.value = size
currentPage.value = 1
fetchUserList()
}
// 处理搜索
const handleSearch = () => {
currentPage.value = 1
fetchUserList()
}
// 处理重置搜索
const handleResetSearch = () => {
keyword.value = ''
currentPage.value = 1
fetchUserList()
}
// 处理删除用户
const handleDeleteUser = (id) => {
ElMessageBox.confirm('确定要删除该用户吗?此操作不可逆', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await deleteUser(id)
if (res.code === 200) {
ElMessage.success('删除成功')
fetchUserList()
} else {
ElMessage.error(res.message || '删除失败')
}
} catch (error) {
console.error('删除用户失败:', error)
ElMessage.error('删除用户失败,请稍后再试')
}
}).catch(() => {
// 取消删除
})
}
// 处理更新用户状态
const handleUpdateUserStatus = async (id, status) => {
try {
const res = await updateUserStatus(id, status)
if (res.code === 200) {
ElMessage.success('更新成功')
fetchUserList()
} else {
ElMessage.error(res.message || '更新失败')
}
} catch (error) {
console.error('更新用户状态失败:', error)
ElMessage.error('更新用户状态失败,请稍后再试')
}
}
// 打开编辑对话框
const handleEdit = async (id) => {
try {
const res = await getUserById(id)
if (res.code === 200) {
const user = res.data
// 填充表单
editForm.id = user.id
editForm.username = user.username
editForm.nickname = user.nickname || ''
editForm.email = user.email || ''
editForm.phone = user.phone || ''
editForm.avatar = user.avatar || ''
editForm.gender = user.gender || 0
editForm.introduction = user.introduction || ''
// 显示对话框
editDialogVisible.value = true
} else {
ElMessage.error(res.message || '获取用户信息失败')
}
} catch (error) {
console.error('获取用户信息失败:', error)
ElMessage.error('获取用户信息失败,请稍后再试')
}
}
// 提交编辑表单
const submitEditForm = async () => {
if (!editFormRef.value) return
await editFormRef.value.validate(async (valid) => {
if (valid) {
editLoading.value = true
try {
const res = await adminUpdateUser(editForm.id, {
nickname: editForm.nickname,
email: editForm.email,
phone: editForm.phone,
avatar: editForm.avatar,
gender: editForm.gender,
introduction: editForm.introduction
})
if (res.code === 200) {
ElMessage.success('更新成功')
editDialogVisible.value = false
fetchUserList()
} else {
ElMessage.error(res.message || '更新失败')
}
} catch (error) {
console.error('更新用户信息失败:', error)
ElMessage.error('更新用户信息失败,请稍后再试')
} finally {
editLoading.value = false
}
}
})
}
// 格式化角色
const formatRole = (role) => {
return role === 1 ? '管理员' : '普通用户'
}
// 格式化状态
const formatStatus = (status) => {
return status === 1 ? '正常' : '禁用'
}
// 格式化性别
const formatGender = (gender) => {
switch (gender) {
case 1:
return '男'
case 2:
return '女'
default:
return '未知'
}
}
// 格式化日期
const formatDate = (date) => {
if (!date) return ''
return new Date(date).toLocaleString()
}
onMounted(() => {
fetchUserList()
})
</script>
<template>
<div class="user-management">
<div class="page-header">
<h2>用户管理</h2>
</div>
<!-- 搜索区域 -->
<div class="search-container">
<el-input
v-model="keyword"
placeholder="搜索用户名、昵称或邮箱"
clearable
@keyup.enter="handleSearch"
>
<template #append>
<el-button @click="handleSearch">
<el-icon><Search /></el-icon>
</el-button>
</template>
</el-input>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleResetSearch">重置</el-button>
</div>
<!-- 用户列表 -->
<el-table
:data="userList"
border
stripe
style="width: 100%"
v-loading="loading"
>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column label="头像" width="80">
<template #default="scope">
<el-avatar :size="40" :src="scope.row.avatar || 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png'" />
</template>
</el-table-column>
<el-table-column prop="username" label="用户名" width="120" />
<el-table-column prop="nickname" label="昵称" width="120" />
<el-table-column prop="email" label="邮箱" width="180" />
<el-table-column label="性别" width="80">
<template #default="scope">
{{ formatGender(scope.row.gender) }}
</template>
</el-table-column>
<el-table-column label="角色" width="100">
<template #default="scope">
<el-tag :type="scope.row.role === 1 ? 'danger' : 'info'">
{{ formatRole(scope.row.role) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="scope">
<el-tag :type="scope.row.status === 1 ? 'success' : 'danger'">
{{ formatStatus(scope.row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="注册时间" width="180">
<template #default="scope">
{{ formatDate(scope.row.createTime) }}
</template>
</el-table-column>
<el-table-column label="操作" width="250" fixed="right">
<template #default="scope">
<el-button
type="primary"
size="small"
@click="handleEdit(scope.row.id)"
>
<el-icon><Edit /></el-icon>
编辑
</el-button>
<el-button
v-if="scope.row.status === 1"
type="warning"
size="small"
@click="handleUpdateUserStatus(scope.row.id, 0)"
>
禁用
</el-button>
<el-button
v-else
type="success"
size="small"
@click="handleUpdateUserStatus(scope.row.id, 1)"
>
启用
</el-button>
<el-button
type="danger"
size="small"
@click="handleDeleteUser(scope.row.id)"
:disabled="scope.row.role === 1"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[5, 10, 15, 20]"
layout="total, sizes, prev, pager, next, jumper"
:total="total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</div>
<!-- 编辑用户对话框 -->
<el-dialog
v-model="editDialogVisible"
title="编辑用户信息"
width="500px"
:close-on-click-modal="false"
>
<el-form
ref="editFormRef"
:model="editForm"
:rules="editFormRules"
label-width="100px"
v-loading="editLoading"
>
<el-form-item label="用户名">
<el-input v-model="editForm.username" disabled></el-input>
</el-form-item>
<el-form-item label="昵称" prop="nickname">
<el-input v-model="editForm.nickname" placeholder="请输入昵称"></el-input>
</el-form-item>
<el-form-item label="头像" prop="avatar">
<el-input v-model="editForm.avatar" placeholder="请输入头像地址"></el-input>
<div class="avatar-preview" v-if="editForm.avatar">
<el-avatar :size="60" :src="editForm.avatar"></el-avatar>
</div>
</el-form-item>
<el-form-item label="性别">
<el-radio-group v-model="editForm.gender">
<el-radio :label="0">未知</el-radio>
<el-radio :label="1"></el-radio>
<el-radio :label="2"></el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="editForm.email" placeholder="请输入邮箱"></el-input>
</el-form-item>
<el-form-item label="手机号">
<el-input v-model="editForm.phone" placeholder="请输入手机号"></el-input>
</el-form-item>
<el-form-item label="个人简介">
<el-input v-model="editForm.introduction" type="textarea" :rows="3" placeholder="请输入个人简介"></el-input>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="editDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitEditForm" :loading="editLoading">确定</el-button>
</span>
</template>
</el-dialog>
</template>
<style scoped>
.user-management {
padding: 20px;
background: rgba(255, 255, 255, 0.8);
border-radius: 16px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
}
.page-header {
margin-bottom: 20px;
color: #4a4a4a;
text-align: center;
}
.search-container {
display: flex;
margin-bottom: 20px;
gap: 10px;
background: rgba(255, 255, 255, 0.9);
padding: 15px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.search-container .el-input {
width: 300px;
}
.search-container .el-input :deep(.el-input__inner) {
border-radius: 8px;
border: 1px solid #b3e5fc;
transition: all 0.3s;
}
.search-container .el-input :deep(.el-input__inner):focus {
border-color: #4dd0e1;
box-shadow: 0 0 0 2px rgba(77, 208, 225, 0.2);
}
.el-button {
border-radius: 8px !important;
font-weight: bold !important;
transition: all 0.3s !important;
}
.el-button--primary {
background: linear-gradient(135deg, #4dd0e1 0%, #26c6da 100%) !important;
border: none !important;
}
.el-button--primary:hover {
transform: translateY(-2px) !important;
box-shadow: 0 4px 8px rgba(38, 198, 218, 0.3) !important;
}
.el-table {
border-radius: 12px;
overflow: hidden;
}
.el-table :deep(th) {
background-color: #e1f5fe !important;
color: #0d47a1;
}
.el-table :deep(.el-table__row) {
transition: all 0.3s;
}
.el-table :deep(.el-table__row:hover) {
background-color: #f5f5f5 !important;
transform: scale(1.01);
}
.el-dialog {
border-radius: 16px !important;
}
.el-dialog :deep(.el-dialog__header) {
background: linear-gradient(135deg, #e1f5fe 0%, #b3e5fc 100%);
border-radius: 16px 16px 0 0 !important;
padding: 15px 20px;
}
.el-dialog :deep(.el-dialog__title) {
color: #0d47a1;
font-weight: bold;
}
.el-form-item :deep(.el-form-item__label) {
color: #4a4a4a;
font-weight: bold;
}
.el-input :deep(.el-input__inner) {
border-radius: 8px;
border: 1px solid #b3e5fc;
}
.el-input :deep(.el-input__inner):focus {
border-color: #4dd0e1;
box-shadow: 0 0 0 2px rgba(77, 208, 225, 0.2);
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
background: rgba(255, 255, 255, 0.9);
padding: 15px;
border-radius: 12px;
}
.avatar-preview {
margin-top: 10px;
display: flex;
justify-content: center;
}
</style>

View File

@@ -0,0 +1,226 @@
<script setup>
import { ref, onMounted } from 'vue'
import { usePlayerStore } from '../../stores/player'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getCollectedMusic, toggleCollectMusic } from '../../api/music'
import { processImageUrl } from '../../utils/imageUtils'
const playerStore = usePlayerStore()
// 收藏的音乐列表
const collectMusicList = ref([])
// 加载状态
const loading = ref(false)
// 获取收藏的音乐列表
const fetchCollectMusic = async () => {
loading.value = true
try {
const res = await getCollectedMusic()
if (res.code === 200) {
// 处理数据
const musicList = res.data.records || res.data.list || []
// 处理音乐数据
collectMusicList.value = musicList.map(item => ({
id: item.id,
name: item.name,
singer: item.singerNames || item.singer || '未知歌手',
album: item.album || '未知专辑',
duration: item.duration || 0,
cover: processImageUrl(item.cover),
url: item.url,
collectTime: item.collectTime ? new Date(item.collectTime).toLocaleDateString() : '未知'
}))
} else {
ElMessage.error(res.message || '获取收藏的音乐列表失败')
}
} catch (error) {
console.error('获取收藏的音乐列表失败:', error)
ElMessage.error('获取收藏的音乐列表失败')
} finally {
loading.value = false
}
}
// 播放音乐
const playMusic = (music) => {
playerStore.setCurrentMusic(music)
}
// 取消收藏音乐
const cancelCollect = (id) => {
ElMessageBox.confirm('确定要取消收藏该音乐吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await toggleCollectMusic(id)
if (res.code === 200) {
// 从列表中移除该音乐
collectMusicList.value = collectMusicList.value.filter(item => item.id !== id)
ElMessage.success('已取消收藏')
} else {
ElMessage.error(res.message || '取消收藏失败')
}
} catch (error) {
console.error('取消收藏失败:', error)
ElMessage.error('取消收藏失败')
}
}).catch(() => {})
}
// 添加到播放列表
const addToPlaylist = (music) => {
playerStore.addToPlaylist(music)
ElMessage.success('已添加到播放列表')
}
// 播放全部
const playAll = () => {
if (collectMusicList.value.length === 0) {
ElMessage.warning('没有可播放的音乐')
return
}
// 清空当前播放列表
playerStore.clearPlaylist()
// 添加所有音乐到播放列表
collectMusicList.value.forEach(music => {
playerStore.addToPlaylist(music)
})
// 播放第一首
playerStore.setCurrentMusic(collectMusicList.value[0])
ElMessage.success('开始播放全部收藏音乐')
}
// 格式化时间
const formatTime = (seconds) => {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
onMounted(() => {
fetchCollectMusic()
})
</script>
<template>
<div class="collect-music-container">
<div class="collect-music-header">
<h2>我收藏的音乐</h2>
<el-button type="primary" @click="playAll" :disabled="collectMusicList.length === 0">
<el-icon><video-play /></el-icon>
播放全部
</el-button>
</div>
<!-- 音乐列表 -->
<div class="music-list-container">
<el-table
v-loading="loading"
:data="collectMusicList"
stripe
style="width: 100%"
@row-dblclick="playMusic"
>
<el-table-column width="80" align="center">
<template #default="scope">
<el-button
circle
:icon="'VideoPlay'"
size="small"
@click="playMusic(scope.row)"
></el-button>
</template>
</el-table-column>
<el-table-column label="封面" width="80" align="center">
<template #default="scope">
<img
:src="scope.row.cover"
:alt="scope.row.name"
class="music-cover"
>
</template>
</el-table-column>
<el-table-column prop="name" label="歌曲名" min-width="200"></el-table-column>
<el-table-column prop="singer" label="歌手" min-width="150"></el-table-column>
<el-table-column prop="album" label="专辑" min-width="150"></el-table-column>
<el-table-column prop="duration" label="时长" width="100" align="center">
<template #default="scope">
{{ formatTime(scope.row.duration) }}
</template>
</el-table-column>
<el-table-column prop="collectTime" label="收藏时间" width="120" align="center"></el-table-column>
<el-table-column label="操作" width="150" align="center">
<template #default="scope">
<el-button
icon="Delete"
circle
size="small"
type="danger"
@click="cancelCollect(scope.row.id)"
></el-button>
<el-button
icon="Plus"
circle
size="small"
@click="addToPlaylist(scope.row)"
></el-button>
</template>
</el-table-column>
</el-table>
<!-- 空状态 -->
<el-empty
v-if="collectMusicList.length === 0 && !loading"
description="暂无收藏的音乐"
></el-empty>
</div>
</div>
</template>
<style scoped>
.collect-music-container {
padding: 0;
}
.collect-music-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.collect-music-header h2 {
margin: 0;
font-size: 1.5rem;
color: #333;
}
.music-list-container {
background-color: #fff;
border-radius: 8px;
}
.music-cover {
width: 50px;
height: 50px;
border-radius: 4px;
object-fit: cover;
}
</style>

View File

@@ -0,0 +1,271 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getCollectedPlaylist, toggleCollectPlaylist } from '../../api/playlist'
import { processImageUrl } from '../../utils/imageUtils'
const router = useRouter()
// 收藏的歌单列表
const collectPlaylists = ref([])
// 加载状态
const loading = ref(false)
// 获取收藏的歌单列表
const fetchCollectPlaylists = async () => {
loading.value = true
try {
const res = await getCollectedPlaylist()
if (res.code === 200) {
// 处理数据
const playlists = res.data.records || res.data.list || []
// 处理歌单数据
collectPlaylists.value = playlists.map(item => ({
id: item.id,
name: item.name,
description: item.description || '暂无描述',
creator: item.creatorName || '未知用户',
cover: processImageUrl(item.cover),
songCount: item.songCount || 0,
playCount: item.playCount || 0,
collectTime: item.collectTime ? new Date(item.collectTime).toLocaleDateString() : '未知'
}))
} else {
ElMessage.error(res.message || '获取收藏的歌单列表失败')
}
} catch (error) {
console.error('获取收藏的歌单列表失败:', error)
ElMessage.error('获取收藏的歌单列表失败')
} finally {
loading.value = false
}
}
// 查看歌单详情
const viewPlaylistDetail = (id) => {
router.push(`/playlist/${id}`)
}
// 取消收藏歌单
const cancelCollect = (id, event) => {
event.stopPropagation()
ElMessageBox.confirm('确定要取消收藏该歌单吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await toggleCollectPlaylist(id)
if (res.code === 200) {
// 从列表中移除该歌单
collectPlaylists.value = collectPlaylists.value.filter(item => item.id !== id)
ElMessage.success('已取消收藏')
} else {
ElMessage.error(res.message || '取消收藏失败')
}
} catch (error) {
console.error('取消收藏失败:', error)
ElMessage.error('取消收藏失败')
}
}).catch(() => {})
}
// 格式化播放次数
const formatPlayCount = (count) => {
if (count < 10000) {
return count.toString()
} else if (count < 100000000) {
return (count / 10000).toFixed(1) + '万'
} else {
return (count / 100000000).toFixed(1) + '亿'
}
}
onMounted(() => {
fetchCollectPlaylists()
})
</script>
<template>
<div class="collect-playlist-container">
<div class="collect-playlist-header">
<h2>我收藏的歌单</h2>
</div>
<!-- 歌单列表 -->
<div class="playlist-list" v-loading="loading">
<div
v-for="playlist in collectPlaylists"
:key="playlist.id"
class="playlist-item"
@click="viewPlaylistDetail(playlist.id)"
>
<div class="playlist-cover">
<img :src="playlist.cover" :alt="playlist.name">
<div class="playlist-info-overlay">
<div class="playlist-play-count">
<el-icon><video-play /></el-icon>
{{ formatPlayCount(playlist.playCount) }}
</div>
<div class="playlist-actions">
<el-button
circle
:icon="'VideoPlay'"
@click.stop="viewPlaylistDetail(playlist.id)"
></el-button>
<el-button
circle
:icon="'Delete'"
type="danger"
@click.stop="cancelCollect(playlist.id, $event)"
></el-button>
</div>
</div>
</div>
<div class="playlist-info">
<div class="playlist-name">{{ playlist.name }}</div>
<div class="playlist-desc">{{ playlist.description }}</div>
<div class="playlist-meta">
<span>{{ playlist.songCount }}首歌曲</span>
<span>收藏于 {{ playlist.collectTime }}</span>
</div>
</div>
</div>
</div>
<!-- 空状态 -->
<el-empty
v-if="collectPlaylists.length === 0 && !loading"
description="暂无收藏的歌单"
></el-empty>
</div>
</template>
<style scoped>
.collect-playlist-container {
padding: 0;
}
.collect-playlist-header {
margin-bottom: 20px;
}
.collect-playlist-header h2 {
margin: 0;
font-size: 1.5rem;
color: #333;
}
.playlist-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 15px;
}
.playlist-item {
background-color: #fff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
transition: all 0.3s;
cursor: pointer;
}
.playlist-item:hover {
transform: translateY(-5px);
box-shadow: 0 5px 15px 0 rgba(0, 0, 0, 0.1);
}
.playlist-cover {
position: relative;
width: 100%;
padding-top: 100%;
overflow: hidden;
}
.playlist-cover img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s;
}
.playlist-item:hover .playlist-cover img {
transform: scale(1.05);
}
.playlist-info-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(to bottom, rgba(0,0,0,0.1), rgba(0,0,0,0.7));
opacity: 0;
transition: opacity 0.3s;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 10px;
}
.playlist-item:hover .playlist-info-overlay {
opacity: 1;
}
.playlist-play-count {
color: white;
display: flex;
align-items: center;
font-size: 0.9rem;
}
.playlist-play-count .el-icon {
margin-right: 5px;
}
.playlist-actions {
display: flex;
justify-content: center;
gap: 10px;
margin-bottom: 10px;
}
.playlist-info {
padding: 15px;
}
.playlist-name {
font-weight: bold;
margin-bottom: 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.playlist-desc {
font-size: 0.9rem;
color: #666;
margin-bottom: 8px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
height: 40px;
}
.playlist-meta {
display: flex;
justify-content: space-between;
font-size: 0.8rem;
color: #999;
}
</style>

View File

@@ -0,0 +1,243 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getCollectedSinger, toggleCollectSinger } from '../../api/singer'
import { processImageUrl } from '../../utils/imageUtils'
const router = useRouter()
// 收藏的歌手列表
const collectSingers = ref([])
// 加载状态
const loading = ref(false)
// 获取收藏的歌手列表
const fetchCollectSingers = async () => {
loading.value = true
try {
const res = await getCollectedSinger()
if (res.code === 200) {
// 处理数据
const singers = res.data.records || res.data.list || []
// 处理歌手数据
collectSingers.value = singers.map(item => ({
id: item.id,
name: item.name,
avatar: processImageUrl(item.avatar),
type: item.type || 0,
musicCount: item.musicCount || 0,
fansCount: item.fansCount || 0,
collectTime: item.collectTime ? new Date(item.collectTime).toLocaleDateString() : '未知'
}))
} else {
ElMessage.error(res.message || '获取收藏的歌手列表失败')
}
} catch (error) {
console.error('获取收藏的歌手列表失败:', error)
ElMessage.error('获取收藏的歌手列表失败')
} finally {
loading.value = false
}
}
// 查看歌手详情
const viewSingerDetail = (id) => {
router.push(`/singer/${id}`)
}
// 取消收藏歌手
const cancelCollect = (id, event) => {
event.stopPropagation()
ElMessageBox.confirm('确定要取消收藏该歌手吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await toggleCollectSinger(id)
if (res.code === 200) {
// 从列表中移除该歌手
collectSingers.value = collectSingers.value.filter(item => item.id !== id)
ElMessage.success('已取消收藏')
} else {
ElMessage.error(res.message || '取消收藏失败')
}
} catch (error) {
console.error('取消收藏失败:', error)
ElMessage.error('取消收藏失败')
}
}).catch(() => {})
}
// 格式化粉丝数
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(() => {
fetchCollectSingers()
})
</script>
<template>
<div class="collect-singer-container">
<div class="collect-singer-header">
<h2>我收藏的歌手</h2>
</div>
<!-- 歌手列表 -->
<div class="singer-list" v-loading="loading">
<div
v-for="singer in collectSingers"
: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="Delete"
type="danger"
@click="(event) => cancelCollect(singer.id, event)"
></el-button>
</div>
</div>
<div class="singer-info">
<div class="singer-name">{{ singer.name }}</div>
<div class="singer-stats">
<span>{{ singer.musicCount }}首歌曲</span>
<span>{{ formatFans(singer.fansCount) }}粉丝</span>
</div>
<div class="singer-collect-time">
收藏于 {{ singer.collectTime }}
</div>
</div>
</div>
</div>
<!-- 空状态 -->
<el-empty
v-if="collectSingers.length === 0 && !loading"
description="暂无收藏的歌手"
></el-empty>
</div>
</template>
<style scoped>
.collect-singer-container {
padding: 0;
}
.collect-singer-header {
margin-bottom: 20px;
}
.collect-singer-header h2 {
margin: 0;
font-size: 1.5rem;
color: #333;
}
.singer-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 15px;
}
.singer-item {
background-color: #fff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
transition: all 0.3s;
cursor: pointer;
}
.singer-item:hover {
transform: translateY(-5px);
box-shadow: 0 5px 15px 0 rgba(0, 0, 0, 0.1);
}
.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.05);
}
.singer-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(to bottom, rgba(0,0,0,0.1), rgba(0,0,0,0.7));
opacity: 0;
transition: opacity 0.3s;
display: flex;
align-items: center;
justify-content: center;
}
.singer-item:hover .singer-overlay {
opacity: 1;
}
.singer-info {
padding: 15px;
}
.singer-name {
font-weight: bold;
margin-bottom: 8px;
text-align: center;
}
.singer-stats {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 10px;
font-size: 0.8rem;
color: #999;
margin-bottom: 8px;
}
.singer-stats span {
white-space: nowrap;
}
.singer-collect-time {
text-align: center;
font-size: 0.8rem;
color: #999;
}
</style>

View File

@@ -0,0 +1,199 @@
<script setup>
import { ref, reactive } from 'vue'
import { ElMessage } from 'element-plus'
import { changePassword } from '../../api/user'
import { useUserStore } from '../../stores/user'
// 修改密码表单
const passwordForm = reactive({
oldPassword: '',
newPassword: '',
confirmPassword: ''
})
// 表单规则
const passwordRules = {
oldPassword: [
{ required: true, message: '请输入原密码', trigger: 'blur' },
{ min: 6, max: 20, message: '密码长度应为6-20个字符', trigger: 'blur' }
],
newPassword: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 6, max: 20, message: '密码长度应为6-20个字符', trigger: 'blur' }
],
confirmPassword: [
{ required: true, message: '请确认新密码', trigger: 'blur' },
{
validator: (rule, value, callback) => {
if (value !== passwordForm.newPassword) {
callback(new Error('两次输入的密码不一致'))
} else {
callback()
}
},
trigger: 'blur'
}
]
}
const passwordFormRef = ref(null)
const loading = ref(false)
const userStore = useUserStore()
// 提交修改密码表单
const submitPasswordForm = () => {
passwordFormRef.value.validate(async (valid) => {
if (valid) {
loading.value = true
try {
const res = await changePassword({
oldPassword: passwordForm.oldPassword,
newPassword: passwordForm.newPassword
})
if (res.code === 200) {
ElMessage.success('密码修改成功')
resetForm()
} else {
ElMessage.error(res.message || '密码修改失败')
}
} catch (error) {
console.error('密码修改失败:', error)
ElMessage.error('密码修改失败,请稍后再试')
} finally {
loading.value = false
}
}
})
}
// 重置表单
const resetForm = () => {
passwordFormRef.value.resetFields()
}
</script>
<template>
<div class="password-container">
<div class="password-header">
<h2>修改密码</h2>
</div>
<div class="password-form-container">
<el-form
ref="passwordFormRef"
:model="passwordForm"
:rules="passwordRules"
label-width="100px"
class="password-form"
>
<el-form-item label="原密码" prop="oldPassword">
<el-input
v-model="passwordForm.oldPassword"
type="password"
placeholder="请输入原密码"
show-password
/>
</el-form-item>
<el-form-item label="新密码" prop="newPassword">
<el-input
v-model="passwordForm.newPassword"
type="password"
placeholder="请输入新密码"
show-password
/>
</el-form-item>
<el-form-item label="确认新密码" prop="confirmPassword">
<el-input
v-model="passwordForm.confirmPassword"
type="password"
placeholder="请确认新密码"
show-password
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" @click="submitPasswordForm">
修改密码
</el-button>
<el-button @click="resetForm">重置</el-button>
</el-form-item>
</el-form>
<div class="password-tips">
<h3>密码修改提示</h3>
<ul>
<li>密码长度应为6-20个字符</li>
<li>建议使用字母数字和特殊字符的组合</li>
<li>不要使用与旧密码相似的新密码</li>
<li>不要使用容易被猜到的密码如生日手机号等</li>
</ul>
</div>
</div>
</div>
</template>
<style scoped>
.password-container {
padding: 0;
}
.password-header {
margin-bottom: 20px;
}
.password-header h2 {
margin: 0;
font-size: 1.5rem;
color: #333;
}
.password-form-container {
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
padding: 30px;
display: flex;
gap: 50px;
}
.password-form {
flex: 1;
max-width: 500px;
}
.password-tips {
flex: 1;
padding: 20px;
background-color: #f5f7fa;
border-radius: 8px;
}
.password-tips h3 {
margin-top: 0;
margin-bottom: 15px;
color: #409eff;
}
.password-tips ul {
padding-left: 20px;
margin: 0;
}
.password-tips li {
margin-bottom: 10px;
color: #666;
}
@media (max-width: 768px) {
.password-form-container {
flex-direction: column;
}
.password-form {
max-width: 100%;
}
}
</style>

View File

@@ -0,0 +1,562 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getUserPlaylist, addPlaylist, updatePlaylist, deletePlaylist } from '../../api/playlist'
import { processImageUrl } from '../../utils/imageUtils'
import { useUserStore } from '../../stores/user'
const router = useRouter()
const userStore = useUserStore()
// 自建歌单列表
const userPlaylists = ref([])
// 加载状态
const loading = ref(false)
// 创建歌单对话框
const createDialogVisible = ref(false)
// 编辑歌单对话框
const editDialogVisible = ref(false)
// 当前编辑的歌单
const currentPlaylist = ref(null)
// 创建歌单表单
const createForm = reactive({
name: '',
description: ''
})
// 编辑歌单表单
const editForm = reactive({
id: null,
name: '',
description: ''
})
// 表单规则
const formRules = {
name: [
{ required: true, message: '请输入歌单名称', trigger: 'blur' },
{ min: 1, max: 50, message: '长度在1到50个字符之间', trigger: 'blur' }
],
description: [
{ max: 200, message: '长度不能超过200个字符', trigger: 'blur' }
]
}
// 表单引用
const createFormRef = ref(null)
const editFormRef = ref(null)
// 头像上传URL
const coverUploadUrl = ref(`${import.meta.env.VITE_API_BASE_URL || ''}/api/file/upload/image`)
// 头像上传headers
const uploadHeaders = ref({
[userStore.tokenName]: userStore.token
})
// 获取自建歌单列表
const fetchUserPlaylists = async () => {
loading.value = true
try {
const res = await getUserPlaylist()
if (res.code === 200) {
// 处理数据
const playlists = res.data.records || res.data.list || []
// 处理歌单数据
userPlaylists.value = playlists.map(item => ({
id: item.id,
name: item.name,
description: item.description || '暂无描述',
cover: processImageUrl(item.cover),
songCount: item.songCount || 0,
createTime: item.createTime ? new Date(item.createTime).toLocaleDateString() : '未知'
}))
} else {
ElMessage.error(res.message || '获取自建歌单列表失败')
}
} catch (error) {
console.error('获取自建歌单列表失败:', error)
ElMessage.error('获取自建歌单列表失败')
} finally {
loading.value = false
}
}
// 查看歌单详情
const viewPlaylistDetail = (id) => {
router.push(`/playlist/${id}`)
}
// 打开创建歌单对话框
const openCreateDialog = () => {
createForm.name = ''
createForm.description = ''
createDialogVisible.value = true
}
// 打开编辑歌单对话框
const openEditDialog = (playlist) => {
currentPlaylist.value = playlist
editForm.id = playlist.id
editForm.name = playlist.name
editForm.description = playlist.description
editDialogVisible.value = true
}
// 创建歌单
const handleCreatePlaylist = () => {
createFormRef.value.validate(async (valid) => {
if (valid) {
try {
const res = await addPlaylist({
name: createForm.name,
description: createForm.description
})
if (res.code === 200) {
// 创建成功,重新获取歌单列表
await fetchUserPlaylists()
createDialogVisible.value = false
ElMessage.success('歌单创建成功')
} else {
ElMessage.error(res.message || '歌单创建失败')
}
} catch (error) {
console.error('歌单创建失败:', error)
ElMessage.error('歌单创建失败')
}
}
})
}
// 编辑歌单
const handleUpdatePlaylist = () => {
editFormRef.value.validate(async (valid) => {
if (valid) {
try {
const res = await updatePlaylist(editForm.id, {
name: editForm.name,
description: editForm.description
})
if (res.code === 200) {
// 更新成功,重新获取歌单列表
await fetchUserPlaylists()
editDialogVisible.value = false
ElMessage.success('歌单更新成功')
} else {
ElMessage.error(res.message || '歌单更新失败')
}
} catch (error) {
console.error('歌单更新失败:', error)
ElMessage.error('歌单更新失败')
}
}
})
}
// 删除歌单
const handleDeletePlaylist = (id) => {
ElMessageBox.confirm('确定要删除该歌单吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await deletePlaylist(id)
if (res.code === 200) {
// 删除成功,重新获取歌单列表
await fetchUserPlaylists()
ElMessage.success('歌单删除成功')
} else {
ElMessage.error(res.message || '歌单删除失败')
}
} catch (error) {
console.error('歌单删除失败:', error)
ElMessage.error('歌单删除失败')
}
}).catch(() => {})
}
// 封面上传成功
const handleCoverSuccess = async (response) => {
if (response.code === 200) {
// 上传成功获取图片URL
const imageUrl = response.data
try {
// 更新歌单封面
const res = await updatePlaylist(currentPlaylist.value.id, {
...editForm,
cover: imageUrl
})
if (res.code === 200) {
// 更新成功,重新获取歌单列表
await fetchUserPlaylists()
ElMessage.success('封面上传成功')
} else {
ElMessage.error(res.message || '更新歌单封面失败')
}
} catch (error) {
console.error('更新歌单封面失败:', error)
ElMessage.error('更新歌单封面失败')
}
} else {
ElMessage.error(response.message || '封面上传失败')
}
}
// 封面上传前的处理
const beforeCoverUpload = (file) => {
const isJPG = file.type === 'image/jpeg'
const isPNG = file.type === 'image/png'
const isLt2M = file.size / 1024 / 1024 < 2
if (!isJPG && !isPNG) {
ElMessage.error('封面只能是JPG或PNG格式!')
return false
}
if (!isLt2M) {
ElMessage.error('封面大小不能超过2MB!')
return false
}
return true
}
onMounted(() => {
fetchUserPlaylists()
})
</script>
<template>
<div class="user-playlist-container">
<div class="user-playlist-header">
<h2>我的歌单</h2>
<el-button type="primary" @click="openCreateDialog">
<el-icon><plus /></el-icon>
创建歌单
</el-button>
</div>
<!-- 歌单列表 -->
<div class="playlist-list" v-loading="loading">
<div
v-for="playlist in userPlaylists"
:key="playlist.id"
class="playlist-item"
>
<div class="playlist-cover" @click="viewPlaylistDetail(playlist.id)">
<img :src="playlist.cover" :alt="playlist.name">
<div class="playlist-info-overlay">
<div class="playlist-song-count">
<el-icon><headset /></el-icon>
{{ playlist.songCount }}首歌曲
</div>
<div class="playlist-actions">
<el-button
circle
:icon="'VideoPlay'"
@click.stop="viewPlaylistDetail(playlist.id)"
></el-button>
</div>
</div>
</div>
<div class="playlist-info">
<div class="playlist-name" @click="viewPlaylistDetail(playlist.id)">{{ playlist.name }}</div>
<div class="playlist-desc">{{ playlist.description }}</div>
<div class="playlist-meta">
<span>创建于 {{ playlist.createTime }}</span>
<div class="playlist-operations">
<el-button
text
type="primary"
size="small"
@click="openEditDialog(playlist)"
>
编辑
</el-button>
<el-button
text
type="danger"
size="small"
@click="handleDeletePlaylist(playlist.id)"
>
删除
</el-button>
</div>
</div>
</div>
</div>
</div>
<!-- 空状态 -->
<el-empty
v-if="userPlaylists.length === 0 && !loading"
description="暂无创建的歌单"
>
<el-button type="primary" @click="openCreateDialog">创建歌单</el-button>
</el-empty>
<!-- 创建歌单对话框 -->
<el-dialog
v-model="createDialogVisible"
title="创建歌单"
width="500px"
>
<el-form
ref="createFormRef"
:model="createForm"
:rules="formRules"
label-width="80px"
>
<el-form-item label="歌单名称" prop="name">
<el-input v-model="createForm.name" placeholder="请输入歌单名称" />
</el-form-item>
<el-form-item label="歌单描述" prop="description">
<el-input
v-model="createForm.description"
type="textarea"
:rows="4"
placeholder="请输入歌单描述"
/>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="createDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleCreatePlaylist">创建</el-button>
</span>
</template>
</el-dialog>
<!-- 编辑歌单对话框 -->
<el-dialog
v-model="editDialogVisible"
title="编辑歌单"
width="500px"
>
<el-form
ref="editFormRef"
:model="editForm"
:rules="formRules"
label-width="80px"
>
<el-form-item label="封面">
<el-upload
class="cover-uploader"
:action="coverUploadUrl"
:headers="uploadHeaders"
:show-file-list="false"
:on-success="handleCoverSuccess"
:before-upload="beforeCoverUpload"
>
<img
v-if="currentPlaylist"
:src="currentPlaylist.cover"
class="cover"
/>
<el-icon v-else class="cover-uploader-icon"><plus /></el-icon>
<div class="cover-tip">点击上传</div>
</el-upload>
</el-form-item>
<el-form-item label="歌单名称" prop="name">
<el-input v-model="editForm.name" placeholder="请输入歌单名称" />
</el-form-item>
<el-form-item label="歌单描述" prop="description">
<el-input
v-model="editForm.description"
type="textarea"
:rows="4"
placeholder="请输入歌单描述"
/>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="editDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleUpdatePlaylist">保存</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.user-playlist-container {
padding: 0;
}
.user-playlist-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.user-playlist-header h2 {
margin: 0;
font-size: 1.5rem;
color: #333;
}
.playlist-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 15px;
}
.playlist-item {
background-color: #fff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
transition: all 0.3s;
}
.playlist-item:hover {
transform: translateY(-5px);
box-shadow: 0 5px 15px 0 rgba(0, 0, 0, 0.1);
}
.playlist-cover {
position: relative;
width: 100%;
padding-top: 100%;
overflow: hidden;
cursor: pointer;
}
.playlist-cover img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s;
}
.playlist-item:hover .playlist-cover img {
transform: scale(1.05);
}
.playlist-info-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(to bottom, rgba(0,0,0,0.1), rgba(0,0,0,0.7));
opacity: 0;
transition: opacity 0.3s;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 10px;
}
.playlist-item:hover .playlist-info-overlay {
opacity: 1;
}
.playlist-song-count {
color: white;
display: flex;
align-items: center;
font-size: 0.9rem;
}
.playlist-song-count .el-icon {
margin-right: 5px;
}
.playlist-actions {
display: flex;
justify-content: center;
gap: 10px;
margin-bottom: 10px;
}
.playlist-info {
padding: 15px;
}
.playlist-name {
font-weight: bold;
margin-bottom: 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
}
.playlist-name:hover {
color: #409eff;
}
.playlist-desc {
font-size: 0.9rem;
color: #666;
margin-bottom: 8px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
height: 40px;
}
.playlist-meta {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.8rem;
color: #999;
}
.playlist-operations {
display: flex;
gap: 10px;
}
.cover-uploader {
text-align: center;
}
.cover {
width: 100px;
height: 100px;
border-radius: 4px;
object-fit: cover;
}
.cover-uploader-icon {
font-size: 28px;
color: #8c939d;
width: 100px;
height: 100px;
line-height: 100px;
text-align: center;
border: 1px dashed #d9d9d9;
border-radius: 4px;
}
.cover-tip {
margin-top: 5px;
font-size: 0.8rem;
color: #999;
}
</style>

View File

@@ -0,0 +1,37 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
},
server: {
port: 5174,
open: true,
proxy: {
'/api': {
target: 'http://localhost:8085',
changeOrigin: true,
rewrite: (path) => path
}
}
},
build: {
outDir: '../src/main/resources/static',
emptyOutDir: true,
assetsDir: 'assets',
rollupOptions: {
output: {
manualChunks: {
'vendor': ['vue', 'vue-router', 'pinia', 'element-plus'],
'element-icons': ['@element-plus/icons-vue']
}
}
}
}
})