|
|
<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" />
|
|
|
|
|
|
<!-- 编辑工单弹窗(复用公共 OrderFormDialog,mode='edit') -->
|
|
|
<OrderFormDialog v-model="editVisible" mode="edit" :hospitals="hospitals" :order-data="detail"
|
|
|
:submitting="editSubmitting" @submit="onEditSubmit" />
|
|
|
|
|
|
<!-- 复制工单:复用 OrderFormDialog,mode='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");
|
|
|
};
|
|
|
|
|
|
// 编辑工单:复用 OrderFormDialog(mode='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>
|