图片,语音,视频上传
前端-api
/** 发送消息 */
sendMsg: (data?: MessageReq) => postRequest<MessageType>(urls.sendMsg, data),
/**
* 发送消息载体
*/
export type MessageReq = {
/** 会话id */
roomId: number
/** 消息类型 */
msgType: MsgEnum
/** 消息体 */
body: {
/** 文本消息内容 */
content?: string
/** 回复的消息id */
replyMsgId?: number
/** 任意 */
[key: string]: any
}
}
/**
* 消息类型
*/
export enum MsgEnum {
/** 未知 */
UNKNOWN,
/** 文本 */
TEXT,
/** 撤回 */
RECALL,
/** 图片 */
IMAGE,
/** 文件 */
FILE,
/** 语音 */
VOICE,
/** 视频 */
VIDEO,
/** 表情包 */
EMOJI,
}
<div class="msg-input">
<div class="action" @click="isAudio = !isAudio">
<Icon v-show="!isAudio" icon="voice" class="audio" />
<Icon v-show="isAudio" icon="jianpan" />
</div>
<div
v-show="isAudio"
class="recorded"
@mousedown="onStartRecord()"
@mouseup="stop()"
@touchstart.passive="onStartRecord()"
@touchend.passive="stop()"
>
<div class="recorded-tips">{{ isRecording ? `录制中 ${second}s` : '按住说话' }}</div>
</div>
<MsgInput
class="m-input"
v-show="!isAudio"
v-model="inputMsg"
ref="mentionRef"
autofocus
:tabindex="!isSign || isSending"
:disabled="!isSign || isSending"
:placeholder="isSign ? (isSending ? '消息发送中' : '来聊点什么吧~') : ''"
:mentions="mentionList"
@change="onInputChange"
@send="sendMsgHandler"
/>
<el-popover
placement="top"
effect="dark"
title=""
v-model:visible="showEmoji"
popper-class="emoji-warpper"
:show-arrow="false"
:width="client === 'PC' ? 385 : '95%'"
trigger="click"
>
<template #reference>
<div class="action" @mouseover="isHovered = true" @mouseleave="isHovered = false">
<Icon v-if="isHovered" icon="shocked" :size="18" colorful />
<Icon v-else icon="happy1" :size="18" colorful />
</div>
</template>
<div class="emoji-panel">
<div v-show="panelIndex === 0" class="emoji-panel-content">
<ul class="emoji-list">
<li
class="emoji-item"
v-for="(emoji, $index) of emojis"
:key="$index"
v-login="() => insertEmoji(emoji)"
>
{{ emoji }}
</li>
</ul>
</div>
<div v-show="panelIndex === 1" class="emoji-panel-content">
<div
v-for="emoji in emojiList"
:key="emoji.id"
class="item"
@click="sendEmoji(emoji.expressionUrl)"
@contextmenu="handleRightClick($event, emoji.id)"
>
<img :src="emoji.expressionUrl" />
<Icon
v-if="emoji.id === tempEmojiId"
icon="guanbi1"
class="del"
@click.stop="emojiStore.deleteEmoji(emoji.id)"
/>
</div>
<Icon
v-if="emojiList.length < 50 && !isEmojiUp"
class="cursor-pointer item-add"
icon="tianjia"
:size="30"
@click="openFileSelect('img', true)"
/>
<div v-else class="item-add">
<Icon icon="loading" spin :size="30" />
</div>
</div>
<div class="footer">
<Icon
:class="['cursor-pointer', 'footer-act', { active: panelIndex === 0 }]"
icon="biaoqing"
:size="18"
@click="panelIndex = 0"
/>
<Icon
:class="['cursor-pointer', 'footer-act', { active: panelIndex === 1 }]"
icon="aixin"
:size="18"
@click="panelIndex = 1"
/>
</div>
</div>
</el-popover>
<Icon
class="action"
icon="at"
:size="20"
colorful
@click="insertInputText({ content: '@', ...mentionRef?.range })"
/>
<Icon
:class="['action', { disabled: isUploading }]"
icon="tupian"
:size="18"
colorful
@click="openFileSelect('img')"
/>
<Icon
class="action"
icon="wenjianjia2"
:size="20"
colorful
@click="openFileSelect('file')"
/>
<div class="divider" />
<div
:class="['action', { 'is-edit': inputMsg.length, 'disabled': !inputMsg.length }]"
@click="sendMsgHandler"
>
<Icon class="send" icon="huojian" :size="20" />
</div>vue
</div>
这里图片和视频都是需要先上传到oss中然后再获取到url链接作为参数再传给发送消息
/** 获取临时上传链接 */
getUploadUrl: (params: any) =>
getRequest<{ downloadUrl: string; uploadUrl: string }>(urls.fileUpload, { localCache: 0, params }),
后端oss
oss.enabled=true
oss.type=minio
oss.endpoint=http://localhost:9000
oss.access-key=BEZ213
oss.secret-key=Ii4vCMIXuFfds1EZ8e7RXI2342342kV
oss.bucketName=default
package com.abin.mallchat.common.common.utils.oss;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
@Data
@ConfigurationProperties(prefix = "oss")
public class OssProperties {
/**
* 是否开启
*/
Boolean enabled;
/**
* 存储对象服务器类型
*/
OssType type;
/**
* OSS 访问端点,集群时需提供统一入口
*/
String endpoint;
/**
* 用户名
*/
String accessKey;
/**
* 密码
*/
String secretKey;
/**
* 存储桶
*/
String bucketName;
}
package com.abin.mallchat.common.common.config;
import com.abin.mallchat.common.common.utils.oss.MinIOTemplate;
import com.abin.mallchat.common.common.utils.oss.OssProperties;
import io.minio.MinioClient;
import lombok.SneakyThrows;
import org.springframework.boot.autoconfigure.condition.*;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass({MinioClient.class})
@EnableConfigurationProperties(OssProperties.class)
@ConditionalOnExpression("${oss.enabled}")
@ConditionalOnProperty(value = "oss.type", havingValue = "minio")
public class MinIOConfiguration {
@Bean
@SneakyThrows
@ConditionalOnMissingBean(MinioClient.class)
public MinioClient minioClient(OssProperties ossProperties) {
return MinioClient.builder()
.endpoint(ossProperties.getEndpoint())
.credentials(ossProperties.getAccessKey(), ossProperties.getSecretKey())
.build();
}
@Bean
@ConditionalOnBean({MinioClient.class})
@ConditionalOnMissingBean(MinIOTemplate.class)
public MinIOTemplate minioTemplate(MinioClient minioClient, OssProperties ossProperties) {
return new MinIOTemplate(minioClient, ossProperties);
}
}
package com.abin.mallchat.common.common.utils.oss;
import cn.hutool.core.date.DatePattern;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.io.file.FileNameUtil;
import cn.hutool.core.util.StrUtil;
import com.abin.mallchat.common.common.utils.oss.domain.OssReq;
import com.abin.mallchat.common.common.utils.oss.domain.OssResp;
import io.minio.*;
import io.minio.http.Method;
import io.minio.messages.Bucket;
import io.minio.messages.Item;
import lombok.AllArgsConstructor;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import java.io.InputStream;
import java.time.ZonedDateTime;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@Slf4j
@AllArgsConstructor
public class MinIOTemplate {
/**
* MinIO 客户端
*/
MinioClient minioClient;
/**
* MinIO 配置类
*/
OssProperties ossProperties;
/**
* 查询所有存储桶
*
* @return Bucket 集合
*/
@SneakyThrows
public List<Bucket> listBuckets() {
return minioClient.listBuckets();
}
/**
* 桶是否存在
*
* @param bucketName 桶名
* @return 是否存在
*/
@SneakyThrows
public boolean bucketExists(String bucketName) {
return minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
}
/**
* 创建存储桶
*
* @param bucketName 桶名
*/
@SneakyThrows
public void makeBucket(String bucketName) {
if (!bucketExists(bucketName)) {
minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());
}
}
/**
* 删除一个空桶 如果存储桶存在对象不为空时,删除会报错。
*
* @param bucketName 桶名
*/
@SneakyThrows
public void removeBucket(String bucketName) {
minioClient.removeBucket(RemoveBucketArgs.builder().bucket(bucketName).build());
}
/**
* 返回临时带签名、过期时间一天、Get请求方式的访问URL
*/
@SneakyThrows
public OssResp getPreSignedObjectUrl(OssReq req) {
String absolutePath = req.isAutoPath() ? generateAutoPath(req) : req.getFilePath() + StrUtil.SLASH + req.getFileName();
String url = minioClient.getPresignedObjectUrl(
GetPresignedObjectUrlArgs.builder()
.method(Method.PUT)
.bucket(ossProperties.getBucketName())
.object(absolutePath)
.expiry(60 * 60 * 24)
.build());
return OssResp.builder()
.uploadUrl(url)
.downloadUrl(getDownloadUrl(ossProperties.getBucketName(), absolutePath))
.build();
}
private String getDownloadUrl(String bucket, String pathFile) {
return ossProperties.getEndpoint() + StrUtil.SLASH + bucket + pathFile;
}
/**
* GetObject接口用于获取某个文件(Object)。此操作需要对此Object具有读权限。
*
* @param bucketName 桶名
* @param ossFilePath Oss文件路径
*/
@SneakyThrows
public InputStream getObject(String bucketName, String ossFilePath) {
return minioClient.getObject(
GetObjectArgs.builder().bucket(bucketName).object(ossFilePath).build());
}
/**
* 查询桶的对象信息
*
* @param bucketName 桶名
* @param recursive 是否递归查询
* @return
*/
@SneakyThrows
public Iterable<Result<Item>> listObjects(String bucketName, boolean recursive) {
return minioClient.listObjects(
ListObjectsArgs.builder().bucket(bucketName).recursive(recursive).build());
}
/**
* 生成随机文件名,防止重复
*
* @return
*/
public String generateAutoPath(OssReq req) {
String uid = Optional.ofNullable(req.getUid()).map(String::valueOf).orElse("000000");
cn.hutool.core.lang.UUID uuid = cn.hutool.core.lang.UUID.fastUUID();
String suffix = FileNameUtil.getSuffix(req.getFileName());
String yearAndMonth = DateUtil.format(new Date(), DatePattern.NORM_MONTH_PATTERN);
return req.getFilePath() + StrUtil.SLASH + yearAndMonth + StrUtil.SLASH + uid + StrUtil.SLASH + uuid + StrUtil.DOT + suffix;
}
/**
* 获取带签名的临时上传元数据对象,前端可获取后,直接上传到Minio
*
* @param bucketName
* @param fileName
* @return
*/
@SneakyThrows
public Map<String, String> getPreSignedPostFormData(String bucketName, String fileName) {
// 为存储桶创建一个上传策略,过期时间为7天
PostPolicy policy = new PostPolicy(bucketName, ZonedDateTime.now().plusDays(7));
// 设置一个参数key,值为上传对象的名称
policy.addEqualsCondition("key", fileName);
// 添加Content-Type以"image/"开头,表示只能上传照片
policy.addStartsWithCondition("Content-Type", "image/");
// 设置上传文件的大小 64kiB to 10MiB.
policy.addContentLengthRangeCondition(64 * 1024, 10 * 1024 * 1024);
return minioClient.getPresignedPostFormData(policy);
}
}
package com.abin.mallchat.custom.user.domain.vo.request.oss;
import io.swagger.annotations.ApiModelProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
/**
* Description: 上传url请求入参
* Author: <a href="https://github.com/zongzibinbin">abin</a>
* Date: 2023-03-23
*/
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class UploadUrlReq {
@ApiModelProperty(value = "文件名(带后缀)")
@NotBlank
private String fileName;
@ApiModelProperty(value = "上传场景1.聊天室,2.表情包")
@NotNull
private Integer scene;
}
package com.abin.mallchat.custom.user.controller;
import com.abin.mallchat.common.common.domain.vo.response.ApiResult;
import com.abin.mallchat.common.common.utils.RequestHolder;
import com.abin.mallchat.common.common.utils.oss.domain.OssResp;
import com.abin.mallchat.custom.user.domain.vo.request.oss.UploadUrlReq;
import com.abin.mallchat.custom.user.service.OssService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.Valid;
/**
* Description: oss控制层
* Author: <a href="https://github.com/zongzibinbin">abin</a>
* Date: 2023-06-20
*/
@RestController
@RequestMapping("/capi/oss")
@Api(tags = "oss相关接口")
public class OssController {
@Autowired
private OssService ossService;
@GetMapping("/upload/url")
@ApiOperation("获取临时上传链接")
public ApiResult<OssResp> getUploadUrl(@Valid UploadUrlReq req) {
return ApiResult.success(ossService.getUploadUrl(RequestHolder.get().getUid(), req));
}
}
package com.abin.mallchat.custom.user.domain.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Arrays;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* Description: 场景枚举
* Author: <a href="https://github.com/zongzibinbin">abin</a>
* Date: 2023-06-20
*/
@AllArgsConstructor
@Getter
public enum OssSceneEnum {
CHAT(1, "聊天", "/chat"),
EMOJI(2, "表情包", "/emoji"),
;
private final Integer type;
private final String desc;
private final String path;
private static final Map<Integer, OssSceneEnum> cache;
static {
cache = Arrays.stream(OssSceneEnum.values()).collect(Collectors.toMap(OssSceneEnum::getType, Function.identity()));
}
public static OssSceneEnum of(Integer type) {
return cache.get(type);
}
}
package com.abin.mallchat.custom.user.service.impl;
import com.abin.mallchat.common.common.utils.AssertUtil;
import com.abin.mallchat.common.common.utils.oss.MinIOTemplate;
import com.abin.mallchat.common.common.utils.oss.domain.OssReq;
import com.abin.mallchat.common.common.utils.oss.domain.OssResp;
import com.abin.mallchat.custom.user.domain.enums.OssSceneEnum;
import com.abin.mallchat.custom.user.domain.vo.request.oss.UploadUrlReq;
import com.abin.mallchat.custom.user.service.OssService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
/**
* Description:
* Author: <a href="https://github.com/zongzibinbin">abin</a>
* Date: 2023-06-20
*/
@Service
public class OssServiceImpl implements OssService {
@Autowired
private MinIOTemplate minIOTemplate;
@Override
public OssResp getUploadUrl(Long uid, UploadUrlReq req) {
OssSceneEnum sceneEnum = OssSceneEnum.of(req.getScene());
AssertUtil.isNotEmpty(sceneEnum, "场景有误");
OssReq ossReq = OssReq.builder()
.fileName(req.getFileName())
.filePath(sceneEnum.getPath())
.uid(uid)
.build();
return minIOTemplate.getPreSignedObjectUrl(ossReq);
}
}
package com.abin.mallchat.common.common.utils.oss.domain;
import io.swagger.annotations.ApiModelProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Description: 上传url请求出参
* Author: <a href="https://github.com/zongzibinbin">abin</a>
* Date: 2023-03-23
*/
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class OssResp {
@ApiModelProperty(value = "上传的临时url")
private String uploadUrl;
@ApiModelProperty(value = "成功后能够下载的url")
private String downloadUrl;
}
前端获取文件上传展示
import { ref } from 'vue'
import { createEventHook } from '@vueuse/core'
import apis from '@/services/apis'
import { ElMessage } from 'element-plus'
/** 文件信息类型 */
export type FileInfoType = {
name: string
type: string
size: number
suffix: string
width?: number
height?: number
downloadUrl?: string
second?: number
thumbWidth?: number
thumbHeight?: number
thumbUrl?: string
}
const Max = 100 // 单位M
const MAX_FILE_SIZE = Max * 1024 * 1024 // 最大上传限制
/**
* 文件上传Hook
*/
export const useUpload = () => {
const isUploading = ref(false) // 是否正在上传
const progress = ref(0) // 进度
const fileInfo = ref<FileInfoType | null>(null) // 文件信息
const { on: onChange, trigger } = createEventHook()
const onStart = createEventHook()
/**
* 上传文件
* @param url 上传链接
* @param file 文件
* @param inner 是否内部调用
*/
const upload = async (url: string, file: File, inner?: boolean) => {
isUploading.value = true
const xhr = new XMLHttpRequest()
xhr.open('PUT', url, true)
xhr.setRequestHeader('Content-Type', file.type)
xhr.upload.onprogress = function (e) {
if (!inner) {
progress.value = Math.round((e.loaded / e.total) * 100)
}
}
xhr.onload = function () {
isUploading.value = false
if (inner) return
if (xhr.status === 200) {
trigger('success')
} else {
trigger('fail')
}
}
xhr.send(file)
}
/**
* 获取视频第一帧
*/
const getVideoCover = (file: File) => {
return new Promise((resolve, reject) => {
const video = document.createElement('video')
const tempUrl = URL.createObjectURL(file)
video.src = tempUrl
video.crossOrigin = 'anonymous' // 视频跨域
video.currentTime = 2 // 第2帧
video.oncanplay = () => {
const canvas = document.createElement('canvas')
canvas.width = video.videoWidth
canvas.height = video.videoHeight
canvas.getContext('2d')?.drawImage(video, 0, 0, canvas.width, canvas.height)
// 将canvas转为图片file
canvas.toBlob((blob) => {
if (!blob) return
// 时间戳生成唯一文件名
const name = Date.now() + 'thumb.jpg'
const thumbFile = new File([blob], name, { type: 'image/jpeg' })
// 转成File对象 并上传
apis
.getUploadUrl({ fileName: name, scene: '1' })
.send()
.then((res) => {
if (res.uploadUrl && res.downloadUrl) {
upload(res.uploadUrl, thumbFile, true)
// 等待上传完成
const timer = setInterval(() => {
if (!isUploading.value) {
clearInterval(timer)
resolve({
thumbWidth: canvas.width,
thumbHeight: canvas.height,
thumbUrl: res.downloadUrl,
thumbSize: thumbFile.size,
tempUrl,
})
}
})
}
})
})
}
video.onerror = function () {
URL.revokeObjectURL(tempUrl) // 释放临时URL资源
reject({ width: 0, height: 0, url: null })
}
})
}
/**
* 获取图片宽高
*/
const getImgWH = (file: File) => {
const img = new Image()
const tempUrl = URL.createObjectURL(file)
img.src = tempUrl
return new Promise((resolve, reject) => {
img.onload = function () {
resolve({ width: img.width, height: img.height, tempUrl })
}
img.onerror = function () {
URL.revokeObjectURL(tempUrl) // 释放临时URL资源
reject({ width: 0, height: 0, url: null })
}
})
}
/**
* 获取音频时长
*/
const getAudioDuration = (file: File) => {
return new Promise((resolve, reject) => {
const audio = new Audio()
const tempUrl = URL.createObjectURL(file)
audio.src = tempUrl
// 计算音频的时长
const countAudioTime = async () => {
while (isNaN(audio.duration) || audio.duration === Infinity) {
// 防止浏览器卡死
await new Promise((resolve) => setTimeout(resolve, 100))
// 随机进度条位置
audio.currentTime = 100000 * Math.random()
}
// 取整
const second = Math.round(audio.duration || 0)
resolve({ second, tempUrl })
}
countAudioTime()
audio.onerror = function () {
reject({ second: 0, tempUrl })
}
})
}
/**
* 解析文件
* @param file 文件
* @returns 文件大小、文件类型、文件名、文件后缀...
*/
const parseFile = async (file: File, addParams: Record<string, any> = {}) => {
const { name, size, type } = file
const suffix = name.split('.').pop()?.trim().toLowerCase() || ''
const baseInfo = { name, size, type, suffix, ...addParams }
if (type.includes('image')) {
const { width, height, tempUrl } = (await getImgWH(file)) as any
return { ...baseInfo, width, height, tempUrl }
}
if (type.includes('audio')) {
const { second, tempUrl } = (await getAudioDuration(file)) as any
return { second, tempUrl, ...baseInfo }
}
// 如果是视频
if (type.includes('video')) {
const { thumbWidth, thumbHeight, tempUrl, thumbTempUrl, thumbUrl, thumbSize } =
(await getVideoCover(file)) as any
return { ...baseInfo, thumbWidth, thumbHeight, tempUrl, thumbTempUrl, thumbUrl, thumbSize }
}
return baseInfo
}
/**
* 上传文件
* @param file 文件
* @param addParams 额外参数
*/
const uploadFile = async (file: File, addParams?: Record<string, any>) => {
if (isUploading.value || !file) return
const info = await parseFile(file, addParams)
// 限制文件大小
if (info.size > MAX_FILE_SIZE) {
ElMessage.warning(`文件不得大于 ${Max} MB`)
return
}
const { downloadUrl, uploadUrl } = await apis
.getUploadUrl({ fileName: info.name, scene: '1' })
.send()
if (uploadUrl && downloadUrl) {
fileInfo.value = { ...info, downloadUrl }
onStart.trigger(fileInfo)
upload(uploadUrl, file)
} else {
trigger('fail')
}
}
return {
fileInfo,
isUploading,
progress,
onStart: onStart.on,
onChange,
uploadFile,
}
}
hook-import { createEventHook } from ‘@vueuse/core’
/**
* 文件上传Hook
*/
export const useUpload = () => {
const isUploading = ref(false) // 是否正在上传
const progress = ref(0) // 进度
const fileInfo = ref<FileInfoType | null>(null) // 文件信息
const { on: onChange, trigger } = createEventHook()
const onStart = createEventHook()
return {
fileInfo,
isUploading,
progress,
onStart: onStart.on,
onChange,
uploadFile,
}
const { isUploading, fileInfo, uploadFile, onStart, onChange: useUploadChange } = useUpload()
useUploadChange((status) => {
if (status === 'success') {
if (!fileInfo.value) return
const { body, type } = generateBody(fileInfo.value, nowMsgType.value)
send(type, body)
}
reset()
})
// 生成消息体
export const generateBody = (fileInfo: any, msgType: MsgEnum, isMock?: boolean) => {
const {
size,
width,
height,
downloadUrl,
name,
second,
tempUrl,
thumbWidth,
thumbHeight,
thumbUrl,
thumbSize,
} = fileInfo
const url = isMock ? tempUrl : downloadUrl
const baseBody = { size, url }
let body = {}
if (msgType === MsgEnum.IMAGE) {
body = { ...baseBody, width, height }
} else if (msgType === MsgEnum.VOICE) {
body = { ...baseBody, second }
} else if (msgType === MsgEnum.VIDEO) {
body = { ...baseBody, thumbWidth, thumbHeight, thumbUrl, thumbSize }
} else if (msgType === MsgEnum.FILE) {
body = { ...baseBody, fileName: name, url: downloadUrl }
}
return { body, type: msgType }
}
const selectAndUploadFile = async (files?: FileList | null) => {
if (!files?.length) return
const file = files[0]
if (nowMsgType.value === MsgEnum.IMAGE) {
if (!file.type.includes('image')) {
return ElMessage.error('请选择图片文件')
}
}
if (isUpEmoji.value) {
await uploadEmoji(file)
} else {
await uploadFile(file)
}
}p