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

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

View File

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