Files
ApiServer-Web-admin_dashboa…/src/views/ticket/TicketList.vue
T
shiran c18622226e
Build and Deploy Vue3 / build (push) Failing after 48s
Build and Deploy Vue3 / deploy (push) Has been skipped
feat(admin): 工单管理 UI 优化与回复模板、文件管理增强
工单列表与详情 UI/交互优化及新工单提醒;新增回复模板与工单类型管理;文件管理增加管理员筛选并优化详情展示。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 17:28:11 +08:00

1222 lines
31 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="tab-pulse" v-if="stats.pending > 0"></span>
待处理 <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 class="batch-actions" v-if="selectedRows.length > 0">
<span class="batch-hint">已选 {{ selectedRows.length }} </span>
<el-button size="small" type="warning" @click="handleBatchClose">批量结束</el-button>
</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 class="poll-settings">
<el-tooltip content="新工单提示音" placement="top">
<el-switch v-model="pollSoundEnabled" size="small" :active-icon="Bell" :inactive-icon="MuteNotification" />
</el-tooltip>
<el-select v-model="pollInterval" style="width: 100px" size="small" @change="restartPolling">
<el-option label="10秒" :value="10000" />
<el-option label="30秒" :value="30000" />
<el-option label="60秒" :value="60000" />
<el-option label="2分钟" :value="120000" />
</el-select>
</div>
</div>
<!-- 工单表格PC端 -->
<el-table
v-loading="isLoading"
:data="filteredTickets"
style="width: 100%"
@row-click="handleRowClick"
@selection-change="handleSelectionChange"
class="desktop-table"
:row-class-name="tableRowClass"
>
<el-table-column type="selection" width="40" />
<el-table-column prop="id" label="工单号" width="90">
<template #default="{ row }">
<span class="ticket-id">#{{ row.id }}</span>
</template>
</el-table-column>
<el-table-column label="用户" width="160">
<template #default="{ row }">
<div class="user-info">
<el-avatar :size="30" :src="row.avatar">{{ row.username?.charAt(0) }}</el-avatar>
<el-link v-if="row.userId" type="primary" :underline="false" @click.stop="openNewTab('/user/detail', { user_id: row.userId })">{{ row.username }}</el-link>
<span v-else class="username">{{ row.username }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="工单标题" min-width="220">
<template #default="{ row }">
<div class="title-cell">
<span class="weight-dot" :class="getWeightClass(row.weight)" :title="getWeightLabel(row.weight)"></span>
<el-tooltip :content="row.title" placement="top" :show-after="500">
<span class="ticket-title-text">{{ row.title }}</span>
</el-tooltip>
</div>
</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<span class="status-badge" :class="'status-' + row.status">{{ getStatusText(row.status) }}</span>
</template>
</el-table-column>
<el-table-column label="紧急度" width="90" align="center">
<template #default="{ row }">
<span class="weight-label" :class="getWeightClass(row.weight)">{{ getWeightLabel(row.weight) }}</span>
</template>
</el-table-column>
<el-table-column label="最后回复" width="130">
<template #default="{ row }">
<el-tooltip :content="row.lastReplyTime" placement="top">
<span class="relative-time">{{ relativeTime(row.lastReplyTimeRaw) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button type="primary" size="small" link @click.stop="goToDetail(row)">查看</el-button>
<el-button
v-if="row.status !== 'completed'"
type="success"
size="small"
link
@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"
:class="'card-' + ticket.status"
@click="goToDetail(ticket)"
>
<div class="ticket-card-header">
<div class="card-left">
<span class="weight-dot" :class="getWeightClass(ticket.weight)"></span>
<span class="ticket-card-id">#{{ ticket.id }}</span>
</div>
<span class="status-badge" :class="'status-' + ticket.status">{{ getStatusText(ticket.status) }}</span>
</div>
<div class="ticket-card-user">
<el-avatar :size="24" :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">{{ relativeTime(ticket.lastReplyTimeRaw) }}</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="warning"
size="small"
@click.stop="handleComplete(ticket)"
>结束</el-button>
</div>
</div>
</div>
<div v-if="filteredTickets.length === 0 && !isLoading" class="empty-state">
<div class="empty-icon">📋</div>
<div class="empty-text">当前没有工单</div>
<div class="empty-sub">新的用户工单将会显示在这里</div>
</div>
</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 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, Bell, MuteNotification } from '@element-plus/icons-vue'
import { ElNotification } from 'element-plus'
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 pollInterval = ref(30000)
const pollSoundEnabled = ref(true)
const lastKnownPendingCount = ref(-1)
// 状态转换
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 getWeightLabel = (weight) => {
const map = { 0: '常规', 1: '一般', 2: '紧急', 3: '非常紧急' }
return map[weight] || '常规'
}
const getWeightClass = (weight) => {
const map = { 0: 'weight-normal', 1: 'weight-low', 2: 'weight-high', 3: 'weight-urgent' }
return map[weight] || 'weight-normal'
}
// 相对时间
const relativeTime = (date) => {
if (!date || isNaN(date.getTime())) return '—'
const now = Date.now()
const diff = now - date.getTime()
if (diff < 60000) return '刚刚'
if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`
if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`
if (diff < 604800000) return `${Math.floor(diff / 86400000)}天前`
return date.toLocaleDateString()
}
// 批量操作
const selectedRows = ref([])
const handleSelectionChange = (rows) => {
selectedRows.value = rows
}
const handleBatchClose = () => {
if (selectedRows.value.length === 0) return
ElMessageBox.confirm(`确定要批量结束选中的 ${selectedRows.value.length} 个工单吗?`, '批量结束', {
type: 'warning'
}).then(async () => {
let successCount = 0
for (const row of selectedRows.value) {
try {
const res = await closeTicket(row.id)
if (res.code === 200) successCount++
} catch (e) { /* skip */ }
}
ElMessage.success(`已成功结束 ${successCount} 个工单`)
refreshList()
}).catch(() => {})
}
// 获取工单列表
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(),
createTimeRaw: new Date(item.created_at),
lastReplyTime: new Date(item.update_time).toLocaleString(),
lastReplyTimeRaw: new Date(item.update_time),
status: convertStatusToString(item.status),
weight: item.weight || 0,
type: item.type || 0,
userGoodsId: item.user_goods_id || 0
}))
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 tableRowClass = ({ row }) => {
if (row.status === 'completed') return 'row-completed'
if (row.weight >= 3) return 'row-urgent'
if (row.weight >= 2) return 'row-high'
return ''
}
// 过滤后的工单列表
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()
}
// 排序变化处理
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 openNewTab = (path, query) => {
const href = router.resolve({ path, query }).href
window.open(href, '_blank')
}
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(() => {})
}
// 全局音频上下文(复用,避免重复创建)
let audioCtx = null
const getAudioContext = () => {
if (!audioCtx) {
audioCtx = new (window.AudioContext || window.webkitAudioContext)()
}
if (audioCtx.state === 'suspended') {
audioCtx.resume()
}
return audioCtx
}
// 用户首次交互时激活音频上下文
const initAudioOnInteraction = () => {
getAudioContext()
document.removeEventListener('click', initAudioOnInteraction)
document.removeEventListener('keydown', initAudioOnInteraction)
}
document.addEventListener('click', initAudioOnInteraction)
document.addEventListener('keydown', initAudioOnInteraction)
// 播放提示音(三声短促提示)
const playNotifySound = () => {
if (!pollSoundEnabled.value) return
try {
const ctx = getAudioContext()
const now = ctx.currentTime
const notes = [880, 1047, 1175]
notes.forEach((freq, i) => {
const osc = ctx.createOscillator()
const gain = ctx.createGain()
osc.connect(gain)
gain.connect(ctx.destination)
osc.frequency.value = freq
osc.type = 'sine'
const startTime = now + i * 0.15
gain.gain.setValueAtTime(0.4, startTime)
gain.gain.exponentialRampToValueAtTime(0.01, startTime + 0.12)
osc.start(startTime)
osc.stop(startTime + 0.12)
})
} catch (e) { /* ignore */ }
}
// 轮询检测新工单
const pollForNewTickets = async () => {
try {
const res = await getTicketCount()
if (res.code === 200) {
const newPending = res.data.wait_count
const prevPending = lastKnownPendingCount.value
stats.total = res.data.all_count
stats.pending = newPending
stats.replied = res.data.reply_count
stats.completed = res.data.close_count
stats.processing = res.data.all_count - newPending - res.data.reply_count - res.data.close_count
if (prevPending >= 0 && newPending > prevPending) {
const diff = newPending - prevPending
playNotifySound()
ElNotification({
title: '新工单提醒',
message: `${diff} 条新的待处理工单`,
type: 'warning',
duration: 5000
})
}
lastKnownPendingCount.value = newPending
}
} catch (e) { /* ignore */ }
// 静默刷新列表
const originalLoading = isLoading.value
fetchTicketList().finally(() => {
isLoading.value = originalLoading
})
}
// 启动轮询
const startAutoRefresh = () => {
if (autoRefreshTimer.value) return
autoRefreshTimer.value = setInterval(pollForNewTickets, pollInterval.value)
}
// 停止轮询
const stopAutoRefresh = () => {
if (autoRefreshTimer.value) {
clearInterval(autoRefreshTimer.value)
autoRefreshTimer.value = null
}
}
// 重启轮询(切换间隔时)
const restartPolling = () => {
stopAutoRefresh()
startAutoRefresh()
}
let isFirstLoad = true
// 监听对话框关闭,清空搜索状态
watch(showUserDialog, (newVal) => {
if (!newVal) {
// 对话框关闭时清空搜索
userSearchKeyword.value = ''
userList.value = []
}
})
onMounted(async () => {
await fetchTicketList()
const statsRes = await getTicketCount().catch(() => null)
if (statsRes?.code === 200) {
stats.total = statsRes.data.all_count
stats.pending = statsRes.data.wait_count
stats.replied = statsRes.data.reply_count
stats.completed = statsRes.data.close_count
stats.processing = statsRes.data.all_count - statsRes.data.wait_count - statsRes.data.reply_count - statsRes.data.close_count
lastKnownPendingCount.value = statsRes.data.wait_count
}
startAutoRefresh()
})
// 当页面被激活时(从详情页返回时)
onActivated(() => {
if (!isFirstLoad) {
refreshList()
}
isFirstLoad = false
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 - 148px);
display: flex;
flex-direction: column;
background: #f8f9fb;
}
.status-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 20px 10px;
background: #fff;
border-bottom: 1px solid #eef0f4;
}
.status-tabs {
display: flex;
gap: 4px;
}
.tab-item {
position: relative;
padding: 7px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
color: #606266;
transition: all 0.25s;
user-select: none;
font-weight: 500;
}
.tab-item:hover {
background: #f0f2f5;
}
.tab-item.active {
color: #fff;
font-weight: 600;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.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.active:not(.pending):not(.processing):not(.replied):not(.completed) { background: #606266; }
.tab-item .count {
margin-left: 4px;
font-size: 12px;
}
.tab-pulse {
position: absolute;
top: 6px;
left: 6px;
width: 7px;
height: 7px;
background: #f56c6c;
border-radius: 50%;
animation: pulse-dot 1.5s infinite;
}
@keyframes pulse-dot {
0% { box-shadow: 0 0 0 0 rgba(245,108,108,0.6); }
70% { box-shadow: 0 0 0 6px rgba(245,108,108,0); }
100% { box-shadow: 0 0 0 0 rgba(245,108,108,0); }
}
.batch-actions {
display: flex;
align-items: center;
gap: 10px;
}
.batch-hint {
font-size: 13px;
color: #606266;
}
.filter-bar {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
padding: 10px 20px;
background: #fff;
border-bottom: 1px solid #eef0f4;
}
.poll-settings {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
padding-left: 10px;
border-left: 1px solid #eef0f4;
}
/* 表格 */
:deep(.el-table) {
flex: 1;
--el-table-border-color: #eef0f4;
}
:deep(.el-table tr) {
cursor: pointer;
transition: background-color 0.2s;
}
:deep(.el-table .row-completed) {
opacity: 0.6;
}
:deep(.el-table .row-urgent td:first-child) {
box-shadow: inset 4px 0 0 #f56c6c;
}
:deep(.el-table .row-high td:first-child) {
box-shadow: inset 4px 0 0 #e6a23c;
}
.ticket-id {
font-size: 13px;
color: #909399;
font-family: 'SF Mono', 'Menlo', monospace;
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.user-info :deep(.el-avatar) {
flex-shrink: 0;
}
.user-info .el-link,
.username {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.title-cell {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.ticket-title-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
}
/* 紧急度圆点 */
.weight-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.weight-dot.weight-normal { background: #c0c4cc; }
.weight-dot.weight-low { background: #409eff; }
.weight-dot.weight-high { background: #e6a23c; }
.weight-dot.weight-urgent { background: #f56c6c; animation: pulse-dot 1.5s infinite; }
/* 状态徽章 */
.status-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 12px;
font-weight: 500;
}
.status-badge.status-pending {
background: #fef0e0;
color: #e6a23c;
}
.status-badge.status-processing {
background: #ecf5ff;
color: #409eff;
}
.status-badge.status-replied {
background: #f4f4f5;
color: #909399;
}
.status-badge.status-completed {
background: #e8f8e8;
color: #67c23a;
}
/* 紧急度文字标签 */
.weight-label {
font-size: 12px;
font-weight: 500;
}
.weight-label.weight-normal { color: #c0c4cc; }
.weight-label.weight-low { color: #409eff; }
.weight-label.weight-high { color: #e6a23c; }
.weight-label.weight-urgent { color: #f56c6c; }
.relative-time {
font-size: 12px;
color: #909399;
}
/* 分页 */
.pagination-wrapper {
padding: 12px 20px;
background: #fff;
border-top: 1px solid #eef0f4;
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: #909399;
}
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
}
.empty-text {
font-size: 16px;
font-weight: 500;
color: #606266;
margin-bottom: 6px;
}
.empty-sub {
font-size: 13px;
color: #a8abb2;
}
/* 移动端卡片列表 */
.mobile-ticket-list {
display: none;
flex-direction: column;
gap: 10px;
padding: 12px;
overflow-y: auto;
flex: 1;
}
.ticket-card {
background: #fff;
border: 1px solid #eef0f4;
border-radius: 10px;
padding: 14px;
cursor: pointer;
transition: all 0.2s;
border-left: 4px solid transparent;
}
.ticket-card.card-pending { border-left-color: #e6a23c; }
.ticket-card.card-processing { border-left-color: #409eff; }
.ticket-card.card-replied { border-left-color: #909399; }
.ticket-card.card-completed { border-left-color: #67c23a; opacity: 0.7; }
.ticket-card:active {
transform: scale(0.99);
background: #fafbfc;
}
.ticket-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.card-left {
display: flex;
align-items: center;
gap: 6px;
}
.ticket-card-id {
font-size: 12px;
color: #909399;
font-family: monospace;
}
.ticket-card-user {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.ticket-card-username {
font-size: 13px;
font-weight: 500;
color: #303133;
}
.ticket-card-title {
font-size: 14px;
color: #303133;
margin-bottom: 10px;
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: #a8abb2;
}
.ticket-card-actions {
display: flex;
gap: 8px;
}
/* 用户对话框 */
.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: 6px;
}
.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;
}
/* 响应式 */
@media (max-width: 1280px) and (min-width: 1021px) {
.filter-bar {
padding: 10px 16px;
gap: 8px;
}
:deep(.el-table) {
font-size: 13px;
}
}
@media (max-width: 1020px) and (min-width: 769px) {
.status-tabs {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.filter-bar {
padding: 10px 16px;
gap: 8px;
}
}
@media (max-width: 768px) {
.ticket-list-page {
height: auto;
min-height: calc(100vh - 60px);
}
.status-bar {
padding: 10px 12px 8px;
flex-wrap: wrap;
gap: 8px;
}
.status-tabs {
width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.status-tabs::-webkit-scrollbar { display: none; }
.tab-item {
flex-shrink: 0;
padding: 6px 12px;
font-size: 12px;
}
.filter-bar {
padding: 8px 12px;
gap: 8px;
}
.filter-bar .el-select,
.filter-bar .el-input {
flex: 1;
min-width: 120px;
}
:deep(.el-table) {
display: none !important;
}
.mobile-ticket-list {
display: flex;
}
.pagination-wrapper {
padding: 12px;
}
.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 .count {
display: none;
}
}
</style>