You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

422 lines
13 KiB
Vue

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<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>
<!-- 编辑工单弹窗(复用公共 OrderFormDialogmode='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>