Преглед изворни кода

feat: 新增音乐管理页面(歌单管理 + 歌曲管理)

- 新增歌单管理页面:支持 CRUD、搜索、分页
- 新增歌曲管理页面:支持 mp3 拖拽上传、自动检测时长、
  自动填充歌名、试听播放、CRUD
- 新增音乐管理相关 API 接口(8 个)
- 新增音乐管理静态路由
- 修复前端提交格式,使用 { id, data: {...} } 匹配后端 BaseRequest
urban пре 2 недеља
родитељ
комит
d3130c47d6

+ 34 - 0
src/api/modules/daytask.js

@@ -306,3 +306,37 @@ export const updateConfig = (params) => {
 export const deleteConfig = (params) => {
   return http.post(`${BASE}/config/delete`, params);
 };
+
+// ==================== 音乐管理 ====================
+
+export const getMusicGroupList = (params) => {
+  return http.get(`${BASE}/music_group/find`, params);
+};
+
+export const createMusicGroup = (params) => {
+  return http.post(`${BASE}/music_group/create`, params);
+};
+
+export const updateMusicGroup = (params) => {
+  return http.post(`${BASE}/music_group/update`, params);
+};
+
+export const deleteMusicGroup = (params) => {
+  return http.post(`${BASE}/music_group/delete`, params);
+};
+
+export const getMusicList = (params) => {
+  return http.get(`${BASE}/music/find`, params);
+};
+
+export const createMusic = (params) => {
+  return http.post(`${BASE}/music/create`, params);
+};
+
+export const updateMusic = (params) => {
+  return http.post(`${BASE}/music/update`, params);
+};
+
+export const deleteMusic = (params) => {
+  return http.post(`${BASE}/music/delete`, params);
+};

+ 28 - 0
src/routers/modules/staticRouter.js

@@ -191,6 +191,34 @@ export const staticRouter = [
           }
         ]
       },
+      // ==================== 音乐管理 ====================
+      {
+        path: "/music",
+        name: "music",
+        meta: {
+          icon: "Headset",
+          isAffix: false,
+          isFull: false,
+          isHide: false,
+          isKeepAlive: true,
+          isLink: false,
+          title: "音乐管理"
+        },
+        children: [
+          {
+            path: "group",
+            name: "musicGroupList",
+            component: () => import("@/views/daytask/music/musicGroupList.vue"),
+            meta: { title: "歌单管理", icon: "Menu", isKeepAlive: true }
+          },
+          {
+            path: "list",
+            name: "musicList",
+            component: () => import("@/views/daytask/music/musicList.vue"),
+            meta: { title: "歌曲管理", icon: "Menu", isKeepAlive: true }
+          }
+        ]
+      },
       // ==================== 数据统计 ====================
       {
         path: "/stats",

+ 169 - 0
src/views/daytask/music/musicGroupList.vue

@@ -0,0 +1,169 @@
+<template>
+  <div class="table-box">
+    <div class="card table-search">
+      <el-form ref="elSearchFormRef" :inline="true" size="small" :model="searchForm" class="demo-form-inline" @keyup.enter="onSubmit">
+        <el-form-item label="歌单名称">
+          <el-input v-model="searchForm.name" placeholder="歌单名称" style="width:150px"></el-input>
+        </el-form-item>
+        <el-form-item label="状态">
+          <el-select v-model="searchForm.status" placeholder="状态" clearable style="width:100px">
+            <el-option label="启用" :value="1" />
+            <el-option label="禁用" :value="0" />
+          </el-select>
+        </el-form-item>
+        <div class="operation">
+          <el-button type="primary" icon="search" @click="onSubmit">查询</el-button>
+          <el-button icon="refresh" @click="onResetSearch">重置</el-button>
+        </div>
+      </el-form>
+    </div>
+
+    <div class="card table-main">
+      <div class="table-header">
+        <div class="header-button-lf">
+          <el-button type="primary" icon="Plus" @click="openDialog()">新增歌单</el-button>
+        </div>
+        <div class="header-button-ri">
+          <el-button :icon="Refresh" circle @click="refresh" />
+        </div>
+      </div>
+
+      <el-table ref="myTable" :data="tableData" border size="small">
+        <el-table-column prop="id" label="ID" align="center" width="80" />
+        <el-table-column prop="name" label="歌单名称" align="center" min-width="150" />
+        <el-table-column prop="cover" label="封面图" align="center" width="100">
+          <template #default="{ row }">
+            <el-image v-if="row.cover" :src="row.cover" style="width: 40px; height: 40px" fit="contain" />
+            <span v-else class="text-muted">-</span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="sort" label="排序" align="center" width="80" />
+        <el-table-column prop="status" label="状态" align="center" width="80">
+          <template #default="{ row }">
+            <el-tag :type="row.status === 1 ? 'success' : 'info'">
+              {{ row.status === 1 ? '启用' : '禁用' }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="createdAt" label="创建时间" align="center" min-width="160">
+          <template #default="{ row }">
+            <span v-if="row.createdAt">{{ formatUnix(row.createdAt) }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column fixed="right" label="操作" align="center" width="150">
+          <template #default="scope">
+            <el-button type="primary" link @click="openDialog(scope.row)">编辑</el-button>
+            <el-button type="danger" link @click="handleDelete(scope.row)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <Pagination :pageable="pageable" @handleCurrent="handleCurrent" />
+    </div>
+  </div>
+
+  <!-- 编辑弹窗 -->
+  <el-dialog v-model="dialogVisible" :title="isEdit ? '编辑歌单' : '新增歌单'" width="500px" center destroy-on-close>
+    <el-form ref="formRef" :model="formData" :rules="formRules" label-width="80px">
+      <el-form-item label="歌单名称" prop="name">
+        <el-input v-model="formData.name" placeholder="请输入歌单名称" />
+      </el-form-item>
+      <el-form-item label="封面图" prop="cover">
+        <el-input v-model="formData.cover" placeholder="请输入封面图URL" />
+      </el-form-item>
+      <el-form-item label="排序" prop="sort">
+        <el-input-number v-model="formData.sort" :min="0" style="width: 100%" />
+      </el-form-item>
+      <el-form-item label="状态" prop="status">
+        <el-radio-group v-model="formData.status">
+          <el-radio :value="1">启用</el-radio>
+          <el-radio :value="0">禁用</el-radio>
+        </el-radio-group>
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button @click="dialogVisible = false">取消</el-button>
+      <el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup>
+import { ref, reactive, onMounted } from "vue";
+import dayjs from "dayjs";
+import { Refresh } from "@element-plus/icons-vue";
+import { ElNotification, ElMessageBox } from "element-plus";
+import Pagination from "@/components/Pangination/Pagination.vue";
+import { getMusicGroupList, createMusicGroup, updateMusicGroup, deleteMusicGroup } from "@/api/modules/daytask.js";
+
+const formatUnix = (ts) => ts ? dayjs.unix(ts).format("YYYY-MM-DD HH:mm:ss") : "";
+
+// 搜索
+const searchForm = reactive({ name: "", status: null });
+const tableData = ref([]);
+const pageable = reactive({ current: 1, pageSize: 20, total: 0 });
+
+const getList = async () => {
+  const params = { current: pageable.current, pageSize: pageable.pageSize, ...searchForm };
+  Object.keys(params).forEach(k => { if (params[k] === "" || params[k] === null) delete params[k]; });
+  const res = await getMusicGroupList(params);
+  if (res.data) {
+    tableData.value = res.data.list || [];
+    pageable.total = res.data.paging?.total || 0;
+  }
+};
+
+const onSubmit = () => { pageable.current = 1; getList(); };
+const onResetSearch = () => { searchForm.name = ""; searchForm.status = null; onSubmit(); };
+const handleCurrent = (val) => { pageable.current = val; getList(); };
+const refresh = () => getList();
+
+// 弹窗
+const dialogVisible = ref(false);
+const isEdit = ref(false);
+const formRef = ref(null);
+const submitLoading = ref(false);
+const formData = reactive({ id: null, name: "", cover: "", sort: 0, status: 1 });
+const formRules = { name: [{ required: true, message: "请输入歌单名称", trigger: "blur" }] };
+
+const openDialog = (row) => {
+  isEdit.value = !!row;
+  if (row) {
+    Object.assign(formData, { id: row.id, name: row.name, cover: row.cover || "", sort: row.sort || 0, status: row.status });
+  } else {
+    Object.assign(formData, { id: null, name: "", cover: "", sort: 0, status: 1 });
+  }
+  dialogVisible.value = true;
+};
+
+const handleSubmit = async () => {
+  await formRef.value?.validate();
+  submitLoading.value = true;
+  try {
+    // 后端期望格式: { id, data: { ...fields } }
+    const { id, ...fields } = formData;
+    if (isEdit.value) {
+      await updateMusicGroup({ id, data: fields });
+    } else {
+      await createMusicGroup({ data: fields });
+    }
+    ElNotification.success({ title: "成功", message: isEdit.value ? "编辑成功" : "新增成功" });
+    dialogVisible.value = false;
+    getList();
+  } catch (e) {
+    ElNotification.error({ title: "错误", message: e.message || "操作失败" });
+  } finally {
+    submitLoading.value = false;
+  }
+};
+
+const handleDelete = (row) => {
+  ElMessageBox.confirm("确定删除该歌单吗?", "提示", { type: "warning" }).then(async () => {
+    await deleteMusicGroup({ id: row.id });
+    ElNotification.success({ title: "成功", message: "删除成功" });
+    getList();
+  }).catch(() => {});
+};
+
+onMounted(() => getList());
+</script>

+ 373 - 0
src/views/daytask/music/musicList.vue

@@ -0,0 +1,373 @@
+<template>
+  <div class="table-box">
+    <div class="card table-search">
+      <el-form ref="elSearchFormRef" :inline="true" size="small" :model="searchForm" class="demo-form-inline" @keyup.enter="onSubmit">
+        <el-form-item label="歌曲名称">
+          <el-input v-model="searchForm.title" placeholder="歌曲名称" style="width:150px"></el-input>
+        </el-form-item>
+        <el-form-item label="歌手">
+          <el-input v-model="searchForm.artist" placeholder="歌手" style="width:120px"></el-input>
+        </el-form-item>
+        <el-form-item label="歌单">
+          <el-select v-model="searchForm.groupId" placeholder="选择歌单" clearable style="width:150px">
+            <el-option v-for="g in groupOptions" :key="g.id" :label="g.name" :value="g.id" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="状态">
+          <el-select v-model="searchForm.status" placeholder="状态" clearable style="width:100px">
+            <el-option label="启用" :value="1" />
+            <el-option label="禁用" :value="0" />
+          </el-select>
+        </el-form-item>
+        <div class="operation">
+          <el-button type="primary" icon="search" @click="onSubmit">查询</el-button>
+          <el-button icon="refresh" @click="onResetSearch">重置</el-button>
+        </div>
+      </el-form>
+    </div>
+
+    <div class="card table-main">
+      <div class="table-header">
+        <div class="header-button-lf">
+          <el-button type="primary" icon="Plus" @click="openDialog()">新增歌曲</el-button>
+        </div>
+        <div class="header-button-ri">
+          <el-button :icon="Refresh" circle @click="refresh" />
+        </div>
+      </div>
+
+      <el-table ref="myTable" :data="tableData" border size="small">
+        <el-table-column prop="id" label="ID" align="center" width="80" />
+        <el-table-column prop="title" label="歌曲名称" align="center" min-width="150" />
+        <el-table-column prop="artist" label="歌手" align="center" width="120" />
+        <el-table-column label="所属歌单" align="center" width="120">
+          <template #default="{ row }">
+            {{ getGroupName(row.groupId) }}
+          </template>
+        </el-table-column>
+        <el-table-column label="试听" align="center" width="80">
+          <template #default="{ row }">
+            <el-button v-if="row.url" type="primary" link size="small" @click="playAudio(row.url)">
+              <el-icon><VideoPlay /></el-icon>
+            </el-button>
+            <span v-else>-</span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="duration" label="时长(秒)" align="center" width="80" />
+        <el-table-column prop="sort" label="排序" align="center" width="70" />
+        <el-table-column prop="status" label="状态" align="center" width="80">
+          <template #default="{ row }">
+            <el-tag :type="row.status === 1 ? 'success' : 'info'">
+              {{ row.status === 1 ? '启用' : '禁用' }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="createdAt" label="创建时间" align="center" min-width="160">
+          <template #default="{ row }">
+            <span v-if="row.createdAt">{{ formatUnix(row.createdAt) }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column fixed="right" label="操作" align="center" width="150">
+          <template #default="scope">
+            <el-button type="primary" link @click="openDialog(scope.row)">编辑</el-button>
+            <el-button type="danger" link @click="handleDelete(scope.row)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <Pagination :pageable="pageable" @handleCurrent="handleCurrent" />
+    </div>
+  </div>
+
+  <!-- 编辑弹窗 -->
+  <el-dialog v-model="dialogVisible" :title="isEdit ? '编辑歌曲' : '新增歌曲'" width="600px" center destroy-on-close>
+    <el-form ref="formRef" :model="formData" :rules="formRules" label-width="100px">
+      <el-form-item label="所属歌单" prop="groupId">
+        <el-select v-model="formData.groupId" placeholder="请选择歌单" style="width: 100%">
+          <el-option v-for="g in groupOptions" :key="g.id" :label="g.name" :value="g.id" />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="歌曲名称" prop="title">
+        <el-input v-model="formData.title" placeholder="请输入歌曲名称" />
+      </el-form-item>
+      <el-form-item label="歌手" prop="artist">
+        <el-input v-model="formData.artist" placeholder="请输入歌手名称" />
+      </el-form-item>
+      <el-form-item label="音频文件" prop="url">
+        <div class="upload-audio-wrapper">
+          <div v-if="formData.url" class="audio-preview">
+            <audio :src="audioPreviewUrl || formData.url" controls style="width: 100%"></audio>
+            <div class="audio-actions">
+              <el-button type="primary" size="small" @click="triggerAudioUpload">重新上传</el-button>
+              <el-button type="danger" size="small" @click="clearAudio">删除</el-button>
+            </div>
+          </div>
+          <el-upload
+            v-else
+            ref="audioUploadRef"
+            class="audio-uploader"
+            drag
+            action="#"
+            :auto-upload="false"
+            :on-change="handleAudioChange"
+            :show-file-list="false"
+            accept=".mp3"
+          >
+            <el-icon class="el-icon--upload"><UploadFilled /></el-icon>
+            <div class="el-upload__text">拖拽 MP3 文件到此处 或 <em>点击上传</em></div>
+          </el-upload>
+          <el-upload
+            v-show="false"
+            ref="audioReuploadRef"
+            action="#"
+            :auto-upload="false"
+            :on-change="handleAudioChange"
+            :show-file-list="false"
+            accept=".mp3"
+          />
+          <div v-if="audioUploading" class="upload-progress">
+            <el-progress :percentage="audioUploadProgress" :status="audioUploadProgress === 100 ? 'success' : undefined" />
+          </div>
+        </div>
+      </el-form-item>
+      <el-form-item label="排序" prop="sort">
+        <el-input-number v-model="formData.sort" :min="0" style="width: 100%" />
+      </el-form-item>
+      <el-form-item label="状态" prop="status">
+        <el-radio-group v-model="formData.status">
+          <el-radio :value="1">启用</el-radio>
+          <el-radio :value="0">禁用</el-radio>
+        </el-radio-group>
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button @click="dialogVisible = false">取消</el-button>
+      <el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
+    </template>
+  </el-dialog>
+
+  <!-- 试听弹窗 -->
+  <el-dialog v-model="playerVisible" title="试听" width="400px" center destroy-on-close>
+    <audio :src="playerUrl" controls autoplay style="width: 100%"></audio>
+  </el-dialog>
+</template>
+
+<script setup>
+import { ref, reactive, onMounted } from "vue";
+import dayjs from "dayjs";
+import { Refresh, UploadFilled, VideoPlay } from "@element-plus/icons-vue";
+import { ElNotification, ElMessageBox } from "element-plus";
+import Pagination from "@/components/Pangination/Pagination.vue";
+import { getMusicList, createMusic, updateMusic, deleteMusic, getMusicGroupList } from "@/api/modules/daytask.js";
+import { uploadImg } from "@/api/modules/upload.js";
+
+const formatUnix = (ts) => ts ? dayjs.unix(ts).format("YYYY-MM-DD HH:mm:ss") : "";
+
+// 歌单选项
+const groupOptions = ref([]);
+const loadGroupOptions = async () => {
+  const res = await getMusicGroupList({ pageSize: 100 });
+  if (res.data) groupOptions.value = res.data.list || [];
+};
+const getGroupName = (id) => {
+  const g = groupOptions.value.find(g => g.id === id);
+  return g ? g.name : "-";
+};
+
+// 试听
+const playerVisible = ref(false);
+const playerUrl = ref("");
+const playAudio = (url) => {
+  playerUrl.value = url;
+  playerVisible.value = true;
+};
+
+// 搜索
+const searchForm = reactive({ title: "", artist: "", groupId: null, status: null });
+const tableData = ref([]);
+const pageable = reactive({ current: 1, pageSize: 20, total: 0 });
+
+const getList = async () => {
+  const params = { current: pageable.current, pageSize: pageable.pageSize, ...searchForm };
+  Object.keys(params).forEach(k => { if (params[k] === "" || params[k] === null) delete params[k]; });
+  const res = await getMusicList(params);
+  if (res.data) {
+    tableData.value = res.data.list || [];
+    pageable.total = res.data.paging?.total || 0;
+  }
+};
+
+const onSubmit = () => { pageable.current = 1; getList(); };
+const onResetSearch = () => { searchForm.title = ""; searchForm.artist = ""; searchForm.groupId = null; searchForm.status = null; onSubmit(); };
+const handleCurrent = (val) => { pageable.current = val; getList(); };
+const refresh = () => getList();
+
+// 弹窗
+const dialogVisible = ref(false);
+const isEdit = ref(false);
+const formRef = ref(null);
+const submitLoading = ref(false);
+const formData = reactive({ id: null, groupId: null, title: "", artist: "", url: "", duration: 0, sort: 0, status: 1 });
+const formRules = {
+  groupId: [{ required: true, message: "请选择歌单", trigger: "change" }],
+  title: [{ required: true, message: "请输入歌曲名称", trigger: "blur" }],
+  url: [{ required: true, message: "请上传音频文件", trigger: "change" }],
+};
+
+// 音频上传
+const audioUploadRef = ref(null);
+const audioReuploadRef = ref(null);
+const audioPreviewUrl = ref("");
+const audioUploading = ref(false);
+const audioUploadProgress = ref(0);
+
+const handleAudioChange = async (file) => {
+  const rawFile = file.raw;
+  audioPreviewUrl.value = URL.createObjectURL(rawFile);
+
+  // 自动获取音频时长
+  const audio = new Audio(audioPreviewUrl.value);
+  audio.addEventListener("loadedmetadata", () => {
+    formData.duration = Math.round(audio.duration);
+  });
+
+  // 自动填充歌曲名称(如果为空)
+  if (!formData.title) {
+    formData.title = rawFile.name.replace(/\.[^/.]+$/, "");
+  }
+
+  // 上传
+  audioUploading.value = true;
+  audioUploadProgress.value = 0;
+  try {
+    const fd = new FormData();
+    fd.append("file", rawFile);
+    fd.append("autoSave", "false");
+
+    const progressInterval = setInterval(() => {
+      if (audioUploadProgress.value < 90) audioUploadProgress.value += 10;
+    }, 100);
+
+    const res = await uploadImg(fd);
+    clearInterval(progressInterval);
+
+    if (res.code === 200) {
+      audioUploadProgress.value = 100;
+      formData.url = res.data.path;
+      if (formRef.value) formRef.value.clearValidate("url");
+      ElNotification.success("音频上传成功");
+    } else {
+      ElNotification.error(res.msg || "音频上传失败");
+      clearAudio();
+    }
+  } catch (e) {
+    ElNotification.error("音频上传失败");
+    clearAudio();
+  } finally {
+    audioUploading.value = false;
+  }
+};
+
+const triggerAudioUpload = () => {
+  audioReuploadRef.value?.$el.querySelector("input")?.click();
+};
+
+const clearAudio = () => {
+  formData.url = "";
+  formData.duration = 0;
+  audioPreviewUrl.value = "";
+};
+
+const openDialog = (row) => {
+  isEdit.value = !!row;
+  audioPreviewUrl.value = "";
+  if (row) {
+    Object.assign(formData, {
+      id: row.id,
+      groupId: row.groupId,
+      title: row.title,
+      artist: row.artist || "",
+      url: row.url,
+      duration: row.duration || 0,
+      sort: row.sort || 0,
+      status: row.status,
+    });
+  } else {
+    Object.assign(formData, { id: null, groupId: null, title: "", artist: "", url: "", duration: 0, sort: 0, status: 1 });
+  }
+  dialogVisible.value = true;
+};
+
+const handleSubmit = async () => {
+  await formRef.value?.validate();
+  submitLoading.value = true;
+  try {
+    // 后端期望格式: { id, data: { ...fields } }
+    const { id, ...fields } = formData;
+    if (isEdit.value) {
+      await updateMusic({ id, data: fields });
+    } else {
+      await createMusic({ data: fields });
+    }
+    ElNotification.success({ title: "成功", message: isEdit.value ? "编辑成功" : "新增成功" });
+    dialogVisible.value = false;
+    getList();
+  } catch (e) {
+    ElNotification.error({ title: "错误", message: e.message || "操作失败" });
+  } finally {
+    submitLoading.value = false;
+  }
+};
+
+const handleDelete = (row) => {
+  ElMessageBox.confirm("确定删除该歌曲吗?", "提示", { type: "warning" }).then(async () => {
+    await deleteMusic({ id: row.id });
+    ElNotification.success({ title: "成功", message: "删除成功" });
+    getList();
+  }).catch(() => {});
+};
+
+onMounted(() => { loadGroupOptions(); getList(); });
+</script>
+
+<style scoped>
+.upload-audio-wrapper {
+  width: 100%;
+}
+
+.audio-uploader {
+  width: 100%;
+}
+
+.audio-uploader :deep(.el-upload) {
+  width: 100%;
+}
+
+.audio-uploader :deep(.el-upload-dragger) {
+  width: 100%;
+  height: auto;
+  min-height: 120px;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  padding: 15px;
+}
+
+.audio-preview {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+  width: 100%;
+}
+
+.audio-actions {
+  display: flex;
+  gap: 10px;
+}
+
+.upload-progress {
+  margin-top: 10px;
+  width: 100%;
+}
+</style>