From e703a76059adb5fe53efc0f4d076e24233aeb8f2 Mon Sep 17 00:00:00 2001 From: "[yxf]" <[1524240689@qq.com]> Date: Mon, 13 Apr 2026 20:56:22 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=88=86=E7=BB=84=E3=80=81?= =?UTF-8?q?=E6=A0=87=E7=AD=BE=E3=80=81=E5=BF=AB=E6=8D=B7=E5=9B=9E=E5=A4=8D?= =?UTF-8?q?=E3=80=81=E9=BB=98=E8=AE=A4=E5=B1=95=E7=A4=BA=E6=B8=B8=E5=AE=A2?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=E3=80=81=E6=89=80=E5=B1=9E=E5=9C=B0=E6=A3=80?= =?UTF-8?q?=E6=9F=A5=E4=B8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/QuickReplyController.java | 30 + .../implatform/controller/UserController.java | 42 ++ .../com/bx/implatform/entity/QuickReply.java | 24 + .../com/bx/implatform/entity/UserGroup.java | 2 + .../com/bx/implatform/entity/UserLabel.java | 2 + .../implatform/mapper/QuickReplyMapper.java | 9 + .../implatform/service/QuickReplyService.java | 8 + .../implatform/service/UserGroupService.java | 7 + .../implatform/service/UserLabelService.java | 5 + .../bx/implatform/service/UserService.java | 31 + .../service/impl/QuickReplyServiceImpl.java | 49 ++ .../service/impl/UserGroupServiceImpl.java | 14 + .../service/impl/UserLabelServiceImpl.java | 14 + .../service/impl/UserServiceImpl.java | 110 +++- .../com/bx/implatform/vo/QuickReplyVO.java | 16 + .../com/bx/implatform/vo/UserGroupVO.java | 15 + .../com/bx/implatform/vo/UserLabelVO.java | 15 + .../java/com/bx/implatform/vo/UserVO.java | 13 + im-web/src/components/chat/ChatBox.vue | 612 +++++++++++++----- 19 files changed, 866 insertions(+), 152 deletions(-) create mode 100644 im-platform/src/main/java/com/bx/implatform/controller/QuickReplyController.java create mode 100644 im-platform/src/main/java/com/bx/implatform/entity/QuickReply.java create mode 100644 im-platform/src/main/java/com/bx/implatform/mapper/QuickReplyMapper.java create mode 100644 im-platform/src/main/java/com/bx/implatform/service/QuickReplyService.java create mode 100644 im-platform/src/main/java/com/bx/implatform/service/impl/QuickReplyServiceImpl.java create mode 100644 im-platform/src/main/java/com/bx/implatform/vo/QuickReplyVO.java create mode 100644 im-platform/src/main/java/com/bx/implatform/vo/UserGroupVO.java create mode 100644 im-platform/src/main/java/com/bx/implatform/vo/UserLabelVO.java diff --git a/im-platform/src/main/java/com/bx/implatform/controller/QuickReplyController.java b/im-platform/src/main/java/com/bx/implatform/controller/QuickReplyController.java new file mode 100644 index 0000000..e61fdbf --- /dev/null +++ b/im-platform/src/main/java/com/bx/implatform/controller/QuickReplyController.java @@ -0,0 +1,30 @@ +package com.bx.implatform.controller; + +import com.bx.implatform.result.Result; +import com.bx.implatform.result.ResultUtils; +import com.bx.implatform.service.QuickReplyService; +import com.bx.implatform.vo.QuickReplyVO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@Tag(name = "快捷回复") +@RestController +@RequestMapping("/quick/reply") +@RequiredArgsConstructor +public class QuickReplyController { + + private final QuickReplyService quickReplyService; + + @GetMapping("/list") + @Operation(summary = "获取快捷回复列表", description = "前端聊天页调用") + public Result> getList(@RequestParam Long userId) { + return ResultUtils.success(quickReplyService.getList(userId)); + } +} \ No newline at end of file diff --git a/im-platform/src/main/java/com/bx/implatform/controller/UserController.java b/im-platform/src/main/java/com/bx/implatform/controller/UserController.java index 03bafef..c434311 100644 --- a/im-platform/src/main/java/com/bx/implatform/controller/UserController.java +++ b/im-platform/src/main/java/com/bx/implatform/controller/UserController.java @@ -62,6 +62,47 @@ public class UserController { return ResultUtils.success(userService.findUserById(id)); } + @PostMapping("/group/save") + @Operation(summary = "保存用户分组", description = "单个分组,保存到 group_ids 字段") + public Result saveGroup(@RequestBody JSONObject jsonObject) { + Long userId = jsonObject.getLong("userId"); + String groupId = jsonObject.getStr("groupIds"); // 前端传分组ID + + if (ObjectUtil.isNull(userId) || ObjectUtil.isEmpty(groupId)) { + return ResultUtils.error(ResultCode.PROGRAM_ERROR, "参数不能为空"); + } + + userService.saveUserGroup(userId, groupId); + return ResultUtils.success(); + } + + @PostMapping("/label/save") + @Operation(summary = "保存用户标签") + public Result saveLabel(@RequestBody JSONObject jsonObject) { + Long userId = jsonObject.getLong("userId"); + String labelIds = jsonObject.getStr("labelIds"); + +// if (ObjectUtil.isNull(userId) || ObjectUtil.isEmpty(labelIds)) { +// return ResultUtils.error(ResultCode.PROGRAM_ERROR, "参数不能为空"); +// } + + userService.saveUserLabel(userId, labelIds); + return ResultUtils.success(); + } + + + @GetMapping("/group/{id}") + @Operation(summary = "查询分组", description = "根据id查找分组") + public Result group(@NotNull @PathVariable("id") Long id) { + return ResultUtils.success(userService.getGroup(id)); + } + + @GetMapping("/label/{id}") + @Operation(summary = "重新分组", description = "根据id查找分组") + public Result label(@NotNull @PathVariable("id") Long id) { + return ResultUtils.success(userService.getLabe(id)); + } + @PutMapping("/update") @Operation(summary = "修改用户信息", description = "修改用户信息,仅允许修改登录用户信息") public Result update(@Valid @RequestBody UserVO vo) { @@ -165,5 +206,6 @@ public class UserController { return ResultUtils.success(vo); } + } diff --git a/im-platform/src/main/java/com/bx/implatform/entity/QuickReply.java b/im-platform/src/main/java/com/bx/implatform/entity/QuickReply.java new file mode 100644 index 0000000..72fd6c0 --- /dev/null +++ b/im-platform/src/main/java/com/bx/implatform/entity/QuickReply.java @@ -0,0 +1,24 @@ +package com.bx.implatform.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.time.LocalDateTime; + +@Data +@TableName("im_quick_reply") +public class QuickReply { + @TableId(type = IdType.AUTO) + private Long id; + private String uniqueToken; + private String replyName; + private Integer replyType; + private String replyTitle; + private String replyContent; + private String remark; + private LocalDateTime createdTime; + private LocalDateTime updatedTime; + private Long creatorId; + private Long updaterId; +} \ No newline at end of file diff --git a/im-platform/src/main/java/com/bx/implatform/entity/UserGroup.java b/im-platform/src/main/java/com/bx/implatform/entity/UserGroup.java index 9be1b9f..3da372a 100644 --- a/im-platform/src/main/java/com/bx/implatform/entity/UserGroup.java +++ b/im-platform/src/main/java/com/bx/implatform/entity/UserGroup.java @@ -20,6 +20,8 @@ public class UserGroup { @TableId(type = IdType.AUTO) private Long id; + private String uniqueToken; + private String groupName; } diff --git a/im-platform/src/main/java/com/bx/implatform/entity/UserLabel.java b/im-platform/src/main/java/com/bx/implatform/entity/UserLabel.java index b237a1a..dd12b3a 100644 --- a/im-platform/src/main/java/com/bx/implatform/entity/UserLabel.java +++ b/im-platform/src/main/java/com/bx/implatform/entity/UserLabel.java @@ -21,6 +21,8 @@ public class UserLabel { @TableId(type = IdType.AUTO) private Long id; + private String uniqueToken; + private String labelName; } diff --git a/im-platform/src/main/java/com/bx/implatform/mapper/QuickReplyMapper.java b/im-platform/src/main/java/com/bx/implatform/mapper/QuickReplyMapper.java new file mode 100644 index 0000000..3d71066 --- /dev/null +++ b/im-platform/src/main/java/com/bx/implatform/mapper/QuickReplyMapper.java @@ -0,0 +1,9 @@ +package com.bx.implatform.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.bx.implatform.entity.QuickReply; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface QuickReplyMapper extends BaseMapper { +} \ No newline at end of file diff --git a/im-platform/src/main/java/com/bx/implatform/service/QuickReplyService.java b/im-platform/src/main/java/com/bx/implatform/service/QuickReplyService.java new file mode 100644 index 0000000..a9a7d7e --- /dev/null +++ b/im-platform/src/main/java/com/bx/implatform/service/QuickReplyService.java @@ -0,0 +1,8 @@ +package com.bx.implatform.service; + +import com.bx.implatform.vo.QuickReplyVO; +import java.util.List; + +public interface QuickReplyService { + List getList(Long userId); +} \ No newline at end of file diff --git a/im-platform/src/main/java/com/bx/implatform/service/UserGroupService.java b/im-platform/src/main/java/com/bx/implatform/service/UserGroupService.java index 261183f..911c6f1 100644 --- a/im-platform/src/main/java/com/bx/implatform/service/UserGroupService.java +++ b/im-platform/src/main/java/com/bx/implatform/service/UserGroupService.java @@ -10,4 +10,11 @@ public interface UserGroupService extends IService { * 根据分组ID列表获取分组名称列表 */ List getGroupNamesByIds(String groupIds); + + /** + * 获取token相同的分组 + */ + List getGroupList(String token); + + } \ No newline at end of file diff --git a/im-platform/src/main/java/com/bx/implatform/service/UserLabelService.java b/im-platform/src/main/java/com/bx/implatform/service/UserLabelService.java index aeecf23..9cfb190 100644 --- a/im-platform/src/main/java/com/bx/implatform/service/UserLabelService.java +++ b/im-platform/src/main/java/com/bx/implatform/service/UserLabelService.java @@ -10,4 +10,9 @@ public interface UserLabelService extends IService { * 根据标签ID列表获取标签名称列表 */ List getLabelNamesByIds(String labelIds); + + /** + * 获取token相同的标签 + */ + List getLabelList(String token); } \ No newline at end of file diff --git a/im-platform/src/main/java/com/bx/implatform/service/UserService.java b/im-platform/src/main/java/com/bx/implatform/service/UserService.java index 565c9e0..b9f60bc 100644 --- a/im-platform/src/main/java/com/bx/implatform/service/UserService.java +++ b/im-platform/src/main/java/com/bx/implatform/service/UserService.java @@ -74,6 +74,37 @@ public interface UserService extends IService { */ UserVO findUserById(Long id); + /** + * 根据用户id查询分组 + * + * @param id 用户id + * @return 用户信息 + */ + UserVO getGroup(Long id); + + /** + * 根据用户id查询标签 + * + * @param id 用户id + * @return 用户信息 + */ + UserVO getLabe(Long id); + + /** + * 保存用户分组 + * @param userId 目标用户ID + * @param groupId 分组ID + */ + void saveUserGroup(Long userId, String groupId); + + /** + * 保存用户标签 + * @param userId 用户ID + * @param labelIds 标签ID,逗号分隔 + */ + void saveUserLabel(Long userId, String labelIds); + + /** * 根据用户昵称查询用户,最多返回20条数据 * diff --git a/im-platform/src/main/java/com/bx/implatform/service/impl/QuickReplyServiceImpl.java b/im-platform/src/main/java/com/bx/implatform/service/impl/QuickReplyServiceImpl.java new file mode 100644 index 0000000..a6ce5f0 --- /dev/null +++ b/im-platform/src/main/java/com/bx/implatform/service/impl/QuickReplyServiceImpl.java @@ -0,0 +1,49 @@ +package com.bx.implatform.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.bx.implatform.entity.QuickReply; +import com.bx.implatform.entity.User; +import com.bx.implatform.mapper.QuickReplyMapper; +import com.bx.implatform.mapper.UserMapper; +import com.bx.implatform.service.QuickReplyService; +import com.bx.implatform.vo.QuickReplyVO; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.BeanUtils; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class QuickReplyServiceImpl extends ServiceImpl implements QuickReplyService { + + private final UserMapper userMapper; + + @Override + public List getList(Long userId) { + // 1. 根据前端传的 userId 查询用户 + User user = userMapper.selectById(userId); + if (user == null) { + return List.of(); + } + + // 2. 拿到用户的 uniqueToken + String uniqueToken = user.getUniqueToken(); + + // 3. 匹配快捷回复 + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(QuickReply::getUniqueToken, uniqueToken); + wrapper.orderByAsc(QuickReply::getCreatedTime); + + List list = this.list(wrapper); + + // 4. 转VO返回 + return list.stream().map(item -> { + QuickReplyVO vo = new QuickReplyVO(); + BeanUtils.copyProperties(item, vo); + return vo; + }).collect(Collectors.toList()); + } +} \ No newline at end of file diff --git a/im-platform/src/main/java/com/bx/implatform/service/impl/UserGroupServiceImpl.java b/im-platform/src/main/java/com/bx/implatform/service/impl/UserGroupServiceImpl.java index e9872a4..f2249fa 100644 --- a/im-platform/src/main/java/com/bx/implatform/service/impl/UserGroupServiceImpl.java +++ b/im-platform/src/main/java/com/bx/implatform/service/impl/UserGroupServiceImpl.java @@ -43,4 +43,18 @@ public class UserGroupServiceImpl extends ServiceImpl getGroupList(String token) { + if (!StringUtils.hasText(token)) { + return Collections.emptyList(); + } + + // 查询与该token匹配的分组 + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(UserGroup::getUniqueToken, token); + + // 直接返回完整的UserGroup对象列表 + return this.list(wrapper); + } } \ No newline at end of file diff --git a/im-platform/src/main/java/com/bx/implatform/service/impl/UserLabelServiceImpl.java b/im-platform/src/main/java/com/bx/implatform/service/impl/UserLabelServiceImpl.java index ba48564..1b51258 100644 --- a/im-platform/src/main/java/com/bx/implatform/service/impl/UserLabelServiceImpl.java +++ b/im-platform/src/main/java/com/bx/implatform/service/impl/UserLabelServiceImpl.java @@ -2,6 +2,7 @@ package com.bx.implatform.service.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.bx.implatform.entity.UserGroup; import com.bx.implatform.entity.UserLabel; import com.bx.implatform.mapper.UserLabelMapper; import com.bx.implatform.service.UserLabelService; @@ -43,4 +44,17 @@ public class UserLabelServiceImpl extends ServiceImpl getLabelList(String token) { + if (!StringUtils.hasText(token)) { + return Collections.emptyList(); + } + + // 查询与该token匹配的分组 + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(UserLabel::getUniqueToken, token); + + // 直接返回完整的UserLabel对象列表 + return this.list(wrapper); + } } \ No newline at end of file diff --git a/im-platform/src/main/java/com/bx/implatform/service/impl/UserServiceImpl.java b/im-platform/src/main/java/com/bx/implatform/service/impl/UserServiceImpl.java index 1bb8557..4655f0b 100644 --- a/im-platform/src/main/java/com/bx/implatform/service/impl/UserServiceImpl.java +++ b/im-platform/src/main/java/com/bx/implatform/service/impl/UserServiceImpl.java @@ -16,9 +16,7 @@ import com.bx.implatform.config.props.JwtProperties; import com.bx.implatform.dto.LoginDTO; import com.bx.implatform.dto.ModifyPwdDTO; import com.bx.implatform.dto.RegisterDTO; -import com.bx.implatform.entity.Friend; -import com.bx.implatform.entity.GroupMember; -import com.bx.implatform.entity.User; +import com.bx.implatform.entity.*; import com.bx.implatform.enums.ResultCode; import com.bx.implatform.exception.GlobalException; import com.bx.implatform.mapper.UserMapper; @@ -29,9 +27,7 @@ import com.bx.implatform.session.UserSession; import com.bx.implatform.util.BeanUtils; import com.bx.implatform.util.IpUtils; import com.bx.implatform.util.SensitiveFilterUtil; -import com.bx.implatform.vo.LoginVO; -import com.bx.implatform.vo.OnlineTerminalVO; -import com.bx.implatform.vo.UserVO; +import com.bx.implatform.vo.*; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; @@ -61,6 +57,10 @@ public class UserServiceImpl extends ServiceImpl implements Us private UserLabelService userLabelService; @Autowired private UserGroupService UserGroupService; + private final UserGroupService userGroupService; // 注入 UserGroupService +// @Autowired +// private UserLabelService UserLabelService; + private final UserLabelService UserLabelService; // 注入 UserGroupService // @Override // public LoginVO login(LoginDTO dto) { @@ -341,6 +341,104 @@ public class UserServiceImpl extends ServiceImpl implements Us return vo; } + @Override + public UserVO getGroup(Long id) { + User user = this.getById(id); + if (user == null) { + throw new GlobalException(ResultCode.PROGRAM_ERROR); + } + + UserVO vo = BeanUtils.copyProperties(user, UserVO.class); + + // 获取用户的分组信息并转换为VO + String token = user.getUniqueToken(); + if (StringUtils.hasText(token)) { + List groups = userGroupService.getGroupList(token); + + // 转换为UserGroupVO + List groupVOList = groups.stream() + .map(g -> new UserGroupVO(g.getId(), g.getGroupName())) + .collect(Collectors.toList()); + vo.setGroupList(groupVOList); + + // 设置分组名称列表 + List groupNames = groups.stream() + .map(UserGroup::getGroupName) + .collect(Collectors.toList()); + } else { + vo.setGroupList(new ArrayList<>()); + } + + return vo; + } + + @Override + public UserVO getLabe(Long id) { + User user = this.getById(id); + if (user == null) { + throw new GlobalException(ResultCode.PROGRAM_ERROR); + } + + UserVO vo = BeanUtils.copyProperties(user, UserVO.class); + + // 获取用户的分组信息并转换为VO + String token = user.getUniqueToken(); + if (StringUtils.hasText(token)) { + List label = UserLabelService.getLabelList(token); + + // 转换为UserGroupVO + List labelVOList = label.stream() + .map(g -> new UserLabelVO(g.getId(), g.getLabelName())) + .collect(Collectors.toList()); + vo.setLabelList(labelVOList); + + // 设置标签名称列表 + List labelNames = label.stream() + .map(UserLabel::getLabelName) + .collect(Collectors.toList()); + } else { + vo.setLabelList(new ArrayList<>()); + } + + return vo; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void saveUserGroup(Long userId, String groupId) { + // 获取目标用户 + User targetUser = this.getById(userId); + if (ObjectUtil.isNull(targetUser)) { + throw new GlobalException(ResultCode.PROGRAM_ERROR); + } + + // 设置分组ID + targetUser.setGroupIds(groupId); + + // 更新用户信息到数据库 + boolean updated = this.updateById(targetUser); + + if (!updated) { + throw new GlobalException(ResultCode.PROGRAM_ERROR, "更新用户分组失败"); + } + + log.info("用户 {} 的分组已更新为: {}", userId, groupId); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void saveUserLabel(Long userId, String labelIds) { + User user = this.getById(userId); + log.info("用户 {} 的分组已更新为: {}", userId, labelIds); + if (ObjectUtil.isNull(user)) { + throw new GlobalException(ResultCode.PROGRAM_ERROR, "用户不存在"); + } + // 保存标签ID串 + user.setLabelIds(labelIds); + this.updateById(user); + log.info("用户{}标签保存成功: {}", userId, labelIds); + } + @Override public List findUserByName(String name) { LambdaQueryWrapper queryWrapper = Wrappers.lambdaQuery(); diff --git a/im-platform/src/main/java/com/bx/implatform/vo/QuickReplyVO.java b/im-platform/src/main/java/com/bx/implatform/vo/QuickReplyVO.java new file mode 100644 index 0000000..0d5b180 --- /dev/null +++ b/im-platform/src/main/java/com/bx/implatform/vo/QuickReplyVO.java @@ -0,0 +1,16 @@ +package com.bx.implatform.vo; + +import lombok.Data; +import java.time.LocalDateTime; + +@Data +public class QuickReplyVO { + private Long id; + private String uniqueToken; + private String replyName; + private Integer replyType; // 0文本 1图片 + private String replyTitle; + private String replyContent; + private String remark; + private LocalDateTime createdTime; +} \ No newline at end of file diff --git a/im-platform/src/main/java/com/bx/implatform/vo/UserGroupVO.java b/im-platform/src/main/java/com/bx/implatform/vo/UserGroupVO.java new file mode 100644 index 0000000..ae20f73 --- /dev/null +++ b/im-platform/src/main/java/com/bx/implatform/vo/UserGroupVO.java @@ -0,0 +1,15 @@ +package com.bx.implatform.vo; + +import lombok.Data; + +@Data +public class UserGroupVO { + private Long id; + private String groupName; + + // 构造方法 + public UserGroupVO(Long id, String groupName) { + this.id = id; + this.groupName = groupName; + } +} \ No newline at end of file diff --git a/im-platform/src/main/java/com/bx/implatform/vo/UserLabelVO.java b/im-platform/src/main/java/com/bx/implatform/vo/UserLabelVO.java new file mode 100644 index 0000000..406b820 --- /dev/null +++ b/im-platform/src/main/java/com/bx/implatform/vo/UserLabelVO.java @@ -0,0 +1,15 @@ +package com.bx.implatform.vo; + +import lombok.Data; + +@Data +public class UserLabelVO { + private Long id; + private String labelName; + + // 构造方法 + public UserLabelVO(Long id, String labelName) { + this.id = id; + this.labelName = labelName; + } +} \ No newline at end of file diff --git a/im-platform/src/main/java/com/bx/implatform/vo/UserVO.java b/im-platform/src/main/java/com/bx/implatform/vo/UserVO.java index c53dcd1..59ae56e 100644 --- a/im-platform/src/main/java/com/bx/implatform/vo/UserVO.java +++ b/im-platform/src/main/java/com/bx/implatform/vo/UserVO.java @@ -1,5 +1,6 @@ package com.bx.implatform.vo; +import com.bx.implatform.entity.UserGroup; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; @@ -15,6 +16,9 @@ public class UserVO { @Schema(description = "id") private Long id; + @Schema(description = "代理标识token") + private String uniqueToken; + @NotEmpty(message = "用户名不能为空") @Length(max = 20, message = "用户名不能大于20字符") @Schema(description = "用户名") @@ -65,4 +69,13 @@ public class UserVO { @Schema(description = "分组名称列表") private List groupNames; +// @Schema(description = "所有分组名称") +// private List groupNameList; + + // 修改为完整的UserGroup列表 + private List groupList; // 完整的分组信息列表 + + // 修改为完整的UserGroup列表 + private List labelList; // 完整的分组信息列表 + } \ No newline at end of file diff --git a/im-web/src/components/chat/ChatBox.vue b/im-web/src/components/chat/ChatBox.vue index f86b10d..616bc7b 100644 --- a/im-web/src/components/chat/ChatBox.vue +++ b/im-web/src/components/chat/ChatBox.vue @@ -35,27 +35,11 @@ - +
-
@@ -83,7 +67,9 @@ @@ -126,6 +125,7 @@ + @@ -134,6 +134,34 @@ :groupMembers="groupMembers" @close="closeHistoryBox"> + + + + +
+
+ {{ item.replyContent }} +
+
+ 暂无快捷回复 +
+
+
+ +
@@ -176,22 +204,32 @@ export default { userInfo: {}, group: {}, groupMembers: [], + groupOptions: [], + groupOptionsLoading: false, + selectedGroup: '', + labelOptions: [], + labelOptionsLoading: false, + selectedLabels: [], sendImageUrl: "", sendImageFile: "", placeholder: "", isReceipt: true, - showRecord: false, // 是否显示语音录制弹窗 - showSide: false, // 是否显示群聊信息栏 - showUserSide: true, // 是否显示用户信息栏 - showHistory: false, // 是否显示历史聊天记录 - showChangeCustomer: false, // 是否显示转接客服弹窗 - lockMessage: false, // 是否锁定发送, - showMinIdx: 0, // 下标低于showMinIdx的消息不显示,否则页面会很卡置 - reqQueue: [], // 等待发送的请求队列 - isSending: false, // 是否正在发消息 - isInBottom: false, // 滚动条是否在底部 - newMessageSize: 0, // 滚动条不在底部时新的消息数量 - maxTmpId: 0 // 最后生成的临时ID + showRecord: false, + showSide: false, + showUserSide: true, + showHistory: false, + showChangeCustomer: false, + showQuickReplyBox: false, // 快捷回复弹窗 + quickReplyList: [], // 快捷回复列表 + quickLoading: false, // 加载状态 + lockMessage: false, + showMinIdx: 0, + reqQueue: [], + isSending: false, + isInBottom: false, + newMessageSize: 0, + ipLocation: '', + maxTmpId: 0 } }, methods: { @@ -201,7 +239,6 @@ export default { }, closeRefBox() { this.$refs.emoBox.close(); - // this.$refs.atBox.close(); }, onCall(type) { if (type == this.$enums.MESSAGE_TYPE.ACT_RT_VOICE) { @@ -234,7 +271,6 @@ export default { this.chatStore.updateMessage(msgInfo, file.chat); }, onImageBefore(file) { - // 被封禁提示 if (this.isBanned) { this.showBannedTip(); return; @@ -255,16 +291,11 @@ export default { readedCount: 0, status: this.$enums.MESSAGE_STATUS.SENDING } - // 填充对方id this.fillTargetId(msgInfo, this.chat.targetId); - // 插入消息 this.chatStore.insertMessage(msgInfo, this.chat); - // 会话置顶 this.moveChatToTop(); - // 借助file对象保存 file.msgInfo = msgInfo; file.chat = this.chat; - // 更新图片尺寸 let chat = this.chat; this.getImageSize(file).then(size => { data.width = size.width; @@ -300,7 +331,6 @@ export default { this.chatStore.updateMessage(msgInfo, file.chat); }, onFileBefore(file) { - // 被封禁提示 if (this.isBanned) { this.showBannedTip(); return; @@ -321,13 +351,9 @@ export default { readedCount: 0, status: this.$enums.MESSAGE_STATUS.SENDING } - // 填充对方id this.fillTargetId(msgInfo, this.chat.targetId); - // 插入消息 this.chatStore.insertMessage(msgInfo, this.chat); - // 会话置顶 this.moveChatToTop(); - // 借助file对象透传 file.msgInfo = msgInfo; file.chat = this.chat; }, @@ -336,18 +362,15 @@ export default { this.showUserSide = false; }, onScrollToTop() { - // 多展示10条信息 this.showMinIdx = this.showMinIdx > 10 ? this.showMinIdx - 10 : 0; }, onScroll(e) { let scrollElement = e.target let scrollTop = scrollElement.scrollTop - if (scrollTop < 30) { // 在顶部,不滚动的情况 - // 多展示20条信息 + if (scrollTop < 30) { this.showMinIdx = this.showMinIdx > 20 ? this.showMinIdx - 20 : 0; this.isInBottom = false; } - // 滚到底部 if (scrollTop + scrollElement.clientHeight >= scrollElement.scrollHeight - 30) { this.isInBottom = true; this.newMessageSize = 0; @@ -372,27 +395,22 @@ export default { this.showRecord = false; }, showPrivateVideo(mode) { - // 检查是否被封禁 if (this.isBanned) { this.showBannedTip(); return; } - let rtcInfo = { mode: mode, isHost: true, friend: this.friend, } - // 通过home.vue打开单人视频窗口 this.$eventBus.$emit("openPrivateVideo", rtcInfo); }, onGroupVideo() { - // 检查是否被封禁 if (this.isBanned) { this.showBannedTip(); return; } - // 邀请成员发起通话 let ids = [this.mine.id]; let maxChannel = this.configStore.webrtc.maxChannel; this.$refs.rtcSel.open(maxChannel, ids, ids, []); @@ -417,7 +435,6 @@ export default { inviterId: this.mine.id, userInfos: userInfos } - // 通过home.vue打开多人视频窗口 this.$eventBus.$emit("openGroupVideo", rtcInfo); }, showHistoryBox() { @@ -427,7 +444,6 @@ export default { this.showHistory = false; }, onSendRecord(data) { - // 检查是否被封禁 if (this.isBanned) { this.showBannedTip(); return; @@ -438,26 +454,18 @@ export default { type: this.$enums.MESSAGE_TYPE.AUDIO, receipt: this.isReceipt } - // 填充对方id this.fillTargetId(msgInfo, this.chat.targetId); - // 防止发送期间用户切换会话导致串扰 const chat = this.chat; - // 临时消息回显 let tmpMessage = this.buildTmpMessage(msgInfo); this.chatStore.insertMessage(tmpMessage, chat); this.moveChatToTop(); this.sendMessageRequest(msgInfo).then(m => { - // 更新消息 tmpMessage.id = m.id; tmpMessage.status = m.status; this.chatStore.updateMessage(tmpMessage, chat); - // 会话置顶 this.moveChatToTop(); - // 保持输入框焦点 this.$refs.chatInputEditor.focus(); - // 滚动到底部 this.scrollToBottom(); - // 关闭录音窗口 this.showRecord = false; this.isReceipt = false; this.refreshPlaceHolder(); @@ -478,8 +486,8 @@ export default { }, async sendMessage(fullList) { this.resetEditor(); + console.log(fullList); this.readedMessage(); - // 检查是否被封禁 if (this.isBanned) { this.showBannedTip(); return; @@ -529,29 +537,22 @@ export default { content: sendText, type: this.$enums.MESSAGE_TYPE.TEXT } - // 填充对方id this.fillTargetId(msgInfo, this.chat.targetId); - // 被@人员列表 if (this.chat.type == "GROUP") { msgInfo.atUserIds = atUserIds; msgInfo.receipt = this.isReceipt; } this.lockMessage = true; - // 防止发送期间用户切换会话导致串扰 const chat = this.chat; - // 回显消息 let tmpMessage = this.buildTmpMessage(msgInfo); this.chatStore.insertMessage(tmpMessage, chat); this.moveChatToTop(); - // 发送 this.sendMessageRequest(msgInfo).then((m) => { - // 更新消息 tmpMessage.id = m.id; tmpMessage.status = m.status; tmpMessage.content = m.content; this.chatStore.updateMessage(tmpMessage, chat); }).catch(() => { - // 更新消息 tmpMessage.status = this.$enums.MESSAGE_STATUS.FAILED; this.chatStore.updateMessage(tmpMessage, chat); }).finally(() => { @@ -573,24 +574,18 @@ export default { this.$message.error("该消息不支持自动重新发送,建议手动重新发送") return; } - // 防止发送期间用户切换会话导致串扰 const chat = this.chat; - // 删除旧消息 this.chatStore.deleteMessage(msgInfo, chat); - // 重新推送 msgInfo.tmpId = this.generateId(); let tmpMessage = this.buildTmpMessage(msgInfo); this.chatStore.insertMessage(tmpMessage, chat); this.moveChatToTop(); - // 发送 this.sendMessageRequest(msgInfo).then(m => { - // 更新消息 tmpMessage.id = m.id; tmpMessage.status = m.status; tmpMessage.content = m.content; this.chatStore.updateMessage(tmpMessage, chat); }).catch(() => { - // 更新消息 tmpMessage.status = this.$enums.MESSAGE_STATUS.FAILED; this.chatStore.updateMessage(tmpMessage, chat); }).finally(() => { @@ -667,7 +662,6 @@ export default { }, updateFriendInfo() { if (this.isFriend) { - // store的数据不能直接修改,深拷贝一份store的数据 let friend = JSON.parse(JSON.stringify(this.friend)); friend.headImage = this.userInfo.headImageThumb; friend.nickName = this.userInfo.nickName; @@ -678,18 +672,45 @@ export default { this.chatStore.updateChatFromUser(this.userInfo); } }, - loadFriend(friendId) { - // 获取好友信息 - this.$http({ - url: `/user/find/${friendId}`, - method: 'GET' - }).then((userInfo) => { + async loadFriend(friendId) { + try { + this.labelOptionsLoading = true; + const userInfo = await this.$http({ + url: `/user/find/${friendId}`, + method: 'GET' + }); this.userInfo = userInfo; - console.log(this.userInfo); + if (userInfo.lastLoginIp) { + const location = await this.getIpLocation(userInfo.lastLoginIp); + this.ipLocation = location; + } this.updateFriendInfo(); - }) + await this.loadLabelOptions(friendId); + this.selectedLabels = []; + if (userInfo.labelIds) { + this.selectedLabels = userInfo.labelIds + .split(',') + .map(id => String(id.trim())) + .filter(id => id); + } + await this.loadAllGroupOptions(friendId); + this.$nextTick(() => { + if (this.userInfo.groupIds) { + this.selectedGroup = String(this.userInfo.groupIds); + } else if (this.userInfo.groupNames && this.userInfo.groupNames.length > 0) { + const currentGroupName = this.userInfo.groupNames[0]; + const matchedGroup = this.groupOptions.find(item => item.label === currentGroupName); + if (matchedGroup) { + this.selectedGroup = matchedGroup.value; + } + } + }); + } catch (error) { + console.error('加载用户信息失败:', error); + } finally { + this.labelOptionsLoading = false; + } }, - showName(msgInfo) { if (!msgInfo) { return "" @@ -732,7 +753,6 @@ export default { }, sendMessageRequest(msgInfo) { return new Promise((resolve, reject) => { - // 请求入队列,防止请求"后发先至",导致消息错序 this.reqQueue.push({ msgInfo, resolve, reject }); this.processReqQueue(); }) @@ -751,7 +771,6 @@ export default { reqData.reject(e) }).finally(() => { this.isSending = false; - // 发送下一条请求 this.processReqQueue(); }) } @@ -803,9 +822,7 @@ export default { }); }, generateId() { - // 生成临时id const id = String(new Date().getTime()) + String(Math.floor(Math.random() * 1000)); - // 必须保证id是递增 if (this.maxTmpId > id) { return this.generateId(); } @@ -820,7 +837,311 @@ export default { }, onTransferSuccess() { this.$message.success('已成功转接客服'); - // 可以在这里添加额外的处理逻辑 + }, + async getIpLocation(ip) { + if (!ip) return ''; + try { + const response = await fetch(`http://ip-api.com/json/${ip}?lang=zh-CN`); + const data = await response.json(); + if (data && data.status === 'success') { + let location = []; + if (data.country) location.push(data.country); + if (data.regionName) location.push(data.regionName); + if (data.city) location.push(data.city); + return location.join(' '); + } + return ''; + } catch (error) { + console.error('获取IP地理位置失败:', error); + try { + const backupResponse = await fetch(`https://whois.pconline.com.cn/ipJson.jsp?ip=${ip}&json=true`); + const text = await backupResponse.text(); + const data = JSON.parse(text); + if (data) { + let location = []; + if (data.pro) location.push(data.pro); + if (data.city) location.push(data.city); + if (data.addr) location.push(data.addr); + return location.join(' '); + } + } catch (backupError) { + console.error('备用API也失败了:', backupError); + } + return ''; + } + }, + formatIpDisplay(ip, location) { + if (!ip) return ''; + return location ? `${ip}(${location})` : ip; + }, + handleAddUserGroup() { + this.$prompt('请输入分组名称', '添加分组', { + confirmButtonText: '确定', + cancelButtonText: '取消', + }).then(({ value }) => { + if (!value || !value.trim()) { + this.$message.warning('分组名称不能为空'); + return; + } + }).catch(() => {}); + }, + loadAllGroupOptions(friendId) { + return this.$http.get(`/user/group/${friendId}`).then(res => { + console.log('分组数据:', res); + if (res.groupList && Array.isArray(res.groupList)) { + this.groupOptions = res.groupList.map(item => ({ + value: item.id, + label: item.groupName + })); + } else { + this.groupOptions = []; + } + return res; + }).catch(error => { + console.error('加载分组选项失败:', error); + this.groupOptions = []; + }); + }, + handleSelectUserGroup(groupId) { + if (groupId === '__CREATE_NEW__') { + this.$prompt('请输入新分组名称', '创建分组', { + confirmButtonText: '确定', + cancelButtonText: '取消', + }).then(({ value }) => { + if (!value.trim()) return + this.createNewGroup(value.trim()) + }) + } else { + this.doSaveGroup(groupId) + } + }, + doSaveGroup(groupId) { + this.$http.post('/user/group/save', { + userId: this.userInfo.id, + groupIds: String(groupId) + }).then(() => { + console.log('res',groupId) + this.$message.success('分组设置成功') + this.loadFriend(this.chat.targetId) + }) + }, + loadLabelOptions(userId) { + this.labelOptionsLoading = true; + return this.$http.get(`/user/label/${userId}`).then(res => { + console.log('标签选项数据:', res); + if (res && Array.isArray(res.labelList)) { + this.labelOptions = res.labelList.map(item => ({ + value: item.id, + label: item.labelName || item.name + })); + } else { + this.labelOptions = []; + } + return res; + }).catch(error => { + console.error('加载标签选项失败:', error); + this.labelOptions = []; + }).finally(() => { + this.labelOptionsLoading = false; + }); + }, + getLabelName(labelId) { + const label = this.labelOptions.find(item => item.value == labelId); + return label ? label.label : ''; + }, + removeLabel(labelId) { + const index = this.selectedLabels.indexOf(labelId); + if (index > -1) { + this.selectedLabels.splice(index, 1); + this.saveLabels(); + } + }, + handleSelectLabels(labelIds) { + const ids = Array.isArray(labelIds) ? labelIds : [labelIds]; + this.selectedLabels = [...new Set(ids.map(id => String(id)))]; + this.saveLabels(); + }, + saveLabels() { + const uniqueLabels = [...new Set(this.selectedLabels)]; + this.$http.post('/user/label/save', { + userId: this.userInfo.id, + labelIds: uniqueLabels.join(',') + }).then(() => { + this.$message.success('标签设置成功'); + this.loadFriend(this.chat.targetId); + }).catch(() => { + this.$message.error('标签设置失败'); + }); + }, + + // ==================== 快捷回复相关 ==================== + // 打开快捷回复并加载列表 + openQuickReplyBox() { + this.showQuickReplyBox = true; + this.loadQuickReplyList(); + }, + // 关闭 + closeQuickReplyBox() { + this.showQuickReplyBox = false; + }, + loadQuickReplyList() { + this.quickLoading = true; + // 传当前登录用户的 ID 给后端 + this.$http.get("/quick/reply/list", { + params: { + userId: this.mine.id + } + }) + .then(res => { + if (res && Array.isArray(res)) { + this.quickReplyList = res; + } else { + this.quickReplyList = []; + } + }) + .catch(() => { + this.quickReplyList = []; + }) + .finally(() => { + this.quickLoading = false; + }); + }, + // 选择快捷语,插入输入框并发送 + // selectQuickReply(item) { + // if (!item || !item.replyContent) return; + // console.log('item',item) + // // 先清空输入框 + // this.$refs.chatInputEditor.clear(); + + // // 插入快捷回复内容(如果组件有 insertText 或类似方法) + // // 方法1:如果 ChatInput 有 insertText 方法 + // if (this.$refs.chatInputEditor.insertText) { + // this.$refs.chatInputEditor.insertText(item.replyContent); + // } + // // 方法2:如果只能通过设置 content 属性 + // else { + // this.$refs.chatInputEditor.content = item.replyContent; + // } + + // // 关闭弹窗 + // this.showQuickReplyBox = false; + // const fullList = [ + // { + // content: item.replyContent, + // type: item.replyType == 0 ? 'text' : 'image' + // } + // ]; + // // 延迟一点确保内容已设置,然后自动发送 + // this.$nextTick(() => { + // setTimeout(() => { + // this.sendMessage(fullList); + // // this.notifySend(); + // }, 50); + // }); + // } + // 选择快捷语,插入输入框并发送 + selectQuickReply(item) { + if (!item || !item.replyContent) return; + console.log('快捷回复数据:', item); + + // 关闭弹窗 + this.showQuickReplyBox = false; + + this.$nextTick(() => { + // 根据 replyType 判断消息类型 + if (item.replyType === 0) { + // 文本消息 + this.sendQuickTextMessage(item.replyContent); + } else if (item.replyType === 1) { + // 图片消息(URL格式) + this.sendQuickImageMessage(item.replyContent); + } else { + // 默认当作文本处理 + this.sendQuickTextMessage(item.replyContent); + } + }); + }, + + // 发送快捷文本消息 + sendQuickTextMessage(content) { + let sendText = this.isReceipt ? "【回执消息】" : ""; + let msgInfo = { + tmpId: this.generateId(), + content: sendText + content, + type: this.$enums.MESSAGE_TYPE.TEXT + }; + + this.fillTargetId(msgInfo, this.chat.targetId); + + if (this.chat.type == "GROUP") { + msgInfo.atUserIds = []; + msgInfo.receipt = this.isReceipt; + } + + const chat = this.chat; + let tmpMessage = this.buildTmpMessage(msgInfo); + this.chatStore.insertMessage(tmpMessage, chat); + this.moveChatToTop(); + + this.sendMessageRequest(msgInfo).then((m) => { + tmpMessage.id = m.id; + tmpMessage.status = m.status; + tmpMessage.content = m.content; + this.chatStore.updateMessage(tmpMessage, chat); + this.scrollToBottom(); + this.$refs.chatInputEditor.clear(); + }).catch(() => { + tmpMessage.status = this.$enums.MESSAGE_STATUS.FAILED; + this.chatStore.updateMessage(tmpMessage, chat); + }).finally(() => { + this.isReceipt = false; + this.refreshPlaceHolder(); + }); + }, + + // 发送快捷图片消息(URL格式) + sendQuickImageMessage(imageUrl) { + // 构造图片消息数据 + let imageData = { + originUrl: imageUrl, + thumbUrl: imageUrl, // 缩略图使用相同URL,或者后端会生成缩略图 + width: 0, // 如果不知道尺寸,设为0 + height: 0 + }; + + let msgInfo = { + tmpId: this.generateId(), + sendId: this.mine.id, + content: JSON.stringify(imageData), + sendTime: new Date().getTime(), + selfSend: true, + type: this.$enums.MESSAGE_TYPE.IMAGE, + readedCount: 0, + status: this.$enums.MESSAGE_STATUS.SENDING, + receipt: this.isReceipt + }; + + this.fillTargetId(msgInfo, this.chat.targetId); + + const chat = this.chat; + + // 先插入临时消息显示 + this.chatStore.insertMessage(msgInfo, chat); + this.moveChatToTop(); + this.scrollToBottom(); + + // 发送消息 + this.sendMessageRequest(msgInfo).then((m) => { + msgInfo.id = m.id; + msgInfo.status = m.status; + this.isReceipt = false; + this.chatStore.updateMessage(msgInfo, chat); + this.$refs.chatInputEditor.clear(); + this.refreshPlaceHolder(); + }).catch(() => { + msgInfo.status = this.$enums.MESSAGE_STATUS.FAILED; + this.chatStore.updateMessage(msgInfo, chat); + }); }, }, computed: { @@ -879,6 +1200,11 @@ export default { return this.group.id; } return 0; + }, + availableLabelOptions() { + return this.labelOptions.filter(item => + !this.selectedLabels.includes(String(item.value)) + ); } }, watch: { @@ -893,25 +1219,16 @@ export default { this.loadGroup(this.chat.targetId); } else { this.loadFriend(this.chat.targetId); - // 加载已读状态 this.loadReaded(this.chat.targetId) } - // 滚到底部 this.scrollToBottom(); this.showSide = false; - this.showUserSide = false; - // 消息已读 this.readedMessage() - // 初始状态只显示30条消息 let size = this.chat.messages.length; this.showMinIdx = size > 30 ? size - 30 : 0; - // 重置输入框 this.resetEditor(); - // 复位回执消息 this.isReceipt = false; - // 清空消息临时id this.maxTmpId = 0; - // 更新placeholder this.refreshPlaceHolder(); } }, @@ -920,7 +1237,6 @@ export default { messageSize: { handler(newSize, oldSize) { if (newSize > oldSize) { - // 收到普通消息,则滚动至底部 let lastMessage = this.chat.messages[newSize - 1]; if (lastMessage && this.$msgType.isNormal(lastMessage.type)) { if (this.isInBottom || lastMessage.selfSend) { @@ -934,7 +1250,6 @@ export default { }, loading: { handler(newLoading, oldLoading) { - // 断线重连后,需要更新一下已读状态 if (!newLoading && this.isPrivate) { this.loadReaded(this.chat.targetId) } @@ -961,8 +1276,6 @@ export default { line-height: 50px; font-size: var(--im-font-size-larger); border-bottom: var(--im-border); - - .btn-side { position: absolute; right: 20px; @@ -975,22 +1288,18 @@ export default { .content-box { position: relative; - .im-chat-main { padding: 0; background-color: #f4f5f6; - .im-chat-box { >ul { padding: 0 20px; - li { list-style-type: none; } } } } - .scroll-to-bottom { text-align: right; position: absolute; @@ -1006,12 +1315,10 @@ export default { z-index: 99; box-shadow: var(--im-box-shadow-light); } - .im-chat-footer { display: flex; flex-direction: column; padding: 0; - .chat-tool-bar { display: flex; position: relative; @@ -1021,7 +1328,6 @@ export default { box-sizing: border-box; border-top: var(--im-border); padding: 4px 2px 2px 8px; - >div { font-size: 22px; cursor: pointer; @@ -1033,26 +1339,22 @@ export default { margin-right: 8px; color: #999; transition: 0.3s; - &.chat-tool-active { font-weight: 600; color: var(--im-color-primary); background-color: #ddd; } } - >div:hover { color: #333; } } - .send-content-area { position: relative; display: flex; flex-direction: column; height: 100%; background-color: white !important; - .send-btn-area { padding: 10px; position: absolute; @@ -1062,22 +1364,18 @@ export default { } } } - .side-box { border-left: var(--im-border); } - .user-info-side { .user-info-container { padding: 20px; height: 100%; overflow-y: auto; - .user-info-header { text-align: center; padding-bottom: 20px; border-bottom: 1px solid #eee; - .user-avatar-large { width: 100px; height: 100px; @@ -1085,48 +1383,47 @@ export default { object-fit: cover; margin-bottom: 15px; } - .user-nickname { font-size: 18px; font-weight: 600; margin: 10px 0 5px; color: #333; } - .user-username { font-size: 14px; color: #999; margin: 0; } } - .user-info-content { padding: 20px 0; - .info-item { display: flex; + align-items: center; padding: 10px 0; border-bottom: 1px solid #f0f0f0; - .info-label { width: 70px; font-size: 14px; color: #999; flex-shrink: 0; } - .info-value { flex: 1; font-size: 14px; color: #333; word-break: break-all; - /* 标签容器样式 */ - .el-tag { - margin-right: 8px; - margin-bottom: 8px; - - &:last-child { - margin-right: 0; + ::v-deep .el-select { + .el-tag { + background-color: #4d4949 !important; + color: #fff !important; + border-color: #1d1b1b !important; + .el-tag__close { + color: #fff !important; + &:hover { + background-color: #333 !important; + } + } } } } @@ -1135,4 +1432,27 @@ export default { } } } + +// 快捷回复样式 +.quick-reply-list { + padding: 10px; + .quick-item { + padding: 10px 12px; + margin-bottom: 8px; + background: #f7f8fa; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s; + border: 1px solid transparent; + &:hover { + background: #e6f7ff; + border-color: #91caff; + } + } + .quick-empty { + text-align: center; + padding: 30px 0; + color: #999; + } +} \ No newline at end of file