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.

874 lines
24 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>
<div class="quick-submit">
<el-card shadow="never">
<template #header>
<span class="card-title">{{ isEdit ? '编辑工单' : $t('modal.submit') }}</span>
</template>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="120px"
>
<el-form-item :label="$t('label.title')" prop="title">
<div class="title-row">
<el-input
v-model="form.title"
:placeholder="$t('placeholder.title')"
maxlength="100"
show-word-limit
/>
<div class="color-tag-picker" ref="colorPickerRef">
<div
class="color-tag-preview"
:style="{ background: form.colorTag || '#dcdfe6' }"
:class="{ 'is-empty': !form.colorTag }"
@click.stop="toggleColorPicker"
></div>
<div
v-show="colorPickerVisible"
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: form.colorTag === c.value }"
:style="{ background: c.value }"
:title="c.label"
@click="selectColor(c.value)"
></div>
</div>
</div>
</div>
</div>
</el-form-item>
<el-form-item :label="$t('label.type')" prop="serviceType">
<el-select
v-model="form.serviceType"
:placeholder="$t('placeholder.type')"
clearable
style="width: 240px"
>
<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="form.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="form.feedbackchannel"
:placeholder="$t('placeholder.feedbackchannel')"
clearable
style="width: 200px"
>
<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="form.department"
:placeholder="$t('placeholder.dept')"
/>
</el-form-item>
<el-form-item :label="$t('label.name')" prop="submitter">
<el-input
v-model="form.submitter"
:placeholder="$t('placeholder.name')"
/>
</el-form-item>
<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-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
v-model:file-list="form.files"
:auto-upload="false"
:limit="1"
:on-exceed="handleAttachmentExceed"
:on-remove="handleAttachmentRemove"
:on-change="handleAttachmentChange"
>
<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-item>
<el-button type="primary" :loading="submitting" @click="submit">{{ $t('common.submit') }}</el-button>
<el-button @click="onCancel">{{ $t('common.cancel') }}</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 提交工单后 +1 积分奖励弹窗 -->
<el-dialog
v-model="rewardVisible"
width="360px"
:show-close="false"
:close-on-click-modal="false"
:close-on-press-escape="false"
append-to-body
class="reward-dialog"
>
<div class="reward-content">
<div class="reward-badge">✦ 奖励已发放</div>
<div class="coin-wrapper">
<span class="reward-coin">🪙</span>
</div>
<div class="points-number">
+1 <small>积分</small>
</div>
<div class="reward-title">🎉 到账通知</div>
<div class="reward-sub">1 积分已放入您的钱包</div>
<div class="reward-progress">
<div class="reward-progress-label">
<span>🏆 距兑换「康小虎 AI 吉祥物」</span>
<span class="reward-distance">
<template v-if="rewardDistance > 0">
还差 {{ rewardDistance }} 分
</template>
<template v-else>已达标 🎉</template>
</span>
</div>
<div class="reward-bar-bg">
<div
class="reward-bar-fill"
:style="{ width: rewardProgress + '%' }"
></div>
</div>
<div class="reward-current">
当前 {{ rewardTotalPoints }} 分
</div>
</div>
<div class="reward-actions">
<el-button class="reward-btn reward-btn-received" @click="onRewardClose">
开心收下
</el-button>
<el-button class="reward-btn reward-btn-assets" type="warning" @click="goMyAssets">
查看我的资产 →
</el-button>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup>
import dayjs from "dayjs";
import {
nextTick,
onBeforeUnmount,
reactive,
ref,
shallowRef,
computed,
watch,
onMounted,
} from "vue";
import { useRouter,useRoute } from "vue-router";
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";
// 兑换目标(与 points 页保持一致)
const EXCHANGE_GOAL = 500;
const { t: $t } = useI18n();
const route = useRoute();
const router = useRouter();
const formRef = ref();
const submitting = ref(false);
// 编辑模式url 带 :id 参数
const isEdit = computed(() => !!route.params.id);
const editId = computed(() => route.params.id);
// 从带路径分隔符的地址中截取最后的文件名
const getAttachmentFileName = (path) => {
if (!path) return "";
return String(path).replace(/\\/g, "/").split("/").pop() || "";
};
// 已加载的原始附件 filePath 集合(用于判断用户是否需要保留原附件)
const originalAttachments = ref([]);
// 根据后端返回填充表单(编辑模式)
const fillFormFromDetail = (detail) => {
if (!detail) return;
form.title = detail.title || "";
form.serviceType = detail.serviceType || "";
form.priority = detail.priority || "中";
form.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")
: "";
// 附件回显:把详情里的第一个附件作为 el-upload 的“已上传”项展示
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-${editId.value}-0`,
name: fileName,
status: "success",
},
];
return;
}
}
form.files = [];
};
const loadDetail = async (id) => {
try {
const res = await getHospitalOrderDetail(id);
fillFormFromDetail(res);
} catch (_) {
ElMessage.error("加载工单详情失败");
}
};
// id 变化时重新加载(同一组件跨路由复用场景)
watch(
() => route.params.id,
(id) => {
if (id) {
loadDetail(id);
}
},
);
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 colorPickerRef = ref();
const toggleColorPicker = () => {
colorPickerVisible.value = !colorPickerVisible.value;
};
const selectColor = (val) => {
form.colorTag = val;
colorPickerVisible.value = false;
};
const onDocClick = (e) => {
if (!colorPickerVisible.value) return;
const el = colorPickerRef.value;
if (el && !el.contains(e.target)) {
colorPickerVisible.value = false;
}
};
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 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 });
});
};
// ============== 提交后奖励弹窗 ==============
// 后端在创建工单时已自动 +1 积分,这里仅刷新最新积分并提示用户
const rewardVisible = ref(false);
const rewardTotalPoints = ref(0);
const rewardProgress = ref(0); // 进度条宽度 (%)
const rewardDistance = ref(0); // 还差多少分
const playCoinAnimation = () => {
// 重播动画:先移除 class 再加回去(仅做 minimal 动画)
nextTick(() => {
const coin = document.querySelector(".reward-coin");
if (!coin) return;
coin.classList.remove("spin");
void coin.offsetWidth;
coin.classList.add("spin");
});
};
const showRewardDialog = async () => {
let total = 0;
try {
const res = await getPoints();
const data = res?.data ?? res ?? {};
total = Number(data.totalPoints) || 0;
} catch (_) {
// 刷新失败不阻塞
}
rewardTotalPoints.value = total;
rewardProgress.value = Math.min(100, Math.round((total / EXCHANGE_GOAL) * 100));
rewardDistance.value = Math.max(0, EXCHANGE_GOAL - total);
rewardVisible.value = true;
playCoinAnimation();
};
const onRewardClose = () => {
rewardVisible.value = false;
router.replace("/hospital/orders");
};
const goMyAssets = () => {
rewardVisible.value = false;
router.replace("/hospital/points");
};
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.value) {
await updateHospitalOrder(editId.value, formData);
ElMessage.success("编辑成功");
router.replace("/hospital/orders");
} else {
await createHospitalOrder(formData);
ElMessage.success($t('msg.submitSuccess'));
// 新建成功 → 弹出 +1 积分奖励提示,后端已自动加分
showRewardDialog();
}
} finally {
submitting.value = false;
}
});
};
const onCancel = () => router.back();
onMounted(() => {
document.addEventListener("click", onDocClick);
if (isEdit.value) {
// 编辑模式:加载详情填充表单
loadDetail(editId.value);
} 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(() => {
document.removeEventListener("click", onDocClick);
removePasteListener();
pasteHandlerRef = null;
const editor = editorRef.value;
if (editor == null) return;
editor.destroy();
});
</script>
<style scoped lang="scss">
.quick-submit {
:deep(.el-card) {
max-width: 100%;
}
.el-form {
max-width: 100%;
}
}
.card-title {
font-size: 16px;
font-weight: 600;
}
.rich-editor {
border: 1px solid #dcdfe6;
border-radius: 4px;
width: 100%;
overflow: hidden;
background: #fff;
}
.rich-editor :deep(.w-e-toolbar) {
background: #fafafa;
}
// +
.title-row {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
}
.title-row .el-input {
flex: 1;
}
// +
.inline-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0 24px;
width: 100%;
}
.inline-row .inline-item {
margin-bottom: 0 !important;
}
.inline-row .inline-item :deep(.el-form-item__content) {
display: flex;
align-items: center;
}
.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);
}
//
:deep(.fujian .el-form-item__content > div) {
width: 100% !important;
}
// ============== 提交后奖励弹窗 ==============
:deep(.reward-dialog) {
.el-dialog__header {
display: none;
}
.el-dialog__body {
padding: 0;
}
}
.reward-content {
text-align: center;
padding: 24px 24px 22px;
}
.reward-badge {
display: inline-block;
background: #fef3e6;
color: #c47a2e;
font-size: 12px;
font-weight: 600;
padding: 4px 16px;
border-radius: 40px;
margin-bottom: 8px;
}
.coin-wrapper {
height: 80px;
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 4px;
}
.reward-coin {
font-size: 64px;
display: inline-block;
filter: drop-shadow(0 8px 16px rgba(255, 193, 7, 0.3));
}
.reward-coin.spin {
animation: rewardCoinSpin 0.8s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
@keyframes rewardCoinSpin {
0% {
transform: rotateY(0deg) scale(0.2);
opacity: 0;
}
60% {
transform: rotateY(720deg) scale(1.2);
opacity: 1;
}
100% {
transform: rotateY(720deg) scale(1);
opacity: 1;
}
}
.points-number {
font-size: 48px;
font-weight: 800;
color: #f5a623;
line-height: 1.1;
margin-top: -4px;
}
.points-number small {
font-size: 20px;
font-weight: 600;
color: #b8863a;
}
.reward-title {
font-size: 22px;
font-weight: 700;
color: #1f140e;
margin: 4px 0 6px;
}
.reward-sub {
font-size: 14px;
color: #7a6a5c;
margin-bottom: 16px;
}
.reward-progress {
background: #f8f4f0;
border-radius: 16px;
padding: 14px 16px;
margin-bottom: 18px;
text-align: left;
}
.reward-progress-label {
display: flex;
justify-content: space-between;
font-size: 13px;
color: #4d3e32;
font-weight: 500;
gap: 8px;
}
.reward-distance {
color: #f5a623;
font-weight: 700;
flex-shrink: 0;
}
.reward-bar-bg {
width: 100%;
height: 6px;
background: #e6ddd5;
border-radius: 10px;
margin-top: 8px;
overflow: hidden;
}
.reward-bar-fill {
height: 100%;
background: linear-gradient(90deg, #f9b84a, #f5a623);
border-radius: 10px;
transition: width 0.6s ease;
}
.reward-current {
margin-top: 8px;
font-size: 12px;
color: #8a7a6a;
text-align: right;
}
.reward-actions {
display: flex;
gap: 10px;
}
.reward-btn {
flex: 1;
padding: 12px 0 !important;
font-size: 14px !important;
font-weight: 600 !important;
border-radius: 60px !important;
}
.reward-btn-received {
background: #f0ebe6 !important;
border-color: #f0ebe6 !important;
color: #4d3e32 !important;
}
.reward-btn-received:hover {
background: #e6ddd5 !important;
border-color: #e6ddd5 !important;
}
.reward-btn-assets {
background: #f9b84a !important;
border-color: #f9b84a !important;
color: #1f140e !important;
box-shadow: 0 4px 14px rgba(249, 184, 74, 0.25);
}
.reward-btn-assets:hover {
background: #f5ac2e !important;
border-color: #f5ac2e !important;
transform: translateY(-1px);
}
</style>