Files
ApiServer-Web-admin_dashboa…/src/views/ticket/TicketDetail.vue
T
wlkjyy 0c6166b3c7
Build and Deploy Vue3 / build (push) Successful in 2m54s
Build and Deploy Vue3 / deploy (push) Successful in 11m17s
feat: 工单详情页增强 - 支持图片粘贴拖拽和消息编辑功能
- 支持剪贴板粘贴图片和文件拖拽上传
- 添加消息编辑功能,支持修改内容和管理图片
- 编辑对话框支持显示、删除原有图片和添加新图片
- 修复消息更新API响应检查逻辑
- 优化图片文件ID管理,支持保留和删除原始图片
2025-12-17 17:03:52 +08:00

1323 lines
35 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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-60是周日
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>