新增消息已读状态
复用 chat_participants.last_read_at 推导已读,无需新表/迁移。 - 后端:MessageDTO 增加 is_read,toMessageDTOs 按「全部已读」语义计算 (所有其他参与者均已读才算已读,1对1 即对方已读);MarkRead 成功后 推送 conversation_read 事件(含 reader 与 read_at),NotifyConversation 与 NotifyAllAdmins 双通道通知,使发送方/客服实时刷新已读 - 前端:ChatMessage 增加 is_read,ChatView/AdminChatsView/MobileChatView 在自己的消息下渲染「已读/未读」;收到 conversation_read 且本端仍有 未获回执的自己消息时才重载,避免无谓请求 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
cc1c0d33de
commit
e75a39b03a
@@ -71,6 +71,15 @@ function handleSSEEvent(event: ChatEvent) {
|
||||
if (event.type === 'conversation_updated') {
|
||||
loadConversations(false)
|
||||
}
|
||||
if (event.type === 'conversation_read') {
|
||||
// 对方已读:仅当本端仍有未获回执的自己消息时重载,刷新「已读」状态
|
||||
if (active.value && event.conversation_id === active.value.id) {
|
||||
const isSelfReader = event.reader_type === 'admin' && event.reader_id === currentAdminId
|
||||
if (!isSelfReader && messages.value.some(m => m.is_self && !m.is_read)) {
|
||||
loadMessages(active.value.id, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (event.type === 'new_message') {
|
||||
const msg = event.message
|
||||
if (!msg) return
|
||||
@@ -91,6 +100,7 @@ function handleSSEEvent(event: ChatEvent) {
|
||||
sender_name: msg.sender_name,
|
||||
sender_avatar: '',
|
||||
is_self: isSelf,
|
||||
is_read: false,
|
||||
content_type: msg.content_type as ChatMessage['content_type'],
|
||||
content: msg.content,
|
||||
attachment_urls: msg.attachment_urls || [],
|
||||
@@ -441,6 +451,9 @@ function getSupportName(item: ChatConversation) {
|
||||
admin
|
||||
/>
|
||||
</div>
|
||||
<small v-if="item.is_self" class="read-status" :class="{ read: item.is_read }">
|
||||
{{ item.is_read ? '已读' : '未读' }}
|
||||
</small>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -729,6 +742,16 @@ function getSupportName(item: ChatConversation) {
|
||||
color: #8a94a6;
|
||||
}
|
||||
|
||||
.read-status {
|
||||
margin-top: 4px;
|
||||
margin-bottom: 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.read-status.read {
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
.message-row p {
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface ChatMessage {
|
||||
sender_name: string
|
||||
sender_avatar: string
|
||||
is_self: boolean
|
||||
is_read: boolean
|
||||
content_type: 'text' | 'system'
|
||||
content: string
|
||||
attachment_urls: string[]
|
||||
|
||||
@@ -16,9 +16,12 @@ export interface SSEMessage {
|
||||
}
|
||||
|
||||
export interface ChatEvent {
|
||||
type: 'new_message' | 'conversation_updated'
|
||||
type: 'new_message' | 'conversation_updated' | 'conversation_read'
|
||||
conversation_id: number
|
||||
message?: SSEMessage
|
||||
reader_type?: string
|
||||
reader_id?: number
|
||||
read_at?: string
|
||||
}
|
||||
|
||||
type EventHandler = (event: ChatEvent) => void
|
||||
@@ -69,6 +72,15 @@ export function useChatSSE(scope: AuthScope, endpoint: string) {
|
||||
}
|
||||
})
|
||||
|
||||
source.addEventListener('conversation_read', e => {
|
||||
try {
|
||||
const data = JSON.parse((e as MessageEvent).data) as ChatEvent
|
||||
handlers.forEach(h => h(data))
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})
|
||||
|
||||
source.onerror = async () => {
|
||||
connected.value = false
|
||||
source?.close()
|
||||
|
||||
@@ -58,6 +58,7 @@ function handleSSEEvent(event: ChatEvent) {
|
||||
sender_name: msg.sender_name,
|
||||
sender_avatar: '',
|
||||
is_self: isSelf,
|
||||
is_read: false,
|
||||
content_type: msg.content_type as ChatMessage['content_type'],
|
||||
content: msg.content,
|
||||
attachment_urls: msg.attachment_urls || [],
|
||||
@@ -76,6 +77,13 @@ function handleSSEEvent(event: ChatEvent) {
|
||||
if (event.type === 'conversation_updated' && event.conversation_id === conversationID.value) {
|
||||
loadConversation()
|
||||
}
|
||||
if (event.type === 'conversation_read' && event.conversation_id === conversationID.value) {
|
||||
// 对方已读:仅当本端仍有未获回执的自己消息时重载,刷新「已读」状态
|
||||
const isSelfReader = event.reader_type === 'user' && event.reader_id === currentUserId
|
||||
if (!isSelfReader && messages.value.some(m => m.is_self && !m.is_read)) {
|
||||
loadMessages(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { onEvent } = useChatSSE('user', '/api/chats/events')
|
||||
@@ -305,6 +313,9 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
<ChatAttachmentImage v-for="url in item.attachment_urls" :key="url" :source="url" />
|
||||
</div>
|
||||
<span class="message-time">{{ formatDateMinute(item.created_at) }}</span>
|
||||
<span v-if="item.is_self" class="read-status" :class="{ read: item.is_read }">
|
||||
{{ item.is_read ? '已读' : '未读' }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -538,6 +549,16 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.read-status {
|
||||
margin-top: 2px;
|
||||
color: #a1a8b4;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.read-status.read {
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
.system-message {
|
||||
max-width: 80%;
|
||||
padding: 6px 12px;
|
||||
|
||||
@@ -62,6 +62,7 @@ function handleSSEEvent(event: ChatEvent) {
|
||||
sender_name: msg.sender_name,
|
||||
sender_avatar: '',
|
||||
is_self: isSelf,
|
||||
is_read: false,
|
||||
content_type: msg.content_type as ChatMessage['content_type'],
|
||||
content: msg.content,
|
||||
attachment_urls: msg.attachment_urls || [],
|
||||
@@ -80,6 +81,13 @@ function handleSSEEvent(event: ChatEvent) {
|
||||
if (event.type === 'conversation_updated' && event.conversation_id === conversationID.value) {
|
||||
loadConversation()
|
||||
}
|
||||
if (event.type === 'conversation_read' && event.conversation_id === conversationID.value) {
|
||||
// 对方已读:仅当本端仍有未获回执的自己消息时重载,刷新「已读」状态
|
||||
const isSelfReader = event.reader_type === 'user' && event.reader_id === currentUserId
|
||||
if (!isSelfReader && messages.value.some(m => m.is_self && !m.is_read)) {
|
||||
loadMessages(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { onEvent } = useChatSSE('user', '/api/chats/events')
|
||||
@@ -284,6 +292,9 @@ function senderLabel(message: ChatMessage) {
|
||||
<ChatAttachmentImage v-for="url in item.attachment_urls" :key="url" :source="url" />
|
||||
</div>
|
||||
<span class="message-time">{{ formatDateMinute(item.created_at) }}</span>
|
||||
<span v-if="item.is_self" class="read-status" :class="{ read: item.is_read }">
|
||||
{{ item.is_read ? '已读' : '未读' }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -499,6 +510,16 @@ function senderLabel(message: ChatMessage) {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.read-status {
|
||||
margin-top: 2px;
|
||||
color: #a1a8b4;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.read-status.read {
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
.system-message {
|
||||
max-width: 82%;
|
||||
padding: 5px 9px;
|
||||
|
||||
Reference in New Issue
Block a user