Browse Source

!141 版本升级:v_3.5.0

Merge pull request !141 from blue/v_3.0.0
master
blue 11 months ago
committed by Gitee
parent
commit
58780f3037
No known key found for this signature in database GPG Key ID: 173E9B9CA92EEF8F
  1. 14
      db/im-platform.sql
  2. 2
      im-platform/src/main/java/com/bx/implatform/annotation/RedisLock.java
  3. 9
      im-platform/src/main/java/com/bx/implatform/aspect/RedisLockAspect.java
  4. 2
      im-platform/src/main/java/com/bx/implatform/config/props/MinioProperties.java
  5. 5
      im-platform/src/main/java/com/bx/implatform/contant/RedisKey.java
  6. 9
      im-platform/src/main/java/com/bx/implatform/controller/FileController.java
  7. 18
      im-platform/src/main/java/com/bx/implatform/controller/GroupController.java
  8. 6
      im-platform/src/main/java/com/bx/implatform/dto/GroupInviteDTO.java
  9. 28
      im-platform/src/main/java/com/bx/implatform/dto/GroupMemberRemoveDTO.java
  10. 67
      im-platform/src/main/java/com/bx/implatform/entity/FileInfo.java
  11. 6
      im-platform/src/main/java/com/bx/implatform/enums/FileType.java
  12. 8
      im-platform/src/main/java/com/bx/implatform/mapper/FileInfoMapper.java
  13. 15
      im-platform/src/main/java/com/bx/implatform/service/FileService.java
  14. 191
      im-platform/src/main/java/com/bx/implatform/service/FileServiceImpl.java
  15. 9
      im-platform/src/main/java/com/bx/implatform/service/GroupMemberService.java
  16. 13
      im-platform/src/main/java/com/bx/implatform/service/GroupService.java
  17. 30
      im-platform/src/main/java/com/bx/implatform/service/impl/GroupMemberServiceImpl.java
  18. 60
      im-platform/src/main/java/com/bx/implatform/service/impl/GroupServiceImpl.java
  19. 121
      im-platform/src/main/java/com/bx/implatform/service/thirdparty/FileService.java
  20. 78
      im-platform/src/main/java/com/bx/implatform/task/schedule/FileExpireTask.java
  21. 25
      im-platform/src/main/java/com/bx/implatform/thirdparty/MinioService.java
  22. 1
      im-platform/src/main/resources/application-dev.yml
  23. 1
      im-platform/src/main/resources/application-prod.yml
  24. 1
      im-platform/src/main/resources/application-test.yml
  25. BIN
      im-platform/src/main/resources/static/favicon.ico
  26. 1
      im-uniapp/App.vue
  27. 7
      im-uniapp/common/recorder-h5.js
  28. 9
      im-uniapp/components/chat-item/chat-item.vue
  29. 30
      im-uniapp/components/group-member-selector/group-member-selector.vue
  30. 7
      im-uniapp/components/image-upload/image-upload.vue
  31. 1
      im-uniapp/components/virtual-scroller/virtual-scroller.vue
  32. 5
      im-uniapp/pages/chat/chat-box.vue
  33. 1
      im-uniapp/pages/chat/chat-group-video.vue
  34. 2
      im-uniapp/pages/group/group-edit.vue
  35. 62
      im-uniapp/pages/group/group-info.vue
  36. 34
      im-uniapp/pages/group/group-member.vue
  37. 2
      im-uniapp/pages/mine/mine-edit.vue
  38. 1
      im-uniapp/pages/mine/mine.vue
  39. 1
      im-uniapp/pages/register/register.vue
  40. 42
      im-uniapp/static/icon/iconfont.css
  41. BIN
      im-uniapp/static/icon/iconfont.ttf
  42. 1
      im-uniapp/store/userStore.js
  43. 1
      im-web/src/api/camera.js
  44. 38
      im-web/src/assets/iconfont/iconfont.css
  45. BIN
      im-web/src/assets/iconfont/iconfont.ttf
  46. 4
      im-web/src/components/chat/ChatAtBox.vue
  47. 12
      im-web/src/components/chat/ChatBox.vue
  48. 4
      im-web/src/components/chat/ChatGroupMember.vue
  49. 73
      im-web/src/components/chat/ChatGroupSide.vue
  50. 7
      im-web/src/components/chat/ChatInput.vue
  51. 18
      im-web/src/components/chat/ChatItem.vue
  52. 18
      im-web/src/components/chat/ChatMessageItem.vue
  53. 6
      im-web/src/components/common/FileUpload.vue
  54. 14
      im-web/src/components/common/HeadImage.vue
  55. 24
      im-web/src/components/common/RightMenu.vue
  56. 89
      im-web/src/components/common/UserInfo.vue
  57. 20
      im-web/src/components/friend/FriendItem.vue
  58. 60
      im-web/src/components/group/AddGroupMember.vue
  59. 8
      im-web/src/components/group/GroupMember.vue
  60. 39
      im-web/src/components/group/GroupMemberSelector.vue
  61. 1
      im-web/src/components/rtc/RtcPrivateVideo.vue
  62. 5
      im-web/src/components/setting/Setting.vue
  63. 1
      im-web/src/view/Friend.vue
  64. 78
      im-web/src/view/Group.vue
  65. 4
      im-web/src/view/Home.vue

14
db/im-platform.sql

@ -92,3 +92,17 @@ create table `im_sensitive_word`(
`creator` bigint DEFAULT NULL COMMENT '创建者',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'
)ENGINE=InnoDB CHARSET=utf8mb4 comment '敏感词';
CREATE TABLE `im_file_info` (
`id` BIGINT NOT NULL auto_increment PRIMARY key comment 'id',
`file_name` VARCHAR(255) NOT NULL comment '文件名',
`file_path` VARCHAR(255) NOT NULL comment '文件地址',
`file_size` INTEGER NOT NULL comment '文件大小',
`file_type` tinyint NOT NULL comment '0:普通文件 1:图片 2:视频',
`compressed_path` VARCHAR(255) DEFAULT NULL comment '压缩文件路径',
`cover_path` VARCHAR(255) DEFAULT NULL comment '封面文件路径,仅视频文件有效',
`upload_time` datetime DEFAULT CURRENT_TIMESTAMP comment '上传时间',
`is_permanent` tinyint DEFAULT 0 comment '是否永久文件',
`md5` VARCHAR(64) NOT NULL comment '文件md5',
UNIQUE KEY `idx_md5` (md5),
) ENGINE = InnoDB CHARSET = utf8mb4 comment '文件';

2
im-platform/src/main/java/com/bx/implatform/annotation/RedisLock.java

@ -21,7 +21,7 @@ public @interface RedisLock {
/**
* spel 表达式
*/
String key();
String key() default "";
/**
* 等待锁的时间默认-1不等待直接失败,redisson默认也是-1

9
im-platform/src/main/java/com/bx/implatform/aspect/RedisLockAspect.java

@ -4,6 +4,7 @@ import cn.hutool.core.util.StrUtil;
import com.bx.implatform.annotation.RedisLock;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.logging.log4j.util.Strings;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
@ -61,18 +62,22 @@ public class RedisLockAspect {
private String parseKey(ProceedingJoinPoint joinPoint){
Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
RedisLock annotation = method.getAnnotation(RedisLock.class);
String key = annotation.key();
if(StrUtil.isEmpty(key)){
return Strings.EMPTY;
}
// el解析需要的上下文对象
EvaluationContext context = new StandardEvaluationContext();
// 参数名
String[] params = parameterNameDiscoverer.getParameterNames(method);
if(Objects.isNull(params)){
return annotation.key();
return key;
}
Object[] args = joinPoint.getArgs();
for (int i = 0; i < params.length; i++) {
context.setVariable(params[i], args[i]);//所有参数都作为原材料扔进去
}
Expression expression = parser.parseExpression(annotation.key());
Expression expression = parser.parseExpression(key);
return expression.getValue(context, String.class);
}

2
im-platform/src/main/java/com/bx/implatform/config/props/MinioProperties.java

@ -29,4 +29,6 @@ public class MinioProperties {
private String filePath;
private String videoPath;
private Integer expireIn;
}

5
im-platform/src/main/java/com/bx/implatform/contant/RedisKey.java

@ -53,4 +53,9 @@ public final class RedisKey {
*/
public static final String IM_REPEAT_SUBMIT = "im:repeat:submit";
/**
* 分布式锁-清理过期文件
*/
public static final String IM_LOCK_FILE_TASK = "im:lock:task:file";
}

9
im-platform/src/main/java/com/bx/implatform/controller/FileController.java

@ -2,7 +2,7 @@ package com.bx.implatform.controller;
import com.bx.implatform.result.Result;
import com.bx.implatform.result.ResultUtils;
import com.bx.implatform.service.thirdparty.FileService;
import com.bx.implatform.service.FileService;
import com.bx.implatform.vo.UploadImageVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@ -24,15 +24,16 @@ public class FileController {
@Operation(summary = "上传图片", description = "上传图片,上传后返回原图和缩略图的url")
@PostMapping("/image/upload")
public Result<UploadImageVO> uploadImage(@RequestParam("file") MultipartFile file) {
return ResultUtils.success(fileService.uploadImage(file));
public Result<UploadImageVO> uploadImage(@RequestParam("file") MultipartFile file,
@RequestParam(defaultValue = "true") Boolean isPermanent) {
return ResultUtils.success(fileService.uploadImage(file,isPermanent));
}
@CrossOrigin
@Operation(summary = "上传文件", description = "上传文件,上传后返回文件url")
@PostMapping("/file/upload")
public Result<String> uploadFile(@RequestParam("file") MultipartFile file) {
return ResultUtils.success(fileService.uploadFile(file), "");
return ResultUtils.success(fileService.uploadFile(file));
}
}

18
im-platform/src/main/java/com/bx/implatform/controller/GroupController.java

@ -1,10 +1,11 @@
package com.bx.implatform.controller;
import com.bx.implatform.annotation.RepeatSubmit;
import com.bx.implatform.dto.GroupMemberRemoveDTO;
import com.bx.implatform.result.Result;
import com.bx.implatform.result.ResultUtils;
import com.bx.implatform.service.GroupService;
import com.bx.implatform.vo.GroupInviteVO;
import com.bx.implatform.dto.GroupInviteDTO;
import com.bx.implatform.vo.GroupMemberVO;
import com.bx.implatform.vo.GroupVO;
import io.swagger.v3.oas.annotations.Operation;
@ -62,8 +63,8 @@ public class GroupController {
@RepeatSubmit
@Operation(summary = "邀请进群", description = "邀请好友进群")
@PostMapping("/invite")
public Result invite(@Valid @RequestBody GroupInviteVO vo) {
groupService.invite(vo);
public Result invite(@Valid @RequestBody GroupInviteDTO dto) {
groupService.invite(dto);
return ResultUtils.success();
}
@ -74,6 +75,15 @@ public class GroupController {
return ResultUtils.success(groupService.findGroupMembers(groupId));
}
@RepeatSubmit
@Operation(summary = "将成员移出群聊", description = "将成员移出群聊")
@DeleteMapping("/members/remove")
public Result removeMembers(@Valid @RequestBody GroupMemberRemoveDTO dto) {
groupService.removeGroupMembers(dto);
return ResultUtils.success();
}
@RepeatSubmit
@Operation(summary = "退出群聊", description = "退出群聊")
@DeleteMapping("/quit/{groupId}")
@ -83,7 +93,7 @@ public class GroupController {
}
@RepeatSubmit
@Operation(summary = "踢出群聊", description = "将用户踢出群聊")
@Operation(summary = "踢出群聊(已废弃)", description = "将用户踢出群聊")
@DeleteMapping("/kick/{groupId}")
public Result kickGroup(@NotNull(message = "群聊id不能为空") @PathVariable Long groupId,
@NotNull(message = "用户id不能为空") @RequestParam Long userId) {

6
im-platform/src/main/java/com/bx/implatform/vo/GroupInviteVO.java → im-platform/src/main/java/com/bx/implatform/dto/GroupInviteDTO.java

@ -1,4 +1,4 @@
package com.bx.implatform.vo;
package com.bx.implatform.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
@ -9,8 +9,8 @@ import lombok.Data;
import java.util.List;
@Data
@Schema(description = "邀请好友进群请求VO")
public class GroupInviteVO {
@Schema(description = "邀请好友进群请求DTO")
public class GroupInviteDTO {
@NotNull(message = "群id不可为空")
@Schema(description = "群id")

28
im-platform/src/main/java/com/bx/implatform/dto/GroupMemberRemoveDTO.java

@ -0,0 +1,28 @@
package com.bx.implatform.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.util.List;
/**
* @author Blue
* @version 1.0
* @date 2025-02-23
*/
@Data
@Schema(description = "移除群聊成员")
public class GroupMemberRemoveDTO {
@NotNull(message = "群id不可为空")
@Schema(description = "群组id")
private Long groupId;
@Size(max = 50, message = "一次最多只能选择50位用户")
@NotEmpty(message = "成员用户id不可为空")
@Schema(description = "成员用户id")
private List<Long> userIds;
}

67
im-platform/src/main/java/com/bx/implatform/entity/FileInfo.java

@ -0,0 +1,67 @@
package com.bx.implatform.entity;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.util.Date;
/**
* @author Blue
* @version 1.0
*/
@Data
@TableName("im_file_info")
public class FileInfo {
/**
* 文件ID
*/
@TableId
private Long id;
/**
* 文件名
*/
private String fileName;
/**
* 原始文件存储路径
*/
private String filePath;
/**
* 压缩文件存储路径
*/
private String compressedPath;
/**
* 封面文件路径
*/
private String coverPath;
/**
* 原始文件大小(字节)
*/
private Long fileSize;
/**
* 上传时间
*/
private Date uploadTime;
/**
* 文件类型枚举: FileType
*/
private Integer fileType;
/**
* 是否永久存储
*/
private Boolean isPermanent;
/**
* 文件MD5哈希值
*/
private String md5;
}

6
im-platform/src/main/java/com/bx/implatform/enums/FileType.java

@ -16,11 +16,7 @@ public enum FileType {
/**
* 视频
*/
VIDEO(2, "视频"),
/**
* 声音
*/
AUDIO(3, "声音");
VIDEO(2, "视频");
private final Integer code;

8
im-platform/src/main/java/com/bx/implatform/mapper/FileInfoMapper.java

@ -0,0 +1,8 @@
package com.bx.implatform.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.bx.implatform.entity.FileInfo;
public interface FileInfoMapper extends BaseMapper<FileInfo> {
}

15
im-platform/src/main/java/com/bx/implatform/service/FileService.java

@ -0,0 +1,15 @@
package com.bx.implatform.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.bx.implatform.entity.FileInfo;
import com.bx.implatform.vo.UploadImageVO;
import org.springframework.web.multipart.MultipartFile;
public interface FileService extends IService<FileInfo> {
String uploadFile(MultipartFile file);
UploadImageVO uploadImage(MultipartFile file,Boolean isPermanent);
}

191
im-platform/src/main/java/com/bx/implatform/service/FileServiceImpl.java

@ -0,0 +1,191 @@
package com.bx.implatform.service;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.bx.implatform.config.props.MinioProperties;
import com.bx.implatform.contant.Constant;
import com.bx.implatform.entity.FileInfo;
import com.bx.implatform.enums.FileType;
import com.bx.implatform.enums.ResultCode;
import com.bx.implatform.exception.GlobalException;
import com.bx.implatform.mapper.FileInfoMapper;
import com.bx.implatform.session.SessionContext;
import com.bx.implatform.thirdparty.MinioService;
import com.bx.implatform.util.FileUtil;
import com.bx.implatform.util.ImageUtil;
import com.bx.implatform.vo.UploadImageVO;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.DigestUtils;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.Date;
import java.util.Objects;
/**
* 文件上传服务
*
* author: Blue date: 2024-09-28 version: 1.0
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class FileServiceImpl extends ServiceImpl<FileInfoMapper, FileInfo> implements FileService {
private final MinioService minioSerivce;
private final MinioProperties minioProps;
@PostConstruct
public void init() {
if (!minioSerivce.bucketExists(minioProps.getBucketName())) {
// 创建bucket
minioSerivce.makeBucket(minioProps.getBucketName());
// 公开bucket
minioSerivce.setBucketPublic(minioProps.getBucketName());
}
}
@Override
public String uploadFile(MultipartFile file) {
try {
Long userId = SessionContext.getSession().getUserId();
// 大小校验
if (file.getSize() > Constant.MAX_FILE_SIZE) {
throw new GlobalException(ResultCode.PROGRAM_ERROR, "文件大小不能超过20M");
}
// 如果文件已存在,直接复用
String md5 = DigestUtils.md5DigestAsHex(file.getInputStream());
FileInfo fileInfo = findByMd5(md5);
if (!Objects.isNull(fileInfo)) {
// 更新上传时间
fileInfo.setUploadTime(new Date());
this.updateById(fileInfo);
// 返回
return fileInfo.getFilePath();
}
// 上传
String fileName = minioSerivce.upload(minioProps.getBucketName(), minioProps.getFilePath(), file);
if (StringUtils.isEmpty(fileName)) {
throw new GlobalException(ResultCode.PROGRAM_ERROR, "文件上传失败");
}
String url = generUrl(FileType.FILE, fileName);
// 保存文件
saveFileInfo(file, md5, url);
log.info("文件文件成功,用户id:{},url:{}", userId, url);
return url;
} catch (IOException e) {
log.error("上传图片失败,{}", e.getMessage(), e);
throw new GlobalException(ResultCode.PROGRAM_ERROR, "上传图片失败");
}
}
@Transactional
@Override
public UploadImageVO uploadImage(MultipartFile file, Boolean isPermanent) {
try {
Long userId = SessionContext.getSession().getUserId();
// 大小校验
if (file.getSize() > Constant.MAX_IMAGE_SIZE) {
throw new GlobalException(ResultCode.PROGRAM_ERROR, "图片大小不能超过20M");
}
// 图片格式校验
if (!FileUtil.isImage(file.getOriginalFilename())) {
throw new GlobalException(ResultCode.PROGRAM_ERROR, "图片格式不合法");
}
UploadImageVO vo = new UploadImageVO();
// 如果文件已存在,直接复用
String md5 = DigestUtils.md5DigestAsHex(file.getInputStream());
FileInfo fileInfo = findByMd5(md5);
if (!Objects.isNull(fileInfo)) {
// 更新上传时间和持久化标记
fileInfo.setIsPermanent(isPermanent || fileInfo.getIsPermanent());
fileInfo.setUploadTime(new Date());
this.updateById(fileInfo);
// 返回
vo.setOriginUrl(fileInfo.getFilePath());
vo.setThumbUrl(fileInfo.getCompressedPath());
return vo;
}
// 上传原图
String fileName = minioSerivce.upload(minioProps.getBucketName(), minioProps.getImagePath(), file);
if (StringUtils.isEmpty(fileName)) {
throw new GlobalException(ResultCode.PROGRAM_ERROR, "图片上传失败");
}
vo.setOriginUrl(generUrl(FileType.IMAGE, fileName));
if (file.getSize() > 50 * 1024) {
// 大于50K的文件需上传缩略图
byte[] imageByte = ImageUtil.compressForScale(file.getBytes(), 30);
String thumbFileName = minioSerivce.upload(minioProps.getBucketName(), minioProps.getImagePath(),
file.getOriginalFilename(), imageByte, file.getContentType());
if (StringUtils.isEmpty(thumbFileName)) {
throw new GlobalException(ResultCode.PROGRAM_ERROR, "图片上传失败");
}
vo.setThumbUrl(generUrl(FileType.IMAGE, thumbFileName));
// 保存文件信息
saveImageFileInfo(file, md5, vo.getOriginUrl(), vo.getThumbUrl(), isPermanent);
}else{
// 小于50k,用原图充当缩略图
vo.setThumbUrl(generUrl(FileType.IMAGE, fileName));
// 保存文件信息,由于缩略图不允许删除,此时原图也不允许删除
saveImageFileInfo(file, md5, vo.getOriginUrl(), vo.getThumbUrl(), true);
}
log.info("文件图片成功,用户id:{},url:{}", userId, vo.getOriginUrl());
return vo;
} catch (IOException e) {
log.error("上传图片失败,{}", e.getMessage(), e);
throw new GlobalException(ResultCode.PROGRAM_ERROR, "图片上传失败");
}
}
private String generUrl(FileType fileType, String fileName) {
return StrUtil.join("/", minioProps.getDomain(), minioProps.getBucketName(), getBucketPath(fileType), fileName);
}
private String getBucketPath(FileType fileType) {
return switch (fileType) {
case FILE -> minioProps.getFilePath();
case IMAGE -> minioProps.getImagePath();
case VIDEO -> minioProps.getVideoPath();
};
}
private FileInfo findByMd5(String md5) {
LambdaQueryWrapper<FileInfo> wrapper = Wrappers.lambdaQuery();
wrapper.eq(FileInfo::getMd5, md5);
return getOne(wrapper);
}
private void saveImageFileInfo(MultipartFile file, String md5, String filePath, String compressedPath,
Boolean isPermanent) throws IOException {
FileInfo fileInfo = new FileInfo();
fileInfo.setFileName(file.getOriginalFilename());
fileInfo.setFileSize(file.getSize());
fileInfo.setFileType(FileType.IMAGE.code());
fileInfo.setFilePath(filePath);
fileInfo.setCompressedPath(compressedPath);
fileInfo.setMd5(md5);
fileInfo.setIsPermanent(isPermanent);
fileInfo.setUploadTime(new Date());
this.save(fileInfo);
}
private void saveFileInfo(MultipartFile file, String md5, String filePath) throws IOException {
FileInfo fileInfo = new FileInfo();
fileInfo.setFileName(file.getOriginalFilename());
fileInfo.setFileSize(file.getSize());
fileInfo.setFileType(FileType.FILE.code());
fileInfo.setFilePath(filePath);
fileInfo.setMd5(md5);
fileInfo.setIsPermanent(false);
fileInfo.setUploadTime(new Date());
this.save(fileInfo);
}
}

9
im-platform/src/main/java/com/bx/implatform/service/GroupMemberService.java

@ -16,6 +16,7 @@ public interface GroupMemberService extends IService<GroupMember> {
*/
GroupMember findByGroupAndUserId(Long groupId, Long userId);
/**
* 根据用户id查询群聊成员
*
@ -74,6 +75,14 @@ public interface GroupMemberService extends IService<GroupMember> {
*/
void removeByGroupAndUserId(Long groupId, Long userId);
/**
* 根据群聊id和用户id移除成员
*
* @param groupId 群聊id
* @param userIds 用户id
*/
void removeByGroupAndUserIds(Long groupId, List<Long> userIds);
/**
* 用户用户是否在群中
*

13
im-platform/src/main/java/com/bx/implatform/service/GroupService.java

@ -1,8 +1,9 @@
package com.bx.implatform.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.bx.implatform.dto.GroupMemberRemoveDTO;
import com.bx.implatform.entity.Group;
import com.bx.implatform.vo.GroupInviteVO;
import com.bx.implatform.dto.GroupInviteDTO;
import com.bx.implatform.vo.GroupMemberVO;
import com.bx.implatform.vo.GroupVO;
@ -48,6 +49,12 @@ public interface GroupService extends IService<Group> {
*/
void kickGroup(Long groupId, Long userId);
/**
* 将用户移出群聊
* @param dto dto
*/
void removeGroupMembers(GroupMemberRemoveDTO dto);
/**
* 查询当前用户的所有群聊
*
@ -58,9 +65,9 @@ public interface GroupService extends IService<Group> {
/**
* 邀请好友进群
*
* @param vo 群id好友id列表
* @param dto 群id好友id列表
**/
void invite(GroupInviteVO vo);
void invite(GroupInviteDTO dto);
/**
* 根据id查找群聊并进行缓存

30
im-platform/src/main/java/com/bx/implatform/service/impl/GroupMemberServiceImpl.java

@ -37,11 +37,13 @@ public class GroupMemberServiceImpl extends ServiceImpl<GroupMemberMapper, Group
@Override
public GroupMember findByGroupAndUserId(Long groupId, Long userId) {
QueryWrapper<GroupMember> wrapper = new QueryWrapper<>();
wrapper.lambda().eq(GroupMember::getGroupId, groupId).eq(GroupMember::getUserId, userId);
LambdaQueryWrapper<GroupMember> wrapper = Wrappers.lambdaQuery();
wrapper.eq(GroupMember::getGroupId, groupId);
wrapper.eq(GroupMember::getUserId, userId);
return this.getOne(wrapper);
}
@Override
public List<GroupMember> findByUserId(Long userId) {
LambdaQueryWrapper<GroupMember> memberWrapper = Wrappers.lambdaQuery();
@ -88,19 +90,35 @@ public class GroupMemberServiceImpl extends ServiceImpl<GroupMemberMapper, Group
@Override
public void removeByGroupAndUserId(Long groupId, Long userId) {
LambdaUpdateWrapper<GroupMember> wrapper = Wrappers.lambdaUpdate();
wrapper.eq(GroupMember::getGroupId, groupId).eq(GroupMember::getUserId, userId).set(GroupMember::getQuit, true)
.set(GroupMember::getQuitTime, new Date());
wrapper.eq(GroupMember::getGroupId, groupId);
wrapper.eq(GroupMember::getUserId, userId);
wrapper.set(GroupMember::getQuit, true);
wrapper.set(GroupMember::getQuitTime, new Date());
this.update(wrapper);
}
@CacheEvict(key = "#groupId")
@Override
public void removeByGroupAndUserIds(Long groupId, List<Long> userId) {
LambdaUpdateWrapper<GroupMember> wrapper = Wrappers.lambdaUpdate();
wrapper.eq(GroupMember::getGroupId, groupId);
wrapper.in(GroupMember::getUserId, userId);
wrapper.set(GroupMember::getQuit, true);
wrapper.set(GroupMember::getQuitTime, new Date());
this.update(wrapper);
}
@Override
public Boolean isInGroup(Long groupId, List<Long> userIds) {
if (CollectionUtils.isEmpty(userIds)) {
return true;
}
LambdaQueryWrapper<GroupMember> wrapper = Wrappers.lambdaQuery();
wrapper.eq(GroupMember::getGroupId, groupId).eq(GroupMember::getQuit, false)
.in(GroupMember::getUserId, userIds);
wrapper.eq(GroupMember::getGroupId, groupId);
wrapper.eq(GroupMember::getQuit, false);
wrapper.in(GroupMember::getUserId, userIds);
return userIds.size() == this.count(wrapper);
}
}

60
im-platform/src/main/java/com/bx/implatform/service/impl/GroupServiceImpl.java

@ -12,6 +12,7 @@ import com.bx.imcommon.model.IMUserInfo;
import com.bx.imcommon.util.CommaTextUtils;
import com.bx.implatform.contant.Constant;
import com.bx.implatform.contant.RedisKey;
import com.bx.implatform.dto.GroupMemberRemoveDTO;
import com.bx.implatform.entity.*;
import com.bx.implatform.enums.MessageStatus;
import com.bx.implatform.enums.MessageType;
@ -25,7 +26,7 @@ import com.bx.implatform.service.UserService;
import com.bx.implatform.session.SessionContext;
import com.bx.implatform.session.UserSession;
import com.bx.implatform.util.BeanUtils;
import com.bx.implatform.vo.GroupInviteVO;
import com.bx.implatform.dto.GroupInviteDTO;
import com.bx.implatform.vo.GroupMemberVO;
import com.bx.implatform.vo.GroupMessageVO;
import com.bx.implatform.vo.GroupVO;
@ -126,7 +127,7 @@ public class GroupServiceImpl extends ServiceImpl<GroupMapper, Group> implements
String content = String.format("'%s'解散了群聊", session.getNickName());
this.sendTipMessage(groupId, userIds, content, true);
// 推送同步消息
this.sendDelGroupMessage(groupId, userIds, false);
this.sendDelGroupMessage(groupId, userIds);
log.info("删除群聊,群聊id:{},群聊名称:{}", group.getId(), group.getName());
}
@ -145,7 +146,7 @@ public class GroupServiceImpl extends ServiceImpl<GroupMapper, Group> implements
// 推送退出群聊提示
this.sendTipMessage(groupId, List.of(userId), "您已退出群聊", false);
// 推送同步消息
this.sendDelGroupMessage(groupId, Lists.newArrayList(), true);
this.sendDelGroupMessage(groupId, List.of(userId));
log.info("退出群聊,群聊id:{},群聊名称:{},用户id:{}", group.getId(), group.getName(), userId);
}
@ -167,10 +168,35 @@ public class GroupServiceImpl extends ServiceImpl<GroupMapper, Group> implements
// 推送踢出群聊提示
this.sendTipMessage(groupId, List.of(userId), "您已被移出群聊", false);
// 推送同步消息
this.sendDelGroupMessage(groupId, List.of(userId), false);
this.sendDelGroupMessage(groupId, List.of(userId));
log.info("踢出群聊,群聊id:{},群聊名称:{},用户id:{}", group.getId(), group.getName(), userId);
}
@Override
public void removeGroupMembers(GroupMemberRemoveDTO dto) {
UserSession session = SessionContext.getSession();
Group group = this.getAndCheckById(dto.getGroupId());
if (!group.getOwnerId().equals(session.getUserId())) {
throw new GlobalException("您没有权限");
}
if (dto.getUserIds().contains(group.getOwnerId())) {
throw new GlobalException("不允许移除群主");
}
if (dto.getUserIds().contains(session.getUserId())) {
throw new GlobalException("不允许移除自己");
}
// 删除群聊成员
groupMemberService.removeByGroupAndUserIds(dto.getGroupId(), dto.getUserIds());
// 清理已读缓存
String key = StrUtil.join(":", RedisKey.IM_GROUP_READED_POSITION, dto.getGroupId());
dto.getUserIds().forEach(id -> redisTemplate.opsForHash().delete(key, id.toString()));
// 推送踢出群聊提示
this.sendTipMessage(dto.getGroupId(), dto.getUserIds(), "您已被移出群聊", false);
// 推送同步消息
this.sendDelGroupMessage(dto.getGroupId(), dto.getUserIds());
log.info("踢出群聊,群聊id:{},群聊名称:{},用户id:{}", group.getId(), group.getName(), dto.getUserIds());
}
@Override
public GroupVO findById(Long groupId) {
UserSession session = SessionContext.getSession();
@ -225,22 +251,22 @@ public class GroupServiceImpl extends ServiceImpl<GroupMapper, Group> implements
}
@Override
public void invite(GroupInviteVO vo) {
public void invite(GroupInviteDTO dto) {
UserSession session = SessionContext.getSession();
Group group = this.getAndCheckById(vo.getGroupId());
GroupMember member = groupMemberService.findByGroupAndUserId(vo.getGroupId(), session.getUserId());
Group group = this.getAndCheckById(dto.getGroupId());
GroupMember member = groupMemberService.findByGroupAndUserId(dto.getGroupId(), session.getUserId());
if (Objects.isNull(group) || member.getQuit()) {
throw new GlobalException("您不在群聊中,邀请失败");
}
// 群聊人数校验
List<GroupMember> members = groupMemberService.findByGroupId(vo.getGroupId());
List<GroupMember> members = groupMemberService.findByGroupId(dto.getGroupId());
long size = members.stream().filter(m -> !m.getQuit()).count();
if (vo.getFriendIds().size() + size > Constant.MAX_LARGE_GROUP_MEMBER) {
if (dto.getFriendIds().size() + size > Constant.MAX_LARGE_GROUP_MEMBER) {
throw new GlobalException("群聊人数不能大于" + Constant.MAX_LARGE_GROUP_MEMBER + "人");
}
// 找出好友信息
List<Friend> friends = friendsService.findByFriendIds(vo.getFriendIds());
if (vo.getFriendIds().size() != friends.size()) {
List<Friend> friends = friendsService.findByFriendIds(dto.getFriendIds());
if (dto.getFriendIds().size() != friends.size()) {
throw new GlobalException("部分用户不是您的好友,邀请失败");
}
// 批量保存成员数据
@ -248,7 +274,7 @@ public class GroupServiceImpl extends ServiceImpl<GroupMapper, Group> implements
Optional<GroupMember> optional =
members.stream().filter(m -> m.getUserId().equals(f.getFriendId())).findFirst();
GroupMember groupMember = optional.orElseGet(GroupMember::new);
groupMember.setGroupId(vo.getGroupId());
groupMember.setGroupId(dto.getGroupId());
groupMember.setUserId(f.getFriendId());
groupMember.setUserNickName(f.getFriendNickName());
groupMember.setHeadImage(f.getFriendHeadImage());
@ -265,12 +291,12 @@ public class GroupServiceImpl extends ServiceImpl<GroupMapper, Group> implements
sendAddGroupMessage(groupVo, List.of(m.getUserId()), false);
}
// 推送进入群聊消息
List<Long> userIds = groupMemberService.findUserIdsByGroupId(vo.getGroupId());
List<Long> userIds = groupMemberService.findUserIdsByGroupId(dto.getGroupId());
String memberNames = groupMembers.stream().map(GroupMember::getShowNickName).collect(Collectors.joining(","));
String content = String.format("'%s'邀请'%s'加入了群聊", session.getNickName(), memberNames);
this.sendTipMessage(vo.getGroupId(), userIds, content, true);
this.sendTipMessage(dto.getGroupId(), userIds, content, true);
log.info("邀请进入群聊,群聊id:{},群聊名称:{},被邀请用户id:{}", group.getId(), group.getName(),
vo.getFriendIds());
dto.getFriendIds());
}
@Override
@ -345,7 +371,7 @@ public class GroupServiceImpl extends ServiceImpl<GroupMapper, Group> implements
imClient.sendGroupMessage(sendMessage);
}
private void sendDelGroupMessage(Long groupId, List<Long> recvIds, Boolean sendToSelf) {
private void sendDelGroupMessage(Long groupId, List<Long> recvIds) {
UserSession session = SessionContext.getSession();
GroupMessageVO msgInfo = new GroupMessageVO();
msgInfo.setType(MessageType.GROUP_DEL.code());
@ -357,7 +383,7 @@ public class GroupServiceImpl extends ServiceImpl<GroupMapper, Group> implements
sendMessage.setRecvIds(recvIds);
sendMessage.setData(msgInfo);
sendMessage.setSendResult(false);
sendMessage.setSendToSelf(sendToSelf);
sendMessage.setSendToSelf(false);
imClient.sendGroupMessage(sendMessage);
}
}

121
im-platform/src/main/java/com/bx/implatform/service/thirdparty/FileService.java

@ -1,121 +0,0 @@
package com.bx.implatform.service.thirdparty;
import com.bx.implatform.config.props.MinioProperties;
import com.bx.implatform.contant.Constant;
import com.bx.implatform.enums.FileType;
import com.bx.implatform.enums.ResultCode;
import com.bx.implatform.exception.GlobalException;
import com.bx.implatform.session.SessionContext;
import com.bx.implatform.util.FileUtil;
import com.bx.implatform.util.ImageUtil;
import com.bx.implatform.util.MinioUtil;
import com.bx.implatform.vo.UploadImageVO;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.Objects;
/**
* todo 通过校验文件MD5实现重复文件秒传
* 文件上传服务
*
* @author Blue
* @date 2022/10/28
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class FileService {
private final MinioUtil minioUtil;
private final MinioProperties minioProps;
@PostConstruct
public void init() {
if (!minioUtil.bucketExists(minioProps.getBucketName())) {
// 创建bucket
minioUtil.makeBucket(minioProps.getBucketName());
// 公开bucket
minioUtil.setBucketPublic(minioProps.getBucketName());
}
}
public String uploadFile(MultipartFile file) {
Long userId = SessionContext.getSession().getUserId();
// 大小校验
if (file.getSize() > Constant.MAX_FILE_SIZE) {
throw new GlobalException(ResultCode.PROGRAM_ERROR, "文件大小不能超过20M");
}
// 上传
String fileName = minioUtil.upload(minioProps.getBucketName(), minioProps.getFilePath(), file);
if (StringUtils.isEmpty(fileName)) {
throw new GlobalException(ResultCode.PROGRAM_ERROR, "文件上传失败");
}
String url = generUrl(FileType.FILE, fileName);
log.info("文件文件成功,用户id:{},url:{}", userId, url);
return url;
}
public UploadImageVO uploadImage(MultipartFile file) {
try {
Long userId = SessionContext.getSession().getUserId();
// 大小校验
if (file.getSize() > Constant.MAX_IMAGE_SIZE) {
throw new GlobalException(ResultCode.PROGRAM_ERROR, "图片大小不能超过20M");
}
// 图片格式校验
if (!FileUtil.isImage(file.getOriginalFilename())) {
throw new GlobalException(ResultCode.PROGRAM_ERROR, "图片格式不合法");
}
// 上传原图
UploadImageVO vo = new UploadImageVO();
String fileName = minioUtil.upload(minioProps.getBucketName(), minioProps.getImagePath(), file);
if (StringUtils.isEmpty(fileName)) {
throw new GlobalException(ResultCode.PROGRAM_ERROR, "图片上传失败");
}
vo.setOriginUrl(generUrl(FileType.IMAGE, fileName));
// 大于30K的文件需上传缩略图
if (file.getSize() > 30 * 1024) {
byte[] imageByte = ImageUtil.compressForScale(file.getBytes(), 30);
fileName = minioUtil.upload(minioProps.getBucketName(), minioProps.getImagePath(), Objects.requireNonNull(file.getOriginalFilename()), imageByte, file.getContentType());
if (StringUtils.isEmpty(fileName)) {
throw new GlobalException(ResultCode.PROGRAM_ERROR, "图片上传失败");
}
}
vo.setThumbUrl(generUrl(FileType.IMAGE, fileName));
log.info("文件图片成功,用户id:{},url:{}", userId, vo.getOriginUrl());
return vo;
} catch (IOException e) {
log.error("上传图片失败,{}", e.getMessage(), e);
throw new GlobalException(ResultCode.PROGRAM_ERROR, "图片上传失败");
}
}
public String generUrl(FileType fileTypeEnum, String fileName) {
String url = minioProps.getDomain() + "/" + minioProps.getBucketName();
switch (fileTypeEnum) {
case FILE:
url += "/" + minioProps.getFilePath() + "/";
break;
case IMAGE:
url += "/" + minioProps.getImagePath() + "/";
break;
case VIDEO:
url += "/" + minioProps.getVideoPath() + "/";
break;
default:
break;
}
url += fileName;
return url;
}
}

78
im-platform/src/main/java/com/bx/implatform/task/schedule/FileExpireTask.java

@ -0,0 +1,78 @@
package com.bx.implatform.task.schedule;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.bx.implatform.annotation.RedisLock;
import com.bx.implatform.config.props.MinioProperties;
import com.bx.implatform.contant.RedisKey;
import com.bx.implatform.entity.FileInfo;
import com.bx.implatform.service.FileService;
import com.bx.implatform.thirdparty.MinioService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.time.DateUtils;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.Date;
import java.util.List;
/**
* 过期文件清理任务
*
* @author Blue
* @version 1.0
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class FileExpireTask {
private final FileService fileService;
private final MinioService minioService;
private final MinioProperties minioProps;
@RedisLock(prefixKey = RedisKey.IM_LOCK_FILE_TASK)
@Scheduled(cron = "0 * * * * ?")
public void run() {
log.info("【定时任务】过期文件处理...");
int batchSize = 100;
List<FileInfo> files = loadBatch(batchSize);
while (true) {
for (FileInfo fileInfo : files) {
String url = fileInfo.getFilePath();
String relativePath = url.substring(fileInfo.getFilePath().indexOf(minioProps.getBucketName()));
String[] arr = relativePath.split("/");
String bucket = minioProps.getBucketName();
String path = arr[1];
String fileNme = StrUtil.join("/", arr[2], arr[3]);
if (minioService.isExist(bucket, path, fileNme)) {
if (!minioService.remove(bucket, path, fileNme)) {
// 删除失败,不再往下执行
log.error("删除过期文件异常, id:{},文件名:{}", fileInfo.getId(), fileInfo.getFileName());
return;
}
// 删除文件信息
fileService.removeById(fileInfo.getId());
}
}
if (files.size() < batchSize) {
break;
}
// 下一批
files = loadBatch(batchSize);
}
}
List<FileInfo> loadBatch(int size) {
Date minDate = DateUtils.addDays(new Date(), -minioProps.getExpireIn());
LambdaQueryWrapper<FileInfo> wrapper = Wrappers.lambdaQuery();
wrapper.eq(FileInfo::getIsPermanent, false);
wrapper.le(FileInfo::getUploadTime, minDate);
wrapper.orderByAsc(FileInfo::getId);
wrapper.last("limit " + size);
return fileService.list(wrapper);
}
}

25
im-platform/src/main/java/com/bx/implatform/util/MinioUtil.java → im-platform/src/main/java/com/bx/implatform/thirdparty/MinioService.java

@ -1,5 +1,6 @@
package com.bx.implatform.util;
package com.bx.implatform.thirdparty;
import com.bx.implatform.util.DateTimeUtils;
import io.minio.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@ -14,7 +15,7 @@ import java.util.Date;
@Slf4j
@Component
@RequiredArgsConstructor
public class MinioUtil {
public class MinioService {
private final MinioClient minioClient;
@ -137,11 +138,29 @@ public class MinioUtil {
*/
public boolean remove(String bucketName, String path, String fileName) {
try {
minioClient.removeObject(RemoveObjectArgs.builder().bucket(bucketName).object(path + fileName).build());
minioClient.removeObject(RemoveObjectArgs.builder().bucket(bucketName).object(path + "/" + fileName).build());
} catch (Exception e) {
log.error("删除文件失败,", e);
return false;
}
return true;
}
/**
* 判断文件是否存在
*
* @param bucketName bucket名称
* @param path 路径
* @param fileName 文件名
* @return
*/
public Boolean isExist(String bucketName, String path, String fileName) {
try {
minioClient.statObject(StatObjectArgs.builder().bucket(bucketName).object(path + "/" + fileName).build());
} catch (Exception e) {
return false;
}
return true;
}
}

1
im-platform/src/main/resources/application-dev.yml

@ -18,6 +18,7 @@ minio:
imagePath: image
filePath: file
videoPath: video
expireIn: 180 # 文件过期时间,单位:天
webrtc:
max-channel: 9 # 多人通话最大通道数量,最大不能超过16,建议值:4,9,16

1
im-platform/src/main/resources/application-prod.yml

@ -19,6 +19,7 @@ minio:
imagePath: image
filePath: file
videoPath: video
expireIn: 180 # 文件过期时间,单位:天
webrtc:
max-channel: 9 # 多人通话最大通道数量,最大不能超过16,建议值:4,9,16

1
im-platform/src/main/resources/application-test.yml

@ -19,6 +19,7 @@ minio:
imagePath: image
filePath: file
videoPath: video
expireIn: 180 # 文件过期时间,单位:天
webrtc:
max-channel: 9 # 多人通话最大通道数量,最大不能超过16,建议值:4,9,16

BIN
im-platform/src/main/resources/static/favicon.ico

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 22 KiB

1
im-uniapp/App.vue

@ -407,7 +407,6 @@ export default {
// #ifdef APP-PLUS
//
setTimeout(() => {
console.log("plus.navigator.closeSplashscreen()")
plus.navigator.closeSplashscreen()
}, delay)
// #endif

7
im-uniapp/common/recorder-h5.js

@ -26,7 +26,6 @@ let checkIsEnable = () => {
let start = () => {
return navigator.mediaDevices.getUserMedia({ audio: true }).then(audioStream => {
console.log("start record")
startTime = new Date().getTime();
chunks = [];
stream = audioStream;
@ -36,7 +35,6 @@ let start = () => {
}
let close = () => {
console.log("stream:", stream)
stream.getTracks().forEach((track) => {
track.stop()
})
@ -47,9 +45,6 @@ let close = () => {
let upload = () => {
return new Promise((resolve, reject) => {
rc.ondataavailable = (e) => {
console.log("ondataavailable:",e.data)
console.log("size:",e.data.size)
console.log("type:",e.data.type)
chunks.push(e.data)
}
rc.onstop = () => {
@ -58,8 +53,6 @@ let upload = () => {
return;
}
duration = (new Date().getTime() - startTime) / 1000;
console.log("时长:", duration)
console.log("上传,chunks:", chunks.length)
const newbolb = new Blob(chunks, { 'type': 'audio/mpeg' });
const name = new Date().getDate() + '.mp3';
const file = new File([newbolb], name)

9
im-uniapp/components/chat-item/chat-item.vue

@ -9,7 +9,6 @@
<view class="chat-name">
<view class="chat-name-text">
<view>{{ chat.showName }}</view>
<uni-tag v-if="chat.type == 'GROUP'" circle text="群" size="small" type="primary"></uni-tag>
</view>
<view class="chat-time">{{ $date.toTimeText(chat.lastSendTime, true) }}</view>
</view>
@ -138,14 +137,6 @@ export default {
overflow: hidden;
display: flex;
align-items: center;
.uni-tag {
text-align: center;
margin-left: 5rpx;
border: 0;
padding: 1px 5px;
//opacity: 0.8;
}
}
.chat-time {

30
im-uniapp/components/group-member-selector/group-member-selector.vue

@ -42,12 +42,15 @@
export default {
name: "chat-group-member-choose",
props: {
group: {
type: Object
},
members: {
type: Array
},
maxSize: {
type: Number,
default: -1
default: 50
}
},
data() {
@ -56,10 +59,11 @@ export default {
};
},
methods: {
init(checkedIds, lockedIds) {
init(checkedIds, lockedIds, hideIds) {
this.members.forEach((m) => {
m.checked = checkedIds.indexOf(m.userId) >= 0;
m.locked = lockedIds.indexOf(m.userId) >= 0;
m.hide = hideIds.indexOf(m.userId) >= 0;
});
},
open() {
@ -80,7 +84,7 @@ export default {
},
onClean() {
this.members.forEach((m) => {
if (!m.locked) {
if (!m.locked && m.checked) {
m.checked = false;
}
})
@ -88,20 +92,17 @@ export default {
onOk() {
this.$refs.popup.close();
this.$emit("complete", this.checkedIds)
},
isChecked(m) {
return this.checkedIds.indexOf(m.userId) >= 0;
}
},
computed: {
checkedIds() {
return this.members.filter((m) => m.checked).map(m => m.userId)
return this.checkedMembers.map(m => m.userId)
},
checkedMembers() {
return this.members.filter((m) => m.checked);
return this.members.filter((m) => !m.quit && !m.hide && m.checked);
},
showMembers() {
return this.members.filter(m => !m.quit && m.showNickName.includes(this.searchText))
return this.members.filter(m => !m.quit && !m.hide && m.showNickName.includes(this.searchText))
}
}
}
@ -115,7 +116,8 @@ export default {
flex-direction: column;
background-color: white;
padding: 10rpx;
border-radius: 15rpx;
border-radius: 15rpx 15rpx 0 0;
overflow: hidden;
.top-bar {
display: flex;
@ -158,6 +160,8 @@ export default {
white-space: nowrap;
.member-name {
display: flex;
align-items: center;
flex: 1;
padding-left: 20rpx;
font-size: 30rpx;
@ -165,11 +169,15 @@ export default {
line-height: 60rpx;
white-space: nowrap;
overflow: hidden;
.uni-tag {
margin-left: 5rpx;
}
}
}
.scroll-bar {
height: 800rpx;
height: 65vh;
}
}
}

7
im-uniapp/components/image-upload/image-upload.vue

@ -29,6 +29,10 @@ export default {
type: String,
default: 'album'
},
isPermanent: {
type: Boolean,
default: false
},
onBefore: {
type: Function,
default: null
@ -50,7 +54,6 @@ export default {
sizeType: ['original'], //original compressed
success: (res) => {
res.tempFiles.forEach((file) => {
console.log("文件:", file)
if (!this.onBefore || this.onBefore(file)) {
//
this.uploadImage(file);
@ -61,7 +64,7 @@ export default {
},
uploadImage(file) {
uni.uploadFile({
url: UNI_APP.BASE_URL + '/image/upload',
url: UNI_APP.BASE_URL + '/image/upload?isPermanent=' + this.isPermanent,
header: {
accessToken: uni.getStorageSync("loginInfo").accessToken
},

1
im-uniapp/components/virtual-scroller/virtual-scroller.vue

@ -28,7 +28,6 @@ export default {
},
methods: {
onScrollToBottom(e) {
console.log("onScrollToBottom")
if (this.showMaxIdx >= this.items.length) {
this.showTip();
} else {

5
im-uniapp/pages/chat/chat-box.vue

@ -733,7 +733,6 @@ export default {
h -= 50;
//
if (this.isShowKeyBoard || this.chatTabBox != 'none') {
console.log("减去键盘高度:", this.keyboardHeight)
h -= this.keyboardHeight;
this.scrollToBottom();
}
@ -742,7 +741,6 @@ export default {
h -= uni.getSystemInfoSync().statusBarHeight;
// #endif
this.chatMainHeight = h;
console.log("窗口高度:", this.chatMainHeight)
if (this.isShowKeyBoard || this.chatTabBox != 'none') {
this.scrollToBottom();
}
@ -806,7 +804,6 @@ export default {
this.reCalChatMainHeight();
},
resizeListener() {
console.log("resize")
let keyboardHeight = this.initHeight - window.innerHeight;
this.isShowKeyBoard = keyboardHeight > 150;
if (this.isShowKeyBoard) {
@ -815,12 +812,10 @@ export default {
this.reCalChatMainHeight();
},
focusInListener() {
console.log("focusInListener")
this.isShowKeyBoard = true;
this.reCalChatMainHeight();
},
focusOutListener() {
console.log("focusOutListener")
this.isShowKeyBoard = false;
this.reCalChatMainHeight();
},

1
im-uniapp/pages/chat/chat-group-video.vue

@ -76,7 +76,6 @@ export default {
},
},
onBackPress() {
console.log("onBackPress")
this.sendMessageToWebView("NAV_BACK", {})
},
onLoad(options) {

2
im-uniapp/pages/group/group-edit.vue

@ -5,7 +5,7 @@
<view class="form-item">
<view class="label">群聊头像</view>
<view class="value"></view>
<image-upload v-if="isOwner" :onSuccess="onUnloadImageSuccess">
<image-upload v-if="isOwner" :isPermanent="true" :onSuccess="onUnloadImageSuccess">
<image :src="group.headImageThumb" class="group-image"></image>
</image-upload>
<head-image v-else class="group-image" :name="group.showGroupName" :url="group.headImageThumb"

62
im-uniapp/pages/group/group-info.vue

@ -4,7 +4,7 @@
<view v-if="!group.quit" class="group-members">
<view class="member-items">
<view v-for="(member, idx) in groupMembers" :key="idx">
<view class="member-item" v-if="idx < 9">
<view class="member-item" v-if="idx < showMaxIdx">
<head-image :id="member.userId" :name="member.showNickName" :url="member.headImage" size="small"
:online="member.online"></head-image>
<view class="member-name">
@ -12,8 +12,17 @@
</view>
</view>
</view>
<view class="invite-btn" @click="onInviteMember()">
<uni-icons type="plusempty" size="20" color="#888888"></uni-icons>
<view class="member-item" @click="onInviteMember()">
<view class="tools-btn">
<uni-icons class="icon" type="plusempty" color="#888888"></uni-icons>
</view>
<view class="member-name">邀请</view>
</view>
<view v-if="isOwner" class="member-item" @click="onRemoveMember()">
<view class="tools-btn">
<text class="icon iconfont icon-remove"></text>
</view>
<view class="member-name">移除</view>
</view>
</view>
<view class="member-more" @click="onShowMoreMmeber()">{{ `查看全部群成员${groupMembers.length}` }}></view>
@ -41,15 +50,15 @@
<view v-if="group.notice" class="form-item">
<uni-notice-bar :text="group.notice" />
</view>
<view v-if="!group.quit" class="group-edit" @click="onEditGroup()">修改群聊资料 > </view>
</view>
<bar-group v-if="!group.quit">
<btn-bar type="primary" title="发送消息" @tap="onSendMessage()"></btn-bar>
<btn-bar v-if="!isOwner" type="danger" title="退出群聊" @tap="onQuitGroup()"></btn-bar>
<btn-bar v-if="isOwner" type="danger" title="解散群聊" @tap="onDissolveGroup()"></btn-bar>
</bar-group>
<group-member-selector ref="removeSelector" :members="groupMembers" :group="group"
@complete="onRemoveComplete"></group-member-selector>
</view>
</template>
@ -69,6 +78,29 @@ export default {
url: `/pages/group/group-invite?id=${this.groupId}`
})
},
onRemoveMember() {
//
let hideIds = [this.group.ownerId];
this.$refs.removeSelector.init([], [], hideIds);
this.$refs.removeSelector.open();
},
onRemoveComplete(userIds) {
let data = {
groupId: this.group.id,
userIds: userIds
}
this.$http({
url: "/group/members/remove",
method: 'DELETE',
data: data
}).then(() => {
this.loadGroupMembers();
uni.showToast({
title: `您移除了${userIds.length}位成员`,
icon: 'none'
})
})
},
onShowMoreMmeber() {
uni.navigateTo({
url: `/pages/group/group-member?id=${this.groupId}`
@ -160,7 +192,6 @@ export default {
});
},
loadGroupMembers() {
console.log("loadGroupMembers")
this.$http({
url: `/group/members/${this.groupId}`,
method: "GET"
@ -176,6 +207,9 @@ export default {
},
isOwner() {
return this.group.ownerId == this.userStore.userInfo.id;
},
showMaxIdx() {
return this.isOwner ? 8 : 9;
}
},
onLoad(options) {
@ -218,17 +252,21 @@ export default {
padding-top: 8rpx;
font-size: $im-font-size-smaller;
}
}
.invite-btn {
.tools-btn {
display: flex;
justify-content: center;
align-items: center;
width: 86rpx;
height: 86rpx;
margin: 10rpx;
border: $im-border solid 2rpx;
border: $im-border solid 1rpx;
border-radius: 10%;
width: 80rpx;
height: 80rpx;
.icon {
font-size: 40rpx !important;
color: $im-text-color-lighter !important;
}
}
}
}

34
im-uniapp/pages/group/group-member.vue

@ -13,14 +13,9 @@
<view class="member-item" @click="onShowUserInfo(item.userId)">
<head-image :name="item.showNickName" :online="item.online" :url="item.headImage"></head-image>
<view class="member-name">{{ item.showNickName }}
<uni-tag v-if="item.userId == group.ownerId" text="群主" size="small" circle type="error">
</uni-tag>
<uni-tag v-if="item.userId == group.ownerId" text="群主" size="small" circle type="error"></uni-tag>
<uni-tag v-if="item.userId == userStore.userInfo.id" text="我" size="small" circle></uni-tag>
</view>
<view class="member-kick">
<button type="warn" plain v-show="isOwner && !isSelf(item.userId)" size="mini"
@click.stop="onKickOut(item)">移出群聊</button>
</view>
</view>
</template>
</virtual-scroller>
@ -44,27 +39,6 @@ export default {
url: "/pages/common/user-info?id=" + userId
})
},
onKickOut(member) {
uni.showModal({
title: '确认移出?',
content: `确定将成员'${member.showNickName}'移出群聊吗?`,
success: (res) => {
if (res.cancel)
return;
this.$http({
url: `/group/kick/${this.group.id}?userId=${member.userId}`,
method: 'DELETE'
}).then(() => {
uni.showToast({
title: `已将${member.showNickName}移出群聊`,
icon: 'none'
})
member.quit = true;
this.isModify = true;
});
}
})
},
loadGroupInfo(id) {
this.$http({
url: `/group/find/${id}`,
@ -141,12 +115,6 @@ export default {
.uni-tag {
margin-left: 5rpx;
width: 40rpx;
border: 0;
height: 30rpx;
line-height: 30rpx;
font-size: 20rpx;
text-align: center;
}
}
}

2
im-uniapp/pages/mine/mine-edit.vue

@ -4,7 +4,7 @@
<view class="form">
<view class="form-item">
<view class="label">头像</view>
<image-upload class="value" :onSuccess="onUnloadImageSuccess">
<image-upload class="value" :isPermanent="true" :onSuccess="onUnloadImageSuccess">
<image :src="userInfo.headImageThumb" class="head-image"></image>
</image-upload>
</view>

1
im-uniapp/pages/mine/mine.vue

@ -56,7 +56,6 @@ export default {
title: '确认退出?',
success: (res) => {
if (res.confirm) {
console.log(getApp())
getApp().$vm.exit()
}
}

1
im-uniapp/pages/register/register.vue

@ -59,7 +59,6 @@ export default {
errorMessage: '请输入确认密码',
}, {
validateFunction: (rule, value, data, callback) => {
console.log("validateFunction")
if (data.password != value) {
callback('两次密码输入不一致')
}

42
im-uniapp/static/icon/iconfont.css

@ -1,6 +1,6 @@
@font-face {
font-family: "iconfont"; /* Project id 4272106 */
src: url('iconfont.ttf?t=1739084401359') format('truetype');
src: url('iconfont.ttf?t=1746119818070') format('truetype');
}
.iconfont {
@ -11,6 +11,46 @@
-moz-osx-font-smoothing: grayscale;
}
.icon-remove:before {
content: "\e603";
}
.icon-doc:before {
content: "\e61c";
}
.icon-image:before {
content: "\e7f7";
}
.icon-top-message:before {
content: "\e6ff";
}
.icon-setting:before {
content: "\e851";
}
.icon-phone:before {
content: "\e692";
}
.icon-email:before {
content: "\e611";
}
.icon-username:before {
content: "\e60f";
}
.icon-chat-muted:before {
content: "\e634";
}
.icon-chat-unmuted:before {
content: "\ec44";
}
.icon-privacy-protocol:before {
content: "\e70a";
}

BIN
im-uniapp/static/icon/iconfont.ttf

Binary file not shown.

1
im-uniapp/store/userStore.js

@ -20,7 +20,6 @@ export default defineStore('userStore', {
url: '/user/self',
method: 'GET'
}).then((userInfo) => {
console.log(userInfo)
this.setUserInfo(userInfo);
resolve();
}).catch((res) => {

1
im-web/src/api/camera.js

@ -21,7 +21,6 @@ ImCamera.prototype.openVideo = function () {
noiseSuppression: true // 开启降噪
}
}
console.log("getUserMedia")
navigator.mediaDevices.getUserMedia(constraints).then((stream) => {
console.log("摄像头打开")
this.stream = stream;

38
im-web/src/assets/iconfont/iconfont.css

@ -1,6 +1,6 @@
@font-face {
font-family: "iconfont"; /* Project id 3791506 */
src: url('iconfont.ttf?t=1718373714629') format('truetype');
src: url('iconfont.ttf?t=1745933248800') format('truetype');
}
.iconfont {
@ -11,6 +11,42 @@
-moz-osx-font-smoothing: grayscale;
}
.icon-man:before {
content: "\e615";
}
.icon-girl:before {
content: "\e602";
}
.icon-no-data:before {
content: "\e61b";
}
.icon-phone:before {
content: "\e692";
}
.icon-email:before {
content: "\e610";
}
.icon-username:before {
content: "\e60f";
}
.icon-chat-unmuted:before {
content: "\ec44";
}
.icon-chat-muted:before {
content: "\e634";
}
.icon-modify:before {
content: "\e60d";
}
.icon-invite-rtc:before {
content: "\e65f";
}

BIN
im-web/src/assets/iconfont/iconfont.ttf

Binary file not shown.

4
im-web/src/components/chat/ChatAtBox.vue

@ -88,13 +88,13 @@ export default {
this.close();
},
scrollToActive() {
if (this.activeIdx * 35 - this.$refs.scrollBox.wrap.clientHeight > this.$refs.scrollBox.wrap.scrollTop) {
if (this.activeIdx * 40 - this.$refs.scrollBox.wrap.clientHeight > this.$refs.scrollBox.wrap.scrollTop) {
this.$refs.scrollBox.wrap.scrollTop += 140;
if (this.$refs.scrollBox.wrap.scrollTop > this.$refs.scrollBox.wrap.scrollHeight) {
this.$refs.scrollBox.wrap.scrollTop = this.$refs.scrollBox.wrap.scrollHeight
}
}
if (this.activeIdx * 35 < this.$refs.scrollBox.wrap.scrollTop) {
if (this.activeIdx * 40 < this.$refs.scrollBox.wrap.scrollTop) {
this.$refs.scrollBox.wrap.scrollTop -= 140;
if (this.$refs.scrollBox.wrap.scrollTop < 0) {
this.$refs.scrollBox.wrap.scrollTop = 0;

12
im-web/src/components/chat/ChatBox.vue

@ -480,7 +480,7 @@ export default {
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
this.$store.commit("deleteMessage", msgInfo);
this.$store.commit("deleteMessage", [msgInfo, this.chat]);
});
},
recallMessage(msgInfo) {
@ -567,13 +567,15 @@ export default {
})
},
showName(msgInfo) {
if (this.chat.type == 'GROUP') {
if (!msgInfo) {
return ""
}
if (this.isGroup) {
let member = this.groupMembers.find((m) => m.userId == msgInfo.sendId);
return member ? member.showNickName : "";
return member ? member.showNickName : msgInfo.sendNickName || "";
} else {
return msgInfo.sendId == this.mine.id ? this.mine.nickName : this.chat.showName
return msgInfo.selfSend ? this.mine.nickName : this.chat.showName
}
},
headImage(msgInfo) {
if (this.chat.type == 'GROUP') {

4
im-web/src/components/chat/ChatGroupMember.vue

@ -48,6 +48,10 @@ export default {
white-space: nowrap;
box-sizing: border-box;
&.active {
background: #E1EAF7;
}
.member-name {
padding-left: 10px;
height: 100%;

73
im-web/src/components/chat/ChatGroupSide.vue

@ -5,20 +5,26 @@
<i class="el-icon-search el-input__icon" slot="prefix"> </i>
</el-input>
</div>
<div class="group-side-scrollbar">
<el-scrollbar v-show="!group.quit" ref="scrollbar" :style="'height: ' + scrollHeight + 'px'">
<div class="group-side-member-list">
<div class="group-side-invite">
<div class="invite-member-btn" title="邀请好友进群聊" @click="showAddGroupMember = true">
<div class="member-list">
<div class="member-tools">
<div class="tool-btn" title="邀请好友进群聊" @click="onInvite()">
<i class="el-icon-plus"></i>
</div>
<div class="invite-member-text">邀请</div>
<add-group-member :visible="showAddGroupMember" :groupId="group.id" :members="groupMembers"
@reload="$emit('reload')" @close="showAddGroupMember = false"></add-group-member>
<div class="tool-text">邀请</div>
<add-group-member ref="addGroupMember" :groupId="group.id" :members="groupMembers"
@reload="$emit('reload')"></add-group-member>
</div>
<div class="member-tools" v-if="isOwner">
<div class="tool-btn" title="选择成员移出群聊" @click="onRemove()">
<i class="el-icon-minus"></i>
</div>
<div class="tool-text">移除</div>
<group-member-selector ref="removeSelector" title="选择成员进行移除" :group="group"
@complete="onRemoveComplete"></group-member-selector>
</div>
<div v-for="(member, idx) in showMembers" :key="member.id">
<group-member v-if="idx < showMaxIdx" class="group-side-member" :member="member"
:showDel="false"></group-member>
<group-member v-if="idx < showMaxIdx" class="group-side-member" :member="member"></group-member>
</div>
</div>
</el-scrollbar>
@ -46,25 +52,24 @@
</div>
</el-form>
</div>
</div>
</template>
<script>
import AddGroupMember from '../group/AddGroupMember.vue';
import GroupMember from '../group/GroupMember.vue';
import GroupMemberSelector from '../group/GroupMemberSelector.vue';
export default {
name: "chatGroupSide",
components: {
AddGroupMember,
GroupMember
GroupMember,
GroupMemberSelector
},
data() {
return {
searchText: "",
editing: false,
showAddGroupMember: false,
showMaxIdx: 50
}
},
@ -80,6 +85,29 @@ export default {
onClose() {
this.$emit('close');
},
onInvite() {
this.$refs.addGroupMember.open()
},
onRemove() {
//
let hideIds = [this.group.ownerId];
this.$refs.removeSelector.open(50, [], [], hideIds);
},
onRemoveComplete(members) {
let userIds = members.map(m => m.userId);
let data = {
groupId: this.group.id,
userIds: userIds
}
this.$http({
url: "/group/members/remove",
method: 'delete',
data: data
}).then(() => {
this.$emit('reload');
this.$message.success(`您移除了${userIds.length}位成员`);
})
},
loadGroupMembers() {
this.$http({
url: `/group/members/${this.group.id}`,
@ -127,12 +155,15 @@ export default {
}
},
computed: {
mine() {
return this.$store.state.userStore.userInfo;
},
ownerName() {
let member = this.groupMembers.find((m) => m.userId == this.group.ownerId);
return member && member.showNickName;
},
isOwner() {
return this.group.ownerId == this.$store.state.userStore.userInfo.id;
return this.group.ownerId == this.mine.id;
},
showMembers() {
return this.groupMembers.filter((m) => !m.quit && m.showNickName.includes(this.searchText))
@ -156,9 +187,6 @@ export default {
padding: 10px;
}
.group-side-scrollbar {
overflow: auto;
}
.el-divider--horizontal {
margin: 0;
@ -168,7 +196,7 @@ export default {
margin-bottom: 0px !important;
}
.group-side-member-list {
.member-list {
padding: 10px;
display: flex;
align-items: center;
@ -180,14 +208,15 @@ export default {
margin-left: 5px;
}
.group-side-invite {
.member-tools {
display: flex;
flex-direction: column;
align-items: center;
width: 50px;
width: 54px;
margin-left: 5px;
.invite-member-btn {
.tool-btn {
width: 38px;
height: 38px;
line-height: 38px;
@ -201,7 +230,7 @@ export default {
}
}
.invite-member-text {
.tool-text {
font-size: 12px;
text-align: center;
width: 100%;

7
im-web/src/components/chat/ChatInput.vue

@ -117,7 +117,6 @@ export default {
e.preventDefault();
e.stopPropagation();
if (this.atIng) {
console.log('选中at的人')
this.$refs.atBox.select();
return;
}
@ -137,12 +136,10 @@ export default {
}
//
if (e.keyCode === 8) {
console.log("delete")
// dom
setTimeout(() => {
let s = this.$refs.content.innerHTML.trim();
// domdom
console.log(s);
if (s === '' || s === '<br>' || s === '<div>&nbsp;</div>') {
// dom
this.empty();
@ -179,7 +176,6 @@ export default {
blurRange.setEnd(blurRange.endContainer, endOffset);
blurRange.deleteContents()
blurRange.collapse();
console.log("onAtSelect")
this.focus();
//
let element = document.createElement('SPAN')
@ -238,8 +234,9 @@ export default {
},
onBlur(e) {
if(!this.atIng){
this.updateRange();
}
},
onMousedown() {
if (this.atIng) {

18
im-web/src/components/chat/ChatItem.vue

@ -19,8 +19,7 @@
<div class="chat-content-text" v-html="$emo.transform(chat.lastContent,'emoji-small')"></div>
</div>
</div>
<right-menu v-show="rightMenu.show" :pos="rightMenu.pos" :items="rightMenu.items"
@close="rightMenu.show = false" @select="onSelectMenu"></right-menu>
<right-menu ref="rightMenu" @select="onSelectMenu"></right-menu>
</div>
</template>
@ -37,13 +36,7 @@ export default {
},
data() {
return {
rightMenu: {
show: false,
pos: {
x: 0,
y: 0
},
items: [{
menuItems: [{
key: 'TOP',
name: '置顶',
icon: 'el-icon-top'
@ -53,7 +46,6 @@ export default {
icon: 'el-icon-delete'
}]
}
}
},
props: {
chat: {
@ -68,11 +60,7 @@ export default {
},
methods: {
showRightMenu(e) {
this.rightMenu.pos = {
x: e.x,
y: e.y
};
this.rightMenu.show = "true";
this.$refs.rightMenu.open(e, this.menuItems);
},
onSelectMenu(item) {
this.$emit(item.key.toLowerCase(), this.msgInfo);

18
im-web/src/components/chat/ChatMessageItem.vue

@ -70,8 +70,7 @@
</div>
</div>
</div>
<right-menu v-show="menu && rightMenu.show" :pos="rightMenu.pos" :items="menuItems"
@close="rightMenu.show = false" @select="onSelectMenu"></right-menu>
<right-menu ref="rightMenu" @select="onSelectMenu"></right-menu>
<chat-group-readed ref="chatGroupReadedBox" :msgInfo="msgInfo" :groupMembers="groupMembers"></chat-group-readed>
</div>
</template>
@ -118,14 +117,7 @@ export default {
},
data() {
return {
audioPlayState: 'STOP',
rightMenu: {
show: false,
pos: {
x: 0,
y: 0
}
}
audioPlayState: 'STOP'
}
},
methods: {
@ -147,11 +139,7 @@ export default {
this.onPlayVoice = 'RUNNING';
},
showRightMenu(e) {
this.rightMenu.pos = {
x: e.x,
y: e.y
};
this.rightMenu.show = "true";
this.$refs.rightMenu.open(e, this.menuItems);
},
onSelectMenu(item) {
this.$emit(item.key.toLowerCase(), this.msgInfo);

6
im-web/src/components/common/FileUpload.vue

@ -33,6 +33,10 @@ export default {
type: Boolean,
default: false
},
isPermanent: {
type: Boolean,
default: false
},
disabled: {
type: Boolean,
default: false
@ -52,7 +56,7 @@ export default {
let formData = new FormData()
formData.append('file', file.file)
this.$http({
url: this.action,
url: this.action + '?isPermanent=' + this.isPermanent,
data: formData,
method: 'post',
headers: {

14
im-web/src/components/common/HeadImage.vue

@ -2,8 +2,7 @@
<div class="head-image" @click="showUserInfo($event)" :style="{ cursor: isShowUserInfo ? 'pointer' : null }">
<img class="avatar-image" v-show="url" :src="url" :style="avatarImageStyle" loading="lazy" />
<div class="avatar-text" v-show="!url" :style="avatarTextStyle">
{{ name?.substring(0, 2).toUpperCase() }}
</div>
{{ name?.substring(0, 2).toUpperCase() }}</div>
<div v-show="online" class="online" title="用户当前在线"></div>
<slot></slot>
</div>
@ -15,8 +14,7 @@ export default {
data() {
return {
colors: ["#5daa31", "#c7515a", "#e03697", "#85029b",
"#c9b455", "#326eb6"
]
"#c9b455", "#326eb6"]
}
},
props: {
@ -61,7 +59,11 @@ export default {
url: `/user/find/${this.id}`,
method: 'get'
}).then((user) => {
this.$store.commit("setUserInfoBoxPos", e);
let pos = {
x: e.x + 30,
y: e.y
}
this.$store.commit("setUserInfoBoxPos", pos);
this.$store.commit("showUserInfoBox", user);
})
}
@ -111,8 +113,6 @@ export default {
display: flex;
align-items: center;
justify-content: center;
//border: 1px solid #ccc;
//box-shadow: var(--im-box-shadow);
}
.online {

24
im-web/src/components/common/RightMenu.vue

@ -1,10 +1,9 @@
<template>
<div class="right-menu-mask" @click.stop="close()" @contextmenu.prevent="close()">
<div v-if="show" class="right-menu-mask" @click.stop="close()" @contextmenu.prevent="close()">
<div class="right-menu" :style="{ 'left': pos.x + 'px', 'top': pos.y + 'px' }">
<el-menu text-color="#333333">
<el-menu-item v-for="(item) in items" :key="item.key" :title="item.name"
@click.native.stop="onSelectMenu(item)">
<!-- <span :class="item.icon"></span>-->
<span>{{ item.name }}</span>
</el-menu-item>
</el-menu>
@ -16,19 +15,23 @@
export default {
name: "rightMenu",
data() {
return {}
},
props: {
return {
show: false,
pos: {
type: Object
x: 0,
y: 0,
},
items: {
type: Array
items: []
}
},
methods: {
open(pos, items) {
this.pos = pos;
this.items = items;
this.show = true;
},
close() {
this.$emit("close");
this.show = false;
},
onSelectMenu(item) {
this.$emit("select", item);
@ -64,8 +67,7 @@ export default {
height: 36px;
line-height: 36px;
min-width: 100px;
text-align: left;
padding: 0 0 0 20px;
text-align: center;
&:hover {
background-color: var(--im-background-active);

89
im-web/src/components/common/UserInfo.vue

@ -1,27 +1,32 @@
<template>
<div class="user-info-mask" @click="$emit('close')">
<div class="user-info" :style="{ left: pos.x + 'px', top: pos.y + 'px' }" @click.stop>
<div class="user-info-box">
<div class="avatar">
<head-image :name="user.nickName" :url="user.headImageThumb" :size="70" :online="user.online"
radius="10%" @click.native="showFullImage()"> </head-image>
<head-image :name="user.nickName" :url="user.headImageThumb" :size="60" :online="user.online"
@click.native="showFullImage()" radius="10%"> </head-image>
</div>
<div class="info-card">
<div class="header">
<div class="nick-name">{{ user.nickName }}</div>
<div v-if="user.sex == 0" class="icon iconfont icon-man" style="color: darkblue;"></div>
<div v-if="user.sex == 1" class="icon iconfont icon-girl" style="color: darkred;"></div>
</div>
<div class="info-item">
用户名: {{ user.userName }}
</div>
<div class="info-item">
个性签名: {{ user.signature }}
</div>
<div>
<el-descriptions :column="1" :title="user.nickName" class="user-info-items">
<el-descriptions-item label="用户名">{{ user.userName }}
</el-descriptions-item>
<el-descriptions-item label="签名">{{ user.signature }}
</el-descriptions-item>
</el-descriptions>
</div>
</div>
<el-divider content-position="center"></el-divider>
<div class="user-btn-group">
<el-button v-show="isFriend" type="primary" @click="onSendMessage()">发消息</el-button>
<el-button v-show="!isFriend" type="primary" @click="onAddFriend()">加为好友</el-button>
</div>
<el-button v-if="isFriend" type="primary" @click="onSendMessage()">发消息</el-button>
<el-button v-else type="primary" @click="onAddFriend()">加为好友</el-button>
</div>
</div>
</template>
<script>
@ -62,23 +67,7 @@ export default {
this.$emit("close");
},
onAddFriend() {
this.$http({
url: "/friend/add",
method: "post",
params: {
friendId: this.user.id
}
}).then(() => {
this.$message.success("添加成功,对方已成为您的好友");
let friend = {
id: this.user.id,
nickName: this.user.nickName,
headImage: this.user.headImageThumb,
online: this.user.online,
deleted: false
}
this.$store.commit("addFriend", friend);
})
this.$refs.applyRef.open(this.user);
},
showFullImage() {
if (this.user.headImage) {
@ -89,6 +78,9 @@ export default {
computed: {
isFriend() {
return this.$store.getters.isFriend(this.user.id);
},
isWaitingApprove() {
return this.$store.getters.isInRecvRequest(this.user.id);
}
}
}
@ -96,7 +88,7 @@ export default {
<style lang="scss">
.user-info-mask {
background-color: rgba($color: #000000, $alpha: 0);
background-color: rgba($color: #f4f4f4, $alpha: 0);
position: fixed;
left: 0;
top: 0;
@ -116,23 +108,33 @@ export default {
display: flex;
align-items: center;
.user-info-items {
margin-left: 10px;
white-space: nowrap;
overflow: hidden;
.info-card {
flex: 1;
padding-left: 10px;
.el-descriptions__header {
margin-bottom: 5px;
.header {
display: flex;
align-items: center;
.nick-name {
font-size: var(--im-font-size-large);
font-weight: 600;
}
.el-descriptions__title {
font-size: 18px;
.icon {
margin-left: 3px;
font-size: var(--im-font-size);
}
}
.info-item {
font-size: var(--im-font-size);
margin-top: 5px;
word-break: break-all;
.el-descriptions-item__cell {
padding-bottom: 1px;
}
}
}
.el-divider--horizontal {
@ -141,6 +143,11 @@ export default {
.user-btn-group {
text-align: center;
.wait-text {
font-size: 14px;
color: var(--im-text-color-light);
}
}
}
</style>

20
im-web/src/components/friend/FriendItem.vue

@ -15,8 +15,7 @@
</i>
</div>
</div>
<right-menu v-show="menu && rightMenu.show" :pos="rightMenu.pos" :items="rightMenu.items"
@close="rightMenu.show = false" @select="onSelectMenu"></right-menu>
<right-menu ref="rightMenu" @select="onSelectMenu"></right-menu>
<slot></slot>
</div>
</template>
@ -33,13 +32,7 @@ export default {
},
data() {
return {
rightMenu: {
show: false,
pos: {
x: 0,
y: 0
},
items: [{
menuItems: [{
key: 'CHAT',
name: '发送消息',
icon: 'el-icon-chat-dot-round'
@ -49,15 +42,12 @@ export default {
icon: 'el-icon-delete'
}]
}
}
},
methods: {
showRightMenu(e) {
this.rightMenu.pos = {
x: e.x,
y: e.y
};
this.rightMenu.show = "true";
if (this.menu) {
this.$refs.rightMenu.open(e, this.menuItems);
}
},
onSelectMenu(item) {
this.$emit(item.key.toLowerCase(), this.msgInfo);

60
im-web/src/components/group/AddGroupMember.vue

@ -1,5 +1,5 @@
<template>
<el-dialog title="邀请好友" :visible.sync="visible" width="620px" :before-close="onClose">
<el-dialog title="邀请好友" :visible.sync="show" width="620px" :before-close="close">
<div class="agm-container">
<div class="agm-l-box">
<div class="search">
@ -30,7 +30,7 @@
</div>
</div>
<span slot="footer" class="dialog-footer">
<el-button @click="onClose()"> </el-button>
<el-button @click="close()"> </el-button>
<el-button type="primary" @click="onOk()"> </el-button>
</span>
</el-dialog>
@ -46,13 +46,35 @@ export default {
},
data() {
return {
show: false,
searchText: "",
friends: []
}
},
methods: {
onClose() {
this.$emit("close");
open() {
this.show = true;
this.friends = [];
this.$store.state.friendStore.friends.forEach((f) => {
if (f.deleted) {
return;
}
let friend = JSON.parse(JSON.stringify(f))
let m = this.members.filter((m) => !m.quit)
.find((m) => m.userId == f.id);
if (m) {
//
friend.disabled = true;
friend.isCheck = true
} else {
friend.disabled = false;
friend.isCheck = false;
}
this.friends.push(friend);
})
},
close() {
this.show = false;
},
onOk() {
let inviteVO = {
@ -72,7 +94,7 @@ export default {
}).then(() => {
this.$message.success("邀请成功");
this.$emit("reload");
this.$emit("close");
this.close()
})
}
},
@ -86,9 +108,6 @@ export default {
}
},
props: {
visible: {
type: Boolean
},
groupId: {
type: Number
},
@ -100,32 +119,7 @@ export default {
checkCount() {
return this.friends.filter((f) => f.isCheck && !f.disabled).length;
}
},
watch: {
visible: function (newData, oldData) {
if (newData) {
this.friends = [];
this.$store.state.friendStore.friends.forEach((f) => {
if (f.deleted) {
return;
}
let friend = JSON.parse(JSON.stringify(f))
let m = this.members.filter((m) => !m.quit)
.find((m) => m.userId == f.id);
if (m) {
//
friend.disabled = true;
friend.isCheck = true
} else {
friend.disabled = false;
friend.isCheck = false;
}
this.friends.push(friend);
})
}
}
}
}
</script>

8
im-web/src/components/group/GroupMember.vue

@ -2,7 +2,6 @@
<div class="group-member">
<head-image :id="member.userId" :name="member.showNickName" :url="member.headImage" :size="38"
:online="member.online">
<div v-if="showDel" @click.stop="onDelete()" class="btn-kick el-icon-error"></div>
</head-image>
<div class="member-name">{{ member.showNickName }}</div>
</div>
@ -20,16 +19,9 @@ export default {
member: {
type: Object,
required: true
},
showDel: {
type: Boolean,
default: false
}
},
methods: {
onDelete() {
this.$emit("del", this.member);
}
}
}
</script>

39
im-web/src/components/group/GroupMemberSelector.vue

@ -1,5 +1,5 @@
<template>
<el-dialog title="选择成员" :visible.sync="isShow" width="700px">
<el-dialog :title="title" :visible.sync="isShow" width="700px">
<div class="group-member-selector">
<div class="left-box">
<el-input placeholder="搜索" v-model="searchText">
@ -7,7 +7,8 @@
</el-input>
<virtual-scroller class="scroll-box" :items="showMembers">
<template v-slot="{ item }">
<group-member-item :member="item" @click.native="onClickMember(item)">
<group-member-item :group="group" :groupMembers="showMembers" :member="item" :menu="false"
@click.native="onClickMember(item)">
<el-checkbox :disabled="item.locked" v-model="item.checked" @change="onChange(item)"
@click.native.stop=""></el-checkbox>
</group-member-item>
@ -17,11 +18,13 @@
<div class="arrow el-icon-d-arrow-right"></div>
<div class="right-box">
<div class="select-tip"> 已勾选{{ checkedMembers.length }}位成员</div>
<el-scrollbar class="scroll-box">
<div class="checked-member-list">
<div v-for="m in members" :key="m.userId">
<group-member class="member-item" v-if="m.checked" :member="m"></group-member>
</div>
</div>
</el-scrollbar>
</div>
</div>
<span slot="footer" class="dialog-footer">
@ -52,25 +55,30 @@ export default {
}
},
props: {
groupId: {
type: Number
group: {
type: Object
},
title: {
type: String,
default: "选择成员"
}
},
methods: {
open(maxSize, checkedIds, lockedIds) {
open(maxSize, checkedIds, lockedIds, hideIds) {
this.maxSize = maxSize;
this.isShow = true;
this.loadGroupMembers(checkedIds, lockedIds);
this.loadGroupMembers(checkedIds, lockedIds, hideIds);
},
loadGroupMembers(checkedIds, lockedIds) {
loadGroupMembers(checkedIds, lockedIds, hideIds) {
this.$http({
url: `/group/members/${this.groupId}`,
url: `/group/members/${this.group.id}`,
method: 'get'
}).then((members) => {
members.forEach((m) => {
//
m.checked = checkedIds.indexOf(m.userId) >= 0;
m.locked = lockedIds.indexOf(m.userId) >= 0;
m.hide = hideIds.indexOf(m.userId) >= 0;
});
this.members = members;
});
@ -79,13 +87,13 @@ export default {
if (!m.locked) {
m.checked = !m.checked;
}
if (this.checkedMembers.length > this.maxSize) {
if (this.maxSize > 0 && this.checkedMembers.length > this.maxSize) {
this.$message.error(`最多选择${this.maxSize}位成员`)
m.checked = false;
}
},
onChange(m) {
if (this.checkedMembers.length > this.maxSize) {
if (this.maxSize > 0 && this.checkedMembers.length > this.maxSize) {
this.$message.error(`最多选择${this.maxSize}位成员`)
m.checked = false;
}
@ -112,7 +120,6 @@ export default {
return this.members.filter((m) => !m.hide && !m.quit && m.showNickName.includes(this.searchText))
}
}
}
</script>
@ -120,25 +127,21 @@ export default {
.group-member-selector {
display: flex;
.scroll-box {
height: 400px;
}
.left-box {
width: 48%;
overflow: hidden;
border: var(--im-border);
.scroll-box {
height: 400px;
}
.el-input__inner {
border: none;
border-bottom: var(--im-border);
}
}
.arrow {
display: flex;
align-items: center;

1
im-web/src/components/rtc/RtcPrivateVideo.vue

@ -157,7 +157,6 @@ export default {
})
},
onReject() {
console.log("onReject")
// 退
this.API.reject(this.friend.id);
// 退

5
im-web/src/components/setting/Setting.vue

@ -3,7 +3,8 @@
<el-form :model="userInfo" label-width="80px" :rules="rules" ref="settingForm" size="small">
<el-form-item label="头像" style="margin-bottom: 0 !important;">
<file-upload class="avatar-uploader" :action="imageAction" :showLoading="true" :maxSize="maxSize"
@success="onUploadSuccess" :fileTypes="['image/jpeg', 'image/png', 'image/jpg', 'image/webp']">
:isPermanent="true" @success="onUploadSuccess"
:fileTypes="['image/jpeg', 'image/png', 'image/jpg', 'image/webp']">
<img v-if="userInfo.headImage" :src="userInfo.headImage" class="avatar">
<i v-else class="el-icon-plus avatar-uploader-icon"></i>
</file-upload>
@ -91,7 +92,7 @@ export default {
}
},
watch: {
visible: function (newData, oldData) {
visible: function(newData, oldData) {
//
let mine = this.$store.state.userStore.userInfo;
this.userInfo = JSON.parse(JSON.stringify(mine));

1
im-web/src/view/Friend.vue

@ -131,7 +131,6 @@ export default {
showName: user.nickName,
headImage: user.headImageThumb,
};
console.log("chat:", chat)
this.$store.commit("openChat", chat);
this.$store.commit("activeChat", 0);
this.$router.push("/home/chat");

78
im-web/src/view/Group.vue

@ -61,7 +61,7 @@
maxlength="1024" placeholder="群主未设置"></el-input>
</el-form-item>
<div>
<el-button type="warning" v-show="isOwner" @click="onInviteMember()">邀请</el-button>
<el-button type="warning" @click="onInviteMember()">邀请</el-button>
<el-button type="success" @click="onSaveGroup()">保存</el-button>
<el-button type="danger" v-show="!isOwner" @click="onQuit()">退出</el-button>
<el-button type="danger" v-show="isOwner" @click="onDissolve()">解散</el-button>
@ -71,19 +71,24 @@
<el-divider content-position="center"></el-divider>
<el-scrollbar ref="scrollbar" :style="'height: ' + scrollHeight + 'px'">
<div class="group-member-list">
<div class="group-invite">
<div class="invite-member-btn" title="邀请好友进群聊" @click="onInviteMember()">
<div class="member-tools">
<div class="tool-btn" title="邀请好友进群聊" @click="onInvite()">
<i class="el-icon-plus"></i>
</div>
<div class="invite-member-text">邀请</div>
<add-group-member :visible="showAddGroupMember" :groupId="activeGroup.id"
:members="groupMembers" @reload="loadGroupMembers"
@close="onCloseAddGroupMember"></add-group-member>
<div class="tool-text">邀请</div>
<add-group-member ref="addGroupMember" :groupId="activeGroup.id" :members="groupMembers"
@reload="$emit('reload')"></add-group-member>
</div>
<div class="member-tools" v-if="isOwner">
<div class="tool-btn" title="选择成员移出群聊" @click="onRemove()">
<i class="el-icon-minus"></i>
</div>
<div class="tool-text">移除</div>
<group-member-selector ref="removeSelector" title="选择成员进行移除" :group="activeGroup"
@complete="onRemoveComplete"></group-member-selector>
</div>
<div v-for="(member, idx) in showMembers" :key="member.id">
<group-member v-if="idx < showMaxIdx" class="group-member" :member="member"
:showDel="isOwner && member.userId != activeGroup.ownerId"
@del="onKick"></group-member>
<group-member v-if="idx < showMaxIdx" class="group-member" :member="member"></group-member>
</div>
</div>
</el-scrollbar>
@ -99,6 +104,7 @@ import GroupItem from '../components/group/GroupItem';
import FileUpload from '../components/common/FileUpload';
import GroupMember from '../components/group/GroupMember.vue';
import AddGroupMember from '../components/group/AddGroupMember.vue';
import GroupMemberSelector from '../components/group/GroupMemberSelector.vue';
import HeadImage from '../components/common/HeadImage.vue';
import { pinyin } from 'pinyin-pro';
@ -109,6 +115,7 @@ export default {
GroupMember,
FileUpload,
AddGroupMember,
GroupMemberSelector,
HeadImage
},
data() {
@ -154,13 +161,31 @@ export default {
// store
this.activeGroup = JSON.parse(JSON.stringify(group));
//
this.groupMembers = [];
this.loadGroupMembers();
},
onInviteMember() {
this.showAddGroupMember = true;
onInvite() {
this.$refs.addGroupMember.open();
},
onCloseAddGroupMember() {
this.showAddGroupMember = false;
onRemove() {
//
let hideIds = [this.activeGroup.ownerId];
this.$refs.removeSelector.open(50, [], [], hideIds);
},
onRemoveComplete(members) {
let userIds = members.map(m => m.userId);
let data = {
groupId: this.activeGroup.id,
userIds: userIds
}
this.$http({
url: "/group/members/remove",
method: 'delete',
data: data
}).then(() => {
this.loadGroupMembers();
this.$message.success(`您移除了${userIds.length}位成员`);
})
},
onUploadSuccess(data) {
this.activeGroup.headImage = data.originUrl;
@ -197,25 +222,6 @@ export default {
});
})
},
onKick(member) {
this.$confirm(`确定将成员'${member.showNickName}'移出群聊吗?`, '确认移出?', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
this.$http({
url: `/group/kick/${this.activeGroup.id}`,
method: 'delete',
params: {
userId: member.userId
}
}).then(() => {
this.$message.success(`已将${member.showNickName}移出群聊`);
member.quit = true;
});
})
},
onQuit() {
this.$confirm(`确认退出'${this.activeGroup.showGroupName}',并清空聊天记录吗?`, '确认退出?', {
confirmButtonText: '确定',
@ -455,13 +461,13 @@ export default {
margin-right: 5px;
}
.group-invite {
.member-tools {
display: flex;
flex-direction: column;
align-items: center;
width: 60px;
.invite-member-btn {
.tool-btn {
width: 38px;
height: 38px;
line-height: 38px;
@ -475,7 +481,7 @@ export default {
}
}
.invite-member-text {
.tool-text {
font-size: var(--im-font-size-smaller);
text-align: center;
width: 100%;

4
im-web/src/view/Home.vue

@ -1,5 +1,5 @@
<template>
<div class="home-page">
<div class="home-page" @click="$store.commit('closeUserInfoBox')">
<div class="app-container" :class="{ fullscreen: isFullscreen }">
<div class="navi-bar">
<div class="navi-bar-box">
@ -132,7 +132,6 @@ export default {
}
});
this.$wsApi.onClose((e) => {
console.log(e);
if (e.code != 3000) {
// 线
this.reconnectWs();
@ -303,7 +302,6 @@ export default {
}
//
if (msg.type == this.$enums.MESSAGE_TYPE.GROUP_DEL) {
console.log("this.$enums.MESSAGE_TYPE.GROUP_DE")
this.$store.commit("removeGroup", msg.groupId);
return;
}

Loading…
Cancel
Save