<script setup lang="ts">
|
import { computed, onMounted, ref } from 'vue'
|
import { useI18n } from 'vue-i18n'
|
import { useRoute, useRouter } from 'vue-router'
|
import { NTag } from 'naive-ui'
|
import type { ImageInst } from 'naive-ui'
|
import { useBoolean } from '@/hooks'
|
import {
|
fetchFeedbackTypeList,
|
fetchOperationsUserListAll,
|
fetchWorkOrderDetail,
|
updateWorkOrder,
|
} from '@/service/api/serviceCenter'
|
import WorkOrderModal from './components/WorkOrderModal.vue'
|
import dayjs from 'dayjs'
|
|
const { t } = useI18n()
|
const route = useRoute()
|
const router = useRouter()
|
const modalVisible = ref(false)
|
const imageRef = ref<ImageInst | null>(null)
|
const {
|
bool: loading,
|
setTrue: startLoading,
|
setFalse: endLoading,
|
} = useBoolean(false)
|
|
const detailData = ref<any>({})
|
const workOrderId = computed(() => route.query.id)
|
const typeOptions = ref<{ label: string, value: number }[]>([])
|
const handleUserOptions = ref<{ label: string, value: number | string }[]>([])
|
|
const problemSourceLabelMap = computed<Record<number, string>>(() => ({
|
1: t('workOrder.detailPage.sourcePhone'),
|
}))
|
const priorityLabelMap = computed<Record<number, string>>(() => ({
|
1: t('workOrder.priorityMap.low'),
|
2: t('workOrder.priorityMap.medium'),
|
3: t('workOrder.priorityMap.high'),
|
}))
|
|
const displayProblemSource = computed(
|
() => problemSourceLabelMap.value[detailData.value.problemSource] || '-',
|
)
|
const displayPriority = computed(
|
() => priorityLabelMap.value[detailData.value.priority] || '-',
|
)
|
const tagType = computed(() =>
|
detailData.value.priority === 3
|
? 'error'
|
: detailData.value.priority === 2
|
? 'warning'
|
: 'success',
|
)
|
const displayType = computed(() => detailData.value.feedbackTypeName || '-')
|
const displayImages = computed(() =>
|
parseFileList(detailData.value.problemImage),
|
)
|
const previewVisible = ref(false)
|
const previewType = ref<'image' | 'video' | 'audio'>('image')
|
const previewUrl = ref('')
|
const previewName = ref('')
|
|
const problemSourceOptions = computed(() => [
|
{ label: t('workOrder.detailPage.sourcePhone'), value: 1 },
|
])
|
|
const priorityOptions = computed(() => [
|
{ label: t('workOrder.priorityMap.low'), value: 1 },
|
{ label: t('workOrder.priorityMap.medium'), value: 2 },
|
{ label: t('workOrder.priorityMap.high'), value: 3 },
|
])
|
|
async function getTypeOptions() {
|
const res = await fetchFeedbackTypeList({ pageNum: 1, pageSize: 9999 })
|
if (res.code === 200 && res.data) {
|
const list = Array.isArray(res.data) ? res.data : res.data.records || []
|
typeOptions.value = list.map((item: any) => ({
|
label: item.feedbackType,
|
value: item.id,
|
}))
|
}
|
else {
|
typeOptions.value = []
|
}
|
}
|
|
async function getHandleUserOptions() {
|
const res = await fetchOperationsUserListAll()
|
if (res.code === 200 && res.data) {
|
const list = Array.isArray(res.data) ? res.data : res.data.records || []
|
handleUserOptions.value = list
|
.filter((item: any) => item.nickName)
|
.map((item: any) => ({
|
label: item.nickName,
|
value: item.id,
|
}))
|
}
|
else {
|
handleUserOptions.value = []
|
}
|
}
|
|
const timelineItems = computed(() => {
|
const items: any[] = []
|
if (detailData.value.createTime) {
|
items.push({
|
title: detailData.value.createTime,
|
handleUserName: detailData.value.createBy,
|
typeTxt: t('workOrder.detailPage.createWorkOrder'),
|
})
|
}
|
const logs = Array.isArray(detailData.value.workOrderLogs)
|
? detailData.value.workOrderLogs
|
: []
|
logs.forEach((log: any) => {
|
items.push({
|
title: log.handleTime || log.createTime || '-',
|
handleUserName: log.handleUserName || '-',
|
typeTxt: t('workOrder.detailPage.handle'),
|
log,
|
})
|
})
|
return items
|
})
|
|
function parseFileList(value: any) {
|
if (!value)
|
return []
|
if (Array.isArray(value))
|
return value
|
if (typeof value === 'string') {
|
try {
|
const parsed = JSON.parse(value)
|
return Array.isArray(parsed) ? parsed : []
|
}
|
catch {
|
return []
|
}
|
}
|
return []
|
}
|
|
function handleTypeLabel(value: number) {
|
if (value === 1)
|
return t('workOrder.detailPage.transfer')
|
if (value === 2)
|
return t('workOrder.detailPage.close')
|
return '-'
|
}
|
|
function getFileExt(fileName: string) {
|
const normalized = fileName || ''
|
const dotIndex = normalized.lastIndexOf('.')
|
if (dotIndex === -1)
|
return ''
|
return normalized.slice(dotIndex + 1).toLowerCase()
|
}
|
|
function isImageFile(fileName: string) {
|
return ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp'].includes(
|
getFileExt(fileName),
|
)
|
}
|
|
function isVideoFile(fileName: string) {
|
return ['mp4', 'mov', 'avi', 'wmv', 'mkv', 'webm'].includes(
|
getFileExt(fileName),
|
)
|
}
|
|
function isAudioFile(fileName: string) {
|
return ['mp3', 'wav', 'aac', 'flac', 'ogg', 'm4a'].includes(
|
getFileExt(fileName),
|
)
|
}
|
|
function isOfficeFile(fileName: string) {
|
return ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'pdf'].includes(
|
getFileExt(fileName),
|
)
|
}
|
|
function openNewTab(url: string) {
|
if (!url) {
|
window.$message?.warning(t('workOrder.detailPage.fileNotExist'))
|
return
|
}
|
window.open(url, '_blank', 'noopener')
|
}
|
|
function handlePreview(file: any) {
|
const url = file?.url
|
const name = file?.name || file?.url || ''
|
if (isOfficeFile(name)) {
|
const previewUrl = `https://view.officeapps.live.com/op/view.aspx?src=${encodeURIComponent(
|
url || '',
|
)}`
|
openNewTab(previewUrl)
|
return
|
}
|
if (isImageFile(name)) {
|
previewUrl.value = url || ''
|
previewName.value = file?.name || ''
|
setTimeout(() => {
|
imageRef.value?.click()
|
}, 0)
|
return
|
}
|
if (isVideoFile(name) || isAudioFile(name)) {
|
previewUrl.value = url || ''
|
previewName.value = file?.name || ''
|
previewType.value = isVideoFile(name) ? 'video' : 'audio'
|
previewVisible.value = true
|
return
|
}
|
openNewTab(url)
|
}
|
|
async function handleDownload(file: any) {
|
const url = file?.url
|
const name = file?.name || file?.url || ''
|
if (!url) {
|
window.$message?.warning(t('workOrder.detailPage.fileNotExist'))
|
return
|
}
|
if (isImageFile(name)) {
|
openNewTab(url)
|
return
|
}
|
try {
|
const response = await fetch(url)
|
if (!response.ok) {
|
throw new Error('download_failed')
|
}
|
const blob = await response.blob()
|
const objectUrl = URL.createObjectURL(blob)
|
const link = document.createElement('a')
|
link.href = objectUrl
|
link.download = file?.name || 'file'
|
document.body.appendChild(link)
|
link.click()
|
document.body.removeChild(link)
|
URL.revokeObjectURL(objectUrl)
|
}
|
catch {
|
const link = document.createElement('a')
|
link.href = url
|
link.download = file?.name || 'file'
|
document.body.appendChild(link)
|
link.click()
|
document.body.removeChild(link)
|
}
|
}
|
|
function handleBack() {
|
router.back()
|
}
|
|
async function getDetail() {
|
if (!workOrderId.value)
|
return
|
startLoading()
|
try {
|
const res = await fetchWorkOrderDetail(workOrderId.value)
|
if (res.code === 200 && res.data) {
|
detailData.value = res.data
|
}
|
else {
|
detailData.value = {}
|
}
|
}
|
finally {
|
endLoading()
|
}
|
}
|
|
function getFileIcon(file: any) {
|
const name = file.name.toLowerCase()
|
if (name.endsWith('.ppt') || name.endsWith('.pptx'))
|
return 'https://huishou-1323682843.cos.ap-nanjing.myqcloud.com/images/27179dcf-fe99-452b-b1c2-27fc7436a7e6.png'
|
if (name.endsWith('.doc') || name.endsWith('.docx') || name.endsWith('.pdf'))
|
return 'https://huishou-1323682843.cos.ap-nanjing.myqcloud.com/images/b0373042-c9c8-476a-8220-8a728d069523.png'
|
|
const imgExts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg']
|
if (imgExts.some(ext => name.endsWith(ext)))
|
return 'https://huishou-1323682843.cos.ap-nanjing.myqcloud.com/images/f129de21-1ac9-4cc9-93d3-7edfdd040a48.png'
|
|
return 'https://huishou-1323682843.cos.ap-nanjing.myqcloud.com/images/f129de21-1ac9-4cc9-93d3-7edfdd040a48.png'
|
}
|
|
async function handleSave(formData: any) {
|
const handleUser = handleUserOptions.value.find(
|
item => item.value === formData.handleUserId,
|
)
|
const savePayload = {
|
id: workOrderId.value,
|
feedbackTypeId: formData.feedbackTypeId || undefined,
|
feedbackUserPhone: formData.feedbackUserPhone || undefined,
|
priority: formData.priority ?? undefined,
|
problemSource: formData.problemSource ?? undefined,
|
problem: formData.problem || undefined,
|
problemDescription: formData.problemDescription || undefined,
|
problemImage: formData.problemImage,
|
handleUserId: formData.handleUserId ?? undefined,
|
handleUserName: handleUser?.label || undefined,
|
}
|
const { code } = await updateWorkOrder(savePayload)
|
if (code === 200) {
|
window.$message?.success(t('workOrder.detailPage.editSuccess'))
|
modalVisible.value = false
|
window.sessionStorage.setItem('workOrderNeedRefresh', '1')
|
getDetail()
|
}
|
else {
|
window.$message?.error(t('workOrder.detailPage.editFailed'))
|
}
|
}
|
|
function handleEdit() {
|
modalVisible.value = true
|
}
|
|
onMounted(() => {
|
getTypeOptions()
|
getHandleUserOptions()
|
getDetail()
|
})
|
</script>
|
|
<template>
|
<div class="h-full flex flex-col">
|
<n-card
|
style="border-radius: 6px"
|
content-style="padding: 20px 24px 24px 24px"
|
>
|
<div class="flex items-center justify-between mb-16px">
|
<div class="flex items-center gap-8px">
|
<div
|
class="bg-[#0068FF]"
|
style="width: 3px; height: 14px; border-radius: 2px"
|
/>
|
<div
|
class="text-[#171F26]"
|
style="line-height: 24px; font-size: 16px; font-weight: bold"
|
>
|
{{ t("workOrder.detailPage.title") }}
|
</div>
|
</div>
|
<n-button
|
v-permission="80215"
|
type="primary"
|
secondary
|
class="w-84px"
|
@click="handleEdit"
|
>
|
{{ t("workOrder.edit") }}
|
</n-button>
|
</div>
|
<n-spin :show="loading">
|
<div class="grid grid-cols-1 gap-16px text-sm text-[#1D2129]">
|
<div>
|
<span class="label-txt-color">{{ t("workOrder.problem") }}:</span>{{ detailData.problem || "-" }}
|
</div>
|
<div class="flex text-sm label-txt-color">
|
<div class="shrink-0">
|
{{ t("workOrder.problemDesc") }}:
|
</div>
|
<div>
|
<div class="whitespace-pre-wrap text-[#171F26]">
|
{{ detailData.problemDescription || "-" }}
|
</div>
|
<div v-if="displayImages.length" class="mt-17px">
|
<n-image-group>
|
<div class="flex flex-wrap gap-15px">
|
<n-image
|
v-for="item in displayImages"
|
:key="item.id || item.url"
|
:src="item.url"
|
width="120"
|
height="120"
|
fallback-src="https://huishou-1323682843.cos.ap-nanjing.myqcloud.com/images/9bed493b-a6aa-4ef6-ac10-7f67f5c6adc5.png"
|
/>
|
</div>
|
</n-image-group>
|
</div>
|
</div>
|
</div>
|
</div>
|
<div class="grid grid-cols-3 gap-16px text-sm text-[#1D2129] mt-16px">
|
<div>
|
<span class="label-txt-color">{{ t("workOrder.feedbackUser") }}:</span>{{ detailData.feedbackUserPhone || "-" }}
|
</div>
|
<div>
|
<span class="label-txt-color">{{ t("workOrder.source") }}:</span>{{ displayProblemSource }}
|
</div>
|
<div>
|
<span class="label-txt-color">{{ t("workOrder.type") }}:</span>{{ displayType }}
|
</div>
|
<div>
|
<span class="label-txt-color">{{ t("workOrder.priority") }}:</span>
|
<NTag :type="tagType" :bordered="false" size="small">
|
{{ displayPriority }}
|
</NTag>
|
</div>
|
<div>
|
<span class="label-txt-color">{{ t("workOrder.detailPage.createBy") }}:</span>{{ detailData.createBy }}
|
</div>
|
<div>
|
<span class="label-txt-color">{{ t("workOrder.detailPage.createTime") }}:</span>{{
|
detailData.createTime
|
? dayjs(detailData.createTime).format("YYYY/MM/DD HH:mm:ss")
|
|| "-"
|
: "-"
|
}}
|
</div>
|
</div>
|
</n-spin>
|
</n-card>
|
|
<n-card
|
style="border-radius: 6px; margin-top: 12px;flex: 1;"
|
content-style="padding: 24px"
|
>
|
<div class="flex items-center gap-8px mb-16px">
|
<div
|
class="bg-[#0068FF]"
|
style="width: 3px; height: 14px; border-radius: 2px"
|
/>
|
<div
|
class="text-[#171F26]"
|
style="line-height: 24px; font-size: 16px; font-weight: bold"
|
>
|
{{ t("workOrder.detailPage.processTitle") }}
|
</div>
|
</div>
|
<n-spin :show="loading">
|
<n-timeline>
|
<n-timeline-item
|
v-for="(item, index) in timelineItems"
|
:key="index"
|
:title="item.typeTxt"
|
type="success"
|
>
|
<div>
|
<div>
|
<div class="txt-[#171F26]">
|
{{ item.handleUserName || "-" }}
|
</div>
|
<div class="mt-4px" style="color: rgba(23, 31, 38, 0.6)">
|
{{ item.title || "-" }}
|
</div>
|
</div>
|
<div
|
v-if="index != 0"
|
class="mt-4px p-12px text-[#171F26] w-552px"
|
style="
|
background-color: rgba(23, 31, 38, 0.05);
|
border-radius: 4px;
|
"
|
>
|
<div class="flex mb-15px">
|
<div class="shrink-0" style="color: rgba(23, 31, 38, 0.6)">
|
{{ t("workOrder.detailPage.handleContent") }}:
|
</div>
|
<div>{{ item.log.handleLog || "-" }}</div>
|
</div>
|
<div class="mt-14px">
|
<div
|
v-if="parseFileList(item.log.handleFile).length"
|
class="mt-2 space-y-2"
|
>
|
<div
|
v-for="file in parseFileList(item.log.handleFile)"
|
:key="file.id || file.url"
|
class="flex items-center gap-27px"
|
>
|
<div class="flex items-center gap-8px">
|
<img
|
:src="getFileIcon(file)"
|
class="w-14px h-18px object-contain shrink-0"
|
>
|
<div class="max-w-[320px] truncate">
|
{{ file.name || "-" }}
|
</div>
|
</div>
|
<div class="flex items-center gap-8px">
|
<img
|
src="/src/assets/element-img/look.png"
|
class="w-16px h-16px"
|
@click="handlePreview(file)"
|
>
|
<img
|
src="/src/assets/element-img/download.png"
|
class="w-16px h-16px"
|
@click="handleDownload(file)"
|
>
|
</div>
|
</div>
|
</div>
|
<div v-else class="text-[#4E5969]">
|
-
|
</div>
|
</div>
|
<div class="mt-15px">
|
<div
|
:class="
|
['handleType1', 'handleType2'][item.log.handleType - 1]
|
"
|
>
|
{{ handleTypeLabel(item.log.handleType) }}
|
</div>
|
</div>
|
</div>
|
</div>
|
</n-timeline-item>
|
</n-timeline>
|
</n-spin>
|
</n-card>
|
<n-modal
|
v-model:show="previewVisible"
|
preset="card"
|
:title="t('workOrder.detailPage.preview')"
|
:style="{ width: '720px' }"
|
:bordered="false"
|
:segmented="{ content: true }"
|
>
|
<div class="flex justify-center">
|
<video
|
v-if="previewType === 'video'"
|
:src="previewUrl"
|
controls
|
class="max-w-full"
|
/>
|
<audio v-else :src="previewUrl" controls class="w-full" />
|
</div>
|
</n-modal>
|
|
<!-- 隐藏的图片预览组件,用于程序化触发预览 -->
|
<n-image
|
v-show="false"
|
ref="imageRef"
|
:src="previewUrl"
|
:alt="previewName"
|
fallback-src="https://huishou-1323682843.cos.ap-nanjing.myqcloud.com/images/9bed493b-a6aa-4ef6-ac10-7f67f5c6adc5.png"
|
/>
|
|
<WorkOrderModal
|
v-model:visible="modalVisible"
|
mode="edit"
|
:row-data="detailData"
|
:type-options="typeOptions"
|
:problem-source-options="problemSourceOptions.value"
|
:priority-options="priorityOptions.value"
|
:handle-user-options="handleUserOptions"
|
@submit="handleSave"
|
/>
|
</div>
|
</template>
|
|
<style scoped lang="scss">
|
.label-txt-color {
|
color: rgba(23, 31, 38, 0.6);
|
}
|
.n-button--secondary {
|
box-sizing: border-box;
|
color: #0068ff;
|
border: 1px solid #7abdff;
|
background-color: #e6f4ff;
|
}
|
.handleType1 {
|
background-color: #0068ff;
|
color: #fff;
|
padding: 0 16px;
|
border-radius: 4px;
|
line-height: 30px;
|
width: fit-content;
|
}
|
.handleType2 {
|
background-color: #c5c7c9;
|
color: #fff;
|
padding: 0 16px;
|
border-radius: 4px;
|
line-height: 30px;
|
width: fit-content;
|
}
|
|
:deep(.n-timeline-item-timeline__line) {
|
background-color: #0068ff !important;
|
margin: 4px 0;
|
border-radius: 4px;
|
left: 4px !important;
|
}
|
|
:deep(.n-timeline-item-timeline__circle) {
|
background-color: #0068ff !important;
|
border: unset !important;
|
width: 10px !important;
|
height: 10px !important;
|
}
|
:deep(.n-timeline-item-content__title) {
|
font-size: 13px !important;
|
font-weight: bold !important;
|
margin-bottom: 4px !important;
|
color: #171f26 !important;
|
}
|
</style>
|