feat:聊天信息支持 文件、图片、语音发送,增加一键启停脚本
This commit is contained in:
@@ -1,55 +1,85 @@
|
||||
/**
|
||||
* 文件上传模块 API
|
||||
* 文件上传 API 封装
|
||||
* 提供通用上传、图片上传(含缩略图)、语音上传接口
|
||||
*
|
||||
* 对应后端路由:POST /api/v1/upload
|
||||
* 使用 uni.uploadFile 上传文件到 MinIO
|
||||
* BASE_URL 复用 @/utils/request 中的统一配置,保证与业务接口一致
|
||||
* (开发环境:http://{hostname}:8085;生产环境:相对路径由 Nginx 反代)
|
||||
*/
|
||||
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { BASE_URL } from '@/utils/request'
|
||||
import { getToken } from '@/utils/storage'
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
* @param {string} filePath - 本地文件路径(如从 uni.chooseImage 获取)
|
||||
* @returns {Promise<Object>} 返回 { url: '...' }
|
||||
* 通用文件上传(最大 50MB)
|
||||
* @param {string} filePath 本地文件临时路径
|
||||
* @param {string} [fileName] 文件名
|
||||
* @returns {Promise<{ url, file_name, size }>}
|
||||
*/
|
||||
const uploadFile = (filePath) => {
|
||||
export function uploadFile(filePath, fileName) {
|
||||
return _doUpload('/api/v1/upload', filePath, fileName)
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片上传(自动生成缩略图,最大 20MB)
|
||||
* @param {string} filePath 本地图片临时路径
|
||||
* @param {string} [fileName] 文件名
|
||||
* @returns {Promise<{ url, thumbnail_url, file_name, size, width, height, mime_type }>}
|
||||
*/
|
||||
export function uploadImage(filePath, fileName) {
|
||||
return _doUpload('/api/v1/upload/image', filePath, fileName)
|
||||
}
|
||||
|
||||
/**
|
||||
* 语音上传(最大 5MB)
|
||||
* @param {string} filePath 本地语音文件临时路径
|
||||
* @param {number} duration 语音时长(秒)
|
||||
* @param {string} [fileName] 文件名
|
||||
* @returns {Promise<{ url, file_name, size, duration, mime_type }>}
|
||||
*/
|
||||
export function uploadVoice(filePath, duration, fileName) {
|
||||
return _doUpload('/api/v1/upload/voice', filePath, fileName, { duration: String(duration) })
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部上传方法
|
||||
*/
|
||||
function _doUpload(apiPath, filePath, fileName, extraFormData = {}) {
|
||||
const userStore = useUserStore()
|
||||
const token = userStore.token
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const token = getToken()
|
||||
uni.uploadFile({
|
||||
url: `${BASE_URL}/api/v1/upload`,
|
||||
url: `${BASE_URL}${apiPath}`,
|
||||
filePath,
|
||||
name: 'file',
|
||||
formData: {
|
||||
...extraFormData,
|
||||
...(fileName ? { file_name: fileName } : {})
|
||||
},
|
||||
header: {
|
||||
Authorization: token ? `Bearer ${token}` : ''
|
||||
},
|
||||
success: (res) => {
|
||||
if (res.statusCode === 200) {
|
||||
try {
|
||||
const data = JSON.parse(res.data)
|
||||
if (data.code === 0) {
|
||||
resolve(data)
|
||||
} else {
|
||||
uni.showToast({ title: data.message || '上传失败', icon: 'none' })
|
||||
reject(data)
|
||||
}
|
||||
} catch {
|
||||
uni.showToast({ title: '响应解析失败', icon: 'none' })
|
||||
reject({ code: -1, message: '响应解析失败' })
|
||||
try {
|
||||
const result = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
|
||||
if (result.code === 0) {
|
||||
resolve(result.data)
|
||||
} else {
|
||||
reject(new Error(result.message || '上传失败'))
|
||||
}
|
||||
} else {
|
||||
uni.showToast({ title: '上传失败', icon: 'none' })
|
||||
reject({ code: res.statusCode, message: '上传失败' })
|
||||
} catch (e) {
|
||||
reject(new Error('解析上传结果失败'))
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
uni.showToast({ title: '网络异常', icon: 'none' })
|
||||
reject({ code: -1, message: err.errMsg || '网络异常' })
|
||||
reject(new Error(err.errMsg || '网络错误'))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
uploadFile
|
||||
uploadFile,
|
||||
uploadImage,
|
||||
uploadVoice
|
||||
}
|
||||
|
||||
70
frontend/src/components/chat/MorePanel.vue
Normal file
70
frontend/src/components/chat/MorePanel.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<!--
|
||||
输入栏"+"展开面板
|
||||
包含图片和文件两个入口
|
||||
网格布局,点击后触发对应的选择事件
|
||||
-->
|
||||
<template>
|
||||
<view class="more-panel" v-if="visible">
|
||||
<view class="panel-grid">
|
||||
<view class="panel-item" @tap="onChooseImage">
|
||||
<view class="item-icon" style="background-color: #DBEAFE;">
|
||||
<uni-icons type="image" :size="28" color="#2563EB" />
|
||||
</view>
|
||||
<text class="item-label">图片</text>
|
||||
</view>
|
||||
<view class="panel-item" @tap="onChooseFile">
|
||||
<view class="item-icon" style="background-color: #FEE2E2;">
|
||||
<uni-icons type="paperclip" :size="28" color="#DC2626" />
|
||||
</view>
|
||||
<text class="item-label">文件</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'MorePanel',
|
||||
props: {
|
||||
visible: { type: Boolean, default: false }
|
||||
},
|
||||
emits: ['choose-image', 'choose-file'],
|
||||
setup(props, { emit }) {
|
||||
const onChooseImage = () => emit('choose-image')
|
||||
const onChooseFile = () => emit('choose-file')
|
||||
return { onChooseImage, onChooseFile }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.more-panel {
|
||||
background-color: #F8FAFC;
|
||||
border-top: 1rpx solid #E2E8F0;
|
||||
padding: 24rpx;
|
||||
padding-bottom: calc(24rpx + env(safe-area-inset-bottom, 0));
|
||||
}
|
||||
.panel-grid {
|
||||
display: flex;
|
||||
gap: 32rpx;
|
||||
}
|
||||
.panel-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
.item-icon {
|
||||
width: 100rpx;
|
||||
height: 100rpx;
|
||||
border-radius: 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.item-icon:active { opacity: 0.7; }
|
||||
.item-label {
|
||||
font-size: 22rpx;
|
||||
color: #64748B;
|
||||
}
|
||||
</style>
|
||||
176
frontend/src/components/chat/VoiceRecorder.vue
Normal file
176
frontend/src/components/chat/VoiceRecorder.vue
Normal file
@@ -0,0 +1,176 @@
|
||||
<!--
|
||||
语音录制组件
|
||||
微信风格:长按录音 / 松手发送 / 上滑取消
|
||||
最长 60 秒,不足 1 秒提示太短
|
||||
-->
|
||||
<template>
|
||||
<view class="voice-recorder">
|
||||
<view
|
||||
class="record-btn"
|
||||
:class="{ 'recording': isRecording, 'cancel-zone': isCancelZone }"
|
||||
@touchstart.prevent="onTouchStart"
|
||||
@touchmove.prevent="onTouchMove"
|
||||
@touchend.prevent="onTouchEnd"
|
||||
>
|
||||
<text class="record-text">
|
||||
{{ isRecording ? (isCancelZone ? '松开取消' : '松开发送') : '按住 说话' }}
|
||||
</text>
|
||||
</view>
|
||||
<view v-if="isRecording" class="recording-overlay">
|
||||
<view class="recording-indicator">
|
||||
<view class="recording-icon" :class="{ 'cancel-icon': isCancelZone }">
|
||||
<uni-icons :type="isCancelZone ? 'close' : 'mic'" :size="40" color="#FFFFFF" />
|
||||
</view>
|
||||
<text class="recording-time">{{ recordingTime }}"</text>
|
||||
<text class="recording-tip">{{ isCancelZone ? '松开手指,取消发送' : '上滑取消发送' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, onBeforeUnmount } from 'vue'
|
||||
|
||||
export default {
|
||||
name: 'VoiceRecorder',
|
||||
emits: ['recorded'],
|
||||
setup(props, { emit }) {
|
||||
const isRecording = ref(false)
|
||||
const isCancelZone = ref(false)
|
||||
const recordingTime = ref(0)
|
||||
let recorder = null
|
||||
let timer = null
|
||||
let startY = 0
|
||||
|
||||
const onTouchStart = (e) => {
|
||||
startY = e.touches[0].clientY
|
||||
isCancelZone.value = false
|
||||
recordingTime.value = 0
|
||||
|
||||
recorder = uni.getRecorderManager()
|
||||
recorder.onStart(() => {
|
||||
isRecording.value = true
|
||||
timer = setInterval(() => {
|
||||
recordingTime.value++
|
||||
if (recordingTime.value >= 60) {
|
||||
onTouchEnd()
|
||||
}
|
||||
}, 1000)
|
||||
})
|
||||
recorder.onStop((res) => {
|
||||
clearInterval(timer)
|
||||
isRecording.value = false
|
||||
if (isCancelZone.value) return
|
||||
if (recordingTime.value < 1) {
|
||||
uni.showToast({ title: '录音时间太短', icon: 'none' })
|
||||
return
|
||||
}
|
||||
emit('recorded', {
|
||||
tempFilePath: res.tempFilePath,
|
||||
duration: recordingTime.value,
|
||||
fileSize: res.fileSize || 0
|
||||
})
|
||||
})
|
||||
recorder.onError(() => {
|
||||
clearInterval(timer)
|
||||
isRecording.value = false
|
||||
uni.showToast({ title: '录音失败', icon: 'none' })
|
||||
})
|
||||
|
||||
recorder.start({
|
||||
format: 'mp3',
|
||||
duration: 60000,
|
||||
sampleRate: 44100,
|
||||
numberOfChannels: 1
|
||||
})
|
||||
}
|
||||
|
||||
const onTouchMove = (e) => {
|
||||
if (!isRecording.value) return
|
||||
const deltaY = startY - e.touches[0].clientY
|
||||
isCancelZone.value = deltaY > 80
|
||||
}
|
||||
|
||||
const onTouchEnd = () => {
|
||||
if (recorder && isRecording.value) {
|
||||
recorder.stop()
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearInterval(timer)
|
||||
if (recorder && isRecording.value) {
|
||||
isCancelZone.value = true
|
||||
recorder.stop()
|
||||
}
|
||||
})
|
||||
|
||||
return { isRecording, isCancelZone, recordingTime, onTouchStart, onTouchMove, onTouchEnd }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.voice-recorder {
|
||||
flex: 1;
|
||||
}
|
||||
.record-btn {
|
||||
height: 72rpx;
|
||||
border-radius: 36rpx;
|
||||
background-color: #F1F5F9;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color 200ms;
|
||||
}
|
||||
.record-btn.recording {
|
||||
background-color: #DBEAFE;
|
||||
}
|
||||
.record-btn.cancel-zone {
|
||||
background-color: #FEE2E2;
|
||||
}
|
||||
.record-text {
|
||||
font-size: 28rpx;
|
||||
color: #64748B;
|
||||
user-select: none;
|
||||
}
|
||||
.recording-overlay {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
}
|
||||
.recording-indicator {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
padding: 40rpx 60rpx;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
border-radius: 24rpx;
|
||||
}
|
||||
.recording-icon {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 60rpx;
|
||||
background-color: #2563EB;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.cancel-icon {
|
||||
background-color: #DC2626;
|
||||
}
|
||||
.recording-time {
|
||||
font-size: 40rpx;
|
||||
font-weight: 700;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
.recording-tip {
|
||||
font-size: 24rpx;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
</style>
|
||||
141
frontend/src/components/msg/MsgFile.vue
Normal file
141
frontend/src/components/msg/MsgFile.vue
Normal file
@@ -0,0 +1,141 @@
|
||||
<!--
|
||||
文件消息组件
|
||||
展示文件图标 + 文件名 + 大小 + 下载/预览操作
|
||||
根据文件扩展名显示不同颜色的图标
|
||||
-->
|
||||
<template>
|
||||
<view v-if="msg.status === 2" class="recalled-text">消息已撤回</view>
|
||||
<view v-else class="msg-file-wrap" :class="{ 'file-self': isSelf }" @tap="onTap">
|
||||
<view class="file-icon-wrap" :style="{ backgroundColor: fileInfo.color + '20' }">
|
||||
<text class="file-icon-label" :style="{ color: fileInfo.color }">{{ fileInfo.label }}</text>
|
||||
</view>
|
||||
<view class="file-detail">
|
||||
<text class="file-name" :class="{ 'file-name-self': isSelf }">{{ fileData.file_name || '未知文件' }}</text>
|
||||
<text class="file-size" :class="{ 'file-size-self': isSelf }">{{ formattedSize }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { computed } from 'vue'
|
||||
import { parseExtra, formatFileSize, getFileTypeInfo, isPreviewable } from '@/utils/file'
|
||||
|
||||
export default {
|
||||
name: 'MsgFile',
|
||||
props: {
|
||||
msg: { type: Object, required: true },
|
||||
isSelf: { type: Boolean, default: false }
|
||||
},
|
||||
setup(props) {
|
||||
const fileData = computed(() => {
|
||||
const extra = parseExtra(props.msg.extra)
|
||||
return extra?.file || {}
|
||||
})
|
||||
|
||||
const fileInfo = computed(() => getFileTypeInfo(fileData.value.file_name))
|
||||
const formattedSize = computed(() => formatFileSize(fileData.value.size))
|
||||
|
||||
const onTap = () => {
|
||||
const url = fileData.value.url
|
||||
if (!url) return
|
||||
|
||||
const fileName = fileData.value.file_name || ''
|
||||
|
||||
if (isPreviewable(fileName)) {
|
||||
// #ifdef H5
|
||||
window.open(url, '_blank')
|
||||
return
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
uni.downloadFile({
|
||||
url,
|
||||
success: (res) => {
|
||||
if (res.statusCode === 200) {
|
||||
uni.openDocument({
|
||||
filePath: res.tempFilePath,
|
||||
showMenu: true,
|
||||
fail: () => uni.showToast({ title: '无法预览此文件', icon: 'none' })
|
||||
})
|
||||
}
|
||||
},
|
||||
fail: () => uni.showToast({ title: '下载失败', icon: 'none' })
|
||||
})
|
||||
// #endif
|
||||
} else {
|
||||
// #ifdef H5
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = fileName
|
||||
a.target = '_blank'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
uni.downloadFile({
|
||||
url,
|
||||
success: (res) => {
|
||||
uni.saveFile({
|
||||
tempFilePath: res.tempFilePath,
|
||||
success: () => uni.showToast({ title: '文件已保存', icon: 'success' }),
|
||||
fail: () => uni.showToast({ title: '保存失败', icon: 'none' })
|
||||
})
|
||||
}
|
||||
})
|
||||
// #endif
|
||||
}
|
||||
}
|
||||
|
||||
return { fileData, fileInfo, formattedSize, onTap }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.msg-file-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
min-width: 300rpx;
|
||||
max-width: 450rpx;
|
||||
cursor: pointer;
|
||||
}
|
||||
.file-icon-wrap {
|
||||
flex-shrink: 0;
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 16rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.file-icon-label {
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.file-detail {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
.file-name {
|
||||
font-size: 26rpx;
|
||||
color: #1E293B;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.file-name-self { color: #FFFFFF; }
|
||||
.file-size {
|
||||
font-size: 22rpx;
|
||||
color: #94A3B8;
|
||||
}
|
||||
.file-size-self { color: rgba(255, 255, 255, 0.7); }
|
||||
.recalled-text {
|
||||
font-size: 24rpx;
|
||||
color: #94A3B8;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
114
frontend/src/components/msg/MsgImage.vue
Normal file
114
frontend/src/components/msg/MsgImage.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<!--
|
||||
图片消息组件
|
||||
支持单图/多图(最多 9 张)网格布局 + 点击大图预览
|
||||
布局规则:1张=大图, 2/4张=2列, 3/5-9张=3列
|
||||
-->
|
||||
<template>
|
||||
<view v-if="msg.status === 2" class="recalled-text">消息已撤回</view>
|
||||
<view v-else class="msg-image-wrap" :class="gridClass">
|
||||
<view
|
||||
v-for="(img, idx) in images"
|
||||
:key="idx"
|
||||
class="img-item"
|
||||
@tap="onPreview(idx)"
|
||||
>
|
||||
<image
|
||||
class="img-thumb"
|
||||
:src="img.thumbnail_url || img.url"
|
||||
mode="aspectFill"
|
||||
lazy-load
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { computed } from 'vue'
|
||||
import { parseExtra } from '@/utils/file'
|
||||
|
||||
export default {
|
||||
name: 'MsgImage',
|
||||
props: {
|
||||
msg: { type: Object, required: true },
|
||||
isSelf: { type: Boolean, default: false }
|
||||
},
|
||||
setup(props) {
|
||||
const images = computed(() => {
|
||||
const extra = parseExtra(props.msg.extra)
|
||||
return extra?.images || []
|
||||
})
|
||||
|
||||
const gridClass = computed(() => {
|
||||
const count = images.value.length
|
||||
if (count <= 1) return 'grid-single'
|
||||
if (count === 2 || count === 4) return 'grid-2col'
|
||||
return 'grid-3col'
|
||||
})
|
||||
|
||||
const onPreview = (idx) => {
|
||||
const urls = images.value.map(img => img.url)
|
||||
uni.previewImage({
|
||||
urls,
|
||||
current: urls[idx] || urls[0]
|
||||
})
|
||||
}
|
||||
|
||||
return { images, gridClass, onPreview }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.msg-image-wrap {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8rpx;
|
||||
max-width: 500rpx;
|
||||
}
|
||||
.grid-single {
|
||||
width: 400rpx;
|
||||
}
|
||||
.grid-single .img-item {
|
||||
width: 100%;
|
||||
min-width: 200rpx;
|
||||
max-width: 400rpx;
|
||||
}
|
||||
.grid-single .img-thumb {
|
||||
width: 400rpx;
|
||||
height: 300rpx;
|
||||
border-radius: 12rpx;
|
||||
display: block;
|
||||
}
|
||||
.grid-2col {
|
||||
width: 320rpx;
|
||||
}
|
||||
.grid-2col .img-item {
|
||||
width: calc(50% - 4rpx);
|
||||
}
|
||||
.grid-2col .img-thumb {
|
||||
width: 156rpx;
|
||||
height: 156rpx;
|
||||
border-radius: 8rpx;
|
||||
display: block;
|
||||
}
|
||||
.grid-3col {
|
||||
width: 360rpx;
|
||||
}
|
||||
.grid-3col .img-item {
|
||||
width: calc(33.33% - 6rpx);
|
||||
}
|
||||
.grid-3col .img-thumb {
|
||||
width: 110rpx;
|
||||
height: 110rpx;
|
||||
border-radius: 8rpx;
|
||||
display: block;
|
||||
}
|
||||
.img-item {
|
||||
overflow: hidden;
|
||||
}
|
||||
.recalled-text {
|
||||
font-size: 24rpx;
|
||||
color: #94A3B8;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
41
frontend/src/components/msg/MsgText.vue
Normal file
41
frontend/src/components/msg/MsgText.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<!--
|
||||
文本消息组件
|
||||
接收 msg prop,展示文本内容
|
||||
撤回消息时显示"消息已撤回"
|
||||
-->
|
||||
<template>
|
||||
<view class="msg-text-wrap">
|
||||
<text v-if="msg.status === 2" class="recalled-text">消息已撤回</text>
|
||||
<text v-else class="msg-text" :class="{ 'msg-text-self': isSelf }">{{ msg.content }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'MsgText',
|
||||
props: {
|
||||
msg: { type: Object, required: true },
|
||||
isSelf: { type: Boolean, default: false }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.msg-text-wrap {
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
.msg-text {
|
||||
font-size: 30rpx;
|
||||
line-height: 42rpx;
|
||||
color: #1E293B;
|
||||
}
|
||||
.msg-text-self {
|
||||
color: #FFFFFF;
|
||||
}
|
||||
.recalled-text {
|
||||
font-size: 24rpx;
|
||||
color: #94A3B8;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
163
frontend/src/components/msg/MsgVoice.vue
Normal file
163
frontend/src/components/msg/MsgVoice.vue
Normal file
@@ -0,0 +1,163 @@
|
||||
<!--
|
||||
语音消息组件
|
||||
显示播放按钮 + 波形条 + 时长
|
||||
宽度按时长线性缩放:最小 120rpx(1秒),最大 500rpx(60秒)
|
||||
同一时间只允许一个语音播放(全局单例)
|
||||
-->
|
||||
<template>
|
||||
<view v-if="msg.status === 2" class="recalled-text">消息已撤回</view>
|
||||
<view
|
||||
v-else
|
||||
class="msg-voice-wrap"
|
||||
:class="{ 'voice-self': isSelf, 'voice-playing': isPlaying }"
|
||||
:style="{ width: voiceWidth + 'rpx' }"
|
||||
@tap="onTogglePlay"
|
||||
>
|
||||
<view class="voice-icon" :class="{ 'voice-icon-self': isSelf }">
|
||||
<uni-icons :type="isPlaying ? 'sound-filled' : 'sound'" :size="18" :color="isSelf ? '#FFFFFF' : '#2563EB'" />
|
||||
</view>
|
||||
<view class="voice-bars">
|
||||
<view v-for="i in 5" :key="i" class="voice-bar" :class="{ 'bar-self': isSelf }" :style="{ height: barHeights[i-1] + 'rpx' }" />
|
||||
</view>
|
||||
<text class="voice-duration" :class="{ 'duration-self': isSelf }">{{ duration }}"</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, computed, onBeforeUnmount } from 'vue'
|
||||
import { parseExtra } from '@/utils/file'
|
||||
|
||||
let _globalAudio = null
|
||||
let _globalPlayingId = null
|
||||
|
||||
export default {
|
||||
name: 'MsgVoice',
|
||||
props: {
|
||||
msg: { type: Object, required: true },
|
||||
isSelf: { type: Boolean, default: false }
|
||||
},
|
||||
setup(props) {
|
||||
const isPlaying = ref(false)
|
||||
|
||||
const voiceData = computed(() => {
|
||||
const extra = parseExtra(props.msg.extra)
|
||||
return extra?.voice || {}
|
||||
})
|
||||
|
||||
const duration = computed(() => voiceData.value.duration || 0)
|
||||
|
||||
const voiceWidth = computed(() => {
|
||||
const d = duration.value
|
||||
const minW = 120, maxW = 500
|
||||
return Math.min(maxW, Math.max(minW, minW + (d / 60) * (maxW - minW)))
|
||||
})
|
||||
|
||||
const barHeights = computed(() => {
|
||||
const base = [20, 30, 24, 32, 18]
|
||||
return base
|
||||
})
|
||||
|
||||
const onTogglePlay = () => {
|
||||
const url = voiceData.value.url
|
||||
if (!url) return
|
||||
|
||||
const msgId = props.msg.id || props.msg.client_msg_id
|
||||
|
||||
if (_globalPlayingId === msgId && _globalAudio) {
|
||||
_globalAudio.stop()
|
||||
_globalAudio = null
|
||||
_globalPlayingId = null
|
||||
isPlaying.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (_globalAudio) {
|
||||
_globalAudio.stop()
|
||||
_globalAudio = null
|
||||
_globalPlayingId = null
|
||||
}
|
||||
|
||||
const audio = uni.createInnerAudioContext()
|
||||
audio.src = url
|
||||
audio.onPlay(() => {
|
||||
isPlaying.value = true
|
||||
_globalPlayingId = msgId
|
||||
})
|
||||
audio.onEnded(() => {
|
||||
isPlaying.value = false
|
||||
_globalAudio = null
|
||||
_globalPlayingId = null
|
||||
})
|
||||
audio.onError(() => {
|
||||
isPlaying.value = false
|
||||
_globalAudio = null
|
||||
_globalPlayingId = null
|
||||
uni.showToast({ title: '播放失败', icon: 'none' })
|
||||
})
|
||||
audio.play()
|
||||
_globalAudio = audio
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
const msgId = props.msg.id || props.msg.client_msg_id
|
||||
if (_globalPlayingId === msgId && _globalAudio) {
|
||||
_globalAudio.stop()
|
||||
_globalAudio = null
|
||||
_globalPlayingId = null
|
||||
}
|
||||
})
|
||||
|
||||
return { isPlaying, duration, voiceWidth, barHeights, onTogglePlay }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.msg-voice-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
padding: 4rpx 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.voice-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.voice-bars {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6rpx;
|
||||
flex: 1;
|
||||
}
|
||||
.voice-bar {
|
||||
width: 6rpx;
|
||||
min-height: 12rpx;
|
||||
border-radius: 3rpx;
|
||||
background-color: #2563EB;
|
||||
transition: background-color 200ms;
|
||||
}
|
||||
.bar-self {
|
||||
background-color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
.voice-playing .voice-bar {
|
||||
animation: voicePulse 0.8s ease-in-out infinite alternate;
|
||||
}
|
||||
.voice-duration {
|
||||
font-size: 24rpx;
|
||||
color: #64748B;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.duration-self {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
.recalled-text {
|
||||
font-size: 24rpx;
|
||||
color: #94A3B8;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@keyframes voicePulse {
|
||||
0% { opacity: 0.4; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
@@ -51,11 +51,14 @@
|
||||
</view>
|
||||
<view
|
||||
class="bubble bubble-other"
|
||||
:class="{ 'bubble-recalled': msg.status === 2 }"
|
||||
:class="{ 'bubble-recalled': msg.status === 2, 'bubble-media': msg.type === 2 }"
|
||||
@longpress="onMsgLongPress(msg)"
|
||||
>
|
||||
<text v-if="msg.status === 2" class="recalled-text">消息已撤回</text>
|
||||
<text v-else class="msg-text">{{ msg.content }}</text>
|
||||
<MsgText v-if="msg.type === 1 || msg.type === 10" :msg="msg" :isSelf="false" />
|
||||
<MsgImage v-else-if="msg.type === 2" :msg="msg" :isSelf="false" />
|
||||
<MsgVoice v-else-if="msg.type === 3" :msg="msg" :isSelf="false" />
|
||||
<MsgFile v-else-if="msg.type === 5" :msg="msg" :isSelf="false" />
|
||||
<MsgText v-else :msg="msg" :isSelf="false" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -71,11 +74,14 @@
|
||||
</view>
|
||||
<view
|
||||
class="bubble bubble-self"
|
||||
:class="{ 'bubble-recalled': msg.status === 2 }"
|
||||
:class="{ 'bubble-recalled': msg.status === 2, 'bubble-media': msg.type === 2 }"
|
||||
@longpress="onMsgLongPress(msg)"
|
||||
>
|
||||
<text v-if="msg.status === 2" class="recalled-text">消息已撤回</text>
|
||||
<text v-else class="msg-text msg-text-self">{{ msg.content }}</text>
|
||||
<MsgText v-if="msg.type === 1 || msg.type === 10" :msg="msg" :isSelf="true" />
|
||||
<MsgImage v-else-if="msg.type === 2" :msg="msg" :isSelf="true" />
|
||||
<MsgVoice v-else-if="msg.type === 3" :msg="msg" :isSelf="true" />
|
||||
<MsgFile v-else-if="msg.type === 5" :msg="msg" :isSelf="true" />
|
||||
<MsgText v-else :msg="msg" :isSelf="true" />
|
||||
</view>
|
||||
<view class="avatar-wrap">
|
||||
<image v-if="selfAvatar" class="avatar-img" :src="selfAvatar" mode="aspectFill" />
|
||||
@@ -96,22 +102,38 @@
|
||||
|
||||
<!-- 输入栏 -->
|
||||
<view class="input-bar">
|
||||
<view class="input-wrap">
|
||||
<input
|
||||
class="msg-input"
|
||||
v-model="inputText"
|
||||
placeholder="输入消息..."
|
||||
placeholder-style="color: #94A3B8"
|
||||
confirm-type="send"
|
||||
@confirm="onSend"
|
||||
@input="onInputChange"
|
||||
:adjust-position="true"
|
||||
/>
|
||||
<view class="voice-toggle-btn" @tap="toggleVoiceMode">
|
||||
<uni-icons :type="voiceMode ? 'compose' : 'mic'" :size="22" color="#64748B" />
|
||||
</view>
|
||||
<view class="send-btn" :class="{ 'send-btn-active': inputText.trim() }" @tap="onSend">
|
||||
<template v-if="voiceMode">
|
||||
<VoiceRecorder @recorded="onVoiceRecorded" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<view class="input-wrap">
|
||||
<input
|
||||
class="msg-input"
|
||||
v-model="inputText"
|
||||
placeholder="输入消息..."
|
||||
placeholder-style="color: #94A3B8"
|
||||
confirm-type="send"
|
||||
@confirm="onSend"
|
||||
@input="onInputChange"
|
||||
:adjust-position="true"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
<view v-if="!voiceMode && inputText.trim()" class="send-btn send-btn-active" @tap="onSend">
|
||||
<uni-icons type="paperplane" size="22" color="#FFFFFF" />
|
||||
</view>
|
||||
<view v-else class="more-btn" @tap="toggleMorePanel">
|
||||
<uni-icons type="plusempty" :size="22" color="#64748B" />
|
||||
</view>
|
||||
</view>
|
||||
<MorePanel
|
||||
:visible="showMorePanel"
|
||||
@choose-image="onChooseImage"
|
||||
@choose-file="onChooseFile"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -120,9 +142,17 @@ import { onLoad, onUnload } from '@dcloudio/uni-app'
|
||||
import { ref, computed, nextTick, watch } from 'vue'
|
||||
import { useChatStore } from '@/store/chat'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { uploadImage, uploadFile, uploadVoice } from '@/api/file'
|
||||
import MsgText from '@/components/msg/MsgText.vue'
|
||||
import MsgImage from '@/components/msg/MsgImage.vue'
|
||||
import MsgVoice from '@/components/msg/MsgVoice.vue'
|
||||
import MsgFile from '@/components/msg/MsgFile.vue'
|
||||
import MorePanel from '@/components/chat/MorePanel.vue'
|
||||
import VoiceRecorder from '@/components/chat/VoiceRecorder.vue'
|
||||
|
||||
export default {
|
||||
name: 'ChatConversation',
|
||||
components: { MsgText, MsgImage, MsgVoice, MsgFile, MorePanel, VoiceRecorder },
|
||||
setup() {
|
||||
const chatStore = useChatStore()
|
||||
const userStore = useUserStore()
|
||||
@@ -142,6 +172,8 @@ export default {
|
||||
const selfName = computed(() => userStore.userInfo?.nickname || userStore.userInfo?.username || '我')
|
||||
|
||||
const convType = ref(1)
|
||||
const voiceMode = ref(false)
|
||||
const showMorePanel = ref(false)
|
||||
|
||||
const isSelf = (msg) => {
|
||||
const myId = Number(userStore.userInfo?.id) || 0
|
||||
@@ -283,6 +315,146 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
const toggleVoiceMode = () => {
|
||||
voiceMode.value = !voiceMode.value
|
||||
showMorePanel.value = false
|
||||
}
|
||||
|
||||
const toggleMorePanel = () => {
|
||||
showMorePanel.value = !showMorePanel.value
|
||||
voiceMode.value = false
|
||||
}
|
||||
|
||||
const onChooseImage = async () => {
|
||||
showMorePanel.value = false
|
||||
uni.chooseImage({
|
||||
count: 9,
|
||||
sizeType: ['compressed'],
|
||||
sourceType: ['album', 'camera'],
|
||||
success: async (res) => {
|
||||
const images = []
|
||||
for (const path of res.tempFilePaths) {
|
||||
try {
|
||||
uni.showLoading({ title: '上传中...' })
|
||||
const result = await uploadImage(path)
|
||||
images.push({
|
||||
url: result.url,
|
||||
thumbnail_url: result.thumbnail_url,
|
||||
width: result.width,
|
||||
height: result.height,
|
||||
size: result.size,
|
||||
file_name: result.file_name
|
||||
})
|
||||
} catch (e) {
|
||||
uni.showToast({ title: e?.message || '图片上传失败', icon: 'none' })
|
||||
} finally {
|
||||
uni.hideLoading()
|
||||
}
|
||||
}
|
||||
if (images.length > 0) {
|
||||
chatStore.sendImageMessage({
|
||||
conversationId: conversationId.value || 0,
|
||||
targetUserId: conversationId.value ? 0 : peerId.value,
|
||||
images
|
||||
})
|
||||
scrollToBottom()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const onChooseFile = async () => {
|
||||
showMorePanel.value = false
|
||||
// #ifdef H5
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.onchange = async (e) => {
|
||||
const file = e.target.files[0]
|
||||
if (!file) return
|
||||
if (file.size > 50 * 1024 * 1024) {
|
||||
uni.showToast({ title: '文件大小不能超过 50MB', icon: 'none' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
uni.showLoading({ title: '上传中...' })
|
||||
const result = await uploadFile(URL.createObjectURL(file), file.name)
|
||||
chatStore.sendFileMessage({
|
||||
conversationId: conversationId.value || 0,
|
||||
targetUserId: conversationId.value ? 0 : peerId.value,
|
||||
file: {
|
||||
url: result.url,
|
||||
// 优先使用前端原始文件名(H5 下 blob URL 会丢失原名)
|
||||
file_name: file.name || result.file_name,
|
||||
size: result.size,
|
||||
mime_type: file.type || 'application/octet-stream',
|
||||
ext: '.' + (file.name.split('.').pop() || '')
|
||||
}
|
||||
})
|
||||
scrollToBottom()
|
||||
} catch (e) {
|
||||
uni.showToast({ title: e?.message || '文件上传失败', icon: 'none' })
|
||||
} finally {
|
||||
uni.hideLoading()
|
||||
}
|
||||
}
|
||||
input.click()
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
uni.chooseFile({
|
||||
count: 1,
|
||||
success: async (res) => {
|
||||
const file = res.tempFiles[0]
|
||||
if (file.size > 50 * 1024 * 1024) {
|
||||
uni.showToast({ title: '文件大小不能超过 50MB', icon: 'none' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
uni.showLoading({ title: '上传中...' })
|
||||
const result = await uploadFile(file.path, file.name)
|
||||
chatStore.sendFileMessage({
|
||||
conversationId: conversationId.value || 0,
|
||||
targetUserId: conversationId.value ? 0 : peerId.value,
|
||||
file: {
|
||||
url: result.url,
|
||||
file_name: file.name || result.file_name,
|
||||
size: result.size,
|
||||
mime_type: file.type || 'application/octet-stream',
|
||||
ext: '.' + (file.name.split('.').pop() || '')
|
||||
}
|
||||
})
|
||||
scrollToBottom()
|
||||
} catch (e) {
|
||||
uni.showToast({ title: e?.message || '文件上传失败', icon: 'none' })
|
||||
} finally {
|
||||
uni.hideLoading()
|
||||
}
|
||||
}
|
||||
})
|
||||
// #endif
|
||||
}
|
||||
|
||||
const onVoiceRecorded = async ({ tempFilePath, duration }) => {
|
||||
try {
|
||||
uni.showLoading({ title: '发送中...' })
|
||||
const result = await uploadVoice(tempFilePath, duration)
|
||||
chatStore.sendVoiceMessage({
|
||||
conversationId: conversationId.value || 0,
|
||||
targetUserId: conversationId.value ? 0 : peerId.value,
|
||||
voice: {
|
||||
url: result.url,
|
||||
duration: result.duration || duration,
|
||||
size: result.size,
|
||||
file_name: result.file_name
|
||||
}
|
||||
})
|
||||
scrollToBottom()
|
||||
} catch (e) {
|
||||
uni.showToast({ title: e?.message || '语音发送失败', icon: 'none' })
|
||||
} finally {
|
||||
uni.hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
const goToSettings = () => {
|
||||
uni.navigateTo({
|
||||
url: `/pages/chat/settings?conversationId=${conversationId.value}&peerId=${peerId.value}&peerName=${encodeURIComponent(peerName.value)}&peerAvatar=${encodeURIComponent(peerAvatar.value)}`
|
||||
@@ -292,10 +464,13 @@ export default {
|
||||
return {
|
||||
peerName, peerAvatar, selfAvatar, selfName,
|
||||
inputText, scrollToId, loadingMore, convType,
|
||||
voiceMode, showMorePanel,
|
||||
messages, hasMore, isTyping,
|
||||
isSelf, isRead, getReadLabel,
|
||||
onSend, onInputChange, onLoadMore,
|
||||
onMsgLongPress, onResend, goBack, goToSettings
|
||||
onMsgLongPress, onResend, goBack, goToSettings,
|
||||
toggleVoiceMode, toggleMorePanel,
|
||||
onChooseImage, onChooseFile, onVoiceRecorded
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -469,4 +644,21 @@ export default {
|
||||
}
|
||||
.send-btn-active { background-color: #2563EB; }
|
||||
.send-btn:active { opacity: 0.85; }
|
||||
|
||||
.voice-toggle-btn, .more-btn {
|
||||
min-width: 72rpx;
|
||||
min-height: 72rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.voice-toggle-btn { margin-right: 8rpx; }
|
||||
.more-btn { margin-left: 8rpx; }
|
||||
.voice-toggle-btn:active, .more-btn:active { opacity: 0.6; }
|
||||
|
||||
.bubble-media {
|
||||
padding: 12rpx !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -68,11 +68,14 @@
|
||||
<text class="sender-name">{{ getMemberName(msg.sender_id) }}</text>
|
||||
<view
|
||||
class="bubble bubble-other"
|
||||
:class="{ 'bubble-recalled': msg.status === 2 }"
|
||||
:class="{ 'bubble-recalled': msg.status === 2, 'bubble-media': msg.type === 2 }"
|
||||
@longpress="onMsgLongPress(msg)"
|
||||
>
|
||||
<text v-if="msg.status === 2" class="recalled-text">消息已撤回</text>
|
||||
<text v-else class="msg-text">{{ msg.content }}</text>
|
||||
<MsgText v-if="msg.type === 1" :msg="msg" :isSelf="false" />
|
||||
<MsgImage v-else-if="msg.type === 2" :msg="msg" :isSelf="false" />
|
||||
<MsgVoice v-else-if="msg.type === 3" :msg="msg" :isSelf="false" />
|
||||
<MsgFile v-else-if="msg.type === 5" :msg="msg" :isSelf="false" />
|
||||
<MsgText v-else :msg="msg" :isSelf="false" />
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
@@ -89,11 +92,14 @@
|
||||
</view>
|
||||
<view
|
||||
class="bubble bubble-self"
|
||||
:class="{ 'bubble-recalled': msg.status === 2 }"
|
||||
:class="{ 'bubble-recalled': msg.status === 2, 'bubble-media': msg.type === 2 }"
|
||||
@longpress="onMsgLongPress(msg)"
|
||||
>
|
||||
<text v-if="msg.status === 2" class="recalled-text">消息已撤回</text>
|
||||
<text v-else class="msg-text msg-text-self">{{ msg.content }}</text>
|
||||
<MsgText v-if="msg.type === 1" :msg="msg" :isSelf="true" />
|
||||
<MsgImage v-else-if="msg.type === 2" :msg="msg" :isSelf="true" />
|
||||
<MsgVoice v-else-if="msg.type === 3" :msg="msg" :isSelf="true" />
|
||||
<MsgFile v-else-if="msg.type === 5" :msg="msg" :isSelf="true" />
|
||||
<MsgText v-else :msg="msg" :isSelf="true" />
|
||||
</view>
|
||||
<view class="avatar-wrap">
|
||||
<image v-if="selfAvatar" class="avatar-img" :src="selfAvatar" mode="aspectFill" />
|
||||
@@ -162,22 +168,38 @@
|
||||
<text class="muted-tip">{{ isAllMuted ? '当前群聊已开启全体禁言' : '你已被禁言,无法发送消息' }}</text>
|
||||
</view>
|
||||
<view v-else class="input-bar">
|
||||
<view class="input-wrap">
|
||||
<input
|
||||
class="msg-input"
|
||||
v-model="inputText"
|
||||
placeholder="输入消息..."
|
||||
placeholder-style="color: #94A3B8"
|
||||
confirm-type="send"
|
||||
@confirm="onSend"
|
||||
@input="onInputChange"
|
||||
:adjust-position="true"
|
||||
/>
|
||||
<view class="voice-toggle-btn" @tap="toggleVoiceMode">
|
||||
<uni-icons :type="voiceMode ? 'compose' : 'mic'" :size="22" color="#64748B" />
|
||||
</view>
|
||||
<view class="send-btn" :class="{ 'send-btn-active': inputText.trim() }" @tap="onSend">
|
||||
<template v-if="voiceMode">
|
||||
<VoiceRecorder @recorded="onVoiceRecorded" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<view class="input-wrap">
|
||||
<input
|
||||
class="msg-input"
|
||||
v-model="inputText"
|
||||
placeholder="输入消息..."
|
||||
placeholder-style="color: #94A3B8"
|
||||
confirm-type="send"
|
||||
@confirm="onSend"
|
||||
@input="onInputChange"
|
||||
:adjust-position="true"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
<view v-if="!voiceMode && inputText.trim()" class="send-btn send-btn-active" @tap="onSend">
|
||||
<uni-icons type="paperplane" size="22" color="#FFFFFF" />
|
||||
</view>
|
||||
<view v-else class="more-btn" @tap="toggleMorePanel">
|
||||
<uni-icons type="plusempty" :size="22" color="#64748B" />
|
||||
</view>
|
||||
</view>
|
||||
<MorePanel
|
||||
:visible="showMorePanel"
|
||||
@choose-image="onChooseImage"
|
||||
@choose-file="onChooseFile"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -188,9 +210,17 @@ import { useChatStore } from '@/store/chat'
|
||||
import { useGroupStore } from '@/store/group'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { GROUP_ROLE } from '@/constants/group'
|
||||
import { uploadImage, uploadFile, uploadVoice } from '@/api/file'
|
||||
import MsgText from '@/components/msg/MsgText.vue'
|
||||
import MsgImage from '@/components/msg/MsgImage.vue'
|
||||
import MsgVoice from '@/components/msg/MsgVoice.vue'
|
||||
import MsgFile from '@/components/msg/MsgFile.vue'
|
||||
import MorePanel from '@/components/chat/MorePanel.vue'
|
||||
import VoiceRecorder from '@/components/chat/VoiceRecorder.vue'
|
||||
|
||||
export default {
|
||||
name: 'GroupConversation',
|
||||
components: { MsgText, MsgImage, MsgVoice, MsgFile, MorePanel, VoiceRecorder },
|
||||
setup() {
|
||||
const chatStore = useChatStore()
|
||||
const groupStore = useGroupStore()
|
||||
@@ -205,6 +235,8 @@ export default {
|
||||
const loadingMore = ref(false)
|
||||
const showAtPicker = ref(false)
|
||||
const atUserIds = ref([])
|
||||
const voiceMode = ref(false)
|
||||
const showMorePanel = ref(false)
|
||||
|
||||
const messages = computed(() => chatStore.currentMessages)
|
||||
const hasMore = computed(() => chatStore.hasMoreMap[conversationId.value] !== false)
|
||||
@@ -446,6 +478,136 @@ export default {
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 富媒体消息 ====================
|
||||
|
||||
const toggleVoiceMode = () => {
|
||||
voiceMode.value = !voiceMode.value
|
||||
showMorePanel.value = false
|
||||
}
|
||||
|
||||
const toggleMorePanel = () => {
|
||||
showMorePanel.value = !showMorePanel.value
|
||||
voiceMode.value = false
|
||||
}
|
||||
|
||||
const onChooseImage = async () => {
|
||||
showMorePanel.value = false
|
||||
uni.chooseImage({
|
||||
count: 9,
|
||||
sizeType: ['compressed'],
|
||||
sourceType: ['album', 'camera'],
|
||||
success: async (res) => {
|
||||
const images = []
|
||||
for (const path of res.tempFilePaths) {
|
||||
try {
|
||||
uni.showLoading({ title: '上传中...' })
|
||||
const result = await uploadImage(path)
|
||||
images.push({
|
||||
url: result.url,
|
||||
thumbnail_url: result.thumbnail_url,
|
||||
width: result.width,
|
||||
height: result.height,
|
||||
size: result.size,
|
||||
file_name: result.file_name
|
||||
})
|
||||
} catch (e) {
|
||||
uni.showToast({ title: e?.message || '图片上传失败', icon: 'none' })
|
||||
} finally {
|
||||
uni.hideLoading()
|
||||
}
|
||||
}
|
||||
if (images.length > 0) {
|
||||
chatStore.sendImageMessage({ conversationId: conversationId.value, images })
|
||||
scrollToBottom()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const onChooseFile = async () => {
|
||||
showMorePanel.value = false
|
||||
// #ifdef H5
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.onchange = async (e) => {
|
||||
const file = e.target.files[0]
|
||||
if (!file) return
|
||||
if (file.size > 50 * 1024 * 1024) {
|
||||
uni.showToast({ title: '文件大小不能超过 50MB', icon: 'none' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
uni.showLoading({ title: '上传中...' })
|
||||
const result = await uploadFile(URL.createObjectURL(file), file.name)
|
||||
chatStore.sendFileMessage({
|
||||
conversationId: conversationId.value,
|
||||
file: {
|
||||
url: result.url,
|
||||
// 优先使用前端原始文件名(H5 下 blob URL 会丢失原名)
|
||||
file_name: file.name || result.file_name,
|
||||
size: result.size,
|
||||
mime_type: file.type || 'application/octet-stream',
|
||||
ext: '.' + (file.name.split('.').pop() || '')
|
||||
}
|
||||
})
|
||||
scrollToBottom()
|
||||
} catch (e) {
|
||||
uni.showToast({ title: e?.message || '文件上传失败', icon: 'none' })
|
||||
} finally {
|
||||
uni.hideLoading()
|
||||
}
|
||||
}
|
||||
input.click()
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
uni.chooseFile({
|
||||
count: 1,
|
||||
success: async (res) => {
|
||||
const file = res.tempFiles[0]
|
||||
if (file.size > 50 * 1024 * 1024) {
|
||||
uni.showToast({ title: '文件大小不能超过 50MB', icon: 'none' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
uni.showLoading({ title: '上传中...' })
|
||||
const result = await uploadFile(file.path, file.name)
|
||||
chatStore.sendFileMessage({
|
||||
conversationId: conversationId.value,
|
||||
file: {
|
||||
url: result.url,
|
||||
file_name: file.name || result.file_name,
|
||||
size: result.size,
|
||||
mime_type: file.type || 'application/octet-stream',
|
||||
ext: '.' + (file.name.split('.').pop() || '')
|
||||
}
|
||||
})
|
||||
scrollToBottom()
|
||||
} catch (e) {
|
||||
uni.showToast({ title: e?.message || '文件上传失败', icon: 'none' })
|
||||
} finally {
|
||||
uni.hideLoading()
|
||||
}
|
||||
}
|
||||
})
|
||||
// #endif
|
||||
}
|
||||
|
||||
const onVoiceRecorded = async ({ tempFilePath, duration }) => {
|
||||
try {
|
||||
uni.showLoading({ title: '发送中...' })
|
||||
const result = await uploadVoice(tempFilePath, duration)
|
||||
chatStore.sendVoiceMessage({
|
||||
conversationId: conversationId.value,
|
||||
voice: { url: result.url, duration: result.duration || duration, size: result.size, file_name: result.file_name }
|
||||
})
|
||||
scrollToBottom()
|
||||
} catch (e) {
|
||||
uni.showToast({ title: e?.message || '语音发送失败', icon: 'none' })
|
||||
} finally {
|
||||
uni.hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 监听新消息到达后标记已读 ====================
|
||||
|
||||
watch(
|
||||
@@ -464,7 +626,10 @@ export default {
|
||||
isSelf, getMemberName, getMemberAvatar, getReadLabel,
|
||||
onSend, onInputChange, onSelectAtMember,
|
||||
onLoadMore, onMsgLongPress, onResend,
|
||||
goBack, goToSettings, goToReadDetail
|
||||
goBack, goToSettings, goToReadDetail,
|
||||
voiceMode, showMorePanel,
|
||||
toggleVoiceMode, toggleMorePanel,
|
||||
onChooseImage, onChooseFile, onVoiceRecorded,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -741,4 +906,31 @@ export default {
|
||||
color: #94A3B8;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ===== 语音/更多 ===== */
|
||||
.voice-toggle-btn {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 12rpx;
|
||||
border-radius: 50%;
|
||||
background-color: #F1F5F9;
|
||||
}
|
||||
.voice-toggle-btn:active { background-color: #E2E8F0; }
|
||||
.more-btn {
|
||||
min-width: 72rpx;
|
||||
min-height: 72rpx;
|
||||
margin-left: 16rpx;
|
||||
border-radius: 50%;
|
||||
background-color: #F1F5F9;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.more-btn:active { background-color: #E2E8F0; }
|
||||
.bubble-media {
|
||||
max-width: 420rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -85,13 +85,13 @@ export const useChatStore = defineStore('chat', () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息
|
||||
* @param {Object} params - { conversationId, targetUserId, content, type }
|
||||
* 发送消息(统一入口,支持文本 + 富媒体)
|
||||
* @param {Object} params - { conversationId, targetUserId, content, type, extra, at_user_ids }
|
||||
* @returns {string} clientMsgId
|
||||
*/
|
||||
const sendMessage = (params) => {
|
||||
const clientMsgId = _generateClientMsgId()
|
||||
const { conversationId, targetUserId, content, type = 1, at_user_ids } = params
|
||||
const { conversationId, targetUserId, content = '', type = 1, extra = '', at_user_ids } = params
|
||||
const userStore = useUserStore()
|
||||
|
||||
const tempMsg = {
|
||||
@@ -100,6 +100,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
sender_id: Number(userStore.userInfo?.id) || 0,
|
||||
type,
|
||||
content,
|
||||
extra: extra || undefined,
|
||||
status: 1,
|
||||
client_msg_id: clientMsgId,
|
||||
created_at: new Date().toISOString().replace('T', ' ').substring(0, 19),
|
||||
@@ -116,6 +117,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
target_user_id: targetUserId || 0,
|
||||
type,
|
||||
content,
|
||||
extra,
|
||||
client_msg_id: clientMsgId
|
||||
}
|
||||
if (at_user_ids && at_user_ids.length > 0) {
|
||||
@@ -133,6 +135,57 @@ export const useChatStore = defineStore('chat', () => {
|
||||
return clientMsgId
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送图片消息
|
||||
* @param {Object} params - { conversationId, targetUserId, images }
|
||||
* images: [{ url, thumbnail_url, width, height, size, file_name }]
|
||||
*/
|
||||
const sendImageMessage = (params) => {
|
||||
const { conversationId, targetUserId, images } = params
|
||||
const extra = JSON.stringify({ images })
|
||||
return sendMessage({
|
||||
conversationId,
|
||||
targetUserId,
|
||||
content: '',
|
||||
type: 2,
|
||||
extra
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送语音消息
|
||||
* @param {Object} params - { conversationId, targetUserId, voice }
|
||||
* voice: { url, duration, size, file_name }
|
||||
*/
|
||||
const sendVoiceMessage = (params) => {
|
||||
const { conversationId, targetUserId, voice } = params
|
||||
const extra = JSON.stringify({ voice })
|
||||
return sendMessage({
|
||||
conversationId,
|
||||
targetUserId,
|
||||
content: '',
|
||||
type: 3,
|
||||
extra
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送文件消息
|
||||
* @param {Object} params - { conversationId, targetUserId, file }
|
||||
* file: { url, file_name, size, mime_type, ext }
|
||||
*/
|
||||
const sendFileMessage = (params) => {
|
||||
const { conversationId, targetUserId, file } = params
|
||||
const extra = JSON.stringify({ file })
|
||||
return sendMessage({
|
||||
conversationId,
|
||||
targetUserId,
|
||||
content: '',
|
||||
type: 5,
|
||||
extra
|
||||
})
|
||||
}
|
||||
|
||||
/** 撤回消息 */
|
||||
const recallMessage = (messageId) => {
|
||||
wsService.send('im.message.recall', { message_id: messageId })
|
||||
@@ -236,6 +289,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
sender_id: data.sender_id,
|
||||
type: data.type,
|
||||
content: data.content,
|
||||
extra: data.extra || undefined,
|
||||
status: 1,
|
||||
client_msg_id: data.client_msg_id || '',
|
||||
created_at: data.created_at
|
||||
@@ -243,7 +297,8 @@ export const useChatStore = defineStore('chat', () => {
|
||||
|
||||
const existingConv = conversationList.value.find(c => c.id === data.conversation_id)
|
||||
if (existingConv) {
|
||||
_updateConversationPreview(data.conversation_id, data.content, data.created_at, data.sender_id)
|
||||
const preview = _getMessagePreview(data.type, data.content, data.extra)
|
||||
_updateConversationPreview(data.conversation_id, preview, data.created_at, data.sender_id)
|
||||
} else {
|
||||
fetchConversations()
|
||||
}
|
||||
@@ -390,6 +445,38 @@ export const useChatStore = defineStore('chat', () => {
|
||||
totalUnread.value = conversationList.value.reduce((sum, c) => sum + (c.unread_count || 0), 0)
|
||||
}
|
||||
|
||||
/** 根据消息类型生成会话预览文案 */
|
||||
const _getMessagePreview = (type, content, extra) => {
|
||||
switch (type) {
|
||||
case 2: {
|
||||
let count = 1
|
||||
try {
|
||||
const parsed = typeof extra === 'string' ? JSON.parse(extra) : extra
|
||||
if (parsed?.images?.length > 1) count = parsed.images.length
|
||||
} catch {}
|
||||
return count > 1 ? `[图片x${count}]` : '[图片]'
|
||||
}
|
||||
case 3: {
|
||||
let dur = 0
|
||||
try {
|
||||
const parsed = typeof extra === 'string' ? JSON.parse(extra) : extra
|
||||
dur = parsed?.voice?.duration || 0
|
||||
} catch {}
|
||||
return dur > 0 ? `[语音 ${dur}"]` : '[语音]'
|
||||
}
|
||||
case 5: {
|
||||
let name = ''
|
||||
try {
|
||||
const parsed = typeof extra === 'string' ? JSON.parse(extra) : extra
|
||||
name = parsed?.file?.file_name || ''
|
||||
} catch {}
|
||||
return name ? `[文件] ${name}` : '[文件]'
|
||||
}
|
||||
default:
|
||||
return content || ''
|
||||
}
|
||||
}
|
||||
|
||||
/** 生成客户端消息唯一 ID */
|
||||
const _generateClientMsgId = () => {
|
||||
return `${Date.now()}-${Math.random().toString(36).substring(2, 10)}`
|
||||
@@ -410,6 +497,9 @@ export const useChatStore = defineStore('chat', () => {
|
||||
fetchConversations,
|
||||
fetchTotalUnread,
|
||||
sendMessage,
|
||||
sendImageMessage,
|
||||
sendVoiceMessage,
|
||||
sendFileMessage,
|
||||
recallMessage,
|
||||
markRead,
|
||||
markGroupRead,
|
||||
|
||||
76
frontend/src/utils/file.js
Normal file
76
frontend/src/utils/file.js
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* 文件工具函数
|
||||
* 提供图片压缩、文件大小格式化、文件类型判断等工具
|
||||
*/
|
||||
|
||||
/**
|
||||
* 文件大小格式化(自动选择 B/KB/MB/GB 单位)
|
||||
* @param {number} bytes 文件大小(字节)
|
||||
* @returns {string} 格式化后的文件大小
|
||||
*/
|
||||
export function formatFileSize(bytes) {
|
||||
if (!bytes || bytes === 0) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
const k = 1024
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + units[i]
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据文件扩展名获取文件类型图标信息
|
||||
* @param {string} fileName 文件名
|
||||
* @returns {{ icon: string, color: string, label: string }}
|
||||
*/
|
||||
export function getFileTypeInfo(fileName) {
|
||||
if (!fileName) return { icon: 'document', color: '#718096', label: '文件' }
|
||||
|
||||
const ext = fileName.split('.').pop()?.toLowerCase() || ''
|
||||
const typeMap = {
|
||||
pdf: { icon: 'document', color: '#E53E3E', label: 'PDF' },
|
||||
doc: { icon: 'document', color: '#2B6CB0', label: 'Word' },
|
||||
docx: { icon: 'document', color: '#2B6CB0', label: 'Word' },
|
||||
xls: { icon: 'document', color: '#276749', label: 'Excel' },
|
||||
xlsx: { icon: 'document', color: '#276749', label: 'Excel' },
|
||||
ppt: { icon: 'document', color: '#C05621', label: 'PPT' },
|
||||
pptx: { icon: 'document', color: '#C05621', label: 'PPT' },
|
||||
zip: { icon: 'document', color: '#6B46C1', label: 'ZIP' },
|
||||
rar: { icon: 'document', color: '#6B46C1', label: 'RAR' },
|
||||
'7z': { icon: 'document', color: '#6B46C1', label: '7Z' },
|
||||
jpg: { icon: 'image', color: '#38A169', label: '图片' },
|
||||
jpeg: { icon: 'image', color: '#38A169', label: '图片' },
|
||||
png: { icon: 'image', color: '#38A169', label: '图片' },
|
||||
gif: { icon: 'image', color: '#38A169', label: '图片' },
|
||||
mp3: { icon: 'sound', color: '#D69E2E', label: '音频' },
|
||||
wav: { icon: 'sound', color: '#D69E2E', label: '音频' },
|
||||
aac: { icon: 'sound', color: '#D69E2E', label: '音频' },
|
||||
m4a: { icon: 'sound', color: '#D69E2E', label: '音频' }
|
||||
}
|
||||
|
||||
return typeMap[ext] || { icon: 'document', color: '#718096', label: '文件' }
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断文件名是否为可预览类型(PDF/图片)
|
||||
* @param {string} fileName 文件名
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isPreviewable(fileName) {
|
||||
if (!fileName) return false
|
||||
const ext = fileName.split('.').pop()?.toLowerCase() || ''
|
||||
return ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'webp'].includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析消息 extra JSON
|
||||
* @param {string|object} extra 扩展数据(JSON 字符串或对象)
|
||||
* @returns {object|null}
|
||||
*/
|
||||
export function parseExtra(extra) {
|
||||
if (!extra) return null
|
||||
if (typeof extra === 'object') return extra
|
||||
try {
|
||||
return JSON.parse(extra)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user