866 lines
30 KiB
Vue
866 lines
30 KiB
Vue
<template>
|
||
<div class="dev-page">
|
||
<!-- 页面头部 -->
|
||
<div class="page-header">
|
||
<div>
|
||
<h2 class="page-title">🎫 工单处理</h2>
|
||
<p class="page-desc">查看和处理用户提交的技术支持工单,及时响应用户反馈。</p>
|
||
</div>
|
||
<a-space>
|
||
<a-input
|
||
v-model:value="keywords"
|
||
allow-clear
|
||
placeholder="搜索工单标题/编号"
|
||
style="width: 200px"
|
||
@press-enter="doSearch"
|
||
/>
|
||
<a-button :loading="loading" @click="loadTickets">
|
||
<ReloadOutlined /> 刷新
|
||
</a-button>
|
||
</a-space>
|
||
</div>
|
||
|
||
<div class="page-body">
|
||
<!-- 统计卡片 -->
|
||
<a-row :gutter="[16, 16]" class="mb-5">
|
||
<a-col :xs="12" :sm="6" v-for="s in statsCards" :key="s.label">
|
||
<div class="stat-card" :class="s.colorClass">
|
||
<div class="stat-icon">{{ s.icon }}</div>
|
||
<div class="stat-info">
|
||
<div class="stat-value">{{ s.value }}</div>
|
||
<div class="stat-label">{{ s.label }}</div>
|
||
</div>
|
||
</div>
|
||
</a-col>
|
||
</a-row>
|
||
|
||
<!-- 主内容面板 -->
|
||
<div class="panel">
|
||
<!-- 筛选栏 -->
|
||
<div class="filter-bar">
|
||
<a-segmented
|
||
v-model:value="statusFilter"
|
||
:options="statusOptions"
|
||
@change="doSearch"
|
||
/>
|
||
<a-space class="filter-right" wrap>
|
||
<a-select
|
||
v-model:value="appFilter"
|
||
allow-clear
|
||
placeholder="按应用筛选"
|
||
style="min-width: 160px"
|
||
@change="doSearch"
|
||
>
|
||
<a-select-option v-for="app in appList" :key="app.productId" :value="app.productId">
|
||
{{ app.siteName || app.productName }}
|
||
</a-select-option>
|
||
</a-select>
|
||
<a-select
|
||
v-model:value="assigneeFilter"
|
||
allow-clear
|
||
placeholder="按处理人筛选"
|
||
style="min-width: 140px"
|
||
@change="doSearch"
|
||
>
|
||
<a-select-option value="mine">仅我负责</a-select-option>
|
||
<a-select-option value="unassigned">未分配</a-select-option>
|
||
</a-select>
|
||
<a-select
|
||
v-model:value="priorityFilter"
|
||
allow-clear
|
||
placeholder="优先级"
|
||
style="min-width: 110px"
|
||
@change="doSearch"
|
||
>
|
||
<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-space>
|
||
</div>
|
||
|
||
<a-alert v-if="error" class="mx-4 mb-3" show-icon type="error" :message="error" />
|
||
|
||
<!-- 工单表格 -->
|
||
<a-spin :spinning="loading">
|
||
<a-table
|
||
:data-source="tickets"
|
||
:columns="columns"
|
||
:pagination="false"
|
||
row-key="ticketId"
|
||
size="middle"
|
||
:scroll="{ x: 900 }"
|
||
class="tickets-table"
|
||
@row-click="openDetail"
|
||
>
|
||
<template #bodyCell="{ column, record }">
|
||
<template v-if="column.key === 'title'">
|
||
<div class="ticket-title-cell">
|
||
<span class="ticket-title-text">{{ record.title }}</span>
|
||
<a-badge v-if="record.hasUnread" color="#4f46e5" />
|
||
</div>
|
||
<div class="ticket-no-text">{{ record.ticketNo }}</div>
|
||
</template>
|
||
|
||
<template v-else-if="column.key === 'app'">
|
||
<span class="app-name">📦 {{ record.productName || `#${record.productId}` }}</span>
|
||
</template>
|
||
|
||
<template v-else-if="column.key === 'status'">
|
||
<a-tag :color="statusColor(record.status)">{{ statusLabel(record.status) }}</a-tag>
|
||
</template>
|
||
|
||
<template v-else-if="column.key === 'priority'">
|
||
<a-tag :color="priorityColor(record.priority)">{{ priorityLabel(record.priority) }}</a-tag>
|
||
</template>
|
||
|
||
<template v-else-if="column.key === 'category'">
|
||
{{ categoryLabel(record.category) }}
|
||
</template>
|
||
|
||
<template v-else-if="column.key === 'assignee'">
|
||
<div v-if="record.assigneeName" class="assignee-cell">
|
||
<a-avatar :size="22" :src="record.assigneeAvatar">
|
||
<template #icon><UserOutlined /></template>
|
||
</a-avatar>
|
||
<span>{{ record.assigneeName }}</span>
|
||
</div>
|
||
<a-tag v-else color="orange" @click.stop="openAssign(record)">
|
||
<PlusOutlined /> 分配
|
||
</a-tag>
|
||
</template>
|
||
|
||
<template v-else-if="column.key === 'submitter'">
|
||
<div class="assignee-cell">
|
||
<a-avatar :size="22" :src="record.submitUserAvatar">
|
||
<template #icon><UserOutlined /></template>
|
||
</a-avatar>
|
||
<span>{{ record.submitUserName }}</span>
|
||
</div>
|
||
</template>
|
||
|
||
<template v-else-if="column.key === 'replyCount'">
|
||
<span class="reply-count-cell">
|
||
<MessageOutlined /> {{ record.replyCount }}
|
||
</span>
|
||
</template>
|
||
|
||
<template v-else-if="column.key === 'createTime'">
|
||
{{ formatTime(record.createTime) }}
|
||
</template>
|
||
|
||
<template v-else-if="column.key === 'action'">
|
||
<a-space size="small" @click.stop>
|
||
<a-button size="small" type="link" @click="openDetail(record)">查看</a-button>
|
||
<a-button
|
||
v-if="record.status === 'pending' || record.status === 'assigned'"
|
||
size="small"
|
||
type="link"
|
||
@click="handlePickUp(record)"
|
||
>接单</a-button>
|
||
<a-button
|
||
v-if="record.status === 'processing'"
|
||
size="small"
|
||
type="link"
|
||
style="color: #16a34a"
|
||
@click="handleResolve(record)"
|
||
>标记解决</a-button>
|
||
</a-space>
|
||
</template>
|
||
</template>
|
||
|
||
<!-- 空状态 -->
|
||
<template #emptyText>
|
||
<div class="empty-state">
|
||
<div class="empty-icon">🎫</div>
|
||
<div class="empty-title">暂无工单</div>
|
||
<div class="empty-desc">当前筛选条件下没有工单记录</div>
|
||
</div>
|
||
</template>
|
||
</a-table>
|
||
</a-spin>
|
||
|
||
<!-- 分页 -->
|
||
<div v-if="total > pageSize" class="pagination-wrap">
|
||
<a-pagination
|
||
v-model:current="currentPage"
|
||
:total="total"
|
||
:page-size="pageSize"
|
||
show-quick-jumper
|
||
:show-total="(t: number) => `共 ${t} 条`"
|
||
@change="loadTickets"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ========= 工单详情抽屉 ========= -->
|
||
<a-drawer
|
||
v-model:open="showDetail"
|
||
:title="`工单详情 — ${currentTicket?.ticketNo}`"
|
||
width="660"
|
||
placement="right"
|
||
>
|
||
<template v-if="currentTicket">
|
||
<!-- 顶部操作栏 -->
|
||
<div class="detail-action-bar">
|
||
<a-space wrap>
|
||
<a-tag :color="statusColor(currentTicket.status)">{{ statusLabel(currentTicket.status) }}</a-tag>
|
||
<a-tag :color="priorityColor(currentTicket.priority)">{{ priorityLabel(currentTicket.priority) }}</a-tag>
|
||
<a-tag>{{ categoryLabel(currentTicket.category) }}</a-tag>
|
||
</a-space>
|
||
<a-space>
|
||
<a-button v-if="!currentTicket.assigneeName" size="small" @click="openAssign(currentTicket)">
|
||
分配处理人
|
||
</a-button>
|
||
<a-button
|
||
v-if="currentTicket.status === 'pending' || currentTicket.status === 'assigned'"
|
||
size="small"
|
||
type="primary"
|
||
@click="handlePickUp(currentTicket)"
|
||
>接单处理</a-button>
|
||
<a-button
|
||
v-if="currentTicket.status === 'processing'"
|
||
size="small"
|
||
style="background:#16a34a;border-color:#16a34a;color:#fff"
|
||
@click="handleResolve(currentTicket)"
|
||
>标记已解决</a-button>
|
||
</a-space>
|
||
</div>
|
||
|
||
<!-- 工单信息 -->
|
||
<a-descriptions :column="2" size="small" class="detail-desc mt-4">
|
||
<a-descriptions-item label="工单编号">{{ currentTicket.ticketNo }}</a-descriptions-item>
|
||
<a-descriptions-item label="关联应用">
|
||
📦 {{ currentTicket.productName || `#${currentTicket.productId}` }}
|
||
</a-descriptions-item>
|
||
<a-descriptions-item label="提交用户">{{ currentTicket.submitUserName }}</a-descriptions-item>
|
||
<a-descriptions-item label="处理人">
|
||
<span v-if="currentTicket.assigneeName">{{ currentTicket.assigneeName }}</span>
|
||
<a-tag v-else color="orange">未分配</a-tag>
|
||
</a-descriptions-item>
|
||
<a-descriptions-item label="提交时间">{{ formatTime(currentTicket.createTime) }}</a-descriptions-item>
|
||
<a-descriptions-item label="最后更新">{{ formatTime(currentTicket.updateTime) }}</a-descriptions-item>
|
||
</a-descriptions>
|
||
|
||
<a-divider />
|
||
|
||
<!-- 问题描述 -->
|
||
<div class="section-title">问题描述</div>
|
||
<div class="detail-content">{{ currentTicket.content }}</div>
|
||
<div v-if="currentTicket.attachments?.length" class="detail-attachments">
|
||
<span class="detail-attachments-label">📎 附件:</span>
|
||
<a
|
||
v-for="url in currentTicket.attachments"
|
||
:key="url"
|
||
:href="getAttachmentUrl(url)"
|
||
target="_blank"
|
||
class="detail-attachment-link"
|
||
>{{ url.split('/').slice(-1)[0] }}</a>
|
||
</div>
|
||
|
||
<a-divider />
|
||
|
||
<!-- 沟通记录 -->
|
||
<div class="section-title">
|
||
沟通记录
|
||
<a-spin v-if="repliesLoading" size="small" style="margin-left: 8px" />
|
||
</div>
|
||
<div 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-body">
|
||
<div class="reply-header">
|
||
<span class="reply-name">{{ reply.userName }}</span>
|
||
<a-tag v-if="reply.isStaff" color="blue" class="staff-tag">技术人员</a-tag>
|
||
<span class="reply-time">{{ formatTime(reply.createTime) }}</span>
|
||
</div>
|
||
<div class="reply-content">{{ reply.content }}</div>
|
||
<div v-if="reply.attachments?.length" class="reply-attachments">
|
||
<a v-for="url in reply.attachments" :key="url" :href="getAttachmentUrl(url)" target="_blank" class="reply-attachment-link">
|
||
📎 {{ url.split('/').slice(-1)[0] }}
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<a-empty v-if="!repliesLoading && replies.length === 0" description="暂无回复" class="mt-4" />
|
||
</div>
|
||
|
||
<!-- 回复输入 -->
|
||
<div
|
||
v-if="!['resolved','closed','rejected'].includes(currentTicket.status)"
|
||
class="reply-input-wrap mt-4"
|
||
>
|
||
<a-textarea
|
||
v-model:value="replyContent"
|
||
placeholder="回复用户(将通知到客户)…"
|
||
:rows="3"
|
||
:maxlength="1000"
|
||
show-count
|
||
/>
|
||
<a-upload
|
||
v-model:file-list="replyFileList"
|
||
:custom-request="handleReplyUpload"
|
||
:before-upload="beforeUpload"
|
||
:on-remove="handleReplyRemove"
|
||
multiple
|
||
:max-count="5"
|
||
class="mt-2"
|
||
>
|
||
<a-button size="small"><PaperClipOutlined /> 添加附件(最多5个)</a-button>
|
||
</a-upload>
|
||
<div class="reply-footer">
|
||
<span class="reply-hint">💡 回复后客户将收到通知</span>
|
||
<a-button type="primary" :loading="replyLoading" :disabled="!replyContent.trim()" @click="handleReply">
|
||
发送回复
|
||
</a-button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</a-drawer>
|
||
|
||
<!-- ========= 分配处理人弹窗 ========= -->
|
||
<a-modal
|
||
v-model:open="showAssign"
|
||
title="分配处理人"
|
||
:confirm-loading="assignLoading"
|
||
ok-text="确认分配"
|
||
@ok="handleAssign"
|
||
@cancel="showAssign = false"
|
||
>
|
||
<a-form layout="vertical" class="mt-2">
|
||
<a-form-item label="选择技术人员">
|
||
<a-select v-model:value="assigneeId" placeholder="选择处理人" style="width: 100%">
|
||
<a-select-option v-for="staff in staffList" :key="staff.userId" :value="staff.userId">
|
||
<div style="display:flex;align-items:center;gap:8px">
|
||
<a-avatar :size="20" :src="staff.avatar">
|
||
<template #icon><UserOutlined /></template>
|
||
</a-avatar>
|
||
{{ staff.nickname }}
|
||
</div>
|
||
</a-select-option>
|
||
</a-select>
|
||
</a-form-item>
|
||
</a-form>
|
||
</a-modal>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { message } from 'ant-design-vue'
|
||
import {
|
||
MessageOutlined,
|
||
PaperClipOutlined,
|
||
PlusOutlined,
|
||
ReloadOutlined,
|
||
UserOutlined,
|
||
} from '@ant-design/icons-vue'
|
||
import {
|
||
getAllTickets,
|
||
getTicketReplies,
|
||
replyTicket,
|
||
updateTicketStatus,
|
||
assignTicket,
|
||
getTicketStats,
|
||
getTechStaffList,
|
||
} from '@/api/ticket'
|
||
import type { Ticket, TicketReply } from '@/api/ticket/model'
|
||
import { getMyAccessibleApps } from '@/api/app/appProduct'
|
||
import { uploadFile } from '@/api/system/file'
|
||
import type { UploadFile } from 'ant-design-vue'
|
||
|
||
definePageMeta({ layout: 'developer' })
|
||
useHead({ title: '工单处理 - 开发者中心' })
|
||
|
||
// ─── 表格列定义 ──────────────────────────────────────────────────
|
||
const columns = [
|
||
{ title: '工单标题', key: 'title', ellipsis: true, width: 220 },
|
||
{ title: '应用', key: 'app', width: 140 },
|
||
{ title: '状态', key: 'status', width: 90 },
|
||
{ title: '优先级', key: 'priority', width: 80 },
|
||
{ title: '分类', key: 'category', width: 90 },
|
||
{ title: '提交人', key: 'submitter', width: 110 },
|
||
{ title: '处理人', key: 'assignee', width: 120 },
|
||
{ title: '回复', key: 'replyCount', width: 70, align: 'center' },
|
||
{ title: '提交时间', key: 'createTime', width: 110 },
|
||
{ title: '操作', key: 'action', width: 140, fixed: 'right' },
|
||
]
|
||
|
||
// ─── 状态 & 筛选 ─────────────────────────────────────────────────
|
||
const loading = ref(false)
|
||
const error = ref('')
|
||
const tickets = ref<Ticket[]>([])
|
||
const total = ref(0)
|
||
const currentPage = ref(1)
|
||
const pageSize = 20
|
||
const keywords = ref('')
|
||
const statusFilter = ref('all')
|
||
const appFilter = ref<number | undefined>(undefined)
|
||
const assigneeFilter = ref<string | undefined>(undefined)
|
||
const priorityFilter = ref<string | undefined>(undefined)
|
||
const appList = ref<{ productId: number; siteName?: string; productName?: string }[]>([])
|
||
const staffList = ref<{ userId: number; nickname: string; avatar: string }[]>([])
|
||
|
||
// ─── 统计 ─────────────────────────────────────────────────────────
|
||
const stats = ref({ total: 0, pending: 0, processing: 0, resolved: 0, closed: 0 })
|
||
const statsCards = computed(() => [
|
||
{ label: '全部工单', value: stats.value.total, icon: '🎫', colorClass: 'blue' },
|
||
{ label: '待处理', value: stats.value.pending, icon: '⏳', colorClass: 'orange' },
|
||
{ label: '处理中', value: stats.value.processing, icon: '⚙️', colorClass: 'purple' },
|
||
{ label: '已解决', value: stats.value.resolved, icon: '✅', colorClass: 'green' },
|
||
])
|
||
|
||
const statusOptions = [
|
||
{ label: '全部', value: 'all' },
|
||
{ label: '待处理', value: 'pending' },
|
||
{ label: '已分配', value: 'assigned' },
|
||
{ label: '处理中', value: 'processing' },
|
||
{ label: '已解决', value: 'resolved' },
|
||
{ label: '已关闭', value: 'closed' },
|
||
]
|
||
|
||
function statusColor(s: string) {
|
||
return { pending: 'orange', assigned: 'blue', processing: 'geekblue', resolved: 'green', closed: 'default', rejected: 'red' }[s] || 'default'
|
||
}
|
||
function statusLabel(s: string) {
|
||
return { pending: '待处理', assigned: '已分配', processing: '处理中', resolved: '已解决', closed: '已关闭', rejected: '已拒绝' }[s] || s
|
||
}
|
||
function priorityColor(p: string) {
|
||
return { low: 'default', normal: 'blue', high: 'orange', urgent: 'red' }[p] || 'default'
|
||
}
|
||
function priorityLabel(p: string) {
|
||
return { low: '低', normal: '普通', high: '高', urgent: '紧急' }[p] || p
|
||
}
|
||
function categoryLabel(c: string) {
|
||
return { bug: '🐛 Bug', feature: '✨ 需求', consultation: '❓ 咨询', complaint: '📢 投诉', other: '📋 其他' }[c] || c
|
||
}
|
||
function formatTime(t: string) {
|
||
if (!t) return ''
|
||
const d = new Date(t)
|
||
const now = new Date()
|
||
const diff = now.getTime() - d.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 d.toLocaleDateString('zh-CN')
|
||
}
|
||
|
||
// ─── 加载数据 ──────────────────────────────────────────────────────
|
||
async function loadTickets() {
|
||
loading.value = true
|
||
error.value = ''
|
||
try {
|
||
const params: any = {
|
||
keywords: keywords.value || undefined,
|
||
status: statusFilter.value === 'all' ? undefined : statusFilter.value,
|
||
productId: appFilter.value,
|
||
priority: priorityFilter.value,
|
||
page: currentPage.value,
|
||
limit: pageSize,
|
||
}
|
||
if (assigneeFilter.value === 'mine') {
|
||
const userId = localStorage.getItem('UserId')
|
||
if (userId) params.assigneeId = Number(userId)
|
||
} else if (assigneeFilter.value === 'unassigned') {
|
||
params.assigneeId = 0
|
||
}
|
||
const res = await getAllTickets(params)
|
||
tickets.value = (res?.data as any)?.data?.list || []
|
||
total.value = (res?.data as any)?.data?.count || 0
|
||
} catch (e: any) {
|
||
error.value = e?.message || '加载失败'
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
async function loadStats() {
|
||
try {
|
||
const res = await getTicketStats()
|
||
Object.assign(stats.value, (res?.data as any)?.data || {})
|
||
} catch {}
|
||
}
|
||
|
||
async function loadStaff() {
|
||
try {
|
||
const res = await getTechStaffList()
|
||
staffList.value = res?.data || []
|
||
} catch {}
|
||
}
|
||
|
||
async function loadApps() {
|
||
try {
|
||
const userId = Number(localStorage.getItem('UserId') || 0)
|
||
if (!userId) return
|
||
const res = await getMyAccessibleApps()
|
||
appList.value = (res?.data || []).map((app: any) => ({
|
||
productId: app.productId,
|
||
siteName: app.siteName,
|
||
productName: app.productName,
|
||
}))
|
||
} catch {}
|
||
}
|
||
|
||
function doSearch() {
|
||
currentPage.value = 1
|
||
loadTickets()
|
||
}
|
||
|
||
// ─── 工单操作 ──────────────────────────────────────────────────────
|
||
async function handlePickUp(ticket: Ticket) {
|
||
const userId = Number(localStorage.getItem('UserId'))
|
||
try {
|
||
await updateTicketStatus({ ticketId: ticket.ticketId, status: 'processing' })
|
||
message.success('已接单,开始处理')
|
||
await loadTickets()
|
||
await loadStats()
|
||
if (currentTicket.value?.ticketId === ticket.ticketId) {
|
||
currentTicket.value.status = 'processing'
|
||
currentTicket.value.assigneeId = userId
|
||
}
|
||
} catch (e: any) {
|
||
message.error(e?.message || '操作失败')
|
||
}
|
||
}
|
||
|
||
async function handleResolve(ticket: Ticket) {
|
||
try {
|
||
await updateTicketStatus({ ticketId: ticket.ticketId, status: 'resolved' })
|
||
message.success('已标记为已解决')
|
||
await loadTickets()
|
||
await loadStats()
|
||
if (currentTicket.value?.ticketId === ticket.ticketId) {
|
||
currentTicket.value.status = 'resolved'
|
||
}
|
||
} catch (e: any) {
|
||
message.error(e?.message || '操作失败')
|
||
}
|
||
}
|
||
|
||
// ─── 详情 & 回复 ─────────────────────────────────────────────────
|
||
const showDetail = ref(false)
|
||
const currentTicket = ref<Ticket | null>(null)
|
||
const replies = ref<TicketReply[]>([])
|
||
const repliesLoading = ref(false)
|
||
const replyContent = ref('')
|
||
const replyLoading = ref(false)
|
||
const replyAttachments = ref<string[]>([])
|
||
const replyFileList = ref<UploadFile[]>([])
|
||
|
||
type UploadRequestOption = { file?: File; onSuccess?: (body: unknown, file: File) => void; onError?: (err: unknown) => void }
|
||
|
||
/** 只有图片类型才附加 OSS 图片处理参数 */
|
||
function getAttachmentUrl(url: string) {
|
||
const imageExts = /\.(jpg|jpeg|png|gif|webp|bmp|svg)(\?.*)?$/i
|
||
if (imageExts.test(url)) {
|
||
const base = url.split('?')[0]
|
||
return `${base}?x-oss-process=image/resize,w_750/quality,Q_90`
|
||
}
|
||
return url
|
||
}
|
||
|
||
function beforeUpload(file: File) {
|
||
if (file.size > 10 * 1024 * 1024) {
|
||
message.error('文件大小不能超过 10MB')
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
async function handleReplyUpload(option: UploadRequestOption) {
|
||
const rawFile = option.file
|
||
if (!rawFile) return
|
||
try {
|
||
const record = await uploadFile(rawFile)
|
||
const url = (record?.url || record?.downloadUrl || '').trim()
|
||
if (!url) throw new Error('上传成功但未返回文件地址')
|
||
replyAttachments.value = [...replyAttachments.value, url]
|
||
replyFileList.value = [
|
||
...replyFileList.value.filter(f => f.status !== 'uploading'),
|
||
{ uid: url, name: rawFile.name, status: 'done', url } as UploadFile,
|
||
]
|
||
option.onSuccess?.(record, rawFile)
|
||
} catch (e) {
|
||
option.onError?.(e)
|
||
message.error(e instanceof Error ? e.message : '上传失败')
|
||
}
|
||
}
|
||
|
||
function handleReplyRemove(file: UploadFile) {
|
||
replyAttachments.value = replyAttachments.value.filter(u => u !== file.url)
|
||
replyFileList.value = replyFileList.value.filter(f => f.uid !== file.uid)
|
||
}
|
||
|
||
async function openDetail(ticket: Ticket) {
|
||
currentTicket.value = { ...ticket }
|
||
showDetail.value = true
|
||
repliesLoading.value = true
|
||
replyContent.value = ''
|
||
replyAttachments.value = []
|
||
replyFileList.value = []
|
||
try {
|
||
const res = await getTicketReplies(ticket.ticketId)
|
||
replies.value = (res?.data as any)?.data || res?.data || []
|
||
} catch {} finally {
|
||
repliesLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function handleReply() {
|
||
if (!replyContent.value.trim()) return
|
||
replyLoading.value = true
|
||
try {
|
||
await replyTicket({
|
||
ticketId: currentTicket.value!.ticketId,
|
||
content: replyContent.value.trim(),
|
||
attachments: replyAttachments.value.length ? replyAttachments.value : undefined,
|
||
})
|
||
message.success('回复成功,已通知客户')
|
||
replyContent.value = ''
|
||
replyAttachments.value = []
|
||
replyFileList.value = []
|
||
const res = await getTicketReplies(currentTicket.value!.ticketId)
|
||
replies.value = (res?.data as any)?.data || res?.data || []
|
||
const t = tickets.value.find(t => t.ticketId === currentTicket.value!.ticketId)
|
||
if (t) t.replyCount++
|
||
} catch (e: any) {
|
||
message.error(e?.message || '回复失败')
|
||
} finally {
|
||
replyLoading.value = false
|
||
}
|
||
}
|
||
|
||
// ─── 分配处理人 ──────────────────────────────────────────────────
|
||
const showAssign = ref(false)
|
||
const assignLoading = ref(false)
|
||
const assigneeId = ref<number | undefined>(undefined)
|
||
let assignTarget: Ticket | null = null
|
||
|
||
function openAssign(ticket: Ticket) {
|
||
assignTarget = ticket
|
||
assigneeId.value = ticket.assigneeId
|
||
showAssign.value = true
|
||
}
|
||
|
||
async function handleAssign() {
|
||
if (!assigneeId.value) { message.warning('请选择处理人'); return }
|
||
assignLoading.value = true
|
||
try {
|
||
await assignTicket({ ticketId: assignTarget!.ticketId, assigneeId: assigneeId.value })
|
||
message.success('分配成功')
|
||
showAssign.value = false
|
||
await loadTickets()
|
||
} catch (e: any) {
|
||
message.error(e?.message || '分配失败')
|
||
} finally {
|
||
assignLoading.value = false
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
loadApps()
|
||
loadStaff()
|
||
loadTickets()
|
||
loadStats()
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.dev-page { min-height: 100%; }
|
||
|
||
/* ── 页面头部 ─────────────────────────────── */
|
||
.page-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 24px 28px 16px;
|
||
border-bottom: 1px solid #f0f0f0;
|
||
gap: 16px;
|
||
}
|
||
|
||
.page-title {
|
||
font-size: 20px;
|
||
font-weight: 700;
|
||
color: rgba(0, 0, 0, 0.88);
|
||
margin: 0 0 4px;
|
||
}
|
||
|
||
.page-desc {
|
||
font-size: 14px;
|
||
color: rgba(0, 0, 0, 0.45);
|
||
margin: 0;
|
||
}
|
||
|
||
.page-body {
|
||
padding: 20px 24px 28px;
|
||
}
|
||
|
||
/* ── 统计卡片 ──────────────────────────────── */
|
||
.stat-card {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 16px;
|
||
border-radius: 12px;
|
||
border: 1px solid transparent;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.stat-card:hover { transform: translateY(-1px); }
|
||
|
||
.stat-card.blue { background: #eff6ff; border-color: #dbeafe; }
|
||
.stat-card.orange { background: #fff7ed; border-color: #fed7aa; }
|
||
.stat-card.purple { background: #f5f3ff; border-color: #e9d5ff; }
|
||
.stat-card.green { background: #f0fdf4; border-color: #bbf7d0; }
|
||
|
||
.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;
|
||
}
|
||
|
||
/* ── 筛选栏 ──────────────────────────────────── */
|
||
.filter-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
padding: 14px 18px;
|
||
border-bottom: 1px solid #f5f5f5;
|
||
}
|
||
|
||
/* ── 表格 ────────────────────────────────────── */
|
||
.tickets-table {
|
||
padding: 0;
|
||
}
|
||
|
||
:deep(.tickets-table .ant-table-thead > tr > th) {
|
||
background: #fafafa;
|
||
font-size: 13px;
|
||
}
|
||
|
||
:deep(.tickets-table .ant-table-tbody > tr) {
|
||
cursor: pointer;
|
||
transition: background 0.15s;
|
||
}
|
||
|
||
:deep(.tickets-table .ant-table-tbody > tr:hover > td) {
|
||
background: #f5f7ff !important;
|
||
}
|
||
|
||
.ticket-title-cell { display: flex; align-items: center; gap: 6px; }
|
||
.ticket-title-text { font-weight: 500; color: rgba(0, 0, 0, 0.85); }
|
||
.ticket-no-text { font-size: 11px; color: #8c8c8c; font-family: monospace; }
|
||
.app-name { font-size: 13px; }
|
||
.assignee-cell { display: flex; align-items: center; gap: 6px; font-size: 13px; }
|
||
.reply-count-cell { display: flex; align-items: center; gap: 4px; color: #8c8c8c; font-size: 12px; }
|
||
|
||
/* ── 分页 ────────────────────────────────────── */
|
||
.pagination-wrap {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
padding: 16px 18px;
|
||
border-top: 1px solid #f5f5f5;
|
||
}
|
||
|
||
/* ── 空状态 ──────────────────────────────────── */
|
||
.empty-state {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
padding: 48px 24px;
|
||
text-align: center;
|
||
}
|
||
.empty-icon { font-size: 40px; margin-bottom: 10px; }
|
||
.empty-title { font-size: 15px; font-weight: 600; color: rgba(0, 0, 0, 0.7); }
|
||
.empty-desc { font-size: 13px; color: rgba(0, 0, 0, 0.4); margin-top: 4px; }
|
||
|
||
/* ── 详情抽屉 ────────────────────────────────── */
|
||
.detail-action-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
|
||
.section-title {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: rgba(0, 0, 0, 0.6);
|
||
margin-bottom: 10px;
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.detail-content {
|
||
font-size: 14px;
|
||
color: #262626;
|
||
line-height: 1.7;
|
||
white-space: pre-wrap;
|
||
background: #fafafa;
|
||
padding: 12px 14px;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.detail-attachments {
|
||
margin-top: 8px;
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.detail-attachments-label { font-size: 12px; color: #8c8c8c; }
|
||
|
||
.detail-attachment-link {
|
||
font-size: 12px;
|
||
color: #4f46e5;
|
||
text-decoration: none;
|
||
background: #f0f0ff;
|
||
padding: 2px 8px;
|
||
border-radius: 4px;
|
||
border: 1px solid #e0e7ff;
|
||
}
|
||
.detail-attachment-link:hover { text-decoration: underline; }
|
||
|
||
/* ── 回复 ────────────────────────────────────── */
|
||
.reply-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 14px;
|
||
max-height: 280px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.reply-item { display: flex; gap: 10px; align-items: flex-start; }
|
||
.reply-staff .reply-body { background: #e6f4ff; }
|
||
.reply-body { flex: 1; background: #f5f5f5; border-radius: 8px; padding: 10px 12px; }
|
||
.reply-header { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
|
||
.reply-name { font-weight: 500; font-size: 13px; }
|
||
.staff-tag { font-size: 11px !important; padding: 0 5px !important; }
|
||
.reply-time { font-size: 11px; color: #8c8c8c; margin-left: auto; }
|
||
.reply-content { font-size: 13px; color: #262626; line-height: 1.6; white-space: pre-wrap; }
|
||
.reply-attachments { margin-top: 6px; display: flex; flex-wrap: wrap; gap: 6px; }
|
||
.reply-attachment-link { font-size: 12px; color: #4f46e5; text-decoration: none; }
|
||
.reply-attachment-link:hover { text-decoration: underline; }
|
||
.reply-footer { display: flex; align-items: center; justify-content: space-between; margin-top: 8px; }
|
||
.reply-hint { font-size: 12px; color: #8c8c8c; }
|
||
</style>
|