聊天增加图片功能
This commit is contained in:
@@ -52,7 +52,8 @@ type MessageDTO struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SendMessageRequest struct {
|
type SendMessageRequest struct {
|
||||||
Content string `json:"content" binding:"required"`
|
Content string `json:"content"`
|
||||||
|
AttachmentURLS []string `json:"attachment_urls"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TransferRequest struct {
|
type TransferRequest struct {
|
||||||
|
|||||||
@@ -340,7 +340,7 @@ func (h *Handler) send(c *gin.Context, principal Principal) {
|
|||||||
}
|
}
|
||||||
var req SendMessageRequest
|
var req SendMessageRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
response.BadRequest(c, "消息内容不能为空")
|
response.BadRequest(c, "消息格式不正确")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
message, err := h.service.SendMessage(principal, id, req)
|
message, err := h.service.SendMessage(principal, id, req)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"hfb_sys/backend/internal/model"
|
"hfb_sys/backend/internal/model"
|
||||||
@@ -312,13 +313,13 @@ func (r *Repository) SendMessage(principal Principal, conversationID uint64, req
|
|||||||
SenderRole: participant.Role,
|
SenderRole: participant.Role,
|
||||||
ContentType: "text",
|
ContentType: "text",
|
||||||
Content: req.Content,
|
Content: req.Content,
|
||||||
AttachmentURLS: emptyJSONList(),
|
AttachmentURLS: encodeStringList(req.AttachmentURLS),
|
||||||
}
|
}
|
||||||
if err := tx.Create(&message).Error; err != nil {
|
if err := tx.Create(&message).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
conversation.LastMessageID = &message.ID
|
conversation.LastMessageID = &message.ID
|
||||||
conversation.LastMessagePreview = truncatePreview(message.Content)
|
conversation.LastMessagePreview = messagePreview(message.Content, req.AttachmentURLS)
|
||||||
conversation.LastMessageAt = &message.CreatedAt
|
conversation.LastMessageAt = &message.CreatedAt
|
||||||
if err := tx.Save(&conversation).Error; err != nil {
|
if err := tx.Save(&conversation).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -708,6 +709,17 @@ func truncatePreview(content string) string {
|
|||||||
return string(runes[:80])
|
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 转接会话给其他客服
|
// TransferConversation 转接会话给其他客服
|
||||||
func (r *Repository) TransferConversation(principal Principal, conversationID uint64, toAdminID uint64) error {
|
func (r *Repository) TransferConversation(principal Principal, conversationID uint64, toAdminID uint64) error {
|
||||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
@@ -908,6 +920,11 @@ func emptyJSONList() datatypes.JSON {
|
|||||||
return datatypes.JSON(raw)
|
return datatypes.JSON(raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func encodeStringList(items []string) datatypes.JSON {
|
||||||
|
raw, _ := json.Marshal(items)
|
||||||
|
return datatypes.JSON(raw)
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateRemark 更新会话备注
|
// UpdateRemark 更新会话备注
|
||||||
func (r *Repository) UpdateRemark(principal Principal, conversationID uint64, remark string) error {
|
func (r *Repository) UpdateRemark(principal Principal, conversationID uint64, remark string) error {
|
||||||
return r.db.Model(&model.ChatParticipant{}).
|
return r.db.Model(&model.ChatParticipant{}).
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package chat
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -63,15 +64,54 @@ func (s *Service) SendMessage(principal Principal, conversationID uint64, req Se
|
|||||||
return nil, ErrDependencyUnavailable
|
return nil, ErrDependencyUnavailable
|
||||||
}
|
}
|
||||||
req.Content = strings.TrimSpace(req.Content)
|
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
|
return nil, ErrInvalidMessage
|
||||||
}
|
}
|
||||||
if len([]rune(req.Content)) > 1000 {
|
if len([]rune(req.Content)) > 1000 {
|
||||||
return nil, ErrInvalidMessage
|
return nil, ErrInvalidMessage
|
||||||
}
|
}
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrInvalidMessage
|
||||||
|
}
|
||||||
return s.repo.SendMessage(principal, conversationID, req)
|
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 {
|
func (s *Service) MarkRead(principal Principal, conversationID uint64) error {
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return ErrDependencyUnavailable
|
return ErrDependencyUnavailable
|
||||||
|
|||||||
@@ -41,10 +41,10 @@ func (s *Service) Upload(req uploadRequest) (*UploadDTO, error) {
|
|||||||
if contentType == "" {
|
if contentType == "" {
|
||||||
contentType = req.Header.Header.Get("Content-Type")
|
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
|
return nil, ErrInvalidFile
|
||||||
}
|
}
|
||||||
scene := normalizeScene(req.Scene)
|
|
||||||
key, err := s.storage.Put(req.Context, scene, req.Header, req.Reader, contentType)
|
key, err := s.storage.Put(req.Context, scene, req.Header, req.Reader, contentType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -65,7 +65,7 @@ func (s *Service) Upload(req uploadRequest) (*UploadDTO, error) {
|
|||||||
func normalizeScene(scene string) string {
|
func normalizeScene(scene string) string {
|
||||||
scene = strings.TrimSpace(strings.ToLower(scene))
|
scene = strings.TrimSpace(strings.ToLower(scene))
|
||||||
switch scene {
|
switch scene {
|
||||||
case "listing", "handoff", "dispute", "realname", "avatar", "home-banner":
|
case "listing", "handoff", "dispute", "realname", "avatar", "home-banner", "chat":
|
||||||
return scene
|
return scene
|
||||||
default:
|
default:
|
||||||
return "misc"
|
return "misc"
|
||||||
|
|||||||
Vendored
+1
@@ -11,6 +11,7 @@ export {}
|
|||||||
/* prettier-ignore */
|
/* prettier-ignore */
|
||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
|
ChatAttachmentImage: typeof import('./src/components/ChatAttachmentImage.vue')['default']
|
||||||
ElAlert: typeof import('element-plus/es')['ElAlert']
|
ElAlert: typeof import('element-plus/es')['ElAlert']
|
||||||
ElAvatar: typeof import('element-plus/es')['ElAvatar']
|
ElAvatar: typeof import('element-plus/es')['ElAvatar']
|
||||||
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
|
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
|
||||||
|
|||||||
@@ -74,8 +74,11 @@ export async function fetchChatMessages(id: number, page = 1, pageSize = 100) {
|
|||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendChatMessage(id: number, content: string) {
|
export async function sendChatMessage(id: number, content: string, attachmentUrls: string[] = []) {
|
||||||
const { data } = await apiClient.post<ApiResponse<ChatMessage>>(`/chats/${id}/messages`, { content })
|
const { data } = await apiClient.post<ApiResponse<ChatMessage>>(`/chats/${id}/messages`, {
|
||||||
|
content,
|
||||||
|
attachment_urls: attachmentUrls,
|
||||||
|
})
|
||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,8 +106,11 @@ export async function fetchAdminChatMessages(id: number, page = 1, pageSize = 10
|
|||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendAdminChatMessage(id: number, content: string) {
|
export async function sendAdminChatMessage(id: number, content: string, attachmentUrls: string[] = []) {
|
||||||
const { data } = await apiClient.post<ApiResponse<ChatMessage>>(`/admin/chats/${id}/messages`, { content })
|
const { data } = await apiClient.post<ApiResponse<ChatMessage>>(`/admin/chats/${id}/messages`, {
|
||||||
|
content,
|
||||||
|
attachment_urls: attachmentUrls,
|
||||||
|
})
|
||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||||
|
import { fetchAdminFileBlob, fetchFileBlobByURL } from '@/api/files'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
source: string
|
||||||
|
admin?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const objectURL = ref('')
|
||||||
|
const failed = ref(false)
|
||||||
|
|
||||||
|
function extractObjectKey(value: string) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(value, window.location.origin)
|
||||||
|
return parsed.searchParams.get('key') || ''
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function revokeCurrentURL() {
|
||||||
|
if (!objectURL.value) return
|
||||||
|
URL.revokeObjectURL(objectURL.value)
|
||||||
|
objectURL.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadImage() {
|
||||||
|
revokeCurrentURL()
|
||||||
|
failed.value = false
|
||||||
|
if (!props.source) {
|
||||||
|
failed.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const key = extractObjectKey(props.source)
|
||||||
|
const blob = props.admin && key
|
||||||
|
? await fetchAdminFileBlob(key)
|
||||||
|
: await fetchFileBlobByURL(props.source)
|
||||||
|
objectURL.value = URL.createObjectURL(blob)
|
||||||
|
} catch {
|
||||||
|
failed.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openImage() {
|
||||||
|
if (!objectURL.value) return
|
||||||
|
window.open(objectURL.value, '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => [props.source, props.admin] as const, loadImage, { immediate: true })
|
||||||
|
|
||||||
|
onBeforeUnmount(revokeCurrentURL)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button v-if="objectURL" class="chat-image-button" type="button" @click="openImage">
|
||||||
|
<img :src="objectURL" alt="聊天图片" loading="lazy">
|
||||||
|
</button>
|
||||||
|
<span v-else class="chat-image-fallback">{{ failed ? '图片加载失败' : '图片加载中' }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chat-image-button {
|
||||||
|
display: block;
|
||||||
|
max-width: 220px;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
cursor: zoom-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-image-button img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 260px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-image-fallback {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #eef2f7;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { ArrowLeft, Loading } from '@element-plus/icons-vue'
|
import { ArrowLeft, Close, Loading, Picture } from '@element-plus/icons-vue'
|
||||||
import {
|
import {
|
||||||
fetchChat,
|
fetchChat,
|
||||||
fetchChatMessages,
|
fetchChatMessages,
|
||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
type ChatConversation,
|
type ChatConversation,
|
||||||
type ChatMessage,
|
type ChatMessage,
|
||||||
} from '@/api/chats'
|
} from '@/api/chats'
|
||||||
|
import { uploadFile } from '@/api/files'
|
||||||
|
import ChatAttachmentImage from '@/components/ChatAttachmentImage.vue'
|
||||||
import { useChatSSE, type ChatEvent } from '@/composables/useChatSSE'
|
import { useChatSSE, type ChatEvent } from '@/composables/useChatSSE'
|
||||||
import { formatDateMinute } from '@/utils/time'
|
import { formatDateMinute } from '@/utils/time'
|
||||||
|
|
||||||
@@ -22,10 +24,14 @@ const conversation = ref<ChatConversation | null>(null)
|
|||||||
const messages = ref<ChatMessage[]>([])
|
const messages = ref<ChatMessage[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const sending = ref(false)
|
const sending = ref(false)
|
||||||
|
const uploading = ref(false)
|
||||||
const content = ref('')
|
const content = ref('')
|
||||||
|
const attachments = ref<string[]>([])
|
||||||
const listRef = ref<HTMLElement | null>(null)
|
const listRef = ref<HTMLElement | null>(null)
|
||||||
|
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
const conversationID = computed(() => Number(route.params.id || 0))
|
const conversationID = computed(() => Number(route.params.id || 0))
|
||||||
|
const canSend = computed(() => content.value.trim() !== '' || attachments.value.length > 0)
|
||||||
const memberText = computed(() => {
|
const memberText = computed(() => {
|
||||||
const participants = conversation.value?.participants || []
|
const participants = conversation.value?.participants || []
|
||||||
if (participants.length === 0) return conversation.value?.type === 'general_support' ? '平台客服' : '订单群聊'
|
if (participants.length === 0) return conversation.value?.type === 'general_support' ? '平台客服' : '订单群聊'
|
||||||
@@ -107,12 +113,14 @@ async function loadMessages(scrollToBottom = true) {
|
|||||||
|
|
||||||
async function handleSend() {
|
async function handleSend() {
|
||||||
const text = content.value.trim()
|
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
|
sending.value = true
|
||||||
try {
|
try {
|
||||||
const sent = await sendChatMessage(conversationID.value, text)
|
const sent = await sendChatMessage(conversationID.value, text, imageUrls)
|
||||||
appendMessage(sent)
|
appendMessage(sent)
|
||||||
content.value = ''
|
content.value = ''
|
||||||
|
attachments.value = []
|
||||||
await loadConversation()
|
await loadConversation()
|
||||||
} catch {
|
} catch {
|
||||||
ElMessage.error('发送失败')
|
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) {
|
function appendMessage(message: ChatMessage) {
|
||||||
if (messages.value.some(item => item.id === message.id)) return
|
if (messages.value.some(item => item.id === message.id)) return
|
||||||
messages.value = [...messages.value, message]
|
messages.value = [...messages.value, message]
|
||||||
@@ -200,7 +247,14 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
<div class="avatar" :class="{ self: item.is_self }">{{ roleLabel(item.sender_role).slice(0, 1) }}</div>
|
<div class="avatar" :class="{ self: item.is_self }">{{ roleLabel(item.sender_role).slice(0, 1) }}</div>
|
||||||
<div class="bubble-wrap" :class="{ self: item.is_self }">
|
<div class="bubble-wrap" :class="{ self: item.is_self }">
|
||||||
<span class="sender-name">{{ senderLabel(item) }}</span>
|
<span class="sender-name">{{ senderLabel(item) }}</span>
|
||||||
<div class="bubble">{{ item.content }}</div>
|
<div v-if="item.content" class="bubble">{{ item.content }}</div>
|
||||||
|
<div v-if="item.attachment_urls.length > 0" class="message-attachments">
|
||||||
|
<ChatAttachmentImage
|
||||||
|
v-for="url in item.attachment_urls"
|
||||||
|
:key="url"
|
||||||
|
:source="url"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<span class="message-time">{{ formatDateMinute(item.created_at) }}</span>
|
<span class="message-time">{{ formatDateMinute(item.created_at) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -209,6 +263,22 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
|
|
||||||
<!-- Composer -->
|
<!-- Composer -->
|
||||||
<div class="composer">
|
<div class="composer">
|
||||||
|
<div v-if="attachments.length > 0" class="pending-attachments">
|
||||||
|
<div v-for="(url, index) in attachments" :key="url" class="pending-item">
|
||||||
|
<ChatAttachmentImage :source="url" />
|
||||||
|
<button type="button" class="remove-attachment" @click="removeAttachment(index)">
|
||||||
|
<el-icon :size="14"><Close /></el-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref="fileInputRef"
|
||||||
|
class="hidden-file"
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp"
|
||||||
|
multiple
|
||||||
|
@change="handleImageChange"
|
||||||
|
>
|
||||||
<el-input
|
<el-input
|
||||||
v-model="content"
|
v-model="content"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
@@ -221,9 +291,14 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
/>
|
/>
|
||||||
<div class="composer-actions">
|
<div class="composer-actions">
|
||||||
<span class="composer-hint">Enter 发送,Shift+Enter 换行</span>
|
<span class="composer-hint">Enter 发送,Shift+Enter 换行</span>
|
||||||
<el-button type="primary" :disabled="!content.trim() || sending" :loading="sending" @click="handleSend">
|
<div class="composer-buttons">
|
||||||
发送
|
<el-button :icon="Picture" :loading="uploading" :disabled="attachments.length >= 9" @click="pickImages">
|
||||||
</el-button>
|
图片
|
||||||
|
</el-button>
|
||||||
|
<el-button type="primary" :disabled="!canSend || sending || uploading" :loading="sending" @click="handleSend">
|
||||||
|
发送
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -376,6 +451,12 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
color: #0f5132;
|
color: #0f5132;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-attachments {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.message-time {
|
.message-time {
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
color: #a1a8b4;
|
color: #a1a8b4;
|
||||||
@@ -400,6 +481,46 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
background: #fafbfc;
|
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 {
|
.composer-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -407,6 +528,11 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.composer-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.composer-hint {
|
.composer-hint {
|
||||||
color: #a0aab6;
|
color: #a0aab6;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { Close, Picture } from '@element-plus/icons-vue'
|
||||||
import {
|
import {
|
||||||
fetchAdminChat,
|
fetchAdminChat,
|
||||||
fetchAdminChatMessages,
|
fetchAdminChatMessages,
|
||||||
@@ -13,6 +14,8 @@ import {
|
|||||||
type ChatMessage,
|
type ChatMessage,
|
||||||
type QuickReply,
|
type QuickReply,
|
||||||
} from '@/api/chats'
|
} from '@/api/chats'
|
||||||
|
import { uploadAdminFile } from '@/api/files'
|
||||||
|
import ChatAttachmentImage from '@/components/ChatAttachmentImage.vue'
|
||||||
import { useChatSSE, type ChatEvent } from '@/composables/useChatSSE'
|
import { useChatSSE, type ChatEvent } from '@/composables/useChatSSE'
|
||||||
import { formatDateMinute } from '@/utils/time'
|
import { formatDateMinute } from '@/utils/time'
|
||||||
import TransferDialog from './components/TransferDialog.vue'
|
import TransferDialog from './components/TransferDialog.vue'
|
||||||
@@ -26,8 +29,11 @@ const messages = ref<ChatMessage[]>([])
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const messageLoading = ref(false)
|
const messageLoading = ref(false)
|
||||||
const sending = ref(false)
|
const sending = ref(false)
|
||||||
|
const uploading = ref(false)
|
||||||
const content = ref('')
|
const content = ref('')
|
||||||
|
const attachments = ref<string[]>([])
|
||||||
const listRef = ref<HTMLElement | null>(null)
|
const listRef = ref<HTMLElement | null>(null)
|
||||||
|
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||||
const filter = ref<'all' | 'mine' | 'unassigned'>('mine')
|
const filter = ref<'all' | 'mine' | 'unassigned'>('mine')
|
||||||
const transferVisible = ref(false)
|
const transferVisible = ref(false)
|
||||||
const quickReplyVisible = ref(false)
|
const quickReplyVisible = ref(false)
|
||||||
@@ -43,6 +49,7 @@ const activeMembers = computed(() => {
|
|||||||
return `${roleLabel(item.role)}:${name}`
|
return `${roleLabel(item.role)}:${name}`
|
||||||
}).join(' / ')
|
}).join(' / ')
|
||||||
})
|
})
|
||||||
|
const canSend = computed(() => content.value.trim() !== '' || attachments.value.length > 0)
|
||||||
|
|
||||||
function getParticipantRemark(participant: any) {
|
function getParticipantRemark(participant: any) {
|
||||||
if (!active.value) return ''
|
if (!active.value) return ''
|
||||||
@@ -116,6 +123,7 @@ async function openConversation(item: ChatConversation) {
|
|||||||
await loadConversations(false)
|
await loadConversations(false)
|
||||||
remarkEditing.value = false
|
remarkEditing.value = false
|
||||||
remarkValue.value = ''
|
remarkValue.value = ''
|
||||||
|
attachments.value = []
|
||||||
} catch {
|
} catch {
|
||||||
ElMessage.error('会话详情加载失败')
|
ElMessage.error('会话详情加载失败')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -134,11 +142,15 @@ async function loadMessages(id: number, scroll = true) {
|
|||||||
|
|
||||||
async function handleSend() {
|
async function handleSend() {
|
||||||
const text = content.value.trim()
|
const text = content.value.trim()
|
||||||
if (!active.value || !text || sending.value) return
|
const imageUrls = [...attachments.value]
|
||||||
|
if (!active.value || (!text && imageUrls.length === 0) || sending.value || uploading.value) return
|
||||||
sending.value = true
|
sending.value = true
|
||||||
try {
|
try {
|
||||||
await sendAdminChatMessage(active.value.id, text)
|
const sent = await sendAdminChatMessage(active.value.id, text, imageUrls)
|
||||||
|
appendMessage(sent)
|
||||||
content.value = ''
|
content.value = ''
|
||||||
|
attachments.value = []
|
||||||
|
await loadConversations(false)
|
||||||
} catch {
|
} catch {
|
||||||
ElMessage.error('发送失败')
|
ElMessage.error('发送失败')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -146,6 +158,51 @@ async function handleSend() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function appendMessage(message: ChatMessage) {
|
||||||
|
if (messages.value.some(item => item.id === message.id)) return
|
||||||
|
messages.value = [...messages.value, message]
|
||||||
|
nextTick(() => scrollBottom())
|
||||||
|
}
|
||||||
|
|
||||||
|
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 uploadAdminFile(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 handleQuickReplySelect(reply: QuickReply) {
|
function handleQuickReplySelect(reply: QuickReply) {
|
||||||
content.value = reply.content
|
content.value = reply.content
|
||||||
quickReplyVisible.value = false
|
quickReplyVisible.value = false
|
||||||
@@ -307,13 +364,29 @@ function getSupportName(item: ChatConversation) {
|
|||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<small>{{ senderLabel(item) }} · {{ formatDateMinute(item.created_at) }}</small>
|
<small>{{ senderLabel(item) }} · {{ formatDateMinute(item.created_at) }}</small>
|
||||||
<p>{{ item.content }}</p>
|
<p v-if="item.content">{{ item.content }}</p>
|
||||||
|
<div v-if="item.attachment_urls.length > 0" class="message-attachments">
|
||||||
|
<ChatAttachmentImage
|
||||||
|
v-for="url in item.attachment_urls"
|
||||||
|
:key="url"
|
||||||
|
:source="url"
|
||||||
|
admin
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="composer">
|
<footer class="composer">
|
||||||
<div class="composer-tools">
|
<div class="composer-tools">
|
||||||
|
<input
|
||||||
|
ref="fileInputRef"
|
||||||
|
class="hidden-file"
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp"
|
||||||
|
multiple
|
||||||
|
@change="handleImageChange"
|
||||||
|
>
|
||||||
<el-dropdown trigger="click" @command="handleQuickReplySelect">
|
<el-dropdown trigger="click" @command="handleQuickReplySelect">
|
||||||
<el-button size="small" text>快捷回复</el-button>
|
<el-button size="small" text>快捷回复</el-button>
|
||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
@@ -332,6 +405,17 @@ function getSupportName(item: ChatConversation) {
|
|||||||
</el-dropdown-menu>
|
</el-dropdown-menu>
|
||||||
</template>
|
</template>
|
||||||
</el-dropdown>
|
</el-dropdown>
|
||||||
|
<el-button size="small" text :icon="Picture" :loading="uploading" :disabled="attachments.length >= 9" @click="pickImages">
|
||||||
|
图片
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<div v-if="attachments.length > 0" class="pending-attachments">
|
||||||
|
<div v-for="(url, index) in attachments" :key="url" class="pending-item">
|
||||||
|
<ChatAttachmentImage :source="url" admin />
|
||||||
|
<button type="button" class="remove-attachment" @click="removeAttachment(index)">
|
||||||
|
<el-icon :size="14"><Close /></el-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="composer-input">
|
<div class="composer-input">
|
||||||
<el-input
|
<el-input
|
||||||
@@ -343,7 +427,7 @@ function getSupportName(item: ChatConversation) {
|
|||||||
placeholder="输入客服回复"
|
placeholder="输入客服回复"
|
||||||
@keydown.enter.exact.prevent="handleSend"
|
@keydown.enter.exact.prevent="handleSend"
|
||||||
/>
|
/>
|
||||||
<el-button type="primary" :loading="sending" :disabled="!content.trim()" @click="handleSend">发送</el-button>
|
<el-button type="primary" :loading="sending" :disabled="!canSend || uploading" @click="handleSend">发送</el-button>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</template>
|
</template>
|
||||||
@@ -565,6 +649,16 @@ function getSupportName(item: ChatConversation) {
|
|||||||
background: #dff5eb;
|
background: #dff5eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-attachments {
|
||||||
|
display: grid;
|
||||||
|
justify-items: start;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-row.self .message-attachments {
|
||||||
|
justify-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
.message-row.system span {
|
.message-row.system span {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 5px 10px;
|
padding: 5px 10px;
|
||||||
@@ -579,10 +673,53 @@ function getSupportName(item: ChatConversation) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.composer-tools {
|
.composer-tools {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
padding: 8px 14px;
|
padding: 8px 14px;
|
||||||
border-bottom: 1px solid #f0f0f0;
|
border-bottom: 1px solid #f0f0f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hidden-file {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-attachments {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-item {
|
||||||
|
position: relative;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-item :deep(.chat-image-button) {
|
||||||
|
width: 84px;
|
||||||
|
height: 84px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-item :deep(.chat-image-button img) {
|
||||||
|
height: 84px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
.composer-input {
|
.composer-input {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 88px;
|
grid-template-columns: minmax(0, 1fr) 88px;
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
type ChatConversation,
|
type ChatConversation,
|
||||||
type ChatMessage,
|
type ChatMessage,
|
||||||
} from '@/api/chats'
|
} from '@/api/chats'
|
||||||
|
import { uploadFile } from '@/api/files'
|
||||||
|
import ChatAttachmentImage from '@/components/ChatAttachmentImage.vue'
|
||||||
import { useChatSSE, type ChatEvent } from '@/composables/useChatSSE'
|
import { useChatSSE, type ChatEvent } from '@/composables/useChatSSE'
|
||||||
import { formatDateMinute } from '@/utils/time'
|
import { formatDateMinute } from '@/utils/time'
|
||||||
|
|
||||||
@@ -21,10 +23,14 @@ const conversation = ref<ChatConversation | null>(null)
|
|||||||
const messages = ref<ChatMessage[]>([])
|
const messages = ref<ChatMessage[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const sending = ref(false)
|
const sending = ref(false)
|
||||||
|
const uploading = ref(false)
|
||||||
const content = ref('')
|
const content = ref('')
|
||||||
|
const attachments = ref<string[]>([])
|
||||||
const listRef = ref<HTMLElement | null>(null)
|
const listRef = ref<HTMLElement | null>(null)
|
||||||
|
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
const conversationID = computed(() => Number(route.params.id || 0))
|
const conversationID = computed(() => Number(route.params.id || 0))
|
||||||
|
const canSend = computed(() => content.value.trim() !== '' || attachments.value.length > 0)
|
||||||
const memberText = computed(() => {
|
const memberText = computed(() => {
|
||||||
const participants = conversation.value?.participants || []
|
const participants = conversation.value?.participants || []
|
||||||
if (participants.length === 0) return conversation.value?.type === 'general_support' ? '平台客服' : '订单群聊'
|
if (participants.length === 0) return conversation.value?.type === 'general_support' ? '平台客服' : '订单群聊'
|
||||||
@@ -100,11 +106,14 @@ async function loadMessages(scrollToBottom = true) {
|
|||||||
|
|
||||||
async function handleSend() {
|
async function handleSend() {
|
||||||
const text = content.value.trim()
|
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
|
sending.value = true
|
||||||
try {
|
try {
|
||||||
await sendChatMessage(conversationID.value, text)
|
const sent = await sendChatMessage(conversationID.value, text, imageUrls)
|
||||||
|
appendMessage(sent)
|
||||||
content.value = ''
|
content.value = ''
|
||||||
|
attachments.value = []
|
||||||
} catch {
|
} catch {
|
||||||
showToast({ message: '发送失败', icon: 'cross' })
|
showToast({ message: '发送失败', icon: 'cross' })
|
||||||
} finally {
|
} finally {
|
||||||
@@ -112,6 +121,51 @@ async function handleSend() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function appendMessage(message: ChatMessage) {
|
||||||
|
if (messages.value.some(item => item.id === message.id)) return
|
||||||
|
messages.value = [...messages.value, message]
|
||||||
|
nextTick(() => scrollBottom())
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
showToast('每条消息最多发送 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) {
|
||||||
|
showToast(`${file.name} 不符合图片规则`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const uploaded = await uploadFile(file, 'chat')
|
||||||
|
attachments.value.push(uploaded.url)
|
||||||
|
}
|
||||||
|
if (files.length > slots) {
|
||||||
|
showToast('每条消息最多发送 9 张图片')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast({ message: '图片上传失败', icon: 'cross' })
|
||||||
|
} finally {
|
||||||
|
uploading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAttachment(index: number) {
|
||||||
|
attachments.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
function scrollBottom() {
|
function scrollBottom() {
|
||||||
const el = listRef.value
|
const el = listRef.value
|
||||||
if (!el) return
|
if (!el) return
|
||||||
@@ -166,7 +220,14 @@ function senderLabel(message: ChatMessage) {
|
|||||||
<div class="avatar">{{ roleLabel(item.sender_role).slice(0, 1) }}</div>
|
<div class="avatar">{{ roleLabel(item.sender_role).slice(0, 1) }}</div>
|
||||||
<div class="bubble-wrap">
|
<div class="bubble-wrap">
|
||||||
<span class="sender-name">{{ senderLabel(item) }}</span>
|
<span class="sender-name">{{ senderLabel(item) }}</span>
|
||||||
<div class="bubble">{{ item.content }}</div>
|
<div v-if="item.content" class="bubble">{{ item.content }}</div>
|
||||||
|
<div v-if="item.attachment_urls.length > 0" class="message-attachments">
|
||||||
|
<ChatAttachmentImage
|
||||||
|
v-for="url in item.attachment_urls"
|
||||||
|
:key="url"
|
||||||
|
:source="url"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<span class="message-time">{{ formatDateMinute(item.created_at) }}</span>
|
<span class="message-time">{{ formatDateMinute(item.created_at) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -174,6 +235,25 @@ function senderLabel(message: ChatMessage) {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<footer class="composer">
|
<footer class="composer">
|
||||||
|
<input
|
||||||
|
ref="fileInputRef"
|
||||||
|
class="hidden-file"
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp"
|
||||||
|
multiple
|
||||||
|
@change="handleImageChange"
|
||||||
|
>
|
||||||
|
<div v-if="attachments.length > 0" class="pending-attachments">
|
||||||
|
<div v-for="(url, index) in attachments" :key="url" class="pending-item">
|
||||||
|
<ChatAttachmentImage :source="url" />
|
||||||
|
<button type="button" class="remove-attachment" @click="removeAttachment(index)">
|
||||||
|
<van-icon name="cross" :size="12" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="tool-btn" type="button" :disabled="uploading || attachments.length >= 9" @click="pickImages">
|
||||||
|
<van-icon name="photo-o" :size="20" />
|
||||||
|
</button>
|
||||||
<van-field
|
<van-field
|
||||||
v-model="content"
|
v-model="content"
|
||||||
class="composer-input"
|
class="composer-input"
|
||||||
@@ -184,7 +264,7 @@ function senderLabel(message: ChatMessage) {
|
|||||||
placeholder="发送消息"
|
placeholder="发送消息"
|
||||||
@keydown.enter.prevent="handleSend"
|
@keydown.enter.prevent="handleSend"
|
||||||
/>
|
/>
|
||||||
<button class="send-btn" type="button" :disabled="!content.trim() || sending" @click="handleSend">
|
<button class="send-btn" type="button" :disabled="!canSend || sending || uploading" @click="handleSend">
|
||||||
<van-icon name="guide-o" :size="20" />
|
<van-icon name="guide-o" :size="20" />
|
||||||
</button>
|
</button>
|
||||||
</footer>
|
</footer>
|
||||||
@@ -323,6 +403,16 @@ function senderLabel(message: ChatMessage) {
|
|||||||
background: #dff5eb;
|
background: #dff5eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-attachments {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-attachments :deep(.chat-image-button) {
|
||||||
|
max-width: min(62vw, 220px);
|
||||||
|
}
|
||||||
|
|
||||||
.message-time {
|
.message-time {
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
color: #a1a8b4;
|
color: #a1a8b4;
|
||||||
@@ -342,7 +432,7 @@ function senderLabel(message: ChatMessage) {
|
|||||||
|
|
||||||
.composer {
|
.composer {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 42px;
|
grid-template-columns: 40px minmax(0, 1fr) 42px;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: end;
|
align-items: end;
|
||||||
padding: 8px 10px calc(8px + env(safe-area-inset-bottom));
|
padding: 8px 10px calc(8px + env(safe-area-inset-bottom));
|
||||||
@@ -350,12 +440,66 @@ function senderLabel(message: ChatMessage) {
|
|||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hidden-file {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-attachments {
|
||||||
|
display: flex;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
gap: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-item {
|
||||||
|
position: relative;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-item :deep(.chat-image-button) {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-item :deep(.chat-image-button img) {
|
||||||
|
height: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-attachment {
|
||||||
|
position: absolute;
|
||||||
|
top: 3px;
|
||||||
|
right: 3px;
|
||||||
|
display: grid;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
place-items: center;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(17, 24, 39, 0.72);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
.composer-input {
|
.composer-input {
|
||||||
border: 1px solid #d9e0e8;
|
border: 1px solid #d9e0e8;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tool-btn {
|
||||||
|
display: grid;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
place-items: center;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #eef4ff;
|
||||||
|
color: #1477ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-btn:disabled {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
.send-btn {
|
.send-btn {
|
||||||
display: grid;
|
display: grid;
|
||||||
width: 40px;
|
width: 40px;
|
||||||
|
|||||||
Reference in New Issue
Block a user