0c6166b3c7
- 支持剪贴板粘贴图片和文件拖拽上传 - 添加消息编辑功能,支持修改内容和管理图片 - 编辑对话框支持显示、删除原有图片和添加新图片 - 修复消息更新API响应检查逻辑 - 优化图片文件ID管理,支持保留和删除原始图片
1323 lines
35 KiB
Vue
1323 lines
35 KiB
Vue
<template>
|
||
<div class="ticket-detail-page">
|
||
<!-- 头部信息 -->
|
||
<div class="page-header">
|
||
<div class="header-left">
|
||
<el-button icon="ArrowLeft" @click="goBack">返回列表</el-button>
|
||
<el-popover placement="bottom-start" :width="300" trigger="click" v-if="ticketInfo" @show="fetchUserDetail">
|
||
<template #reference>
|
||
<div class="user-info clickable">
|
||
<el-avatar :size="36" :src="ticketInfo.avatar">{{ ticketInfo.username?.charAt(0) }}</el-avatar>
|
||
<div class="user-detail">
|
||
<div class="username">{{ ticketInfo.username }}</div>
|
||
<div class="create-time">创建于 {{ ticketInfo.createTime }}</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<div class="user-popover" v-loading="isLoadingUser">
|
||
<div class="popover-header">
|
||
<el-avatar :size="48" :src="userDetail?.Avatar || ticketInfo.avatar">{{ ticketInfo.username?.charAt(0) }}</el-avatar>
|
||
<div class="popover-info">
|
||
<div class="popover-name">{{ userDetail?.UserName || ticketInfo.username }}</div>
|
||
<div class="popover-id">UID: {{ ticketInfo.userId }}</div>
|
||
</div>
|
||
</div>
|
||
<el-divider style="margin: 12px 0" />
|
||
<div class="popover-items">
|
||
<div class="popover-item">
|
||
<span class="label">手机号</span>
|
||
<span class="value">{{ userDetail?.Phone || '-' }}</span>
|
||
</div>
|
||
<div class="popover-item">
|
||
<span class="label">邮箱</span>
|
||
<span class="value">{{ userDetail?.Email || '-' }}</span>
|
||
</div>
|
||
<div class="popover-item">
|
||
<span class="label">实名状态</span>
|
||
<el-tag :type="userDetail?.RealName?.Status === 1 ? 'success' : 'info'" size="small">
|
||
{{ userDetail?.RealName?.Status === 1 ? '已实名' : '未实名' }}
|
||
</el-tag>
|
||
</div>
|
||
<div class="popover-item">
|
||
<span class="label">用户组</span>
|
||
<span class="value">{{ userDetail?.UserGroup?.Name || '-' }}</span>
|
||
</div>
|
||
<!-- <div class="popover-item">
|
||
<span class="label">管理员组</span>
|
||
<span class="value">{{ userDetail?.AdminGroup?.name || '-' }}</span>
|
||
</div> -->
|
||
</div>
|
||
<el-button type="primary" size="small" style="width: 100%; margin-top: 12px" @click="goToUserDetail">
|
||
查看完整资料
|
||
</el-button>
|
||
</div>
|
||
</el-popover>
|
||
<div class="ticket-title" v-if="ticketInfo">{{ ticketInfo.title }}</div>
|
||
</div>
|
||
<div class="header-right" v-if="ticketInfo">
|
||
<span class="ticket-id">工单号: {{ ticketInfo.id }}</span>
|
||
<el-select
|
||
v-model="ticketInfo.status"
|
||
size="small"
|
||
style="width: 120px"
|
||
@change="handleStatusChange"
|
||
>
|
||
<el-option label="待处理" value="pending" />
|
||
<el-option label="处理中" value="processing" />
|
||
<el-option label="已回复" value="replied" />
|
||
<el-option label="已完成" value="completed" />
|
||
</el-select>
|
||
<el-button
|
||
v-if="ticketInfo.status !== 'completed'"
|
||
type="success"
|
||
size="small"
|
||
@click="handleComplete"
|
||
>
|
||
结束工单
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 聊天记录 -->
|
||
<div class="chat-container" v-loading="isLoadingMessages">
|
||
<div class="chat-messages" ref="messagesContainer">
|
||
<div
|
||
v-for="(message, index) in messages"
|
||
:key="index"
|
||
:class="['message-item', message.isAdmin ? 'message-admin' : 'message-user']"
|
||
>
|
||
<div class="message-avatar" v-if="!message.isAdmin">
|
||
<el-avatar :size="36" :src="message.avatar">{{ ticketInfo?.username?.charAt(0) || 'U' }}</el-avatar>
|
||
</div>
|
||
<div class="message-content">
|
||
<div class="message-text" v-if="message.content">
|
||
{{ message.content }}
|
||
<span
|
||
v-if="message.isAdmin && !message.isTempMessage"
|
||
class="edit-btn"
|
||
@click="handleEditMessage(message)"
|
||
title="编辑消息"
|
||
>
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||
<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/>
|
||
</svg>
|
||
</span>
|
||
</div>
|
||
<div class="message-images" v-if="message.images && message.images.length">
|
||
<img
|
||
v-for="(img, imgIndex) in message.images"
|
||
:key="imgIndex"
|
||
:src="img"
|
||
class="message-image"
|
||
@click="openImage(img)"
|
||
/>
|
||
</div>
|
||
<div class="message-time">{{ formatMessageTime(message.time) }}</div>
|
||
</div>
|
||
<div class="message-avatar" v-if="message.isAdmin">
|
||
<el-avatar :size="36" :src="message.avatar">A</el-avatar>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 回复输入区域 -->
|
||
<div class="reply-container" v-if="ticketInfo && ticketInfo.status !== 'completed'">
|
||
<!-- 图片预览 -->
|
||
<div class="upload-preview" v-if="selectedImages.length > 0">
|
||
<div class="preview-item" v-for="(image, index) in selectedImages" :key="index">
|
||
<img :src="image" alt="预览图片" />
|
||
<div class="delete-preview" @click="removeImage(index)">×</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 快捷回复 -->
|
||
<div class="quick-replies">
|
||
<el-button
|
||
v-for="(reply, index) in quickReplies"
|
||
:key="index"
|
||
size="small"
|
||
@click="useQuickReply(reply)"
|
||
>
|
||
{{ reply.title }}
|
||
</el-button>
|
||
</div>
|
||
|
||
<!-- 输入框 -->
|
||
<div
|
||
class="input-area"
|
||
@drop.prevent="handleDrop"
|
||
@dragover.prevent="handleDragOver"
|
||
@dragleave.prevent="handleDragLeave"
|
||
:class="{ 'drag-over': isDragOver }"
|
||
>
|
||
<el-input
|
||
ref="textareaRef"
|
||
v-model="messageInput"
|
||
type="textarea"
|
||
:rows="3"
|
||
placeholder="请输入回复内容(支持粘贴图片和拖拽图片)..."
|
||
@keyup.ctrl.enter="sendMessage"
|
||
/>
|
||
<div class="input-actions">
|
||
<div class="left-actions">
|
||
<el-upload
|
||
action="#"
|
||
:auto-upload="false"
|
||
:show-file-list="false"
|
||
:on-change="handleFileChange"
|
||
multiple
|
||
accept="image/*"
|
||
>
|
||
<el-button type="primary" plain icon="Plus">图片</el-button>
|
||
</el-upload>
|
||
</div>
|
||
<span class="hint-text">Ctrl + Enter 发送</span>
|
||
<el-button
|
||
type="primary"
|
||
:disabled="!messageInput.trim() && selectedImages.length === 0"
|
||
:loading="isSending"
|
||
@click="sendMessage"
|
||
>
|
||
发送
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 已结束提示 -->
|
||
<div class="closed-notice" v-else-if="ticketInfo && ticketInfo.status === 'completed'">
|
||
<el-alert title="该工单已结束,无法继续回复" type="info" :closable="false" />
|
||
</div>
|
||
|
||
<!-- 编辑消息对话框 -->
|
||
<el-dialog v-model="editDialogVisible" title="编辑消息" width="600px">
|
||
<el-form>
|
||
<el-form-item label="消息内容">
|
||
<el-input
|
||
v-model="editMessageContent"
|
||
type="textarea"
|
||
:rows="4"
|
||
placeholder="请输入消息内容"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="图片">
|
||
<!-- 现有图片预览 -->
|
||
<div class="edit-images-container">
|
||
<div class="edit-preview-item" v-for="(image, index) in editMessageImages" :key="index">
|
||
<img :src="image" alt="图片" />
|
||
<div class="delete-preview" @click="removeEditImage(index)">×</div>
|
||
</div>
|
||
<!-- 添加图片按钮 -->
|
||
<el-upload
|
||
action="#"
|
||
:auto-upload="false"
|
||
:show-file-list="false"
|
||
:on-change="handleEditFileChange"
|
||
multiple
|
||
accept="image/*"
|
||
>
|
||
<div class="add-image-btn">
|
||
<el-icon :size="24"><Plus /></el-icon>
|
||
<div class="add-text">添加图片</div>
|
||
</div>
|
||
</el-upload>
|
||
</div>
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="editDialogVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="isEditingSaving" @click="saveEditMessage">保存</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 图片查看器 -->
|
||
<el-dialog v-model="imageViewerVisible" width="auto" destroy-on-close>
|
||
<img :src="currentViewImage" style="max-width: 100%; max-height: 80vh;" />
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, onMounted, nextTick, onBeforeUnmount, watch } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import { getTicketDetail, replyTicket, closeTicket, updateTicketInfo, updateTicketReplayInfo } from '@/api/ticket'
|
||
import { Plus } from '@element-plus/icons-vue'
|
||
import { getUserInfo } from '@/api/admin/user'
|
||
import { uploadFile } from '@/api/admin/file'
|
||
import { useUserStore } from '@/store/userStore'
|
||
import { useTagsViewStore } from '@/store/tagsViewStore'
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const userStore = useUserStore()
|
||
const tagsViewStore = useTagsViewStore()
|
||
|
||
// 工单信息
|
||
const ticketInfo = ref(null)
|
||
const messages = ref([])
|
||
const isLoadingMessages = ref(false)
|
||
const isSending = ref(false)
|
||
|
||
// 用户详情弹窗
|
||
const userDetail = ref(null)
|
||
const isLoadingUser = ref(false)
|
||
|
||
// 输入相关
|
||
const messageInput = ref('')
|
||
const selectedImages = ref([])
|
||
const selectedFiles = ref([]) // 存储原始文件对象
|
||
const messagesContainer = ref(null)
|
||
const textareaRef = ref(null)
|
||
const isDragOver = ref(false)
|
||
|
||
// 图片查看
|
||
const imageViewerVisible = ref(false)
|
||
const currentViewImage = ref('')
|
||
|
||
// 编辑消息
|
||
const editDialogVisible = ref(false)
|
||
const editMessageContent = ref('')
|
||
const editMessageImages = ref([])
|
||
const editMessageFiles = ref([])
|
||
const editOriginalFileIds = ref([]) // 保存原始文件ID
|
||
const editingMessage = ref(null)
|
||
const isEditingSaving = ref(false)
|
||
|
||
// 定时刷新
|
||
const refreshTimer = ref(null)
|
||
|
||
// 快捷回复
|
||
const quickReplies = [
|
||
{ title: '您好,有什么可以帮助您的?', content: '您好,有什么可以帮助您的?' },
|
||
{ title: '正在处理中', content: '您的问题正在处理中,请稍等片刻,我们会尽快给您答复。' },
|
||
{ title: '需要更多信息', content: '为了更好地解决您的问题,请您提供更多相关信息。' },
|
||
{ title: '问题已解决', content: '您的问题已解决,感谢您的反馈。如有其他问题,请随时联系我们。' }
|
||
]
|
||
|
||
// 状态转换
|
||
const convertStatusToString = (status) => {
|
||
const statusMap = { 0: 'pending', 1: 'processing', 2: 'replied', 3: 'completed' }
|
||
return statusMap[status] || 'processing'
|
||
}
|
||
|
||
const getStatusText = (status) => {
|
||
const statusMap = { pending: '待处理', processing: '处理中', replied: '已回复', completed: '已完成' }
|
||
return statusMap[status] || status
|
||
}
|
||
|
||
const getStatusType = (status) => {
|
||
const typeMap = { pending: 'warning', processing: 'primary', replied: 'info', completed: 'success' }
|
||
return typeMap[status] || ''
|
||
}
|
||
|
||
// 判断是否是管理员
|
||
const isAdmin = (userId) => {
|
||
return userId === userStore.userInfo?.user_id
|
||
}
|
||
|
||
// 比较两个消息列表是否相同
|
||
const messagesEqual = (oldMessages, newMessages) => {
|
||
if (oldMessages.length !== newMessages.length) return false
|
||
|
||
for (let i = 0; i < oldMessages.length; i++) {
|
||
const oldMsg = oldMessages[i]
|
||
const newMsg = newMessages[i]
|
||
|
||
// 比较关键字段
|
||
if (oldMsg.id !== newMsg.id ||
|
||
oldMsg.content !== newMsg.content ||
|
||
oldMsg.images?.length !== newMsg.images?.length) {
|
||
return false
|
||
}
|
||
|
||
// 比较图片URL
|
||
if (oldMsg.images && newMsg.images) {
|
||
for (let j = 0; j < oldMsg.images.length; j++) {
|
||
if (oldMsg.images[j] !== newMsg.images[j]) {
|
||
return false
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return true
|
||
}
|
||
|
||
// 获取工单详情
|
||
const fetchTicketDetail = async (showLoading = true) => {
|
||
const workId = route.query.id
|
||
if (!workId) {
|
||
// 没有ID时静默跳转到列表页
|
||
router.replace('/ticket/list')
|
||
return
|
||
}
|
||
|
||
try {
|
||
if (showLoading) {
|
||
isLoadingMessages.value = true
|
||
}
|
||
const res = await getTicketDetail(workId)
|
||
|
||
if (res.code === 200) {
|
||
const detail = res.data
|
||
ticketInfo.value = {
|
||
id: detail.id,
|
||
title: detail.name,
|
||
username: detail.user?.userName || `用户${detail.user?.userId || 'Unknown'}`,
|
||
userId: detail.user?.userId,
|
||
avatar: detail.user?.coverUrl || '',
|
||
createTime: new Date(detail.created_at).toLocaleString(),
|
||
status: convertStatusToString(detail.status)
|
||
}
|
||
|
||
// 处理消息列表并按ID排序
|
||
if (detail.content && detail.content.length > 0) {
|
||
const newMessages = detail.content
|
||
.map((msg) => ({
|
||
id: msg.id,
|
||
content: msg.content !== 'empty' ? msg.content : null,
|
||
images: msg.flies ? msg.flies.map(file => file.url) : [],
|
||
fileIds: msg.flies ? msg.flies.map(file => file.id) : [], // 保存文件ID
|
||
time: msg.created_at || msg.updated_at || new Date().toLocaleString(),
|
||
isAdmin: isAdmin(msg.user?.userId),
|
||
userId: msg.user?.userId,
|
||
avatar: msg.user?.coverUrl || ''
|
||
}))
|
||
.sort((a, b) => a.id - b.id) // 按ID从小到大排序
|
||
|
||
// 比较新旧消息列表,只有在有变化时才更新
|
||
const hasChanges = !messagesEqual(messages.value, newMessages)
|
||
|
||
if (hasChanges) {
|
||
const shouldScroll = showLoading || newMessages.length > messages.value.length
|
||
messages.value = newMessages
|
||
|
||
if (shouldScroll) {
|
||
nextTick(() => scrollToBottom())
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
ElMessage.error(res.message || '获取工单详情失败')
|
||
}
|
||
} catch (error) {
|
||
console.error('获取工单详情出错:', error)
|
||
if (showLoading) {
|
||
ElMessage.error('网络错误,请稍后重试')
|
||
}
|
||
} finally {
|
||
if (showLoading) {
|
||
isLoadingMessages.value = false
|
||
}
|
||
}
|
||
}
|
||
|
||
// 发送消息
|
||
const sendMessage = async () => {
|
||
if ((!messageInput.value.trim() && selectedImages.value.length === 0) || isSending.value) return
|
||
|
||
const workId = route.query.id
|
||
const content = messageInput.value.trim() || 'empty'
|
||
|
||
try {
|
||
isSending.value = true
|
||
const inputMsg = messageInput.value.trim()
|
||
const inputImages = [...selectedImages.value]
|
||
const inputFiles = [...selectedFiles.value]
|
||
|
||
// 上传图片并获取文件ID
|
||
let fileIds = []
|
||
if (inputFiles.length > 0) {
|
||
try {
|
||
const formData = new FormData()
|
||
|
||
// 添加所有文件
|
||
inputFiles.forEach((file) => {
|
||
formData.append('files', file)
|
||
formData.append('file_names', file.name)
|
||
})
|
||
|
||
// 设置上传类型为工单
|
||
formData.append('update_type', 'work_order')
|
||
formData.append('open_down', 'true')
|
||
|
||
const uploadRes = await uploadFile(formData)
|
||
|
||
if (uploadRes.data?.code === 200) {
|
||
// 从返回的数据中提取文件ID(字段名是 id)
|
||
const data = uploadRes.data.data
|
||
if (Array.isArray(data)) {
|
||
fileIds = data.map(item => String(item.id))
|
||
} else if (data.id) {
|
||
fileIds = [String(data.id)]
|
||
}
|
||
|
||
if (fileIds.length === 0) {
|
||
ElMessage.error('未获取到文件ID')
|
||
isSending.value = false
|
||
return
|
||
}
|
||
} else {
|
||
ElMessage.error(uploadRes.data?.message || '图片上传失败')
|
||
isSending.value = false
|
||
return
|
||
}
|
||
} catch (error) {
|
||
console.error('图片上传失败:', error)
|
||
ElMessage.error('图片上传失败,请重试')
|
||
isSending.value = false
|
||
return
|
||
}
|
||
}
|
||
|
||
messageInput.value = ''
|
||
selectedImages.value = []
|
||
selectedFiles.value = []
|
||
|
||
// 临时消息
|
||
const tempMsg = {
|
||
id: Date.now(),
|
||
content: inputMsg || null,
|
||
images: inputImages,
|
||
time: new Date().toLocaleString(),
|
||
isAdmin: true,
|
||
userId: userStore.userInfo?.user_id,
|
||
avatar: userStore.userInfo?.cover_url || '',
|
||
isTempMessage: true
|
||
}
|
||
messages.value.push(tempMsg)
|
||
await nextTick()
|
||
scrollToBottom()
|
||
|
||
const res = await replyTicket(workId, content, fileIds.join(','))
|
||
|
||
if (res.code === 200) {
|
||
messages.value = messages.value.filter(msg => !msg.isTempMessage)
|
||
await fetchTicketDetail()
|
||
ElMessage.success('回复成功')
|
||
} else {
|
||
messages.value = messages.value.filter(msg => !msg.isTempMessage)
|
||
messageInput.value = inputMsg
|
||
selectedImages.value = inputImages
|
||
selectedFiles.value = inputFiles
|
||
ElMessage.error(res.message || '发送失败')
|
||
}
|
||
} catch (error) {
|
||
console.error('发送消息出错:', error)
|
||
ElMessage.error('网络错误,请稍后重试')
|
||
messages.value = messages.value.filter(msg => !msg.isTempMessage)
|
||
} finally {
|
||
isSending.value = false
|
||
}
|
||
}
|
||
|
||
// 修改工单状态
|
||
const handleStatusChange = async (newStatus) => {
|
||
const statusMap = {
|
||
'pending': 0,
|
||
'processing': 1,
|
||
'replied': 2,
|
||
'completed': 3
|
||
}
|
||
|
||
const oldStatus = ticketInfo.value.status
|
||
|
||
try {
|
||
const formData = new FormData()
|
||
formData.append('work_id', route.query.id)
|
||
formData.append('Status', statusMap[newStatus])
|
||
|
||
const res = await updateTicketInfo(formData)
|
||
|
||
if (res.code === 200) {
|
||
ElMessage.success('工单状态已更新')
|
||
ticketInfo.value.status = newStatus
|
||
} else {
|
||
ElMessage.error(res.message || '更新失败')
|
||
ticketInfo.value.status = oldStatus // 恢复原状态
|
||
}
|
||
} catch (error) {
|
||
console.error('更新工单状态出错:', error)
|
||
ElMessage.error('网络错误,请稍后重试')
|
||
ticketInfo.value.status = oldStatus // 恢复原状态
|
||
}
|
||
}
|
||
|
||
// 结束工单
|
||
const handleComplete = () => {
|
||
ElMessageBox.confirm('确定要结束此工单吗?结束后将无法继续回复。', '确认操作', {
|
||
confirmButtonText: '确定',
|
||
cancelButtonText: '取消',
|
||
type: 'warning'
|
||
}).then(async () => {
|
||
try {
|
||
const res = await closeTicket(route.query.id)
|
||
if (res.code === 200) {
|
||
ElMessage.success('工单已成功结束')
|
||
ticketInfo.value.status = 'completed'
|
||
} else {
|
||
ElMessage.error(res.message || '结束工单失败')
|
||
}
|
||
} catch (error) {
|
||
ElMessage.error('网络错误,请稍后重试')
|
||
}
|
||
}).catch(() => {})
|
||
}
|
||
|
||
// 图片处理
|
||
const handleFileChange = (file) => {
|
||
if (!file) return
|
||
addImageFile(file.raw)
|
||
}
|
||
|
||
// 添加图片文件(统一处理函数)
|
||
const addImageFile = (file) => {
|
||
// 验证文件类型
|
||
if (!file.type.startsWith('image/')) {
|
||
ElMessage.warning('只支持图片格式')
|
||
return
|
||
}
|
||
|
||
// 验证文件大小(限制10MB)
|
||
if (file.size > 10 * 1024 * 1024) {
|
||
ElMessage.warning('图片大小不能超过10MB')
|
||
return
|
||
}
|
||
|
||
// 保存原始文件对象用于上传
|
||
selectedFiles.value.push(file)
|
||
|
||
// 读取文件用于预览
|
||
const reader = new FileReader()
|
||
reader.onload = (e) => selectedImages.value.push(e.target.result)
|
||
reader.readAsDataURL(file)
|
||
}
|
||
|
||
// 处理粘贴事件
|
||
const handlePaste = (e) => {
|
||
const items = e.clipboardData?.items
|
||
if (!items) return
|
||
|
||
for (let i = 0; i < items.length; i++) {
|
||
const item = items[i]
|
||
if (item.type.indexOf('image') !== -1) {
|
||
e.preventDefault()
|
||
const file = item.getAsFile()
|
||
if (file) {
|
||
addImageFile(file)
|
||
ElMessage.success('图片已添加')
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 处理拖拽进入
|
||
const handleDragOver = (e) => {
|
||
isDragOver.value = true
|
||
}
|
||
|
||
// 处理拖拽离开
|
||
const handleDragLeave = (e) => {
|
||
isDragOver.value = false
|
||
}
|
||
|
||
// 处理文件拖放
|
||
const handleDrop = (e) => {
|
||
isDragOver.value = false
|
||
|
||
const files = e.dataTransfer?.files
|
||
if (!files || files.length === 0) return
|
||
|
||
let addedCount = 0
|
||
for (let i = 0; i < files.length; i++) {
|
||
const file = files[i]
|
||
if (file.type.startsWith('image/')) {
|
||
addImageFile(file)
|
||
addedCount++
|
||
}
|
||
}
|
||
|
||
if (addedCount > 0) {
|
||
ElMessage.success(`已添加 ${addedCount} 张图片`)
|
||
} else {
|
||
ElMessage.warning('未找到图片文件')
|
||
}
|
||
}
|
||
|
||
const removeImage = (index) => {
|
||
selectedImages.value.splice(index, 1)
|
||
selectedFiles.value.splice(index, 1)
|
||
}
|
||
|
||
const openImage = (img) => {
|
||
currentViewImage.value = img
|
||
imageViewerVisible.value = true
|
||
}
|
||
|
||
// 编辑消息
|
||
const handleEditMessage = (message) => {
|
||
editingMessage.value = message
|
||
editMessageContent.value = message.content || ''
|
||
editMessageImages.value = message.images ? [...message.images] : []
|
||
editOriginalFileIds.value = message.fileIds ? [...message.fileIds] : []
|
||
editMessageFiles.value = []
|
||
editDialogVisible.value = true
|
||
}
|
||
|
||
// 处理编辑对话框中的文件选择
|
||
const handleEditFileChange = (file) => {
|
||
if (!file) return
|
||
|
||
// 验证文件类型
|
||
if (!file.raw.type.startsWith('image/')) {
|
||
ElMessage.warning('只支持图片格式')
|
||
return
|
||
}
|
||
|
||
// 验证文件大小(限制10MB)
|
||
if (file.raw.size > 10 * 1024 * 1024) {
|
||
ElMessage.warning('图片大小不能超过10MB')
|
||
return
|
||
}
|
||
|
||
// 保存原始文件对象用于上传
|
||
editMessageFiles.value.push(file.raw)
|
||
|
||
// 读取文件用于预览
|
||
const reader = new FileReader()
|
||
reader.onload = (e) => editMessageImages.value.push(e.target.result)
|
||
reader.readAsDataURL(file.raw)
|
||
}
|
||
|
||
// 删除编辑对话框中的图片
|
||
const removeEditImage = (index) => {
|
||
const originalImagesCount = editingMessage.value?.images?.length || 0
|
||
|
||
// 如果删除的是原始图片,从原始文件ID列表中删除
|
||
if (index < originalImagesCount) {
|
||
editOriginalFileIds.value.splice(index, 1)
|
||
} else {
|
||
// 如果删除的是新添加的图片,从新文件列表中删除
|
||
const fileIndex = index - originalImagesCount
|
||
if (fileIndex >= 0 && fileIndex < editMessageFiles.value.length) {
|
||
editMessageFiles.value.splice(fileIndex, 1)
|
||
}
|
||
}
|
||
|
||
// 从预览列表中删除
|
||
editMessageImages.value.splice(index, 1)
|
||
}
|
||
|
||
// 保存编辑的消息
|
||
const saveEditMessage = async () => {
|
||
if (!editMessageContent.value.trim()) {
|
||
ElMessage.warning('消息内容不能为空')
|
||
return
|
||
}
|
||
|
||
if (!editingMessage.value || !editingMessage.value.id) {
|
||
ElMessage.error('无法获取消息ID')
|
||
return
|
||
}
|
||
|
||
try {
|
||
isEditingSaving.value = true
|
||
|
||
// 如果有新添加的图片,先上传
|
||
let newFileIds = []
|
||
if (editMessageFiles.value.length > 0) {
|
||
try {
|
||
const formData = new FormData()
|
||
|
||
editMessageFiles.value.forEach((file) => {
|
||
formData.append('files', file)
|
||
formData.append('file_names', file.name)
|
||
})
|
||
|
||
formData.append('update_type', 'work_order')
|
||
formData.append('open_down', 'true')
|
||
|
||
const uploadRes = await uploadFile(formData)
|
||
|
||
if (uploadRes.data?.code === 200) {
|
||
const data = uploadRes.data.data
|
||
if (Array.isArray(data)) {
|
||
newFileIds = data.map(item => String(item.id))
|
||
} else if (data.id) {
|
||
newFileIds = [String(data.id)]
|
||
}
|
||
} else {
|
||
ElMessage.error(uploadRes.data?.message || '图片上传失败')
|
||
isEditingSaving.value = false
|
||
return
|
||
}
|
||
} catch (error) {
|
||
console.error('图片上传失败:', error)
|
||
ElMessage.error('图片上传失败,请重试')
|
||
isEditingSaving.value = false
|
||
return
|
||
}
|
||
}
|
||
|
||
// 合并原始文件ID和新上传的文件ID
|
||
const allFileIds = [...editOriginalFileIds.value, ...newFileIds]
|
||
|
||
const formData = new FormData()
|
||
formData.append('id', editingMessage.value.id)
|
||
formData.append('content', editMessageContent.value.trim())
|
||
formData.append('files', allFileIds.join(','))
|
||
|
||
const res = await updateTicketReplayInfo(formData)
|
||
|
||
if (res.code === 200) {
|
||
ElMessage.success('消息已更新')
|
||
editDialogVisible.value = false
|
||
|
||
// 重新获取工单详情以确保数据同步
|
||
await fetchTicketDetail(false)
|
||
} else {
|
||
ElMessage.error(res.message || '更新失败')
|
||
}
|
||
} catch (error) {
|
||
console.error('更新消息出错:', error)
|
||
ElMessage.error('网络错误,请稍后重试')
|
||
} finally {
|
||
isEditingSaving.value = false
|
||
}
|
||
}
|
||
|
||
// 快捷回复
|
||
const useQuickReply = (reply) => {
|
||
messageInput.value = reply.content
|
||
}
|
||
|
||
// 滚动到底部
|
||
const scrollToBottom = () => {
|
||
if (messagesContainer.value) {
|
||
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
|
||
}
|
||
}
|
||
|
||
// 格式化时间
|
||
const formatMessageTime = (timeStr) => {
|
||
if (!timeStr) return ''
|
||
try {
|
||
const date = new Date(timeStr)
|
||
if (isNaN(date.getTime())) return ''
|
||
|
||
const now = new Date()
|
||
const time = `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
|
||
|
||
// 判断是否是今天
|
||
const isToday = date.getFullYear() === now.getFullYear() &&
|
||
date.getMonth() === now.getMonth() &&
|
||
date.getDate() === now.getDate()
|
||
|
||
if (isToday) {
|
||
return time
|
||
}
|
||
|
||
// 判断是否是昨天
|
||
const yesterday = new Date(now)
|
||
yesterday.setDate(yesterday.getDate() - 1)
|
||
const isYesterday = date.getFullYear() === yesterday.getFullYear() &&
|
||
date.getMonth() === yesterday.getMonth() &&
|
||
date.getDate() === yesterday.getDate()
|
||
|
||
if (isYesterday) {
|
||
return `昨天 ${time}`
|
||
}
|
||
|
||
// 判断是否是本周(周一到周日)
|
||
// 获取本周一的日期(0点)
|
||
const currentDay = now.getDay() // 0-6,0是周日
|
||
const mondayOffset = currentDay === 0 ? -6 : 1 - currentDay // 周日的话往前推6天,其他往前推到周一
|
||
const monday = new Date(now)
|
||
monday.setDate(now.getDate() + mondayOffset)
|
||
monday.setHours(0, 0, 0, 0)
|
||
|
||
// 获取本周日的日期(23:59:59)
|
||
const sunday = new Date(monday)
|
||
sunday.setDate(monday.getDate() + 6)
|
||
sunday.setHours(23, 59, 59, 999)
|
||
|
||
// 判断消息日期是否在本周范围内
|
||
if (date >= monday && date <= sunday) {
|
||
const weekdays = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
|
||
return `${weekdays[date.getDay()]} ${time}`
|
||
}
|
||
|
||
// 其他情况显示完整日期时间
|
||
const year = date.getFullYear()
|
||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||
const day = String(date.getDate()).padStart(2, '0')
|
||
return `${year}-${month}-${day} ${time}`
|
||
} catch (e) {
|
||
return ''
|
||
}
|
||
}
|
||
|
||
// 返回列表
|
||
const goBack = () => {
|
||
// 关闭当前tab
|
||
tagsViewStore.delVisitedView(route)
|
||
router.push('/ticket/list')
|
||
}
|
||
|
||
// 获取用户详情
|
||
const fetchUserDetail = async () => {
|
||
if (!ticketInfo.value?.userId) return
|
||
try {
|
||
isLoadingUser.value = true
|
||
const res = await getUserInfo({ user_id: ticketInfo.value.userId })
|
||
if (res.data?.code === 200) {
|
||
userDetail.value = res.data.data
|
||
}
|
||
} catch (error) {
|
||
console.error('获取用户详情失败:', error)
|
||
} finally {
|
||
isLoadingUser.value = false
|
||
}
|
||
}
|
||
|
||
// 跳转用户详情
|
||
const goToUserDetail = () => {
|
||
if (ticketInfo.value?.userId) {
|
||
router.push({ path: '/user/detail', query: { user_id: ticketInfo.value.userId } })
|
||
}
|
||
}
|
||
|
||
// 定时刷新
|
||
const startAutoRefresh = () => {
|
||
refreshTimer.value = setInterval(() => {
|
||
if (ticketInfo.value?.status !== 'completed') {
|
||
fetchTicketDetail(false) // 定时刷新时不显示 loading
|
||
}
|
||
}, 10000)
|
||
}
|
||
|
||
const stopAutoRefresh = () => {
|
||
if (refreshTimer.value) {
|
||
clearInterval(refreshTimer.value)
|
||
refreshTimer.value = null
|
||
}
|
||
}
|
||
|
||
// 监听路由query变化,重新加载数据
|
||
watch(
|
||
() => route.query.id,
|
||
(newId) => {
|
||
if (newId) {
|
||
fetchTicketDetail()
|
||
}
|
||
}
|
||
)
|
||
|
||
onMounted(() => {
|
||
fetchTicketDetail()
|
||
startAutoRefresh()
|
||
|
||
// 绑定粘贴事件到原生 textarea
|
||
nextTick(() => {
|
||
const textarea = textareaRef.value?.$el?.querySelector('textarea')
|
||
if (textarea) {
|
||
textarea.addEventListener('paste', handlePaste)
|
||
}
|
||
})
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
stopAutoRefresh()
|
||
|
||
// 移除粘贴事件监听
|
||
const textarea = textareaRef.value?.$el?.querySelector('textarea')
|
||
if (textarea) {
|
||
textarea.removeEventListener('paste', handlePaste)
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.ticket-detail-page {
|
||
padding: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: calc(100vh - 100px);
|
||
min-height: 600px;
|
||
}
|
||
|
||
.page-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
background: #fff;
|
||
padding: 16px 20px;
|
||
border-bottom: 1px solid #ebeef5;
|
||
}
|
||
|
||
.header-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
}
|
||
|
||
.header-right {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.ticket-id {
|
||
font-weight: 500;
|
||
color: #303133;
|
||
font-size: 14px;
|
||
white-space: nowrap;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.user-info {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
|
||
.user-info.clickable {
|
||
cursor: pointer;
|
||
padding: 4px 8px;
|
||
border-radius: 6px;
|
||
transition: background 0.2s;
|
||
}
|
||
|
||
.user-info.clickable:hover {
|
||
background: #f5f7fa;
|
||
}
|
||
|
||
.user-popover .popover-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.user-popover .popover-info .popover-name {
|
||
font-size: 16px;
|
||
font-weight: 500;
|
||
color: #303133;
|
||
}
|
||
|
||
.user-popover .popover-info .popover-id {
|
||
font-size: 12px;
|
||
color: #909399;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.user-popover .popover-items {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.user-popover .popover-item {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
font-size: 13px;
|
||
color: #606266;
|
||
}
|
||
|
||
.user-popover .popover-item .label {
|
||
color: #909399;
|
||
}
|
||
|
||
.user-popover .popover-item .value.highlight {
|
||
color: #e6a23c;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.user-detail .username {
|
||
font-weight: 500;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.user-detail .create-time {
|
||
font-size: 12px;
|
||
color: #909399;
|
||
}
|
||
|
||
.ticket-title {
|
||
font-size: 15px;
|
||
font-weight: 500;
|
||
color: #303133;
|
||
max-width: 300px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.chat-container {
|
||
flex: 1;
|
||
min-height: 400px;
|
||
background: #f5f7fa;
|
||
overflow: hidden;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.chat-messages {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 20px;
|
||
}
|
||
|
||
.message-item {
|
||
display: flex;
|
||
margin-bottom: 16px;
|
||
gap: 12px;
|
||
}
|
||
|
||
.message-user {
|
||
justify-content: flex-start;
|
||
}
|
||
|
||
.message-admin {
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.message-content {
|
||
max-width: 60%;
|
||
}
|
||
|
||
.message-text {
|
||
background: #fff;
|
||
padding: 12px 16px;
|
||
border-radius: 8px;
|
||
word-break: break-word;
|
||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||
position: relative;
|
||
}
|
||
|
||
.message-text .edit-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
margin-left: 8px;
|
||
padding: 4px;
|
||
cursor: pointer;
|
||
opacity: 0.6;
|
||
transition: all 0.2s;
|
||
border-radius: 4px;
|
||
vertical-align: middle;
|
||
}
|
||
|
||
.message-text .edit-btn:hover {
|
||
opacity: 1;
|
||
background: rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
.message-text:hover .edit-btn {
|
||
opacity: 1;
|
||
}
|
||
|
||
.message-admin .message-text {
|
||
background: #409eff;
|
||
color: #fff;
|
||
}
|
||
|
||
.message-admin .message-text .edit-btn {
|
||
color: #fff;
|
||
opacity: 0.7;
|
||
}
|
||
|
||
.message-admin .message-text .edit-btn:hover {
|
||
opacity: 1;
|
||
background: rgba(255, 255, 255, 0.2);
|
||
}
|
||
|
||
.message-images {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.message-image {
|
||
max-width: 200px;
|
||
max-height: 200px;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.message-time {
|
||
font-size: 12px;
|
||
color: #909399;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.message-admin .message-time {
|
||
text-align: right;
|
||
}
|
||
|
||
.reply-container {
|
||
background: #fff;
|
||
padding: 16px 20px;
|
||
border-top: 1px solid #ebeef5;
|
||
}
|
||
|
||
.upload-preview {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.preview-item {
|
||
position: relative;
|
||
width: 80px;
|
||
height: 80px;
|
||
}
|
||
|
||
.preview-item img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.delete-preview {
|
||
position: absolute;
|
||
top: -8px;
|
||
right: -8px;
|
||
width: 20px;
|
||
height: 20px;
|
||
background: #f56c6c;
|
||
color: #fff;
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.quick-replies {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.input-area {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
position: relative;
|
||
transition: all 0.3s;
|
||
}
|
||
|
||
.input-area.drag-over {
|
||
background: #f0f9ff;
|
||
border: 2px dashed #409eff;
|
||
border-radius: 4px;
|
||
padding: 8px;
|
||
}
|
||
|
||
.input-area.drag-over::before {
|
||
content: '释放以添加图片';
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
color: #409eff;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
pointer-events: none;
|
||
z-index: 1;
|
||
}
|
||
|
||
.input-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.left-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
|
||
.hint-text {
|
||
font-size: 12px;
|
||
color: #909399;
|
||
}
|
||
|
||
.closed-notice {
|
||
padding: 16px 20px;
|
||
background: #fff;
|
||
border-top: 1px solid #ebeef5;
|
||
}
|
||
|
||
.edit-images-container {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
}
|
||
|
||
.edit-preview-item {
|
||
position: relative;
|
||
width: 100px;
|
||
height: 100px;
|
||
border-radius: 4px;
|
||
overflow: hidden;
|
||
border: 1px solid #dcdfe6;
|
||
}
|
||
|
||
.edit-preview-item img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
}
|
||
|
||
.edit-preview-item .delete-preview {
|
||
position: absolute;
|
||
top: -8px;
|
||
right: -8px;
|
||
width: 24px;
|
||
height: 24px;
|
||
background: #f56c6c;
|
||
color: #fff;
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
font-size: 18px;
|
||
line-height: 1;
|
||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||
}
|
||
|
||
.add-image-btn {
|
||
width: 100px;
|
||
height: 100px;
|
||
border: 2px dashed #dcdfe6;
|
||
border-radius: 4px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
transition: all 0.3s;
|
||
color: #909399;
|
||
}
|
||
|
||
.add-image-btn:hover {
|
||
border-color: #409eff;
|
||
color: #409eff;
|
||
}
|
||
|
||
.add-image-btn .add-text {
|
||
font-size: 12px;
|
||
margin-top: 4px;
|
||
}
|
||
</style>
|