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.

570 lines
19 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-handle-dialog" width="900px" :close-on-click-modal="false"
top="5vh" @update:model-value="(v) => $emit('update:modelValue', v)" @open="onOpen" @closed="cleanupPreview">
<!-- 顶部:返回按钮 -->
<div class="dialog-topbar">
<el-button link class="back-btn" @click="closeDialog">
<img src="@/assets/image/admin/orders/arrow-left.svg" alt="返回" class="icon" />
<span>返回</span>
</el-button>
</div>
<div class="body-wrap" v-loading="loading">
<div v-if="detail.id" class="handle-form">
<el-form>
<!-- 第一行:所属医院 / 服务类型 / 提出科室 / 提出人员 -->
<el-row :gutter="16">
<el-col :span="6">
<el-form-item label="所属医院" required>
<el-input :model-value="detail.hospitalName" readonly />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="服务类型" required>
<el-input :model-value="detail.serviceType" readonly />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="提出科室" required>
<el-input :model-value="detail.department" readonly />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="提出人员" required>
<el-input :model-value="detail.submitter" readonly />
</el-form-item>
</el-col>
</el-row>
<!-- 第二行:问题名称 + 优先级 -->
<el-form-item label="问题名称" required>
<div class="title-row title-row-priority">
<el-input class="title-input" :model-value="detail.title" readonly style="flex: 1" />
<div class="priority-block">
<div class="priority-label">优先级</div>
<div class="priority-container">
<div class="priority-row high" v-if="detail.priority === '高'">
{{ detail.priority }}
</div>
<div class="priority-row medium" v-if="detail.priority === '中'">
{{ detail.priority }}
</div>
<div class="priority-row low" v-if="detail.priority === '低'">
{{ detail.priority }}
</div>
</div>
<!-- <el-input class="priority-input" :model-value="detail.priority" readonly
style="width: 100px" /> -->
</div>
</div>
</el-form-item>
<!-- 第三行:问题描述 富文本 -->
<el-form-item label="问题描述" required>
<div class="rich-editor readonly-editor">
<div class="editor-content"
v-html="detail.description || '<p style=\'color: #999;\'>暂无描述</p>'"></div>
</div>
</el-form-item>
<!-- 底部 4 个操作按钮 -->
<el-form-item label=" ">
<div class="action-bar">
<div class="action-btn" @click="onProcess">
<img :src="iconProcess" alt="" />
<span>{{
detail.status === "已完成" ? "修改" : "处理"
}}</span>
</div>
<div class="line"></div>
<div class="action-btn" @click="onEdit">
<img :src="iconEdit" alt="" />
<span>编辑工单</span>
</div>
<div class="line"></div>
<div class="action-btn" @click="onCopy">
<img :src="iconCopy" alt="" />
<span>复制工单</span>
</div>
<div class="line"></div>
<el-popconfirm width="auto"
title="确定删除该工单吗"
confirm-button-text="确定"
cancel-button-text="取消"
@confirm="onDelete"
>
<template #reference>
<div class="action-btn danger">
<img :src="iconDelete" alt="" />
<span>删除工单</span>
</div>
</template>
</el-popconfirm>
</div>
</el-form-item>
<!-- 附件 -->
<el-form-item label="附件">
<div class="attachment">
<span v-if="!detail.attachments?.length" v-html="$t('orderDetail.noAttachment')"></span>
<div v-else class="files">
<el-button v-for="(f, i) in detail.attachments" :key="i" link
class="file-item" @click="viewReport(f)">
<div class="inner">
<img :src="iconFile" alt="" class="file-icon" />
<span>{{ f.fileName }}</span>
</div>
</el-button>
</div>
</div>
</el-form-item>
</el-form>
</div>
</div>
<!-- 处理说明弹窗(复用 OrderProcessDialog -->
<OrderProcessDialog v-model="processVisible" :order-id="currentOrderId" @processed="onProcessed" />
<!-- 编辑工单弹窗(复用公共 OrderFormDialogmode='edit' -->
<OrderFormDialog v-model="editVisible" mode="edit" :hospitals="hospitals" :order-data="detail"
:submitting="editSubmitting" @submit="onEditSubmit" />
<!-- 复制工单:复用 OrderFormDialogmode='create',回填整条 detail剔除附件字段 -->
<OrderFormDialog v-model="copyVisible" mode="create" :hospitals="hospitals" :order-data="copyOrderData"
:submitting="copySubmitting" @submit="onCopySubmit" />
<!-- 文件预览弹窗 -->
<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 { computed, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import { useI18n } from "vue-i18n";
import {
createAdminOrder,
deleteAdminOrder,
getAdminHospitals,
getAdminOrderDetail,
getUploadFile,
updateAdminOrder,
} from "@/service/modular/admin";
import FilePreviewDialog from "@/components/FilePreviewDialog.vue";
import OrderFormDialog from "./OrderFormDialog.vue";
import OrderProcessDialog from "./OrderProcessDialog.vue";
import iconProcess from "@/assets/image/admin/orders/process.svg";
import iconEdit from "@/assets/image/admin/orders/edit.svg";
import iconCopy from "@/assets/image/admin/orders/copy.svg";
import iconDelete from "@/assets/image/admin/orders/delete.svg";
import iconFile from "@/assets/image/admin/orders/file.svg";
import dayjs from "dayjs";
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 hospitals = ref([]);
const getAttachmentFileName = (path) => {
if (!path) return "";
return String(path).replace(/\\/g, "/").split("/").pop() || "";
};
const loadDetail = async () => {
if (!props.orderId) return;
loading.value = true;
try {
detail.value = await getAdminOrderDetail(props.orderId);
} finally {
loading.value = false;
}
};
const loadHospitals = async () => {
try {
const res = await getAdminHospitals({ page: 1, pageSize: 1000 });
hospitals.value = res?.list || [];
} catch (_) {
hospitals.value = [];
}
};
const onOpen = () => {
loadDetail();
loadHospitals();
};
watch(
() => props.orderId,
() => {
if (props.modelValue) loadDetail();
},
);
const closeDialog = () => emit("update:modelValue", false);
// 复制工单回填数据:剔除附件字段,避免上传框中显示原工单的附件占位项
const copyOrderData = computed(() => {
console.log(detail.value);
const { attachments: _attachments, ...rest } = detail.value || {};
rest.createdAt = dayjs().format("YYYY-MM-DD HH:mm:ss");
return rest;
});
// ============== 按钮操作 ==============
const currentOrderId = computed(() => props.orderId);
const processVisible = ref(false);
const editVisible = ref(false);
const editSubmitting = ref(false);
const copyVisible = ref(false);
const copySubmitting = ref(false);
// 处理 / 修改:复用原处理工单弹窗逻辑
const onProcess = () => {
if (!detail.value.id) return;
processVisible.value = true;
};
const onProcessed = () => {
loadDetail();
emit("updated");
};
// 编辑工单:复用 OrderFormDialogmode='edit'
const onEdit = () => {
if (!detail.value.id) return;
editVisible.value = true;
};
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;
}
};
// 复制工单:打开 create 模式弹窗,整个 detail 会作为 orderData 回显(附件不处理)
const onCopy = () => {
if (!detail.value.id) return;
copyVisible.value = true;
};
const onCopySubmit = async ({ formData }) => {
copySubmitting.value = true;
try {
await createAdminOrder(formData);
ElMessage.success($t("msg.submitSuccess"));
copyVisible.value = false;
emit("updated");
} finally {
copySubmitting.value = false;
}
};
// 删除工单:调用 DELETE /admin/orders/{id}(软删除)
const onDelete = async () => {
if (!props.orderId) return;
try {
await deleteAdminOrder(props.orderId);
ElMessage.success("删除成功");
emit("updated");
closeDialog();
} catch (e) {
ElMessage.error(e?.message || "删除失败");
}
};
// ============== 附件预览 ==============
const previewVisible = ref(false);
const previewUrl = ref("");
const previewBlob = ref(null);
const previewName = ref("");
const previewType = ref("");
const cleanupPreview = () => {
if (previewUrl.value) {
URL.revokeObjectURL(previewUrl.value);
previewUrl.value = "";
}
previewBlob.value = null;
previewName.value = "";
previewType.value = "";
};
// 点击附件名预览/下载
const viewReport = async (file) => {
if (!file) return;
const path = String(file.filePath || "");
if (!path) {
ElMessage.warning("附件路径为空");
return;
}
const downloadName = file.fileName || getAttachmentFileName(path);
try {
const blob = await getUploadFile(path);
const type = getFileType(downloadName, blob);
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 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";
};
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);
};
const onPreviewDownload = ({ blob, fileName }) => {
if (!blob) return;
downloadBlob(blob, fileName);
};
</script>
<style lang="scss">
.admin-order-handle-dialog {
max-width: 90vw;
min-width: 70vw;
border-radius: 8px !important;
overflow: hidden;
background: #FFFFFF;
box-shadow: 0px 25px 50px -12px rgba(0, 0, 0, 0.25);
--el-component-size: 40px;
.dialog-topbar {
padding: 16px 24px;
border-bottom: 1px solid #F3F4F6;
.back-btn {
font-weight: 500;
font-size: 16px;
color: #374151;
border: 1px solid #D1D5DB;
border-radius: 4px;
padding: 8px 16px;
&:hover {
border: 1px solid #D1D5DB;
}
.icon {
margin-right: 4px;
}
}
}
.body-wrap {
padding: 24px;
.handle-form {
.el-form-item {
margin-bottom: 16px;
}
.el-form-item__label {
font-weight: 500;
font-size: 14px;
color: #374151;
width: 7em;
text-align: left;
}
}
.title-row {
display: flex;
align-items: center;
width: 100%;
.title-input .el-input__wrapper {
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
}
.priority-block {
display: flex;
align-items: center;
flex-shrink: 0;
.priority-label {
background: #F9FAFF;
border-radius: 0px 0px 0px 0px;
border-top: 1px solid #F2F3F5;
border-bottom: 1px solid #F2F3F5;
font-weight: 400;
font-size: 14px;
color: #BCBCBC;
padding: 0 16px;
height: 40px;
line-height: 40px;
}
.priority-container {
border-radius: 4px;
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
padding: 5px 16px;
box-shadow: 0px 0px 0px 1px #EBEEF5 inset;
.priority-row {
&.high {
--current-color: #ff383c;
background: #ffebec;
}
&.medium {
--current-color: #ff8d28;
background: #ffefe2;
}
&.low {
--current-color: #3289fb;
background: #eaf3ff;
}
width: 50px;
height: 30px;
border-radius: 8px 8px 8px 8px;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
&::before {
content: "";
display: block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--current-color);
}
color: var(--current-color);
}
}
}
}
.rich-editor {
width: 100%;
background: #fff;
border-radius: 4px;
border: 1px solid #EBEEF5;
overflow: hidden;
min-height: 40px;
padding: 5px 11px;
max-height: 300px;
overflow-y: auto;
img {
max-width: 100%;
}
}
// 底部 4 个操作按钮
.action-bar {
width: 100%;
display: flex;
align-items: center;
// margin: 16px 0 16px 90px;
background: rgba(0, 0, 0, 0.56);
border-radius: 4px 4px 4px 4px;
overflow: hidden;
.action-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 12px 0;
cursor: pointer;
font-weight: 500;
font-size: 16px;
color: #FFFFFF;
line-height: 28px;
transition: background 0.2s ease;
&:hover {
background: rgba(0, 0, 0, 0.1);
}
img {
width: 20px;
height: 20px;
}
}
.line {
width: 1px;
height: 12px;
background: rgba(255, 255, 255, 0.1);
}
}
.attachment {
.file-list {
margin-bottom: 8px;
}
.file-item {
.inner {
display: inline-flex;
align-items: center;
gap: 4px;
}
.file-icon {
width: 18px;
height: 18px;
}
}
}
}
}
</style>