Prechádzať zdrojové kódy

新增重新发送通知功能

liuq 3 týždňov pred
rodič
commit
bd664f38dc

+ 97 - 2
components/chat/PrivateMessageBubble.vue

@@ -24,7 +24,34 @@
 			</view>
 			<!-- 中间:气泡 + 时间 -->
 			<view class="center" :class="{ isMe: msg.isMe }">
-				<view class="bubble">
+				<!-- 用户通知:与系统通知一致的卡片(绿标题条 + 白底正文 + 蓝边框按钮) -->
+				<view
+					v-if="msg.contentType === 'USER_NOTIFICATION'"
+					class="bubble notify-card"
+				>
+					<view class="notify-header">
+						<text class="notify-header-text">{{ msg.title || '通知' }}</text>
+					</view>
+					<view class="notify-body">
+						<text v-if="msg.content" class="notify-content">{{ msg.content }}</text>
+					</view>
+					<!-- 接收方:可打开 callback;发送方:再次提醒(不展示 action 文案) -->
+					<view v-if="!msg.isMe && (msg.actionText || msg.actionUrl)" class="notify-footer">
+						<view class="notify-btn" @click="onOpenNotificationUrl">
+							<text class="notify-btn-text">{{ msg.actionText || '打开应用' }}</text>
+						</view>
+					</view>
+					<view v-if="msg.isMe && !isTempMessage" class="notify-footer">
+						<view
+							class="notify-btn"
+							:class="{ 'is-disabled': remindLoading }"
+							@click="onRemindClick"
+						>
+							<text class="notify-btn-text">{{ remindLoading ? '发送中...' : '再次提醒' }}</text>
+						</view>
+					</view>
+				</view>
+				<view v-else class="bubble">
 					<text v-if="msg.contentType === 'TEXT'" class="bubble-text">{{ msg.content }}</text>
 					<image
 						v-else-if="msg.contentType === 'IMAGE'"
@@ -80,10 +107,20 @@ const props = defineProps({
 	showDateLabel: {
 		type: Boolean,
 		default: false
+	},
+	/** 当前消息是否正在「再次提醒」发送中(用于按钮 loading / 防连点) */
+	remindLoading: {
+		type: Boolean,
+		default: false
 	}
 })
 
-const emit = defineEmits(['preview-image', 'open-notification-url', 'retry'])
+const emit = defineEmits(['preview-image', 'open-notification-url', 'retry', 'remind-user-notification'])
+
+const isTempMessage = computed(() => {
+	const m = props.msg
+	return !!(m.tempId || (m.id != null && String(m.id).startsWith('temp-')))
+})
 
 const dateLabel = computed(() => {
 	const t = props.msg.createdAt
@@ -111,6 +148,11 @@ function onOpenNotificationUrl() {
 	emit('open-notification-url', props.msg)
 }
 
+function onRemindClick() {
+	if (props.remindLoading) return
+	emit('remind-user-notification', props.msg)
+}
+
 function onRetry() {
 	if (props.msg.status === 'failed') emit('retry', props.msg)
 }
@@ -187,9 +229,21 @@ function onRetry() {
 	background: #e5e7eb;
 	box-shadow: 0 4rpx 10rpx rgba(15, 23, 42, 0.06);
 }
+.bubble.notify-card {
+	width: 100%;
+	max-width: 560rpx;
+	min-width: 0;
+	padding: 0;
+	border-radius: 18rpx;
+	background: #ffffff;
+	overflow: hidden;
+}
 .message-row.isMe .bubble {
 	background: #259653;
 }
+.message-row.isMe .bubble.notify-card {
+	background: #ffffff;
+}
 .bubble-text {
 	font-size: 28rpx;
 	color: #111827;
@@ -219,6 +273,47 @@ function onRetry() {
 	font-size: 26rpx;
 	color: #259653;
 }
+.notify-header {
+	background-color: #bbf7d0;
+	padding: 16rpx 20rpx;
+}
+.notify-header-text {
+	font-size: 26rpx;
+	color: #166534;
+	font-weight: 600;
+}
+.notify-body {
+	padding: 20rpx 20rpx 8rpx;
+}
+.notify-content {
+	font-size: 26rpx;
+	color: #111827;
+	line-height: 1.5;
+	word-break: break-all;
+}
+.notify-footer {
+	padding: 12rpx 20rpx 20rpx;
+}
+.notify-btn {
+	width: 100%;
+	height: 72rpx;
+	border-radius: 12rpx;
+	border-width: 2rpx;
+	border-style: solid;
+	border-color: #1677ff;
+	background-color: #ffffff;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+}
+.notify-btn-text {
+	font-size: 28rpx;
+	color: #1677ff;
+}
+.notify-btn.is-disabled {
+	opacity: 0.65;
+	pointer-events: none;
+}
 .side-right {
 	flex-shrink: 0;
 	width: 72rpx;

+ 54 - 0
composables/useMessages.js

@@ -163,6 +163,59 @@ export function useMessages() {
 		}
 	}
 
+	/** 再次提醒:向对方重发一条相同结构的 USER_NOTIFICATION */
+	async function remindUserNotification(contactId, sourceMsg) {
+		if (!sourceMsg || sourceMsg.tempId) return
+		const token = getToken()
+		if (!token) {
+			uni.showToast({ title: '请先登录', icon: 'none' })
+			return
+		}
+		const cid = String(contactId)
+		const tempId = 'temp-' + Date.now()
+		const content = sourceMsg.content != null ? String(sourceMsg.content) : ''
+		const title =
+			sourceMsg.title != null && sourceMsg.title !== '' ? String(sourceMsg.title) : undefined
+		const tempMsg = {
+			id: tempId,
+			tempId,
+			senderId: null,
+			type: 'MESSAGE',
+			content,
+			contentType: 'USER_NOTIFICATION',
+			title: sourceMsg.title,
+			actionUrl: sourceMsg.actionUrl,
+			actionText: sourceMsg.actionText,
+			createdAt: new Date().toISOString(),
+			isMe: true,
+			status: 'sending'
+		}
+		chatStore.appendMessage(cid, tempMsg)
+		try {
+			const res = await apiSendMessage(token, cid, content, 'USER_NOTIFICATION', title, {
+				action_url: sourceMsg.actionUrl,
+				action_text: sourceMsg.actionText
+			})
+			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.contentType === 'USER_NOTIFICATION'
+						? serverMsg
+						: { ...serverMsg, contentType: 'USER_NOTIFICATION', content, title: sourceMsg.title }
+				chatStore.replaceTempMessage(cid, tempId, finalMsg)
+			} else {
+				chatStore.updateMessage(cid, tempId, { status: 'sent' })
+			}
+		} catch (e) {
+			chatStore.removeMessage(cid, tempId)
+			const errMsg = (e && (e.message || e.errMsg)) || '发送失败'
+			uni.showToast({ title: errMsg, icon: 'none', duration: 3000 })
+		}
+	}
+
 	return {
 		messages: chatStore.messages,
 		loadingMore: chatStore.loadingMore,
@@ -171,6 +224,7 @@ export function useMessages() {
 		sendMessage,
 		retrySendMessage,
 		sendFileMessage,
+		remindUserNotification,
 		hasContactLoaded: (id) => chatStore.hasContactLoaded(id),
 		getContentType
 	}

+ 16 - 1
pages/chat/index.vue

@@ -42,9 +42,11 @@
 					:me-id="currentUserId"
 					:me-avatar="currentUserAvatar"
 					:show-date-label="shouldShowDateLabel(index)"
+					:remind-loading="remindingNotificationId === String(msg.id)"
 					@preview-image="previewImage"
 					@open-notification-url="openNotificationUrl"
 					@retry="onRetry"
+					@remind-user-notification="onRemindUserNotification"
 				/>
 				<NotificationBubble
 					v-else
@@ -111,8 +113,10 @@ const fallbackContactName = ref('')
 const inputValue = ref('')
 const scrollIntoView = ref('')
 const showPlusPanel = ref(false)
+/** 正在对哪条 USER_NOTIFICATION 执行「再次提醒」(用于按钮 loading,并防并发连点) */
+const remindingNotificationId = ref('')
 
-const { messages, loadingMore, fetchMessages, fetchMoreMessages, sendMessage, retrySendMessage, sendFileMessage } = useMessages()
+const { messages, loadingMore, fetchMessages, fetchMoreMessages, sendMessage, retrySendMessage, sendFileMessage, remindUserNotification } = useMessages()
 const { fetchContacts } = useContacts()
 
 function syncContactTitle() {
@@ -214,6 +218,17 @@ function onRetry(msg) {
 	retrySendMessage(otherUserId.value, msg)
 }
 
+async function onRemindUserNotification(msg) {
+	if (remindingNotificationId.value) return
+	if (!msg || msg.tempId) return
+	remindingNotificationId.value = String(msg.id)
+	try {
+		await remindUserNotification(otherUserId.value, msg)
+	} finally {
+		remindingNotificationId.value = ''
+	}
+}
+
 function getSenderName(msg) {
 	return msg.isMe ? (currentUserName.value || '我') : contactTitle.value
 }

+ 8 - 0
store/chat.js

@@ -93,6 +93,14 @@ export const chatStore = reactive({
 		}
 	},
 
+	/** 按 id / tempId 移除一条(如再次提醒发送失败时撤回临时消息) */
+	removeMessage(contactId, messageId) {
+		const id = String(contactId)
+		const list = this.messages[id]
+		if (!list || !list.length) return
+		this.messages[id] = list.filter((m) => m.id !== messageId && m.tempId !== messageId)
+	},
+
 	/** 更新某条消息(如 status: 'sending' / 'failed') */
 	updateMessage(contactId, tempIdOrId, updates) {
 		const id = String(contactId)

+ 9 - 3
utils/api.js

@@ -94,19 +94,25 @@ export function getMessages(token, otherUserId, params = {}) {
 
 /**
  * 发送消息(对接文档 Message_Integration_Guide.md 3.2)
- * POST /messages/  Body: { receiver_id, type, content_type, title?, content? }
+ * POST /messages/  Body: { receiver_id, type, content_type, title?, content?, action_url?, action_text? }
  * 用户发私信:receiver_id 为对方用户 ID(数字),type: 'MESSAGE',建议带 title(如「私信」)
+ * @param {object} [extras] - 可选,USER_NOTIFICATION 等可传 { action_url, action_text }
  */
-export function sendMessage(token, receiverId, content, contentType = 'TEXT', title) {
+export function sendMessage(token, receiverId, content, contentType = 'TEXT', title, extras) {
 	const id = receiverId != null ? String(receiverId).trim() : ''
 	const num = id === '' ? NaN : Number(id)
+	const defaultTitle = contentType === 'USER_NOTIFICATION' ? '通知' : '私信'
 	const body = {
 		receiver_id: (num !== num || !Number.isInteger(num)) ? (id || receiverId) : num,
 		type: 'MESSAGE',
 		content_type: contentType,
-		title: title != null && title !== '' ? String(title) : '私信',
+		title: title != null && title !== '' ? String(title) : defaultTitle,
 		content: content != null ? String(content) : ''
 	}
+	if (extras && typeof extras === 'object') {
+		if (extras.action_url != null && extras.action_url !== '') body.action_url = String(extras.action_url)
+		if (extras.action_text != null && extras.action_text !== '') body.action_text = String(extras.action_text)
+	}
 	return request({
 		token,
 		url: '/messages/',