<script lang="ts" setup>
|
import type { AftersalesTicketApi } from '#/api/aftersales/ticket';
|
|
import { computed, onMounted, ref } from 'vue';
|
import { useRoute, useRouter } from 'vue-router';
|
|
import { Page } from '@vben/common-ui';
|
import { AftersaleIssueTypeEnum, AftersaleTicketStatusEnum } from '@vben/constants';
|
|
import {
|
Button,
|
Card,
|
Descriptions,
|
Input,
|
message,
|
Popconfirm,
|
Select,
|
Switch,
|
Tag,
|
} from 'ant-design-vue';
|
|
import {
|
assignTicket,
|
cancelTicket,
|
classifyTicket,
|
closeTicket,
|
getTicket,
|
resolveTicket,
|
} from '#/api/aftersales/ticket';
|
import type { AftersalesVisitApi } from '#/api/aftersales/visit';
|
import {
|
createVisit,
|
getVisitListByTicketId,
|
} from '#/api/aftersales/visit';
|
|
defineOptions({ name: 'AftersalesTicketDetail' });
|
|
const route = useRoute();
|
const router = useRouter();
|
const loading = ref(false);
|
const ticket = ref<AftersalesTicketApi.Ticket>();
|
|
const ticketId = computed(() => Number(route.params.id));
|
|
const isDraft = computed(() => ticket.value?.status === AftersaleTicketStatusEnum.DRAFT);
|
const isProcessing = computed(() => ticket.value?.status === AftersaleTicketStatusEnum.PROCESSING);
|
const isPendingReturn = computed(() => ticket.value?.status === AftersaleTicketStatusEnum.PENDING_RETURN);
|
const isPendingRefund = computed(() => ticket.value?.status === AftersaleTicketStatusEnum.PENDING_REFUND);
|
const isResolved = computed(() => ticket.value?.status === AftersaleTicketStatusEnum.RESOLVED);
|
/** 回访是否已通过(满意且未复发) */
|
const isVisitPassed = computed(() => ticket.value?.visitStatus === 1);
|
|
// 回访记录
|
const visits = ref<AftersalesVisitApi.Visit[]>([]);
|
const showVisitForm = ref(false);
|
const visitForm = ref<AftersalesVisitApi.CreateVisit>({
|
ticketId: 0,
|
visitType: 0,
|
content: '',
|
satisfied: true,
|
recurred: false,
|
});
|
|
const visitTypeOptions = [
|
{ label: '电话回访', value: 0 },
|
{ label: '上门回访', value: 1 },
|
{ label: '远程回访', value: 2 },
|
{ label: '其他', value: 3 },
|
];
|
const visitTypeMap: Record<number, string> = {
|
0: '电话',
|
1: '上门',
|
2: '远程',
|
3: '其他',
|
};
|
|
// 分配处理人
|
const showAssignInput = ref(false);
|
const assignUserId = ref<number>();
|
|
// 问题判定
|
const showClassifyInput = ref(false);
|
const classifyIssueType = ref<number>();
|
const classifyIssueCategory = ref('');
|
const classifySeverityLevel = ref<number>();
|
|
const issueTypeOptions = [
|
{ label: '一般问题', value: 1 },
|
{ label: '维修问题', value: 2 },
|
{ label: '退货问题', value: 3 },
|
];
|
|
const severityOptions = [
|
{ label: '轻微', value: 0 },
|
{ label: '一般', value: 1 },
|
{ label: '严重', value: 2 },
|
{ label: '致命', value: 3 },
|
];
|
|
const statusMap: Record<number, { color: string; label: string }> = {
|
[AftersaleTicketStatusEnum.DRAFT]: { color: 'default', label: '草稿' },
|
[AftersaleTicketStatusEnum.PROCESSING]: { color: 'processing', label: '处理中' },
|
[AftersaleTicketStatusEnum.PENDING_RETURN]: { color: 'warning', label: '待退货' },
|
[AftersaleTicketStatusEnum.PENDING_REFUND]: { color: 'warning', label: '待退款/换货' },
|
[AftersaleTicketStatusEnum.RESOLVED]: { color: 'success', label: '已完结' },
|
[AftersaleTicketStatusEnum.CLOSED]: { color: 'default', label: '已关闭' },
|
[AftersaleTicketStatusEnum.CANCELLED]: { color: 'error', label: '已取消' },
|
};
|
|
const issueTypeMap: Record<number, string> = {
|
1: '一般问题',
|
2: '维修问题',
|
3: '退货问题',
|
};
|
|
const severityMap: Record<number, { color: string; label: string }> = {
|
0: { color: 'green', label: '轻微' },
|
1: { color: 'blue', label: '一般' },
|
2: { color: 'orange', label: '严重' },
|
3: { color: 'red', label: '致命' },
|
};
|
|
async function loadTicket() {
|
loading.value = true;
|
try {
|
ticket.value = await getTicket(ticketId.value);
|
} finally {
|
loading.value = false;
|
}
|
}
|
|
async function loadVisits() {
|
visits.value = await getVisitListByTicketId(ticketId.value);
|
}
|
|
function toggleVisitForm() {
|
showVisitForm.value = !showVisitForm.value;
|
if (showVisitForm.value) {
|
visitForm.value = {
|
ticketId: ticketId.value,
|
visitType: 0,
|
content: '',
|
satisfied: true,
|
recurred: false,
|
};
|
}
|
}
|
|
async function handleCreateVisit() {
|
await createVisit({ ...visitForm.value, ticketId: ticketId.value });
|
message.success('回访登记成功');
|
showVisitForm.value = false;
|
await Promise.all([loadVisits(), loadTicket()]);
|
}
|
|
function toggleAssignInput() {
|
showAssignInput.value = !showAssignInput.value;
|
assignUserId.value = undefined;
|
}
|
|
async function handleAssign() {
|
if (!assignUserId.value) {
|
message.warning('请输入处理人ID');
|
return;
|
}
|
await assignTicket(ticketId.value, assignUserId.value);
|
message.success('分配成功');
|
showAssignInput.value = false;
|
await loadTicket();
|
}
|
|
function toggleClassifyInput() {
|
showClassifyInput.value = !showClassifyInput.value;
|
if (showClassifyInput.value) {
|
classifyIssueType.value = ticket.value?.issueType;
|
classifyIssueCategory.value = ticket.value?.issueCategory || '';
|
classifySeverityLevel.value = ticket.value?.severityLevel;
|
}
|
}
|
|
async function handleClassify() {
|
if (!classifyIssueType.value) {
|
message.warning('请选择问题类型');
|
return;
|
}
|
await classifyTicket({
|
id: ticketId.value,
|
issueType: classifyIssueType.value,
|
issueCategory: classifyIssueCategory.value || undefined,
|
severityLevel: classifySeverityLevel.value,
|
});
|
message.success('问题判定成功');
|
showClassifyInput.value = false;
|
await loadTicket();
|
}
|
|
async function handleResolve() {
|
await resolveTicket(ticketId.value);
|
message.success('完结成功');
|
await loadTicket();
|
}
|
|
async function handleClose() {
|
await closeTicket(ticketId.value);
|
message.success('关闭成功');
|
await loadTicket();
|
}
|
|
async function handleCancel() {
|
await cancelTicket(ticketId.value);
|
message.success('取消成功');
|
await loadTicket();
|
}
|
|
function handleCreateReturn() {
|
router.push({
|
name: 'AftersalesReturnDetail',
|
params: { id: 'new' },
|
query: { ticketId: ticketId.value },
|
});
|
}
|
|
function goBack() {
|
router.back();
|
}
|
|
onMounted(() => {
|
loadTicket();
|
loadVisits();
|
});
|
</script>
|
|
<template>
|
<Page auto-content-height :loading="loading">
|
<template #title>
|
<div class="flex items-center gap-3">
|
<Button @click="goBack">返回</Button>
|
<span class="text-lg font-bold">售后工单详情</span>
|
</div>
|
</template>
|
|
<Card v-if="ticket" title="基本信息" class="mb-4">
|
<Descriptions :column="3" bordered size="small">
|
<Descriptions.Item label="工单单号">{{ ticket.no }}</Descriptions.Item>
|
<Descriptions.Item label="工单主题">{{ ticket.name }}</Descriptions.Item>
|
<Descriptions.Item label="状态">
|
<Tag :color="statusMap[ticket.status!]?.color">
|
{{ statusMap[ticket.status!]?.label }}
|
</Tag>
|
</Descriptions.Item>
|
<Descriptions.Item label="客户">{{ ticket.customerName }}</Descriptions.Item>
|
<Descriptions.Item label="销售订单号">{{ ticket.saleOrderNo }}</Descriptions.Item>
|
<Descriptions.Item label="问题类型">
|
{{ ticket.issueType ? issueTypeMap[ticket.issueType] : '-' }}
|
</Descriptions.Item>
|
<Descriptions.Item label="严重程度">
|
<Tag v-if="ticket.severityLevel !== undefined" :color="severityMap[ticket.severityLevel]?.color">
|
{{ severityMap[ticket.severityLevel]?.label }}
|
</Tag>
|
<span v-else>-</span>
|
</Descriptions.Item>
|
<Descriptions.Item label="负责人">{{ ticket.ownerUserName || '-' }}</Descriptions.Item>
|
<Descriptions.Item label="处理人">{{ ticket.handlerUserName || '-' }}</Descriptions.Item>
|
<Descriptions.Item label="回访状态">
|
<Tag v-if="ticket.visitStatus === 1" color="green">已回访通过</Tag>
|
<Tag v-else color="orange">待回访</Tag>
|
</Descriptions.Item>
|
<Descriptions.Item label="问题沉淀" :span="2">
|
<template v-if="ticket.problemNo">{{ ticket.problemNo }}</template>
|
<template v-else>-</template>
|
</Descriptions.Item>
|
<Descriptions.Item label="问题描述" :span="3">
|
{{ ticket.issueDescription || '-' }}
|
</Descriptions.Item>
|
<Descriptions.Item label="备注" :span="3">
|
{{ ticket.remark || '-' }}
|
</Descriptions.Item>
|
</Descriptions>
|
</Card>
|
|
<Card v-if="ticket?.items?.length" title="明细行" class="mb-4">
|
<div class="overflow-x-auto">
|
<table class="w-full border text-sm">
|
<thead class="bg-gray-50">
|
<tr>
|
<th class="border p-2 text-left">物料编码</th>
|
<th class="border p-2 text-left">物料名称</th>
|
<th class="border p-2 text-left">批次号</th>
|
<th class="border p-2 text-right">销售数量</th>
|
<th class="border p-2 text-right">退货数量</th>
|
<th class="border p-2 text-center">需要维修</th>
|
<th class="border p-2 text-center">需要退货</th>
|
</tr>
|
</thead>
|
<tbody>
|
<tr v-for="item in ticket.items" :key="item.id">
|
<td class="border p-2">{{ item.itemCode }}</td>
|
<td class="border p-2">{{ item.itemName }}</td>
|
<td class="border p-2">{{ item.batchCode || '-' }}</td>
|
<td class="border p-2 text-right">{{ item.saleQuantity }}</td>
|
<td class="border p-2 text-right">{{ item.returnQuantity || '-' }}</td>
|
<td class="border p-2 text-center">
|
<Tag :color="item.needRepair ? 'green' : 'default'">
|
{{ item.needRepair ? '是' : '否' }}
|
</Tag>
|
</td>
|
<td class="border p-2 text-center">
|
<Tag :color="item.needReturn ? 'blue' : 'default'">
|
{{ item.needReturn ? '是' : '否' }}
|
</Tag>
|
</td>
|
</tr>
|
</tbody>
|
</table>
|
</div>
|
</Card>
|
|
<!-- 附件 -->
|
<Card v-if="ticket?.attachmentList?.length" title="附件" class="mb-4">
|
<div class="flex flex-wrap gap-3">
|
<a
|
v-for="file in ticket.attachmentList"
|
:key="file.id"
|
:href="file.url"
|
target="_blank"
|
class="text-blue-500 hover:underline"
|
>
|
{{ file.name || '附件-' + file.id }}
|
</a>
|
</div>
|
</Card>
|
|
<!-- 回访记录 -->
|
<Card v-if="ticket" title="回访记录" class="mb-4">
|
<template #extra>
|
<Button
|
v-if="isResolved && !showVisitForm"
|
type="primary"
|
size="small"
|
@click="toggleVisitForm"
|
>
|
登记回访
|
</Button>
|
</template>
|
|
<div v-if="isResolved" class="mb-3 text-sm text-gray-500">
|
登记「满意 且 未复发」的回访后,工单才可归档;登记「不满意或复发」会自动退回处理中。
|
</div>
|
|
<!-- 登记回访表单 -->
|
<div
|
v-if="showVisitForm"
|
class="mb-4 flex flex-wrap items-center gap-3 rounded bg-gray-50 p-3"
|
>
|
<div class="flex items-center gap-1">
|
<span class="text-sm text-gray-500">回访方式:</span>
|
<Select
|
v-model:value="visitForm.visitType"
|
:options="visitTypeOptions"
|
style="width: 120px"
|
/>
|
</div>
|
<div class="flex items-center gap-1">
|
<span class="text-sm text-gray-500">回访内容:</span>
|
<Input
|
v-model:value="visitForm.content"
|
placeholder="客户反馈内容"
|
style="width: 260px"
|
/>
|
</div>
|
<div class="flex items-center gap-1">
|
<span class="text-sm text-gray-500">是否满意:</span>
|
<Switch
|
v-model:checked="visitForm.satisfied"
|
checked-children="满意"
|
un-checked-children="不满意"
|
/>
|
</div>
|
<div class="flex items-center gap-1">
|
<span class="text-sm text-gray-500">是否复发:</span>
|
<Switch
|
v-model:checked="visitForm.recurred"
|
checked-children="复发"
|
un-checked-children="未复发"
|
/>
|
</div>
|
<Button type="primary" size="small" @click="handleCreateVisit">提交</Button>
|
<Button size="small" @click="toggleVisitForm">取消</Button>
|
</div>
|
|
<!-- 回访记录列表 -->
|
<div v-if="visits.length === 0" class="text-sm text-gray-400">暂无回访记录</div>
|
<div v-else class="overflow-x-auto">
|
<table class="w-full border text-sm">
|
<thead class="bg-gray-50">
|
<tr>
|
<th class="border p-2 text-left">回访时间</th>
|
<th class="border p-2 text-left">方式</th>
|
<th class="border p-2 text-left">回访人</th>
|
<th class="border p-2 text-center">满意</th>
|
<th class="border p-2 text-center">复发</th>
|
<th class="border p-2 text-left">内容</th>
|
<th class="border p-2 text-left">备注</th>
|
</tr>
|
</thead>
|
<tbody>
|
<tr v-for="visit in visits" :key="visit.id">
|
<td class="border p-2">{{ visit.visitTime }}</td>
|
<td class="border p-2">{{ visitTypeMap[visit.visitType!] ?? '-' }}</td>
|
<td class="border p-2">{{ visit.visitorUserName || '-' }}</td>
|
<td class="border p-2 text-center">
|
<Tag :color="visit.satisfied ? 'green' : 'red'">
|
{{ visit.satisfied ? '满意' : '不满意' }}
|
</Tag>
|
</td>
|
<td class="border p-2 text-center">
|
<Tag v-if="visit.recurred" color="orange">复发</Tag>
|
<Tag v-else color="default">未复发</Tag>
|
</td>
|
<td class="border p-2">{{ visit.content || '-' }}</td>
|
<td class="border p-2">{{ visit.remark || '-' }}</td>
|
</tr>
|
</tbody>
|
</table>
|
</div>
|
</Card>
|
|
<!-- 问题判定表单 -->
|
<Card v-if="showClassifyInput" title="问题判定" class="mb-4">
|
<div class="flex flex-wrap items-center gap-3">
|
<div class="flex items-center gap-1">
|
<span class="text-sm text-gray-500">问题类型:</span>
|
<Select
|
v-model:value="classifyIssueType"
|
:options="issueTypeOptions"
|
placeholder="请选择"
|
style="width: 140px"
|
/>
|
</div>
|
<div class="flex items-center gap-1">
|
<span class="text-sm text-gray-500">问题分类:</span>
|
<Input v-model:value="classifyIssueCategory" placeholder="如外观缺陷" style="width: 160px" />
|
</div>
|
<div class="flex items-center gap-1">
|
<span class="text-sm text-gray-500">严重程度:</span>
|
<Select
|
v-model:value="classifySeverityLevel"
|
:options="severityOptions"
|
placeholder="请选择"
|
style="width: 120px"
|
/>
|
</div>
|
<Button type="primary" size="small" @click="handleClassify">确认判定</Button>
|
<Button size="small" @click="toggleClassifyInput">取消</Button>
|
</div>
|
</Card>
|
|
<template #extra>
|
<div class="flex gap-2">
|
<!-- 创建退货申请 -->
|
<Button v-if="isProcessing && ticket?.issueType === AftersaleIssueTypeEnum.RETURN" type="primary" @click="handleCreateReturn">
|
创建退货申请
|
</Button>
|
|
<!-- 分配处理人 -->
|
<template v-if="isDraft || isProcessing">
|
<template v-if="!showAssignInput">
|
<Button @click="toggleAssignInput">分配处理人</Button>
|
</template>
|
<template v-else>
|
<Input
|
v-model:value="assignUserId"
|
placeholder="处理人ID"
|
type="number"
|
style="width: 120px"
|
@pressEnter="handleAssign"
|
/>
|
<Button size="small" type="primary" @click="handleAssign">确认</Button>
|
<Button size="small" @click="toggleAssignInput">取消</Button>
|
</template>
|
</template>
|
|
<!-- 问题判定 -->
|
<Button v-if="isProcessing && !showClassifyInput" @click="toggleClassifyInput">问题判定</Button>
|
|
<!-- 完结 -->
|
<Popconfirm
|
v-if="isProcessing || isPendingReturn || isPendingRefund"
|
title="确认完结该工单?"
|
@confirm="handleResolve"
|
>
|
<Button type="primary">完结</Button>
|
</Popconfirm>
|
|
<!-- 归档(仅回访通过后可归档;未回访请在“回访记录”里登记) -->
|
<Popconfirm
|
v-if="isResolved && isVisitPassed"
|
title="确认归档该工单?归档后自动沉淀一条问题,工单只读不可恢复。"
|
@confirm="handleClose"
|
>
|
<Button type="primary">归档</Button>
|
</Popconfirm>
|
|
<!-- 取消 -->
|
<Popconfirm
|
v-if="isDraft || isProcessing"
|
title="确认取消该工单?取消后不可恢复。"
|
@confirm="handleCancel"
|
>
|
<Button danger>取消</Button>
|
</Popconfirm>
|
</div>
|
</template>
|
</Page>
|
</template>
|