feat:聊天信息支持 文件、图片、语音发送,增加一键启停脚本

This commit is contained in:
bujinyuan
2026-04-20 11:53:27 +08:00
parent 1a5aac9998
commit 0c1540bdee
116 changed files with 20277 additions and 144 deletions

View File

@@ -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
}

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,163 @@
<!--
语音消息组件
显示播放按钮 + 波形条 + 时长
宽度按时长线性缩放最小 120rpx1最大 500rpx60
同一时间只允许一个语音播放全局单例
-->
<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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,

View 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
}
}