feat: 工单模块改为列表形式,点击回复进入详情页
This commit is contained in:
@@ -0,0 +1,388 @@
|
||||
<template>
|
||||
<div class="ticket-list-page">
|
||||
<!-- 统计卡片 -->
|
||||
<div class="stats-cards">
|
||||
<div class="stat-card" :class="{ active: activeStatus === '' }" @click="filterByStatus('')">
|
||||
<div class="stat-number">{{ stats.total }}</div>
|
||||
<div class="stat-label">全部工单</div>
|
||||
</div>
|
||||
<div class="stat-card pending" :class="{ active: activeStatus === 'pending' }" @click="filterByStatus('pending')">
|
||||
<div class="stat-number">{{ stats.pending }}</div>
|
||||
<div class="stat-label">待处理</div>
|
||||
</div>
|
||||
<div class="stat-card processing" :class="{ active: activeStatus === 'processing' }" @click="filterByStatus('processing')">
|
||||
<div class="stat-number">{{ stats.processing }}</div>
|
||||
<div class="stat-label">处理中</div>
|
||||
</div>
|
||||
<div class="stat-card replied" :class="{ active: activeStatus === 'replied' }" @click="filterByStatus('replied')">
|
||||
<div class="stat-number">{{ stats.replied }}</div>
|
||||
<div class="stat-label">已回复</div>
|
||||
</div>
|
||||
<div class="stat-card completed" :class="{ active: activeStatus === 'completed' }" @click="filterByStatus('completed')">
|
||||
<div class="stat-number">{{ stats.completed }}</div>
|
||||
<div class="stat-label">已完成</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索和筛选 -->
|
||||
<div class="filter-bar">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索工单号、标题、用户名"
|
||||
prefix-icon="Search"
|
||||
clearable
|
||||
style="width: 300px"
|
||||
@input="handleSearch"
|
||||
/>
|
||||
<el-button type="primary" icon="Refresh" @click="refreshList">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 工单表格 -->
|
||||
<el-table
|
||||
v-loading="isLoading"
|
||||
:data="filteredTickets"
|
||||
stripe
|
||||
style="width: 100%"
|
||||
@row-click="handleRowClick"
|
||||
>
|
||||
<el-table-column prop="id" label="工单号" width="100" />
|
||||
<el-table-column label="用户" width="180">
|
||||
<template #default="{ row }">
|
||||
<div class="user-info">
|
||||
<el-avatar :size="32" :src="row.avatar">{{ row.username?.charAt(0) }}</el-avatar>
|
||||
<span class="username">{{ row.username }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="title" label="工单标题" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStatusType(row.status)" size="small">
|
||||
{{ getStatusText(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createTime" label="创建时间" width="180" />
|
||||
<el-table-column prop="lastReplyTime" label="最后回复" width="180" />
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" size="small" @click.stop="goToDetail(row)">
|
||||
回复
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.status !== 'completed'"
|
||||
type="success"
|
||||
size="small"
|
||||
@click.stop="handleComplete(row)"
|
||||
>
|
||||
结束
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-wrapper">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="totalCount"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
getTickerList,
|
||||
closeTicket,
|
||||
getTicketCount
|
||||
} from '@/api/ticket'
|
||||
import notificationSound from '@/assets/7.wav'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 分页
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const totalCount = ref(0)
|
||||
const isLoading = ref(false)
|
||||
|
||||
// 工单数据
|
||||
const ticketList = ref([])
|
||||
const searchKeyword = ref('')
|
||||
const activeStatus = ref('')
|
||||
|
||||
// 统计数据
|
||||
const stats = reactive({
|
||||
pending: 0,
|
||||
processing: 0,
|
||||
replied: 0,
|
||||
completed: 0,
|
||||
total: 0
|
||||
})
|
||||
|
||||
// 定时刷新
|
||||
const refreshTimer = ref(null)
|
||||
const refreshInterval = 10000
|
||||
const previousPendingCount = ref(0)
|
||||
const audio = new Audio(notificationSound)
|
||||
|
||||
// 状态转换
|
||||
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 fetchTicketList = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
let statusParam = ''
|
||||
if (activeStatus.value) {
|
||||
const statusMap = { pending: '0', processing: '1', replied: '2', completed: '3' }
|
||||
statusParam = statusMap[activeStatus.value] || ''
|
||||
}
|
||||
|
||||
const res = await getTickerList(pageSize.value, currentPage.value, statusParam)
|
||||
|
||||
if (res.code === 200) {
|
||||
ticketList.value = (res.data.data || []).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)
|
||||
}))
|
||||
totalCount.value = res.data.all_count || 0
|
||||
} else {
|
||||
ElMessage.error(res.message || '获取工单列表失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取工单列表出错:', error)
|
||||
ElMessage.error('网络错误,请稍后重试')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取统计数据
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const res = await getTicketCount()
|
||||
if (res.code === 200) {
|
||||
const data = res.data
|
||||
if (data.wait_count > previousPendingCount.value && previousPendingCount.value !== 0) {
|
||||
audio.play().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
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取统计数据出错:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤后的工单列表
|
||||
const filteredTickets = computed(() => {
|
||||
if (!searchKeyword.value) return ticketList.value
|
||||
const keyword = searchKeyword.value.toLowerCase()
|
||||
return ticketList.value.filter(ticket =>
|
||||
ticket.title.toLowerCase().includes(keyword) ||
|
||||
ticket.username.toLowerCase().includes(keyword) ||
|
||||
String(ticket.id).includes(keyword)
|
||||
)
|
||||
})
|
||||
|
||||
// 按状态过滤
|
||||
const filterByStatus = (status) => {
|
||||
if (activeStatus.value === status) return
|
||||
activeStatus.value = status
|
||||
currentPage.value = 1
|
||||
fetchTicketList()
|
||||
}
|
||||
|
||||
// 搜索处理
|
||||
const handleSearch = () => {}
|
||||
|
||||
// 分页处理
|
||||
const handleSizeChange = () => {
|
||||
currentPage.value = 1
|
||||
fetchTicketList()
|
||||
}
|
||||
|
||||
const handlePageChange = () => {
|
||||
fetchTicketList()
|
||||
}
|
||||
|
||||
// 刷新列表
|
||||
const refreshList = () => {
|
||||
fetchTicketList()
|
||||
fetchStats()
|
||||
}
|
||||
|
||||
// 跳转到详情页
|
||||
const goToDetail = (row) => {
|
||||
router.push({ path: '/ticket/detail', query: { id: row.id } })
|
||||
}
|
||||
|
||||
const handleRowClick = (row) => {
|
||||
goToDetail(row)
|
||||
}
|
||||
|
||||
// 结束工单
|
||||
const handleComplete = (ticket) => {
|
||||
ElMessageBox.confirm('确定要结束此工单吗?结束后将无法继续回复。', '确认操作', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(async () => {
|
||||
try {
|
||||
const res = await closeTicket(ticket.id)
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('工单已成功结束')
|
||||
refreshList()
|
||||
} else {
|
||||
ElMessage.error(res.message || '结束工单失败')
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('网络错误,请稍后重试')
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// 定时刷新
|
||||
const startAutoRefresh = () => {
|
||||
refreshTimer.value = setInterval(() => {
|
||||
fetchStats()
|
||||
}, refreshInterval)
|
||||
}
|
||||
|
||||
const stopAutoRefresh = () => {
|
||||
if (refreshTimer.value) {
|
||||
clearInterval(refreshTimer.value)
|
||||
refreshTimer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchTicketList()
|
||||
fetchStats()
|
||||
startAutoRefresh()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopAutoRefresh()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ticket-list-page {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.stats-cards {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
flex: 1;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.stat-card.active {
|
||||
border-color: #409eff;
|
||||
}
|
||||
|
||||
.stat-card.pending .stat-number { color: #e6a23c; }
|
||||
.stat-card.processing .stat-number { color: #409eff; }
|
||||
.stat-card.replied .stat-number { color: #909399; }
|
||||
.stat-card.completed .stat-number { color: #67c23a; }
|
||||
|
||||
.stat-number {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
background: #fff;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.username {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
:deep(.el-table) {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
:deep(.el-table tr) {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user