Files
jczxw-pc/app/pages/admin/tickets.vue
2026-04-23 16:30:57 +08:00

553 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="tickets-page">
<div class="page-header">
<div>
<h2 class="page-title">🎫 工单处理</h2>
<p class="page-desc">处理用户提交的技术支持工单分配并跟进处理进度</p>
</div>
<a-space>
<a-button @click="loadTickets" :loading="loading">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
</a-space>
</div>
<!-- 统计卡片 -->
<a-row :gutter="[16, 16]" class="mb-6">
<a-col :xs="12" :md="6" v-for="stat in statCards" :key="stat.key">
<div
class="stat-card"
:class="[stat.color, { active: filterStatus === stat.key }]"
@click="handleStatFilter(stat.key)"
>
<div class="stat-icon">{{ stat.icon }}</div>
<div class="stat-info">
<div class="stat-value">{{ stat.value }}</div>
<div class="stat-label">{{ stat.label }}</div>
</div>
</div>
</a-col>
</a-row>
<!-- 工单列表 -->
<div class="panel">
<div class="panel-header">
<span class="panel-title">📋 工单列表</span>
<a-space wrap>
<a-select v-model:value="filterStatus" style="width: 130px" @change="handleSearch">
<a-select-option value="">全部状态</a-select-option>
<a-select-option value="pending">待处理</a-select-option>
<a-select-option value="assigned">已分配</a-select-option>
<a-select-option value="processing">处理中</a-select-option>
<a-select-option value="resolved">已解决</a-select-option>
<a-select-option value="closed">已关闭</a-select-option>
</a-select>
<a-select v-model:value="filterPriority" style="width: 110px" @change="handleSearch">
<a-select-option value="">全部优先级</a-select-option>
<a-select-option value="urgent">紧急</a-select-option>
<a-select-option value="high"></a-select-option>
<a-select-option value="normal">普通</a-select-option>
<a-select-option value="low"></a-select-option>
</a-select>
<a-select v-model:value="filterCategory" style="width: 110px" @change="handleSearch">
<a-select-option value="">全部分类</a-select-option>
<a-select-option value="bug">Bug反馈</a-select-option>
<a-select-option value="feature">功能请求</a-select-option>
<a-select-option value="consultation">咨询</a-select-option>
<a-select-option value="complaint">投诉</a-select-option>
<a-select-option value="other">其他</a-select-option>
</a-select>
<a-input-search
v-model:value="searchKeyword"
placeholder="搜索工单标题"
style="width: 200px"
@search="handleSearch"
/>
</a-space>
</div>
<a-table
:columns="columns"
:data-source="tickets"
:loading="loading"
:pagination="pagination"
row-key="ticketId"
@change="handleTableChange"
size="middle"
>
<template #bodyCell="{ column, record }">
<!-- 工单信息 -->
<template v-if="column.key === 'ticketInfo'">
<div class="ticket-info">
<div class="ticket-title">
<a-tag v-if="record.hasUnread" color="red" style="font-size:10px;padding:0 4px">NEW</a-tag>
{{ record.title }}
</div>
<div class="ticket-no">{{ record.ticketNo }}</div>
<div class="ticket-meta">
<span>来自{{ record.submitUserName || '-' }}</span>
<span v-if="record.productName" style="margin-left:8px">应用{{ record.productName }}</span>
</div>
</div>
</template>
<!-- 分类 -->
<template v-if="column.key === 'category'">
<a-tag :color="categoryColor(record.category)">{{ categoryText(record.category) }}</a-tag>
</template>
<!-- 优先级 -->
<template v-if="column.key === 'priority'">
<span :class="['priority-badge', 'priority-' + record.priority]">
{{ priorityText(record.priority) }}
</span>
</template>
<!-- 状态 -->
<template v-if="column.key === 'status'">
<a-tag :color="statusColor(record.status)">{{ statusText(record.status) }}</a-tag>
</template>
<!-- 负责人 -->
<template v-if="column.key === 'assignee'">
<div v-if="record.assigneeName" class="assignee-cell">
<a-avatar :size="24" :src="record.assigneeAvatar">
<template #icon><UserOutlined /></template>
</a-avatar>
<span>{{ record.assigneeName }}</span>
</div>
<a-button v-else type="link" size="small" @click="handleAssign(record)">
<PlusOutlined /> 分配
</a-button>
</template>
<!-- 提交时间 -->
<template v-if="column.key === 'createTime'">
<span class="text-sm text-gray">{{ record.createTime?.substring(0, 16) || '-' }}</span>
</template>
<!-- 操作 -->
<template v-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleView(record)">处理</a-button>
<a-dropdown :trigger="['click']">
<a-button type="link" size="small">更多 <DownOutlined /></a-button>
<template #overlay>
<a-menu @click="({ key }) => handleQuickStatus(key as string, record)">
<a-menu-item key="processing" v-if="['pending','assigned'].includes(record.status)">🔄 开始处理</a-menu-item>
<a-menu-item key="resolved" v-if="['processing','assigned'].includes(record.status)"> 标记已解决</a-menu-item>
<a-menu-item key="closed" v-if="record.status !== 'closed'">🔒 关闭工单</a-menu-item>
<a-menu-item key="rejected"> 拒绝工单</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</a-space>
</template>
</template>
</a-table>
</div>
<!-- 工单详情/处理弹窗 -->
<a-modal
v-model:open="showDetailModal"
:title="`工单处理:${currentTicket?.ticketNo || ''}`"
width="780px"
:footer="null"
destroy-on-close
>
<template v-if="currentTicket">
<!-- 基本信息 -->
<a-descriptions :column="3" size="small" class="mb-4">
<a-descriptions-item label="工单标题" :span="3">
<strong>{{ currentTicket.title }}</strong>
</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="statusColor(currentTicket.status)">{{ statusText(currentTicket.status) }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="优先级">
<span :class="['priority-badge', 'priority-' + currentTicket.priority]">{{ priorityText(currentTicket.priority) }}</span>
</a-descriptions-item>
<a-descriptions-item label="分类">
<a-tag :color="categoryColor(currentTicket.category)">{{ categoryText(currentTicket.category) }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="提交人">{{ currentTicket.submitUserName || '-' }}</a-descriptions-item>
<a-descriptions-item label="关联应用">{{ currentTicket.productName || '-' }}</a-descriptions-item>
<a-descriptions-item label="负责人">{{ currentTicket.assigneeName || '未分配' }}</a-descriptions-item>
<a-descriptions-item label="提交时间" :span="3">{{ currentTicket.createTime }}</a-descriptions-item>
</a-descriptions>
<!-- 工单内容 -->
<div class="ticket-content-box">
<div class="ticket-content-title">📝 工单内容</div>
<div class="ticket-content-body">{{ currentTicket.content }}</div>
</div>
<!-- 回复区 -->
<div class="reply-section">
<div class="reply-title">💬 回复记录</div>
<a-spin v-if="loadingReplies" style="padding:20px;display:block;text-align:center" />
<div v-else-if="replies.length === 0" class="reply-empty">暂无回复</div>
<div v-else class="reply-list">
<div v-for="reply in replies" :key="reply.replyId" class="reply-item" :class="{ 'reply-staff': reply.isStaff }">
<a-avatar :size="32" :src="reply.userAvatar">
<template #icon><UserOutlined /></template>
</a-avatar>
<div class="reply-bubble">
<div class="reply-meta">
<span class="reply-name">{{ reply.userName }}</span>
<a-tag v-if="reply.isStaff" color="blue" style="font-size:10px;padding:0 4px">客服</a-tag>
<span class="reply-time">{{ reply.createTime?.substring(0, 16) }}</span>
</div>
<div class="reply-content">{{ reply.content }}</div>
</div>
</div>
</div>
<!-- 回复输入框 -->
<div class="reply-input-area">
<a-textarea
v-model:value="replyContent"
:rows="3"
placeholder="输入回复内容..."
:maxlength="2000"
show-count
/>
<div class="reply-actions">
<a-space>
<a-select v-model:value="quickStatus" style="width: 140px" placeholder="同时更新状态">
<a-select-option value="">不更新状态</a-select-option>
<a-select-option value="processing">更新为处理中</a-select-option>
<a-select-option value="resolved">更新为已解决</a-select-option>
<a-select-option value="closed">更新为已关闭</a-select-option>
</a-select>
<a-button type="primary" :loading="replying" @click="handleSubmitReply">发送回复</a-button>
</a-space>
</div>
</div>
</div>
</template>
</a-modal>
<!-- 分配弹窗 -->
<a-modal
v-model:open="showAssignModal"
title="分配工单"
:confirm-loading="assigning"
@ok="confirmAssign"
>
<a-form layout="vertical">
<a-form-item label="选择负责人" required>
<a-select v-model:value="assigneeId" placeholder="请选择技术人员" style="width:100%">
<a-select-option v-for="staff in staffList" :key="staff.userId" :value="staff.userId">
{{ staff.nickname }}
</a-select-option>
</a-select>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ReloadOutlined, UserOutlined, PlusOutlined, DownOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import {
getAllTickets, getTicketDetail, getTicketReplies, replyTicket,
updateTicketStatus, assignTicket, getTechStaffList, getTicketStats,
} from '@/api/ticket/index'
import type { Ticket, TicketReply, TicketStatus } from '@/api/ticket/model'
definePageMeta({ layout: 'admin' })
useHead({ title: '工单处理 - 平台管理' })
const loading = ref(false)
const tickets = ref<Ticket[]>([])
const filterStatus = ref<TicketStatus | ''>('')
const filterPriority = ref('')
const filterCategory = ref('')
const searchKeyword = ref('')
const pagination = reactive({ current: 1, pageSize: 20, total: 0, showSizeChanger: true, showQuickJumper: true })
const statCards = reactive([
{ key: 'pending', icon: '⏳', label: '待处理', value: 0, color: 'orange' },
{ key: 'processing', icon: '🔄', label: '处理中', value: 0, color: 'blue' },
{ key: 'resolved', icon: '✅', label: '已解决', value: 0, color: 'green' },
{ key: '', icon: '📋', label: '全部工单', value: 0, color: 'gray' },
])
const columns = [
{ title: '工单信息', key: 'ticketInfo', width: 280 },
{ title: '分类', key: 'category', width: 100 },
{ title: '优先级', key: 'priority', width: 90 },
{ title: '状态', key: 'status', width: 100 },
{ title: '负责人', key: 'assignee', width: 140 },
{ title: '提交时间', key: 'createTime', width: 140 },
{ title: '操作', key: 'action', width: 150 },
]
// 详情弹窗
const showDetailModal = ref(false)
const currentTicket = ref<Ticket | null>(null)
const replies = ref<TicketReply[]>([])
const loadingReplies = ref(false)
const replyContent = ref('')
const replying = ref(false)
const quickStatus = ref('')
// 分配弹窗
const showAssignModal = ref(false)
const assigning = ref(false)
const assigneeId = ref<number | null>(null)
const assignTarget = ref<Ticket | null>(null)
const staffList = ref<{ userId: number; nickname: string; avatar: string }[]>([])
async function loadTickets() {
loading.value = true
try {
const res = await getAllTickets({
page: pagination.current,
limit: pagination.pageSize,
status: filterStatus.value as TicketStatus || undefined,
priority: filterPriority.value as any || undefined,
category: filterCategory.value as any || undefined,
keywords: searchKeyword.value || undefined,
})
// ticket API 直接返回 axios responsedata 字段即 {list, count}
const data = (res as any)?.data ?? res
tickets.value = data?.list || []
pagination.total = data?.count || 0
loadStats()
} catch {
message.error('加载工单列表失败')
} finally {
loading.value = false
}
}
async function loadStats() {
try {
const res = await getTicketStats()
const stats = (res as any)?.data ?? res
statCards[0].value = stats?.pending || 0
statCards[1].value = stats?.processing || 0
statCards[2].value = stats?.resolved || 0
statCards[3].value = stats?.total || 0
} catch { /* ignore */ }
}
function handleStatFilter(key: string) {
filterStatus.value = key as any
pagination.current = 1
loadTickets()
}
function handleSearch() {
pagination.current = 1
loadTickets()
}
function handleTableChange(pag: any) {
pagination.current = pag.current
pagination.pageSize = pag.pageSize
loadTickets()
}
async function handleView(record: Ticket) {
currentTicket.value = record
showDetailModal.value = true
replyContent.value = ''
quickStatus.value = ''
loadingReplies.value = true
try {
const res = await getTicketReplies(record.ticketId)
const data = (res as any)?.data ?? res
replies.value = Array.isArray(data) ? data : (data?.list || [])
} catch {
replies.value = []
} finally {
loadingReplies.value = false
}
}
async function handleSubmitReply() {
if (!replyContent.value.trim()) {
message.warning('请输入回复内容')
return
}
replying.value = true
try {
await replyTicket({ ticketId: currentTicket.value!.ticketId, content: replyContent.value })
if (quickStatus.value) {
await updateTicketStatus({ ticketId: currentTicket.value!.ticketId, status: quickStatus.value as TicketStatus })
}
message.success('回复已发送')
replyContent.value = ''
quickStatus.value = ''
// 重新加载回复
const res = await getTicketReplies(currentTicket.value!.ticketId)
const data = (res as any)?.data ?? res
replies.value = Array.isArray(data) ? data : (data?.list || [])
loadTickets()
} catch (e: any) {
message.error(e?.message || '发送失败')
} finally {
replying.value = false
}
}
async function handleQuickStatus(key: string, record: Ticket) {
try {
await updateTicketStatus({ ticketId: record.ticketId, status: key as TicketStatus })
message.success('状态已更新')
loadTickets()
} catch (e: any) {
message.error(e?.message || '操作失败')
}
}
async function handleAssign(record: Ticket) {
assignTarget.value = record
assigneeId.value = null
showAssignModal.value = true
try {
const res = await getTechStaffList()
const data = (res as any)?.data ?? res
staffList.value = Array.isArray(data) ? data : []
} catch { staffList.value = [] }
}
async function confirmAssign() {
if (!assigneeId.value) {
message.warning('请选择负责人')
return
}
assigning.value = true
try {
await assignTicket({ ticketId: assignTarget.value!.ticketId, assigneeId: assigneeId.value })
message.success('工单已分配')
showAssignModal.value = false
loadTickets()
} catch (e: any) {
message.error(e?.message || '分配失败')
} finally {
assigning.value = false
}
}
function statusText(status?: string) {
const map: Record<string, string> = {
pending: '待处理', assigned: '已分配', processing: '处理中',
resolved: '已解决', closed: '已关闭', rejected: '已拒绝',
}
return map[status || ''] || status || '-'
}
function statusColor(status?: string) {
const map: Record<string, string> = {
pending: 'orange', assigned: 'blue', processing: 'processing',
resolved: 'success', closed: 'default', rejected: 'error',
}
return map[status || ''] || 'default'
}
function priorityText(p?: string) {
const map: Record<string, string> = { urgent: '紧急', high: '高', normal: '普通', low: '低' }
return map[p || ''] || p || '-'
}
function categoryText(c?: string) {
const map: Record<string, string> = { bug: 'Bug反馈', feature: '功能请求', consultation: '咨询', complaint: '投诉', other: '其他' }
return map[c || ''] || c || '-'
}
function categoryColor(c?: string) {
const map: Record<string, string> = { bug: 'error', feature: 'blue', consultation: 'cyan', complaint: 'warning', other: 'default' }
return map[c || ''] || 'default'
}
onMounted(() => loadTickets())
</script>
<style scoped>
.tickets-page { min-height: 100%; }
.page-header {
display: flex; align-items: center;
justify-content: space-between; margin-bottom: 20px;
}
.page-title { font-size: 18px; font-weight: 700; color: #1f2937; margin: 0; }
.page-desc { font-size: 13px; color: #9ca3af; margin: 2px 0 0; }
.stat-card {
display: flex; align-items: center; gap: 12px; padding: 16px;
border-radius: 12px; border: 2px solid transparent; cursor: pointer; transition: all 0.2s;
}
.stat-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.08); }
.stat-card.active { box-shadow: 0 4px 12px rgba(0,0,0,0.12); }
.stat-card.blue { background: #eff6ff; border-color: #dbeafe; }
.stat-card.orange { background: #fff7ed; border-color: #fed7aa; }
.stat-card.green { background: #f0fdf4; border-color: #bbf7d0; }
.stat-card.gray { background: #f9fafb; border-color: #e5e7eb; }
.stat-card.active.blue { border-color: #3b82f6; }
.stat-card.active.orange { border-color: #f97316; }
.stat-card.active.green { border-color: #22c55e; }
.stat-icon { font-size: 28px; flex-shrink: 0; }
.stat-value { font-size: 22px; font-weight: 700; color: rgba(0,0,0,0.85); line-height: 1.2; }
.stat-label { font-size: 12px; color: rgba(0,0,0,0.45); margin-top: 2px; }
.panel { background: #fff; border: 1px solid #f0f0f0; border-radius: 12px; overflow: hidden; }
.panel-header {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 18px; border-bottom: 1px solid #f5f5f5; flex-wrap: wrap; gap: 10px;
}
.panel-title { font-size: 14px; font-weight: 600; color: rgba(0,0,0,0.85); }
.ticket-info .ticket-title { font-size: 14px; font-weight: 500; color: rgba(0,0,0,0.85); margin-bottom: 3px; }
.ticket-no { font-size: 11px; color: #4f46e5; font-family: monospace; }
.ticket-meta { font-size: 11px; color: rgba(0,0,0,0.45); margin-top: 2px; }
.priority-badge { font-size: 12px; font-weight: 600; padding: 2px 8px; border-radius: 4px; }
.priority-urgent { color: #fff; background: #ef4444; }
.priority-high { color: #fff; background: #f97316; }
.priority-normal { color: #1d4ed8; background: #dbeafe; }
.priority-low { color: rgba(0,0,0,0.45); background: #f3f4f6; }
.assignee-cell { display: flex; align-items: center; gap: 6px; font-size: 13px; }
/* 详情弹窗 */
.ticket-content-box {
background: #fafafa; border-radius: 8px; padding: 14px;
margin-bottom: 20px; border: 1px solid #f0f0f0;
}
.ticket-content-title { font-size: 13px; font-weight: 600; color: rgba(0,0,0,0.65); margin-bottom: 8px; }
.ticket-content-body { font-size: 14px; color: rgba(0,0,0,0.85); white-space: pre-wrap; word-break: break-word; line-height: 1.8; }
.reply-section { margin-top: 4px; }
.reply-title { font-size: 13px; font-weight: 600; color: rgba(0,0,0,0.65); margin-bottom: 12px; }
.reply-empty { text-align: center; color: rgba(0,0,0,0.45); padding: 20px; font-size: 13px; }
.reply-list { display: flex; flex-direction: column; gap: 12px; margin-bottom: 16px; max-height: 300px; overflow-y: auto; }
.reply-item { display: flex; gap: 10px; }
.reply-staff { flex-direction: row-reverse; }
.reply-bubble { flex: 1; }
.reply-meta { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; flex-wrap: wrap; }
.reply-staff .reply-meta { flex-direction: row-reverse; }
.reply-name { font-size: 13px; font-weight: 500; color: rgba(0,0,0,0.65); }
.reply-time { font-size: 11px; color: rgba(0,0,0,0.35); }
.reply-content {
font-size: 13px; line-height: 1.6; padding: 10px 14px;
background: #f5f5f5; border-radius: 8px; word-break: break-word;
display: inline-block; max-width: 100%;
}
.reply-staff .reply-content { background: #e0f2fe; }
.reply-input-area { border-top: 1px solid #f0f0f0; padding-top: 14px; margin-top: 4px; }
.reply-actions { display: flex; justify-content: flex-end; margin-top: 10px; }
.mb-4 { margin-bottom: 16px; }
.mb-6 { margin-bottom: 24px; }
.text-sm { font-size: 12px; }
.text-gray { color: rgba(0,0,0,0.45); }
</style>