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.

771 lines
22 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="hospital-order-form-dialog" :close-on-click-modal="true"
@update:model-value="(v) => $emit('update:modelValue', v)" @closed="onClosed">
<div class="body-wrap">
<div class="body-header">
<div class="body-title">{{ isEdit() ? '编辑工单' : '新建工单' }}</div>
<img src="@/assets/image/close-btn.svg" alt="" class="close-btn" @click="onCancel" />
</div>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<!-- 第一行 2 列:服务类型 + 反馈渠道(医院端没有"所属医院" -->
<el-row :gutter="20">
<el-col :span="12">
<el-form-item :label="$t('label.type')" prop="serviceType">
<el-select v-model="form.serviceType" :placeholder="$t('placeholder.type')" clearable style="width: 100%">
<el-option label="故障报修" value="故障报修" />
<el-option label="业务咨询" value="业务咨询" />
<el-option label="需求变更" value="需求变更" />
<el-option label="其他" value="其他" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="$t('label.feedbackchannel')" prop="feedbackchannel">
<el-select v-model="form.feedbackchannel" :placeholder="$t('placeholder.feedbackchannel')" clearable
style="width: 100%">
<el-option label="微信群" value="微信群" />
<el-option label="微信" value="微信" />
<el-option label="电话" value="电话" />
<el-option label="现场" value="现场" />
<el-option label="钉钉群" value="钉钉群" />
<el-option label="钉钉" value="钉钉" />
<el-option label="其他" value="其他" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<!-- 第二行 3 列:科室 + 提单人 + 提交时间 -->
<el-row :gutter="20">
<el-col :span="8">
<el-form-item :label="$t('label.dept')" prop="department">
<el-input v-model="form.department" :placeholder="$t('placeholder.dept')" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item :label="$t('label.name')" prop="submitter">
<el-input v-model="form.submitter" :placeholder="$t('placeholder.name')" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item :label="$t('label.createat')" prop="createdat">
<el-date-picker v-model="form.createdat" type="datetime" :placeholder="$t('placeholder.createat')"
value-format="YYYY-MM-DD HH:mm:ss" format="YYYY-MM-DD HH:mm:ss" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<!-- 标题 + 颜色标签 + 优先级 同一行 -->
<el-form-item :label="$t('label.title')" prop="title">
<div class="title-row">
<el-input v-model="form.title" :placeholder="$t('placeholder.title')" maxlength="300" class="title-input">
<template #suffix>
<span class="title-counter"><span :style="{ color: form.colorTag || '#dcdfe6' }">{{ form.title.length
}}</span>/300</span>
<el-popover v-model:visible="colorPickerVisible" :width="180" placement="bottom-end" trigger="click"
:show-arrow="false" popper-class="color-tag-popper">
<template #reference>
<el-badge :color="form.colorTag || '#dcdfe6'" is-dot class="item">
<img src="@/assets/image/admin/orders/sepan.svg" alt="" class="title-sepan" />
</el-badge>
</template>
<div class="color-tag-title">{{ $t('label.colorTag') }}</div>
<div class="color-tag-options">
<div v-for="c in colorOptions" :key="c.value" class="color-tag-option"
:class="{ active: form.colorTag === c.value }" :style="{ background: c.value }" :title="c.label"
@click="selectColor(c.value)"></div>
</div>
</el-popover>
</template>
</el-input>
<div class="priority-wrap">
<div class="priority-label">{{ $t('label.priority') }}</div>
<el-select v-model="form.priority" class="priority-select" :teleported="false">
<template #label="{ label, value }">
<div class="priority-row high" v-if="value === '高'">{{ value }}</div>
<div class="priority-row medium" v-if="value === '中'">{{ value }}</div>
<div class="priority-row low" v-if="value === '低'">{{ value }}</div>
</template>
<el-option v-for="opt in priorityOptions" :key="opt.value" :value="opt.value">
<span class="priority-option">
<span class="priority-dot" :class="opt.cls"></span>
<span class="priority-option-label">{{ opt.label }}</span>
</span>
</el-option>
</el-select>
</div>
</div>
</el-form-item>
<el-form-item :label="$t('label.description')" prop="description">
<div class="rich-editor">
<Toolbar :editor="editorRef" :default-config="toolbarConfig" mode="default"
style="border-bottom: 1px solid #dcdfe6" />
<Editor v-model="form.description" :default-config="editorConfig" mode="default"
style="height: 200px; overflow-y: auto" @on-created="handleEditorCreated"
@custom-paste="handleEditorPasteImage" />
</div>
</el-form-item>
<el-form-item class="fujian" :label="$t('label.attachment')">
<el-upload class="upload-wrap" v-model:file-list="form.files" :auto-upload="false" :limit="1"
:on-exceed="handleAttachmentExceed" :on-remove="handleAttachmentRemove" :on-change="handleAttachmentChange">
<div class="upload-btn">
<div class="left">+ 添加文件</div>
<div class="right">{{ $t('label.attachmentTip') }}</div>
</div>
<template #file="{ file }">
<div class="file-item">
<img class="file-icon" src="@/assets/image/admin/orders/file.svg" alt="">
<div class="file-name">&nbsp;&nbsp;{{ file.name }}</div>
<div class="file-size">&nbsp;&nbsp;{{ ((file.size || 0) / 1024 / 1024).toFixed(2) }}MB</div>
<div class="file-delete" @click="handleAttachmentDelete(file)">删除</div>
</div>
</template>
</el-upload>
</el-form-item>
</el-form>
</div>
<div class="body-footer">
<el-button @click="onCancel">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" :loading="submitting" @click="submit">
{{ $t('common.submit') }}
</el-button>
</div>
</el-dialog>
</template>
<script setup>
import dayjs from "dayjs";
import {
nextTick,
onBeforeUnmount,
reactive,
ref,
shallowRef,
watch,
} from "vue";
import { ElMessage } from "element-plus";
import { Editor, Toolbar } from "@wangeditor/editor-for-vue";
import "@wangeditor/editor/dist/css/style.css";
import { useI18n } from "vue-i18n";
import {
createHospitalOrder,
getHospitalOrderDetail,
updateHospitalOrder,
getPoints,
} from "@/service/modular/hospital";
import { useRewardResultStore } from "@/stores/rewardResult";
import iconSubmit from "@/assets/image/hospital/ponits/icon-submit-large.svg";
const props = defineProps({
modelValue: { type: Boolean, default: false },
orderId: { type: [String, Number], default: null },
});
const emit = defineEmits(["update:modelValue", "success"]);
const { t: $t } = useI18n();
const isEdit = () => !!props.orderId;
const formRef = ref();
const submitting = ref(false);
// 已加载的原始附件 filePath 集合(用于判断用户是否需要保留原附件)
const originalAttachments = ref([]);
const rewardResultStore = useRewardResultStore();
const getAttachmentFileName = (path) => {
if (!path) return "";
return String(path).replace(/\\/g, "/").split("/").pop() || "";
};
// 根据后端返回填充表单(编辑模式)
const fillFormFromDetail = (detail) => {
if (!detail) return;
form.title = detail.title || "";
form.serviceType = detail.serviceType || "";
form.priority = detail.priority || "";
form.feedbackchannel = detail.feedbackChannel || detail.feedbackchannel || "";
form.department = detail.department || "";
form.colorTag = detail.colorTag || "#667eea";
form.description = detail.description || "";
form.submitter = detail.submitter || "";
form.createdat = detail.createdAt
? dayjs(detail.createdAt).format("YYYY-MM-DD HH:mm:ss")
: "";
// 附件回显
const atts = detail.attachments || [];
originalAttachments.value = atts.map((a) => a.filePath).filter(Boolean);
if (atts.length) {
const first = atts[0];
const fileName = first.fileName || getAttachmentFileName(first.filePath);
if (fileName) {
form.files = [
{
uid: `attachment-${props.orderId}-0`,
name: fileName,
status: "success",
size: first.fileSize || 0,
},
];
return;
}
}
form.files = [];
};
const loadDetail = async (id) => {
try {
const res = await getHospitalOrderDetail(id);
fillFormFromDetail(res);
} catch (_) {
ElMessage.error("加载工单详情失败");
}
};
const form = reactive({
title: "",
serviceType: "",
priority: "",
feedbackchannel: "",
department: "",
colorTag: "#667eea",
description: "",
submitter: "",
createdat: "",
files: [],
});
const rules = {
title: [{ required: true, message: () => $t('msg.pleaseInputTitle'), trigger: "blur" }],
serviceType: [{ required: true, message: () => $t('msg.pleaseSelectType'), trigger: "change" }],
priority: [{ required: true, message: () => $t('msg.pleaseSelectPriority'), trigger: "change" }],
feedbackchannel: [
{ required: true, message: () => $t('msg.pleaseSelectFeedbackChannel'), trigger: "change" },
],
department: [
{ required: true, whitespace: true, message: () => $t('msg.pleaseInputDept'), trigger: "blur" },
],
submitter: [
{ required: true, whitespace: true, message: () => $t('msg.pleaseInputName'), trigger: "blur" },
],
description: [
{
required: true,
validator: (_, value, callback) => {
const hasImage = /<img\s/i.test(value || "");
const text = (value || "")
.replace(/<[^>]*>/g, "")
.replace(/&nbsp;/gi, "")
.trim();
if (!text && !hasImage) return callback(new Error($t('msg.pleaseInputDescription')));
callback();
},
trigger: "change",
},
],
};
// 颜色标签可选值
const colorOptions = [
{ label: "", value: "#667eea" },
{ label: "绿", value: "#52c41a" },
{ label: "", value: "#13c2c2" },
{ label: "", value: "#722ed1" },
{ label: "", value: "#fa8c16" },
{ label: "", value: "#ff4d4f" },
];
const colorPickerVisible = ref(false);
const selectColor = (val) => {
form.colorTag = val;
colorPickerVisible.value = false;
};
// 优先级可选值(带高/中/低配色 cls
const priorityOptions = [
{ label: "", value: "", cls: "high" },
{ label: "", value: "", cls: "medium" },
{ label: "", value: "", cls: "low" },
];
const MAX_FILE_SIZE = 50 * 1024 * 1024;
const MAX_PASTE_IMAGE_SIZE = 5 * 1024 * 1024;
const handleAttachmentChange = (file) => {
if (file?.size > MAX_FILE_SIZE) {
ElMessage.warning("文件不能超过 50MB");
form.files = form.files.filter(
(f) => f.uid !== file.uid || f.size <= MAX_FILE_SIZE,
);
}
};
const handleAttachmentExceed = () => {
ElMessage.warning("只能上传 1 个文件");
};
const handleAttachmentRemove = () => {
// el-upload 会自动从 v-model:file-list 移除
};
const handleAttachmentDelete = (file) => {
// 自定义"删除"按钮:按 uid 从 form.files 中移除(与 admin 端保持一致)
form.files = (form.files || []).filter((f) => f.uid !== file?.uid);
};
const nowDatetime = () => dayjs().format("YYYY-MM-DD HH:mm:ss");
// 富文本编辑器配置
const editorRef = shallowRef();
const toolbarConfig = {
excludeKeys: [
"uploadImage",
"insertVideo",
"uploadVideo",
"group-video",
"emotion",
"group-emoji",
],
};
const collectPastedImages = (clipboardData) => {
const files = [];
const items = clipboardData?.items;
if (items) {
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind === "file" && item.type?.startsWith("image/")) {
const file = item.getAsFile();
if (file) files.push(file);
}
}
}
if (files.length === 0 && clipboardData?.files) {
for (let i = 0; i < clipboardData.files.length; i++) {
const file = clipboardData.files[i];
if (file.type?.startsWith("image/")) files.push(file);
}
}
return files;
};
const handleEditorPasteImage = (editor, event) => {
if (event.__wpsImageHandled) return;
const imageFiles = collectPastedImages(event.clipboardData);
if (imageFiles.length === 0) return;
event.__wpsImageHandled = true;
event.preventDefault();
if (typeof event.stopPropagation === "function") event.stopPropagation();
if (typeof event.stopImmediatePropagation === "function")
event.stopImmediatePropagation();
if (typeof editor.focus === "function") editor.focus();
const savedSelection = editor.selection;
imageFiles.forEach((file) => {
if (file.size > MAX_PASTE_IMAGE_SIZE) {
ElMessage.warning("粘贴的图片不能超过 5MB");
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const dataUrl = e.target?.result;
if (!dataUrl) {
ElMessage.error("图片读取失败");
return;
}
try {
if (savedSelection) editor.select(savedSelection);
} catch (_) {
if (typeof editor.focus === "function") editor.focus();
}
if (typeof editor.focus === "function") editor.focus();
editor.dangerouslyInsertHtml(
`<img src="${dataUrl}" style="max-width:100%;height:auto;" />`,
);
if (typeof editor.updateView === "function") editor.updateView();
};
reader.onerror = () => {
ElMessage.error("图片处理失败");
};
reader.readAsDataURL(file);
});
return false;
};
const editorConfig = {
placeholder: $t('placeholder.description'),
};
let pasteHandlerRef = null;
const handleEditorCreated = (editor) => {
editorRef.value = editor;
nextTick(() => {
const root = typeof editor.$el === "function" ? editor.$el() : editor.$el;
if (!root) return;
const editable = root.querySelector?.('[contenteditable="true"]') || root;
if (pasteHandlerRef) {
editable.removeEventListener("paste", pasteHandlerRef, { capture: true });
}
pasteHandlerRef = (event) => handleEditorPasteImage(editor, event);
editable.addEventListener("paste", pasteHandlerRef, { capture: true });
});
};
// ============== 取最新积分 ==============
const fetchLatestPoints = async () => {
try {
const res = await getPoints();
const data = res?.data ?? res ?? {};
return Number(data.totalPoints) || 0;
} catch (_) {
return 0;
}
};
const submit = async () => {
form.department = (form.department || "").trim();
form.submitter = (form.submitter || "").trim();
await formRef.value.validate(async (valid) => {
if (!valid) return;
submitting.value = true;
try {
const formData = new FormData();
const { files, ...rest } = form;
for (const [k, v] of Object.entries(rest)) {
if (v !== undefined && v !== null && v !== "") {
formData.append(k, v);
}
}
for (const f of files || []) {
if (f.raw) formData.append("files", f.raw, f.name);
}
if (isEdit()) {
await updateHospitalOrder(props.orderId, formData);
ElMessage.success("编辑成功");
emit("update:modelValue", false);
emit("success", { isEdit: true });
} else {
await createHospitalOrder(formData);
ElMessage.success($t('msg.submitSuccess'));
// 取最新积分,关闭自己,打开全局奖励结果弹窗
const totalPoints = await fetchLatestPoints();
emit("update:modelValue", false);
setTimeout(() => {
rewardResultStore.open({
title: "提交工单",
icon: iconSubmit,
points: 1,
batchGoal: 500,
batchCurrent: totalPoints,
rewardName: "康小虎 AI 吉祥物",
});
}, 100);
emit("success", { isCreate: true });
}
} finally {
submitting.value = false;
}
});
};
const onCancel = () => {
emit("update:modelValue", false);
};
const onClosed = () => {
// 关闭时重置表单(编辑模式下重新打开会重新加载详情)
form.title = "";
form.serviceType = "";
form.priority = "";
form.feedbackchannel = "";
form.department = "";
form.colorTag = "#667eea";
form.description = "";
form.submitter = "";
form.createdat = "";
form.files = [];
originalAttachments.value = [];
};
// 打开时根据 orderId 决定加载模式
watch(
() => [props.modelValue, props.orderId],
([visible, id]) => {
if (!visible) return;
if (id) {
loadDetail(id);
} else {
// 新增模式:默认提交时间为当前时间
form.createdat = nowDatetime();
}
},
);
const removePasteListener = () => {
const editor = editorRef.value;
if (!editor || !pasteHandlerRef) return;
const root = typeof editor.$el === "function" ? editor.$el() : editor.$el;
if (!root) return;
const editable = root.querySelector?.('[contenteditable="true"]') || root;
editable.removeEventListener("paste", pasteHandlerRef, { capture: true });
};
onBeforeUnmount(() => {
removePasteListener();
pasteHandlerRef = null;
const editor = editorRef.value;
if (editor == null) return;
editor.destroy();
});
</script>
<style lang="scss">
.hospital-order-form-dialog {
.rich-editor {
width: 100%;
background: #fff;
border-radius: 4px;
border: 1px solid #dcdfe6;
overflow: hidden;
}
.rich-editor :deep(.w-e-toolbar) {
background: #fafafa;
border-bottom: 1px solid #dcdfe6;
}
.rich-editor :deep(.w-e-text-container) {
background: #fff;
}
.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);
}
// 标题 + 颜色标签 + 优先级 同一行(与 admin 端一致)
.title-row {
display: flex;
align-items: center;
width: 100%;
}
.title-row .title-input {
flex: 1;
.el-input__wrapper {
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
}
}
.title-row :deep(.el-input__suffix) {
display: flex;
align-items: center;
gap: 8px;
}
.title-counter {
font-size: 13px;
color: #909399;
margin-right: 10px;
}
.title-sepan {
width: 16px;
height: 16px;
display: block;
cursor: pointer;
}
.color-tag-picker {
position: relative;
flex-shrink: 0;
}
// 优先级(与 admin 端一致:左 label + 右 select 拼接)
.priority-wrap {
display: flex;
align-items: center;
flex-shrink: 0;
border-radius: 8px;
height: 40px;
}
.priority-label {
height: 40px;
border-top: 1px solid #ebeef5;
border-bottom: 1px solid #ebeef5;
background: #F9FAFF;
color: #BCBCBC;
font-weight: 400;
font-size: 14px;
white-space: nowrap;
padding: 0 14px;
line-height: 40px;
}
.priority-select {
width: 110px;
}
.priority-select .el-select__wrapper {
background: transparent;
padding: 5px 13px;
display: flex;
justify-content: space-between;
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
}
.priority-select :deep(.el-select__placeholder) {
color: #303133;
}
.priority-option {
display: inline-flex;
align-items: center;
gap: 8px;
}
.priority-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
}
.priority-dot.high {
background: #ff383c;
}
.priority-dot.medium {
background: #ff8d28;
}
.priority-dot.low {
background: #3289fb;
}
.priority-option-label {
font-size: 14px;
color: #303133;
}
:deep(.fujian .el-form-item__content > div) {
width: 100% !important;
}
}
</style>
<style lang="scss">
.hospital-order-form-dialog {
max-width: 90vw;
width: 1168px;
overflow: visible;
.body-footer {
border-radius: 0 0 24px 24px;
}
--el-component-size: 40px;
.el-select__wrapper {
height: 40px;
}
.fujian {
.upload-wrap {
width: 100%;
.el-upload-list__item {
&:hover {
background: transparent;
}
.file-item {
display: flex;
align-items: center;
font-weight: 400;
font-size: 14px;
.file-icon {
width: 18px;
height: 18px;
}
.file-name,
.file-delete {
color: #1D2129;
&:hover {
color: #1890ff;
}
}
.file-delete {
margin-left: 14px;
cursor: pointer;
}
.file-size {
color: #BCBCBC;
}
}
}
}
.upload-btn {
display: flex;
align-items: center;
.left {
font-weight: 400;
font-size: 14px;
color: #2B324C;
}
.right {
font-weight: 400;
font-size: 14px;
color: #666666;
}
}
}
}
// 颜色标签 popover 内容(默认 teleport 到 body需放非 scoped 样式块)
.color-tag-popper {
padding: 12px 14px !important;
border-radius: 10px !important;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12) !important;
min-width: 180px;
}
</style>