feat: 初始化音乐项目后台管理系统- 新增后台管理相关页面和功能组件
- 实现管理员评论管理、数据统计等功能 - 添加后台布局和路由管理 -配置数据库和Redis连接 - 实现文件上传和访问路径配置 - 添加Sa-Token安全配置
This commit is contained in:
80
music-qianduan/.gitignore
vendored
Normal file
80
music-qianduan/.gitignore
vendored
Normal 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
13
music-qianduan/index.html
Normal 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
2075
music-qianduan/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
music-qianduan/package.json
Normal file
26
music-qianduan/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1
music-qianduan/public/vite.svg
Normal file
1
music-qianduan/public/vite.svg
Normal 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 |
66
music-qianduan/src/App.vue
Normal file
66
music-qianduan/src/App.vue
Normal 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>
|
||||
43
music-qianduan/src/api/admin/comment.js
Normal file
43
music-qianduan/src/api/admin/comment.js
Normal 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}`)
|
||||
}
|
||||
9
music-qianduan/src/api/admin/dashboard.js
Normal file
9
music-qianduan/src/api/admin/dashboard.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import api from '../index'
|
||||
|
||||
/**
|
||||
* 获取控制台统计数据
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
export function getDashboardStats() {
|
||||
return api.get('/admin/dashboard/stats')
|
||||
}
|
||||
106
music-qianduan/src/api/admin/type.js
Normal file
106
music-qianduan/src/api/admin/type.js
Normal 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}`)
|
||||
}
|
||||
69
music-qianduan/src/api/admin/user.js
Normal file
69
music-qianduan/src/api/admin/user.js
Normal 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)
|
||||
}
|
||||
162
music-qianduan/src/api/banner.js
Normal file
162
music-qianduan/src/api/banner.js
Normal 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
|
||||
}
|
||||
})
|
||||
}
|
||||
57
music-qianduan/src/api/comment.js
Normal file
57
music-qianduan/src/api/comment.js
Normal 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
|
||||
}
|
||||
})
|
||||
}
|
||||
123
music-qianduan/src/api/index.js
Normal file
123
music-qianduan/src/api/index.js
Normal 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
|
||||
242
music-qianduan/src/api/music.js
Normal file
242
music-qianduan/src/api/music.js
Normal 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}`;
|
||||
}
|
||||
63
music-qianduan/src/api/musicType.js
Normal file
63
music-qianduan/src/api/musicType.js
Normal 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}`)
|
||||
}
|
||||
362
music-qianduan/src/api/playlist.js
Normal file
362
music-qianduan/src/api/playlist.js
Normal 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
|
||||
})
|
||||
}
|
||||
63
music-qianduan/src/api/playlistType.js
Normal file
63
music-qianduan/src/api/playlistType.js
Normal 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}`)
|
||||
}
|
||||
244
music-qianduan/src/api/singer.js
Normal file
244
music-qianduan/src/api/singer.js
Normal 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}`)
|
||||
}
|
||||
63
music-qianduan/src/api/singerType.js
Normal file
63
music-qianduan/src/api/singerType.js
Normal 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}`)
|
||||
}
|
||||
121
music-qianduan/src/api/user.js
Normal file
121
music-qianduan/src/api/user.js
Normal 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}`)
|
||||
}
|
||||
76
music-qianduan/src/assets/css/global.css
Normal file
76
music-qianduan/src/assets/css/global.css
Normal 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%;
|
||||
}
|
||||
1
music-qianduan/src/assets/vue.svg
Normal file
1
music-qianduan/src/assets/vue.svg
Normal 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 |
43
music-qianduan/src/components/HelloWorld.vue
Normal file
43
music-qianduan/src/components/HelloWorld.vue
Normal 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>
|
||||
122
music-qianduan/src/components/HomeBanner.vue
Normal file
122
music-qianduan/src/components/HomeBanner.vue
Normal 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>
|
||||
98
music-qianduan/src/components/ImagePreview.vue
Normal file
98
music-qianduan/src/components/ImagePreview.vue
Normal 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>
|
||||
384
music-qianduan/src/components/MusicList.vue
Normal file
384
music-qianduan/src/components/MusicList.vue
Normal 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>
|
||||
1195
music-qianduan/src/components/MusicPlayer.vue
Normal file
1195
music-qianduan/src/components/MusicPlayer.vue
Normal file
File diff suppressed because it is too large
Load Diff
336
music-qianduan/src/layouts/AdminLayout.vue
Normal file
336
music-qianduan/src/layouts/AdminLayout.vue
Normal 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>
|
||||
384
music-qianduan/src/layouts/MainLayout.vue
Normal file
384
music-qianduan/src/layouts/MainLayout.vue
Normal 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>
|
||||
27
music-qianduan/src/main.js
Normal file
27
music-qianduan/src/main.js
Normal 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')
|
||||
312
music-qianduan/src/router/index.js
Normal file
312
music-qianduan/src/router/index.js
Normal 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
|
||||
5
music-qianduan/src/stores/index.js
Normal file
5
music-qianduan/src/stores/index.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
const pinia = createPinia()
|
||||
|
||||
export default pinia
|
||||
285
music-qianduan/src/stores/player.js
Normal file
285
music-qianduan/src/stores/player.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
145
music-qianduan/src/stores/user.js
Normal file
145
music-qianduan/src/stores/user.js
Normal 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 // 将错误向上抛出,以便调用者可以处理
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
84
music-qianduan/src/style.css
Normal file
84
music-qianduan/src/style.css
Normal 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;
|
||||
}
|
||||
}
|
||||
12
music-qianduan/src/utils/clearStorage.js
Normal file
12
music-qianduan/src/utils/clearStorage.js
Normal 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('本地存储已清理')
|
||||
}
|
||||
42
music-qianduan/src/utils/imageUtils.js
Normal file
42
music-qianduan/src/utils/imageUtils.js
Normal 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) + '亿';
|
||||
}
|
||||
}
|
||||
95
music-qianduan/src/utils/websocket.js
Normal file
95
music-qianduan/src/utils/websocket.js
Normal 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 };
|
||||
80
music-qianduan/src/views/About.vue
Normal file
80
music-qianduan/src/views/About.vue
Normal 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 3、Element Plus、Pinia和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>
|
||||
502
music-qianduan/src/views/Home.vue
Normal file
502
music-qianduan/src/views/Home.vue
Normal 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>
|
||||
212
music-qianduan/src/views/ImageTest.vue
Normal file
212
music-qianduan/src/views/ImageTest.vue
Normal 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>
|
||||
227
music-qianduan/src/views/Login.vue
Normal file
227
music-qianduan/src/views/Login.vue
Normal 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>
|
||||
336
music-qianduan/src/views/Music.vue
Normal file
336
music-qianduan/src/views/Music.vue
Normal 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>
|
||||
448
music-qianduan/src/views/MusicDetail.vue
Normal file
448
music-qianduan/src/views/MusicDetail.vue
Normal 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>
|
||||
32
music-qianduan/src/views/NotFound.vue
Normal file
32
music-qianduan/src/views/NotFound.vue
Normal 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>
|
||||
547
music-qianduan/src/views/Playlist.vue
Normal file
547
music-qianduan/src/views/Playlist.vue
Normal 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>
|
||||
659
music-qianduan/src/views/PlaylistDetail.vue
Normal file
659
music-qianduan/src/views/PlaylistDetail.vue
Normal 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>
|
||||
207
music-qianduan/src/views/Register.vue
Normal file
207
music-qianduan/src/views/Register.vue
Normal 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>
|
||||
692
music-qianduan/src/views/Search.vue
Normal file
692
music-qianduan/src/views/Search.vue
Normal 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>
|
||||
499
music-qianduan/src/views/Singer.vue
Normal file
499
music-qianduan/src/views/Singer.vue
Normal 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>
|
||||
576
music-qianduan/src/views/SingerDetail.vue
Normal file
576
music-qianduan/src/views/SingerDetail.vue
Normal 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>
|
||||
493
music-qianduan/src/views/UserCenter.vue
Normal file
493
music-qianduan/src/views/UserCenter.vue
Normal 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>
|
||||
414
music-qianduan/src/views/admin/Banner.vue
Normal file
414
music-qianduan/src/views/admin/Banner.vue
Normal 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>
|
||||
673
music-qianduan/src/views/admin/BannerManagement.vue
Normal file
673
music-qianduan/src/views/admin/BannerManagement.vue
Normal 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>
|
||||
440
music-qianduan/src/views/admin/CommentManagement.vue
Normal file
440
music-qianduan/src/views/admin/CommentManagement.vue
Normal 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>
|
||||
707
music-qianduan/src/views/admin/Dashboard.vue
Normal file
707
music-qianduan/src/views/admin/Dashboard.vue
Normal 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>
|
||||
257
music-qianduan/src/views/admin/Index.vue
Normal file
257
music-qianduan/src/views/admin/Index.vue
Normal 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>
|
||||
788
music-qianduan/src/views/admin/Music.vue
Normal file
788
music-qianduan/src/views/admin/Music.vue
Normal 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>
|
||||
1064
music-qianduan/src/views/admin/Playlist.vue
Normal file
1064
music-qianduan/src/views/admin/Playlist.vue
Normal file
File diff suppressed because it is too large
Load Diff
333
music-qianduan/src/views/admin/PlaylistType.vue
Normal file
333
music-qianduan/src/views/admin/PlaylistType.vue
Normal 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>
|
||||
852
music-qianduan/src/views/admin/Singer.vue
Normal file
852
music-qianduan/src/views/admin/Singer.vue
Normal 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>
|
||||
357
music-qianduan/src/views/admin/Type.vue
Normal file
357
music-qianduan/src/views/admin/Type.vue
Normal 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>
|
||||
780
music-qianduan/src/views/admin/TypeManagement.vue
Normal file
780
music-qianduan/src/views/admin/TypeManagement.vue
Normal 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>
|
||||
294
music-qianduan/src/views/admin/User.vue
Normal file
294
music-qianduan/src/views/admin/User.vue
Normal 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>
|
||||
513
music-qianduan/src/views/admin/UserManagement.vue
Normal file
513
music-qianduan/src/views/admin/UserManagement.vue
Normal 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>
|
||||
226
music-qianduan/src/views/user/CollectMusic.vue
Normal file
226
music-qianduan/src/views/user/CollectMusic.vue
Normal 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>
|
||||
271
music-qianduan/src/views/user/CollectPlaylist.vue
Normal file
271
music-qianduan/src/views/user/CollectPlaylist.vue
Normal 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>
|
||||
243
music-qianduan/src/views/user/CollectSinger.vue
Normal file
243
music-qianduan/src/views/user/CollectSinger.vue
Normal 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>
|
||||
199
music-qianduan/src/views/user/Password.vue
Normal file
199
music-qianduan/src/views/user/Password.vue
Normal 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>
|
||||
562
music-qianduan/src/views/user/Playlist.vue
Normal file
562
music-qianduan/src/views/user/Playlist.vue
Normal 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>
|
||||
37
music-qianduan/vite.config.js
Normal file
37
music-qianduan/vite.config.js
Normal 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']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user