feat: 品牌裁剪弹窗/Markdown 行内短代码与详情视频块等

新增:
- BrandCropModal 品牌图裁剪弹窗 + cropImage 工具
- Markdown 行内短代码(inlineShortcodes + remark 插件)
- MdDetails 折叠块、MdVideo 视频块组件
- directiveBlocks/inlineShortcodes 测试

其它:
- 聊天后端(handler/service chat)调整
- Markdown 渲染链路、编辑器、全局样式与品牌相关面板更新
This commit is contained in:
2026-10-02 01:40:06 +08:00
parent 3e98052c16
commit 99c0f0319d
23 changed files with 2012 additions and 119 deletions

View File

@@ -131,7 +131,7 @@ func (s *ChatService) Membership(roomID, userID uint) (*model.ChatRoomMember, er
return s.membership(s.db, roomID, userID)
}
// unreadCounts 批量计算访问者在给定群集合中的未读数(消息 ID 大于已读水位、且排除本人发送)
// unreadCounts 批量计算访问者在给定群集合中的未读数(消息 ID 大于已读水位、且排除本人发送与系统提示)
func (s *ChatService) unreadCounts(userID uint, roomIDs []uint) map[uint]int64 {
out := make(map[uint]int64)
if userID == 0 || len(roomIDs) == 0 {
@@ -141,7 +141,7 @@ func (s *ChatService) unreadCounts(userID uint, roomIDs []uint) map[uint]int64 {
s.db.Table("chat_messages AS m").
Select("m.room_id AS room_id, count(*) AS cnt").
Joins("JOIN chat_room_members AS rm ON rm.room_id = m.room_id AND rm.user_id = ?", userID).
Where("m.room_id IN ? AND m.id > rm.last_read_message_id AND m.sender_id <> ? AND m.deleted_at IS NULL AND m.recalled_at IS NULL", roomIDs, userID).
Where("m.room_id IN ? AND m.id > rm.last_read_message_id AND m.sender_id <> ? AND m.deleted_at IS NULL AND m.recalled_at IS NULL AND m.is_system = ?", roomIDs, userID, false).
Group("m.room_id").
Scan(&rows)
for _, r := range rows {
@@ -767,8 +767,10 @@ func (s *ChatService) Kick(operatorID, roomID, targetID uint, oversee bool) erro
return s.removeMember(roomID, targetID)
}
// SendSystemMessage 向房间落库一条系统提示消息(sender_id=0,is_system=true),
// 并推进房间 last_message_id 与 updated_at。失败返回 nil + error。
// SendSystemMessage 向房间落库一条系统提示消息(sender_id=0,is_system=true)。
// 只推进房间 updated_at,不推进 last_message_id:系统提示不作为会话预览,
// 也不参与排序顶置,避免禁言/解禁等管理动作在会话列表伪装成「新消息」。
// 失败返回 nil + error。
func (s *ChatService) SendSystemMessage(roomID uint, content string) (*model.ChatMessage, error) {
msg := &model.ChatMessage{
RoomID: roomID,
@@ -782,10 +784,7 @@ func (s *ChatService) SendSystemMessage(roomID uint, content string) (*model.Cha
return err
}
return tx.Model(&model.ChatRoom{}).Where("id = ?", roomID).
Updates(map[string]interface{}{
"last_message_id": msg.ID,
"updated_at": time.Now(),
}).Error
Update("updated_at", time.Now()).Error
})
if err != nil {
return nil, err
@@ -1020,7 +1019,7 @@ func (s *ChatService) SendMessage(senderID, roomID uint, content string, replyTo
if err != nil {
return nil, nil, err
}
if err := s.db.Preload("Sender").First(msg, msg.ID).Error; err != nil {
if err := s.db.Preload("Sender").Preload("Sender.Badges.Badge").First(msg, msg.ID).Error; err != nil {
return nil, nil, err
}
for _, uid := range mentionIDs {
@@ -1109,7 +1108,8 @@ func (s *ChatService) ListMessages(userID, roomID, beforeID uint, size int, over
q = q.Where("id < ?", beforeID)
}
var msgs []model.ChatMessage
if err := q.Order("id DESC").Limit(size + 1).Preload("Sender").Preload("Recaller").Find(&msgs).Error; err != nil {
// Sender.Badges.Badge 嵌套预加载:消息气泡需展示发送者等级与徽章(与成员列表一致)
if err := q.Order("id DESC").Limit(size + 1).Preload("Sender").Preload("Sender.Badges.Badge").Preload("Recaller").Find(&msgs).Error; err != nil {
return nil, false, err
}
hasMore := len(msgs) > size
@@ -1138,7 +1138,7 @@ func (s *ChatService) RecallMessage(operatorID, roomID, messageID uint, oversee
return nil, false, err
}
var msg model.ChatMessage
if err := s.db.Where("id = ? AND room_id = ?", messageID, roomID).First(&msg).Error; err != nil {
if err := s.db.Where("id = ? AND room_id = ?", messageID, roomID).Preload("Sender").First(&msg).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, false, ErrChatMsgNotFound
}
@@ -1147,6 +1147,10 @@ func (s *ChatService) RecallMessage(operatorID, roomID, messageID uint, oversee
if msg.RecalledAt != nil {
return &msg, false, nil // 幂等
}
// 站长的消息禁止群主/群管/站点消息管撤回(仅全站监管可撤回)
if msg.Sender.Role == model.RoleOwner && !oversee {
return nil, false, ErrChatRecallDenied
}
if msg.SenderID == operatorID {
if !oversee {
if _, err := s.membership(s.db, roomID, operatorID); err != nil {
@@ -1165,7 +1169,17 @@ func (s *ChatService) RecallMessage(operatorID, roomID, messageID uint, oversee
}
msg.RecalledAt = &now
msg.RecalledBy = operatorID
_ = s.db.Preload("Sender").Preload("Recaller").First(&msg, msg.ID)
// 撤回的是房间当前预览消息时,回退 last_message_id 到最近一条未撤回的非系统消息:
// 撤回不在会话列表留下「已撤回」预览,也不让房间因此停留在列表前排,避免被当成新消息提醒。
var prevID uint
_ = s.db.Model(&model.ChatMessage{}).
Where("room_id = ? AND deleted_at IS NULL AND recalled_at IS NULL AND is_system = ?", roomID, false).
Order("id DESC").Limit(1).
Select("id").Row().Scan(&prevID)
_ = s.db.Model(&model.ChatRoom{}).
Where("id = ? AND last_message_id = ?", roomID, msg.ID).
Update("last_message_id", prevID).Error
_ = s.db.Preload("Sender").Preload("Sender.Badges.Badge").Preload("Recaller").First(&msg, msg.ID)
return &msg, true, nil
}
@@ -1345,13 +1359,13 @@ func (s *ChatService) MarkRead(userID, roomID, messageID uint) error {
Update("last_read_message_id", messageID).Error
}
// UnreadSummary 当前用户所有群的未读明细与总数
// UnreadSummary 当前用户所有群的未读明细与总数(系统提示消息不计入)
func (s *ChatService) UnreadSummary(userID uint) (int64, []UnreadRoom, error) {
var rows []UnreadRoom
err := s.db.Table("chat_messages AS m").
Select("m.room_id AS room_id, count(*) AS cnt").
Joins("JOIN chat_room_members AS rm ON rm.room_id = m.room_id AND rm.user_id = ?", userID).
Where("m.id > rm.last_read_message_id AND m.sender_id <> ? AND m.deleted_at IS NULL AND m.recalled_at IS NULL", userID).
Where("m.id > rm.last_read_message_id AND m.sender_id <> ? AND m.deleted_at IS NULL AND m.recalled_at IS NULL AND m.is_system = ?", userID, false).
Group("m.room_id").
Scan(&rows).Error
if err != nil {