From 0f44b024cb10d4d5a203538ff9632c7aefa9b3e7 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 29 May 2026 12:56:25 +0800 Subject: [PATCH] =?UTF-8?q?=E8=81=8A=E5=A4=A9=E5=A2=9E=E5=8A=A0=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/modules/chat/dto.go | 3 +- backend/internal/modules/chat/handler.go | 2 +- backend/internal/modules/chat/repository.go | 21 ++- backend/internal/modules/chat/service.go | 42 ++++- backend/internal/modules/file/service.go | 6 +- frontend/components.d.ts | 1 + frontend/src/api/chats.ts | 14 +- .../src/components/ChatAttachmentImage.vue | 90 ++++++++++ frontend/src/views/account/ChatView.vue | 140 +++++++++++++++- frontend/src/views/admin/AdminChatsView.vue | 145 ++++++++++++++++- frontend/src/views/mobile/MobileChatView.vue | 154 +++++++++++++++++- 11 files changed, 590 insertions(+), 28 deletions(-) create mode 100644 frontend/src/components/ChatAttachmentImage.vue diff --git a/backend/internal/modules/chat/dto.go b/backend/internal/modules/chat/dto.go index 60fc1d6..e7aa2a9 100644 --- a/backend/internal/modules/chat/dto.go +++ b/backend/internal/modules/chat/dto.go @@ -52,7 +52,8 @@ type MessageDTO struct { } type SendMessageRequest struct { - Content string `json:"content" binding:"required"` + Content string `json:"content"` + AttachmentURLS []string `json:"attachment_urls"` } type TransferRequest struct { diff --git a/backend/internal/modules/chat/handler.go b/backend/internal/modules/chat/handler.go index 229c50a..04c1057 100644 --- a/backend/internal/modules/chat/handler.go +++ b/backend/internal/modules/chat/handler.go @@ -340,7 +340,7 @@ func (h *Handler) send(c *gin.Context, principal Principal) { } var req SendMessageRequest if err := c.ShouldBindJSON(&req); err != nil { - response.BadRequest(c, "消息内容不能为空") + response.BadRequest(c, "消息格式不正确") return } message, err := h.service.SendMessage(principal, id, req) diff --git a/backend/internal/modules/chat/repository.go b/backend/internal/modules/chat/repository.go index 19afa0c..3d48f5a 100644 --- a/backend/internal/modules/chat/repository.go +++ b/backend/internal/modules/chat/repository.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "strconv" + "strings" "time" "hfb_sys/backend/internal/model" @@ -312,13 +313,13 @@ func (r *Repository) SendMessage(principal Principal, conversationID uint64, req SenderRole: participant.Role, ContentType: "text", Content: req.Content, - AttachmentURLS: emptyJSONList(), + AttachmentURLS: encodeStringList(req.AttachmentURLS), } if err := tx.Create(&message).Error; err != nil { return err } conversation.LastMessageID = &message.ID - conversation.LastMessagePreview = truncatePreview(message.Content) + conversation.LastMessagePreview = messagePreview(message.Content, req.AttachmentURLS) conversation.LastMessageAt = &message.CreatedAt if err := tx.Save(&conversation).Error; err != nil { return err @@ -708,6 +709,17 @@ func truncatePreview(content string) string { return string(runes[:80]) } +func messagePreview(content string, attachments []string) string { + content = strings.TrimSpace(content) + if content != "" { + return truncatePreview(content) + } + if len(attachments) > 0 { + return "[图片]" + } + return "" +} + // TransferConversation 转接会话给其他客服 func (r *Repository) TransferConversation(principal Principal, conversationID uint64, toAdminID uint64) error { return r.db.Transaction(func(tx *gorm.DB) error { @@ -908,6 +920,11 @@ func emptyJSONList() datatypes.JSON { return datatypes.JSON(raw) } +func encodeStringList(items []string) datatypes.JSON { + raw, _ := json.Marshal(items) + return datatypes.JSON(raw) +} + // UpdateRemark 更新会话备注 func (r *Repository) UpdateRemark(principal Principal, conversationID uint64, remark string) error { return r.db.Model(&model.ChatParticipant{}). diff --git a/backend/internal/modules/chat/service.go b/backend/internal/modules/chat/service.go index ccc01a2..1b51d5a 100644 --- a/backend/internal/modules/chat/service.go +++ b/backend/internal/modules/chat/service.go @@ -2,6 +2,7 @@ package chat import ( "errors" + "net/url" "strings" ) @@ -63,15 +64,54 @@ func (s *Service) SendMessage(principal Principal, conversationID uint64, req Se return nil, ErrDependencyUnavailable } req.Content = strings.TrimSpace(req.Content) - if conversationID == 0 || req.Content == "" { + attachments, ok := normalizeAttachmentURLS(req.AttachmentURLS) + req.AttachmentURLS = attachments + if conversationID == 0 || (req.Content == "" && len(req.AttachmentURLS) == 0) { return nil, ErrInvalidMessage } if len([]rune(req.Content)) > 1000 { return nil, ErrInvalidMessage } + if !ok { + return nil, ErrInvalidMessage + } return s.repo.SendMessage(principal, conversationID, req) } +func normalizeAttachmentURLS(items []string) ([]string, bool) { + if len(items) > 9 { + return nil, false + } + result := make([]string, 0, len(items)) + for _, item := range items { + value := strings.TrimSpace(item) + if value == "" { + continue + } + if len(value) > 500 || strings.Contains(value, "..") { + return nil, false + } + parsed, err := url.Parse(value) + if err != nil { + return nil, false + } + if parsed.Host != "" { + return nil, false + } + switch parsed.Path { + case "/api/files/object", "/api/admin/files/object", "/api/public/files/object": + default: + return nil, false + } + key := strings.TrimSpace(parsed.Query().Get("key")) + if key == "" || strings.Contains(key, "..") { + return nil, false + } + result = append(result, value) + } + return result, true +} + func (s *Service) MarkRead(principal Principal, conversationID uint64) error { if s.repo == nil { return ErrDependencyUnavailable diff --git a/backend/internal/modules/file/service.go b/backend/internal/modules/file/service.go index a388da1..1d0f386 100644 --- a/backend/internal/modules/file/service.go +++ b/backend/internal/modules/file/service.go @@ -41,10 +41,10 @@ func (s *Service) Upload(req uploadRequest) (*UploadDTO, error) { if contentType == "" { contentType = req.Header.Header.Get("Content-Type") } - if !allowedContentTypes[contentType] { + scene := normalizeScene(req.Scene) + if !allowedContentTypes[contentType] || (scene == "chat" && !strings.HasPrefix(contentType, "image/")) { return nil, ErrInvalidFile } - scene := normalizeScene(req.Scene) key, err := s.storage.Put(req.Context, scene, req.Header, req.Reader, contentType) if err != nil { return nil, err @@ -65,7 +65,7 @@ func (s *Service) Upload(req uploadRequest) (*UploadDTO, error) { func normalizeScene(scene string) string { scene = strings.TrimSpace(strings.ToLower(scene)) switch scene { - case "listing", "handoff", "dispute", "realname", "avatar", "home-banner": + case "listing", "handoff", "dispute", "realname", "avatar", "home-banner", "chat": return scene default: return "misc" diff --git a/frontend/components.d.ts b/frontend/components.d.ts index da3b850..964e87b 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -11,6 +11,7 @@ export {} /* prettier-ignore */ declare module 'vue' { export interface GlobalComponents { + ChatAttachmentImage: typeof import('./src/components/ChatAttachmentImage.vue')['default'] ElAlert: typeof import('element-plus/es')['ElAlert'] ElAvatar: typeof import('element-plus/es')['ElAvatar'] ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb'] diff --git a/frontend/src/api/chats.ts b/frontend/src/api/chats.ts index aea6b56..93baa2d 100644 --- a/frontend/src/api/chats.ts +++ b/frontend/src/api/chats.ts @@ -74,8 +74,11 @@ export async function fetchChatMessages(id: number, page = 1, pageSize = 100) { return data.data } -export async function sendChatMessage(id: number, content: string) { - const { data } = await apiClient.post>(`/chats/${id}/messages`, { content }) +export async function sendChatMessage(id: number, content: string, attachmentUrls: string[] = []) { + const { data } = await apiClient.post>(`/chats/${id}/messages`, { + content, + attachment_urls: attachmentUrls, + }) return data.data } @@ -103,8 +106,11 @@ export async function fetchAdminChatMessages(id: number, page = 1, pageSize = 10 return data.data } -export async function sendAdminChatMessage(id: number, content: string) { - const { data } = await apiClient.post>(`/admin/chats/${id}/messages`, { content }) +export async function sendAdminChatMessage(id: number, content: string, attachmentUrls: string[] = []) { + const { data } = await apiClient.post>(`/admin/chats/${id}/messages`, { + content, + attachment_urls: attachmentUrls, + }) return data.data } diff --git a/frontend/src/components/ChatAttachmentImage.vue b/frontend/src/components/ChatAttachmentImage.vue new file mode 100644 index 0000000..735f6b0 --- /dev/null +++ b/frontend/src/components/ChatAttachmentImage.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/frontend/src/views/account/ChatView.vue b/frontend/src/views/account/ChatView.vue index 59f3e43..1076210 100644 --- a/frontend/src/views/account/ChatView.vue +++ b/frontend/src/views/account/ChatView.vue @@ -2,7 +2,7 @@ import { computed, nextTick, onMounted, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import { ElMessage } from 'element-plus' -import { ArrowLeft, Loading } from '@element-plus/icons-vue' +import { ArrowLeft, Close, Loading, Picture } from '@element-plus/icons-vue' import { fetchChat, fetchChatMessages, @@ -11,6 +11,8 @@ import { type ChatConversation, type ChatMessage, } from '@/api/chats' +import { uploadFile } from '@/api/files' +import ChatAttachmentImage from '@/components/ChatAttachmentImage.vue' import { useChatSSE, type ChatEvent } from '@/composables/useChatSSE' import { formatDateMinute } from '@/utils/time' @@ -22,10 +24,14 @@ const conversation = ref(null) const messages = ref([]) const loading = ref(false) const sending = ref(false) +const uploading = ref(false) const content = ref('') +const attachments = ref([]) const listRef = ref(null) +const fileInputRef = ref(null) const conversationID = computed(() => Number(route.params.id || 0)) +const canSend = computed(() => content.value.trim() !== '' || attachments.value.length > 0) const memberText = computed(() => { const participants = conversation.value?.participants || [] if (participants.length === 0) return conversation.value?.type === 'general_support' ? '平台客服' : '订单群聊' @@ -107,12 +113,14 @@ async function loadMessages(scrollToBottom = true) { async function handleSend() { const text = content.value.trim() - if (!text || sending.value) return + const imageUrls = [...attachments.value] + if ((!text && imageUrls.length === 0) || sending.value || uploading.value) return sending.value = true try { - const sent = await sendChatMessage(conversationID.value, text) + const sent = await sendChatMessage(conversationID.value, text, imageUrls) appendMessage(sent) content.value = '' + attachments.value = [] await loadConversation() } catch { ElMessage.error('发送失败') @@ -121,6 +129,45 @@ async function handleSend() { } } +function pickImages() { + if (uploading.value || attachments.value.length >= 9) return + fileInputRef.value?.click() +} + +async function handleImageChange(event: Event) { + const input = event.target as HTMLInputElement + const files = Array.from(input.files || []) + input.value = '' + if (files.length === 0) return + const slots = 9 - attachments.value.length + if (slots <= 0) { + ElMessage.warning('每条消息最多发送 9 张图片') + return + } + uploading.value = true + try { + for (const file of files.slice(0, slots)) { + if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type) || file.size > 10 * 1024 * 1024) { + ElMessage.warning(`${file.name} 不符合图片规则`) + continue + } + const uploaded = await uploadFile(file, 'chat') + attachments.value.push(uploaded.url) + } + if (files.length > slots) { + ElMessage.warning('每条消息最多发送 9 张图片') + } + } catch { + ElMessage.error('图片上传失败') + } finally { + uploading.value = false + } +} + +function removeAttachment(index: number) { + attachments.value.splice(index, 1) +} + function appendMessage(message: ChatMessage) { if (messages.value.some(item => item.id === message.id)) return messages.value = [...messages.value, message] @@ -200,7 +247,14 @@ function handleKeydown(e: KeyboardEvent) {
{{ roleLabel(item.sender_role).slice(0, 1) }}
{{ senderLabel(item) }} -
{{ item.content }}
+
{{ item.content }}
+
+ +
{{ formatDateMinute(item.created_at) }}
@@ -209,6 +263,22 @@ function handleKeydown(e: KeyboardEvent) {
+
+
+ + +
+
+
Enter 发送,Shift+Enter 换行 - - 发送 - +
+ + 图片 + + + 发送 + +
@@ -376,6 +451,12 @@ function handleKeydown(e: KeyboardEvent) { color: #0f5132; } +.message-attachments { + display: grid; + gap: 6px; + margin-top: 6px; +} + .message-time { margin-top: 4px; color: #a1a8b4; @@ -400,6 +481,46 @@ function handleKeydown(e: KeyboardEvent) { background: #fafbfc; } +.pending-attachments { + display: flex; + gap: 8px; + margin-bottom: 10px; + overflow-x: auto; +} + +.pending-item { + position: relative; + flex: none; +} + +.pending-item :deep(.chat-image-button) { + width: 86px; + height: 86px; +} + +.pending-item :deep(.chat-image-button img) { + height: 86px; +} + +.remove-attachment { + position: absolute; + top: 4px; + right: 4px; + display: grid; + width: 22px; + height: 22px; + place-items: center; + border: 0; + border-radius: 50%; + background: rgba(17, 24, 39, 0.72); + color: #fff; + cursor: pointer; +} + +.hidden-file { + display: none; +} + .composer-actions { display: flex; align-items: center; @@ -407,6 +528,11 @@ function handleKeydown(e: KeyboardEvent) { margin-top: 8px; } +.composer-buttons { + display: flex; + gap: 8px; +} + .composer-hint { color: #a0aab6; font-size: 12px; diff --git a/frontend/src/views/admin/AdminChatsView.vue b/frontend/src/views/admin/AdminChatsView.vue index 8c4bff6..32e9768 100644 --- a/frontend/src/views/admin/AdminChatsView.vue +++ b/frontend/src/views/admin/AdminChatsView.vue @@ -1,6 +1,7 @@