vue3 docxtemplater库的 组件实现word导出,支持word图片浮动插入

<script>
import { defineComponent, ref, watch, onMounted } from 'vue';
import Docxtemplater from 'docxtemplater';
import PizZip from 'pizzip';
import { saveAs } from 'file-saver';
import { renderAsync } from 'docx-preview';
import { Buffer } from 'buffer'
import ImageModule from 'docxtemplater-image-module-free';
//import { Document, Packer, Paragraph, ImageRun } from 'docx';

// 接口
import { getDetailStaff } from '@/api/info/staff'

import config from '@/config'
import JSZip from 'jszip';

const { fileDownload: fileDownloadUrl } = config.filestoreUrl

// 处理图片数据
const loading = ref(false);
const imageCache = new Map();
const getImageBase64 = async (imageUrl) => {
  if (imageCache.has(imageUrl)) {
    return imageCache.get(imageUrl);
  }
  const response = await fetch(imageUrl);
  const blob = await response.blob();
  const base64 = await new Promise((resolve) => {
    const reader = new FileReader();
    reader.onloadend = () => resolve(reader.result);
    reader.readAsDataURL(blob);
  });
  imageCache.set(imageUrl, base64);
  return base64;
};

// 优化的图片获取方法
const getImageBase64Sp = async (imageUrl) => {
  // 1. 检查缓存
  if (imageCache.has(imageUrl)) {
    return imageCache.get(imageUrl);
  }

  try {
    let finalImageUrl = imageUrl;
 
    // 调用异步API获取签名图片路径
    const response = await getDetailStaff({ id: imageUrl });
   
    // 确保数据存在
    if (!response?.data?.data?.signature) {
      throw new Error(`No signature found for id: ${imageUrl}`);
    }
   
    // 构建最终图片URL
    finalImageUrl = fileDownloadUrl + response.data.data.signature;
    console.log('Resolved image URL:', finalImageUrl);

    // 3. 获取图片数据
    const base64 = await fetchAndConvertToBase64(finalImageUrl);
   
    // 4. 缓存结果
    imageCache.set(imageUrl, base64);
    return base64;
   
  } catch (error) {
    console.error('Error in getImageBase64Sp:', error);
   
    // 返回占位图片或空字符串
    return getPlaceholderImage();
  }
};
// 辅助函数:获取并转换图片为base64
const fetchAndConvertToBase64 = async (url) => {
  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(`Failed to fetch image: ${response.status} ${response.statusText}`);
  }
  const blob = await response.blob();
  return new Promise((resolve) => {
    const reader = new FileReader();
    reader.onloadend = () => resolve(reader.result);
    reader.readAsDataURL(blob);
  });
};
// 辅助函数:获取占位图片
const getPlaceholderImage = () => {
  // 返回一个透明1x1像素的占位图
  return 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
};


// 辅助函数:检查是否是Base64字符串
const isBase64 = (str) => {
  return /^data:image\/(png|jpeg|jpg|gif);base64,/.test(str);
};

// 默认字段映射配置
const defaultFieldMapping = {};
export default defineComponent({
  name: 'DocxView',
  props: {
    modelData: {
      type: Object,
      required: true
    },
    templateUrl: {
      type: String,
      default: ''
    },
    fieldMapping: {
      type: Object,
      default: () => ({...defaultFieldMapping})
    },
    downloadFileName: {
      type: String,
      default: 'generated-doc.docx'
    }
  },
  setup(props) {
    const previewContainer = ref(null);
    const fullscreenPreview = ref(null);
   
    const templateData = ref(null);
    const open = ref(false);

    // 更新模板数据
    const updateTemplateData =async () => {
      try {
        // 合并字段映射配置
        const mapping = { ...defaultFieldMapping, ...props.fieldMapping };
        // 处理所有包含Uuid的字段
        const processUuidFields = async (obj) => {
          for (const key in obj) {
            if (key.endsWith('Uuid') && obj[key]) {
              obj[key] = isBase64(obj[key])
                ? obj[key]
                : await getImageBase64(fileDownloadUrl + obj[key]);
            }
          }
        };
       
        // 处理所有包含Seal的字段
        const processSealFields = async (obj) => {
          for (const key in obj) {
            if (key.endsWith('Seal') && obj[key]) {
              obj[key] = isBase64(obj[key])
                ? obj[key]
                : await getImageBase64(fileDownloadUrl + obj[key]);
            }
          }
        };
       
        // 处理所有包含SignaturePic的字段
        const processSignaturePicFields = async (obj) => {
          for (const key in obj) {
            if (key.endsWith('SignaturePic') && obj[key]) {
              obj[key] = isBase64(obj[key])
                ? obj[key]
                : await getImageBase64Sp(obj[key]);
            }
          }
        };
        // 处理所有ListJSON字段
        const processListFields = async () => {
          console.log('processListFields')
          for (const key in mapping) {
            if (key.endsWith('ListJSON') && Array.isArray(mapping[key])) {
              mapping[key] = await Promise.all(
                mapping[key].map(async (item) => {
                  const newItem = { ...item };
                  await processUuidFields(newItem);
                  return newItem;
                })
              );
            }
          }
        };
        // 处理所有ListJSONSign字段
        const processListFieldsSign = async () => {
          console.log('processListFields')
          for (const key in mapping) {
            if (key.endsWith('ListJSONSign') && Array.isArray(mapping[key])) {
              mapping[key] = await Promise.all(
                mapping[key].map(async (item) => {
                  const newItem = { ...item };
                  await processSignaturePicFields(newItem);
                  return newItem;
                })
              );
            }
          }
        };
        // 先处理普通字段中的Seal
        await processSealFields(mapping);
        // 先处理普通字段中的SignaturePic
        await processSignaturePicFields(mapping);
        // 先处理普通字段中的Uuid
        await processUuidFields(mapping);
        // 再处理ListJSON中的Uuid
        await processListFields();
        await processListFieldsSign();
        templateData.value = mapping;
      } catch (error) {
        console.error('更新模板数据失败:', error);
        // 可以根据需要添加错误处理逻辑
      }
    };

    // 加载模板文件
    const loadTemplate = async () => {
      const response = await fetch(props.templateUrl);
      return await response.arrayBuffer();
    };

    // 生成文档
    const generateDoc = async (data) => {
      try {
        const template = await loadTemplate();
        const zip = new PizZip(template);
        const doc = new Docxtemplater(zip, {
          modules: [
            new ImageModule({
              getImage: (tagValue,tagName) => {
                console.log('getImage:', tagName)
                // 处理Base64图片
                if (typeof tagValue === 'string' && tagValue.startsWith('data:')) {
                  const base64Data = tagValue.split(',')[1]
                  return Buffer.from(base64Data, 'base64')
                }
                return null;
              },
              getSize: (img, tagValue, tagName) => {
                if (tagName.endsWith('SignaturePic')) {
                  return [80, 60];
                }
                else if(tagName.endsWith('Seal')){
                  // 默认尺寸(可选)
                  return [100, 100];
                }
                else {
                  // 默认尺寸(可选)
                  return [100, 100];
                }
              }
            })
          ],
          paragraphLoop: true,
          linebreaks: true,
        });
       
        doc.render(data);
        return doc.getZip().generate({
          type: 'blob',
          mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
        });
      } catch (error) {
        console.error('文档生成失败:', error);
        throw error;
      }
    };

    // 导出文档
    const xmlContent = ref("");
    const images = ref([]);
    const documentInfo = ref(null);
    const processedDoc = ref(null);
    const exportWord = async () => {
      if (!templateData.value) return;
      try {
        // 生成文档
        const blob = await generateDoc(templateData.value);
        // 2. 初始化docxtemplater
        const templateArrayBuffer = await blob.arrayBuffer();
        //
        // 读取原始docx的Blob,用JSZip解压
        if(blob){
          const unzipData = await JSZip.loadAsync(templateArrayBuffer)
          console.log('unzipData,',unzipData)
          // 获取document.xml
          const documentXml = await unzipData.file('word/document.xml').async('text');
          xmlContent.value = documentXml;
          console.log('documentXml,',documentXml)
          // 解析XML获取图片和替换文字
          const parser = new DOMParser();
          const xmlDoc = parser.parseFromString(documentXml, 'text/xml');
         
          // 查找所有包含替换文字的图片元素
          const drawings = xmlDoc.getElementsByTagName('w:drawing');
          const foundImages = [];
          for (let i = 0; i < drawings.length; i++) {
            const drawing = drawings[i];
            const docPr = drawing.getElementsByTagName('wp:docPr')[0];
           
            if (docPr) {
              const altText = docPr.getAttribute('descr') || docPr.getAttribute('title') || '';
              const blip = drawing.getElementsByTagName('a:blip')[0];
             
              if (blip) {
                const embedId = blip.getAttribute('r:embed');
                if (embedId) {
                  // 获取图片名称
                  const rels = await unzipData.file('word/_rels/document.xml.rels').async('text');
                  const relsDoc = parser.parseFromString(rels, 'text/xml');
                  const relationships = relsDoc.getElementsByTagName('Relationship');
                 
                  for (let j = 0; j < relationships.length; j++) {
                    const rel = relationships[j];
                    if (rel.getAttribute('Id') === embedId) {
                      const target = rel.getAttribute('Target');
                      console.log('target:',target)
                      const imgPath = `word/${target}`;
                      const imgFile = unzipData.file(imgPath);
                      if (imgFile) {
                        const blob = await imgFile.async('blob');
                        const preview = URL.createObjectURL(blob);
                        foundImages.push({
                          name: imgPath.split('/').pop(),
                          path: imgPath,
                          altText: altText,
                          preview: preview,
                          size: blob.size,
                          type: blob.type,
                          embedId: embedId,
                          replaced: false
                        });
                      }
                      break;
                    }
                  }
                }
              }
            }
          }
         
          images.value = foundImages;
          console.log('foundImages:',foundImages)
           
          documentInfo.value = {
            // name: file.name,
            // size: file.size,
            imageCount: images.value.length,
            altTextCount: images.value.filter(img => img.altText).length
          };
           
          for (const img of images.value) {
          console.log('replaceimg:',img)
          // 重点,签章替换,识别word带替换文本的图片(可用透明图片来占位)
          if (img.altText  === 'zjzSeal') {
          const arrayBuffer =base64ToArrayBuffer('data:image/png;base64,iVBORw0KG ')
           
          }
          }

          // 生成新的DOCX文件
          const content = await unzipData.generateAsync({ type: 'blob' });
          processedDoc.value = content;
          saveAs(processedDoc.value, props.downloadFileName);
        }
      } catch (error) {
        console.error('导出失败:', error);
      }
    };

    function base64ToArrayBuffer(base64) {
      // 移除 data URL 头部(如果存在)
      const base64Data = base64.split(',')[1] || base64;
     
      // 解码 Base64 字符串
      const binaryString = atob(base64Data);
     
      // 创建 ArrayBuffer 和视图
      const buffer = new ArrayBuffer(binaryString.length);
      const uintArray = new Uint8Array(buffer);
     
      // 填充二进制数据
      for (let i = 0; i < binaryString.length; i++) {
        uintArray[i] = binaryString.charCodeAt(i);
      }
     
      return buffer;
    }

    // 预览文档
    const previewWord = async () => {
      try {
        if (!previewContainer.value) return;
        loading.value = true;
        const blob = await generateDoc(templateData.value);

        await renderAsync(blob, previewContainer.value);
        if(fullscreenPreview.value){
          await renderAsync(blob, fullscreenPreview.value);
        }
      } catch (error) {
        console.error('预览生成失败:', error);
      } finally {
        loading.value = false;
      }
    };

    // 全屏预览文档
    const previewWordFull = async () => {
      try {
        const blob = await generateDoc(templateData.value);
        await renderAsync(blob, fullscreenPreview.value);
      } catch (error) {
        console.error('预览生成失败:', error);
      } finally {
        loading.value = false;
      }
    };

    const onOpen = () =>{
      previewWordFull()
      open.value = true
    }

    // 初始化数据
    updateTemplateData(props.modelData);

    // 监听modelData变化
    watch(
        () => props.modelData,
        (newVal) => {
          updateTemplateData(newVal);
          previewWord();
          previewWordFull()
        },
        {deep: true}
    );

    // 全屏高度相关
    const modalRef = ref()
    const dynamicHeight = ref('0px')
    // 挂载后立即预览
    onMounted(async () => {
      await updateTemplateData();
      await previewWord();
      loading.value = false;
    });

    return {
      modalRef,
      open,
      onOpen,
      previewContainer,
      fullscreenPreview,
      exportWord,
      previewWord,
      previewWordFull,
      dynamicHeight
    };
  }
})
</script>

<template>
  <div ref="detailContainerRef" class="detail-container">
    <div class="top-container">
      <Icon
        class="top-container-icon"
        type="ios-expand"
        size="32"
        title="全屏"
        @click="onOpen">
      </Icon>
    </div>
    <Teleport to="body">
      <div v-if="open" ref="modalRef" class="modal">
        <div class="modal-top">
          <Button type="primary" @click="open = false">关闭</Button>
        </div><div ref="fullscreenPreview" class="docx-preview-container full-screen"></div>
      </div>
    </Teleport>
    <div ref="previewContainer" class="docx-preview-container"></div>
  </div>
</template>

<style lang="less" scoped>
.modal {
  position: fixed;
  height: 100%;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: white;
  z-index: 9999;
  padding: 20px;
  &>.modal-top{
    display: flex;
    flex-direction: row-reverse;
  }
}
.detail-container{
  margin-top: 10px;
  width: 100%;
  min-height: 650px;
  border: 1px solid #ddd;
  &>.top-container{
    display: flex;
    flex-direction: row-reverse;
    padding: 15px 15px 0 15px;
    &>.top-container-icon{
      cursor: pointer;
    }
  }
}
.docx-preview-container {
  width: 100%;
  min-height: 600px;
  padding: 0 15px 15px 0;
  margin-top: 15px;
  box-sizing: border-box;
}

/* 适配docx预览样式 */
.docx-wrapper {
  background: #fff !important;
  padding: 20px !important;
}
.full-screen{
  overflow: hidden;
  overflow-y: scroll;
  height: calc(100vh - 75px);
}
</style>
posted @ 2025-06-17 11:06  烧肉粽  阅读(477)  评论(0)    收藏  举报