1814 lines
48 KiB
Vue
1814 lines
48 KiB
Vue
<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> |