gongchunyi
2026-05-15 51081f3acbeef7e5358e20a653c38b0ed3afbe23
feat: 设备维修新增报修人、验收人、维修人
已添加3个文件
已修改4个文件
1119 ■■■■■ 文件已修改
src/api/equipmentManagement/repair.js 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/equipmentManagement/repairFileType.js 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/equipmentManagement/repair/Modal/AcceptanceModal.vue 125 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/equipmentManagement/repair/Modal/MaintainModal.vue 161 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/equipmentManagement/repair/Modal/RepairDetailModal.vue 208 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/equipmentManagement/repair/Modal/RepairModal.vue 324 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/equipmentManagement/repair/index.vue 277 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/equipmentManagement/repair.js
@@ -26,7 +26,7 @@
/**
 * @desc è®¾å¤‡æŠ¥ä¿®-上传附件
 * @param {FormData} formData file + deviceRepairId
 * @param {FormData} formData file + deviceRepairId + fileType(14设备问题/15维修完成)
 */
export const uploadRepairFile = (formData) => {
  return request({
@@ -108,3 +108,14 @@
    data,
  });
};
/**
 * @desc è®¾å¤‡æŠ¥ä¿®éªŒæ”¶
 */
export const acceptRepair = (data) => {
  return request({
    url: `/device/repair/acceptance`,
    method: "post",
    data,
  });
};
src/api/equipmentManagement/repairFileType.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,11 @@
/** è®¾å¤‡æŠ¥ä¿®é™„件类型 */
export const REPAIR_FILE_TYPE_LEGACY = 13;
export const REPAIR_FILE_TYPE_PROBLEM = 14;
export const REPAIR_FILE_TYPE_MAINTAIN = 15;
/** æ˜¯å¦è®¾å¤‡é—®é¢˜ç±»å›¾ç‰‡ */
export const isProblemRepairFile = (type) =>
  type === REPAIR_FILE_TYPE_PROBLEM || type === REPAIR_FILE_TYPE_LEGACY;
/** æ˜¯å¦ç»´ä¿®å®Œæˆç±»å›¾ç‰‡ */
export const isMaintainRepairFile = (type) => type === REPAIR_FILE_TYPE_MAINTAIN;
src/views/equipmentManagement/repair/Modal/AcceptanceModal.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,125 @@
<template>
  <FormDialog
    v-model="visible"
    title="验收审批"
    width="520px"
    :loading="loading"
    @confirm="sendForm"
    @cancel="handleCancel"
    @close="handleClose"
  >
    <el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
      <el-form-item label="验收人" prop="acceptanceName">
        <el-input v-model="form.acceptanceName" disabled placeholder="报修时指定的验收人" />
      </el-form-item>
      <el-form-item label="验收时间" prop="acceptanceTime">
        <el-date-picker
          v-model="form.acceptanceTime"
          type="datetime"
          placeholder="请选择验收时间"
          format="YYYY-MM-DD HH:mm:ss"
          value-format="YYYY-MM-DD HH:mm:ss"
          style="width: 100%"
        />
      </el-form-item>
      <el-form-item label="验收备注" prop="acceptanceRemark">
        <el-input
          v-model="form.acceptanceRemark"
          type="textarea"
          :rows="3"
          placeholder="请输入验收备注"
        />
      </el-form-item>
    </el-form>
  </FormDialog>
</template>
<script setup>
import FormDialog from "@/components/Dialog/FormDialog.vue";
import { acceptRepair } from "@/api/equipmentManagement/repair";
import useFormData from "@/hooks/useFormData";
import useUserStore from "@/store/modules/user";
import dayjs from "dayjs";
import { ElMessage } from "element-plus";
defineOptions({
  name: "设备报修验收弹窗",
});
const emits = defineEmits(["ok"]);
const repairId = ref();
const visible = ref(false);
const loading = ref(false);
const formRef = ref();
const userStore = useUserStore();
const rules = {
  acceptanceName: [{ required: true, message: "验收人不能为空", trigger: "blur" }],
  acceptanceTime: [{ required: true, message: "请选择验收时间", trigger: "change" }],
  acceptanceRemark: [{ required: true, message: "请输入验收备注", trigger: "blur" }],
};
const { form, resetForm } = useFormData({
  acceptanceName: undefined,
  acceptanceTime: dayjs().format("YYYY-MM-DD HH:mm:ss"),
  acceptanceRemark: undefined,
});
const setForm = (row) => {
  form.acceptanceName = row.acceptanceName;
  form.acceptanceTime = dayjs().format("YYYY-MM-DD HH:mm:ss");
  form.acceptanceRemark = undefined;
};
const sendForm = async () => {
  const valid = await formRef.value?.validate().catch(() => false);
  if (!valid) return;
  if (form.acceptanceName !== userStore.nickName) {
    ElMessage.warning("仅指定的验收人可进行验收");
    return;
  }
  loading.value = true;
  try {
    const { code, msg } = await acceptRepair({
      id: repairId.value,
      acceptanceTime: form.acceptanceTime,
      acceptanceRemark: form.acceptanceRemark,
    });
    if (code === 200) {
      ElMessage.success("验收成功");
      visible.value = false;
      emits("ok");
    } else if (msg) {
      ElMessage.error(msg);
    }
  } finally {
    loading.value = false;
  }
};
const handleCancel = () => {
  resetForm();
  visible.value = false;
};
const handleClose = () => {
  resetForm();
  visible.value = false;
};
const open = async (id, row) => {
  if (!row?.acceptanceName || row.acceptanceName !== userStore.nickName) {
    ElMessage.warning("仅指定的验收人可进行验收");
    return;
  }
  repairId.value = id;
  visible.value = true;
  await nextTick();
  setForm(row);
};
defineExpose({
  open,
});
</script>
src/views/equipmentManagement/repair/Modal/MaintainModal.vue
@@ -1,27 +1,27 @@
<template>
  <FormDialog
    v-model="visible"
    :title="'设备维修'"
    width="500px"
    title="设备维修"
    width="640px"
    :loading="loading"
    @confirm="sendForm"
    @cancel="handleCancel"
    @close="handleClose"
  >
    <el-form :model="form" label-width="80px">
      <el-form-item label="维修人">
        <el-input v-model="form.maintenanceName" placeholder="请输入维修人" />
    <el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
      <el-form-item label="维修人" prop="maintenanceName">
        <el-input v-model="form.maintenanceName" disabled placeholder="报修时指定的维修人" />
      </el-form-item>
      <el-form-item label="维修结果">
      <el-form-item label="维修结果" prop="maintenanceResult">
        <el-input v-model="form.maintenanceResult" placeholder="请输入维修结果" />
      </el-form-item>
      <el-form-item label="维修状态">
        <el-select v-model="form.status">
          <el-option label="待报修" :value="0"></el-option>
          <el-option label="完结" :value="1"></el-option>
          <el-option label="失败" :value="2"></el-option>
      <el-form-item label="维修状态" prop="status">
        <el-select v-model="form.status" style="width: 100%">
          <el-option label="待验收" :value="3" />
          <el-option label="维修失败" :value="2" />
        </el-select>
      </el-form-item>
      <el-form-item label="维修日期">
      <el-form-item label="维修日期" prop="maintenanceTime">
        <el-date-picker
          v-model="form.maintenanceTime"
          placeholder="请选择维修日期"
@@ -32,17 +32,39 @@
          style="width: 100%"
        />
      </el-form-item>
      <el-form-item label="附件">
        <el-upload
          class="repair-attachment-upload"
          v-model:file-list="attachmentFileList"
          drag
          multiple
          :auto-upload="false"
          :limit="9"
          accept="image/png,image/jpeg,image/jpg"
          :before-upload="beforeAttachmentUpload"
        >
          <el-icon class="repair-upload-icon"><UploadFilled /></el-icon>
          <div class="el-upload__text">将文件拖到此处,或 <em>点击选择文件</em></div>
          <template #tip>
            <div class="el-upload__tip">
              æ”¯æŒ png / jpg / jpeg,单个不超过 50MB,保存后与列表「附件」同步
            </div>
          </template>
        </el-upload>
      </el-form-item>
    </el-form>
  </FormDialog>
</template>
<script setup>
import FormDialog from "@/components/Dialog/FormDialog.vue";
import { addMaintain } from "@/api/equipmentManagement/repair";
import { addMaintain, uploadRepairFile } from "@/api/equipmentManagement/repair";
import { REPAIR_FILE_TYPE_MAINTAIN } from "@/api/equipmentManagement/repairFileType.js";
import useFormData from "@/hooks/useFormData";
import useUserStore from "@/store/modules/user";
import dayjs from "dayjs";
import { ElMessage } from "element-plus";
import { UploadFilled } from "@element-plus/icons-vue";
defineOptions({
  name: "维修模态框",
@@ -50,39 +72,95 @@
const emits = defineEmits(["ok"]);
// ä¿å­˜æŠ¥ä¿®è®°å½•çš„id
const repairId = ref();
const visible = ref(false);
const loading = ref(false);
const formRef = ref();
const attachmentFileList = ref([]);
const ATTACH_MAX_MB = 50;
const userStore = useUserStore();
const rules = {
  maintenanceName: [{ required: true, message: "请选择维修人", trigger: "change" }],
  maintenanceResult: [{ required: true, message: "请输入维修结果", trigger: "blur" }],
  maintenanceTime: [{ required: true, message: "请选择维修日期", trigger: "change" }],
};
const { form, resetForm } = useFormData({
  maintenanceName: undefined, // ç»´ä¿®åç§°
  maintenanceResult: undefined, // ç»´ä¿®ç»“æžœ
  maintenanceTime: undefined, // ç»´ä¿®æ—¥æœŸ
  status: 0,
  maintenanceName: undefined,
  maintenanceResult: undefined,
  maintenanceTime: dayjs().format("YYYY-MM-DD HH:mm:ss"),
  status: 3,
});
const syncMaintenanceNameFromRow = (row) => {
  form.maintenanceName = row?.maintenanceName || "";
};
const beforeAttachmentUpload = (rawFile) => {
  const okType = ["image/jpeg", "image/jpg", "image/png"].includes(rawFile.type);
  if (!okType) {
    ElMessage.error("只能上传 png / jpg / jpeg å›¾ç‰‡");
    return false;
  }
  if (rawFile.size > ATTACH_MAX_MB * 1024 * 1024) {
    ElMessage.error(`单个文件不能超过 ${ATTACH_MAX_MB}MB`);
    return false;
  }
  return true;
};
const clearAttachmentQueue = () => {
  attachmentFileList.value = [];
};
const uploadQueuedImages = async (id) => {
  const files = (attachmentFileList.value || []).map((f) => f.raw).filter(Boolean);
  for (const file of files) {
    const fd = new FormData();
    fd.append("file", file);
    fd.append("deviceRepairId", String(id));
    fd.append("fileType", String(REPAIR_FILE_TYPE_MAINTAIN));
    const res = await uploadRepairFile(fd);
    if (res.code !== 200) {
      throw new Error(res.msg || "附件上传失败");
    }
  }
};
const setForm = (data) => {
  form.maintenanceName = data.maintenanceName ?? userStore.nickName;
  syncMaintenanceNameFromRow(data);
  form.maintenanceResult = data.maintenanceResult;
  form.maintenanceTime =
    data.maintenanceTime
  form.maintenanceTime = data.maintenanceTime
      ? dayjs(data.maintenanceTime).format("YYYY-MM-DD HH:mm:ss")
      : dayjs().format("YYYY-MM-DD HH:mm:ss");
  form.status = 1; // é»˜è®¤çŠ¶æ€ä¸ºå®Œç»“
  form.status = 3;
};
const sendForm = async () => {
  const valid = await formRef.value?.validate().catch(() => false);
  if (!valid) return;
  if (form.maintenanceName !== userStore.nickName) {
    ElMessage.warning("仅指定的维修人可进行维修");
    return;
  }
  loading.value = true;
  try {
    const { code } = await addMaintain({ id: repairId.value, ...form });
    if (code == 200) {
      ElMessage.success("维修成功");
      emits("ok");
      resetForm();
      visible.value = false;
    const { code, msg } = await addMaintain({ id: repairId.value, ...form });
    if (code !== 200) {
      if (msg) ElMessage.error(msg);
      return;
    }
    if (attachmentFileList.value.length) {
      await uploadQueuedImages(repairId.value);
    }
    ElMessage.success("维修成功");
    clearAttachmentQueue();
    visible.value = false;
    emits("ok");
  } catch (e) {
    ElMessage.error(e?.message || "保存或上传附件失败");
  } finally {
    loading.value = false;
  }
@@ -90,19 +168,26 @@
const handleCancel = () => {
  resetForm();
  clearAttachmentQueue();
  visible.value = false;
};
const handleClose = () => {
  resetForm();
  clearAttachmentQueue();
  visible.value = false;
};
const open = async (id, row) => {
  repairId.value = id; // ä¿å­˜æŠ¥ä¿®è®°å½•çš„id
  repairId.value = id;
  clearAttachmentQueue();
  visible.value = true;
  await nextTick();
  setForm(row);
  if (row?.maintenanceName && row.maintenanceName !== userStore.nickName) {
    ElMessage.warning("仅指定的维修人可进行维修");
    visible.value = false;
  }
};
defineExpose({
@@ -110,4 +195,20 @@
});
</script>
<style lang="scss" scoped></style>
<style lang="scss" scoped>
.repair-attachment-upload {
  width: 100%;
  :deep(.el-upload) {
    width: 100%;
  }
  :deep(.el-upload-dragger) {
    width: 100%;
    padding: 20px 16px;
  }
}
.repair-upload-icon {
  font-size: 42px;
  color: var(--el-color-primary);
  margin-bottom: 8px;
}
</style>
src/views/equipmentManagement/repair/Modal/RepairDetailModal.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,208 @@
<template>
  <el-dialog
    v-model="visible"
    title="报修全过程"
    width="820px"
    destroy-on-close
    @closed="onClosed"
  >
    <div v-loading="loading" class="repair-detail">
      <template v-if="detail">
        <div class="phase-block">
          <div class="phase-title">1. æŠ¥ä¿®ç™»è®°</div>
          <el-descriptions :column="2" border size="small">
            <el-descriptions-item label="设备名称">{{ detail.deviceName || "—" }}</el-descriptions-item>
            <el-descriptions-item label="规格型号">{{ detail.deviceModel || "—" }}</el-descriptions-item>
            <el-descriptions-item label="报修日期">{{ fmtDate(detail.repairTime) }}</el-descriptions-item>
            <el-descriptions-item label="报修人">{{ detail.repairName || "—" }}</el-descriptions-item>
            <el-descriptions-item label="验收人">{{ detail.acceptanceName || "—" }}</el-descriptions-item>
            <el-descriptions-item label="维修人">{{ detail.maintenanceName || "—" }}</el-descriptions-item>
            <el-descriptions-item label="故障现象" :span="2">{{ detail.remark || "—" }}</el-descriptions-item>
            <el-descriptions-item label="当前状态" :span="2">
              <el-tag v-if="detail.status === 0" type="warning" size="small">待维修</el-tag>
              <el-tag v-else-if="detail.status === 3" type="info" size="small">待验收</el-tag>
              <el-tag v-else-if="detail.status === 1" type="success" size="small">完成</el-tag>
              <el-tag v-else-if="detail.status === 2" type="danger" size="small">维修失败</el-tag>
              <span v-else>—</span>
            </el-descriptions-item>
          </el-descriptions>
          <div class="img-row-label">设备问题图片</div>
          <div v-if="problemImages.length" class="img-grid">
            <el-image
              v-for="img in problemImages"
              :key="img.id"
              :src="img.url"
              fit="cover"
              class="thumb"
              :preview-src-list="problemImages.map((i) => i.url)"
              preview-teleported
            />
          </div>
          <el-empty v-else description="暂无设备问题图片" :image-size="56" />
        </div>
        <div v-if="hasMaintenanceStep" class="phase-block">
          <div class="phase-title">2. ç»´ä¿®å¤„理</div>
          <el-descriptions :column="2" border size="small">
            <el-descriptions-item label="维修人">{{ detail.maintenanceName || "—" }}</el-descriptions-item>
            <el-descriptions-item label="维修时间">{{ fmtDateTime(detail.maintenanceTime) }}</el-descriptions-item>
            <el-descriptions-item label="维修结果" :span="2">{{ detail.maintenanceResult || "—" }}</el-descriptions-item>
          </el-descriptions>
          <div v-if="maintainImages.length" class="img-row-label">维修完成图片</div>
          <div v-if="maintainImages.length" class="img-grid">
            <el-image
              v-for="img in maintainImages"
              :key="img.id"
              :src="img.url"
              fit="cover"
              class="thumb"
              :preview-src-list="maintainImages.map((i) => i.url)"
              preview-teleported
            />
          </div>
        </div>
        <div v-if="detail.status === 1" class="phase-block">
          <div class="phase-title">3. éªŒæ”¶</div>
          <el-descriptions :column="2" border size="small">
            <el-descriptions-item label="验收人">{{ detail.acceptanceName || "—" }}</el-descriptions-item>
            <el-descriptions-item label="验收时间">{{ fmtDateTime(detail.acceptanceTime) }}</el-descriptions-item>
            <el-descriptions-item label="验收备注" :span="2">{{ detail.acceptanceRemark || "—" }}</el-descriptions-item>
          </el-descriptions>
        </div>
      </template>
    </div>
    <template #footer>
      <el-button type="primary" @click="visible = false">关闭</el-button>
    </template>
  </el-dialog>
</template>
<script setup>
import { computed } from "vue";
import dayjs from "dayjs";
import { getRepairById, getRepairFileList } from "@/api/equipmentManagement/repair";
import { isProblemRepairFile, isMaintainRepairFile } from "@/api/equipmentManagement/repairFileType.js";
defineOptions({ name: "RepairDetailModal" });
const props = defineProps({
  /** ä¸Žåˆ—表页、附件弹窗一致,用于拼静态资源完整地址(子组件无全局 proxy æ—¶éœ€ç”±çˆ¶é¡µä¼ å…¥ï¼‰ */
  javaApi: {
    type: String,
    default: "",
  },
});
const visible = ref(false);
const loading = ref(false);
const detail = ref(null);
const problemImages = ref([]);
const maintainImages = ref([]);
const apiBase = computed(
  () => props.javaApi || import.meta.env.VITE_APP_BASE_API || ""
);
const hasMaintenanceStep = computed(() => {
  const s = detail.value?.status;
  return s === 2 || s === 3 || s === 1;
});
const fmtDate = (v) => (v ? dayjs(v).format("YYYY-MM-DD") : "—");
const fmtDateTime = (v) => (v ? dayjs(v).format("YYYY-MM-DD HH:mm:ss") : "—");
/** ä¸Žè®¾å¤‡æŠ¥ä¿® index.vue ä¸­ getFileAccessUrl + normalizeFileUrl é€»è¾‘一致 */
const buildFileUrl = (file = {}) => {
  let raw = "";
  if (file.link) {
    if (String(file.link).startsWith("http")) return file.link;
    raw = file.link;
  } else {
    raw = file.url || "";
  }
  if (!raw) return "";
  if (String(raw).startsWith("http")) return raw;
  let fileUrl = raw;
  if (fileUrl && fileUrl.indexOf("\\") > -1) {
    const lowerPath = fileUrl.toLowerCase();
    const uploadPathIndex = lowerPath.indexOf("uploadpath");
    if (uploadPathIndex > -1) {
      fileUrl = fileUrl.substring(uploadPathIndex).replace(/\\/g, "/");
    } else {
      fileUrl = fileUrl.replace(/\\/g, "/");
    }
  }
  fileUrl = fileUrl.replace(/^\/?uploadPath/, "/profile");
  if (!fileUrl.startsWith("http")) {
    if (!fileUrl.startsWith("/")) fileUrl = "/" + fileUrl;
    fileUrl = apiBase.value + fileUrl;
  }
  return fileUrl;
};
const open = async (id) => {
  if (!id) return;
  visible.value = true;
  loading.value = true;
  detail.value = null;
  problemImages.value = [];
  maintainImages.value = [];
  try {
    const { data } = await getRepairById(id);
    detail.value = data || null;
    const fileRes = await getRepairFileList(id);
    const list = Array.isArray(fileRes?.data) ? fileRes.data : [];
    problemImages.value = list
      .filter((f) => isProblemRepairFile(f.type))
      .map((f) => ({ id: f.id, url: buildFileUrl(f) }));
    maintainImages.value = list
      .filter((f) => isMaintainRepairFile(f.type))
      .map((f) => ({ id: f.id, url: buildFileUrl(f) }));
  } finally {
    loading.value = false;
  }
};
const onClosed = () => {
  detail.value = null;
  problemImages.value = [];
  maintainImages.value = [];
};
defineExpose({ open });
</script>
<style scoped>
.repair-detail {
  min-height: 120px;
}
.phase-block {
  margin-bottom: 20px;
}
.phase-title {
  font-size: 15px;
  font-weight: 600;
  color: #303133;
  margin-bottom: 10px;
  padding-left: 8px;
  border-left: 3px solid var(--el-color-primary);
}
.img-row-label {
  font-size: 13px;
  color: #606266;
  margin: 14px 0 8px;
  font-weight: 500;
}
.img-grid {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}
.thumb {
  width: 88px;
  height: 88px;
  border-radius: 6px;
  border: 1px solid var(--el-border-color);
}
</style>
src/views/equipmentManagement/repair/Modal/RepairModal.vue
@@ -2,7 +2,7 @@
  <FormDialog
    v-model="visible"
    :title="id ? '编辑设备报修' : '新增设备报修'"
    width="800px"
    width="880px"
    @confirm="sendForm"
    @cancel="handleCancel"
    @close="handleClose"
@@ -11,7 +11,7 @@
      <el-row>
        <el-col :span="12">
          <el-form-item label="设备名称">
            <el-select v-model="form.deviceLedgerId" @change="setDeviceModel" filterable>
            <el-select v-model="form.deviceLedgerId" placeholder="请选择" @change="setDeviceModel" filterable style="width: 100%">
              <el-option
                v-for="(item, index) in deviceOptions"
                :key="index"
@@ -30,6 +30,8 @@
            />
          </el-form-item>
        </el-col>
      </el-row>
      <el-row>
        <el-col :span="12">
          <el-form-item label="报修日期">
            <el-date-picker
@@ -45,7 +47,45 @@
        </el-col>
        <el-col :span="12">
          <el-form-item label="报修人">
            <el-input v-model="form.repairName" placeholder="请输入报修人" />
            <el-input v-model="form.repairName" disabled placeholder="当前登录人" />
          </el-form-item>
        </el-col>
      </el-row>
      <el-row>
        <el-col :span="12">
          <el-form-item label="验收人">
            <el-select
              v-model="form.acceptanceName"
              placeholder="请选择验收人"
              filterable
              clearable
              style="width: 100%"
            >
              <el-option
                v-for="item in userList"
                :key="item.userId ?? item.nickName"
                :label="item.nickName"
                :value="item.nickName"
              />
            </el-select>
          </el-form-item>
        </el-col>
        <el-col :span="12">
          <el-form-item label="维修人" required>
            <el-select
              v-model="form.maintenanceName"
              placeholder="请选择维修人"
              filterable
              clearable
              style="width: 100%"
            >
              <el-option
                v-for="item in userList"
                :key="'m-' + (item.userId ?? item.nickName)"
                :label="item.nickName"
                :value="item.nickName"
              />
            </el-select>
          </el-form-item>
        </el-col>
      </el-row>
@@ -72,22 +112,63 @@
          </el-form-item>
        </el-col>
      </el-row>
      <el-row>
        <el-col :span="24">
          <el-form-item label="设备问题图片">
            <div v-if="id" class="problem-existing-wrap">
              <div
                v-for="img in displayedExistingProblems"
                :key="img.id"
                class="problem-thumb"
              >
                <el-image :src="img.url" fit="cover" class="problem-thumb-img" @click="previewProblem(img.url)" />
                <el-icon class="problem-thumb-remove" @click.stop="markProblemRemove(img.id)"><Close /></el-icon>
              </div>
            </div>
            <el-upload
              class="repair-attachment-upload"
              v-model:file-list="attachmentFileList"
              drag
              multiple
              :auto-upload="false"
              :limit="9"
              accept="image/png,image/jpeg,image/jpg"
              :before-upload="beforeAttachmentUpload"
            >
              <el-icon class="repair-upload-icon"><UploadFilled /></el-icon>
              <div class="el-upload__text">将文件拖到此处,或 <em>点击选择文件</em></div>
              <template #tip>
                <div class="el-upload__tip">
                  è®¾å¤‡é—®é¢˜å›¾ï¼š{{ id ? '编辑时删除需点弹窗底部「确认」后才生效;' : '' }}支持 png / jpg / jpeg,单张不超过 50MB,最多 9 å¼ 
                </div>
              </template>
            </el-upload>
          </el-form-item>
        </el-col>
      </el-row>
    </el-form>
  </FormDialog>
</template>
<script setup>
import { getCurrentInstance, computed } from "vue";
import FormDialog from "@/components/Dialog/FormDialog.vue";
import {
  addRepair,
  editRepair,
  getRepairById,
  getRepairFileList,
  uploadRepairFile,
  deleteRepairFile,
} from "@/api/equipmentManagement/repair";
import { REPAIR_FILE_TYPE_PROBLEM, isProblemRepairFile } from "@/api/equipmentManagement/repairFileType.js";
import { ElMessage } from "element-plus";
import { UploadFilled, Close } from "@element-plus/icons-vue";
import dayjs from "dayjs";
import useFormData from "@/hooks/useFormData";
import { getDeviceLedger } from "@/api/equipmentManagement/ledger";
import useUserStore from "@/store/modules/user";
import { userListNoPage } from "@/api/system/user.js";
defineOptions({
  name: "设备报修弹窗",
@@ -101,10 +182,133 @@
const userStore = useUserStore();
const deviceOptions = ref([]);
const userList = ref([]);
/** å¾…提交的新增设备问题图(确认保存后上传) */
const attachmentFileList = ref([]);
/** ç¼–辑时:已存在的设备问题图(服务端) */
const existingProblemImages = ref([]);
/** ç¼–辑时:标记待删除的图片 id,仅点「确认」后调接口删除 */
const pendingRemoveProblemIds = ref([]);
const displayedExistingProblems = computed(() =>
  existingProblemImages.value.filter((img) => !pendingRemoveProblemIds.value.includes(img.id))
);
const getFileAccessUrlForModal = (file = {}) => {
  let raw = file?.link || file?.url || "";
  if (!raw) return "";
  if (String(raw).startsWith("http")) return raw;
  const javaApi = getCurrentInstance()?.proxy?.javaApi || "";
  let fileUrl = raw;
  if (fileUrl.indexOf("\\") > -1) {
    const lowerPath = fileUrl.toLowerCase();
    const uploadPathIndex = lowerPath.indexOf("uploadpath");
    if (uploadPathIndex > -1) {
      fileUrl = fileUrl.substring(uploadPathIndex).replace(/\\/g, "/");
    } else {
      fileUrl = fileUrl.replace(/\\/g, "/");
    }
  }
  fileUrl = fileUrl.replace(/^\/?uploadPath/, "/profile");
  if (!fileUrl.startsWith("http")) {
    if (!fileUrl.startsWith("/")) fileUrl = "/" + fileUrl;
    fileUrl = javaApi + fileUrl;
  }
  return fileUrl;
};
const markProblemRemove = (fileId) => {
  if (!pendingRemoveProblemIds.value.includes(fileId)) {
    pendingRemoveProblemIds.value.push(fileId);
  }
};
const previewProblem = (url) => {
  window.open(url, "_blank");
};
const loadExistingProblemImages = async (repairId) => {
  existingProblemImages.value = [];
  pendingRemoveProblemIds.value = [];
  if (!repairId) return;
  try {
    const res = await getRepairFileList(repairId);
    const list = Array.isArray(res?.data) ? res.data : [];
    existingProblemImages.value = list
      .filter((item) => isProblemRepairFile(item.type))
      .map((item) => ({
        id: item.id,
        name: item.name,
        url: getFileAccessUrlForModal(item),
      }));
  } catch {
    existingProblemImages.value = [];
  }
};
const resetProblemAttachmentState = () => {
  existingProblemImages.value = [];
  pendingRemoveProblemIds.value = [];
};
const ATTACH_MAX_MB = 50;
const loadDeviceName = async () => {
  const { data } = await getDeviceLedger();
  deviceOptions.value = data;
};
const loadUserList = async () => {
  const res = await userListNoPage();
  userList.value = Array.isArray(res?.data) ? res.data : [];
};
/** æŠ¥ä¿®äººå›ºå®šä¸ºå½“前登录用户(不可改) */
const syncRepairNameToLoginUser = () => {
  form.repairName = userStore.nickName || "";
};
const beforeAttachmentUpload = (rawFile) => {
  const okType = ["image/jpeg", "image/jpg", "image/png"].includes(rawFile.type);
  if (!okType) {
    ElMessage.error("只能上传 png / jpg / jpeg å›¾ç‰‡");
    return false;
  }
  const maxBytes = ATTACH_MAX_MB * 1024 * 1024;
  if (rawFile.size > maxBytes) {
    ElMessage.error(`单个文件不能超过 ${ATTACH_MAX_MB}MB`);
    return false;
  }
  return true;
};
const clearAttachmentQueue = () => {
  attachmentFileList.value = [];
};
const applyPendingProblemDeletes = async () => {
  for (const fileId of pendingRemoveProblemIds.value) {
    const { code } = await deleteRepairFile(fileId);
    if (code !== 200) {
      throw new Error("删除设备问题图片失败");
    }
  }
  pendingRemoveProblemIds.value = [];
};
const uploadQueuedRepairImages = async (repairId) => {
  const rows = attachmentFileList.value || [];
  const files = rows.map((f) => f.raw).filter(Boolean);
  if (!files.length || repairId == null) return;
  for (const file of files) {
    const fd = new FormData();
    fd.append("file", file);
    fd.append("deviceRepairId", String(repairId));
    fd.append("fileType", String(REPAIR_FILE_TYPE_PROBLEM));
    const res = await uploadRepairFile(fd);
    if (res.code !== 200) {
      throw new Error(res.msg || "附件上传失败");
    }
  }
};
const { form, resetForm } = useFormData({
@@ -113,13 +317,15 @@
  deviceModel: undefined, // è§„格型号
  repairTime: dayjs().format("YYYY-MM-DD"), // æŠ¥ä¿®æ—¥æœŸï¼Œé»˜è®¤å½“天
  repairName: userStore.nickName, // æŠ¥ä¿®äºº
  acceptanceName: undefined, // éªŒæ”¶äºº
  maintenanceName: undefined, // ç»´ä¿®äººï¼ˆå¯é€‰ï¼Œä¸Žåˆ—表维修人同源)
  remark: undefined, // æ•…障现象
  status: 0, // æŠ¥ä¿®çŠ¶æ€
});
const setDeviceModel = (deviceId) => {
  const option = deviceOptions.value.find((item) => item.id === deviceId);
  form.deviceModel = option.deviceModel;
  form.deviceModel = option?.deviceModel;
};
const setForm = (data) => {
@@ -127,22 +333,53 @@
  form.deviceName = data.deviceName;
  form.deviceModel = data.deviceModel;
  form.repairTime = data.repairTime;
  form.repairName = data.repairName;
  form.acceptanceName = data.acceptanceName;
  form.maintenanceName = data.maintenanceName;
  form.remark = data.remark;
  form.status = data.status;
  syncRepairNameToLoginUser();
};
const sendForm = async () => {
  syncRepairNameToLoginUser();
  if (!form.deviceLedgerId) {
    ElMessage.warning("请选择设备名称");
    return;
  }
  if (!form.acceptanceName) {
    ElMessage.warning("请选择验收人");
    return;
  }
  if (!form.maintenanceName) {
    ElMessage.warning("请选择维修人");
    return;
  }
  loading.value = true;
  try {
    const { code } = id.value
      ? await editRepair({ id: unref(id), ...form })
      : await addRepair(form);
    if (code == 200) {
    let repairId = id.value ? unref(id) : undefined;
    if (repairId) {
      const { code } = await editRepair({ id: repairId, ...form });
      if (code !== 200) return;
      await applyPendingProblemDeletes();
    } else {
      const res = await addRepair(form);
      if (res.code !== 200) return;
      repairId = res.data;
      if (repairId == null && attachmentFileList.value.length) {
        ElMessage.error("保存成功但未返回报修单编号,无法上传附件");
        return;
      }
    }
    if (attachmentFileList.value.length && repairId != null) {
      await uploadQueuedRepairImages(repairId);
    }
      ElMessage.success(`${id.value ? "编辑" : "新增"}报修成功`);
    clearAttachmentQueue();
    resetProblemAttachmentState();
      visible.value = false;
      emits("ok");
    }
  } catch (e) {
    ElMessage.error(e?.message || "保存或上传附件失败");
  } finally {
    loading.value = false;
  }
@@ -150,27 +387,38 @@
const handleCancel = () => {
  resetForm();
  syncRepairNameToLoginUser();
  clearAttachmentQueue();
  resetProblemAttachmentState();
  visible.value = false;
};
const handleClose = () => {
  resetForm();
  syncRepairNameToLoginUser();
  clearAttachmentQueue();
  resetProblemAttachmentState();
  visible.value = false;
};
const openAdd = async () => {
  id.value = undefined;
  clearAttachmentQueue();
  resetProblemAttachmentState();
  visible.value = true;
  await nextTick();
  await loadDeviceName();
  await Promise.all([loadDeviceName(), loadUserList()]);
  syncRepairNameToLoginUser();
};
const openEdit = async (editId) => {
  clearAttachmentQueue();
  resetProblemAttachmentState();
  const { data } = await getRepairById(editId);
  id.value = editId;
  visible.value = true;
  await nextTick();
  await loadDeviceName();
  await Promise.all([loadDeviceName(), loadUserList(), loadExistingProblemImages(editId)]);
  setForm(data);
};
@@ -180,4 +428,54 @@
});
</script>
<style lang="scss" scoped></style>
<style lang="scss" scoped>
.repair-attachment-upload {
  width: 100%;
  :deep(.el-upload) {
    width: 100%;
  }
  :deep(.el-upload-dragger) {
    width: 100%;
    padding: 24px 16px;
  }
}
.repair-upload-icon {
  font-size: 48px;
  color: var(--el-color-primary);
  margin-bottom: 8px;
}
.problem-existing-wrap {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  margin-bottom: 12px;
}
.problem-thumb {
  position: relative;
  width: 88px;
  height: 88px;
  border-radius: 6px;
  overflow: hidden;
  border: 1px solid var(--el-border-color);
}
.problem-thumb-img {
  width: 100%;
  height: 100%;
  cursor: pointer;
}
.problem-thumb-remove {
  position: absolute;
  top: 2px;
  right: 2px;
  font-size: 16px;
  color: #fff;
  background: rgba(0, 0, 0, 0.45);
  border-radius: 50%;
  padding: 2px;
  cursor: pointer;
}
</style>
src/views/equipmentManagement/repair/index.vue
@@ -98,74 +98,109 @@
          @pagination="changePage"
      >
        <template #statusRef="{ row }">
          <el-tag v-if="row.status === 2" type="danger">失败</el-tag>
          <el-tag v-if="row.status === 1" type="success">完结</el-tag>
          <el-tag v-if="row.status === 0" type="warning">待维修</el-tag>
          <el-tag v-else-if="row.status === 3" type="info">待验收</el-tag>
          <el-tag v-else-if="row.status === 1" type="success">完成</el-tag>
          <el-tag v-else-if="row.status === 2" type="danger">维修失败</el-tag>
        </template>
        <template #operation="{ row }">
          <el-button
            type="primary"
            link
            :disabled="row.status === 1"
            @click="editRepair(row.id)"
          >
          <el-button type="info" link @click="openRepairDetail(row.id)">
            è¯¦æƒ…
          </el-button>
          <el-button type="primary" link :disabled="!canEdit(row)" @click="editRepair(row.id)">
            ç¼–辑
          </el-button>
          <el-button
            type="info"
            link
            :disabled="row.status === 1"
            @click="openAttachment(row)"
          >
            é™„ä»¶
          </el-button>
          <el-button
            type="success"
            link
            :disabled="row.status === 1"
            @click="addMaintain(row)"
          >
          <el-button type="success" link :disabled="!canMaintain(row)" @click="addMaintain(row)">
            ç»´ä¿®
          </el-button>
          <el-button
            type="danger"
            link
            :disabled="row.status === 1"
            @click="delRepairByIds(row.id)"
          >
          <el-button type="warning" link :disabled="!canAccept(row)" @click="openAcceptance(row)">
            éªŒæ”¶
          </el-button>
          <el-button type="danger" link :disabled="!canDelete(row)" @click="delRepairByIds(row.id)">
            åˆ é™¤
          </el-button>
          <el-button type="primary" link @click="openAttachment(row)">
            é™„ä»¶
          </el-button>
        </template>
      </PIMTable>
    </div>
    <RepairModal ref="repairModalRef" @ok="getTableData"/>
    <MaintainModal ref="maintainModalRef" @ok="getTableData"/>
    <AcceptanceModal ref="acceptanceModalRef" @ok="getTableData"/>
    <RepairDetailModal ref="repairDetailModalRef" :java-api="javaApi" />
    <el-dialog v-model="attachment.visible" title="附件" width="860px" @closed="onAttachmentClosed">
    <el-dialog v-model="attachment.visible" title="附件" width="900px" @closed="onAttachmentClosed">
      <div v-loading="attachment.loading" class="attachment-wrap">
        <div class="attachment-actions">
        <p v-if="attachmentReadOnly" class="attachment-readonly-tip">
          å·²å¼€å§‹ç»´ä¿®ï¼Œæ­¤å¤„仅可查看;设备问题图请在报修「待维修」时于「编辑」中维护;维修完成图请在「维修」时上传。
        </p>
        <div class="attachment-section">
          <div class="attachment-section-title">设备问题图片</div>
          <template v-if="attachmentReadOnly">
            <div v-if="attachment.problemFileList.length" class="attachment-readonly-grid">
              <div
                v-for="f in attachment.problemFileList"
                :key="f.id"
                class="attachment-readonly-item"
                @click="handleAttachmentPreview(f)"
              >
                <el-image :src="f.url" fit="cover" class="attachment-readonly-img" />
              </div>
            </div>
            <el-empty
              v-else
              description="暂无设备问题图片"
              :image-size="60"
            />
          </template>
          <template v-else>
          <el-upload
            v-model:file-list="attachment.fileList"
              v-model:file-list="attachment.problemFileList"
            :action="upload.url"
            multiple
            ref="attachmentFileUpload"
            auto-upload
            :headers="upload.headers"
            :data="{ deviceRepairId: attachment.deviceRepairId }"
              :data="uploadDataProblem"
            :before-upload="handleAttachmentBeforeUpload"
            :on-error="handleAttachmentUploadError"
            :on-success="handleAttachmentUploadSuccess"
              :on-success="() => handleAttachmentUploadSuccess('problem')"
            :on-preview="handleAttachmentPreview"
            :on-remove="handleAttachmentRemove"
              :on-remove="(file) => handleAttachmentRemove(file, 'problem')"
            list-type="picture-card"
            :limit="9"
            accept="image/png,image/jpeg,image/jpg"
          >
            +
          </el-upload>
            <el-empty
              v-if="!attachment.loading && attachment.problemFileList.length === 0"
              description="暂无设备问题图片"
              :image-size="60"
            />
          </template>
        </div>
        <el-empty v-if="!attachment.loading && attachment.files.length === 0" description="暂无附件" />
        <div class="attachment-section">
          <div class="attachment-section-title">维修完成图片</div>
          <p v-if="!attachmentReadOnly" class="attachment-readonly-tip" style="margin-top: 0">
            ç»´ä¿®å®Œæˆå›¾è¯·åœ¨åˆ—表「维修」操作中上传,此处仅查看。
          </p>
          <div v-if="attachment.maintainFileList.length" class="attachment-readonly-grid">
            <div
              v-for="f in attachment.maintainFileList"
              :key="f.id"
              class="attachment-readonly-item"
              @click="handleAttachmentPreview(f)"
            >
              <el-image :src="f.url" fit="cover" class="attachment-readonly-img" />
            </div>
          </div>
          <el-empty
            v-else
            description="暂无维修完成图片"
            :image-size="60"
          />
        </div>
      </div>
      <template #footer>
        <el-button @click="attachment.visible = false">关闭</el-button>
@@ -203,7 +238,15 @@
import {ElMessageBox, ElMessage} from "element-plus";
import dayjs from "dayjs";
import MaintainModal from "./Modal/MaintainModal.vue";
import AcceptanceModal from "./Modal/AcceptanceModal.vue";
import RepairDetailModal from "./Modal/RepairDetailModal.vue";
import { getToken } from "@/utils/auth";
import useUserStore from "@/store/modules/user";
import {
  REPAIR_FILE_TYPE_PROBLEM,
  isProblemRepairFile,
  isMaintainRepairFile,
} from "@/api/equipmentManagement/repairFileType.js";
defineOptions({
  name: "设备报修",
@@ -215,6 +258,9 @@
// æ¨¡æ€æ¡†å®žä¾‹
const repairModalRef = ref();
const maintainModalRef = ref();
const acceptanceModalRef = ref();
const repairDetailModalRef = ref();
const userStore = useUserStore();
// è¡¨æ ¼å¤šé€‰æ¡†é€‰ä¸­é¡¹
const multipleList = ref([]);
@@ -223,10 +269,30 @@
  visible: false,
  loading: false,
  deviceRepairId: undefined,
  /** æŠ¥ä¿®å•状态:非待维修(0)时附件弹窗只读 */
  repairStatus: undefined,
  files: [],
  fileList: [],
  problemFileList: [],
  maintainFileList: [],
  previewVisible: false,
  previewUrl: "",
});
/** å·²å¼€å§‹ç»´ä¿®åŽï¼šåˆ—表「附件」仅查看,不可增删 */
const attachmentReadOnly = computed(
  () => attachment.repairStatus != null && attachment.repairStatus !== 0
);
const uploadDataProblem = computed(() => ({
  deviceRepairId: attachment.deviceRepairId,
  fileType: REPAIR_FILE_TYPE_PROBLEM,
}));
const toUploadFileItem = (item) => ({
  id: item.id,
  name: item.name,
  url: getFileAccessUrl(item),
  fileType: item.type,
});
const getFileAccessUrl = (file = {}) => {
@@ -312,6 +378,11 @@
        prop: "repairName",
      },
      {
        label: "验收人",
        align: "center",
        prop: "acceptanceName",
      },
      {
        label: "故障现象",
        align: "center",
        prop: "remark",
@@ -345,10 +416,27 @@
        dataType: "slot",
        slot: "operation",
        align: "center",
        width: "300px",
        width: "420px",
      },
    ]
);
const isCurrentUser = (name) => !!name && name === userStore.nickName;
const canEdit = (row) => row.status === 0;
/** ä»…报修时指定的维修人可维修 */
const canMaintain = (row) => {
  if (row.status !== 0) return false;
  if (!row.maintenanceName) return false;
  return isCurrentUser(row.maintenanceName);
};
const canDelete = (row) => row.status === 0;
/** ä»…报修时指定的验收人可验收 */
const canAccept = (row) => {
  if (row.status !== 3) return false;
  if (!row.acceptanceName) return false;
  return isCurrentUser(row.acceptanceName);
};
// type === 1 ç»´ä¿® 2报修间
const handleDateChange = (value, type) => {
@@ -371,10 +459,14 @@
  multipleList.value = selectionList;
};
// æ£€æŸ¥é€‰ä¸­çš„记录中是否有完结状态的
// æ‰¹é‡åˆ é™¤ï¼šä»…允许待维修
const hasFinishedStatus = computed(() => {
  return multipleList.value.some(item => item.status === 1)
})
  return multipleList.value.some((item) => item.status !== 0);
});
const openRepairDetail = (id) => {
  repairDetailModalRef.value?.open(id);
};
// æ–°å¢žæŠ¥ä¿®
const addRepair = () => {
@@ -386,9 +478,22 @@
  repairModalRef.value.openEdit(id);
};
// æ–°å¢žç»´ä¿®
// ç»´ä¿®ï¼ˆä»…指定维修人)
const addMaintain = (row) => {
  if (!canMaintain(row)) {
    ElMessage.warning("仅指定的维修人可进行维修");
    return;
  }
  maintainModalRef.value.open(row.id, row);
};
// éªŒæ”¶ï¼ˆä»…报修时指定的验收人、且待验收状态可点)
const openAcceptance = (row) => {
  if (!canAccept(row)) {
    ElMessage.warning("仅指定的验收人可进行验收");
    return;
  }
  acceptanceModalRef.value.open(row.id, row);
};
const changePage = ({page, limit}) => {
@@ -401,13 +506,13 @@
const delRepairByIds = async (ids) => {
  // æ£€æŸ¥æ˜¯å¦æœ‰å®Œç»“状态的记录
  const idsArray = Array.isArray(ids) ? ids : [ids];
  const hasFinished = idsArray.some(id => {
    const record = dataList.value.find(item => item.id === id);
    return record && record.status === 1;
  const cannotDelete = idsArray.some((id) => {
    const record = dataList.value.find((item) => item.id === id);
    return record && record.status !== 0;
  });
  if (hasFinished) {
    ElMessage.warning('不能删除状态为完结的记录');
  if (cannotDelete) {
    ElMessage.warning("仅待维修状态可删除");
    return;
  }
@@ -440,7 +545,9 @@
};
const openAttachment = async (row) => {
  attachment.fileList = [];
  attachment.problemFileList = [];
  attachment.maintainFileList = [];
  attachment.repairStatus = row?.status;
  attachment.visible = true;
  attachment.deviceRepairId = row?.id;
  await refreshAttachmentList();
@@ -452,11 +559,12 @@
  try {
    const res = await getRepairFileList(attachment.deviceRepairId);
    attachment.files = Array.isArray(res?.data) ? res.data : [];
    attachment.fileList = attachment.files.map((item) => ({
      id: item.id,
      name: item.name,
      url: getFileAccessUrl(item),
    }));
    attachment.problemFileList = attachment.files
      .filter((item) => isProblemRepairFile(item.type))
      .map(toUploadFileItem);
    attachment.maintainFileList = attachment.files
      .filter((item) => isMaintainRepairFile(item.type))
      .map(toUploadFileItem);
  } finally {
    attachment.loading = false;
  }
@@ -465,13 +573,18 @@
const onAttachmentClosed = () => {
  attachment.loading = false;
  attachment.deviceRepairId = undefined;
  attachment.repairStatus = undefined;
  attachment.files = [];
  attachment.fileList = [];
  attachment.problemFileList = [];
  attachment.maintainFileList = [];
  attachment.previewVisible = false;
  attachment.previewUrl = "";
};
const handleAttachmentBeforeUpload = (file) => {
  if (attachmentReadOnly.value) {
    return false;
  }
  const isImage = ["image/png", "image/jpeg", "image/jpg"].includes(file.type);
  if (!isImage) {
    ElMessage.error("只能上传 png/jpg/jpeg å›¾ç‰‡");
@@ -480,11 +593,9 @@
  return true;
};
const handleAttachmentUploadSuccess = async (res) => {
  if (res?.code === 200) {
const handleAttachmentUploadSuccess = async () => {
    ElMessage.success("上传成功");
    await refreshAttachmentList();
  }
};
const handleAttachmentUploadError = () => {
@@ -501,15 +612,19 @@
  attachment.previewVisible = true;
};
const handleAttachmentRemove = async (file) => {
  // ä»…移除前端未入库文件时,不调用删除接口
const handleAttachmentRemove = async (file, category) => {
  if (attachmentReadOnly.value) {
    return false;
  }
  const matched = attachment.files.find((item) => item.id === file?.id)
    || attachment.files.find((item) => item.name === file?.name);
  if (!matched) return;
  const expectProblem = category === "problem";
  if (expectProblem && !isProblemRepairFile(matched.type)) return;
  if (!expectProblem && !isMaintainRepairFile(matched.type)) return;
  try {
    await confirmDeleteAttachment(matched);
  } finally {
    // å–消删除时,el-upload å·²å…ˆç§»é™¤ï¼Œåˆ·æ–°ä¸€æ¬¡ä¿æŒä¸ŽåŽç«¯ä¸€è‡´
    await refreshAttachmentList();
  }
};
@@ -548,11 +663,43 @@
  min-height: 240px;
}
.attachment-actions {
  display: flex;
  align-items: center;
  justify-content: space-between;
.attachment-section {
  margin-bottom: 20px;
}
.attachment-section-title {
  font-size: 14px;
  font-weight: 600;
  color: #303133;
  margin-bottom: 12px;
  padding-left: 8px;
  border-left: 3px solid var(--el-color-primary);
}
.attachment-readonly-tip {
  font-size: 13px;
  color: var(--el-text-color-secondary);
  margin-bottom: 16px;
  line-height: 1.5;
}
.attachment-readonly-grid {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
}
.attachment-readonly-item {
  width: 100px;
  height: 100px;
  border-radius: 6px;
  overflow: hidden;
  cursor: pointer;
  border: 1px solid var(--el-border-color);
}
.attachment-readonly-img {
  width: 100%;
  height: 100%;
}
.attachment-preview-wrap {