新增消息已读状态
复用 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
@@ -45,6 +45,7 @@ type MessageDTO struct {
|
|||||||
SenderName string `json:"sender_name"`
|
SenderName string `json:"sender_name"`
|
||||||
SenderAvatar string `json:"sender_avatar"`
|
SenderAvatar string `json:"sender_avatar"`
|
||||||
IsSelf bool `json:"is_self"`
|
IsSelf bool `json:"is_self"`
|
||||||
|
IsRead bool `json:"is_read"`
|
||||||
ContentType string `json:"content_type"`
|
ContentType string `json:"content_type"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
AttachmentURLS []string `json:"attachment_urls"`
|
AttachmentURLS []string `json:"attachment_urls"`
|
||||||
|
|||||||
@@ -160,7 +160,9 @@ func (r *Repository) SendMessage(ctx context.Context, principal Principal, conve
|
|||||||
return &items[0], nil
|
return &items[0], nil
|
||||||
}
|
}
|
||||||
func (r *Repository) MarkRead(ctx context.Context, principal Principal, conversationID uint64) error {
|
func (r *Repository) MarkRead(ctx context.Context, principal Principal, conversationID uint64) error {
|
||||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
now := time.Now()
|
||||||
|
updated := false
|
||||||
|
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
// 管理员可以不是 participant,直接返回成功
|
// 管理员可以不是 participant,直接返回成功
|
||||||
if principal.Type == "admin" {
|
if principal.Type == "admin" {
|
||||||
// 尝试查找 participant 记录,如果有就更新
|
// 尝试查找 participant 记录,如果有就更新
|
||||||
@@ -169,7 +171,7 @@ func (r *Repository) MarkRead(ctx context.Context, principal Principal, conversa
|
|||||||
conversationID, principal.Type, principal.ID).First(&participant).Error
|
conversationID, principal.Type, principal.ID).First(&participant).Error
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// 有 participant 记录,更新已读时间
|
// 有 participant 记录,更新已读时间
|
||||||
now := time.Now()
|
updated = true
|
||||||
return tx.Model(&participant).Update("last_read_at", now).Error
|
return tx.Model(&participant).Update("last_read_at", now).Error
|
||||||
} else if errors.Is(err, gorm.ErrRecordNotFound) {
|
} else if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
// 没有 participant 记录,直接返回成功(管理员无需记录已读)
|
// 没有 participant 记录,直接返回成功(管理员无需记录已读)
|
||||||
@@ -183,7 +185,24 @@ func (r *Repository) MarkRead(ctx context.Context, principal Principal, conversa
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
now := time.Now()
|
updated = true
|
||||||
return tx.Model(participant).Update("last_read_at", now).Error
|
return tx.Model(participant).Update("last_read_at", now).Error
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 已读时间发生变化时,通知会话内其他参与者刷新「已读」状态。
|
||||||
|
if updated && r.hub != nil {
|
||||||
|
event := &chathub.ChatEvent{
|
||||||
|
Type: "conversation_read",
|
||||||
|
ConversationID: conversationID,
|
||||||
|
ReaderType: principal.Type,
|
||||||
|
ReaderID: principal.ID,
|
||||||
|
ReadAt: now.Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
r.hub.NotifyConversation(conversationID, event)
|
||||||
|
// 同时广播给所有在线客服,覆盖未分配会话中非参与者客服的「已读」刷新。
|
||||||
|
r.hub.NotifyAllAdmins(event)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ func (r *Repository) participants(ctx context.Context, conversationID uint64) ([
|
|||||||
func (r *Repository) toMessageDTOs(ctx context.Context, principal Principal, rows []model.ChatMessage) ([]MessageDTO, error) {
|
func (r *Repository) toMessageDTOs(ctx context.Context, principal Principal, rows []model.ChatMessage) ([]MessageDTO, error) {
|
||||||
userIDs := make([]uint64, 0)
|
userIDs := make([]uint64, 0)
|
||||||
adminIDs := make([]uint64, 0)
|
adminIDs := make([]uint64, 0)
|
||||||
|
conversationIDSet := make(map[uint64]struct{})
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
if row.SenderType == "user" && row.SenderID > 0 {
|
if row.SenderType == "user" && row.SenderID > 0 {
|
||||||
userIDs = append(userIDs, row.SenderID)
|
userIDs = append(userIDs, row.SenderID)
|
||||||
@@ -112,6 +113,7 @@ func (r *Repository) toMessageDTOs(ctx context.Context, principal Principal, row
|
|||||||
if row.SenderType == "admin" && row.SenderID > 0 {
|
if row.SenderType == "admin" && row.SenderID > 0 {
|
||||||
adminIDs = append(adminIDs, row.SenderID)
|
adminIDs = append(adminIDs, row.SenderID)
|
||||||
}
|
}
|
||||||
|
conversationIDSet[row.ConversationID] = struct{}{}
|
||||||
}
|
}
|
||||||
userNames, userAvatars, err := r.userNames(ctx, userIDs)
|
userNames, userAvatars, err := r.userNames(ctx, userIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -121,6 +123,11 @@ func (r *Repository) toMessageDTOs(ctx context.Context, principal Principal, row
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// 加载相关会话的参与者已读时间,用于推导「自己发出的消息」是否已被对方读取。
|
||||||
|
readParticipants, err := r.conversationReadParticipants(ctx, conversationIDSet)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
items := make([]MessageDTO, 0, len(rows))
|
items := make([]MessageDTO, 0, len(rows))
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
name := "系统"
|
name := "系统"
|
||||||
@@ -136,6 +143,7 @@ func (r *Repository) toMessageDTOs(ctx context.Context, principal Principal, row
|
|||||||
name = name + " (管理员)"
|
name = name + " (管理员)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
isSelf := row.SenderType == principal.Type && row.SenderID == principal.ID
|
||||||
items = append(items, MessageDTO{
|
items = append(items, MessageDTO{
|
||||||
ID: row.ID,
|
ID: row.ID,
|
||||||
ConversationID: row.ConversationID,
|
ConversationID: row.ConversationID,
|
||||||
@@ -144,7 +152,9 @@ func (r *Repository) toMessageDTOs(ctx context.Context, principal Principal, row
|
|||||||
SenderRole: row.SenderRole,
|
SenderRole: row.SenderRole,
|
||||||
SenderName: fallbackName(row.SenderType, row.SenderID, name),
|
SenderName: fallbackName(row.SenderType, row.SenderID, name),
|
||||||
SenderAvatar: avatar,
|
SenderAvatar: avatar,
|
||||||
IsSelf: row.SenderType == principal.Type && row.SenderID == principal.ID,
|
IsSelf: isSelf,
|
||||||
|
// 仅对自己发出的消息计算已读:是否已被所有其他参与者读取。
|
||||||
|
IsRead: isSelf && messageReadByOthers(readParticipants[row.ConversationID], row),
|
||||||
ContentType: row.ContentType,
|
ContentType: row.ContentType,
|
||||||
Content: row.Content,
|
Content: row.Content,
|
||||||
AttachmentURLS: decodeStringList(row.AttachmentURLS),
|
AttachmentURLS: decodeStringList(row.AttachmentURLS),
|
||||||
@@ -153,6 +163,62 @@ func (r *Repository) toMessageDTOs(ctx context.Context, principal Principal, row
|
|||||||
}
|
}
|
||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type readParticipant struct {
|
||||||
|
ParticipantType string
|
||||||
|
ParticipantID uint64
|
||||||
|
LastReadAt *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// conversationReadParticipants 批量加载若干会话的参与者已读时间。
|
||||||
|
func (r *Repository) conversationReadParticipants(ctx context.Context, ids map[uint64]struct{}) (map[uint64][]readParticipant, error) {
|
||||||
|
result := make(map[uint64][]readParticipant)
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
idList := make([]uint64, 0, len(ids))
|
||||||
|
for id := range ids {
|
||||||
|
idList = append(idList, id)
|
||||||
|
}
|
||||||
|
type row struct {
|
||||||
|
ConversationID uint64
|
||||||
|
ParticipantType string
|
||||||
|
ParticipantID uint64
|
||||||
|
LastReadAt *time.Time
|
||||||
|
}
|
||||||
|
var rows []row
|
||||||
|
if err := r.db.WithContext(ctx).Table("chat_participants").
|
||||||
|
Select("conversation_id, participant_type, participant_id, last_read_at").
|
||||||
|
Where("conversation_id IN ?", idList).
|
||||||
|
Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, rw := range rows {
|
||||||
|
result[rw.ConversationID] = append(result[rw.ConversationID], readParticipant{
|
||||||
|
ParticipantType: rw.ParticipantType,
|
||||||
|
ParticipantID: rw.ParticipantID,
|
||||||
|
LastReadAt: rw.LastReadAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// messageReadByOthers 采用「全部已读」语义:所有其他参与者的 last_read_at 均不早于该消息时间。
|
||||||
|
// 1对1 即对方已读;群聊需所有其他成员都已读。无其他参与者时返回 false。
|
||||||
|
func messageReadByOthers(participants []readParticipant, msg model.ChatMessage) bool {
|
||||||
|
others := 0
|
||||||
|
for _, p := range participants {
|
||||||
|
// 排除发送者本人
|
||||||
|
if p.ParticipantType == msg.SenderType && p.ParticipantID == msg.SenderID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
others++
|
||||||
|
if p.LastReadAt == nil || p.LastReadAt.Before(msg.CreatedAt) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return others > 0
|
||||||
|
}
|
||||||
func (r *Repository) participantNames(ctx context.Context, rows []model.ChatParticipant) (map[uint64]string, map[uint64]string, map[uint64]string, error) {
|
func (r *Repository) participantNames(ctx context.Context, rows []model.ChatParticipant) (map[uint64]string, map[uint64]string, map[uint64]string, error) {
|
||||||
userIDs := make([]uint64, 0)
|
userIDs := make([]uint64, 0)
|
||||||
adminIDs := make([]uint64, 0)
|
adminIDs := make([]uint64, 0)
|
||||||
|
|||||||
@@ -9,9 +9,13 @@ import (
|
|||||||
|
|
||||||
// ChatEvent 是通过 SSE 推送给客户端的事件。
|
// ChatEvent 是通过 SSE 推送给客户端的事件。
|
||||||
type ChatEvent struct {
|
type ChatEvent struct {
|
||||||
Type string `json:"type"` // "new_message" | "conversation_updated"
|
Type string `json:"type"` // "new_message" | "conversation_updated" | "conversation_read"
|
||||||
ConversationID uint64 `json:"conversation_id"`
|
ConversationID uint64 `json:"conversation_id"`
|
||||||
Message *MessageData `json:"message,omitempty"`
|
Message *MessageData `json:"message,omitempty"`
|
||||||
|
// 以下字段仅在 conversation_read 事件中使用,标识谁在何时读取了会话。
|
||||||
|
ReaderType string `json:"reader_type,omitempty"`
|
||||||
|
ReaderID uint64 `json:"reader_id,omitempty"`
|
||||||
|
ReadAt string `json:"read_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MessageData 是事件中携带的消息数据,与 chat.MessageDTO 对齐。
|
// MessageData 是事件中携带的消息数据,与 chat.MessageDTO 对齐。
|
||||||
|
|||||||
@@ -71,6 +71,15 @@ function handleSSEEvent(event: ChatEvent) {
|
|||||||
if (event.type === 'conversation_updated') {
|
if (event.type === 'conversation_updated') {
|
||||||
loadConversations(false)
|
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') {
|
if (event.type === 'new_message') {
|
||||||
const msg = event.message
|
const msg = event.message
|
||||||
if (!msg) return
|
if (!msg) return
|
||||||
@@ -91,6 +100,7 @@ function handleSSEEvent(event: ChatEvent) {
|
|||||||
sender_name: msg.sender_name,
|
sender_name: msg.sender_name,
|
||||||
sender_avatar: '',
|
sender_avatar: '',
|
||||||
is_self: isSelf,
|
is_self: isSelf,
|
||||||
|
is_read: false,
|
||||||
content_type: msg.content_type as ChatMessage['content_type'],
|
content_type: msg.content_type as ChatMessage['content_type'],
|
||||||
content: msg.content,
|
content: msg.content,
|
||||||
attachment_urls: msg.attachment_urls || [],
|
attachment_urls: msg.attachment_urls || [],
|
||||||
@@ -441,6 +451,9 @@ function getSupportName(item: ChatConversation) {
|
|||||||
admin
|
admin
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<small v-if="item.is_self" class="read-status" :class="{ read: item.is_read }">
|
||||||
|
{{ item.is_read ? '已读' : '未读' }}
|
||||||
|
</small>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -729,6 +742,16 @@ function getSupportName(item: ChatConversation) {
|
|||||||
color: #8a94a6;
|
color: #8a94a6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.read-status {
|
||||||
|
margin-top: 4px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-status.read {
|
||||||
|
color: #67c23a;
|
||||||
|
}
|
||||||
|
|
||||||
.message-row p {
|
.message-row p {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export interface ChatMessage {
|
|||||||
sender_name: string
|
sender_name: string
|
||||||
sender_avatar: string
|
sender_avatar: string
|
||||||
is_self: boolean
|
is_self: boolean
|
||||||
|
is_read: boolean
|
||||||
content_type: 'text' | 'system'
|
content_type: 'text' | 'system'
|
||||||
content: string
|
content: string
|
||||||
attachment_urls: string[]
|
attachment_urls: string[]
|
||||||
|
|||||||
@@ -16,9 +16,12 @@ export interface SSEMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatEvent {
|
export interface ChatEvent {
|
||||||
type: 'new_message' | 'conversation_updated'
|
type: 'new_message' | 'conversation_updated' | 'conversation_read'
|
||||||
conversation_id: number
|
conversation_id: number
|
||||||
message?: SSEMessage
|
message?: SSEMessage
|
||||||
|
reader_type?: string
|
||||||
|
reader_id?: number
|
||||||
|
read_at?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type EventHandler = (event: ChatEvent) => void
|
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 () => {
|
source.onerror = async () => {
|
||||||
connected.value = false
|
connected.value = false
|
||||||
source?.close()
|
source?.close()
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ function handleSSEEvent(event: ChatEvent) {
|
|||||||
sender_name: msg.sender_name,
|
sender_name: msg.sender_name,
|
||||||
sender_avatar: '',
|
sender_avatar: '',
|
||||||
is_self: isSelf,
|
is_self: isSelf,
|
||||||
|
is_read: false,
|
||||||
content_type: msg.content_type as ChatMessage['content_type'],
|
content_type: msg.content_type as ChatMessage['content_type'],
|
||||||
content: msg.content,
|
content: msg.content,
|
||||||
attachment_urls: msg.attachment_urls || [],
|
attachment_urls: msg.attachment_urls || [],
|
||||||
@@ -76,6 +77,13 @@ function handleSSEEvent(event: ChatEvent) {
|
|||||||
if (event.type === 'conversation_updated' && event.conversation_id === conversationID.value) {
|
if (event.type === 'conversation_updated' && event.conversation_id === conversationID.value) {
|
||||||
loadConversation()
|
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')
|
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" />
|
<ChatAttachmentImage v-for="url in item.attachment_urls" :key="url" :source="url" />
|
||||||
</div>
|
</div>
|
||||||
<span class="message-time">{{ formatDateMinute(item.created_at) }}</span>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -538,6 +549,16 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.read-status {
|
||||||
|
margin-top: 2px;
|
||||||
|
color: #a1a8b4;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-status.read {
|
||||||
|
color: #67c23a;
|
||||||
|
}
|
||||||
|
|
||||||
.system-message {
|
.system-message {
|
||||||
max-width: 80%;
|
max-width: 80%;
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ function handleSSEEvent(event: ChatEvent) {
|
|||||||
sender_name: msg.sender_name,
|
sender_name: msg.sender_name,
|
||||||
sender_avatar: '',
|
sender_avatar: '',
|
||||||
is_self: isSelf,
|
is_self: isSelf,
|
||||||
|
is_read: false,
|
||||||
content_type: msg.content_type as ChatMessage['content_type'],
|
content_type: msg.content_type as ChatMessage['content_type'],
|
||||||
content: msg.content,
|
content: msg.content,
|
||||||
attachment_urls: msg.attachment_urls || [],
|
attachment_urls: msg.attachment_urls || [],
|
||||||
@@ -80,6 +81,13 @@ function handleSSEEvent(event: ChatEvent) {
|
|||||||
if (event.type === 'conversation_updated' && event.conversation_id === conversationID.value) {
|
if (event.type === 'conversation_updated' && event.conversation_id === conversationID.value) {
|
||||||
loadConversation()
|
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')
|
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" />
|
<ChatAttachmentImage v-for="url in item.attachment_urls" :key="url" :source="url" />
|
||||||
</div>
|
</div>
|
||||||
<span class="message-time">{{ formatDateMinute(item.created_at) }}</span>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -499,6 +510,16 @@ function senderLabel(message: ChatMessage) {
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.read-status {
|
||||||
|
margin-top: 2px;
|
||||||
|
color: #a1a8b4;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-status.read {
|
||||||
|
color: #67c23a;
|
||||||
|
}
|
||||||
|
|
||||||
.system-message {
|
.system-message {
|
||||||
max-width: 82%;
|
max-width: 82%;
|
||||||
padding: 5px 9px;
|
padding: 5px 9px;
|
||||||
|
|||||||
Reference in New Issue
Block a user