refactor(tickets): 删除工单处理功能及相关导航入口

- 从控制台导航中移除工单管理菜单项
- 从开发者导航中移除工单处理菜单项
- 完全删除工单处理页面及其代码实现
- 移除所有与工单列表、详情、回复和分配相关的UI组件和逻辑
- 清理工单处理相关的样式和API调用代码
This commit is contained in:
2026-04-26 02:57:10 +08:00
parent 6f33b89864
commit 3edf4f0124
5 changed files with 1 additions and 561 deletions

View File

@@ -85,13 +85,6 @@ export const consoleNav: ConsoleNavEntry[] = [
{ key: 'console-contracts', label: '合同管理', icon: ProfileOutlined, to: '/console/contracts' },
],
},
{
key: 'console-tickets',
label: '工单管理',
icon: CustomerServiceOutlined,
badge: 'NEW',
to: '/console/tickets',
},
{
key: 'console-notifications',
label: '消息通知',

View File

@@ -47,5 +47,4 @@ export const developerNav: DeveloperNavItem[] = [
// 帮助
{ key: 'developer-support', label: '支持与反馈', to: '/developer/support', icon: '💬', group: '帮助' },
{ key: 'developer-tickets', label: '工单处理', to: '/developer/tickets', icon: '🎫', group: '帮助', badge: 'NEW' },
]

View File

@@ -1,552 +0,0 @@
<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>