Compare commits

...

28 Commits

Author SHA1 Message Date
ikmkj
395c75d3fb fix: 修复音乐编辑时长丢失及日期格式问题 2026-04-09 20:17:59 +08:00
ikmkj
aa1eabfeb7 refactor(comment): 优化评论功能实现并改进热门音乐缓存更新机制
- 移除CommentController中多余的权限注解导入
- 在CommentController中注入MusicService用于缓存更新
- 添加评论和删除评论时触发热门音乐缓存更新
- 提取评论数更新逻辑到独立方法updateTargetCommentCount
- 删除评论时在最后同步评论数避免统计偏大
- 优化HotScoreUtil中播放量权重算法采用对数缩放
- 在MusicController的播放、收藏、点赞操作后更新热门音乐缓存
- 改进MusicRepository查询排序避免数据重复
- 重构MusicServiceImpl中热门音乐排序逻辑增强稳定性
2026-04-07 21:26:35 +08:00
ikmkj
86b286453a feat(data): 统一数据结构处理并优化音乐播放功能
- 添加 extractList 函数统一处理不同数据结构(list、records、items)
- 添加 extractPageData 函数标准化分页数据处理逻辑
- 修复音乐播放器移除播放列表项时的索引问题
- 优化播放列表清空时的音频暂停和状态重置
- 调整收藏按钮图标显示逻辑(实心星和空心星对调)
- 更新前端API调用路径 /user/profile 为 /user/current
- 修复后端收藏歌单查询的字段映射和排序问题
- 在主布局中引入 ArrowDown 图标用于下拉功能
- 改进用户中心头像上传的认证头构建方式
- 优化用户统计数据显示的数据提取逻辑
2026-03-21 23:49:29 +08:00
ikmkj
c76b4d9281 feat(singer): 添加歌手收藏时间显示功能
- 在SingerDTO中新增collectTime字段用于显示收藏时间
- 修改SingerServiceImpl中的分页查询逻辑以填充收藏时间信息
- 修复UserCollectPlaylist实体中createTime字段的数据库映射注解
- 扩展UserSingerRepository添加findByUserIdAndSingerId方法支持收藏记录查询
2026-03-21 23:21:11 +08:00
ikmkj
f5d262f4fc refactor(admin): 重构歌单管理功能提升代码质量
- 移除未使用的 computed 导入语句
- 添加错误处理工具 showErrorOnce 的导入
- 新增 playlistMusics 响应式变量存储歌单音乐数据
- 优化歌单详情加载逻辑,完善异常情况的数据重置
- 在音乐添加和移除操作后同步更新歌单列表数据
- 添加统一的错误处理机制替代原有 console.error
- 重构歌单权限校验逻辑,提取为独立的验证方法
- 增强管理员权限支持,允许后台管理员维护用户歌单
2026-03-21 22:03:11 +08:00
ikmkj
f0fa9cf67d feat(playlist): 添加歌单封面上传功能
- 在编辑表单中新增 coverFile 字段用于存储封面文件
- 移除原有的头像上传URL和请求头配置
- 实现封面预览功能,支持本地图片预览
- 修改歌单更新接口调用,支持封面文件参数传递
- 替换原有的封面上传成功回调为封面选择处理逻辑
- 更新上传组件配置,使用手动上传模式并支持图片预览
- 优化封面上传前验证和错误处理机制
2026-03-11 14:12:55 +08:00
ikmkj
bfb401cac6 fix(playlist): 解决歌单详情更新后封面缓存问题
- 在歌单更新成功后添加 await 确保数据同步
- 实现封面图片时间戳缓存破除机制
- 检测封面 URL 是否已存在查询参数并正确拼接时间戳
- 防止浏览器缓存导致的封面更新不及时问题
2026-03-11 13:36:08 +08:00
ikmkj
c7588d3de8 feat(comment): 实现评论删除的WebSocket实时同步功能
- 后端服务删除评论时通过WebSocket广播删除事件
- 前端音乐详情页接收删除消息并从列表中移除对应评论
- 前端歌单详情页支持删除事件处理并更新评论计数
- 前端歌手详情页实现删除事件处理和评论计数更新
- 支持父子评论层级结构的删除操作
- 统一WebSocket消息格式包含删除状态和评论ID信息
2026-03-11 13:33:53 +08:00
ikmkj
4595cd59e9 style(playlist): 优化播放列表详情页评论样式
- 调整评论输入区域的间距和对齐方式
- 移除按钮悬停效果并简化样式
- 更新评论项目的边框和底部间距
- 修改用户名字体颜色和粗细
- 添加子评论列表的缩进和边框样式
- 创建评论底部操作区域的布局结构
- 简化评论操作按钮的过渡效果
2026-03-11 00:13:04 +08:00
ikmkj
b29cc352cc fix(login): 修复登录错误处理并统一后端异常抛出
- 前端登录失败时添加错误处理标识检查,避免重复显示错误消息
- 后端服务层统一使用BusinessException替代RuntimeException
- 用户注册时用户名、邮箱、密码验证失败统一抛出业务异常
- 用户登录时用户名密码错误、账号禁用情况统一抛出业务异常
- 用户查询、更新、删除操作时用户不存在情况统一抛出业务异常
- 密码修改时旧密码错误、密码不一致情况统一抛出业务异常
2026-03-11 00:05:46 +08:00
ikmkj
efccff567a feat(admin): 添加管理员面板路由跳转功能
- 引入 vue-router 的 useRouter 钩子
- 在组件中初始化路由器实例
- 为最新用户卡片添加路由跳转功能
- 为最新音乐卡片添加路由跳转功能
- 点击查看更多按钮可跳转到对应管理页面
2026-03-10 23:15:53 +08:00
root
f27a4cc7ec 增强音乐页多类型分类筛选兼容性 2026-03-08 23:38:00 +08:00
root
23a2090644 修复音乐页热门与最新分类筛选不生效 2026-03-08 23:32:46 +08:00
root
322ab81643 修复音乐页三标签筛选与搜索一致性 2026-03-08 23:07:34 +08:00
root
8aca3ddf75 修复音乐/歌单/歌手页搜索清空与标签筛选逻辑 2026-03-08 22:56:55 +08:00
root
af9bc8f21e fix(frontend): 收敛重复错误弹窗并统一去重处理 2026-03-08 15:10:09 +08:00
root
1a00afd628 fix(comment): align houbb sensitive-word integration with official usage 2026-03-08 15:02:15 +08:00
root
e2e5d1a3a4 fix(deps): use correct houbb sensitive-word artifact 2026-03-08 14:57:44 +08:00
root
b96a39ff04 fix: 使用SensitiveWordBs替代旧Helper导入 2026-03-08 14:53:51 +08:00
root
d116403db4 refactor(comment): migrate sensitive word filter to houbb sensitive-word 2026-03-08 14:51:22 +08:00
root
bd5af140fe feat(comment): 新增敏感词过滤并拦截评论发送 2026-03-08 14:43:58 +08:00
root
1383c4a7de fix(admin-user): refresh avatar preview after remove and re-upload 2026-03-08 14:35:51 +08:00
root
63d71c01dd feat(admin-backend): add user avatar upload endpoint for admins 2026-03-08 14:27:17 +08:00
root
d214ed3d71 feat(admin-frontend): support avatar file upload in user edit dialog 2026-03-08 14:27:13 +08:00
root
e46cde6daa feat(ui): move music like/collect totals into action buttons 2026-03-08 14:20:04 +08:00
root
414d214fa9 fix(admin-user): replace fragile avatar url regex with robust validator 2026-03-08 14:09:13 +08:00
root
24eb4ec5d6 fix(music-detail): show global like/collect counts on music detail page 2026-03-08 14:09:10 +08:00
ikmkj
6f0e59cd28 config(database): 更新数据库连接URL配置
- 移除过时的字符编码和时区参数
- 添加allowPublicKeyRetrieval=true参数以解决公钥检索问题
- 简化数据库连接URL配置项
2026-03-08 13:38:41 +08:00
46 changed files with 1610 additions and 922 deletions

View File

@@ -104,6 +104,13 @@
<version>5.8.22</version>
</dependency>
<!-- houbb 敏感词过滤 -->
<dependency>
<groupId>com.github.houbb</groupId>
<artifactId>sensitive-word</artifactId>
<version>0.29.4</version>
</dependency>
<!-- 工具类 -->
<dependency>
<groupId>org.apache.commons</groupId>

View File

@@ -12,6 +12,7 @@ import java.time.format.DateTimeFormatter;
@JsonComponent
public class JacksonConfig {
// 全局默认使用短格式(仅日期),避免影响管理后台等只需日期的场景
private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
public static class LocalDateTimeSerializer extends JsonSerializer<LocalDateTime> {

View File

@@ -1,13 +1,12 @@
package com.test.musichouduan.controller;
import cn.dev33.satoken.annotation.SaCheckLogin;
import cn.dev33.satoken.annotation.SaCheckRole;
import cn.dev33.satoken.annotation.SaIgnore;
import cn.dev33.satoken.stp.StpUtil;
import com.test.musichouduan.dto.CommentDTO;
import com.test.musichouduan.dto.PageResult;
import com.test.musichouduan.dto.Result;
import com.test.musichouduan.service.CommentService;
import com.test.musichouduan.service.MusicService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
@@ -21,6 +20,9 @@ public class CommentController {
@Autowired
private CommentService commentService;
@Autowired
private MusicService musicService;
/**
* 根据ID获取评论
*
@@ -62,6 +64,9 @@ public class CommentController {
@PostMapping("/add")
public Result<CommentDTO> addComment(@RequestBody CommentDTO commentDTO) {
CommentDTO result = commentService.addComment(commentDTO);
if (commentDTO.getTargetType() != null && commentDTO.getTargetType() == 1) {
musicService.updateHotMusicCache();
}
return Result.success("评论成功", result);
}
@@ -74,7 +79,11 @@ public class CommentController {
@SaCheckLogin
@DeleteMapping("/{id}")
public Result<Boolean> deleteComment(@PathVariable Long id) {
CommentDTO commentDTO = commentService.getCommentById(id);
boolean result = commentService.deleteComment(id);
if (commentDTO.getTargetType() != null && commentDTO.getTargetType() == 1) {
musicService.updateHotMusicCache();
}
return Result.success("删除成功", result);
}

View File

@@ -166,6 +166,7 @@ public class MusicController {
@RequestPart(value = "cover", required = false) MultipartFile cover,
@RequestPart(value = "lyric", required = false) MultipartFile lyric) {
MusicDTO result = musicService.updateMusic(id, musicDTO, file, cover, lyric);
musicService.updateHotMusicCache();
return Result.success("更新成功", result);
}
@@ -195,6 +196,7 @@ public class MusicController {
// 获取当前登录用户ID
Long userId = StpUtil.getLoginIdAsLong();
boolean isCollected = musicService.toggleCollectMusic(id, userId);
musicService.updateHotMusicCache();
String message = isCollected ? "收藏成功" : "取消收藏成功";
return Result.success(message, isCollected);
}
@@ -227,6 +229,9 @@ public class MusicController {
@PostMapping("/{id}/play")
public Result<MusicDTO> playMusic(@PathVariable Long id) {
MusicDTO musicDTO = musicService.playMusic(id);
if (musicDTO != null) {
musicService.updateHotMusicCache();
}
return Result.success(musicDTO);
}
@@ -242,6 +247,7 @@ public class MusicController {
// 获取当前登录用户ID
Long userId = StpUtil.getLoginIdAsLong();
boolean isLiked = musicService.toggleLikeMusic(id, userId);
musicService.updateHotMusicCache();
String message = isLiked ? "点赞成功" : "取消点赞成功";
return Result.success(message, isLiked);
}

View File

@@ -207,6 +207,27 @@ public class UserController {
return Result.success("更新成功", userDTO);
}
/**
* 管理员上传指定用户头像
*
* @param id 用户ID
* @param file 头像文件
* @return 响应结果
*/
@Operation(summary = "管理员上传用户头像", description = "管理员上传指定用户头像并更新用户信息,需要管理员权限")
@SaCheckRole("admin")
@PostMapping("/{id}/upload/avatar")
public Result<String> adminUploadUserAvatar(
@Parameter(description = "用户ID", example = "1") @PathVariable Long id,
@Parameter(description = "头像文件") @RequestParam("file") MultipartFile file) {
String avatarPhysicalPath = fileService.uploadImage(file);
String avatarUrl = avatarPhysicalPath.replace(System.getProperty("user.dir") + File.separator + "music-houduan", "").replace(File.separator, "/");
userService.adminUpdateUserAvatar(id, avatarUrl);
return Result.success("上传成功", avatarUrl);
}
/**
* 上传用户头像
*

View File

@@ -54,6 +54,16 @@ public class MusicDTO {
*/
private Long playCount;
/**
* 点赞数
*/
private Integer likeCount;
/**
* 收藏数
*/
private Integer collectCount;
/**
* 歌手列表
*/

View File

@@ -70,6 +70,13 @@ public class SingerDTO {
*/
private Boolean isCollected;
/**
* 收藏时间
*/
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@JsonDeserialize(using = LocalDateTimeDeserializer.class)
private LocalDateTime collectTime;
/**
* 创建时间
*/

View File

@@ -36,6 +36,6 @@ public class UserCollectPlaylist {
* 收藏时间
*/
@CreationTimestamp
@Column(nullable = false, updatable = false)
@Column(name = "create_time", nullable = false, updatable = false)
private LocalDateTime createTime;
}

View File

@@ -78,7 +78,7 @@ public interface MusicRepository extends JpaRepository<Music, Long>, JpaSpecific
* @param pageable 分页
* @return 音乐列表
*/
@Query("SELECT m FROM Music m ORDER BY m.createTime DESC")
@Query("SELECT m FROM Music m ORDER BY m.createTime DESC, m.id DESC")
List<Music> findLatest(Pageable pageable);
/**
@@ -87,7 +87,7 @@ public interface MusicRepository extends JpaRepository<Music, Long>, JpaSpecific
* @param pageable 分页
* @return 音乐列表
*/
@Query("SELECT m FROM Music m ORDER BY m.playCount DESC")
@Query("SELECT m FROM Music m ORDER BY m.playCount DESC, m.createTime DESC, m.id DESC")
List<Music> findMostPlayed(Pageable pageable);
/**

View File

@@ -3,19 +3,15 @@ package com.test.musichouduan.repository;
import com.test.musichouduan.entity.Playlist;
import com.test.musichouduan.entity.User;
import com.test.musichouduan.entity.UserCollectPlaylist;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
/**
@@ -89,7 +85,7 @@ public interface UserCollectPlaylistRepository extends JpaRepository<UserCollect
@Modifying
@Transactional
@Query(value = "INSERT INTO user_collect_playlist (user_id, playlist_id, collect_time) VALUES (?1, ?2, NOW())", nativeQuery = true)
@Query(value = "INSERT INTO user_collect_playlist (user_id, playlist_id, create_time) VALUES (?1, ?2, NOW())", nativeQuery = true)
void collectPlaylist(Long userId, Long playlistId);
@Modifying
@@ -97,7 +93,10 @@ public interface UserCollectPlaylistRepository extends JpaRepository<UserCollect
@Query(value = "DELETE FROM user_collect_playlist WHERE user_id = ?1 AND playlist_id = ?2", nativeQuery = true)
void uncollectPlaylist(Long userId, Long playlistId);
@Query("SELECT ucp.playlist FROM UserCollectPlaylist ucp WHERE ucp.user.id = ?1")
@Query(
value = "SELECT ucp.playlist FROM UserCollectPlaylist ucp WHERE ucp.user.id = ?1 ORDER BY ucp.createTime DESC",
countQuery = "SELECT COUNT(ucp) FROM UserCollectPlaylist ucp WHERE ucp.user.id = ?1"
)
Page<Playlist> findCollectedPlaylist(Long userId, Pageable pageable);
Optional<UserCollectPlaylist> findByUserIdAndPlaylistId(Long userId, Long playlistId);

View File

@@ -12,6 +12,8 @@ import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
/**
* 用户歌手关联数据访问层
*/
@@ -86,4 +88,13 @@ public interface UserSingerRepository extends JpaRepository<UserSinger, Long> {
@Transactional
@Query("DELETE FROM UserSinger us WHERE us.singer.id = :singerId")
void deleteBySingerId(@Param("singerId") Long singerId);
/**
* 根据用户ID和歌手ID查询收藏记录
*
* @param userId 用户ID
* @param singerId 歌手ID
* @return 收藏记录
*/
Optional<UserSinger> findByUserIdAndSingerId(Long userId, Long singerId);
}

View File

@@ -0,0 +1,14 @@
package com.test.musichouduan.service;
/**
* 敏感词服务接口
*/
public interface SensitiveWordService {
/**
* 校验评论内容是否包含敏感词
*
* @param content 评论内容
*/
void validateCommentContent(String content);
}

View File

@@ -107,6 +107,15 @@ public interface UserService {
*/
UserDTO adminUpdateUser(Long id, UserUpdateRequest request);
/**
* 管理员上传并更新指定用户头像
*
* @param id 用户ID
* @param avatar 头像地址
* @return 用户DTO
*/
UserDTO adminUpdateUserAvatar(Long id, String avatar);
/**
* 根据ID获取用户实体
*

View File

@@ -14,6 +14,7 @@ import com.test.musichouduan.repository.PlaylistRepository;
import com.test.musichouduan.repository.SingerRepository;
import com.test.musichouduan.repository.UserRepository;
import com.test.musichouduan.service.CommentService;
import com.test.musichouduan.service.SensitiveWordService;
import org.springframework.messaging.simp.SimpMessagingTemplate; // 添加导入
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
@@ -27,7 +28,9 @@ import org.springframework.transaction.annotation.Transactional;
import jakarta.persistence.criteria.Predicate;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
@@ -55,6 +58,9 @@ public class CommentServiceImpl implements CommentService {
@Autowired
private SimpMessagingTemplate messagingTemplate; // 注入 SimpMessagingTemplate
@Autowired
private SensitiveWordService sensitiveWordService; // 注入敏感词服务
@Override
public CommentDTO getCommentById(Long id) {
Comment comment = commentRepository.findById(id)
@@ -114,6 +120,9 @@ public class CommentServiceImpl implements CommentService {
User user = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException("用户不存在"));
// 保存前统一校验评论内容,覆盖音乐/歌单/歌手评论及回复评论
sensitiveWordService.validateCommentContent(commentDTO.getContent());
// 创建评论实体
Comment comment = new Comment();
comment.setContent(commentDTO.getContent());
@@ -127,33 +136,7 @@ public class CommentServiceImpl implements CommentService {
Comment savedComment = commentRepository.save(comment);
// 如果评论的是音乐 (targetType=1, 这个值需要与前端约定好)
// 通常可以定义一个枚举或常量类来管理这些类型值
if (savedComment.getTargetType() == 1) {
musicRepository.findById(savedComment.getTargetId()).ifPresent(music -> {
// 首先判断类型是否等于1
if (savedComment.getTargetType() == 1) {
// 查询评论表使用COUNT(id)统计该音乐的评论数量
long commentCount = commentRepository.countByTargetIdAndTargetType(savedComment.getTargetId(), 1);
// 设置到音乐中
music.setCommentCount((int) commentCount);
// 保存音乐实体
musicRepository.save(music);
}
});
} else if (savedComment.getTargetType() == 2) { // 2 代表歌单
playlistRepository.findById(savedComment.getTargetId()).ifPresent(playlist -> {
long commentCount = commentRepository.countByTargetIdAndTargetType(savedComment.getTargetId(), 2);
playlist.setCommentCount((int) commentCount);
playlistRepository.save(playlist);
});
} else if (savedComment.getTargetType() == 3) { // 3 代表歌手
singerRepository.findById(savedComment.getTargetId()).ifPresent(singer -> {
long commentCount = commentRepository.countByTargetIdAndTargetType(savedComment.getTargetId(), 3);
singer.setCommentCount((int) commentCount);
singerRepository.save(singer);
});
}
updateTargetCommentCount(savedComment.getTargetType(), savedComment.getTargetId());
// 将新评论转换为DTO
CommentDTO savedCommentDTO = convertToDTO(savedComment);
@@ -185,27 +168,6 @@ public class CommentServiceImpl implements CommentService {
// 删除评论
commentRepository.delete(comment);
// 更新相关实体的评论数
if (comment.getTargetType() == 1) {
musicRepository.findById(comment.getTargetId()).ifPresent(music -> {
long commentCount = commentRepository.countByTargetIdAndTargetType(comment.getTargetId(), 1);
music.setCommentCount((int) commentCount);
musicRepository.save(music);
});
} else if (comment.getTargetType() == 2) {
playlistRepository.findById(comment.getTargetId()).ifPresent(playlist -> {
long commentCount = commentRepository.countByTargetIdAndTargetType(comment.getTargetId(), 2);
playlist.setCommentCount((int) commentCount);
playlistRepository.save(playlist);
});
} else if (comment.getTargetType() == 3) {
singerRepository.findById(comment.getTargetId()).ifPresent(singer -> {
long commentCount = commentRepository.countByTargetIdAndTargetType(comment.getTargetId(), 3);
singer.setCommentCount((int) commentCount);
singerRepository.save(singer);
});
}
// 如果是父评论,同时删除子评论
if (comment.getParentId() == null) {
List<Comment> children = commentRepository.findByParentIdOrderByCreateTimeAsc(id);
@@ -214,9 +176,45 @@ public class CommentServiceImpl implements CommentService {
}
}
// 删除完成后再同步评论数,避免父评论删除后子评论残留导致统计偏大
updateTargetCommentCount(comment.getTargetType(), comment.getTargetId());
// 通过 WebSocket 广播删除事件
String destination = String.format("/topic/comments/%d/%d", comment.getTargetType(), comment.getTargetId());
Map<String, Object> deleteEvent = new HashMap<>();
deleteEvent.put("deleted", true);
deleteEvent.put("id", id);
deleteEvent.put("parentId", comment.getParentId());
messagingTemplate.convertAndSend(destination, deleteEvent);
return true;
}
private void updateTargetCommentCount(Integer targetType, Long targetId) {
if (targetType == null || targetId == null) {
return;
}
long commentCount = commentRepository.countByTargetIdAndTargetType(targetId, targetType);
if (targetType == 1) {
musicRepository.findById(targetId).ifPresent(music -> {
music.setCommentCount((int) commentCount);
musicRepository.save(music);
});
} else if (targetType == 2) {
playlistRepository.findById(targetId).ifPresent(playlist -> {
playlist.setCommentCount((int) commentCount);
playlistRepository.save(playlist);
});
} else if (targetType == 3) {
singerRepository.findById(targetId).ifPresent(singer -> {
singer.setCommentCount((int) commentCount);
singerRepository.save(singer);
});
}
}
@Override
public PageResult<CommentDTO> getUserCommentList(Long userId, Integer page, Integer size) {
// 获取用户
@@ -336,6 +334,14 @@ public class CommentServiceImpl implements CommentService {
}
}
// 通过 WebSocket 广播删除事件
String destination = String.format("/topic/comments/%d/%d", comment.getTargetType(), comment.getTargetId());
Map<String, Object> deleteEvent = new HashMap<>();
deleteEvent.put("deleted", true);
deleteEvent.put("id", id);
deleteEvent.put("parentId", comment.getParentId());
messagingTemplate.convertAndSend(destination, deleteEvent);
return true;
}

View File

@@ -48,9 +48,9 @@ public class FileServiceImpl implements FileService {
// 检查文件类型
String originalFilename = file.getOriginalFilename();
String extension = getFileExtension(originalFilename);
List<String> imageTypes = Arrays.asList("jpg", "jpeg", "png", "gif");
List<String> imageTypes = Arrays.asList("jpg", "jpeg", "png", "gif", "webp");
if (!imageTypes.contains(extension.toLowerCase())) {
throw new BusinessException("只能上传jpg、jpeg、png、gif格式的图片");
throw new BusinessException("只能上传jpg、jpeg、png、gif、webp格式的图片");
}
// 上传文件

View File

@@ -243,6 +243,14 @@ public class MusicServiceImpl implements MusicService {
public CompletableFuture<List<MusicDTO>> updateHotMusicCache() {
// 使用 JOIN FETCH 一次性加载所有音乐及其关联的歌手信息
List<Music> allMusic = musicRepository.findAllWithSingers();
Comparator<Music> hotMusicComparator = Comparator
.comparing((Music music) -> music.getHotScore() != null ? music.getHotScore() : 0D, Comparator.reverseOrder())
.thenComparing(music -> music.getPlayCount() != null ? music.getPlayCount() : 0L, Comparator.reverseOrder())
.thenComparing(music -> music.getCollectCount() != null ? music.getCollectCount() : 0, Comparator.reverseOrder())
.thenComparing(music -> music.getLikeCount() != null ? music.getLikeCount() : 0, Comparator.reverseOrder())
.thenComparing(music -> music.getCommentCount() != null ? music.getCommentCount() : 0, Comparator.reverseOrder())
.thenComparing(Music::getCreateTime, Comparator.reverseOrder())
.thenComparing(Music::getId, Comparator.reverseOrder());
// 使用并行流来加速计算过程
allMusic.parallelStream().forEach(music -> {
@@ -252,7 +260,7 @@ public class MusicServiceImpl implements MusicService {
// 对所有音乐按计算出的热度分进行降序排序
List<MusicDTO> sortedHotMusic = allMusic.stream()
.sorted(Comparator.comparing(Music::getHotScore).reversed())
.sorted(hotMusicComparator)
.limit(HOT_MUSIC_LIMIT) // 取热度最高的前50
.map(this::convertToDTO)
.collect(Collectors.toList());
@@ -402,7 +410,10 @@ public class MusicServiceImpl implements MusicService {
// 更新音乐信息
music.setName(musicDTO.getName());
music.setAlbum(musicDTO.getAlbum());
music.setDuration(musicDTO.getDuration());
// 仅当 DTO 中有值时才覆盖时长,避免编辑时被清空
if (musicDTO.getDuration() != null) {
music.setDuration(musicDTO.getDuration());
}
// 更新音乐类型
if (musicDTO.getTypeIds() != null) {
@@ -650,6 +661,10 @@ public class MusicServiceImpl implements MusicService {
MusicDTO musicDTO = new MusicDTO();
BeanUtils.copyProperties(music, musicDTO);
// 兜底互动计数,避免前端出现 null
musicDTO.setLikeCount(music.getLikeCount() != null ? music.getLikeCount() : 0);
musicDTO.setCollectCount(music.getCollectCount() != null ? music.getCollectCount() : 0);
// 确保专辑字段正确复制
musicDTO.setAlbum(music.getAlbum());

View File

@@ -0,0 +1,30 @@
package com.test.musichouduan.service.impl;
import com.github.houbb.sensitive.word.core.SensitiveWordHelper;
import com.test.musichouduan.exception.BusinessException;
import com.test.musichouduan.service.SensitiveWordService;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 敏感词服务实现
*/
@Service
public class SensitiveWordServiceImpl implements SensitiveWordService {
@Override
public void validateCommentContent(String content) {
if (content == null || content.trim().isEmpty()) {
return;
}
// 按官方 README 常见用法,直接使用 SensitiveWordHelper 进行检测。
if (SensitiveWordHelper.contains(content)) {
List<String> hitWords = SensitiveWordHelper.findAll(content);
if (!hitWords.isEmpty()) {
throw new BusinessException("评论包含敏感词,请修改后再发布");
}
}
}
}

View File

@@ -385,9 +385,15 @@ public class SingerServiceImpl implements SingerService {
PageRequest.of(page - 1, size, Sort.by(Sort.Direction.DESC, "collectTime"))
);
// 转换为DTO
// 转换为DTO,并填充收藏时间
List<SingerDTO> singerDTOs = singerPage.getContent().stream()
.map(this::convertToDTO)
.map(singer -> {
SingerDTO dto = convertToDTO(singer);
// 从收藏记录中获取收藏时间
userSingerRepository.findByUserIdAndSingerId(userId, singer.getId())
.ifPresent(us -> dto.setCollectTime(us.getCollectTime()));
return dto;
})
.collect(Collectors.toList());
return new PageResult<>(

View File

@@ -4,6 +4,7 @@ import cn.dev33.satoken.secure.BCrypt;
import cn.dev33.satoken.stp.StpUtil;
import com.test.musichouduan.dto.*;
import com.test.musichouduan.entity.User;
import com.test.musichouduan.exception.BusinessException;
import com.test.musichouduan.repository.*;
import com.test.musichouduan.service.UserService;
import org.springframework.beans.BeanUtils;
@@ -49,17 +50,17 @@ public class UserServiceImpl implements UserService {
public UserDTO register(RegisterRequest request) {
// 检查用户名是否已存在
if (userRepository.existsByUsername(request.getUsername())) {
throw new RuntimeException("用户名已存在");
throw new BusinessException(400,"用户名已存在");
}
// 检查邮箱是否已存在
if (userRepository.existsByEmail(request.getEmail())) {
throw new RuntimeException("邮箱已存在");
throw new BusinessException(400,"邮箱已存在");
}
// 检查两次密码是否一致
if (!request.getPassword().equals(request.getConfirmPassword())) {
throw new RuntimeException("两次密码不一致");
throw new BusinessException(400,"两次密码不一致");
}
// 创建用户
@@ -83,16 +84,16 @@ public class UserServiceImpl implements UserService {
public LoginResultDTO login(LoginRequest request) {
// 根据用户名查找用户
User user = userRepository.findByUsername(request.getUsername())
.orElseThrow(() -> new RuntimeException("用户名或密码错误"));
.orElseThrow(() -> new BusinessException(400,"用户名或密码错误"));
// 检查用户状态
if (user.getStatus() == 0) {
throw new RuntimeException("账号已被禁用");
throw new BusinessException(400,"账号已被禁用");
}
// 检查密码
if (!BCrypt.checkpw(request.getPassword(), user.getPassword())) {
throw new RuntimeException("用户名或密码错误");
throw new BusinessException(400,"用户名或密码错误");
}
// 登录
@@ -126,7 +127,7 @@ public class UserServiceImpl implements UserService {
// 根据ID查找用户
User user = userRepository.findById(userId)
.orElseThrow(() -> new RuntimeException("用户不存在"));
.orElseThrow(() -> new BusinessException(400,"用户不存在"));
// 返回用户DTO
return getUserStats(userId);
@@ -136,7 +137,7 @@ public class UserServiceImpl implements UserService {
public UserDTO getUserById(Long id) {
// 根据ID查找用户
User user = userRepository.findById(id)
.orElseThrow(() -> new RuntimeException("用户不存在"));
.orElseThrow(() -> new BusinessException(400,"用户不存在"));
// 返回用户DTO
return convertToDTO(user);
@@ -146,7 +147,7 @@ public class UserServiceImpl implements UserService {
public UserDTO getUserByUsername(String username) {
// 根据用户名查找用户
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new RuntimeException("用户不存在"));
.orElseThrow(() -> new BusinessException(400,"用户不存在"));
// 返回用户DTO
return convertToDTO(user);
@@ -160,7 +161,7 @@ public class UserServiceImpl implements UserService {
// 根据ID查找用户
User user = userRepository.findById(userId)
.orElseThrow(() -> new RuntimeException("用户不存在"));
.orElseThrow(() -> new BusinessException(400,"用户不存在"));
// 更新用户信息
if (request.getNickname() != null) {
@@ -197,16 +198,16 @@ public class UserServiceImpl implements UserService {
// 根据ID查找用户
User user = userRepository.findById(userId)
.orElseThrow(() -> new RuntimeException("用户不存在"));
.orElseThrow(() -> new BusinessException(400,"用户不存在"));
// 检查旧密码
if (!BCrypt.checkpw(request.getOldPassword(), user.getPassword())) {
throw new RuntimeException("旧密码错误");
throw new BusinessException(400,"旧密码错误");
}
// 检查两次密码是否一致
if (!request.getNewPassword().equals(request.getConfirmPassword())) {
throw new RuntimeException("两次密码不一致");
throw new BusinessException(400,"两次密码不一致");
}
// 更新密码
@@ -222,7 +223,7 @@ public class UserServiceImpl implements UserService {
public UserDTO getUserStats(Long userId) {
// 根据ID查找用户
User user = userRepository.findById(userId)
.orElseThrow(() -> new RuntimeException("用户不存在"));
.orElseThrow(() -> new BusinessException(400,"用户不存在"));
// 转换为DTO
UserDTO userDTO = convertToDTO(user);
@@ -274,7 +275,7 @@ public class UserServiceImpl implements UserService {
public boolean deleteUser(Long id) {
// 检查用户是否存在
if (!userRepository.existsById(id)) {
throw new RuntimeException("用户不存在");
throw new BusinessException(400,"用户不存在");
}
// 删除用户
@@ -288,7 +289,7 @@ public class UserServiceImpl implements UserService {
public boolean updateUserStatus(Long id, Integer status) {
// 根据ID查找用户
User user = userRepository.findById(id)
.orElseThrow(() -> new RuntimeException("用户不存在"));
.orElseThrow(() -> new BusinessException(400,"用户不存在"));
// 更新状态
user.setStatus(status);
@@ -329,10 +330,19 @@ public class UserServiceImpl implements UserService {
return convertToDTO(savedUser);
}
@Override
@Transactional
public UserDTO adminUpdateUserAvatar(Long id, String avatar) {
User user = getById(id);
user.setAvatar(avatar);
User savedUser = userRepository.save(user);
return convertToDTO(savedUser);
}
@Override
public User getById(Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new RuntimeException("用户不存在"));
.orElseThrow(() -> new BusinessException(400,"用户不存在"));
}
/**

View File

@@ -9,6 +9,8 @@ import java.time.LocalDateTime;
*/
public class HotScoreUtil {
// 播放量采用对数缩放,避免高播放量对热门榜产生碾压
private static final double PLAY_WEIGHT = 1.2;
// 定义各种交互的权重
private static final double LIKE_WEIGHT = 1.0;
private static final double COLLECT_WEIGHT = 2.0;
@@ -27,17 +29,24 @@ public class HotScoreUtil {
public static double calculateHotScore(Music music) {
// 1. 计算交互权重和
// 如果交互数据为 null则默认为 0
long playCount = music.getPlayCount() != null ? music.getPlayCount() : 0;
long likeCount = music.getLikeCount() != null ? music.getLikeCount() : 0;
long collectCount = music.getCollectCount() != null ? music.getCollectCount() : 0;
long commentCount = music.getCommentCount() != null ? music.getCommentCount() : 0;
double interactionScore = (likeCount * LIKE_WEIGHT) +
double playScore = Math.log1p(playCount) * PLAY_WEIGHT;
double interactionScore = playScore +
(likeCount * LIKE_WEIGHT) +
(collectCount * COLLECT_WEIGHT) +
(commentCount * COMMENT_WEIGHT);
// 2. 计算时间衰减因子
// (当前时间 - 创建时间)的小时数
LocalDateTime createTime = music.getCreateTime();
if (createTime == null) {
createTime = LocalDateTime.now();
}
LocalDateTime now = LocalDateTime.now();
long hoursElapsed = Duration.between(createTime, now).toHours();

View File

@@ -12,7 +12,7 @@ spring:
# 数据库配置
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/music_db?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=Asia/Shanghai
url: jdbc:mysql://127.0.0.1:3306/music_db?useSSL=false&allowPublicKeyRetrieval=true
username: root
password: 123456
hikari:

View File

@@ -0,0 +1,3 @@
# 敏感词词库(每行一个,支持后续自行维护)
# 示例词条:
# 坏词示例

View File

@@ -67,3 +67,20 @@ export function updateUser(data) {
export function adminUpdateUser(id, data) {
return api.put(`/user/${id}`, data)
}
/**
* 管理员上传指定用户头像
* @param {number} id 用户ID
* @param {File} file 头像文件
* @returns {Promise<any>}
*/
export function adminUploadUserAvatar(id, file) {
const formData = new FormData()
formData.append('file', file)
return api.post(`/user/${id}/upload/avatar`, formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
}

View File

@@ -89,7 +89,12 @@ api.interceptors.response.use(
// 其他错误
ElMessage.error(res.message || '未知错误')
}
return Promise.reject(new Error(res.message || '未知错误'))
const businessError = new Error(res.message || '未知错误')
businessError.code = res.code
businessError.message = res.message || '未知错误'
// 标记为已提示,避免页面 catch 再次弹窗
businessError.__handled = true
return Promise.reject(businessError)
} else {
return res
}
@@ -111,11 +116,16 @@ api.interceptors.response.use(
// 处理网络错误
if (error.message && error.message.includes('timeout')) {
ElMessage.error('请求超时,请稍后再试')
error.message = '请求超时,请稍后再试'
} else if (error.message && error.message.includes('Network Error')) {
ElMessage.error('网络错误,请检查网络连接')
error.message = '网络错误,请检查网络连接'
} else {
ElMessage.error(error.message || '未知错误')
error.message = error.message || '未知错误'
}
// 标记为已提示,避免页面 catch 再次弹窗
error.__handled = true
return Promise.reject(error)
}
)

View File

@@ -39,7 +39,7 @@ export function getCurrentUser() {
* @returns {Promise} 请求结果
*/
export function getUserInfo() {
return api.get('/user/profile')
return api.get('/user/current')
}
/**

View File

@@ -508,7 +508,11 @@ const toggleLike = async () => {
// 播放列表相关
const togglePlaylist = () => playerStore.setShowPlaylist(!showPlaylist.value)
const playFromPlaylist = (index) => playerStore.playFromPlaylist(index)
const removeFromPlaylist = (index) => playerStore.removeFromPlaylist(index)
const removeFromPlaylist = (index) => {
const music = playlist.value[index]
if (!music) return
playerStore.removeFromPlaylist(music.id)
}
const clearPlaylist = () => playerStore.clearPlaylist()
// 展开/收起播放器

View File

@@ -3,7 +3,7 @@ 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 { Search, ArrowDown } from '@element-plus/icons-vue'
import MusicPlayer from '../components/MusicPlayer.vue'
const router = useRouter()

View File

@@ -173,8 +173,17 @@ export const usePlayerStore = defineStore('player', {
// 清空播放列表
clearPlaylist() {
if (this.audio) {
this.audio.pause()
this.audio.src = ''
}
this.playlist = []
this.setCurrentMusic(null)
this.currentMusic = null
this.isPlaying = false
this.currentTime = 0
this.duration = 0
this.currentIndex = -1
},
// 播放上一首

View File

@@ -0,0 +1,16 @@
import { ElMessage } from 'element-plus'
/**
* 统一错误提示入口,避免拦截器和页面重复弹窗
* @param {Error|Object|string} error 错误对象
* @param {string} fallbackMsg 兜底提示文案
*/
export const showErrorOnce = (error, fallbackMsg = '操作失败,请稍后重试') => {
// 已在请求拦截器中提示过,则直接跳过
if (error && error.__handled) {
return
}
const message = error?.message || fallbackMsg
ElMessage.error(message)
}

View File

@@ -13,6 +13,14 @@ import { processImageUrl } from '../utils/imageUtils'
const router = useRouter()
const playerStore = usePlayerStore()
const extractList = (data) => {
if (Array.isArray(data)) return data
if (Array.isArray(data?.list)) return data.list
if (Array.isArray(data?.records)) return data.records
if (Array.isArray(data?.items)) return data.items
return []
}
// 新歌推荐
const newSongs = ref([])
const loadingNewSongs = ref(false)
@@ -35,7 +43,7 @@ const fetchLatestMusic = async () => {
try {
const res = await getLatestMusic(5) // 获取5首最新音乐
if (res.code === 200) {
newSongs.value = res.data.map(item => ({
newSongs.value = extractList(res.data).map(item => ({
...item,
cover: processMusicUrl(item.cover),
url: processMusicUrl(item.url)
@@ -59,7 +67,7 @@ const fetchHotMusic = async () => {
// 注意:这里调用的是新的 hot-music API
const res = await getHotMusic(5) // 获取5首热门音乐
if (res.code === 200) {
hotSongs.value = res.data.map(item => ({
hotSongs.value = extractList(res.data).map(item => ({
...item,
cover: processMusicUrl(item.cover),
url: processMusicUrl(item.url)
@@ -81,7 +89,7 @@ const fetchLatestPlaylist = async () => {
try {
const res = await getLatestPlaylist(5) // 获取5个最新歌单
if (res.code === 200) {
newPlaylists.value = res.data
newPlaylists.value = extractList(res.data)
console.log('最新歌单数据:', newPlaylists.value)
} else {
ElMessage.error(res.message || '获取最新歌单失败')
@@ -100,7 +108,7 @@ const fetchLatestSinger = async () => {
try {
const res = await getLatestSinger(5) // 获取5个最新歌手
if (res.code === 200) {
newSingers.value = res.data.map(item => ({
newSingers.value = extractList(res.data).map(item => ({
...item,
avatar: processImageUrl(item.avatar)
}))

View File

@@ -81,7 +81,9 @@ const handleLogin = () => {
}
} catch (error) {
console.error('登录失败:', error)
ElMessage.error(error.message || '登录失败,请检查用户名和密码')
if (!error.__handled) {
ElMessage.error(error.message || '登录失败,请检查用户名和密码')
}
} finally {
loading.value = false
}

View File

@@ -1,5 +1,5 @@
<script setup>
import { ref, onMounted } from 'vue'
import { ref, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { Search } from '@element-plus/icons-vue'
import { getMusicList, getHotMusic, getLatestMusic, processMusicUrl, getMusicStatus } from '../api/music'
@@ -11,6 +11,22 @@ import { useUserStore } from '../stores/user'
const router = useRouter()
const userStore = useUserStore()
const extractList = (data) => {
if (Array.isArray(data)) return data
if (Array.isArray(data?.list)) return data.list
if (Array.isArray(data?.records)) return data.records
if (Array.isArray(data?.items)) return data.items
return []
}
const extractPageData = (data) => {
const list = extractList(data)
return {
list,
total: Number.isFinite(Number(data?.total)) ? Number(data.total) : list.length
}
}
// 音乐列表数据
const musicList = ref([])
const loading = ref(false)
@@ -25,11 +41,91 @@ const musicTypes = ref([])
// 标签页
const activeTab = ref('all')
const hotMusicSource = ref([])
const hotMusicList = ref([])
const latestMusicSource = ref([])
const latestMusicList = ref([])
const loadingHot = ref(false)
const loadingLatest = ref(false)
const getMusicTypeId = (music) => {
return music?.typeId ?? music?.musicTypeId ?? music?.type?.id ?? null
}
const normalizeTypeToken = (value) => {
if (value == null || value === '') return []
if (Array.isArray(value)) {
return value.flatMap(item => normalizeTypeToken(item))
}
if (typeof value === 'object') {
return normalizeTypeToken(value.id ?? value.typeId ?? value.name ?? value.typeName)
}
if (typeof value === 'string') {
return value
.split(/[,、|/]/)
.map(item => item.trim())
.filter(Boolean)
}
return [String(value)]
}
const getMusicTypeTokens = (music) => {
const sources = [
music?.typeId,
music?.musicTypeId,
music?.type?.id,
music?.typeName,
music?.type?.name,
music?.types,
music?.typeList,
music?.typeIds,
music?.typeNames,
music?.tags,
music?.labels
]
return sources.flatMap(source => normalizeTypeToken(source))
}
const applyMusicFilter = (list = []) => {
const normalizedKeyword = keyword.value.trim().toLowerCase()
const selectedTypeItem = musicTypes.value.find(item => item.id === selectedType.value)
const selectedTypeName = selectedTypeItem?.name || ''
return list.filter((music) => {
const musicTypeId = getMusicTypeId(music)
const musicTypeName = music?.typeName || music?.type?.name || ''
const typeTokens = getMusicTypeTokens(music)
const matchType = selectedType.value == null
|| (musicTypeId != null && Number(musicTypeId) === Number(selectedType.value))
|| typeTokens.some(token => Number(token) === Number(selectedType.value))
|| (selectedTypeName && typeTokens.includes(selectedTypeName))
if (!normalizedKeyword) {
return matchType
}
const searchText = [
music.name,
musicTypeName,
...typeTokens,
music.album,
music.singer,
...(Array.isArray(music.singerNames) ? music.singerNames : [])
]
.filter(Boolean)
.join(' ')
.toLowerCase()
return matchType && searchText.includes(normalizedKeyword)
})
}
// 获取音乐列表
const fetchMusicList = async (params = {}) => {
loading.value = true
@@ -39,11 +135,9 @@ const fetchMusicList = async (params = {}) => {
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)
const pageData = extractPageData(res.data)
musicList.value = pageData.list
total.value = pageData.total
// 确保音乐URL和封面URL正确处理
musicList.value.forEach(music => {
@@ -68,12 +162,30 @@ const fetchMusicList = async (params = {}) => {
}
}
const updateHotMusicView = (params = {}) => {
const filteredList = applyMusicFilter(hotMusicSource.value)
const page = params.page || currentPage.value
const size = params.size || pageSize.value
total.value = filteredList.length
const startIndex = (page - 1) * size
const endIndex = startIndex + size
hotMusicList.value = filteredList.slice(startIndex, endIndex)
}
const updateLatestMusicView = () => {
latestMusicList.value = applyMusicFilter(latestMusicSource.value)
}
// 获取音乐类型列表
const fetchMusicTypes = async () => {
try {
const res = await getMusicTypeList()
if (res.code === 200) {
musicTypes.value = res.data || []
musicTypes.value = [
{ id: null, name: '全部' },
...(res.data || [])
]
}
} catch (error) {
console.error('获取音乐类型失败:', error)
@@ -83,12 +195,51 @@ const fetchMusicTypes = async () => {
// 处理搜索
const handleSearch = () => {
currentPage.value = 1
if (activeTab.value === 'hot') {
updateHotMusicView({ page: 1, size: pageSize.value })
return
}
if (activeTab.value === 'latest') {
updateLatestMusicView()
return
}
fetchMusicList()
}
const handleKeywordClear = () => {
keyword.value = ''
currentPage.value = 1
if (activeTab.value === 'hot') {
updateHotMusicView({ page: 1, size: pageSize.value })
return
}
if (activeTab.value === 'latest') {
updateLatestMusicView()
return
}
fetchMusicList()
}
// 处理类型变化
const handleTypeChange = () => {
currentPage.value = 1
if (activeTab.value === 'hot') {
updateHotMusicView({ page: 1, size: pageSize.value })
return
}
if (activeTab.value === 'latest') {
updateLatestMusicView()
return
}
fetchMusicList()
}
@@ -126,25 +277,19 @@ const fetchHotMusic = async (params = {}) => {
// 从缓存中获取全部50条热门音乐
const res = await getHotMusic(50)
if (res.code === 200) {
const allHotMusic = res.data || []
total.value = allHotMusic.length // 总数是50
hotMusicSource.value = extractList(res.data)
// 处理URL
allHotMusic.forEach(music => {
hotMusicSource.value.forEach(music => {
if (music.url) music.url = processMusicUrl(music.url)
if (music.cover) music.cover = processMusicUrl(music.cover)
})
// 更新状态
await updateMusicStatuses(allHotMusic)
// 根据当前页码和每页数量进行前端分页
const page = params.page || currentPage.value
const size = params.size || pageSize.value
const startIndex = (page - 1) * size
const endIndex = startIndex + size
hotMusicList.value = allHotMusic.slice(startIndex, endIndex)
await updateMusicStatuses(hotMusicSource.value)
// 根据筛选和分页展示
updateHotMusicView(params)
} else {
ElMessage.error(res.message || '获取热门音乐失败')
}
@@ -162,10 +307,10 @@ const fetchLatestMusic = async () => {
try {
const res = await getLatestMusic(20) // 获取20首最新音乐
if (res.code === 200) {
latestMusicList.value = res.data || []
latestMusicSource.value = extractList(res.data)
// 处理音乐URL和封面URL
latestMusicList.value.forEach(music => {
latestMusicSource.value.forEach(music => {
if (music.url) {
music.url = processMusicUrl(music.url)
}
@@ -175,7 +320,8 @@ const fetchLatestMusic = async () => {
})
// 更新状态
updateMusicStatuses(latestMusicList.value)
await updateMusicStatuses(latestMusicSource.value)
updateLatestMusicView()
} else {
ElMessage.error(res.message || '获取最新音乐失败')
}
@@ -189,16 +335,34 @@ const fetchLatestMusic = async () => {
// 切换标签页
const handleTabChange = (tab) => {
currentPage.value = 1; // 重置页码
currentPage.value = 1 // 重置页码
if (tab.paneName === 'hot') {
fetchHotMusic();
fetchHotMusic({ page: 1, size: pageSize.value })
} else if (tab.paneName === 'latest') {
fetchLatestMusic();
fetchLatestMusic()
} else {
fetchMusicList();
fetchMusicList({ page: 1, size: pageSize.value })
}
}
watch(keyword, (val) => {
if (val === '') {
currentPage.value = 1
if (activeTab.value === 'hot') {
updateHotMusicView({ page: 1, size: pageSize.value })
return
}
if (activeTab.value === 'latest') {
updateLatestMusicView()
return
}
fetchMusicList({ page: 1, size: pageSize.value })
}
})
// 初始化
onMounted(() => {
fetchMusicList()
@@ -224,6 +388,7 @@ onMounted(() => {
v-model="keyword"
placeholder="搜索音乐"
clearable
@clear="handleKeywordClear"
@keyup.enter="handleSearch"
>
<template #suffix>
@@ -255,7 +420,7 @@ onMounted(() => {
:total="total"
v-model:current-page="currentPage"
v-model:page-size="pageSize"
@page-change="fetchHotMusic"
@page-change="updateHotMusicView"
@collect-change="handleCollectChange"
/>
</el-tab-pane>

View File

@@ -54,6 +54,7 @@
<span class="label">发行日期</span>
<span class="value">{{ music.releaseDate || '-' }}</span>
</div>
</div>
<div class="music-actions">
@@ -68,9 +69,9 @@
<el-button
:type="music.collected ? 'danger' : 'default'"
@click="toggleCollect"
:icon="music.collected ? 'Star' : 'StarFilled'"
:icon="music.collected ? 'StarFilled' : 'Star'"
>
{{ music.collected ? '已收藏' : '收藏' }}
{{ music.collected ? `已收藏 (${music.collectCount ?? 0})` : `收藏 (${music.collectCount ?? 0})` }}
</el-button>
<el-button
@@ -78,7 +79,7 @@
@click="toggleLike"
>
<el-icon><component :is="music.liked ? 'Check' : 'Pointer'" /></el-icon>
{{ music.liked ? '已点赞' : '点赞' }}
{{ music.liked ? `已点赞 (${music.likeCount ?? 0})` : `点赞 (${music.likeCount ?? 0})` }}
</el-button>
<el-button
@@ -262,6 +263,7 @@ import { connect, subscribe, unsubscribe, disconnect } from '../utils/websocket'
import { ElMessage, ElMessageBox } from 'element-plus'
import { VideoPlay, VideoPause, Star, StarFilled, Plus } from '@element-plus/icons-vue'
import { processImageUrl } from '../utils/imageUtils'
import { showErrorOnce } from '../utils/errorHandler'
const route = useRoute()
const router = useRouter()
@@ -321,7 +323,11 @@ const fetchMusicDetail = async () => {
const res = await getMusicById(id)
if (res.code === 200) {
if (res.data) {
music.value = res.data
music.value = {
...res.data,
likeCount: res.data.likeCount ?? 0,
collectCount: res.data.collectCount ?? 0
}
// 获取音乐状态
fetchMusicStatus()
} else {
@@ -420,7 +426,7 @@ const submitComment = async (isReply = false) => {
}
} catch (error) {
console.error('提交评论失败:', error);
ElMessage.error('评论失败,请稍后重试');
showErrorOnce(error, '评论失败,请稍后重试');
}
};
@@ -444,7 +450,7 @@ const deleteComment = async (commentId) => {
} catch (error) {
if (error !== 'cancel') {
console.error('删除评论失败:', error);
ElMessage.error('删除失败,请稍后重试');
showErrorOnce(error, '删除失败,请稍后重试');
}
}
};
@@ -484,7 +490,25 @@ const setupWebSocket = () => {
if (!music.value) return;
connect(() => {
const destination = `/topic/comments/1/${music.value.id}`; // 1 代表音乐类型
commentSubscription = subscribe(destination, (newComment) => {
commentSubscription = subscribe(destination, (message) => {
// 处理删除事件
if (message && message.deleted) {
const deletedId = message.id;
if (message.parentId) {
// 删除子评论
const parentComment = comments.value.find(c => c.id === message.parentId);
if (parentComment && parentComment.children) {
parentComment.children = parentComment.children.filter(c => c.id !== deletedId);
}
} else {
// 删除父评论
comments.value = comments.value.filter(c => c.id !== deletedId);
}
return;
}
// 处理新增评论
const newComment = message;
if (newComment && newComment.id) {
if (newComment.user && newComment.user.avatar) {
newComment.user.avatar = processImageUrl(newComment.user.avatar);
@@ -543,13 +567,19 @@ const toggleCollect = async () => {
if (res.code === 200) {
// 后端直接返回了最新的收藏状态 (true/false),直接赋值即可
music.value.collected = res.data
// 仅在操作成功时同步更新本地收藏数
if (music.value.collected) {
music.value.collectCount = (music.value.collectCount || 0) + 1
} else {
music.value.collectCount = Math.max(0, (music.value.collectCount || 0) - 1)
}
ElMessage.success(res.message || (music.value.collected ? '收藏成功' : '已取消收藏'))
} else {
ElMessage.error(res.message || '操作失败')
}
} catch (error) {
console.error('收藏操作失败:', error)
ElMessage.error('操作失败,请稍后重试')
showErrorOnce(error, '操作失败,请稍后重试')
}
}
@@ -572,7 +602,7 @@ const toggleLike = async () => {
}
} catch (error) {
console.error('点赞操作失败:', error);
ElMessage.error('操作失败,请稍后重试');
showErrorOnce(error, '操作失败,请稍后重试');
}
};

View File

@@ -1,8 +1,9 @@
<script setup>
import { ref, onMounted } from 'vue'
import { ref, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '../stores/user'
import { ElMessage } from 'element-plus'
import { showErrorOnce } from '../utils/errorHandler'
import { Search, Headset } from '@element-plus/icons-vue'
import { getPlaylistList, getHotPlaylist, toggleCollectPlaylist, isPlaylistCollected, uncollectPlaylist, collectPlaylist } from '../api/playlist'
import { getPlaylistTypeList } from '../api/playlistType'
@@ -10,6 +11,22 @@ import { getPlaylistTypeList } from '../api/playlistType'
const router = useRouter()
const userStore = useUserStore()
const extractList = (data) => {
if (Array.isArray(data)) return data
if (Array.isArray(data?.list)) return data.list
if (Array.isArray(data?.records)) return data.records
if (Array.isArray(data?.items)) return data.items
return []
}
const extractPageData = (data) => {
const list = extractList(data)
return {
list,
total: Number.isFinite(Number(data?.total)) ? Number(data.total) : list.length
}
}
// 歌单类型列表
const playlistTypes = ref([
{ id: null, name: '全部' }
@@ -88,8 +105,9 @@ const fetchPlaylists = async () => {
)
if (res.code === 200) {
playlists.value = res.data.list || res.data.records || []
total.value = res.data.total || 0
const pageData = extractPageData(res.data)
playlists.value = pageData.list
total.value = pageData.total
updatePlaylistStatuses(playlists.value)
} else {
ElMessage.error(res.message || '获取歌单列表失败')
@@ -108,7 +126,7 @@ const fetchHotPlaylists = async () => {
try {
const res = await getHotPlaylist(8) // 获取8个热门歌单
if (res.code === 200) {
hotPlaylists.value = res.data || []
hotPlaylists.value = extractList(res.data)
updatePlaylistStatuses(hotPlaylists.value)
} else {
ElMessage.error(res.message || '获取热门歌单失败')
@@ -128,6 +146,13 @@ const handleSearch = () => {
fetchPlaylists()
}
const handleKeywordClear = () => {
keyword.value = ''
searchedKeyword.value = ''
currentPage.value = 1
fetchPlaylists()
}
// 处理分页变化
const handlePageChange = (page) => {
currentPage.value = page
@@ -170,7 +195,7 @@ const toggleCollect = async (playlist) => {
}
} catch (error) {
console.error('收藏操作失败:', error)
ElMessage.error('操作失败,请稍后重试')
showErrorOnce(error, '操作失败,请稍后重试')
}
}
@@ -204,6 +229,15 @@ const formatPlayCount = (count) => {
}
}
watch(keyword, (val) => {
if (val === '' && searchedKeyword.value !== '') {
searchedKeyword.value = ''
currentPage.value = 1
fetchPlaylists()
}
})
onMounted(() => {
fetchPlaylistTypes()
fetchPlaylists()
@@ -220,6 +254,7 @@ onMounted(() => {
v-model="keyword"
placeholder="搜索歌单"
clearable
@clear="handleKeywordClear"
@keyup.enter="handleSearch"
>
<template #suffix>

View File

@@ -9,6 +9,7 @@ import { getMusicStatus } from '../api/music'
import { getCommentList, addComment, deleteComment as apiDeleteComment } from '../api/comment'
import { connect, subscribe, unsubscribe, disconnect } from '../utils/websocket' // 导入 WebSocket 工具
import { processImageUrl } from '../utils/imageUtils'
import { showErrorOnce } from '../utils/errorHandler'
import MusicList from '../components/MusicList.vue'
const route = useRoute()
@@ -201,7 +202,7 @@ const toggleCollect = async () => {
}
} catch (error) {
console.error('收藏操作失败:', error)
ElMessage.error('操作失败,请稍后重试')
showErrorOnce(error, '操作失败,请稍后重试')
}
}
@@ -226,7 +227,7 @@ const deleteComment = async (commentId) => {
} catch (error) {
if (error !== 'cancel') {
console.error('删除评论失败:', error)
ElMessage.error('删除失败,请稍后重试')
showErrorOnce(error, '删除失败,请稍后重试')
}
}
}
@@ -319,7 +320,7 @@ const submitComment = async (isReply = false) => {
}
} catch (error) {
console.error('提交评论失败:', error)
ElMessage.error('评论失败,请稍后重试')
showErrorOnce(error, '评论失败,请稍后重试')
}
}
@@ -358,13 +359,18 @@ const submitEdit = async () => {
if (res.code === 200) {
ElMessage.success('歌单更新成功')
editDialogVisible.value = false
fetchPlaylistDetail()
await fetchPlaylistDetail()
// 如果上传了新封面,加时间戳破浏览器缓存
if (editForm.value.cover && playlist.value && playlist.value.cover) {
const separator = playlist.value.cover.includes('?') ? '&' : '?'
playlist.value.cover = playlist.value.cover + separator + 't=' + Date.now()
}
} else {
ElMessage.error(res.message || '更新失败')
}
} catch (error) {
console.error('更新歌单失败:', error)
ElMessage.error('更新失败,请稍后重试')
showErrorOnce(error, '更新失败,请稍后重试')
}
}
@@ -390,7 +396,7 @@ const deletePlaylist = () => {
}
} catch (error) {
console.error('删除歌单失败:', error)
ElMessage.error('删除失败,请稍后重试')
showErrorOnce(error, '删除失败,请稍后重试')
}
}).catch(() => {})
}
@@ -510,7 +516,7 @@ const deleteFromPlaylist = async (music) => {
} catch (error) {
if (error !== 'cancel') {
console.error('移除歌曲失败:', error)
ElMessage.error('移除失败,请稍后重试')
showErrorOnce(error, '移除失败,请稍后重试')
}
}
}
@@ -524,7 +530,28 @@ onMounted(() => {
// 连接 WebSocket 并订阅评论
connect(() => {
const destination = `/topic/comments/2/${playlistId.value}`; // 2 代表歌单类型
commentSubscription = subscribe(destination, (newComment) => {
commentSubscription = subscribe(destination, (message) => {
// 处理删除事件
if (message && message.deleted) {
const deletedId = message.id;
if (message.parentId) {
// 删除子评论
const parentComment = comments.value.find(c => c.id === message.parentId);
if (parentComment && parentComment.children) {
parentComment.children = parentComment.children.filter(c => c.id !== deletedId);
}
} else {
// 删除父评论
comments.value = comments.value.filter(c => c.id !== deletedId);
}
if (playlist.value && playlist.value.commentCount > 0) {
playlist.value.commentCount--;
}
return;
}
// 处理新增评论
const newComment = message;
if (newComment && newComment.id) {
if (newComment.user && newComment.user.avatar) {
newComment.user.avatar = processImageUrl(newComment.user.avatar);
@@ -900,21 +927,13 @@ onUnmounted(() => {
margin-bottom: 20px;
display: flex;
flex-direction: column;
gap: 15px;
align-items: flex-end;
gap: 10px;
}
.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-submit-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.comment-list {
@@ -924,15 +943,8 @@ onUnmounted(() => {
.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);
padding-bottom: 20px;
border-bottom: 1px solid #eee;
}
.comment-avatar {
@@ -951,8 +963,17 @@ onUnmounted(() => {
}
.comment-user {
font-weight: 600;
color: #ff6b81;
font-weight: bold;
}
.child-comment-list {
margin-top: 15px;
padding-left: 15px;
border-left: 2px solid #eee;
}
.child-comment-input {
margin-top: 15px;
}
.comment-text {
@@ -961,18 +982,11 @@ onUnmounted(() => {
line-height: 1.5;
}
.comment-time {
.comment-footer {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.8rem;
color: #999;
}
.comment-actions .el-button {
border-radius: 20px;
padding: 5px 10px;
transition: all 0.3s;
}
.comment-actions .el-button:hover {
transform: scale(1.05);
}
</style>

View File

@@ -152,6 +152,7 @@ import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '../stores/user'
import { ElMessage } from 'element-plus'
import { showErrorOnce } from '../utils/errorHandler'
import { Search } from '@element-plus/icons-vue'
import { searchMusic, toggleCollectMusic, getMusicStatus } from '../api/music'
import { searchPlaylist, toggleCollectPlaylist } from '../api/playlist'
@@ -164,6 +165,22 @@ const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const extractList = (data) => {
if (Array.isArray(data)) return data
if (Array.isArray(data?.list)) return data.list
if (Array.isArray(data?.records)) return data.records
if (Array.isArray(data?.items)) return data.items
return []
}
const extractPageData = (data) => {
const list = extractList(data)
return {
list,
total: Number.isFinite(Number(data?.total)) ? Number(data.total) : list.length
}
}
// 搜索关键词
const keyword = computed(() => route.query.keyword || '')
const searchKeyword = ref(keyword.value)
@@ -213,8 +230,9 @@ const fetchMusicList = async () => {
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
const pageData = extractPageData(res.data)
musicList.value = pageData.list
musicTotal.value = pageData.total
// 处理音乐URL和封面URL
musicList.value = musicList.value.map(item => ({
@@ -244,8 +262,9 @@ const fetchPlaylistList = async () => {
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
const pageData = extractPageData(res.data)
playlistList.value = pageData.list
playlistTotal.value = pageData.total
// 处理封面URL
playlistList.value = playlistList.value.map(item => ({
@@ -271,8 +290,9 @@ const fetchSingerList = async () => {
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
const pageData = extractPageData(res.data)
singerList.value = pageData.list
singerTotal.value = pageData.total
// 处理头像URL
singerList.value = singerList.value.map(item => ({
@@ -362,7 +382,7 @@ const handleCollectChange = async (id) => {
}
} catch (error) {
console.error('收藏操作失败:', error)
ElMessage.error('操作失败,请稍后重试')
showErrorOnce(error, '操作失败,请稍后重试')
}
}
@@ -387,7 +407,7 @@ const handleCollectPlaylist = async (id) => {
}
} catch (error) {
console.error('收藏操作失败:', error)
ElMessage.error('操作失败,请稍后重试')
showErrorOnce(error, '操作失败,请稍后重试')
}
}
@@ -414,7 +434,7 @@ const handleCollectSinger = async (id, event) => {
}
} catch (error) {
console.error('收藏操作失败:', error)
ElMessage.error('操作失败,请稍后重试')
showErrorOnce(error, '操作失败,请稍后重试')
}
}

View File

@@ -1,8 +1,9 @@
<script setup>
import { ref, onMounted } from 'vue'
import { ref, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '../stores/user'
import { ElMessage } from 'element-plus'
import { showErrorOnce } from '../utils/errorHandler'
import { Search } from '@element-plus/icons-vue'
import { getSingerList, getHotSinger, isSingerCollected, collectSinger, uncollectSinger } from '../api/singer'
import { getSingerTypeList } from '../api/singerType'
@@ -11,6 +12,22 @@ import { processImageUrl } from '../utils/imageUtils'
const router = useRouter()
const userStore = useUserStore()
const extractList = (data) => {
if (Array.isArray(data)) return data
if (Array.isArray(data?.list)) return data.list
if (Array.isArray(data?.records)) return data.records
if (Array.isArray(data?.items)) return data.items
return []
}
const extractPageData = (data) => {
const list = extractList(data)
return {
list,
total: Number.isFinite(Number(data?.total)) ? Number(data.total) : list.length
}
}
// 歌手类型列表
const singerTypes = ref([{ id: null, name: '全部' }])
@@ -74,8 +91,9 @@ const fetchSingers = async () => {
)
if (res.code === 200) {
singers.value = res.data.list || res.data.records || []
total.value = res.data.total || 0
const pageData = extractPageData(res.data)
singers.value = pageData.list
total.value = pageData.total
// 处理图片URL
singers.value = singers.value.map(singer => ({
@@ -100,7 +118,7 @@ const fetchHotSingers = async () => {
try {
const res = await getHotSinger(8) // 获取8个热门歌手
if (res.code === 200) {
hotSingers.value = res.data || []
hotSingers.value = extractList(res.data)
// 处理图片URL
hotSingers.value = hotSingers.value.map(singer => ({
@@ -126,6 +144,13 @@ const handleSearch = () => {
fetchSingers()
}
const handleKeywordClear = () => {
keyword.value = ''
searchedKeyword.value = ''
currentPage.value = 1
fetchSingers()
}
// 处理分页变化
const handlePageChange = (page) => {
currentPage.value = page
@@ -170,7 +195,7 @@ const toggleCollect = async (singer, event) => {
}
} catch (error) {
console.error('收藏操作失败:', error)
ElMessage.error('操作失败,请稍后重试')
showErrorOnce(error, '操作失败,请稍后重试')
}
}
@@ -204,6 +229,15 @@ const formatFans = (count) => {
}
}
watch(keyword, (val) => {
if (val === '' && searchedKeyword.value !== '') {
searchedKeyword.value = ''
currentPage.value = 1
fetchSingers()
}
})
onMounted(() => {
fetchSingerTypes()
fetchSingers()
@@ -220,6 +254,7 @@ onMounted(() => {
v-model="keyword"
placeholder="搜索歌手"
clearable
@clear="handleKeywordClear"
@keyup.enter="handleSearch"
>
<template #suffix>

View File

@@ -7,6 +7,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import { getSingerById, isSingerCollected, collectSinger, uncollectSinger } from '../api/singer'
import { getCommentList, addComment, deleteComment as apiDeleteComment } from '../api/comment'
import { processImageUrl } from '../utils/imageUtils'
import { showErrorOnce } from '../utils/errorHandler'
import { processMusicUrl, getMusicBySinger, getMusicStatus } from '../api/music'
import { connect, subscribe, unsubscribe, disconnect } from '../utils/websocket' // 导入 WebSocket 工具
import MusicList from '../components/MusicList.vue'
@@ -219,7 +220,7 @@ const toggleCollect = async () => {
}
} catch (error) {
console.error('收藏操作失败:', error)
ElMessage.error('操作失败,请稍后重试')
showErrorOnce(error, '操作失败,请稍后重试')
}
}
@@ -311,7 +312,7 @@ const submitComment = async (isReply = false) => {
}
} catch (error) {
console.error('提交评论失败:', error)
ElMessage.error('评论失败,请稍后重试')
showErrorOnce(error, '评论失败,请稍后重试')
}
}
@@ -336,7 +337,7 @@ const deleteComment = async (commentId) => {
} catch (error) {
if (error !== 'cancel') {
console.error('删除评论失败:', error)
ElMessage.error('删除失败,请稍后重试')
showErrorOnce(error, '删除失败,请稍后重试')
}
}
}
@@ -440,7 +441,28 @@ onMounted(() => {
// 连接 WebSocket 并订阅评论
connect(() => {
const destination = `/topic/comments/3/${singerId.value}`; // 3 代表歌手类型
commentSubscription = subscribe(destination, (newComment) => {
commentSubscription = subscribe(destination, (message) => {
// 处理删除事件
if (message && message.deleted) {
const deletedId = message.id;
if (message.parentId) {
// 删除子评论
const parentComment = comments.value.find(c => c.id === message.parentId);
if (parentComment && parentComment.children) {
parentComment.children = parentComment.children.filter(c => c.id !== deletedId);
}
} else {
// 删除父评论
comments.value = comments.value.filter(c => c.id !== deletedId);
}
if (singer.value && singer.value.commentCount > 0) {
singer.value.commentCount--;
}
return;
}
// 处理新增评论
const newComment = message;
if (newComment && newComment.id) {
if (newComment.user && newComment.user.avatar) {
newComment.user.avatar = processImageUrl(newComment.user.avatar);

View File

@@ -12,6 +12,22 @@ import { processImageUrl } from '../utils/imageUtils'
const router = useRouter()
const userStore = useUserStore()
const extractList = (data) => {
if (Array.isArray(data)) return data
if (Array.isArray(data?.list)) return data.list
if (Array.isArray(data?.records)) return data.records
if (Array.isArray(data?.items)) return data.items
return []
}
const extractPageData = (data) => {
const list = extractList(data)
return {
list,
total: Number.isFinite(Number(data?.total)) ? Number(data.total) : list.length
}
}
// 用户信息
const userInfo = ref(null)
@@ -43,7 +59,7 @@ const avatarUploadUrl = ref(`${import.meta.env.VITE_API_BASE_URL || ''}/api/user
// 头像上传headers
const uploadHeaders = ref({
[localStorage.getItem('tokenName') || 'satoken']: userStore.token
[localStorage.getItem('tokenName') || 'satoken']: `${localStorage.getItem('tokenPrefix') || ''}${userStore.token || localStorage.getItem('token') || ''}`
})
// 当前激活的菜单
@@ -168,25 +184,25 @@ const fetchUserStats = async () => {
// 获取收藏的音乐数量
const musicRes = await getCollectedMusic()
if (musicRes.code === 200) {
userStats.value.collectMusicCount = musicRes.data.total || musicRes.data.length || 0
userStats.value.collectMusicCount = extractPageData(musicRes.data).total
}
// 获取收藏的歌单数量
const playlistRes = await getCollectedPlaylist()
if (playlistRes.code === 200) {
userStats.value.collectPlaylistCount = playlistRes.data.total || playlistRes.data.length || 0
userStats.value.collectPlaylistCount = extractPageData(playlistRes.data).total
}
// 获取收藏的歌手数量
const singerRes = await getCollectedSinger()
if (singerRes.code === 200) {
userStats.value.collectSingerCount = singerRes.data.total || singerRes.data.length || 0
userStats.value.collectSingerCount = extractPageData(singerRes.data).total
}
// 获取创建的歌单数量
const userPlaylistRes = await getUserPlaylist()
if (userPlaylistRes.code === 200) {
userStats.value.createPlaylistCount = userPlaylistRes.data.total || userPlaylistRes.data.length || 0
userStats.value.createPlaylistCount = extractPageData(userPlaylistRes.data).total
}
} catch (error) {
console.error('获取用户统计数据失败:', error)

View File

@@ -1,5 +1,6 @@
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElLoading, ElMessage } from 'element-plus'
import * as echarts from 'echarts/core'
import { BarChart, LineChart, PieChart } from 'echarts/charts'
@@ -57,6 +58,9 @@ const userGrowthData = ref([])
const musicTypeData = ref([])
const playCountData = ref([])
// 路由
const router = useRouter()
// 加载状态
const loading = ref(false)
@@ -484,7 +488,7 @@ onUnmounted(() => {
<template #header>
<div class="card-header">
<span>最新用户</span>
<el-button text>查看更多</el-button>
<el-button text @click="router.push({ name: 'admin-user' })">查看更多</el-button>
</div>
</template>
@@ -501,7 +505,7 @@ onUnmounted(() => {
<template #header>
<div class="card-header">
<span>最新音乐</span>
<el-button text>查看更多</el-button>
<el-button text @click="router.push({ name: 'admin-music' })">查看更多</el-button>
</div>
</template>

View File

@@ -270,7 +270,8 @@ const submitForm = async () => {
singer: form.value.singer,
album: form.value.album,
typeIds: form.value.types,
lyric: form.value.lyric
lyric: form.value.lyric,
duration: form.value.duration
},
form.value.file,
form.value.cover

View File

@@ -1,9 +1,10 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getPlaylistList, getPlaylistById, addPlaylist as apiAddPlaylist, updatePlaylist as apiUpdatePlaylist, deletePlaylist as apiDeletePlaylist } from '../../api/playlist'
import { getPlaylistTypeList } from '../../api/playlistType'
import { processMusicUrl } from '../../api/music'
import { showErrorOnce } from '../../utils/errorHandler'
// 歌单列表
const playlistList = ref([])
@@ -251,6 +252,7 @@ const musicDialogVisible = ref(false)
const currentPlaylistId = ref(null)
const currentPlaylistName = ref('')
const musicList = ref([])
const playlistMusics = ref([])
const selectedMusicIds = ref([])
const musicSearchKeyword = ref('')
const musicLoading = ref(false)
@@ -278,10 +280,17 @@ const fetchPlaylistMusic = async (playlistId) => {
try {
const res = await getPlaylistById(playlistId)
if (res.code === 200 && res.data.musics) {
playlistMusics.value = res.data.musics
selectedMusicIds.value = res.data.musics.map(m => m.id)
} else {
playlistMusics.value = []
selectedMusicIds.value = []
}
} catch (error) {
console.error('获取歌单音乐失败:', error)
playlistMusics.value = []
selectedMusicIds.value = []
showErrorOnce(error, '获取歌单音乐失败')
}
}
@@ -290,6 +299,8 @@ const managePlaylistMusic = async (row) => {
currentPlaylistId.value = row.id
currentPlaylistName.value = row.name
musicSearchKeyword.value = ''
playlistMusics.value = []
selectedMusicIds.value = []
musicDialogVisible.value = true
// 加载音乐列表和已选音乐
@@ -305,13 +316,16 @@ const addMusicToPlaylistLocal = async (musicId) => {
const res = await addMusicToPlaylist(currentPlaylistId.value, musicId)
if (res.code === 200) {
ElMessage.success('添加成功')
await fetchPlaylistMusic(currentPlaylistId.value)
await Promise.all([
fetchPlaylistMusic(currentPlaylistId.value),
fetchPlaylistList()
])
} else {
ElMessage.error(res.message || '添加失败')
}
} catch (error) {
console.error('添加音乐失败:', error)
ElMessage.error('添加失败')
showErrorOnce(error, '添加失败')
}
}
@@ -321,13 +335,16 @@ const removeMusicFromPlaylistLocal = async (musicId) => {
const res = await removeMusicFromPlaylist(currentPlaylistId.value, musicId)
if (res.code === 200) {
ElMessage.success('移除成功')
await fetchPlaylistMusic(currentPlaylistId.value)
await Promise.all([
fetchPlaylistMusic(currentPlaylistId.value),
fetchPlaylistList()
])
} else {
ElMessage.error(res.message || '移除失败')
}
} catch (error) {
console.error('移除音乐失败:', error)
ElMessage.error('移除失败')
showErrorOnce(error, '移除失败')
}
}
@@ -737,7 +754,7 @@ import { addMusicToPlaylist, removeMusicFromPlaylist } from '../../api/playlist'
<div class="selected-music-container">
<h3>已添加的音乐</h3>
<el-table
:data="musicList.filter(m => selectedMusicIds.includes(m.id))"
:data="playlistMusics"
stripe
style="width: 100%"
empty-text="暂无音乐"

View File

@@ -1,8 +1,9 @@
<script setup>
import { ref, onMounted, reactive } from 'vue'
import { ref, onMounted, reactive, computed } 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'
import { Search, Edit, Plus } from '@element-plus/icons-vue'
import { getUserList, deleteUser, updateUserStatus, getUserById, adminUpdateUser, adminUploadUserAvatar } from '../../api/admin/user'
import { processImageUrl } from '../../utils/imageUtils'
// 用户列表
const userList = ref([])
@@ -36,13 +37,74 @@ const editFormRules = {
],
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 avatarUploading = ref(false)
const avatarPreviewVersion = ref(Date.now())
const avatarPreviewUrl = computed(() => {
if (!editForm.avatar) return ''
const avatarUrl = processImageUrl(editForm.avatar)
const separator = avatarUrl.includes('?') ? '&' : '?'
return `${avatarUrl}${separator}t=${avatarPreviewVersion.value}`
})
const resolveAvatarPath = (avatarData) => {
if (!avatarData) return ''
if (typeof avatarData === 'string') return avatarData
if (typeof avatarData === 'object') {
return avatarData.avatar || avatarData.url || avatarData.path || avatarData.filePath || ''
}
return ''
}
const handleAvatarBeforeUpload = (file) => {
const isAllowedType = ['image/jpeg', 'image/png', 'image/webp'].includes(file.type)
const isLt2M = file.size / 1024 / 1024 < 2
if (!isAllowedType) {
ElMessage.error('头像仅支持 JPG/PNG/WEBP 格式')
return false
}
if (!isLt2M) {
ElMessage.error('头像大小不能超过 2MB')
return false
}
return true
}
const handleAvatarUpload = async ({ file }) => {
if (!editForm.id) {
ElMessage.error('请先选择要编辑的用户')
return
}
avatarUploading.value = true
try {
const res = await adminUploadUserAvatar(editForm.id, file)
if (res.code === 200) {
editForm.avatar = resolveAvatarPath(res.data)
avatarPreviewVersion.value = Date.now()
ElMessage.success('头像上传成功')
} else {
ElMessage.error(res.message || '头像上传失败')
}
} catch (error) {
console.error('上传头像失败:', error)
ElMessage.error('头像上传失败,请稍后再试')
} finally {
avatarUploading.value = false
}
}
const handleAvatarRemove = () => {
editForm.avatar = ''
avatarPreviewVersion.value = Date.now()
}
// 获取用户列表
const fetchUserList = async () => {
@@ -144,6 +206,7 @@ const handleEdit = async (id) => {
editForm.avatar = user.avatar || ''
editForm.gender = user.gender || 0
editForm.introduction = user.introduction || ''
avatarPreviewVersion.value = Date.now()
// 显示对话框
editDialogVisible.value = true
@@ -360,11 +423,24 @@ onMounted(() => {
<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>
<el-form-item label="头像">
<el-upload
class="avatar-upload"
:http-request="handleAvatarUpload"
:before-upload="handleAvatarBeforeUpload"
:show-file-list="false"
accept=".jpg,.jpeg,.png,.webp"
:disabled="avatarUploading || editLoading"
>
<div class="avatar-uploader-content" v-loading="avatarUploading">
<el-avatar v-if="editForm.avatar" :size="60" :src="avatarPreviewUrl"></el-avatar>
<el-icon v-else class="avatar-uploader-icon"><Plus /></el-icon>
</div>
</el-upload>
<div class="avatar-upload-actions" v-if="editForm.avatar">
<el-button link type="danger" @click="handleAvatarRemove">移除头像</el-button>
</div>
<div class="el-upload__tip">支持 jpg/png/webp大小不超过 2MB</div>
</el-form-item>
<el-form-item label="性别">
<el-radio-group v-model="editForm.gender">
@@ -505,9 +581,33 @@ onMounted(() => {
border-radius: 12px;
}
.avatar-preview {
margin-top: 10px;
.avatar-upload :deep(.el-upload) {
border: 1px dashed #b3e5fc;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s;
overflow: hidden;
}
.avatar-upload :deep(.el-upload):hover {
border-color: #4dd0e1;
}
.avatar-uploader-content {
width: 80px;
height: 80px;
display: flex;
align-items: center;
justify-content: center;
background: #f8fdff;
}
.avatar-uploader-icon {
font-size: 24px;
color: #8c939d;
}
.avatar-upload-actions {
margin-top: 8px;
}
</style>

View File

@@ -13,6 +13,20 @@ const collectMusicList = ref([])
// 加载状态
const loading = ref(false)
// 格式化日期时间兼容后端返回的多种格式ISO、yyyy-MM-dd HH:mm:ss、yyyy-MM-dd
const formatDate = (dateStr) => {
if (!dateStr) return ''
const date = new Date(dateStr)
// 检查是否为有效日期
if (isNaN(date.getTime())) return dateStr
return date.getFullYear() + '-' +
String(date.getMonth() + 1).padStart(2, '0') + '-' +
String(date.getDate()).padStart(2, '0') + ' ' +
String(date.getHours()).padStart(2, '0') + ':' +
String(date.getMinutes()).padStart(2, '0') + ':' +
String(date.getSeconds()).padStart(2, '0')
}
// 获取收藏的音乐列表
const fetchCollectMusic = async () => {
loading.value = true
@@ -31,7 +45,7 @@ const fetchCollectMusic = async () => {
duration: item.duration || 0,
cover: processImageUrl(item.cover),
url: item.url,
collectTime: item.collectTime ? new Date(item.collectTime).toLocaleDateString() : '未知'
collectTime: item.collectTime ? formatDate(item.collectTime) : '未知'
}))
} else {
ElMessage.error(res.message || '获取收藏的音乐列表失败')

View File

@@ -34,7 +34,8 @@ const createForm = reactive({
const editForm = reactive({
id: null,
name: '',
description: ''
description: '',
coverFile: null
})
// 表单规则
@@ -52,14 +53,6 @@ const formRules = {
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
@@ -107,6 +100,8 @@ const openEditDialog = (playlist) => {
editForm.id = playlist.id
editForm.name = playlist.name
editForm.description = playlist.description
editForm.coverFile = null
coverPreview.value = playlist.cover
editDialogVisible.value = true
}
@@ -144,7 +139,7 @@ const handleUpdatePlaylist = () => {
const res = await updatePlaylist(editForm.id, {
name: editForm.name,
description: editForm.description
})
}, editForm.coverFile)
if (res.code === 200) {
// 更新成功,重新获取歌单列表
@@ -185,33 +180,13 @@ const handleDeletePlaylist = (id) => {
}).catch(() => {})
}
// 封面上传成功
const handleCoverSuccess = async (response) => {
if (response.code === 200) {
// 上传成功获取图片URL
const imageUrl = response.data
// 封面预览URL
const coverPreview = ref('')
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 handleCoverChange = (file) => {
editForm.coverFile = file.raw
coverPreview.value = URL.createObjectURL(file.raw)
}
// 封面上传前的处理
@@ -354,15 +329,15 @@ onMounted(() => {
<el-form-item label="封面">
<el-upload
class="cover-uploader"
:action="coverUploadUrl"
:headers="uploadHeaders"
action="#"
:show-file-list="false"
:on-success="handleCoverSuccess"
:auto-upload="false"
:on-change="handleCoverChange"
:before-upload="beforeCoverUpload"
>
<img
v-if="currentPlaylist"
:src="currentPlaylist.cover"
v-if="coverPreview"
:src="coverPreview"
class="cover"
/>
<el-icon v-else class="cover-uploader-icon"><plus /></el-icon>