添加编辑工单

master
2358328281@qq.com 2 days ago
parent 51cac2c3db
commit 0249ef7d2c

@ -17,6 +17,10 @@ export const createAdminOrder = (formData) => {
export const processAdminOrder = (id, data) => {
return kcRequest.request(`/admin/orders/${id}/process`, "put", data);
};
// 编辑工单multipart/form-data承载附件 files
export const updateAdminOrder = (id, formData) => {
return kcRequest.uploadFile(`/admin/orders/${id}`, formData, true, "PUT");
};
// ============== 医院信息 ==============
export const getAdminHospitals = (params) => {

@ -39,7 +39,7 @@
/>
<el-table-column :label="$t('label.category')" prop="customerCategory" width="auto" />
<el-table-column :label="$t('table.contact')" prop="contactPerson" width="auto" />
<el-table-column :label="$t('table.phone')" prop="contactPhone" width="auto" />
<el-table-column :label="$t('table.phone')" prop="contactPhone" :min-width="getPhoneMinWidth()" show-overflow-tooltip />
<el-table-column :label="$t('table.manager')" prop="managerName" width="auto" />
<el-table-column :label="$t('table.usageYears')" width="auto">
<template #default="{ row }">{{ formatUsageYears(row.signDate) }}</template>
@ -118,6 +118,14 @@ const pageSize = ref(10);
const loading = ref(false);
const filters = reactive({ keyword: "", category: "" });
const getPhoneMinWidth = () => {
const rootStyle = getComputedStyle(document.documentElement);
const remBase = parseFloat(rootStyle.fontSize) || 16;
const elFontSizeBase = parseFloat(rootStyle.getPropertyValue('--el-font-size-base')) || 1;
const minWidthRem = remBase * elFontSizeBase * 12 * 0.56 + 24; // 10 + padding
return `${minWidthRem.toFixed(4)}px`;
};
const formatDate = (v) => (v ? dayjs(v).format("YYYY-MM-DD") : "—");
// 使"XX"
@ -206,4 +214,11 @@ onMounted(loadList);
height: 80vh;
border: 0;
}
:deep(.contact-phone) {
// width: 120em !important;
.cell {
white-space: nowrap;
}
}
</style>

@ -14,12 +14,12 @@
<el-tag :type="statusType(detail.status)" size="small">{{ detail.status }}</el-tag>
<el-button
v-if="detail.status !== '已完成'"
type="success"
type="primary"
size="small"
style="margin-left:auto"
@click="goProcess"
@click="openEdit"
>
{{ $t('btn.process') }}
{{ $t('btn.edit') }}
</el-button>
</div>
</template>
@ -71,6 +71,179 @@
</div>
</el-card>
<!-- 编辑工单弹窗 -->
<el-dialog
v-model="editVisible"
:title="$t('modal.edit')"
width="640px"
:close-on-click-modal="false"
@closed="onEditClosed"
>
<el-form
ref="editFormRef"
:model="editForm"
:rules="editRules"
label-width="100px"
v-loading="editLoading"
>
<el-form-item :label="$t('label.hospitalName')" prop="hospitalId">
<el-select
v-model="editForm.hospitalId"
:placeholder="$t('msg.pleaseSelectHospital')"
filterable
style="width: 100%"
>
<el-option
v-for="h in hospitals"
:key="h.id"
:label="h.name"
:value="h.id"
/>
</el-select>
</el-form-item>
<el-form-item :label="$t('label.title')" prop="title">
<div class="title-row">
<el-input
v-model="editForm.title"
:placeholder="$t('placeholder.title')"
maxlength="100"
show-word-limit
/>
<div class="color-tag-picker" ref="editColorPickerRef">
<div
class="color-tag-preview"
:style="{ background: editForm.colorTag || '#dcdfe6' }"
:class="{ 'is-empty': !editForm.colorTag }"
@click.stop="toggleEditColorPicker"
></div>
<div
v-show="editColorPickerVisible"
class="color-tag-dropdown"
@click.stop
>
<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: editForm.colorTag === c.value }"
:style="{ background: c.value }"
:title="c.label"
@click="selectEditColor(c.value)"
></div>
</div>
</div>
</div>
</div>
</el-form-item>
<el-form-item :label="$t('label.type')" prop="serviceType">
<el-select
v-model="editForm.serviceType"
:placeholder="$t('placeholder.type')"
clearable
>
<el-option label="故障问题" value="故障问题" />
<el-option label="使用咨询" value="使用咨询" />
<el-option label="功能需求" value="功能需求" />
<el-option label="其他" value="其他" />
</el-select>
</el-form-item>
<el-form-item label-width="0">
<div class="inline-row">
<el-form-item :label="$t('label.priority')" prop="priority" class="inline-item">
<el-radio-group v-model="editForm.priority">
<el-radio-button label="高"></el-radio-button>
<el-radio-button label="中"></el-radio-button>
<el-radio-button label="低"></el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item :label="$t('label.feedbackchannel')" prop="feedbackchannel" class="inline-item">
<el-select
v-model="editForm.feedbackchannel"
:placeholder="$t('placeholder.feedbackchannel')"
clearable
style="width: 160px"
>
<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>
</div>
</el-form-item>
<el-form-item :label="$t('label.dept')" prop="department">
<el-input
v-model="editForm.department"
:placeholder="$t('placeholder.dept')"
/>
</el-form-item>
<el-form-item :label="$t('label.name')" prop="submitter">
<el-input
v-model="editForm.submitter"
:placeholder="$t('placeholder.name')"
/>
</el-form-item>
<el-form-item :label="$t('label.createat')" prop="createdat">
<el-date-picker
v-model="editForm.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-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="editForm.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 :label="$t('label.attachment')">
<el-upload
v-model:file-list="editForm.files"
:auto-upload="false"
:limit="1"
:on-exceed="handleAttachmentExceed"
:on-change="handleAttachmentChange"
:on-remove="handleAttachmentRemove"
>
<el-button type="primary" plain>{{ $t("btn.upload") }}</el-button>
<template #tip>
<div class="el-upload__tip">
{{ $t("label.attachmentTip") }}
</div>
</template>
</el-upload>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editVisible = false">{{ $t("common.cancel") }}</el-button>
<el-button
type="primary"
:loading="editSubmitting"
@click="submitEdit"
>{{ $t("common.save") }}</el-button>
</template>
</el-dialog>
<el-dialog
v-model="previewVisible"
:title="$t('btn.preview')"
@ -94,12 +267,29 @@
</template>
<script setup>
import { onMounted, ref } from "vue";
import dayjs from "dayjs";
import {
nextTick,
onBeforeUnmount,
onMounted,
reactive,
ref,
shallowRef,
} from "vue";
import { useRoute, useRouter } from "vue-router";
import { ArrowLeft, Document } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { getAdminOrderDetail, getUploadFile } from "@/service/modular/admin";
import { Editor, Toolbar } from "@wangeditor/editor-for-vue";
import "@wangeditor/editor/dist/css/style.css";
import { useI18n } from "vue-i18n";
import {
getAdminOrderDetail,
getAdminHospitals,
getUploadFile,
updateAdminOrder,
} from "@/service/modular/admin";
const { t: $t } = useI18n();
const route = useRoute();
const router = useRouter();
const loading = ref(false);
@ -119,7 +309,6 @@ const loadDetail = async () => {
};
const goBack = () => router.back();
const goProcess = () => router.push(`/admin/orders/process/${route.params.id}`);
const previewVisible = ref(false);
const previewUrl = ref("");
@ -168,7 +357,6 @@ const 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);
@ -186,7 +374,372 @@ const viewReport = async (attachmentPath, originalName) => {
}
};
onMounted(loadDetail);
// ============== ==============
const hospitals = ref([]);
// Hex
const colorOptions = [
{ label: "蓝", value: "#667eea" },
{ label: "绿", value: "#52c41a" },
{ label: "青", value: "#13c2c2" },
{ label: "紫", value: "#722ed1" },
{ label: "橙", value: "#fa8c16" },
{ label: "红", value: "#ff4d4f" },
];
const loadHospitals = async () => {
try {
const res = await getAdminHospitals({ page: 1, pageSize: 1000 });
hospitals.value = res?.list || [];
} catch (_) {
hospitals.value = [];
}
};
const editVisible = ref(false);
const editLoading = ref(false);
const editSubmitting = ref(false);
const editFormRef = ref();
// filePath
const editOriginalAttachments = ref([]);
//
const editColorPickerVisible = ref(false);
const editColorPickerRef = ref();
const toggleEditColorPicker = () => {
editColorPickerVisible.value = !editColorPickerVisible.value;
};
const selectEditColor = (val) => {
editForm.colorTag = val;
editColorPickerVisible.value = false;
};
const onDocClick = (e) => {
if (!editColorPickerVisible.value) return;
const el = editColorPickerRef.value;
if (el && !el.contains(e.target)) {
editColorPickerVisible.value = false;
}
};
const editForm = reactive({
hospitalId: undefined,
title: "",
serviceType: "",
priority: "中",
feedbackchannel: "",
department: "",
colorTag: "#667eea",
description: "",
submitter: "",
createdat: "",
files: [],
});
const MAX_FILE_SIZE = 50 * 1024 * 1024;
const MAX_PASTE_IMAGE_SIZE = 5 * 1024 * 1024;
// el-upload
const handleAttachmentChange = (file) => {
if (file?.size > MAX_FILE_SIZE) {
ElMessage.warning("文件不能超过 50MB");
editForm.files = editForm.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 editRules = {
hospitalId: [
{
required: true,
message: () => $t("msg.pleaseSelectHospital"),
trigger: "change",
},
],
title: [
{
required: true,
message: () => $t("msg.pleaseInputTitle"),
trigger: "blur",
},
],
serviceType: [
{
required: true,
message: () => $t("msg.pleaseSelectType"),
trigger: "blur",
},
],
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 editorRef = shallowRef();
const toolbarConfig = {
excludeKeys: [
"uploadImage",
"insertVideo",
"uploadVideo",
"group-video",
"emotion",
"group-emoji",
],
};
// items files
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 getAttachmentFileName = (path) => {
if (!path) return "";
return String(path).replace(/\\/g, "/").split("/").pop() || "";
};
const resetEditForm = () => {
Object.assign(editForm, {
hospitalId: undefined,
title: "",
serviceType: "",
priority: "中",
feedbackchannel: "",
department: "",
colorTag: "#667eea",
description: "",
submitter: "",
createdat: "",
files: [],
});
editOriginalAttachments.value = [];
editFormRef.value?.clearValidate();
};
const openEdit = () => {
resetEditForm();
editLoading.value = true;
editVisible.value = true;
// v-model
nextTick(async () => {
const d = detail.value || {};
editForm.hospitalId = hospitals.value.find((h) => h.name === d.hospitalName)?.id || undefined;
editForm.title = d.title || "";
editForm.serviceType = d.serviceType || "";
editForm.priority = d.priority || "中";
editForm.feedbackchannel = d.feedbackChannel || d.feedbackchannel || "";
editForm.department = d.department || "";
editForm.colorTag = d.colorTag || "#667eea";
editForm.description = d.description || "";
editForm.submitter = d.submitter || "";
// T YYYY-MM-DD HH:mm:ss date-picker
editForm.createdat = d.createdAt
? dayjs(d.createdAt).format("YYYY-MM-DD HH:mm:ss")
: "";
// el-upload ""
const atts = d.attachments || [];
editOriginalAttachments.value = atts.map((a) => a.filePath).filter(Boolean);
if (atts.length) {
const first = atts[0];
const fileName = first.fileName || getAttachmentFileName(first.filePath);
if (fileName) {
editForm.files = [
{
uid: `attachment-${route.params.id}-0`,
name: fileName,
status: "success",
},
];
}
}
editLoading.value = false;
});
};
const onEditClosed = () => {
resetEditForm();
};
const submitEdit = async () => {
editForm.department = (editForm.department || "").trim();
editForm.submitter = (editForm.submitter || "").trim();
await editFormRef.value.validate(async (valid) => {
if (!valid) return;
editSubmitting.value = true;
try {
const formData = new FormData();
const { files, ...rest } = editForm;
for (const [k, v] of Object.entries(rest)) {
if (v !== undefined && v !== null && v !== "") {
formData.append(k, v);
}
}
// files formData
// files files
const item = (files || [])[0];
if (item) {
const file = item.raw || (typeof item.size === "number" ? item : null);
if (file) formData.append("files", file, item.name);
}
await updateAdminOrder(route.params.id, formData);
ElMessage.success($t("msg.editSuccess"));
editVisible.value = false;
loadDetail();
} finally {
editSubmitting.value = false;
}
});
};
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 });
};
onMounted(() => {
loadDetail();
loadHospitals();
document.addEventListener("click", onDocClick);
});
onBeforeUnmount(() => {
document.removeEventListener("click", onDocClick);
removePasteListener();
pasteHandlerRef = null;
const editor = editorRef.value;
if (editor == null) return;
editor.destroy();
});
</script>
<style scoped lang="scss">
@ -254,4 +807,119 @@ onMounted(loadDetail);
margin: 0 auto;
object-fit: contain;
}
.rich-editor {
width: 100%;
background: #fff;
border-radius: 4px;
// box-shadow wangEditor border
// box-shadow: 0 0 0 1px #dcdfe6 inset;
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;
}
// +
.title-row {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
}
.title-row .el-input {
flex: 1;
}
// +
.inline-row {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 0 24px;
width: 100%;
}
.inline-row .inline-item {
flex: 1 1 0;
min-width: 0;
margin-bottom: 0 !important;
}
.inline-row .inline-item :deep(.el-form-item__content) {
display: flex;
align-items: center;
flex-wrap: wrap;
}
.color-tag-picker {
position: relative;
flex-shrink: 0;
}
.color-tag-preview {
width: 28px;
height: 28px;
border-radius: 6px;
cursor: pointer;
border: 2px solid #fff;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1);
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
}
.color-tag-preview:hover {
transform: scale(1.08);
}
.color-tag-preview.is-empty {
background-image: linear-gradient(
135deg,
#f5f5f5 25%,
transparent 25%,
transparent 50%,
#f5f5f5 50%,
#f5f5f5 75%,
transparent 75%
);
background-size: 8px 8px;
box-shadow: 0 0 0 1px #dcdfe6;
}
.color-tag-dropdown {
position: absolute;
top: calc(100% + 8px);
left: 0;
z-index: 10;
background: #fff;
border-radius: 10px;
padding: 12px 14px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
min-width: 180px;
}
.color-tag-title {
font-size: 13px;
font-weight: 600;
color: #333;
margin-bottom: 10px;
}
.color-tag-options {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.color-tag-option {
width: 24px;
height: 24px;
border-radius: 6px;
cursor: pointer;
border: 2px solid transparent;
transition:
transform 0.2s ease,
border-color 0.2s ease;
}
.color-tag-option:hover {
transform: scale(1.12);
}
.color-tag-option.active {
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
</style>

Loading…
Cancel
Save