Files
ApiServer-Web-admin_dashboa…/src/views/ticket/TicketChat.vue
T

1814 lines
48 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="chat-container">
<!-- 左侧工单列表 -->
<div class="ticket-list">
<div class="list-header">
<el-input placeholder="搜索工单" v-model="searchKeyword" prefix-icon="Search" clearable @input="handleSearch" />
</div>
<div class="ticket-stats">
<div class="stat-item" :class="{ active: activeStatus === '' }" @click="filterByStatus('')">
全部 ({{ stats.total }})
</div>
<div class="stat-item" :class="{ active: activeStatus === 'pending' }" @click="filterByStatus('pending')">
待处理 ({{ stats.pending }})
</div>
<div class="stat-item" :class="{ active: activeStatus === 'processing' }" @click="filterByStatus('processing')">
处理中 ({{ stats.processing }})
</div>
<div class="stat-item" :class="{ active: activeStatus === 'replied' }" @click="filterByStatus('replied')">
已回复 ({{ stats.replied }})
</div>
<div class="stat-item" :class="{ active: activeStatus === 'completed' }" @click="filterByStatus('completed')">
已完成 ({{ stats.completed }})
</div>
</div>
<div class="ticket-items" @scroll="handleScroll">
<div
v-for="ticket in filteredTickets"
:key="ticket.id"
class="ticket-item"
:class="{ active: currentTicket && currentTicket.id === ticket.id }"
@click="selectTicket(ticket)"
>
<div class="ticket-avatar">
<el-avatar :size="40" :src="ticket.avatar">{{ ticket.username.charAt(0) }}</el-avatar>
</div>
<div class="ticket-content">
<div class="ticket-top">
<div class="ticket-name">{{ ticket.username }}</div>
<div class="ticket-time">{{ formatTime(ticket.lastReplyTime || ticket.createTime) }}</div>
</div>
<div class="ticket-bottom">
<div class="ticket-message">{{ ticket.title }}</div>
<div class="ticket-badge" v-if="ticket.status === 'pending'">
<el-badge value="新" type="danger" />
</div>
</div>
</div>
</div>
<div v-if="isLoading" class="loading-more">
<el-icon class="is-loading"><Loading /></el-icon>
<span>加载中...</span>
</div>
<div v-if="!hasMore && ticketList.length > 0" class="no-more">
没有更多工单了
</div>
</div>
</div>
<!-- 右侧聊天内容 -->
<div class="chat-content" v-if="currentTicket">
<div class="chat-header">
<div class="ticket-info">
<div class="ticket-title">{{ currentTicket.title }}</div>
<div class="ticket-id">工单号: {{ currentTicket.id }}</div>
</div>
<div class="ticket-actions">
<el-tag :type="getStatusType(currentTicket.status)" size="small">
{{ getStatusText(currentTicket.status) }}
</el-tag>
<el-button
v-if="currentTicket.status !== 'completed'"
type="success"
size="small"
@click="handleComplete(currentTicket)"
>
结束工单
</el-button>
</div>
</div>
<div class="chat-messages" ref="messagesContainer">
<div class="chat-date">{{ formatDate(currentTicket.createTime) }}</div>
<!-- 显示加载中 -->
<div v-if="isLoadingMessages" class="messages-loading">
<el-icon class="is-loading"><Loading /></el-icon>
<span>聊天记录加载中...</span>
</div>
<div
v-else
v-for="(message, index) in currentMessages"
:key="index"
:class="['message-item', message.isAdmin ? 'message-admin' : message.isSystem ? 'message-system' : 'message-user']"
>
<div class="message-avatar" v-if="!message.isAdmin && !message.isSystem">
<el-avatar :size="36" :src="message.avatar">
{{ message.userId === currentTicket.userId ? currentTicket.username.charAt(0) : 'U' }}
</el-avatar>
</div>
<div class="message-content">
<div class="message-text" v-if="message.content">
{{ message.content }}
<i class="el-icon-loading" v-if="message.isLoading"></i>
</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 && !message.isSystem">
<el-avatar :size="36" :src="message.avatar">A</el-avatar>
</div>
</div>
</div>
<div class="chat-input-container">
<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" v-if="currentTicket.status !== 'completed'">
<el-button
v-for="(reply, index) in quickReplies"
:key="index"
size="small"
@click="useQuickReply(reply)"
>
{{ reply.title }}
</el-button>
</div>
<div class="input-area">
<el-input
v-model="messageInput"
:disabled="currentTicket.status === 'completed'"
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 class="upload-btn" type="primary" plain icon="Plus" :disabled="currentTicket.status === 'completed'">
图片
</el-button>
</el-upload>
</div>
<span class="hint-text">Ctrl + Enter 发送</span>
<el-button
type="primary"
:disabled="currentTicket.status === 'completed' || (!messageInput.trim() && selectedImages.length === 0)"
@click="sendMessage"
>
发送
</el-button>
</div>
</div>
</div>
</div>
<!-- 未选择工单时的默认界面 -->
<div class="chat-placeholder" v-else>
<el-empty description="请选择一个工单进行处理" />
</div>
<!-- 图片查看器 -->
<el-dialog v-model="imageViewerVisible" :show-close="true" width="auto" destroy-on-close class="image-viewer-dialog">
<div class="image-viewer-container">
<img :src="currentViewImage" class="viewer-image" />
</div>
<template #footer>
<div class="image-viewer-footer">
<el-button @click="imageViewerVisible = false">关闭</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, nextTick, watch, onBeforeUnmount, onActivated, onDeactivated } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Plus, Loading } from '@element-plus/icons-vue'
import { useRoute, useRouter } from 'vue-router'
import {
getTickerList,
getTicketDetail,
replyTicket,
closeTicket,
getUserAvatar,
getFileImage,
parseFilesToImages,
getTicketCount
} from '@/api/ticket'
import notificationSound from '@/assets/7.wav'
import { useUserStore } from '@/store/userStore'
// 路由相关
const route = useRoute()
const router = useRouter()
// 用户 store
const userStore = useUserStore()
// 头像
const adminAvatar = ref('')
const userAvatar = ref('')
// 分页和加载更多
const currentPage = ref(1)
const pageSize = ref(10)
const isLoading = ref(false)
const hasMore = ref(true)
// 定时刷新的定时器
const refreshTimer = ref(null)
const refreshInterval = 5000 // 5秒刷新一次
// 页面可见性状态
const isPageVisible = ref(true)
// 当前路由路径,用于检测路由变化
const currentRoutePath = ref(route.path)
// 工单数据
const ticketList = ref([])
const currentTicket = ref(null)
const selectedTicketId = ref('')
// 消息输入
const messageInput = ref('')
const messagesContainer = ref(null)
// 图片相关
const selectedImages = ref([])
const imageViewerVisible = ref(false)
const currentViewImage = ref('')
// 搜索与过滤
const searchKeyword = ref('')
const activeStatus = ref('')
// 当前工单的消息列表
const currentMessages = ref([])
const isLoadingMessages = ref(false) // 添加消息加载状态
// 统计数据
const stats = reactive({
pending: 0,
processing: 0,
replied: 0,
completed: 0,
total: 0,
isLoadingStats: false
})
// 上一次的待处理数量,用于判断是否有新工单
const previousPendingCount = ref(0)
// 音频对象
const audio = new Audio(notificationSound)
// 快捷回复选项
const quickReplies = ref([
{ title: '您好,有什么可以帮助您的?', content: '您好,有什么可以帮助您的?' },
{ title: '正在处理中', content: '您的问题正在处理中,请稍等片刻,我们会尽快给您答复。' },
{ title: '需要更多信息', content: '为了更好地解决您的问题,请您提供更多相关信息。' },
{ title: '问题已解决', content: '您的问题已解决,感谢您的反馈。如有其他问题,请随时联系我们。' },
{ title: '稍后回复', content: '我们需要一些时间核实相关信息,稍后会给您详细回复。' }
])
// 处理图片上传
const handleFileChange = (file) => {
if (!file) return
// 使用FileReader读取文件为base64
const reader = new FileReader()
reader.onload = (e) => {
selectedImages.value.push(e.target.result)
}
reader.readAsDataURL(file.raw)
}
// 移除选择的图片
const removeImage = (index) => {
selectedImages.value.splice(index, 1)
}
// 查看大图
const openImage = (img) => {
currentViewImage.value = img
imageViewerVisible.value = true
}
// 获取工单列表
const fetchTicketList = async (append = false) => {
if (isLoading.value) return
try {
isLoading.value = true
// 使用当前选中的状态值
let statusParam = ''
if (activeStatus.value) {
if (activeStatus.value === 'pending') statusParam = '0'
else if (activeStatus.value === 'processing') statusParam = '1'
else if (activeStatus.value === 'replied') statusParam = '2'
else if (activeStatus.value === 'completed') statusParam = '3'
}
const res = await getTickerList(pageSize.value, currentPage.value, statusParam)
if (res.code === 200) {
const tickets = res.data.data || []
// 转换API返回的数据到需要的格式
const mappedTickets = tickets.map(item => ({
id: item.work_id,
title: item.name,
username: item.user?.userName || `用户${item.user?.userId || 'Unknown'}`,
userId: item.user?.userId,
avatar: item.user?.coverUrl || '',
createTime: new Date(item.created_at).toLocaleString(),
lastReplyTime: new Date(item.update_time).toLocaleString(),
status: convertStatusToString(item.status),
content: item.name
}))
if (append) {
// 翻页时:合并列表,使用work_id去重,然后按work_id从大到小排序
const existingIds = new Set(ticketList.value.map(t => t.id))
const newTickets = mappedTickets.filter(t => !existingIds.has(t.id))
const mergedTickets = [...ticketList.value, ...newTickets]
// 按work_id从大到小排序
ticketList.value = mergedTickets.sort((a, b) => {
const idA = typeof a.id === 'string' ? parseInt(a.id) || 0 : a.id
const idB = typeof b.id === 'string' ? parseInt(b.id) || 0 : b.id
return idB - idA
})
} else {
// 首次加载或切换类别:直接使用新数据,按work_id从大到小排序
ticketList.value = mappedTickets.sort((a, b) => {
const idA = typeof a.id === 'string' ? parseInt(a.id) || 0 : a.id
const idB = typeof b.id === 'string' ? parseInt(b.id) || 0 : b.id
return idB - idA
})
}
hasMore.value = ticketList.value.length < res.data.all_count
} else {
ElMessage.error(res.message || '获取工单列表失败')
}
} catch (error) {
console.error('获取工单列表出错:', error)
ElMessage.error('网络错误,请稍后重试')
} finally {
isLoading.value = false
}
}
// 获取所有状态的工单数量
const fetchAllStats = async () => {
stats.isLoadingStats = true
try {
const res = await getTicketCount()
if (res.code === 200) {
const data = res.data
// 检查是否有新工单(待处理数量增加)
if (data.wait_count > previousPendingCount.value && previousPendingCount.value !== 0) {
try {
audio.play().catch(e => console.error('播放提示音失败:', e))
} catch (e) {
console.error('播放提示音出错:', e)
}
}
// 更新上一次的数量
previousPendingCount.value = data.wait_count
stats.total = data.all_count
stats.pending = data.wait_count
stats.replied = data.reply_count
stats.completed = data.close_count
// 计算处理中的数量:总数 - 待处理 - 已回复 - 已完成
stats.processing = data.all_count - data.wait_count - data.reply_count - data.close_count
} else {
console.error('获取工单统计失败:', res.message)
}
} catch (error) {
console.error('获取工单统计数据出错:', error)
} finally {
stats.isLoadingStats = false
}
}
// 刷新统计数据(用于定时刷新)
const fetchCurrentStatusStat = async () => {
await fetchAllStats()
}
// 加载更多工单
const loadMoreTickets = () => {
if (!hasMore.value || isLoading.value) return
currentPage.value++
fetchTicketList(true)
}
// 状态码转换为字符串
const convertStatusToString = (status) => {
const statusMap = {
0: 'pending',
1: 'processing',
2: 'replied',
3: 'completed'
}
return statusMap[status] || 'processing'
}
// 过滤后的工单列表
const filteredTickets = computed(() => {
let result = [...ticketList.value]
// 按状态过滤
if (activeStatus.value) {
result = result.filter(ticket => ticket.status === activeStatus.value)
}
// 按关键词搜索
if (searchKeyword.value) {
const keyword = searchKeyword.value.toLowerCase()
result = result.filter(ticket =>
ticket.title.toLowerCase().includes(keyword) ||
ticket.username.toLowerCase().includes(keyword) ||
String(ticket.id).toLowerCase().includes(keyword)
)
}
// 按work_id从大到小排序(优先显示待处理)
return result.sort((a, b) => {
// 优先显示待处理
if (a.status === 'pending' && b.status !== 'pending') return -1
if (a.status !== 'pending' && b.status === 'pending') return 1
// 然后按work_id从大到小排序
const idA = typeof a.id === 'string' ? parseInt(a.id) || 0 : a.id
const idB = typeof b.id === 'string' ? parseInt(b.id) || 0 : b.id
return idB - idA
})
})
// 判断是否是当前登录的管理员
const isAdmin = (userId) => {
return userId === userStore.userInfo?.user_id
}
// 状态转换
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 selectTicket = async (ticket) => {
if (selectedTicketId.value === ticket.id && currentTicket.value) {
return // 如果点击的是当前已选中工单,不做处理
}
currentTicket.value = ticket
selectedTicketId.value = ticket.id
// 获取工单的聊天记录
await fetchTicketMessages(ticket.id)
// 滚动到底部
nextTick(() => {
scrollToBottom()
})
}
// 获取工单的聊天记录
const fetchTicketMessages = async (workId) => {
try {
isLoadingMessages.value = true // 设置消息加载状态为true
currentMessages.value = [] // 清空当前消息列表
const res = await getTicketDetail(workId)
if (res.code === 200) {
const detail = res.data
// 更新当前工单信息,但不改变待处理状态
if (currentTicket.value) {
// 只有非待处理状态才直接更新,待处理状态保持不变,等待回复后再更新
if (currentTicket.value.status !== 'pending') {
currentTicket.value.status = convertStatusToString(detail.Status)
}
}
// 处理消息列表
if (detail.content && detail.content.length > 0) {
// 处理所有消息
const messages = detail.content.map((msg) => {
const isAdminMsg = isAdmin(msg.user?.userId)
// 从 flies 数组中提取图片 URL
const images = msg.flies ? msg.flies.map(file => file.url) : []
return {
id: msg.id,
content: msg.content !== 'empty' ? msg.content : null,
images: images,
time: msg.created_at || msg.updated_at || new Date().toLocaleString(),
isAdmin: isAdminMsg,
isSystem: false,
userId: msg.user?.userId,
avatar: msg.user?.coverUrl || ''
}
})
currentMessages.value = messages
}
} else {
ElMessage.error(res.message || '获取工单详情失败')
}
} catch (error) {
console.error('获取工单详情出错:', error)
ElMessage.error('网络错误,请稍后重试')
} finally {
isLoadingMessages.value = false // 设置消息加载状态为false
isLoading.value = false
}
}
// 发送消息
const sendMessage = async () => {
if ((!messageInput.value.trim() && selectedImages.value.length === 0) ||
!currentTicket.value ||
currentTicket.value.status === 'completed') {
return
}
const workId = currentTicket.value.id
const content = messageInput.value.trim() || 'empty'
// 这里需要处理上传图片,暂时用模拟数据
// 实际情况中应该上传图片获取fileIds
const fileIds = []
try {
// 保存输入内容
const inputMsg = messageInput.value.trim()
const inputImages = [...selectedImages.value]
// 清空输入和已选图片
messageInput.value = ''
selectedImages.value = []
// 立即添加消息到界面(不显示 loading)
const tempMsg = {
id: Date.now(), // 临时 ID
content: inputMsg || null,
images: inputImages.length > 0 ? inputImages : [],
time: new Date().toLocaleString(),
isAdmin: true,
isSystem: false,
userId: userStore.userInfo?.user_id,
avatar: userStore.userInfo?.cover_url || '',
isTempMessage: true
}
currentMessages.value.push(tempMsg)
// 滚动到底部
await nextTick()
scrollToBottom()
// 发送消息
const res = await replyTicket(workId, content, fileIds.join(','))
if (res.code === 200) {
// 移除临时消息
currentMessages.value = currentMessages.value.filter(msg => !msg.isTempMessage)
// 重新获取消息列表,确保显示最新数据
await fetchTicketMessages(workId)
// 更新工单状态和最后回复时间
if (currentTicket.value && currentTicket.value.status === 'pending') {
currentTicket.value.status = 'processing'
updateTicketStats()
}
currentTicket.value.lastReplyTime = new Date().toLocaleString()
} else {
// 移除临时消息
currentMessages.value = currentMessages.value.filter(msg => !msg.isTempMessage)
// 恢复输入内容
messageInput.value = inputMsg
selectedImages.value = inputImages
ElMessage.error(res.message || '发送失败')
}
} catch (error) {
console.error('发送消息出错:', error)
ElMessage.error('网络错误,请稍后重试')
// 移除临时消息
currentMessages.value = currentMessages.value.filter(msg => !msg.isTempMessage)
}
}
// 使用快捷回复
const useQuickReply = (reply) => {
messageInput.value = reply.content
}
// 滚动聊天区域到底部
const scrollToBottom = () => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
}
// 结束工单
const handleComplete = (ticket) => {
ElMessageBox.confirm(
'确定要结束此工单吗?结束后将无法继续回复。',
'确认操作',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
.then(async () => {
try {
const res = await closeTicket(ticket.id)
if (res.code === 200) {
ticket.status = 'completed'
// 添加系统消息
currentMessages.value.push({
content: '工单已结束,感谢您的使用!',
time: new Date().toLocaleString(),
isAdmin: true,
isSystem: true
})
// 更新统计
updateTicketStats()
ElMessage.success('工单已成功结束')
// 滚动到底部
nextTick(() => {
scrollToBottom()
})
} else {
ElMessage.error(res.message || '结束工单失败')
}
} catch (error) {
console.error('结束工单出错:', error)
ElMessage.error('网络错误,请稍后重试')
}
})
.catch(() => {
// 用户取消操作
})
}
// 按状态过滤
const filterByStatus = (status) => {
if (activeStatus.value === status) return; // 如果点击当前已选标签,不做处理
activeStatus.value = status
currentPage.value = 1 // 切换状态后重置页码
hasMore.value = true // 重置加载更多标志
ticketList.value = [] // 清空列表,不缓存
fetchTicketList(false) // 从头重新获取数据,不追加
}
// 搜索处理
const handleSearch = () => {
// 过滤已经由计算属性处理
}
// 更新工单统计
const updateTicketStats = () => {
// 重新获取所有统计数据
fetchAllStats()
}
// 格式化消息时间
const formatMessageTime = (timeStr) => {
if (!timeStr) return ''
try {
const date = new Date(timeStr)
if (isNaN(date.getTime())) return ''
// 格式化为 HH:MM
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${hours}:${minutes}`
} catch (e) {
console.error('时间格式化失败:', e)
return ''
}
}
// 格式化列表项时间
const formatTime = (timeStr) => {
if (!timeStr) return ''; // 空值兜底
// 步骤1:解析中文时间字符串(核心适配点)
let date;
try {
// 先尝试原生解析(兼容ISO格式)
date = new Date(timeStr);
// 若原生解析失败(返回Invalid Date),解析中文格式
if (isNaN(date.getTime())) {
// 正则提取中文时间的年、月、日、时、分、秒
const cnTimeMatch = timeStr.match(
/(\d{4})年(\d{1,2})月(\d{1,2})日\s*(上午|下午)\s*(\d{1,2}):(\d{1,2}):(\d{1,2})/
);
if (cnTimeMatch) {
const [, year, month, day, period, hour, minute, second] = cnTimeMatch;
// 处理下午/上午的小时转换(12小时制转24小时制)
let hour24 = parseInt(hour, 10);
if (period === '下午' && hour24 !== 12) {
hour24 += 12;
}
if (period === '上午' && hour24 === 12) {
hour24 = 0; // 上午12点转为0点
}
// 构造日期(月份从0开始,需-1)
date = new Date(
parseInt(year, 10),
parseInt(month, 10) - 1,
parseInt(day, 10),
hour24,
parseInt(minute, 10),
parseInt(second, 10)
);
} else {
return '无效时间'; // 既不是ISO也不是中文格式
}
}
} catch (e) {
console.error('时间解析失败:', e);
return '无效时间';
}
const now = new Date();
const dateTime = date.getTime();
const nowTime = now.getTime();
const diff = nowTime - dateTime;
// 步骤2:判断“今天”(年/月/日完全一致)
const isToday = date.getFullYear() === now.getFullYear() &&
date.getMonth() === now.getMonth() &&
date.getDate() === now.getDate();
if (isToday) {
// 格式化今天的时间(24小时制,补零)
const hour = String(date.getHours()).padStart(2, '0');
const minute = String(date.getMinutes()).padStart(2, '0');
return `${hour}:${minute}`;
}
// 步骤3:判断“一周内”
const oneWeek = 7 * 24 * 60 * 60 * 1000;
if (diff < oneWeek) {
const weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
return weekdays[date.getDay()];
}
// 步骤4:格式化其他日期(补零,统一格式:YYYY/MM/DD)
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}`;
};
// 格式化日期显示
const formatDate = (timeStr) => {
console.log("原始时间字符串:", timeStr);
if (!timeStr) return ''; // 空值兜底
let date;
// 1. 先尝试原生解析(兼容ISO等标准格式)
date = new Date(timeStr);
// 2. 若原生解析失败,专门解析中文时间格式
if (isNaN(date.getTime())) {
// 正则匹配:xxxx年xx月xx日 上午/下午 xx:xx:xx
const cnTimeReg = /(\d{4})年(\d{1,2})月(\d{1,2})日\s*(上午|下午)\s*(\d{1,2}):(\d{1,2}):(\d{1,2})/;
const match = timeStr.match(cnTimeReg);
if (match) {
const [, year, month, day, period, hour, minute, second] = match;
// 处理12小时制转24小时制(关键适配)
let hour24 = parseInt(hour, 10);
if (period === '下午') {
hour24 = hour24 === 12 ? 12 : hour24 + 12; // 下午12点=12点,下午1-11点+12
} else { // 上午
hour24 = hour24 === 12 ? 0 : hour24; // 上午12点=0点,上午1-11点不变
}
// 手动构造合法的Date对象(月份从0开始,需-1)
date = new Date(
parseInt(year, 10),
parseInt(month, 10) - 1,
parseInt(day, 10),
hour24,
parseInt(minute, 10),
parseInt(second, 10)
);
} else {
return '无效时间'; // 非目标格式,返回兜底
}
}
const now = new Date();
// 3. 对比“今天”(按日期维度,忽略时分秒)
const isToday = date.getFullYear() === now.getFullYear() &&
date.getMonth() === now.getMonth() &&
date.getDate() === now.getDate();
if (isToday) {
return '今天';
}
// 4. 格式化非今天的日期(统一格式,避免环境差异)
const formattedDate = `${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')}`;
return formattedDate;
};
// 监听显示工单变化,更新消息和滚动
watch(currentTicket, (newVal) => {
if (newVal) {
nextTick(() => {
scrollToBottom()
})
}
})
// 监听列表底部,加载更多
const handleScroll = (e) => {
const element = e.target
if (element.scrollHeight - element.scrollTop - element.clientHeight < 50 && hasMore.value && !isLoading.value) {
loadMoreTickets()
}
}
// 开始定时刷新
const startAutoRefresh = () => {
// 避免创建多个定时器
stopAutoRefresh()
refreshTimer.value = setInterval(() => {
// 检查页面可见性和路由是否还在当前页面
if (!isPageVisible.value || route.path !== currentRoutePath.value) {
return
}
// 如果当前有工单选中,则刷新聊天记录
if (currentTicket.value && selectedTicketId.value) {
// 静默刷新聊天记录,不显示loading状态
refreshTicketMessages(selectedTicketId.value)
}
// 静默刷新工单列表,保持当前页码
refreshTicketList()
// 只刷新当前分类的统计数据,减少请求数量
fetchCurrentStatusStat()
}, refreshInterval)
}
// 停止定时刷新
const stopAutoRefresh = () => {
if (refreshTimer.value) {
clearInterval(refreshTimer.value)
refreshTimer.value = null
}
}
// 静默刷新工单列表(不显示loading状态)
const refreshTicketList = async () => {
try {
// 使用当前选中的状态值
let statusParam = ''
if (activeStatus.value) {
if (activeStatus.value === 'pending') statusParam = '0'
else if (activeStatus.value === 'processing') statusParam = '1'
else if (activeStatus.value === 'replied') statusParam = '2'
else if (activeStatus.value === 'completed') statusParam = '3'
}
// 刷新时只获取第一页数据,用于更新最新数据
const res = await getTickerList(pageSize.value, 1, statusParam)
if (res.code === 200) {
const tickets = res.data.data || []
// 转换API返回的数据到需要的格式
const mappedTickets = tickets.map(item => ({
id: item.work_id,
title: item.name,
username: item.user?.userName || `用户${item.user?.userId || 'Unknown'}`,
userId: item.user?.userId,
avatar: item.user?.coverUrl || '',
createTime: new Date(item.created_at).toLocaleString(),
lastReplyTime: new Date(item.update_time).toLocaleString(),
status: convertStatusToString(item.status),
content: item.name
}))
// 合并到现有列表,去重并按work_id从大到小排序
// 使用Map来更新已存在的工单信息,添加新的工单
const ticketMap = new Map()
// 先添加现有列表
ticketList.value.forEach(ticket => {
ticketMap.set(ticket.id, ticket)
})
// 更新或添加新数据(新数据会覆盖旧数据)
mappedTickets.forEach(ticket => {
ticketMap.set(ticket.id, ticket)
})
// 转换为数组并按work_id从大到小排序
ticketList.value = Array.from(ticketMap.values()).sort((a, b) => {
const idA = typeof a.id === 'string' ? parseInt(a.id) || 0 : a.id
const idB = typeof b.id === 'string' ? parseInt(b.id) || 0 : b.id
return idB - idA
})
// 不重置页码,保持用户当前的浏览位置
// 更新加载更多标志
hasMore.value = ticketList.value.length < res.data.all_count
}
} catch (error) {
console.error('刷新工单列表出错:', error)
}
}
// 辅助函数:去除 URL 中的查询参数
const normalizeUrl = (url) => {
if (!url) return ''
return url.split('?')[0]
}
// 辅助函数:比较两个消息数组是否相同(忽略 URL 查询参数)
const areMessagesEqual = (messages1, messages2) => {
if (messages1.length !== messages2.length) return false
for (let i = 0; i < messages1.length; i++) {
const msg1 = messages1[i]
const msg2 = messages2[i]
// 比较消息 ID 和内容
if (msg1.id !== msg2.id || msg1.content !== msg2.content) return false
// 比较图片数量
if ((msg1.images?.length || 0) !== (msg2.images?.length || 0)) return false
// 比较图片 URL(去除查询参数)
if (msg1.images && msg2.images) {
for (let j = 0; j < msg1.images.length; j++) {
if (normalizeUrl(msg1.images[j]) !== normalizeUrl(msg2.images[j])) {
return false
}
}
}
}
return true
}
// 静默刷新聊天记录(不显示loading状态)
const refreshTicketMessages = async (workId) => {
try {
const res = await getTicketDetail(workId)
if (res.code === 200) {
const detail = res.data
// 更新当前工单信息,但不改变待处理状态
if (currentTicket.value) {
// 只有非待处理状态才直接更新,待处理状态保持不变,等待回复后再更新
if (currentTicket.value.status !== 'pending') {
currentTicket.value.status = convertStatusToString(detail.status)
}
}
// 处理消息列表
if (detail.content && detail.content.length > 0) {
// 构建新消息列表
const newMessages = detail.content.map((msg) => {
const isAdminMsg = isAdmin(msg.user?.userId)
// 从 flies 数组中提取图片 URL
const images = msg.flies ? msg.flies.map(file => file.url) : []
return {
id: msg.id,
content: msg.content !== 'empty' ? msg.content : null,
images: images,
time: msg.created_at || msg.updated_at || new Date().toLocaleString(),
isAdmin: isAdminMsg,
isSystem: false,
userId: msg.user?.userId,
avatar: msg.user?.coverUrl || ''
}
})
// 只有在消息真正发生变化时才更新(忽略 URL 查询参数的变化)
if (!areMessagesEqual(currentMessages.value, newMessages)) {
currentMessages.value = newMessages
// 如果有新消息,滚动到底部
nextTick(() => {
scrollToBottom()
})
}
}
}
} catch (error) {
console.error('刷新聊天记录出错:', error)
}
}
// 处理页面可见性变化
const handleVisibilityChange = () => {
isPageVisible.value = !document.hidden
if (isPageVisible.value && route.path === currentRoutePath.value) {
// 页面变为可见且还在当前路由时,重新启动定时器
startAutoRefresh()
} else {
// 页面不可见或路由已改变时,停止定时器
stopAutoRefresh()
}
}
// 监听路由变化
watch(() => route.path, (newPath, oldPath) => {
if (newPath !== currentRoutePath.value) {
// 路由已经离开当前页面,立即停止所有请求
console.log('路由变化:从', oldPath, '到', newPath, ',停止工单数据请求')
stopAutoRefresh()
isPageVisible.value = false
} else if (newPath === currentRoutePath.value) {
// 路由返回到当前页面,重新启动请求
console.log('路由返回到工单页面,重新启动数据请求')
isPageVisible.value = true
startAutoRefresh()
}
}, { immediate: false })
onMounted(() => {
// 记录当前路由路径
currentRoutePath.value = route.path
// 获取工单列表
fetchTicketList()
// 独立获取所有统计数据
fetchAllStats()
// 添加滚动监听
const listElement = document.querySelector('.ticket-items')
if (listElement) {
listElement.addEventListener('scroll', handleScroll)
}
// 监听页面可见性变化
document.addEventListener('visibilitychange', handleVisibilityChange)
// 启动自动刷新
startAutoRefresh()
})
onBeforeUnmount(() => {
// 组件销毁前停止定时器
stopAutoRefresh()
// 移除滚动监听
const listElement = document.querySelector('.ticket-items')
if (listElement) {
listElement.removeEventListener('scroll', handleScroll)
}
// 移除页面可见性监听
document.removeEventListener('visibilitychange', handleVisibilityChange)
})
// 组件激活时(keep-alive场景)
onActivated(() => {
console.log('组件激活,重新启动工单数据请求')
currentRoutePath.value = route.path
isPageVisible.value = true
startAutoRefresh()
})
// 组件失活时(keep-alive场景)
onDeactivated(() => {
console.log('组件失活,停止工单数据请求')
isPageVisible.value = false
stopAutoRefresh()
})
</script>
<style scoped>
.chat-container {
display: flex;
height: calc(100vh - 100px);
background-color: #f7f7f7;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 20px 0 rgba(0, 0, 0, 0.08);
}
/* 左侧工单列表 */
.ticket-list {
width: 320px;
background-color: #fff;
border-right: 1px solid #eaeaea;
display: flex;
flex-direction: column;
}
.list-header {
padding: 16px;
border-bottom: 1px solid #eaeaea;
background-color: #fafafa;
}
.ticket-stats {
display: flex;
border-bottom: 1px solid #eaeaea;
background-color: #f9f9f9;
padding: 2px 0;
}
.stat-item {
flex: 1;
padding: 10px 4px;
text-align: center;
font-size: 13px;
color: #606266;
cursor: pointer;
transition: all 0.3s;
position: relative;
}
.stat-item:hover {
color: #409EFF;
background-color: rgba(64, 158, 255, 0.05);
}
.stat-item.active {
color: #409EFF;
font-weight: bold;
}
.stat-item.active:after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 40%;
height: 3px;
background-color: #409EFF;
border-radius: 2px;
}
.ticket-items {
flex: 1;
overflow-y: auto;
scrollbar-width: thin;
}
.ticket-items::-webkit-scrollbar {
width: 4px;
}
.ticket-items::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.2);
border-radius: 4px;
}
.ticket-item {
display: flex;
padding: 16px;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: all 0.3s;
}
.ticket-item:hover {
background-color: #f5f7fa;
}
.ticket-item.active {
background-color: #ecf5ff;
}
.ticket-avatar {
margin-right: 12px;
}
.ticket-content {
flex: 1;
overflow: hidden;
}
.ticket-top {
display: flex;
justify-content: space-between;
margin-bottom: 6px;
}
.ticket-name {
font-weight: 500;
color: #303133;
font-size: 15px;
}
.ticket-time {
color: #909399;
font-size: 12px;
}
.ticket-bottom {
display: flex;
justify-content: space-between;
}
.ticket-message {
color: #606266;
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 180px;
}
/* 右侧聊天内容 */
.chat-content, .chat-placeholder {
flex: 1;
display: flex;
flex-direction: column;
background-color: #f5f7fa;
}
.chat-placeholder {
justify-content: center;
align-items: center;
background-color: #F1F1F1;
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4QceDiok7ULvrQAAAB1pVFh0Q29tbWVudAAAAAAAQ3JlYXRlZCB3aXRoIEdJTVBkLmUHAAAANElEQVQ4y2NgGAVDAHyz8v9TNujtnD9qIE0M/EcLA1FDKQ/+09LA0VAc4Ab+o6WBo2AIAQBbtS1tCcO9TAAAAABJRU5ErkJggg==');
}
.chat-header {
padding: 16px 20px;
display: flex;
justify-content: space-between;
align-items: center;
background-color: #fff;
border-bottom: 1px solid #eaeaea;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.03);
}
.ticket-info {
display: flex;
flex-direction: column;
}
.ticket-title {
font-size: 16px;
font-weight: bold;
color: #303133;
margin-bottom: 4px;
}
.ticket-id {
font-size: 12px;
color: #909399;
}
.ticket-actions {
display: flex;
gap: 12px;
align-items: center;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 20px;
background-color: #F1F1F1;
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4QceDiok7ULvrQAAAB1pVFh0Q29tbWVudAAAAAAAQ3JlYXRlZCB3aXRoIEdJTVBkLmUHAAAANElEQVQ4y2NgGAVDAHyz8v9TNujtnD9qIE0M/EcLA1FDKQ/+09LA0VAc4Ab+o6WBo2AIAQBbtS1tCcO9TAAAAABJRU5ErkJggg==');
scrollbar-width: thin;
}
.chat-messages::-webkit-scrollbar {
width: 6px;
}
.chat-messages::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.2);
border-radius: 6px;
}
.chat-date {
text-align: center;
margin: 16px 0;
color: #909399;
font-size: 12px;
background-color: rgba(245, 247, 250, 0.8);
padding: 6px 16px;
border-radius: 16px;
display: inline-block;
margin: 16px auto;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.message-item {
display: flex;
margin-bottom: 24px;
align-items: flex-start;
animation: fadeIn 0.3s ease-in-out;
}
.message-user {
justify-content: flex-start;
}
.message-admin {
justify-content: flex-end;
}
.message-avatar {
margin: 0 12px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
border-radius: 50%;
}
.message-content {
max-width: 70%;
border-radius: 4px;
padding: 12px 16px;
position: relative;
word-break: break-word;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06);
}
.message-images {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 8px 0;
}
.message-image {
max-width: 200px;
max-height: 150px;
border-radius: 8px;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
object-fit: cover;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
.message-image:hover {
transform: scale(1.03);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.message-user .message-content {
background-color: #fff;
margin-right: auto;
border-radius: 0px 12px 12px 12px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05);
}
.message-user .message-content:before {
content: '';
position: absolute;
top: 0;
left: -8px;
width: 0;
height: 0;
border-top: 8px solid #fff;
border-left: 8px solid transparent;
}
.message-admin .message-content {
background-color: #95ec69;
margin-left: auto;
border-radius: 12px 0px 12px 12px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05);
}
.message-admin .message-content:before {
content: '';
position: absolute;
top: 0;
right: -8px;
width: 0;
height: 0;
border-top: 8px solid #95ec69;
border-right: 8px solid transparent;
}
.message-text {
word-break: break-word;
line-height: 1.6;
font-size: 14px;
}
.message-time {
text-align: right;
font-size: 11px;
color: #909399;
margin-top: 6px;
}
.chat-input-container {
padding: 16px 20px;
background-color: #fff;
border-top: 1px solid #eaeaea;
box-shadow: 0 -2px 6px rgba(0, 0, 0, 0.03);
}
.upload-preview {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 12px;
padding: 10px;
background-color: #f9f9f9;
border-radius: 8px;
border: 1px dashed #dcdfe6;
}
.preview-item {
position: relative;
width: 80px;
height: 80px;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: transform 0.2s;
}
.preview-item:hover {
transform: scale(1.05);
}
.preview-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
.delete-preview {
position: absolute;
top: 4px;
right: 4px;
width: 20px;
height: 20px;
background-color: rgba(0, 0, 0, 0.6);
color: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 14px;
transition: background-color 0.2s;
}
.delete-preview:hover {
background-color: rgba(255, 0, 0, 0.7);
}
.quick-replies {
margin-bottom: 12px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.quick-replies .el-button {
transition: all 0.3s;
}
.quick-replies .el-button:hover {
transform: translateY(-2px);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
.input-area {
display: flex;
flex-direction: column;
}
.input-area .el-textarea__inner {
border-radius: 8px;
padding: 12px 14px;
resize: none;
transition: all 0.3s;
border: 1px solid #dcdfe6;
font-size: 14px;
}
.input-area .el-textarea__inner:focus {
border-color: #409EFF;
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2);
}
.input-actions {
margin-top: 12px;
display: flex;
justify-content: space-between;
align-items: center;
}
.left-actions {
display: flex;
gap: 10px;
}
.upload-btn {
margin-right: 10px;
position: relative;
overflow: hidden;
}
.upload-btn:hover {
transform: translateY(-2px);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
.hint-text {
color: #909399;
font-size: 12px;
background-color: #f5f7fa;
padding: 4px 10px;
border-radius: 4px;
}
.input-actions .el-button {
padding: 10px 20px;
font-weight: 500;
transition: all 0.3s ease;
}
.input-actions .el-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.3);
}
/* 响应式调整 */
@media (max-width: 768px) {
.chat-container {
flex-direction: column;
height: auto;
}
.ticket-list {
width: 100%;
height: 300px;
}
.chat-content, .chat-placeholder {
height: calc(100vh - 400px);
}
}
/* 图片查看器样式 */
:deep(.image-viewer-dialog .el-dialog__body) {
padding: 0;
background-color: rgba(0, 0, 0, 0.9);
border-radius: 8px;
overflow: hidden;
}
:deep(.image-viewer-dialog .el-dialog__header) {
padding: 12px 20px;
margin: 0;
background-color: rgba(0, 0, 0, 0.9);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
:deep(.image-viewer-dialog .el-dialog__headerbtn .el-dialog__close) {
color: #ffffff;
font-size: 18px;
}
:deep(.image-viewer-dialog .el-dialog__footer) {
padding: 12px 20px;
margin: 0;
background-color: rgba(0, 0, 0, 0.9);
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.image-viewer-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 200px;
max-height: 80vh;
overflow: auto;
}
.viewer-image {
max-width: 100%;
max-height: 70vh;
object-fit: contain;
transition: transform 0.3s;
}
.image-viewer-footer {
display: flex;
justify-content: center;
}
/* 美化消息气泡 */
.message-user .message-content {
position: relative;
margin-left: 6px;
}
.message-admin .message-content {
position: relative;
margin-right: 6px;
}
/* 添加更多动画效果 */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 美化工单状态标签 */
:deep(.el-tag) {
padding: 4px 10px;
font-weight: 500;
border-radius: 12px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
transition: all 0.3s;
}
:deep(.el-tag:hover) {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
/* 美化输入框获取焦点效果 */
:deep(.el-input__inner:focus),
:deep(.el-textarea__inner:focus) {
border-color: #409EFF;
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2);
}
/* 美化头像 */
:deep(.el-avatar) {
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
border: 2px solid #fff;
transition: all 0.3s;
}
.ticket-avatar :deep(.el-avatar) {
background: linear-gradient(135deg, #42b983, #33a3dc);
}
.message-admin .message-avatar :deep(.el-avatar) {
background: linear-gradient(135deg, #36d1dc, #5b86e5);
}
.message-user .message-avatar :deep(.el-avatar) {
background: linear-gradient(135deg, #ff9a9e, #fad0c4);
}
/* 美化按钮效果 */
.ticket-actions :deep(.el-button) {
transition: all 0.3s;
border-radius: 6px;
}
.ticket-actions :deep(.el-button:hover) {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
/* 系统消息样式 */
.message-item.message-system {
justify-content: center;
margin: 16px 0;
}
.message-system .message-content {
background-color: rgba(255, 255, 255, 0.7);
padding: 6px 16px;
border-radius: 16px;
max-width: 60%;
text-align: center;
color: #606266;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
border: 1px solid rgba(0, 0, 0, 0.05);
}
.message-system .message-time {
text-align: center;
margin-top: 4px;
}
/* 滚动条美化 */
.chat-container *::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.chat-container *::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.2);
border-radius: 6px;
}
.chat-container *::-webkit-scrollbar-track {
background-color: rgba(0, 0, 0, 0.05);
border-radius: 6px;
}
.loading-more, .no-more {
text-align: center;
padding: 10px;
color: #909399;
font-size: 12px;
}
.loading-more {
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
}
/* 聊天记录加载中样式 */
.messages-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 30px 0;
color: #909399;
font-size: 14px;
flex-direction: column;
gap: 10px;
background-color: rgba(255, 255, 255, 0.7);
border-radius: 8px;
margin: 20px 0;
}
.messages-loading .el-icon {
font-size: 24px;
color: #409EFF;
}
</style>