|
|
<template>
|
|
|
<el-dialog :model-value="modelValue" class="admin-order-detail-dialog" width="900px" :close-on-click-modal="true"
|
|
|
top="5vh" @update:model-value="(v) => $emit('update:modelValue', v)" @open="onOpen" @closed="cleanupPreview">
|
|
|
<div class="body-wrap" v-loading="loading">
|
|
|
<div class="body-header">
|
|
|
<div class="body-title">{{ $t('modal.detail') }}</div>
|
|
|
<img src="@/assets/image/close-btn.svg" alt="" class="close-btn" @click="closeDialog" />
|
|
|
</div>
|
|
|
|
|
|
|
|
|
<div v-if="detail.id" class="detail-container">
|
|
|
<!-- 第一行:4个 -->
|
|
|
<div class="detail-row row-4">
|
|
|
<div class="detail-item">
|
|
|
<div class="label">工单号</div>
|
|
|
<div class="value">{{ detail.orderNo }}</div>
|
|
|
</div>
|
|
|
<div class="detail-item">
|
|
|
<div class="label">当前状态</div>
|
|
|
<div class="value">{{ detail.status }}</div>
|
|
|
</div>
|
|
|
<div class="detail-item">
|
|
|
<div class="label">优先级</div>
|
|
|
<div class="value">{{ detail.priority }}</div>
|
|
|
</div>
|
|
|
<div class="detail-item">
|
|
|
<div class="label">服务类型</div>
|
|
|
<div class="value">{{ detail.serviceType }}</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
<!-- 第二行:1个 -->
|
|
|
<div class="detail-row row-1">
|
|
|
<div class="detail-item">
|
|
|
<div class="label">问题名称</div>
|
|
|
<div class="value">{{ detail.title }}</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
<!-- 第三行:1个 -->
|
|
|
<div class="detail-row row-1">
|
|
|
<div class="detail-item">
|
|
|
<div class="label">问题描述</div>
|
|
|
<div class="value" style="max-height: 300px; overflow: auto;">
|
|
|
<div class="html-content" v-html="detail.description || '—'"></div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
<!-- 第四行:1个 -->
|
|
|
<div class="detail-row row-1">
|
|
|
<div class="detail-item">
|
|
|
<div class="label">附件</div>
|
|
|
<div class="value">
|
|
|
<el-empty style="padding:0;" v-if="!detail.attachments?.length"
|
|
|
:description="$t('orderDetail.noAttachment')" :image-size="80" />
|
|
|
<div v-else class="files">
|
|
|
<el-button v-for="(f, i) in detail.attachments" :key="i" link type="primary"
|
|
|
class="file-item" @click="viewReport(f.filePath, f.fileName)">
|
|
|
<el-icon>
|
|
|
<Document />
|
|
|
</el-icon>{{ f.fileName }}
|
|
|
</el-button>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
<!-- 第五行:4个 -->
|
|
|
<div class="detail-row row-4">
|
|
|
<div class="detail-item">
|
|
|
<div class="label">提出医院</div>
|
|
|
<div class="value">{{ detail.hospitalName || '-' }}</div>
|
|
|
</div>
|
|
|
<div class="detail-item">
|
|
|
<div class="label">提出科室</div>
|
|
|
<div class="value">{{ detail.department || '-' }}</div>
|
|
|
</div>
|
|
|
<div class="detail-item">
|
|
|
<div class="label">提出人员</div>
|
|
|
<div class="value">{{ detail.submitter || '-' }}</div>
|
|
|
</div>
|
|
|
<div class="detail-item">
|
|
|
<div class="label">提交时间</div>
|
|
|
<div class="value">{{ detail.createdAt?.split('T').join(' ') || '-' }}</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
|
|
|
<div class="section" v-if="detail.statusLogs?.length">
|
|
|
<div class="section-title">{{ $t('orderDetail.statusChange') }}</div>
|
|
|
<el-timeline>
|
|
|
<el-timeline-item v-for="(log, i) in detail.statusLogs || []" :key="i" :timestamp="log.createdAt"
|
|
|
placement="top">
|
|
|
<div class="log-title">{{ log.toStatus }} · {{ log.operator }}</div>
|
|
|
<div class="log-remark" v-html="log.remark"></div>
|
|
|
</el-timeline-item>
|
|
|
</el-timeline>
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="body-footer">
|
|
|
<el-button type="primary" plain style="margin-left:auto" @click="closeDialog">
|
|
|
取消
|
|
|
</el-button>
|
|
|
<el-button v-if="detail.status !== '已完成'" type="primary" @click="editVisible = true">
|
|
|
{{ $t('btn.edit') }}
|
|
|
</el-button>
|
|
|
</div>
|
|
|
|
|
|
<!-- 编辑工单弹窗(复用公共 OrderFormDialog,mode='edit') -->
|
|
|
<OrderFormDialog v-model="editVisible" mode="edit" :hospitals="hospitals" :order-data="detail"
|
|
|
:submitting="editSubmitting" @submit="onEditSubmit" />
|
|
|
<!-- 福建弹窗 -->
|
|
|
<FilePreviewDialog :visible="previewVisible" :blob-url="previewUrl" :blob="previewBlob" :file-name="previewName"
|
|
|
:file-type="previewType" @update:visible="(v) => (previewVisible = v)" @close="cleanupPreview"
|
|
|
@download="onPreviewDownload" />
|
|
|
</el-dialog>
|
|
|
</template>
|
|
|
|
|
|
<script setup>
|
|
|
import { ref, watch } from "vue";
|
|
|
import { Document } from "@element-plus/icons-vue";
|
|
|
import { ElMessage } from "element-plus";
|
|
|
import { useI18n } from "vue-i18n";
|
|
|
import {
|
|
|
getAdminOrderDetail,
|
|
|
getAdminHospitals,
|
|
|
getUploadFile,
|
|
|
updateAdminOrder,
|
|
|
} from "@/service/modular/admin";
|
|
|
import FilePreviewDialog from "@/components/FilePreviewDialog.vue";
|
|
|
import OrderFormDialog from "./OrderFormDialog.vue";
|
|
|
|
|
|
const { t: $t } = useI18n();
|
|
|
|
|
|
const props = defineProps({
|
|
|
modelValue: { type: Boolean, default: false },
|
|
|
orderId: { type: [String, Number], default: "" },
|
|
|
});
|
|
|
const emit = defineEmits(["update:modelValue", "updated"]);
|
|
|
|
|
|
const loading = ref(false);
|
|
|
const detail = ref({});
|
|
|
|
|
|
const priorityType = (p) => (p === "高" ? "danger" : p === "中" ? "warning" : "info");
|
|
|
const statusType = (s) =>
|
|
|
({ 待处理: "warning", 处理中: "primary", 已完成: "success", 已取消: "info" })[s] || "info";
|
|
|
|
|
|
const loadDetail = async () => {
|
|
|
if (!props.orderId) return;
|
|
|
loading.value = true;
|
|
|
try {
|
|
|
detail.value = await getAdminOrderDetail(props.orderId);
|
|
|
} finally {
|
|
|
loading.value = false;
|
|
|
}
|
|
|
};
|
|
|
|
|
|
const onOpen = () => {
|
|
|
loadDetail();
|
|
|
};
|
|
|
|
|
|
watch(
|
|
|
() => props.orderId,
|
|
|
() => {
|
|
|
if (props.modelValue) loadDetail();
|
|
|
},
|
|
|
);
|
|
|
|
|
|
const closeDialog = () => emit("update:modelValue", false);
|
|
|
|
|
|
const previewVisible = ref(false);
|
|
|
const previewUrl = ref("");
|
|
|
const previewBlob = ref(null);
|
|
|
const previewName = ref("");
|
|
|
const previewType = ref(""); // 'pdf' | 'image' | ''
|
|
|
|
|
|
const cleanupPreview = () => {
|
|
|
if (previewUrl.value) {
|
|
|
URL.revokeObjectURL(previewUrl.value);
|
|
|
previewUrl.value = "";
|
|
|
}
|
|
|
previewBlob.value = null;
|
|
|
previewName.value = "";
|
|
|
previewType.value = "";
|
|
|
};
|
|
|
|
|
|
// 根据文件名/扩展名+blob 的 mime 判断文件类型
|
|
|
const getFileType = (fileName, blob) => {
|
|
|
const name = String(fileName || "");
|
|
|
const ext = name.split(".").pop()?.toLowerCase() || "";
|
|
|
const mime = blob?.type || "";
|
|
|
if (ext === "pdf" || mime === "application/pdf") return "pdf";
|
|
|
if (
|
|
|
["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"].includes(ext) ||
|
|
|
(mime && mime.startsWith("image/"))
|
|
|
) {
|
|
|
return "image";
|
|
|
}
|
|
|
return "other";
|
|
|
};
|
|
|
|
|
|
// 浏览器直接下载 blob(指定文件名)
|
|
|
const downloadBlob = (blob, fileName) => {
|
|
|
const url = URL.createObjectURL(blob);
|
|
|
const a = document.createElement("a");
|
|
|
a.href = url;
|
|
|
a.download = fileName || "download";
|
|
|
document.body.appendChild(a);
|
|
|
a.click();
|
|
|
document.body.removeChild(a);
|
|
|
setTimeout(() => URL.revokeObjectURL(url), 0);
|
|
|
};
|
|
|
|
|
|
// FilePreviewDialog 下载按钮回调
|
|
|
const onPreviewDownload = ({ blob, fileName }) => {
|
|
|
if (!blob) return;
|
|
|
downloadBlob(blob, fileName);
|
|
|
};
|
|
|
|
|
|
const viewReport = async (attachmentPath, originalName) => {
|
|
|
if (!attachmentPath) return;
|
|
|
const path = String(attachmentPath);
|
|
|
const downloadName = originalName || path;
|
|
|
try {
|
|
|
const blob = await getUploadFile(path);
|
|
|
const type = getFileType(downloadName, blob);
|
|
|
// PDF / 图片走预览弹窗(PDF 走 vue-pdf-embed),其他格式直接下载
|
|
|
if (type === "pdf" || type === "image") {
|
|
|
cleanupPreview();
|
|
|
previewBlob.value = blob;
|
|
|
previewUrl.value = URL.createObjectURL(blob);
|
|
|
previewName.value = downloadName;
|
|
|
previewType.value = type;
|
|
|
previewVisible.value = true;
|
|
|
} else {
|
|
|
downloadBlob(blob, downloadName);
|
|
|
}
|
|
|
} catch (e) {
|
|
|
ElMessage.error(e?.message || $t('msg.failed'));
|
|
|
}
|
|
|
};
|
|
|
|
|
|
// ============== 编辑工单 ==============
|
|
|
const hospitals = ref([]);
|
|
|
const loadHospitals = async () => {
|
|
|
try {
|
|
|
const res = await getAdminHospitals({ page: 1, pageSize: 1000 });
|
|
|
hospitals.value = res?.list || [];
|
|
|
} catch (_) {
|
|
|
hospitals.value = [];
|
|
|
}
|
|
|
};
|
|
|
|
|
|
const editVisible = ref(false);
|
|
|
const editSubmitting = ref(false);
|
|
|
|
|
|
const onEditSubmit = async ({ formData }) => {
|
|
|
editSubmitting.value = true;
|
|
|
try {
|
|
|
await updateAdminOrder(props.orderId, formData);
|
|
|
ElMessage.success($t("msg.editSuccess"));
|
|
|
editVisible.value = false;
|
|
|
loadDetail();
|
|
|
emit("updated");
|
|
|
} finally {
|
|
|
editSubmitting.value = false;
|
|
|
}
|
|
|
};
|
|
|
|
|
|
// 首次打开时加载医院列表
|
|
|
watch(
|
|
|
() => props.modelValue,
|
|
|
(v) => {
|
|
|
if (v && hospitals.value.length === 0) {
|
|
|
loadHospitals();
|
|
|
}
|
|
|
},
|
|
|
);
|
|
|
</script>
|
|
|
|
|
|
<style lang="scss">
|
|
|
.admin-order-detail-dialog {
|
|
|
max-width: 90vw;
|
|
|
min-width: 70vw;
|
|
|
|
|
|
.body-wrap {
|
|
|
padding: 24px;
|
|
|
|
|
|
.body-header {
|
|
|
margin-bottom: 16px;
|
|
|
}
|
|
|
|
|
|
.detail-container {
|
|
|
.detail-row+.detail-row {
|
|
|
margin-top: 15px;
|
|
|
}
|
|
|
|
|
|
.detail-row {
|
|
|
display: grid;
|
|
|
gap: 15px;
|
|
|
|
|
|
&.row-1 {
|
|
|
grid-template-columns: 1fr;
|
|
|
}
|
|
|
|
|
|
&.row-4 {
|
|
|
grid-template-columns: repeat(4, 1fr);
|
|
|
}
|
|
|
|
|
|
.detail-item {
|
|
|
display: flex;
|
|
|
align-items: center;
|
|
|
gap: 10px;
|
|
|
|
|
|
.label {
|
|
|
font-weight: 400;
|
|
|
font-size: 14px;
|
|
|
color: #1D2129;
|
|
|
width: 4em;
|
|
|
overflow: hidden;
|
|
|
text-overflow: ellipsis;
|
|
|
white-space: nowrap;
|
|
|
flex-shrink: 0;
|
|
|
flex-grow: 0;
|
|
|
}
|
|
|
|
|
|
.value {
|
|
|
flex-shrink: 1;
|
|
|
flex-grow: 1;
|
|
|
font-weight: 400;
|
|
|
font-size: 14px;
|
|
|
color: #666666;
|
|
|
padding: 10px;
|
|
|
background: #F8F8F8;
|
|
|
border-radius: 8px 8px 8px 8px;
|
|
|
border: 1px solid #E0E0E0;
|
|
|
|
|
|
.html-content {
|
|
|
|
|
|
background: #fafafa;
|
|
|
padding: 12px;
|
|
|
border-radius: 4px;
|
|
|
min-height: 60px;
|
|
|
line-height: 1.6;
|
|
|
max-width: 100%;
|
|
|
|
|
|
img {
|
|
|
max-width: 100% !important;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.card-header {
|
|
|
display: flex;
|
|
|
align-items: center;
|
|
|
gap: 8px;
|
|
|
margin-bottom: 16px;
|
|
|
|
|
|
.title {
|
|
|
font-size: 16px;
|
|
|
font-weight: 600;
|
|
|
margin-right: 8px;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
.section {
|
|
|
margin-top: 20px;
|
|
|
|
|
|
.section-title {
|
|
|
font-size: 14px;
|
|
|
font-weight: 600;
|
|
|
color: #303133;
|
|
|
margin-bottom: 10px;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.files {
|
|
|
display: flex;
|
|
|
flex-direction: column;
|
|
|
gap: 8px;
|
|
|
|
|
|
.file-item {
|
|
|
display: inline-flex;
|
|
|
align-items: center;
|
|
|
gap: 4px;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
.log-title {
|
|
|
font-weight: 600;
|
|
|
}
|
|
|
|
|
|
.log-remark {
|
|
|
color: #606266;
|
|
|
margin-top: 4px;
|
|
|
}
|
|
|
|
|
|
.preview-iframe {
|
|
|
width: 100%;
|
|
|
height: 80vh;
|
|
|
border: 0;
|
|
|
}
|
|
|
|
|
|
.preview-image {
|
|
|
display: block;
|
|
|
max-width: 100%;
|
|
|
max-height: 80vh;
|
|
|
margin: 0 auto;
|
|
|
object-fit: contain;
|
|
|
}
|
|
|
</style> |