|
|
@@ -5,6 +5,8 @@ import { logger } from '../../utils/logger'
|
|
|
import { api } from '../../services/api'
|
|
|
import { normalizeExternalUrl } from '../../utils/urlUtils'
|
|
|
import { isNotificationLikeMessage } from '../../utils/messageTypes'
|
|
|
+import { formatApiMessageToMessage } from '../../utils/formatMessage'
|
|
|
+import { showToast } from '../../utils/toast'
|
|
|
|
|
|
const OPEN_IN_SYSTEM_BROWSER_KEY = 'launchpad-open-in-system-browser'
|
|
|
|
|
|
@@ -12,6 +14,7 @@ interface MessageBubbleProps {
|
|
|
msg: Message
|
|
|
uploadProgress?: { [key: string]: number }
|
|
|
activeContactId: number | null
|
|
|
+ currentUserId: number | null
|
|
|
token: string
|
|
|
setMessages: React.Dispatch<React.SetStateAction<Record<number, Message[]>>>
|
|
|
}
|
|
|
@@ -20,10 +23,12 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
|
|
|
msg,
|
|
|
uploadProgress = {},
|
|
|
activeContactId,
|
|
|
+ currentUserId,
|
|
|
token,
|
|
|
setMessages
|
|
|
}) => {
|
|
|
const [actionLoading, setActionLoading] = useState(false)
|
|
|
+ const [remindLoading, setRemindLoading] = useState(false)
|
|
|
|
|
|
// 通知类型消息(带跳转:先请求 callback-url 再打开,打开方式与应用中心一致)
|
|
|
if (msg.type === 'notification' || isNotificationLikeMessage(msg.messageType, msg.content_type)) {
|
|
|
@@ -35,6 +40,44 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
|
|
|
}
|
|
|
})()
|
|
|
|
|
|
+ const isSenderUserNotification =
|
|
|
+ msg.isSelf && msg.content_type === 'USER_NOTIFICATION'
|
|
|
+ const targetReceiverId = msg.receiver_id ?? activeContactId
|
|
|
+ const showRemindButton = Boolean(isSenderUserNotification && targetReceiverId != null)
|
|
|
+ const showCallbackButton = Boolean(!isSenderUserNotification && msg.actionUrl)
|
|
|
+
|
|
|
+ const handleRemind = async (e: React.MouseEvent) => {
|
|
|
+ e.stopPropagation()
|
|
|
+ const rid = msg.receiver_id ?? activeContactId
|
|
|
+ if (!rid || remindLoading) return
|
|
|
+ setRemindLoading(true)
|
|
|
+ try {
|
|
|
+ const response = await api.sendUserNotificationMessage(token, {
|
|
|
+ receiverId: rid,
|
|
|
+ title: msg.title || '通知',
|
|
|
+ content: msg.content || '',
|
|
|
+ actionUrl: msg.actionUrl,
|
|
|
+ actionText: msg.actionText,
|
|
|
+ appId: msg.app_id
|
|
|
+ })
|
|
|
+ const newMsg = formatApiMessageToMessage(response, currentUserId)
|
|
|
+ if (activeContactId == null) return
|
|
|
+ setMessages(prev => {
|
|
|
+ const list = prev[activeContactId] || []
|
|
|
+ if (list.some(m => m.id === newMsg.id)) return prev
|
|
|
+ return {
|
|
|
+ ...prev,
|
|
|
+ [activeContactId]: [...list, newMsg]
|
|
|
+ }
|
|
|
+ })
|
|
|
+ } catch (err) {
|
|
|
+ logger.error('MessageBubble: remind failed', err)
|
|
|
+ showToast(err instanceof Error ? err.message : '发送失败')
|
|
|
+ } finally {
|
|
|
+ setRemindLoading(false)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
const handleNotificationAction = async (e: React.MouseEvent) => {
|
|
|
if (!msg.actionUrl) return
|
|
|
e.stopPropagation()
|
|
|
@@ -101,33 +144,62 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
|
|
|
>
|
|
|
{msg.content}
|
|
|
</div>
|
|
|
- {msg.actionUrl && (
|
|
|
+ {(showRemindButton || showCallbackButton) && (
|
|
|
<div style={{ padding: '0 12px 12px', backgroundColor: '#ffffff' }}>
|
|
|
- <button
|
|
|
- type="button"
|
|
|
- onClick={(e) => handleNotificationAction(e)}
|
|
|
- disabled={actionLoading}
|
|
|
- style={{
|
|
|
- padding: '5px 16px',
|
|
|
- backgroundColor: 'transparent',
|
|
|
- color: actionLoading ? '#999' : '#1890ff',
|
|
|
- border: `1px solid ${actionLoading ? '#d9d9d9' : '#1890ff'}`,
|
|
|
- borderRadius: '4px',
|
|
|
- cursor: actionLoading ? 'not-allowed' : 'pointer',
|
|
|
- fontSize: '13px',
|
|
|
- fontWeight: 500,
|
|
|
- transition: 'color 0.2s, border-color 0.2s, background-color 0.2s'
|
|
|
- }}
|
|
|
- onMouseEnter={(e) => {
|
|
|
- if (actionLoading) return
|
|
|
- e.currentTarget.style.backgroundColor = 'rgba(24, 144, 255, 0.06)'
|
|
|
- }}
|
|
|
- onMouseLeave={(e) => {
|
|
|
- e.currentTarget.style.backgroundColor = 'transparent'
|
|
|
- }}
|
|
|
- >
|
|
|
- {actionLoading ? '跳转中...' : (msg.actionText || '立即处理')}
|
|
|
- </button>
|
|
|
+ {showRemindButton && (
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ onClick={(e) => handleRemind(e)}
|
|
|
+ disabled={remindLoading}
|
|
|
+ style={{
|
|
|
+ padding: '5px 16px',
|
|
|
+ backgroundColor: 'transparent',
|
|
|
+ color: remindLoading ? '#999' : '#1890ff',
|
|
|
+ border: `1px solid ${remindLoading ? '#d9d9d9' : '#1890ff'}`,
|
|
|
+ borderRadius: '4px',
|
|
|
+ cursor: remindLoading ? 'not-allowed' : 'pointer',
|
|
|
+ fontSize: '13px',
|
|
|
+ fontWeight: 500,
|
|
|
+ transition: 'color 0.2s, border-color 0.2s, background-color 0.2s'
|
|
|
+ }}
|
|
|
+ onMouseEnter={(e) => {
|
|
|
+ if (remindLoading) return
|
|
|
+ e.currentTarget.style.backgroundColor = 'rgba(24, 144, 255, 0.06)'
|
|
|
+ }}
|
|
|
+ onMouseLeave={(e) => {
|
|
|
+ e.currentTarget.style.backgroundColor = 'transparent'
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {remindLoading ? '发送中...' : '再次提醒'}
|
|
|
+ </button>
|
|
|
+ )}
|
|
|
+ {showCallbackButton && (
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ onClick={(e) => handleNotificationAction(e)}
|
|
|
+ disabled={actionLoading}
|
|
|
+ style={{
|
|
|
+ padding: '5px 16px',
|
|
|
+ backgroundColor: 'transparent',
|
|
|
+ color: actionLoading ? '#999' : '#1890ff',
|
|
|
+ border: `1px solid ${actionLoading ? '#d9d9d9' : '#1890ff'}`,
|
|
|
+ borderRadius: '4px',
|
|
|
+ cursor: actionLoading ? 'not-allowed' : 'pointer',
|
|
|
+ fontSize: '13px',
|
|
|
+ fontWeight: 500,
|
|
|
+ transition: 'color 0.2s, border-color 0.2s, background-color 0.2s'
|
|
|
+ }}
|
|
|
+ onMouseEnter={(e) => {
|
|
|
+ if (actionLoading) return
|
|
|
+ e.currentTarget.style.backgroundColor = 'rgba(24, 144, 255, 0.06)'
|
|
|
+ }}
|
|
|
+ onMouseLeave={(e) => {
|
|
|
+ e.currentTarget.style.backgroundColor = 'transparent'
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {actionLoading ? '跳转中...' : (msg.actionText || '立即处理')}
|
|
|
+ </button>
|
|
|
+ )}
|
|
|
</div>
|
|
|
)}
|
|
|
</div>
|