Browse Source

视频和图片的传输

liuq 4 weeks ago
parent
commit
618786b231

+ 161 - 7
components/chat/PrivateMessageBubble.vue

@@ -26,7 +26,7 @@
 			<view class="center" :class="{ isMe: msg.isMe }">
 				<!-- 用户通知:与系统通知一致的卡片(绿标题条 + 白底正文 + 蓝边框按钮) -->
 				<view
-					v-if="msg.contentType === 'USER_NOTIFICATION'"
+					v-if="isUserNotification"
 					class="bubble notify-card"
 				>
 					<view class="notify-header">
@@ -51,20 +51,50 @@
 						</view>
 					</view>
 				</view>
-				<view v-else class="bubble">
-					<text v-if="msg.contentType === 'TEXT'" class="bubble-text">{{ msg.content }}</text>
+				<view
+					v-else
+					class="bubble"
+					:class="{ 'is-sending': msg.status === 'sending' }"
+				>
+					<text v-if="msgType === 'TEXT'" class="bubble-text">{{ msg.content }}</text>
 					<image
-						v-else-if="msg.contentType === 'IMAGE'"
+						v-else-if="msgType === 'IMAGE'"
 						class="bubble-img"
 						:src="msg.content"
 						mode="widthFix"
-						@click="onPreviewImage"
+						@click.stop="onPreviewImage"
 					/>
-					<view v-else class="bubble-file">
+					<view
+						v-else-if="msgType === 'VIDEO'"
+						class="bubble-video-card"
+						@click.stop="onOpenVideo"
+					>
+						<image
+							class="bubble-video-icon"
+							src="/static/icons/video.svg"
+							mode="aspectFit"
+						/>
+						<view class="bubble-video-meta">
+							<text class="bubble-video-title">{{ msg.title || '视频' }}</text>
+							<text class="bubble-video-hint">{{ canPlayVideo ? '点击下载并播放' : '暂无法播放' }}</text>
+						</view>
+					</view>
+					<view v-else-if="msgType === 'FILE'" class="bubble-file" @click.stop="onOpenFile">
 						<text class="file-name">{{ msg.title || '文件' }}</text>
-						<text v-if="msg.actionUrl" class="action-btn" @click="onOpenNotificationUrl">立即处理</text>
+						<text v-if="msg.actionUrl" class="action-btn" @click.stop="onOpenNotificationUrl">立即处理</text>
+						<text v-else-if="canOpenRemoteFile" class="action-btn" @click.stop="onOpenFile">打开</text>
+					</view>
+					<view v-else class="bubble-file">
+						<text class="file-name">{{ msg.title || msg.content || '消息' }}</text>
+						<text v-if="msg.actionUrl" class="action-btn" @click.stop="onOpenNotificationUrl">立即处理</text>
+					</view>
+					<view v-if="msg.isMe && msg.status === 'sending' && uploadProgressText" class="upload-hint">
+						<text>{{ uploadProgressText }}</text>
 					</view>
 				</view>
+				<view v-if="msg.isMe && msg.status === 'failed'" class="msg-failed" @click.stop="onRetry">
+					<text>发送失败,点击重试</text>
+				</view>
 				<view class="time-row">
 					<text class="bubble-time">{{ timeText }}</text>
 				</view>
@@ -89,6 +119,8 @@
 <script setup>
 import { computed } from 'vue'
 import UserAvatar from '../UserAvatar.vue'
+import { normalizeMessageContentType } from '../../utils/api'
+import { openVideoWithSystemPlayer } from '../../utils/openVideo'
 
 const props = defineProps({
 	msg: {
@@ -117,6 +149,37 @@ const props = defineProps({
 
 const emit = defineEmits(['preview-image', 'open-notification-url', 'retry', 'remind-user-notification'])
 
+const isUserNotification = computed(
+	() => normalizeMessageContentType(props.msg.contentType) === 'USER_NOTIFICATION'
+)
+
+const msgType = computed(() => normalizeMessageContentType(props.msg.contentType))
+
+const canOpenRemoteFile = computed(() => {
+	const c = props.msg.content
+	return !!(c && /^https?:\/\//i.test(String(c)))
+})
+
+/** 可下载并系统播放:远程 URL,或发送中/本地的临时路径 */
+const canPlayVideo = computed(() => {
+	const c = props.msg.content
+	if (!c) return false
+	const s = String(c)
+	if (/^https?:\/\//i.test(s)) return true
+	if (props.msg.tempId || props.msg.status === 'sending') return true
+	if (s.startsWith('/') || s.startsWith('file:') || /^[a-zA-Z]:\\/.test(s)) return true
+	return false
+})
+
+const uploadProgressText = computed(() => {
+	const m = props.msg
+	if (m.status !== 'sending') return ''
+	if (m.uploadProgress != null && m.uploadProgress > 0 && m.uploadProgress < 1) {
+		return '上传中 ' + Math.round(m.uploadProgress * 100) + '%'
+	}
+	return '发送中…'
+})
+
 const isTempMessage = computed(() => {
 	const m = props.msg
 	return !!(m.tempId || (m.id != null && String(m.id).startsWith('temp-')))
@@ -144,6 +207,45 @@ function onPreviewImage() {
 	if (props.msg.content) emit('preview-image', props.msg.content)
 }
 
+function onOpenVideo() {
+	if (!canPlayVideo.value) {
+		uni.showToast({ title: '暂无法播放', icon: 'none' })
+		return
+	}
+	openVideoWithSystemPlayer(props.msg.content)
+}
+
+function onOpenFile() {
+	const url = props.msg.content
+	if (!url) return
+	if (/^https?:\/\//i.test(String(url))) {
+		uni.showLoading({ title: '加载中', mask: true })
+		uni.downloadFile({
+			url: String(url),
+			success: (res) => {
+				if (res.statusCode === 200 && res.tempFilePath) {
+					uni.openDocument({
+						filePath: res.tempFilePath,
+						fail: () => {
+							uni.showToast({ title: '无法打开此文件', icon: 'none' })
+						}
+					})
+				} else {
+					uni.showToast({ title: '下载失败', icon: 'none' })
+				}
+			},
+			fail: () => {
+				uni.showToast({ title: '下载失败', icon: 'none' })
+			},
+			complete: () => {
+				uni.hideLoading()
+			}
+		})
+	} else {
+		uni.showToast({ title: '暂无法预览', icon: 'none' })
+	}
+}
+
 function onOpenNotificationUrl() {
 	emit('open-notification-url', props.msg)
 }
@@ -259,6 +361,58 @@ function onRetry() {
 	display: block;
 	box-shadow: 0 4rpx 10rpx rgba(15, 23, 42, 0.06);
 }
+.bubble-video-card {
+	display: flex;
+	align-items: center;
+	gap: 20rpx;
+	max-width: 420rpx;
+	min-height: 100rpx;
+	overflow: hidden;
+}
+.bubble-video-icon {
+	width: 72rpx;
+	height: 72rpx;
+	flex-shrink: 0;
+	opacity: 0.95;
+}
+.bubble-video-meta {
+	flex: 1;
+	min-width: 0;
+	display: flex;
+	flex-direction: column;
+	gap: 8rpx;
+}
+.bubble-video-title {
+	font-size: 28rpx;
+	color: #111827;
+	word-break: break-all;
+}
+.message-row.isMe .bubble-video-title {
+	color: #f9fafb;
+}
+.bubble-video-hint {
+	font-size: 24rpx;
+	color: #6b7280;
+}
+.message-row.isMe .bubble-video-hint {
+	color: rgba(249, 250, 251, 0.75);
+}
+.bubble.is-sending {
+	opacity: 0.92;
+}
+.upload-hint {
+	margin-top: 12rpx;
+	font-size: 22rpx;
+	color: #9ca3af;
+}
+.message-row.isMe .upload-hint {
+	color: rgba(249, 250, 251, 0.85);
+}
+.msg-failed {
+	margin-top: 6rpx;
+	font-size: 26rpx;
+	color: #fecaca;
+}
 .bubble-file .file-name {
 	display: block;
 	font-size: 28rpx;

+ 102 - 9
composables/useMessages.js

@@ -1,7 +1,15 @@
 /**
  * 会话消息:拉取历史、加载更多、发文字、发文件
  */
-import { getMessages, sendMessage as apiSendMessage, uploadFile, getToken, getContentType, getUserIdFromToken } from '../utils/api'
+import {
+	getMessages,
+	sendMessage as apiSendMessage,
+	uploadFile,
+	getToken,
+	getContentType,
+	getUserIdFromToken,
+	normalizeMessageContentType
+} from '../utils/api'
 import { chatStore } from '../store/chat'
 
 /**
@@ -17,13 +25,17 @@ function normalizeMessage(m, currentUserId) {
 		const senderId = m.sender_id ?? m.senderId
 		isMe = String(senderId) === String(currentUserId)
 	}
+	const rawText = m.content ?? m.text ?? ''
+	const urlField = m.url ?? m.file_url ?? m.content_url
+	const content =
+		urlField && /^https?:\/\//i.test(String(urlField)) ? String(urlField) : String(rawText)
 	return {
 		id: String(m.id),
 		type,
 		senderId: m.sender_id ?? m.senderId,
 		receiverId: m.receiver_id ?? m.receiverId,
-		content: m.content ?? m.text ?? '',
-		contentType: m.content_type ?? m.contentType ?? 'TEXT',
+		content,
+		contentType: normalizeMessageContentType(m.content_type ?? m.contentType ?? 'TEXT'),
 		title: m.title,
 		createdAt: m.created_at ?? m.createdAt,
 		isMe: !!isMe,
@@ -150,16 +162,96 @@ export function useMessages() {
 			uni.showToast({ title: '请先登录', icon: 'none' })
 			return
 		}
+		const cid = String(contactId)
+		const apiCt = normalizeMessageContentType(contentType)
+		const displayTitle =
+			(fileName && String(fileName).trim()) ||
+			(apiCt === 'IMAGE' ? '图片' : apiCt === 'VIDEO' ? '视频' : '文件')
+		const tempId = 'temp-' + Date.now()
+		const tempMsg = {
+			id: tempId,
+			tempId,
+			senderId: null,
+			content: filePath,
+			contentType: apiCt,
+			title: displayTitle,
+			createdAt: new Date().toISOString(),
+			isMe: true,
+			status: 'sending',
+			localFilePath: filePath,
+			uploadProgress: 0
+		}
+		chatStore.appendMessage(cid, tempMsg)
 		try {
-			const uploadResult = await uploadFile(token, filePath, fileName, onProgress)
+			const uploadResult = await uploadFile(token, filePath, fileName, (p) => {
+				if (typeof p === 'number' && p >= 0 && p <= 1) {
+					chatStore.updateMessage(cid, tempId, { uploadProgress: p })
+				}
+				if (typeof onProgress === 'function') onProgress(p)
+			})
 			const key = uploadResult.key || uploadResult.file_key
-			const name = fileName || uploadResult.filename
+			const name = (fileName && String(fileName).trim()) || uploadResult.filename || displayTitle
 			if (!key) throw new Error('上传未返回 key')
-			const res = await apiSendMessage(token, contactId, key, contentType, name)
-			const serverMsg = normalizeMessage(res, getUserIdFromToken(token))
-			if (serverMsg) chatStore.appendMessage(contactId, serverMsg)
+			const res = await apiSendMessage(token, cid, key, apiCt, name)
+			const raw = (res && (res.data ?? res.message ?? res)) || res
+			const currentUserId = getUserIdFromToken(token)
+			const serverMsg = normalizeMessage(typeof raw === 'object' && raw !== null ? raw : null, currentUserId)
+			const hasValidId = serverMsg && serverMsg.id && String(serverMsg.id) !== 'undefined'
+			if (hasValidId) {
+				const finalMsg =
+					serverMsg.content != null && String(serverMsg.content) !== ''
+						? serverMsg
+						: { ...serverMsg, content: key, contentType: apiCt, title: name }
+				chatStore.replaceTempMessage(cid, tempId, finalMsg)
+			} else {
+				chatStore.updateMessage(cid, tempId, { status: 'sent', uploadProgress: undefined })
+			}
 		} catch (e) {
-			uni.showToast({ title: e.message || '发送失败', icon: 'none' })
+			chatStore.updateMessage(cid, tempId, { status: 'failed', uploadProgress: undefined })
+			const errMsg = (e && (e.message || e.errMsg)) || '发送失败'
+			uni.showToast({ title: errMsg, icon: 'none', duration: 3000 })
+		}
+	}
+
+	async function retrySendFileMessage(contactId, msg) {
+		if (!msg || !msg.tempId || msg.status !== 'failed' || !msg.localFilePath) return
+		const token = getToken()
+		if (!token) {
+			uni.showToast({ title: '请先登录', icon: 'none' })
+			return
+		}
+		const cid = String(contactId)
+		const tempId = msg.tempId
+		const apiCt = normalizeMessageContentType(msg.contentType)
+		const displayTitle = (msg.title && String(msg.title).trim()) || (apiCt === 'IMAGE' ? '图片' : apiCt === 'VIDEO' ? '视频' : '文件')
+		chatStore.updateMessage(cid, tempId, { status: 'sending', uploadProgress: 0 })
+		try {
+			const uploadResult = await uploadFile(token, msg.localFilePath, undefined, (p) => {
+				if (typeof p === 'number' && p >= 0 && p <= 1) {
+					chatStore.updateMessage(cid, tempId, { uploadProgress: p })
+				}
+			})
+			const key = uploadResult.key || uploadResult.file_key
+			const name = uploadResult.filename || displayTitle
+			if (!key) throw new Error('上传未返回 key')
+			const res = await apiSendMessage(token, cid, key, apiCt, name)
+			const raw = (res && (res.data ?? res.message ?? res)) || res
+			const currentUserId = getUserIdFromToken(token)
+			const serverMsg = normalizeMessage(typeof raw === 'object' && raw !== null ? raw : null, currentUserId)
+			const hasValidId = serverMsg && serverMsg.id && String(serverMsg.id) !== 'undefined'
+			if (hasValidId) {
+				const finalMsg =
+					serverMsg.content != null && String(serverMsg.content) !== ''
+						? serverMsg
+						: { ...serverMsg, content: key, contentType: apiCt, title: name }
+				chatStore.replaceTempMessage(cid, tempId, finalMsg)
+			} else {
+				chatStore.updateMessage(cid, tempId, { status: 'sent', uploadProgress: undefined })
+			}
+		} catch (e) {
+			chatStore.updateMessage(cid, tempId, { status: 'failed', uploadProgress: undefined })
+			const errMsg = (e && (e.message || e.errMsg)) || '发送失败'
+			uni.showToast({ title: errMsg, icon: 'none', duration: 3000 })
 		}
 	}
 
@@ -224,6 +316,7 @@ export function useMessages() {
 		sendMessage,
 		retrySendMessage,
 		sendFileMessage,
+		retrySendFileMessage,
 		remindUserNotification,
 		hasContactLoaded: (id) => chatStore.hasContactLoaded(id),
 		getContentType

+ 7 - 3
composables/useWebSocket.js

@@ -1,7 +1,7 @@
 /**
  * 消息 WebSocket:登录后连接,收到新消息时更新 chatStore(消息列表、本地未读、会话预览),不整表刷新
  */
-import { getToken } from '../utils/api'
+import { getToken, normalizeMessageContentType } from '../utils/api'
 import { chatStore } from '../store/chat'
 
 const WS_BASE = 'wss://api.hnyunzhu.com/api/v1/ws/messages'
@@ -15,13 +15,17 @@ function normalizeWsMessage(raw) {
 	const type = m.type ?? 'MESSAGE'
 	// 通知类消息:应用发给用户,当前用户始终为接收方
 	const isMe = type === 'NOTIFICATION' ? false : (m.is_me ?? m.isMe)
+	const rawText = m.content ?? m.text ?? ''
+	const urlField = m.url ?? m.file_url ?? m.content_url
+	const content =
+		urlField && /^https?:\/\//i.test(String(urlField)) ? String(urlField) : String(rawText)
 	return {
 		id: String(m.id),
 		type,
 		senderId: m.sender_id ?? m.senderId,
 		receiverId: m.receiver_id ?? m.receiverId,
-		content: m.content ?? m.text ?? '',
-		contentType: m.content_type ?? m.contentType ?? 'TEXT',
+		content,
+		contentType: normalizeMessageContentType(m.content_type ?? m.contentType ?? 'TEXT'),
 		title: m.title,
 		createdAt: m.created_at ?? m.createdAt,
 		isMe,

+ 32 - 9
pages/chat/index.vue

@@ -116,7 +116,7 @@ const showPlusPanel = ref(false)
 /** 正在对哪条 USER_NOTIFICATION 执行「再次提醒」(用于按钮 loading,并防并发连点) */
 const remindingNotificationId = ref('')
 
-const { messages, loadingMore, fetchMessages, fetchMoreMessages, sendMessage, retrySendMessage, sendFileMessage, remindUserNotification } = useMessages()
+const { messages, loadingMore, fetchMessages, fetchMoreMessages, sendMessage, retrySendMessage, sendFileMessage, retrySendFileMessage, remindUserNotification } = useMessages()
 const { fetchContacts } = useContacts()
 
 function syncContactTitle() {
@@ -214,8 +214,20 @@ function onLoadMore() {
 	fetchMoreMessages(otherUserId.value)
 }
 
+function basenameFromPath(p) {
+	if (!p) return ''
+	const s = String(p).replace(/\\/g, '/')
+	const i = s.lastIndexOf('/')
+	return i >= 0 ? s.slice(i + 1) : s
+}
+
 function onRetry(msg) {
-	retrySendMessage(otherUserId.value, msg)
+	if (!msg || msg.status !== 'failed') return
+	if (msg.contentType === 'TEXT') {
+		retrySendMessage(otherUserId.value, msg)
+		return
+	}
+	retrySendFileMessage(otherUserId.value, msg)
 }
 
 async function onRemindUserNotification(msg) {
@@ -305,7 +317,8 @@ function onChooseImage() {
 		count: 1,
 		success: (res) => {
 			const path = res.tempFilePaths[0]
-			sendFileMessage(otherUserId.value, path, 'image')
+			const name = basenameFromPath(path) || 'image.jpg'
+			sendFileMessage(otherUserId.value, path, 'IMAGE', name)
 		}
 	})
 }
@@ -317,7 +330,8 @@ function onChooseVideo() {
 		success: (res) => {
 			const path = res.tempFilePath || (res.tempFilePaths && res.tempFilePaths[0])
 			if (path) {
-				sendFileMessage(otherUserId.value, path, 'video')
+				const name = basenameFromPath(path) || 'video.mp4'
+				sendFileMessage(otherUserId.value, path, 'VIDEO', name)
 			}
 		}
 	})
@@ -325,16 +339,25 @@ function onChooseVideo() {
 
 function onChooseFile() {
 	uni.hideKeyboard()
-	// 不同平台支持差异较大,如有需要可按端分别处理
-	if (uni.chooseMessageFile) {
+	const pick = (path, name) => {
+		const n = (name && String(name).trim()) || basenameFromPath(path) || '文件'
+		sendFileMessage(otherUserId.value, path, 'FILE', n)
+	}
+	if (typeof uni.chooseMessageFile === 'function') {
 		uni.chooseMessageFile({
 			count: 1,
 			type: 'file',
 			success: (res) => {
 				const file = res.tempFiles && res.tempFiles[0]
-				if (file && file.path) {
-					sendFileMessage(otherUserId.value, file.path, 'file')
-				}
+				if (file && file.path) pick(file.path, file.name)
+			}
+		})
+	} else if (typeof uni.chooseFile === 'function') {
+		uni.chooseFile({
+			count: 1,
+			success: (res) => {
+				const path = (res.tempFilePaths && res.tempFilePaths[0]) || ''
+				if (path) pick(path)
 			}
 		})
 	} else {

+ 15 - 0
utils/api.js

@@ -331,6 +331,21 @@ export function ssoLogin(appId, username = '', password = '') {
 	})
 }
 
+/**
+ * 与后端约定一致:TEXT / IMAGE / VIDEO / FILE / USER_NOTIFICATION
+ */
+export function normalizeMessageContentType(ct) {
+	if (ct == null || ct === '') return 'TEXT'
+	const upper = String(ct).trim().toUpperCase()
+	if (['TEXT', 'IMAGE', 'VIDEO', 'FILE', 'USER_NOTIFICATION'].includes(upper)) return upper
+	const lower = String(ct).toLowerCase()
+	if (lower === 'image') return 'IMAGE'
+	if (lower === 'video') return 'VIDEO'
+	if (lower === 'file') return 'FILE'
+	if (lower === 'text') return 'TEXT'
+	return 'TEXT'
+}
+
 /**
  * 根据文件名/类型得到 content_type
  */

+ 59 - 0
utils/openVideo.js

@@ -0,0 +1,59 @@
+/**
+ * 视频:先下载远程地址到本地,再用系统默认应用打开;本地路径直接打开。
+ */
+
+function openLocalVideoPath(filePath) {
+	if (!filePath) return
+	// #ifdef APP-PLUS
+	try {
+		if (typeof plus !== 'undefined' && plus.runtime && typeof plus.runtime.openFile === 'function') {
+			plus.runtime.openFile(filePath, () => {
+				uni.openDocument({
+					filePath,
+					fail: () => {
+						uni.showToast({ title: '无法打开视频', icon: 'none' })
+					}
+				})
+			})
+			return
+		}
+	} catch (e) {
+		// fall through
+	}
+	// #endif
+	uni.openDocument({
+		filePath,
+		fail: () => {
+			uni.showToast({ title: '无法打开视频', icon: 'none' })
+		}
+	})
+}
+
+/**
+ * @param {string} pathOrUrl - 本地临时路径或 http(s) 地址
+ */
+export function openVideoWithSystemPlayer(pathOrUrl) {
+	if (!pathOrUrl) return
+	const s = String(pathOrUrl).trim()
+	if (/^https?:\/\//i.test(s)) {
+		uni.showLoading({ title: '下载中', mask: true })
+		uni.downloadFile({
+			url: s,
+			success: (res) => {
+				if (res.statusCode === 200 && res.tempFilePath) {
+					openLocalVideoPath(res.tempFilePath)
+				} else {
+					uni.showToast({ title: '下载失败', icon: 'none' })
+				}
+			},
+			fail: () => {
+				uni.showToast({ title: '下载失败', icon: 'none' })
+			},
+			complete: () => {
+				uni.hideLoading()
+			}
+		})
+		return
+	}
+	openLocalVideoPath(s)
+}