Files
ApiServer-Web-admin_dashboa…/src/views/ticket/TicketList.vue
T
shiran 475c62aefc
Build and Deploy Vue3 / build (push) Successful in 1m30s
Build and Deploy Vue3 / deploy (push) Successful in 1m15s
feat: 添加用户ID和订单ID链接跳转功能
- 在团购活动页面中为用户ID添加点击跳转到用户详情的功能
- 在团购管理页面中为用户ID添加点击跳转到用户详情的功能
- 在审核相关页面中为容器ID添加点击跳转到容器详情的功能
- 在营销相关页面中为用户ID和订单ID添加点击跳转功能
- 在订单列表页面中为用户ID和商品ID添加点击跳转功能
- 在商品列表页面中为用户和商品添加点击跳转功能
- 在工单列表页面中为用户名添加点击跳转到用户详情的功能
- 在用户虚拟机列表中为商品和用户添加点击跳转功能
- 在用户余额页面中为支付订单ID添加点击跳转到订单详情的功能
- 统一使用el-link组件实现可点击的链接样式
- 添加useRouter依赖并创建router实例用于页面跳转
2026-04-24 18:06:29 +08:00

928 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="ticket-list-page">
<!-- 顶部状态标签栏 -->
<div class="status-bar">
<div class="status-tabs">
<div class="tab-item pending" :class="{ active: activeStatus === 'pending' }" @click="filterByStatus('pending')">
待处理 <span class="count">{{ stats.pending }}</span>
</div>
<div class="tab-item processing" :class="{ active: activeStatus === 'processing' }" @click="filterByStatus('processing')">
处理中 <span class="count">{{ stats.processing }}</span>
</div>
<div class="tab-item replied" :class="{ active: activeStatus === 'replied' }" @click="filterByStatus('replied')">
已回复 <span class="count">{{ stats.replied }}</span>
</div>
<div class="tab-item completed" :class="{ active: activeStatus === 'completed' }" @click="filterByStatus('completed')">
已完成 <span class="count">{{ stats.completed }}</span>
</div>
<div class="tab-item" :class="{ active: activeStatus === '' }" @click="filterByStatus('')">
全部 <span class="count">{{ stats.total }}</span>
</div>
</div>
</div>
<!-- 筛选工具栏 -->
<div class="filter-bar">
<el-select v-model="sortBy" placeholder="排序方式" clearable style="width: 140px" @change="handleSortChange">
<el-option label="不排序" value="" />
<el-option label="创建时间" value="created_at" />
<el-option label="更新时间" value="updated_at" />
<el-option label="工单号" value="id" />
</el-select>
<el-select v-model="sortOrder" placeholder="排序顺序" clearable style="width: 100px" @change="handleSortChange">
<el-option label="默认" value="" />
<el-option label="降序" value="desc" />
<el-option label="升序" value="asc" />
</el-select>
<el-input
:model-value="selectedUser ? selectedUser.user_name : ''"
placeholder="点击选择用户筛选"
readonly
style="width: 180px; cursor: pointer"
@click="showUserDialog = true"
>
<template #prefix>
<el-icon><User /></el-icon>
</template>
<template #suffix v-if="selectedUser">
<el-icon @click.stop="clearUserFilter" style="cursor: pointer"><Close /></el-icon>
</template>
</el-input>
<el-input
v-model="searchKeyword"
placeholder="搜索工单标题/内容"
clearable
style="width: 200px"
@input="handleKeywordSearch"
@clear="handleKeywordSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button icon="Refresh" @click="refreshList">刷新</el-button>
</div>
<!-- 工单表格PC端 -->
<el-table
v-loading="isLoading"
:data="filteredTickets"
stripe
style="width: 100%"
@row-click="handleRowClick"
class="desktop-table"
>
<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>
<el-link v-if="row.userId" type="primary" :underline="false" @click.stop="router.push({ path: '/user/detail', query: { user_id: row.userId } })">{{ row.username }}</el-link>
<span v-else 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="mobile-ticket-list" v-loading="isLoading">
<div
v-for="ticket in filteredTickets"
:key="ticket.id"
class="ticket-card"
@click="goToDetail(ticket)"
>
<div class="ticket-card-header">
<span class="ticket-card-id">#{{ ticket.id }}</span>
<el-tag :type="getStatusType(ticket.status)" size="small">
{{ getStatusText(ticket.status) }}
</el-tag>
</div>
<div class="ticket-card-user">
<el-avatar :size="28" :src="ticket.avatar">{{ ticket.username?.charAt(0) }}</el-avatar>
<span class="ticket-card-username">{{ ticket.username }}</span>
</div>
<div class="ticket-card-title">{{ ticket.title }}</div>
<div class="ticket-card-footer">
<span class="ticket-card-time">{{ ticket.createTime }}</span>
<div class="ticket-card-actions">
<el-button type="primary" size="small" @click.stop="goToDetail(ticket)">回复</el-button>
<el-button
v-if="ticket.status !== 'completed'"
type="success"
size="small"
@click.stop="handleComplete(ticket)"
>结束</el-button>
</div>
</div>
</div>
<el-empty v-if="filteredTickets.length === 0 && !isLoading" description="暂无工单数据" />
</div>
<!-- 分页 -->
<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>
<!-- 用户选择对话框 -->
<el-dialog
v-model="showUserDialog"
title="选择用户"
width="600px"
destroy-on-close
>
<div class="user-dialog-content">
<el-input
v-model="userSearchKeyword"
placeholder="输入用户名/手机号/邮箱搜索"
clearable
@input="handleUserSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<div class="user-list-container" v-loading="isSearchingUser">
<!-- 调试信息 -->
<div style="padding: 8px; font-size: 12px; color: #909399; border-bottom: 1px solid #eee;">
搜索关键词: {{ userSearchKeyword }} | 用户数量: {{ userList.length }}
</div>
<div v-if="!userSearchKeyword" class="empty-hint">
请输入关键词搜索用户
</div>
<div v-else-if="userSearchKeyword && userList.length === 0 && !isSearchingUser" class="empty-hint">
未找到匹配的用户
</div>
<div v-if="userList.length > 0" class="user-list">
<div
v-for="user in userList"
:key="user.user_id"
class="user-list-item"
@click="selectUser(user)"
>
<el-avatar :size="40" :src="user.cover">{{ user.user_name?.charAt(0) }}</el-avatar>
<div class="user-list-info">
<div class="user-list-name">{{ user.user_name }}</div>
<div class="user-list-sub">
<span v-if="user.phone">手机: {{ user.phone }}</span>
<span v-else-if="user.email">邮箱: {{ user.email }}</span>
<span v-else>UID: {{ user.user_id }}</span>
</div>
</div>
<el-icon class="user-list-arrow"><ArrowRight /></el-icon>
</div>
</div>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, onActivated, onBeforeUnmount, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, User, Close, ArrowRight } from '@element-plus/icons-vue'
import {
getTickerList,
closeTicket,
getTicketCount
} from '@/api/ticket'
import { getUserList } from '@/api/admin/user'
const router = useRouter()
// 分页
const currentPage = ref(1)
const pageSize = ref(10)
const totalCount = ref(0)
const isLoading = ref(false)
// 工单数据
const ticketList = ref([])
const activeStatus = ref('pending') // 默认选中"待处理"
// 关键词搜索
const searchKeyword = ref('')
const keywordSearchTimer = ref(null)
// 用户搜索
const userSearchKeyword = ref('')
const userList = ref([])
const selectedUser = ref(null)
const showUserDialog = ref(false)
const isSearchingUser = ref(false)
const userSearchTimer = ref(null)
// 排序
const sortBy = ref('') // 默认不排序
const sortOrder = ref('') // 默认不选择排序顺序
// 统计数据
const stats = reactive({
pending: 0,
processing: 0,
replied: 0,
completed: 0,
total: 0
})
// 自动刷新定时器
const autoRefreshTimer = ref(null)
// 状态转换
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] || ''
}
console.log('调用getTickerList,排序参数:', { sortBy: sortBy.value, sortOrder: sortOrder.value })
const res = await getTickerList(
pageSize.value,
currentPage.value,
statusParam,
sortBy.value,
sortOrder.value,
selectedUser.value?.user_id,
searchKeyword.value.trim()
)
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
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(() => ticketList.value)
// 用户搜索
const handleUserSearch = () => {
if (userSearchTimer.value) {
clearTimeout(userSearchTimer.value)
}
const keyword = userSearchKeyword.value.trim()
if (!keyword) {
userList.value = []
return
}
userSearchTimer.value = setTimeout(async () => {
try {
isSearchingUser.value = true
const res = await getUserList({ page: 1, count: 10, key: keyword })
console.log('用户搜索响应:', res)
if (res.data?.code === 200) {
// 注意:响应结构是 res.data.data.data
userList.value = res.data.data?.data || []
console.log('用户列表更新:', userList.value)
} else {
ElMessage.error(res.data?.message || '搜索用户失败')
userList.value = []
}
} catch (error) {
console.error('搜索用户出错:', error)
ElMessage.error('搜索用户失败')
userList.value = []
} finally {
isSearchingUser.value = false
}
}, 300)
}
// 选择用户
const selectUser = (user) => {
selectedUser.value = user
showUserDialog.value = false
userSearchKeyword.value = ''
userList.value = []
currentPage.value = 1
fetchTicketList()
}
// 清除用户筛选
const clearUserFilter = () => {
selectedUser.value = null
currentPage.value = 1
fetchTicketList()
}
// 关键词搜索
const handleKeywordSearch = () => {
if (keywordSearchTimer.value) {
clearTimeout(keywordSearchTimer.value)
}
keywordSearchTimer.value = setTimeout(() => {
currentPage.value = 1
fetchTicketList()
}, 300)
}
// 按状态过滤
const filterByStatus = (status) => {
if (activeStatus.value === status) return
activeStatus.value = status
currentPage.value = 1
fetchTicketList()
// 切换状态时重新设置定时器
stopAutoRefresh()
if (status === 'pending') {
startAutoRefresh()
}
}
// 排序变化处理
const handleSortChange = () => {
currentPage.value = 1
fetchTicketList()
}
// 分页处理
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 = () => {
if (autoRefreshTimer.value) return
autoRefreshTimer.value = setInterval(() => {
if (activeStatus.value === 'pending') {
// 静默刷新,不显示loading
const originalLoading = isLoading.value
fetchTicketList().finally(() => {
isLoading.value = originalLoading
})
fetchStats()
}
}, 30000) // 30秒
}
// 停止自动刷新
const stopAutoRefresh = () => {
if (autoRefreshTimer.value) {
clearInterval(autoRefreshTimer.value)
autoRefreshTimer.value = null
}
}
let isFirstLoad = true
// 监听对话框关闭,清空搜索状态
watch(showUserDialog, (newVal) => {
if (!newVal) {
// 对话框关闭时清空搜索
userSearchKeyword.value = ''
userList.value = []
}
})
onMounted(() => {
fetchTicketList()
fetchStats()
// 如果默认是待处理状态,启动自动刷新
if (activeStatus.value === 'pending') {
startAutoRefresh()
}
})
// 当页面被激活时(从详情页返回时)
onActivated(() => {
// 跳过首次加载,只在从其他页面返回时刷新
if (!isFirstLoad) {
refreshList()
}
isFirstLoad = false
// 重新启动自动刷新(如果是待处理状态)
if (activeStatus.value === 'pending') {
startAutoRefresh()
}
})
// 组件卸载时清理定时器
onBeforeUnmount(() => {
stopAutoRefresh()
if (userSearchTimer.value) {
clearTimeout(userSearchTimer.value)
}
if (keywordSearchTimer.value) {
clearTimeout(keywordSearchTimer.value)
}
})
</script>
<style scoped>
.ticket-list-page {
padding: 0;
height: calc(100vh - 100px);
display: flex;
flex-direction: column;
background: #fff;
}
.status-bar {
display: flex;
align-items: center;
justify-content: flex-start;
padding: 14px 20px 0;
}
.status-tabs {
display: flex;
gap: 6px;
}
.tab-item {
padding: 6px 16px;
border-radius: 20px;
cursor: pointer;
font-size: 14px;
color: #606266;
transition: all 0.2s;
user-select: none;
}
.tab-item:hover {
background: #f0f2f5;
}
.tab-item.active {
background: #409eff;
color: #fff;
font-weight: 500;
}
.tab-item.pending.active { background: #e6a23c; }
.tab-item.processing.active { background: #409eff; }
.tab-item.replied.active { background: #909399; }
.tab-item.completed.active { background: #67c23a; }
.tab-item .count {
margin-left: 4px;
font-weight: 500;
}
.filter-bar {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
padding: 12px 20px;
border-bottom: 1px solid #ebeef5;
}
.user-dialog-content {
display: flex;
flex-direction: column;
gap: 16px;
}
.user-list-container {
min-height: 300px;
max-height: 400px;
overflow-y: auto;
border: 1px solid #dcdfe6;
border-radius: 4px;
}
.empty-hint {
display: flex;
align-items: center;
justify-content: center;
height: 300px;
color: #909399;
font-size: 14px;
}
.user-list {
padding: 8px 0;
}
.user-list-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
cursor: pointer;
transition: background 0.2s;
}
.user-list-item:hover {
background: #f5f7fa;
}
.user-list-info {
flex: 1;
min-width: 0;
}
.user-list-name {
font-size: 14px;
font-weight: 500;
color: #303133;
margin-bottom: 4px;
}
.user-list-sub {
font-size: 12px;
color: #909399;
}
.user-list-arrow {
color: #c0c4cc;
font-size: 16px;
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
}
.username {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pagination-wrapper {
padding: 12px 20px;
border-top: 1px solid #ebeef5;
}
:deep(.el-table) {
flex: 1;
}
:deep(.el-table tr) {
cursor: pointer;
}
/* 移动端卡片列表 */
.mobile-ticket-list {
display: none;
flex-direction: column;
gap: 12px;
padding: 12px;
overflow-y: auto;
flex: 1;
}
.ticket-card {
background: #fff;
border: 1px solid #ebeef5;
border-radius: 8px;
padding: 12px;
cursor: pointer;
transition: all 0.2s;
}
.ticket-card:active {
background: #f5f7fa;
}
.ticket-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.ticket-card-id {
font-size: 12px;
color: #909399;
}
.ticket-card-user {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.ticket-card-username {
font-size: 14px;
font-weight: 500;
color: #303133;
}
.ticket-card-title {
font-size: 14px;
color: #303133;
margin-bottom: 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ticket-card-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.ticket-card-time {
font-size: 12px;
color: #909399;
}
.ticket-card-actions {
display: flex;
gap: 8px;
}
/* 大屏平板尺寸响应式样式 (1020px - 1280px) */
@media (max-width: 1280px) and (min-width: 1021px) {
.filter-bar {
padding: 10px 16px;
gap: 8px;
}
.filter-bar .el-select {
width: 120px !important;
}
.filter-bar .el-input {
min-width: 160px;
}
:deep(.el-table) {
font-size: 13px;
}
:deep(.el-table .el-table__cell) {
padding: 10px 8px;
}
}
/* 平板尺寸响应式样式 (769px - 1020px) */
@media (max-width: 1020px) and (min-width: 769px) {
.status-bar {
padding: 10px 16px 0;
}
.status-tabs {
width: 100%;
justify-content: flex-start;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.status-tabs::-webkit-scrollbar {
height: 4px;
}
.status-tabs::-webkit-scrollbar-thumb {
background: #dcdfe6;
border-radius: 2px;
}
.filter-bar {
padding: 10px 16px;
gap: 8px;
}
.filter-bar .el-select {
width: 120px !important;
}
.filter-bar .el-input {
flex: 1;
min-width: 150px;
}
:deep(.el-table) {
font-size: 13px;
}
:deep(.el-table .el-table__cell) {
padding: 8px 6px;
}
}
/* 移动端响应式样式 */
@media (max-width: 768px) {
.ticket-list-page {
height: auto;
min-height: calc(100vh - 60px);
}
.status-bar {
padding: 10px 12px 0;
}
.status-tabs {
width: 100%;
overflow-x: auto;
padding-bottom: 4px;
-webkit-overflow-scrolling: touch;
}
.status-tabs::-webkit-scrollbar {
display: none;
}
.tab-item {
flex-shrink: 0;
padding: 8px 12px;
font-size: 13px;
}
.filter-bar {
padding: 10px 12px;
gap: 8px;
}
.filter-bar .el-select,
.filter-bar .el-input {
flex: 1;
min-width: 120px;
}
.filter-bar .el-button {
flex-shrink: 0;
}
/* 隐藏PC端表格,显示移动端卡片 */
:deep(.el-table) {
display: none !important;
}
.mobile-ticket-list {
display: flex;
}
.pagination-wrapper {
padding: 12px;
justify-content: center;
}
.pagination-wrapper :deep(.el-pagination) {
flex-wrap: wrap;
justify-content: center;
gap: 8px;
}
.pagination-wrapper :deep(.el-pagination__sizes),
.pagination-wrapper :deep(.el-pagination__jump) {
display: none;
}
/* 用户选择弹窗移动端适配 */
:deep(.el-dialog) {
width: 90% !important;
margin: 5vh auto !important;
}
}
@media (max-width: 480px) {
.tab-item {
padding: 6px 10px;
font-size: 12px;
}
.tab-item .count {
display: none;
}
}
</style>