前端 Word 导出:自定义页眉表格的实现方案

在前端导出 Word 文档的场景中,自定义页眉(尤其是包含复杂表格布局的页眉)是常见需求。本文将聚焦标准 JD 库项目中,如何通过docx库实现固定结构的自定义页眉表格,结合实际代码,拆解从表格布局设计、样式配置到最终集成的完整流程。

一、需求核心:自定义页眉表格的目标

本次导出功能的页眉表格需满足以下要求:

  1. 布局:4 列 2 行结构,第二列合并两行显示核心信息
  2. 内容:包含标识文本(VOSS、Job Description)、职位名称、版本信息(Issue No.)、日期(Issue Date)、编制人(Compiled By)、审批人(Approved By)
  3. 样式:统一边框、文字居中对齐、固定列宽比例、垂直居中布局
  4. 适配:页眉在文档每一页顶部显示,与正文内容分离

二、技术选型与依赖准备

1. 核心依赖

  • docx@9.5.1:负责 Word 文档结构构建、表格和样式定义(选择该版本是因为 API 稳定,适配前端动态导入)
  • file-saver:处理 Blob 对象转文件下载

2. 依赖安装

复制代码
npm install docx@9.5.1 file-saver --save

3. 动态导入优化

为避免初始打包体积过大,在导出函数内动态导入docx库:

复制代码
const handleExportJd = async () => {
  try {
    // 动态导入docx核心模块(仅导出时加载)
    const docx = await import('docx');
    const {
      Table, TableRow, TableCell, TextRun, Paragraph,
      WidthType, BorderStyle, AlignmentType, Header
    } = docx;
    // 后续页眉表格构建逻辑...
  } catch (error) {
    console.error('导出Word失败:', error);
    antdApp.message.error('导出Word失败,请检查文档内容格式');
  }
};

三、核心组件(DOM)

复制代码
<div className={styles.rightPane}>
            <Typography.Title className={styles.JdTitle} level={5}>
              <span className={styles.JdBasTitleText}>基础信息</span>
              <div className={styles.JdButton}>
                <Button className={styles.JdExportButton} onClick={handleExportJd} disabled={!selectedJdDescription}><img src="https://xingge-ai.oss-cn-shenzhen.aliyuncs.com/fusi/down_icon.png" alt="导出JD" width={14} height={14} />导出JD</Button>
              </div>
            </Typography.Title>
            <div className={styles.infoContainer}>
              <div className={styles.infoRow}>
                <div className={styles.infoItem}>
                  <span className={styles.infoLabel}>Issue No.</span>
                  <Input value="001-F002" disabled style={{ width: 150, marginLeft: 10 }} />
                </div>
                <div className={styles.infoItem}>
                  <span className={styles.infoLabel}>Issue Date.</span>
                  <Input value="2024/9/21" disabled style={{ width: 150, marginLeft: 10 }} />
                </div>
              </div>
              <div className={styles.infoRow}>
                <div className={styles.infoItem}>
                  <span className={styles.infoLabel}>Compiled By</span>
                  <Input value={compiledBy} onChange={(e) => setCompiledBy(e.target.value)} style={{ width: 150, marginLeft: 10 }} />
                </div>
                <div className={styles.infoItem}>
                  <span className={styles.infoLabel}>Approved By</span>
                  <Input value={approvedBy} onChange={(e) => setApprovedBy(e.target.value)} style={{ width: 150, marginLeft: 10 }} />
                </div>
              </div>
            </div>
            <Typography.Title className={styles.JdTitle} level={5}>
              <span className={styles.JdTitleText}>JD描述</span>
            </Typography.Title>
            <Spin spinning={rowLoading}>
              <Input.TextArea
                value={selectedJdDescription}
                onChange={(e) => setSelectedJdDescription(e.target.value)}
                autoSize={{ minRows: 27 }}
                placeholder="暂无JD描述"
              />
            </Spin>
          </div>

其中 selectedJdDescription 的内容是根据左侧点击表格行 -> 调用 detail 接口获取 documentId/segmentId,再请求子块内容(获取接口中 content 的 Markdown 内容 ---正文内容)

四、自定义页眉表格的核心实现

1. 表格结构设计(4 列 2 行)

先明确表格的布局规则,确保各列宽比例合理、内容分区清晰:

列索引(宽比例) 第 1 行内容 第 2 行内容 特殊配置
列 1(25%) VOSS(标识文本) Job Description + 职位描述 独立两行
列 2(35%) 职位名称(从选中 JD 提取) - 合并两行(rowSpan=2)
列 3(20%) Issue No. + 编号(001-F002) Compiled By + 输入值 独立两行
列 4(20%) Issue Date + 日期(2024/9/21) Approved By + 输入值 独立两行

2. 关键配置:表格样式与布局约束

2.1 统一样式定义(复用性优化)

提前定义表格边框、文字大小、对齐方式等公共样式,避免重复代码:

复制代码
// 表格边框样式(统一加粗黑色边框)
const tableBorder = {
  style: BorderStyle.SINGLE,
  size: 3,
  color: '000000'
};

// 文字基础样式(根据内容重要性区分大小)
const titleTextStyle = { bold: true, size: 45, color: '#FF00FF' }; // VOSS标识
const subtitleTextStyle = { bold: true, size: 20 }; // 标签文本(如Issue No.)
const contentTextStyle = { size: 18 }; // 内容文本(如编号、日期)
const positionTextStyle = { bold: true, size: 28, color: '#000000' }; // 职位名称

2.2 表格行与单元格实现

通过TableRowTableCell组件构建结构,重点处理列合并、宽高约束、对齐方式:

复制代码
// 提取职位名称(从选中的JD内容中获取)
const positionName = selectedJdDescription 
  ? selectedJdDescription.split('\n').filter(line => line.trim() !== '')[0]?.trim() 
  : '未获取职位信息';

// 构建页眉表格
const headerTable = new Table({
  width: { size: 100, type: WidthType.PERCENTAGE }, // 表格占满页面宽度
  border: tableBorder, // 统一表格边框
  rows: [
    // 第一行(包含VOSS、职位名称、Issue No.、Issue Date)
    new TableRow({
      height: { value: 800, type: 'dxa' }, // 行高(dxa为Word内部单位,800≈28px)
      children: [
        // 列1:VOSS标识(水平+垂直居中)
        new TableCell({
          width: { size: 25, type: WidthType.PERCENTAGE },
          borders: { top: tableBorder, bottom: tableBorder, left: tableBorder, right: tableBorder },
          verticalAlign: 'center', // 垂直居中
          children: [
            new Paragraph({
              children: [new TextRun({ text: 'VOSS', ...titleTextStyle })],
              alignment: AlignmentType.CENTER // 水平居中
            })
          ]
        }),
        // 列2:职位名称(合并两行,垂直居中)
        new TableCell({
          rowSpan: 2, // 合并两行
          width: { size: 35, type: WidthType.PERCENTAGE },
          borders: { top: tableBorder, bottom: tableBorder, left: tableBorder, right: tableBorder },
          verticalAlign: 'center',
          children: [
            new Paragraph({
              children: [new TextRun({ text: positionName, ...positionTextStyle })],
              alignment: AlignmentType.CENTER
            })
          ]
        }),
        // 列3:Issue No. 与编号
        new TableCell({
          width: { size: 20, type: WidthType.PERCENTAGE },
          borders: { top: tableBorder, bottom: tableBorder, left: tableBorder, right: tableBorder },
          verticalAlign: 'center',
          children: [
            new Paragraph({
              children: [new TextRun({ text: 'Issue No.', ...subtitleTextStyle })],
              alignment: AlignmentType.CENTER,
              spacing: { after: 25 } // 文字间距
            }),
            new Paragraph({
              children: [new TextRun({ text: '001-F002', ...contentTextStyle })],
              alignment: AlignmentType.CENTER
            })
          ]
        }),
        // 列4:Issue Date 与日期
        new TableCell({
          width: { size: 20, type: WidthType.PERCENTAGE },
          borders: { top: tableBorder, bottom: tableBorder, left: tableBorder, right: tableBorder },
          verticalAlign: 'center',
          children: [
            new Paragraph({
              children: [new TextRun({ text: 'Issue Date', ...subtitleTextStyle })],
              alignment: AlignmentType.CENTER,
              spacing: { after: 25 }
            }),
            new Paragraph({
              children: [new TextRun({ text: '2024/9/21', ...contentTextStyle })],
              alignment: AlignmentType.CENTER
            })
          ]
        })
      ]
    }),
    // 第二行(包含Job Description、Compiled By、Approved By)
    new TableRow({
      height: { value: 600, type: 'dxa' },
      children: [
        // 列1:Job Description + 职位描述
        new TableCell({
          width: { size: 25, type: WidthType.PERCENTAGE },
          borders: { top: tableBorder, bottom: tableBorder, left: tableBorder, right: tableBorder },
          verticalAlign: 'center',
          children: [
            new Paragraph({
              children: [new TextRun({ text: 'Job Description', ...subtitleTextStyle })],
              alignment: AlignmentType.CENTER,
              spacing: { after: 15 }
            }),
            new Paragraph({
              children: [new TextRun({ text: '职位描述', ...contentTextStyle })],
              alignment: AlignmentType.CENTER
            })
          ]
        }),
        // 列3:Compiled By(编制人,绑定页面输入值)
        new TableCell({
          width: { size: 20, type: WidthType.PERCENTAGE },
          borders: { top: tableBorder, bottom: tableBorder, left: tableBorder, right: tableBorder },
          verticalAlign: 'center',
          children: [
            new Paragraph({
              children: [new TextRun({ text: 'Compiled By', ...subtitleTextStyle })],
              alignment: AlignmentType.CENTER,
              spacing: { after: 25 }
            }),
            new Paragraph({
              children: [new TextRun({ text: compiledBy || '未填写', ...contentTextStyle })],
              alignment: AlignmentType.CENTER
            })
          ]
        }),
        // 列4:Approved By(审批人,绑定页面输入值)
        new TableCell({
          width: { size: 20, type: WidthType.PERCENTAGE },
          borders: { top: tableBorder, bottom: tableBorder, left: tableBorder, right: tableBorder },
          verticalAlign: 'center',
          children: [
            new Paragraph({
              children: [new TextRun({ text: 'Approved By', ...subtitleTextStyle })],
              alignment: AlignmentType.CENTER,
              spacing: { after: 25 }
            }),
            new Paragraph({
              children: [new TextRun({ text: approvedBy || '未填写', ...contentTextStyle })],
              alignment: AlignmentType.CENTER
            })
          ]
        })
      ]
    })
  ]
});

3. 页眉与文档集成

将构建好的表格包装为 Word 页眉,并配置到文档中,确保每一页都显示该页眉:

复制代码
// 3.1 处理 JD 内容,转换为 Word 标准格式(同步放大文字大小)
const jdParagraphs = [];
const lines = selectedJdDescription.split('\n');
let firstHeadingFound = false;

for (let i = 0; i < lines.length; i++) {
  const line = lines[i];
  const trimmedLine = line.trim();

  if (trimmedLine === '') {
    // 空行:保留间距,优化阅读体验
    jdParagraphs.push(new Paragraph({
      spacing: { after: 80 }, // 空行间距(dxa单位,80≈2.8px)
    }));
  } else if (trimmedLine.startsWith('# ')) {
    // 一级标题:除首个标题外,其余标题前添加分页符
    if (firstHeadingFound) {
      jdParagraphs.push(new PageBreak()); // 分页符,标题独立起页
    } else {
      firstHeadingFound = true; // 标记已找到首个标题
    }
    // 一级标题样式:加粗、放大字号、左对齐、底部间距
    jdParagraphs.push(new Paragraph({
      children: [new TextRun({
        text: trimmedLine.slice(2), // 移除Markdown前缀 "# "
        bold: true,
        size: 24, // 字体大小(24=12pt,放大后更醒目)
        color: "#000000",
        font: "Arial"
      })],
      heading: HeadingLevel.HEADING_1, // 绑定Word一级标题样式(支持目录生成)
      spacing: { after: 150 }, // 标题底部间距(150≈5.3px)
      alignment: AlignmentType.LEFT,
    }));
  } else if (trimmedLine.startsWith('- ')) {
    // 列表项:添加项目符号、放大字号、保留间距
    jdParagraphs.push(new Paragraph({
      children: [new TextRun({
        text: trimmedLine.slice(2), // 移除Markdown前缀 "- "
        size: 20, // 字体大小(20=10pt,适配阅读)
        color: "#333333",
        font: "Arial"
      })],
      bullet: { level: 0 }, // 一级项目符号(实心圆点)
      spacing: { after: 80 }, // 列表项底部间距
      alignment: AlignmentType.LEFT,
    }));
  } else {
    // 普通文本:放大字号、左对齐、统一间距
    jdParagraphs.push(new Paragraph({
      children: [new TextRun({
        text: trimmedLine,
        size: 20, // 字体大小(20=10pt,比默认放大更易读)
        color: "#333333",
        font: "Arial"
      })],
      spacing: { after: 80 }, // 文本行底部间距
      alignment: AlignmentType.LEFT,
    }));
  }
}

// 3.2 包装页眉(default 表示所有页面共用该页眉)
const header = new Header({
  children: [headerTable], // 传入之前构建的4列2行页眉表格
  margin: { top: 50, bottom: 50 } // 页眉内边距(优化与正文间距)
});

// 3.3 创建Word文档,整合页眉与格式化正文
const doc = new Document({
  sections: [
    {
      // 页面配置:适配页眉高度,设置合理边距
      properties: {
        page: {
          margin: {
            top: 1800, // 顶部边距(1800≈63px,预留页眉空间)
            bottom: 900, // 底部边距(900≈31.5px)
            left: 1260, // 左侧边距(1260≈44px)
            right: 1260, // 右侧边距(1260≈44px)
          },
          size: {
            width: 11906, // A4纸宽度(默认值,适配标准打印)
            height: 16838, // A4纸高度(默认值)
          }
        }
      },
      headers: { default: header }, // 绑定全局页眉(所有页面显示)
      children: [
        // 正文内容:先添加分页符(避免页眉与正文重叠),再导入格式化后的段落
        new PageBreak(),
        ...jdParagraphs // 展开处理后的JD正文段落
      ]
    }
  ]
});

// 3.4 生成Blob对象并触发下载
const blob = await Packer.toBlob(doc); // 将文档打包为Blob
const fileName = `${positionName}_JD_${formattedIssueDate}.docx`; // 文件名:职位名称_日期.docx
saveAs(blob, fileName); // 调用file-saver下载文件
message.success('JD文档导出成功!');

五、handleExportJd 完整逻辑

复制代码
const handleExportJd = async () => {
    try {
      // 动态导入 docx 库(适配 9.5.1 版本)
      const docx = await import('docx');
      const {
        Document,
        Packer,
        TextRun,
        Paragraph,
        Table,
        TableRow,
        TableCell,
        WidthType,
        BorderStyle,
        AlignmentType,
        HeadingLevel,
        Header,
        SectionType,
        PageBreak
      } = docx;

      // 从 selectedJdDescription 中提取第一个非空行作为职位名称
      const getPositionName = () => {
        if (!selectedJdDescription) return '未获取职位信息';
        // 按换行符分割,过滤空行和纯空格行
        const lines = selectedJdDescription.split('\n').filter(line => line.trim() !== '');
        // 返回第一个非空行的内容(去除前后空格)
        return lines.length > 0 ? lines[0].trim() : '未获取职位信息';
      };
      const positionName = getPositionName();

      // 创建页眉表格
      const headerTable = new Table({
        rows: [
          new TableRow({
            height: { value: 800, type: 'dxa' }, // 增加行高(dxa 为 Word 内部单位)
            children: [
              // 第一列第一行:仅显示 VOSS
              new TableCell({
                children: [
                  new Paragraph({
                    children: [new TextRun({ text: 'VOSS', bold: true, size: 45, color: '#FF00FF' })],
                    alignment: AlignmentType.CENTER, // 水平居中
                    spacing: { after: 0 },
                  }),
                ],
                width: { size: 25, type: WidthType.PERCENTAGE },
                borders: {
                  top: { style: BorderStyle.SINGLE, size: 3, color: '000000' },
                  bottom: { style: BorderStyle.SINGLE, size: 3, color: '000000' },
                  left: { style: BorderStyle.SINGLE, size: 3, color: '000000' },
                  right: { style: BorderStyle.SINGLE, size: 3, color: '000000' },
                },
                verticalAlign: 'center',
              }),
              // 第二列:合并行显示提取的职位名称
              new TableCell({
                rowSpan: 2,
                children: [
                  new Paragraph({
                    children: [new TextRun({
                      text: positionName,
                      bold: true,
                      size: 28,
                      color: '#000000'
                    })],
                    alignment: AlignmentType.CENTER,
                    spacing: { after: 40 },
                  })
                ],
                width: { size: 35, type: WidthType.PERCENTAGE },
                borders: {
                  top: { style: BorderStyle.SINGLE, size: 3, color: '000000' },
                  bottom: { style: BorderStyle.SINGLE, size: 3, color: '000000' },
                  left: { style: BorderStyle.SINGLE, size: 3, color: '000000' },
                  right: { style: BorderStyle.SINGLE, size: 3, color: '000000' },
                },
                verticalAlign: 'center',
              }),
              // 第三列第一行:Issue No.
              new TableCell({
                children: [
                  new Paragraph({
                    children: [new TextRun({ text: 'Issue No.', bold: true, size: 20 })],
                    alignment: AlignmentType.CENTER,
                    spacing: { after: 25 },
                  }),
                  new Paragraph({
                    children: [new TextRun({ text: '001-F002', size: 18 })],
                    alignment: AlignmentType.CENTER,
                  }),
                ],
                width: { size: 20, type: WidthType.PERCENTAGE },
                borders: {
                  top: { style: BorderStyle.SINGLE, size: 3, color: '000000' },
                  bottom: { style: BorderStyle.SINGLE, size: 3, color: '000000' },
                  left: { style: BorderStyle.SINGLE, size: 3, color: '000000' },
                  right: { style: BorderStyle.SINGLE, size: 3, color: '000000' },
                },
                verticalAlign: 'center',
              }),
              // 第四列第一行:Issue Date
              new TableCell({
                children: [
                  new Paragraph({
                    children: [new TextRun({ text: 'Issue Date', bold: true, size: 20 })],
                    alignment: AlignmentType.CENTER,
                    spacing: { after: 25 },
                  }),
                  new Paragraph({
                    children: [new TextRun({ text: '2024/9/21', size: 18 })],
                    alignment: AlignmentType.CENTER,
                  }),
                ],
                width: { size: 20, type: WidthType.PERCENTAGE },
                borders: {
                  top: { style: BorderStyle.SINGLE, size: 3, color: '000000' },
                  bottom: { style: BorderStyle.SINGLE, size: 3, color: '000000' },
                  left: { style: BorderStyle.SINGLE, size: 3, color: '000000' },
                  right: { style: BorderStyle.SINGLE, size: 3, color: '000000' },
                },
                verticalAlign: 'center',
              }),
            ],
          }),
          new TableRow({
            height: { value: 600, type: 'dxa' },
            children: [
              // 第一列第二行:显示 Job Description 和 职位描述(保持居中)
              new TableCell({
                children: [
                  new Paragraph({
                    children: [new TextRun({ text: 'Job Description', bold: true, size: 20 })],
                    alignment: AlignmentType.CENTER,
                    spacing: { after: 15 },
                  }),
                  new Paragraph({
                    children: [new TextRun({ text: '职位描述', size: 18 })],
                    alignment: AlignmentType.CENTER,
                  }),
                ],
                width: { size: 25, type: WidthType.PERCENTAGE },
                borders: {
                  top: { style: BorderStyle.SINGLE, size: 3, color: '000000' },
                  bottom: { style: BorderStyle.SINGLE, size: 3, color: '000000' },
                  left: { style: BorderStyle.SINGLE, size: 3, color: '000000' },
                  right: { style: BorderStyle.SINGLE, size: 3, color: '000000' },
                },
                verticalAlign: 'center',
              }),
              // 第三列第二行:Compiled By
              new TableCell({
                children: [
                  new Paragraph({
                    children: [new TextRun({ text: 'Compiled By', bold: true, size: 20 })],
                    alignment: AlignmentType.CENTER,
                    spacing: { after: 25 },
                  }),
                  new Paragraph({
                    children: [new TextRun({ text: compiledBy || '未填写', size: 18 })],
                    alignment: AlignmentType.CENTER,
                  }),
                ],
                width: { size: 20, type: WidthType.PERCENTAGE },
                borders: {
                  top: { style: BorderStyle.SINGLE, size: 3, color: '000000' },
                  bottom: { style: BorderStyle.SINGLE, size: 3, color: '000000' },
                  left: { style: BorderStyle.SINGLE, size: 3, color: '000000' },
                  right: { style: BorderStyle.SINGLE, size: 3, color: '000000' },
                },
                verticalAlign: 'center',
              }),
              // 第四列第二行:Approved By
              new TableCell({
                children: [
                  new Paragraph({
                    children: [new TextRun({ text: 'Approved By', bold: true, size: 20 })],
                    alignment: AlignmentType.CENTER,
                    spacing: { after: 25 },
                  }),
                  new Paragraph({
                    children: [new TextRun({ text: approvedBy || '未填写', size: 18 })],
                    alignment: AlignmentType.CENTER,
                  }),
                ],
                width: { size: 20, type: WidthType.PERCENTAGE },
                borders: {
                  top: { style: BorderStyle.SINGLE, size: 3, color: '000000' },
                  bottom: { style: BorderStyle.SINGLE, size: 3, color: '000000' },
                  left: { style: BorderStyle.SINGLE, size: 3, color: '000000' },
                  right: { style: BorderStyle.SINGLE, size: 3, color: '000000' },
                },
                verticalAlign: 'center',
              }),
            ],
          }),
        ],
        width: { size: 100, type: WidthType.PERCENTAGE },
        border: { style: BorderStyle.SINGLE, size: 3, color: '000000' },
      });

      // 将表格包装成页眉对象
      const header = new Header({
        children: [headerTable],
      });

      // 处理 JD 内容,转换为 Word 段落(同步放大文字大小)
      const jdParagraphs = [];
      const lines = selectedJdDescription.split('\n');
      let firstHeadingFound = false;

      for (let i = 0; i < lines.length; i++) {
        const line = lines[i];
        const trimmedLine = line.trim();

        if (trimmedLine === '') {
          // 空行(调整间距)
          jdParagraphs.push(new Paragraph({
            spacing: { after: 80 },
          }));
        } else if (trimmedLine.startsWith('# ')) {
          // 一级标题,前添加分页符(除了第一个标题)
          if (firstHeadingFound) {
            jdParagraphs.push(new PageBreak());
          } else {
            firstHeadingFound = true;
          }
          jdParagraphs.push(new Paragraph({
            children: [new TextRun({ text: trimmedLine.slice(2), bold: true, size: 24 })],
            heading: HeadingLevel.HEADING_1,
            spacing: { after: 150 },
            alignment: AlignmentType.LEFT,
          }));
        } else if (trimmedLine.startsWith('- ')) {
          // 列表项
          jdParagraphs.push(new Paragraph({
            children: [new TextRun({ text: trimmedLine.slice(2), size: 20 })],
            bullet: { level: 0 },
            spacing: { after: 80 },
            alignment: AlignmentType.LEFT,
          }));
        } else {
          // 普通文本
          jdParagraphs.push(new Paragraph({
            children: [new TextRun({ text: trimmedLine, size: 20 })],
            spacing: { after: 80 },
            alignment: AlignmentType.LEFT,
          }));
        }
      }

      // 创建文档(调整页面边距以适应放大的表格)
      const doc = new Document({
        sections: [
          {
            properties: {
              page: {
                margin: {
                  top: 1800, // 增加顶部边距(适应放大后的页眉)
                  bottom: 900,
                  left: 1260,
                  right: 1260,
                },
              },
            },
            // 设置页眉(每一页显示)
            headers: {
              default: header,
            },
            children: [
              new PageBreak(), // 标题后分页
              ...jdParagraphs,
            ],
          },
        ],
      });

      // 导出文档
      const blob = await Packer.toBlob(doc);
      const fileName = positionName !== '未获取职位信息' ? `${positionName}_JD.docx` : 'JD文档.docx';
      saveAs(blob, fileName);
      antdApp.message.success('导出 Word 成功');
    } catch (error) {
      console.error('导出 Word 失败:', error);
      antdApp.message.error('导出 Word 失败,请检查文档内容格式');
    }
  };

如果遇到发送给其他人(windows、mac)打不开或者不兼容

复制代码
const handleExportJd = async () => {
    try {
      // 动态导入 docx 库
      const docx = await import('docx');
      const {
        Document,
        Packer,
        TextRun,
        Paragraph,
        Table,
        TableRow,
        TableCell,
        WidthType,
        BorderStyle,
        AlignmentType,
        HeadingLevel,
        Header,
        PageBreak,
      } = docx;

      // 从 selectedJdDescription 中提取第一个非空行作为职位名称
      const getPositionName = () => {
        if (!selectedJdDescription) return '未获取职位信息';
        const lines = selectedJdDescription.split('\n').filter(line => line.trim() !== '');
        return lines.length > 0 ? lines[0].trim() : '未获取职位信息';
      };
      const positionName = getPositionName();

      // 创建页眉表格
      const headerTable = new Table({
        rows: [
          new TableRow({
            children: [
              // 第一列第一行:仅显示 VOSS
              new TableCell({
                children: [
                  new Paragraph({
                    children: [new TextRun({ text: 'VOSS', bold: true, size: 35, color: '#FF00FF' })],
                    alignment: AlignmentType.CENTER,
                  }),
                ],
                width: { size: 25, type: WidthType.PERCENTAGE },
                borders: {
                  top: { style: BorderStyle.SINGLE, size: 2, color: '000000' },
                  bottom: { style: BorderStyle.SINGLE, size: 2, color: '000000' },
                  left: { style: BorderStyle.SINGLE, size: 2, color: '000000' },
                  right: { style: BorderStyle.SINGLE, size: 2, color: '000000' },
                },
              }),
              // 第二列:合并行显示提取的职位名称
              new TableCell({
                rowSpan: 2,
                children: [
                  new Paragraph({
                    children: [new TextRun({
                      text: positionName,
                      bold: true,
                      size: 22,
                      color: '#000000'
                    })],
                    alignment: AlignmentType.CENTER,
                  })
                ],
                width: { size: 35, type: WidthType.PERCENTAGE },
                borders: {
                  top: { style: BorderStyle.SINGLE, size: 2, color: '000000' },
                  bottom: { style: BorderStyle.SINGLE, size: 2, color: '000000' },
                  left: { style: BorderStyle.SINGLE, size: 2, color: '000000' },
                  right: { style: BorderStyle.SINGLE, size: 2, color: '000000' },
                },
                verticalAlign: AlignmentType.CENTER, // 设置垂直居中对齐
              }),
              // 第三列第一行:Issue No.
              new TableCell({
                children: [
                  new Paragraph({
                    children: [new TextRun({ text: 'Issue No.', bold: true, size: 18 })],
                    alignment: AlignmentType.CENTER,
                  }),
                  new Paragraph({
                    children: [new TextRun({ text: issueNo || '未填写', size: 16 })],
                    alignment: AlignmentType.CENTER,
                  }),
                ],
                width: { size: 20, type: WidthType.PERCENTAGE },
                borders: {
                  top: { style: BorderStyle.SINGLE, size: 2, color: '000000' },
                  bottom: { style: BorderStyle.SINGLE, size: 2, color: '000000' },
                  left: { style: BorderStyle.SINGLE, size: 2, color: '000000' },
                  right: { style: BorderStyle.SINGLE, size: 2, color: '000000' },
                },
              }),
              // 第四列第一行:Issue Date
              new TableCell({
                children: [
                  new Paragraph({
                    children: [new TextRun({ text: 'Issue Date', bold: true, size: 18 })],
                    alignment: AlignmentType.CENTER,
                  }),
                  new Paragraph({
                    children: [new TextRun({ text: issueDate || '未填写', size: 16 })],
                    alignment: AlignmentType.CENTER,
                  }),
                ],
                width: { size: 20, type: WidthType.PERCENTAGE },
                borders: {
                  top: { style: BorderStyle.SINGLE, size: 2, color: '000000' },
                  bottom: { style: BorderStyle.SINGLE, size: 2, color: '000000' },
                  left: { style: BorderStyle.SINGLE, size: 2, color: '000000' },
                  right: { style: BorderStyle.SINGLE, size: 2, color: '000000' },
                },
              }),
            ],
          }),
          new TableRow({
            children: [
              // 第一列第二行:显示 Job Description 和 职位描述
              new TableCell({
                children: [
                  new Paragraph({
                    children: [new TextRun({ text: 'Job Description', bold: true, size: 18 })],
                    alignment: AlignmentType.CENTER,
                  }),
                  new Paragraph({
                    children: [new TextRun({ text: '职位描述', size: 16 })],
                    alignment: AlignmentType.CENTER,
                  }),
                ],
                width: { size: 25, type: WidthType.PERCENTAGE },
                borders: {
                  top: { style: BorderStyle.SINGLE, size: 2, color: '000000' },
                  bottom: { style: BorderStyle.SINGLE, size: 2, color: '000000' },
                  left: { style: BorderStyle.SINGLE, size: 2, color: '000000' },
                  right: { style: BorderStyle.SINGLE, size: 2, color: '000000' },
                },
              }),
              // 第三列第二行:Compiled By
              new TableCell({
                children: [
                  new Paragraph({
                    children: [new TextRun({ text: 'Compiled By', bold: true, size: 18 })],
                    alignment: AlignmentType.CENTER,
                  }),
                  new Paragraph({
                    children: [new TextRun({ text: compiledBy || '未填写', size: 16 })],
                    alignment: AlignmentType.CENTER,
                  }),
                ],
                width: { size: 20, type: WidthType.PERCENTAGE },
                borders: {
                  top: { style: BorderStyle.SINGLE, size: 2, color: '000000' },
                  bottom: { style: BorderStyle.SINGLE, size: 2, color: '000000' },
                  left: { style: BorderStyle.SINGLE, size: 2, color: '000000' },
                  right: { style: BorderStyle.SINGLE, size: 2, color: '000000' },
                },
              }),
              // 第四列第二行:Approved By
              new TableCell({
                children: [
                  new Paragraph({
                    children: [new TextRun({ text: 'Approved By', bold: true, size: 18 })],
                    alignment: AlignmentType.CENTER,
                  }),
                  new Paragraph({
                    children: [new TextRun({ text: approvedBy || '未填写', size: 16 })],
                    alignment: AlignmentType.CENTER,
                  }),
                ],
                width: { size: 20, type: WidthType.PERCENTAGE },
                borders: {
                  top: { style: BorderStyle.SINGLE, size: 2, color: '000000' },
                  bottom: { style: BorderStyle.SINGLE, size: 2, color: '000000' },
                  left: { style: BorderStyle.SINGLE, size: 2, color: '000000' },
                  right: { style: BorderStyle.SINGLE, size: 2, color: '000000' },
                },
              }),
            ],
          }),
        ],
        width: { size: 100, type: WidthType.PERCENTAGE },
      });

      // 将表格包装成页眉对象,简化配置以确保兼容性
      const header = new Header({
        children: [headerTable],
        // 设置页眉的边距和定位
        properties: {
          headerPosition: {
            value: 0, // 距离页面顶部的距离
          },
        },
      });

      // 处理 JD 内容,转换为 Word 段落
      const jdParagraphs = [];
      const lines = selectedJdDescription.split('\n');
      let firstHeadingFound = false;

      for (const line of lines) {
        const trimmedLine = line.trim();

        if (trimmedLine === '') {
          // 空行
          jdParagraphs.push(new Paragraph({ spacing: { after: 50 } }));
        } else if (trimmedLine.startsWith('# ')) {
          // 一级标题
          if (firstHeadingFound) {
            jdParagraphs.push(new PageBreak());
          } else {
            firstHeadingFound = true;
          }
          jdParagraphs.push(new Paragraph({
            children: [new TextRun({ text: trimmedLine.slice(2), bold: true, size: 22 })],
            heading: HeadingLevel.HEADING_1,
            spacing: { after: 100 },
          }));
        } else if (trimmedLine.startsWith('- ')) {
          // 列表项
          jdParagraphs.push(new Paragraph({
            children: [new TextRun({ text: trimmedLine.slice(2), size: 18 })],
            bullet: { level: 0 },
            spacing: { after: 50 },
          }));
        } else {
          // 普通文本
          jdParagraphs.push(new Paragraph({
            children: [new TextRun({ text: trimmedLine, size: 18 })],
            spacing: { after: 50 },
          }));
        }
      }

      // 创建文档
      const doc = new Document({
        sections: [
          {
            properties: {
              page: {
                margin: {
                  top: 1800, // 增加顶部边距以确保页眉能正确显示
                  bottom: 800,
                  left: 1000,
                  right: 1000,
                },
              },
              // 确保所有页面都使用相同的页眉配置
              headerFooter: {
                differentFirstPage: false,
                differentOddEven: false,
              },
            },
            // 为所有页面类型都设置相同的页眉
            headers: {
              default: header,
              first: header,
              even: header,
              odd: header,
            },
            children: [...jdParagraphs],
          },
        ],
      });

      // 导出文档 - 使用更可靠的方式处理 blob
      const blob = await Packer.toBlob(doc);
      const fileName = positionName !== '未获取职位信息' ? `${positionName}_JD.docx` : 'JD文档.docx';

      try {
        // 确保blob类型正确
        const docxBlob = new Blob([blob], { type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' });

        // 使用标准的下载方法,避免依赖外部库
        const url = window.URL.createObjectURL(docxBlob);
        const a = document.createElement('a');
        a.href = url;
        a.download = fileName;
        document.body.appendChild(a);

        // 触发下载
        if (document.createEvent) {
          const event = document.createEvent('MouseEvents');
          event.initEvent('click', true, true);
          a.dispatchEvent(event);
        } else {
          a.click();
        }

        // 清理
        setTimeout(() => {
          document.body.removeChild(a);
          window.URL.revokeObjectURL(url);
        }, 100);

        antdApp.message.success('导出 Word 成功');
      } catch (downloadError) {
        console.error('下载文件失败:', downloadError);

        // 降级处理:尝试直接打开blob
        const url = window.URL.createObjectURL(blob);
        window.open(url, '_blank');
        antdApp.message.info('导出成功,文件已在新窗口打开');
      }
    } catch (error) {
      console.error('导出 Word 失败:', error);
      // 提供更详细的错误信息
      if (error instanceof Error) {
        console.error('Error message:', error.message);
        console.error('Error stack:', error.stack);
      }
      antdApp.message.error('导出 Word 失败,请检查文档内容格式');
    }
  };

仅供参考!!!

六、关键技术点总结

  1. 列合并实现 :通过TableCellrowSpan属性实现跨行合并,适用于需要纵向占用多行的内容(如职位名称)。
  2. 宽高控制 :列宽使用WidthType.PERCENTAGE(百分比)确保适配不同页面尺寸,行高使用dxa单位(Word 原生单位)确保精度。
  3. 对齐方式 :结合verticalAlign: 'center'(垂直居中)和AlignmentType.CENTER(水平居中),实现表格内容的完全居中。
  4. 样式复用:提取公共样式(边框、文字大小),降低代码冗余,便于后续维护。

七、注意事项

  1. docx库的单位差异:dxa是 Word 内部长度单位(1pt≈20dxa),设置行高时需注意换算,避免页眉过高或过低。
  2. 边框样式一致性:需为每个单元格单独设置边框(或通过表格全局边框配置),避免出现边框缺失。
  3. 动态导入兼容性:确保docx版本与导入方式匹配(v9.5.1 支持 ES 模块动态导入),避免版本冲突。

八、最终呈现效果

下载到本地之后打开即可:

通过以上方案,可实现结构固定、样式统一、数据动态的自定义页眉表格,满足 Word 导出场景中的复杂页眉需求,同时保证导出文档的专业性和规范性。

注意**⚠****:**本文主要是针对如何实现 word 文档页眉表格形式的设置,其余逻辑以及业务需自行修改!!!

相关推荐
心在飞扬2 小时前
langchain学习总结-OutputParser组件及使用技巧
前端·后端
llq_3502 小时前
Ant Design v5 样式兼容性问题与解决方案
前端
triumph_passion2 小时前
React Hook Form 状态下沉最佳实践
前端·react.js
心在飞扬2 小时前
langchain学习总结-两个Runnable核心类的讲解与使用
前端·后端
德育处主任2 小时前
在小程序做海报的话,Painter就很给力
前端·微信小程序·canvas
匠心码员2 小时前
Git Commit 提交规范:让每一次提交都清晰可读
前端
骑斑马的李司凌2 小时前
调试时卡半天?原来127.0.0.1和localhost的区别这么大!
前端
哈哈O哈哈哈2 小时前
Electron + Vue 3 + Node.js 的跨平台桌面应用示例项目
前端
ycbing2 小时前
设计并实现一个 MCP Server
前端
千寻girling2 小时前
面试官: “ 说一下怎么做到前端图片尺寸的响应式适配 ”
前端·javascript·面试