Skip to content
 

把页面导出为 pdf

更新: 9/4/2025字数: 0 字 时长: 0 分钟


js
<template>
  <Dialog :title="dialogTitle" v-model="dialogVisible" width="900">
    <div class="content" id="eps">
      <p class="top" style="margin-top: 0">网格化慢病管理平台</p>
      <h1 class="title">运动处方</h1>
      <p class="num">
        处方编号:<span id="prescriptionNumber">{{ prescriptionNumber }}</span>
      </p>
      <div class="split"></div>
      <div class="user-info-parent">
        <p class="title2">一、个人信息</p>
        <div class="user-info">
          <div class="row">
            <p>
              <span>姓名:</span><span> {{ patientInfo.name }}</span>
            </p>
            <p>
              <span>性别:</span><span> {{ getDictLabel(DICT_TYPE.SYSTEM_USER_SEX, patientInfo.sex) }}</span>
            </p>
            <p>
              <span>年龄:</span><span> {{ calculateAge(patientInfo.birthday) }}</span>
            </p>
          </div>

          <div class="row">
            <p>
              <span>医保卡号:</span><span> {{ patientInfo.insuranceCardNo }}</span>
            </p>
            <p>
              <span>病历卡号:</span><span> {{ patientInfo.medicalCardNo }}</span>
            </p>
          </div>
          <div class="row">
            <p>
              <span>诊断结果:</span><span> {{ visitResult }}</span>
            </p>
          </div>
          <div class="row">
            <p>
              <!-- 体测结果 -->
              <span>运动能力评估 :</span><span>{{ getDictLabel(DICT_TYPE.PHYSICAL_ASSESSMENT_LEVEL, physicalResult) }}</span>
            </p>
          </div>
        </div>
      </div>

      <div class="user-info-parent">
        <p class="title2">二、处方内容</p>
        <div class="user-info">
          <div class="row">
            <p>
              <span>运动目标:</span><span> {{ prescriptionInfo.sportGoal }}</span>
            </p>
          </div>
          <div class="row">
            <p>
              <span>处方开始时间:</span><span> {{ prescriptionInfo.startDate }}</span>
            </p>
          </div>
          <div class="row">
            <p>
              <span>处方周期:</span><span> {{ prescriptionInfo.totalCycle }}周</span>
            </p>
          </div>
          <div class="row">
            <p style="font-weight: 600">每周运动内容:</p>
          </div>
          <div class="table">
            <el-table :data="prescriptionInfo.sportProjectDetail" border style="width: 100%" :header-cell-style="{ color: '#000' }" align="center">
              <el-table-column prop="projectId" label="运动项目" align="center">
                <template #default="scope">
                  <span>{{ sportProjectFun(scope.row.projectId) }}</span>
                </template>
              </el-table-column>
              <el-table-column prop="frequencyCount" label="运动频率" align="center">
                <template #default="scope">
                  <span>{{ scope.row.frequencyCount }}次/周</span>
                </template>
              </el-table-column>
              <el-table-column prop="singleQuantity" label="运动量" align="center">
                <template #default="scope">
                  <span v-if="scope.row.singleQuantity">{{ scope.row.singleQuantity }}个</span>
                  <span v-else>--</span>
                </template>
              </el-table-column>
              <el-table-column prop="singleDuration" label="运动时间" align="center">
                <template #default="scope">
                  <span>{{ Math.floor(scope.row.singleDuration / 60) }}分钟</span>
                </template>
              </el-table-column>
              <el-table-column prop="intensityCount" label="运动强度" align="center">
                <template #default="scope">
                  <span>{{ getDictLabel(DICT_TYPE.AI_EXERCISE_INTENSITY_LEVEL, scope.row.intensityCount) }}</span>
                </template>
              </el-table-column>
            </el-table>
          </div>

          <div class="careful">
            <p>注意事项:</p>
            <p v-for="item in precautionsList" :key="item">{{ item }}</p>
          </div>
        </div>
      </div>

      <!-- 签名和二维码 -->
      <div class="sign-parent" id="sign-parent">
        <div class="split"></div>
        <div class="code-sign">
          <div class="code">
            <img style="width: 100px; height: 100px" :src="mpCodeImage" alt="" />
            扫码查看电子版运动处方
          </div>
          <div class="sign">
            <span class="doctor">医师(签章):</span>
            <img style="width: 200px; height: 100px; margin-top: auto" :src="signatureImage" alt="" />
          </div>
        </div>
      </div>
    </div>

    <template #footer>
      <el-button @click="submitForm" type="primary" :disabled="formLoading">确认开具</el-button>
      <el-button @click="dialogVisible = false">取 消</el-button>
    </template>
  </Dialog>
</template>

<script lang="ts" setup>
import { ref, reactive, toRefs, onBeforeMount, computed } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { getIntDictOptions, DICT_TYPE, getDictLabel } from '@/utils/dict';
import { InfoApi, PrescriptionInfoSaveReqVO } from '@/api/prescription/info/index';
import { InfoApi as patientInfoApi, InfoVO as patientInfoVO } from '@/api/patient/patientInfo';
import { DoctorInfoApi } from '@/api/system/doctorinfo/index';
import { MpConfigApi } from '@/api/patient/mpconfig';
import { BasicInfoApi } from '@/api/exercise/basicInfo';
import { prescriptionStoreWithOut } from '@/store/modules/prescription'; // vuex
import { ElLoading } from 'element-plus';
import html2canvas from 'html2canvas';
import jsPDF from 'jspdf';
import { calculateAge, generateFileName } from '@/utils/common';
import { useTagsViewStore } from '@/store/modules/tagsView';
import { ElNotification } from 'element-plus';

const { delView } = useTagsViewStore();

const prescriptionStore = prescriptionStoreWithOut(); // vuex
const { currentRoute, push, go } = useRouter(); // 路由

const visitResult = ref(); // 就诊结果
const physicalResult = ref(); // 体格检查结果

const message = useMessage(); // 消息弹窗
const dialogVisible = ref(false); // 弹窗的是否展示
const dialogTitle = ref(''); // 弹窗的标题
const formLoading = ref(false); // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
const exerciseDict = ref<{ label: string; value: number }[]>([]); // 运动项目

const patientInfoBasic = {
  id: null, // 编号
  name: '', // 姓名
  sex: null, // 性别
  birthday: '', // 出生年月日
  idCard: '', // 身份证
  mobile: '', // 手机号
  medicalCardNo: '', // 病历卡号
  insuranceCardNo: '', // 医保卡号
  height: null, // 身高
  weight: null, // 体重
  waistline: null, // 腰围
  lastRelatedDoctor: '', // 最近关联医生
  lastVisitId: null, // 最近就诊记录
  conditionDetails: '' // 病历信息
};
const signatureImage = ref(''); // 医生签名图片
const mpCodeImage = ref('');
// 患者本人的信息
const patientInfo = ref<patientInfoVO>(patientInfoBasic);

// 处方编号
const prescriptionNumber = ref('开具处方之后生成');
// 处方报告信息
const prescriptionInfo = ref<PrescriptionInfoSaveReqVO>({
  id: null,
  patientId: null,
  patientVisitId: null,
  physicalAssessmentRecordId: null,
  sportGoal: ' ',
  startDate: '',
  totalCycle: 2,
  cycleUnit: 2, //周期单位 字典值
  // 运动内容
  sportProjectDetail: [
    {
      projectId: null,
      frequencyCount: null,
      intensityCount: null,
      singleQuantity: null,
      singleDuration: null
    }
  ],
  // 注意事项
  precautions: ''
});

/** 获取运动项目  */
function sportProjectFun(id: number) {
  let result = '';
  if (id) {
    exerciseDict.value.forEach((dictData) => {
      if (dictData.value == id) {
        result = dictData.label;
      }
    });
  }
  return result;
}
/** 打开弹窗 prescriptionData:是处方数据  */
const open = async (prescriptionData: any) => {
  dialogVisible.value = true;
  dialogTitle.value = '预览运动处方';

  resetForm(); // 清除表单

  prescriptionInfo.value = prescriptionData; // 渲染处方报告信息

  // 就诊信息和体测结果
  if (prescriptionStore.visitResult || prescriptionStore.physicalResult) {
    visitResult.value = prescriptionStore.visitResult;
    physicalResult.value = prescriptionStore.physicalResult;
  }
  // 并行请求
  try {
    const [patientInfoResult, exerciseDictResult, doctorInfoResult, mpCodeObjResult] = await Promise.all([
      patientInfoApi.getInfo(Number(prescriptionData.patientId)),
      BasicInfoApi.getExerciseList(),
      DoctorInfoApi.getProfile(),
      MpConfigApi.getMpConfigByKey('mpCode')
    ]);
    // 更新状态
    patientInfo.value = patientInfoResult;
    exerciseDict.value = exerciseDictResult;
    signatureImage.value = doctorInfoResult.signatureImage;
    mpCodeImage.value = mpCodeObjResult.configValue;
  } catch (error) {
    console.error('Error during parallel requests:', error);
    // 处理错误
    message.error('获取数据失败');
  }
};

defineExpose({ open }); // 提供 open 方法,用于打开弹窗

/**
 * 注意事项列表
 */
const precautionsList = computed(() => {
  return prescriptionInfo.value.precautions.split('\n');
});

/**
 * 导出PDF 以毫米为单位的A4纸
 */
async function printPDF() {
  processingTruncation(); // 处理截断

  const tableElement: HTMLElement | null = document.getElementById('eps'); // 获取表格元素

  const canvas = await html2canvas(tableElement!, {
    allowTaint: false,
    useCORS: true,
    // @ts-ignore
    background: '#ffffff',
    scale: 2, // 提高渲染分辨率
    // @ts-ignore
    dpi: 300, // 提高 DPI 原来是300
    onclone: (doc) => {
      // 在克隆文档中隐藏不需要的元素(如果有的话)
      const epsElement = doc.getElementById('eps') as HTMLDivElement | null;
      const prescriptionNumberEl = doc.getElementById('prescriptionNumber') as HTMLElement;
      if (epsElement) {
        epsElement.style.border = 'none';
        prescriptionNumberEl.innerText = prescriptionNumber.value;
      }
    }
  });

  // PDF文件名 patientInfo.name
  const pdfFileName = `${generateFileName(patientInfo.value.name)}.pdf`;

  // 创建一个新的PDF文档
  const pdf = new jsPDF('p', 'mm', 'a4'); // A4纸张,纵向
  const ctx = canvas.getContext('2d')!;
  const a4Width = 190; // A4纸张宽度减去两侧各10mm的边距
  // const a4Height = 277; // A4纸张高度减去上下各10mm的边距
  const a4Height = 297; // 不用减
  const imgHeight = Math.floor((a4Height * canvas.width) / a4Width); // 按A4显示比例计算图像的高度
  let renderedHeight = 0; // 渲染高度

  // 分页处理图像
  while (renderedHeight < canvas.height) {
    const pageCanvas = document.createElement('canvas');
    pageCanvas.width = canvas.width;
    pageCanvas.height = Math.min(imgHeight, canvas.height - renderedHeight); // 当前页的图像高度

    // 剪裁指定区域并画到新的canvas对象中
    const pageCtx = pageCanvas.getContext('2d')!;
    pageCtx.putImageData(ctx.getImageData(0, renderedHeight, canvas.width, Math.min(imgHeight, canvas.height - renderedHeight)), 0, 0);

    // 添加图像到PDF页面
    pdf.addImage(pageCanvas.toDataURL('image/jpeg', 1.0), 'JPEG', 10, 10, a4Width, Math.min(a4Height, (a4Width * pageCanvas.height) / pageCanvas.width));

    // 更新已渲染的高度
    renderedHeight += pageCanvas.height;

    // 如果后面还有内容,则添加新页面
    if (renderedHeight < canvas.height) {
      pdf.addPage();
    }
  }

  console.log(222, 'printPDF');

  // 输出 PDF 文件为 Blob 对象
  // @ts-ignore
  const blob = pdf.output('blob', { filename: pdfFileName });
  await uploadSignatureFun(blob);

  console.log(333);

  // 保存PDF文件
  pdf.save(pdfFileName);

  // 生成完成之后去除留白节点
  const emptyDivParent = document.getElementsByClassName('careful')[0];
  const emptyDiv = document.getElementsByClassName('emptyDiv')[0];
  if (emptyDivParent && emptyDiv) {
    // 确保 emptyDiv 是 emptyDivParent 的子元素
    if (emptyDivParent.contains(emptyDiv)) {
      emptyDivParent.removeChild(emptyDiv);
    }
  }
}

/**
 * 上传pdf
 */
async function uploadSignatureFun(blob: Blob) {
  const pdfFile = new FormData();
  pdfFile.append('file', blob);
  // @ts-ignore
  pdfFile.append('id', prescriptionNumber.value); // 处方编号
  await InfoApi.uploadSignature(pdfFile);
}

/**
 * 处理截断  添加空白div把节点挤到下一页
 */
function processingTruncation(): void {
  // 获取所有需要分割的节点
  const nodeList = document.querySelectorAll('.careful p') as NodeListOf<HTMLParagraphElement>;
  const A4_WIDTH = 592.28;
  const A4_HEIGHT = 841.89;
  const target = document.getElementById('eps') as HTMLElement;
  const pageHeight = (target.scrollWidth / A4_WIDTH) * A4_HEIGHT;

  // 遍历节点,判断是否需要分割并添加留白节点
  for (let i = 0; i < nodeList.length; i++) {
    const multiple = Math.ceil((nodeList[i].offsetTop + nodeList[i].scrollHeight) / pageHeight);
    if (isSplit(nodeList, i, multiple * pageHeight)) {
      const divParent = nodeList[i].parentNode as HTMLElement; // 获取节点的父节点
      // 添加空白div
      const emptyDivNode = document.createElement('div');
      emptyDivNode.className = 'emptyDiv';
      emptyDivNode.style.background = '#fff';
      const _H = multiple * pageHeight - (nodeList[i].offsetTop + nodeList[i].scrollHeight);
      emptyDivNode.style.height = _H + 30 + 'px';
      emptyDivNode.style.width = '100%';
      const next = nodeList[i].nextSibling as Node | null; // 获取节点的下一个兄弟节点
      if (next) {
        divParent.insertBefore(emptyDivNode, next); // 将留白节点插入到下一个兄弟节点之前
      } else {
        divParent.appendChild(emptyDivNode); // 将留白节点添加到最后
      }
    }
  }
}

/**
 * 判断是否需要添加空白div
 * @param nodes 要检查的节点列表
 * @param index 当前节点的索引
 * @param pageHeight 页面的高度
 * @returns 是否需要添加空白div
 */
const isSplit = (nodes: NodeListOf<HTMLParagraphElement>, index: number, pageHeight: number): boolean => {
  // 计算当前这块dom是否跨越了a4大小,以此分割
  if (nodes[index].offsetTop + nodes[index].offsetHeight < pageHeight && nodes[index + 1] && nodes[index + 1].offsetTop + nodes[index + 1].offsetHeight > pageHeight) {
    return true;
  }
  return false;
};

/** 开具处方并生成pdf */
async function submitForm() {
  let loading: any = null;
  try {
    console.log(111);

    // 1,先开具处方
    const data = await InfoApi.saveOrUpdatePrescription(prescriptionInfo.value);

    if (data) {
      prescriptionNumber.value = data;
    }

    loading = ElLoading.service({
      lock: true,
      text: 'Loading',
      background: 'rgba(0, 0, 0, 0.7)'
    });

    try {
      // 2,生成PDF
      await printPDF();
      console.log(4444);
    } catch (error) {
      console.warn('生成PDF失败', error);
    }

    // 延迟三秒 生成处方报告比较慢
    await new Promise((resolve) => setTimeout(resolve, 2000));

    // 3,关闭加载状态、对话框
    loading.close();
    dialogVisible.value = false;

    // 4,返回详情页面
    delView(unref(currentRoute));
    ElNotification.success({
      title: '处方开具完成',
      message: `已下载运动处方到本地`,
      offset: 100
    });

    go(-2);

    // 5,添加返回到患者详情 - 运动处方切换栏 的缓存标识 需要在切换时主动清除缓存
    localStorage.setItem('_activeName', 'prescriptionInfo');
  } catch (error) {
    message.error('提交处方失败,请稍后再试');
  } finally {
    loading.close(); // 确保加载状态始终关闭
  }
}
/** 重置数据  */
function resetForm() {
  // @ts-ignore
  prescriptionInfo.value = {};
  patientInfo.value = Object.assign({}, patientInfoBasic);
  formLoading.value = false;
  prescriptionNumber.value = '开具处方之后生成';
}
</script>

<style scoped lang="scss">
@mixin eps_flex($direction: row, $justify: null, $align: null, $flex-wrap: null) {
  display: flex;
  flex-direction: $direction;
  justify-content: $justify;
  align-items: $align;
  flex-wrap: $flex-wrap;
}

// 设置表头边框颜色
:deep(th.el-table__cell.is-leaf) {
  border-color: #000;
}
// 设置表格边框颜色
:deep(td.el-table__cell, ) {
  border-color: #000;
}
// 设置表格四周边框颜色
:deep(.el-table--border) {
  border: 1px solid #000;
}
// 设置字体颜色
:deep(.el-table) {
  color: #000;
}

.table {
  width: 100%;
}

.imgs {
  display: flex;
  justify-content: center;
  gap: 100px;
  align-items: center;
}

// 内容容器
.content {
  position: relative;
  box-sizing: border-box;
  width: 792px;
  height: 1123px; // a4纸的高度
  border: 1px dashed #000;
  margin: 0 auto;
  padding: 0px 20px;
  color: #000;

  p {
    margin: 3px 0;
  }

  .title {
    margin: 20px auto;
    font-weight: 300;
    text-align: center;
    letter-spacing: 2px;
    font-size: 30px;
  }
  .top,
  .num {
    font-size: 17px;
    letter-spacing: 2px;
    font-weight: 300;
    text-align: center;
  }

  .split {
    width: 100%;
    height: 1px;
    background: #888;
    margin: 15px 0;
  }
}

.user-info-parent {
  .title2 {
    font-size: 18px;
    font-weight: 300;
    margin: 10px 0;
  }
  .row {
    @include eps_flex(row, null, center);
    gap: 20px;
    p {
      min-width: 33%;
      font-size: 16px;
    }
  }
  .careful {
    margin-top: 10px;
    margin-bottom: 30px;
    font-size: 15px;
    p {
      margin: 5px 0;
    }
  }
}

.sign-parent {
  box-sizing: border-box;
  padding: 0 20px;
  width: 100%;
  position: absolute;
  bottom: 0;
  left: 0;
}
.code-sign {
  // margin-top: 40px;
  @include eps_flex(row, null);
  justify-content: space-between;
  .code {
    width: 150px;
    font-size: 13px;
    @include eps_flex(column, null, center);
    // gap: 10px;
  }
  .sign {
    @include eps_flex(row, null);
    .doctor {
      font-size: 20px;
      font-weight: 500;
      color: #444;
    }
  }
}
</style>

我见青山多妩媚,料青山见我应如是。