From 3df8b1aa4ac3d5ca030121ab7ee9387e9d106951 Mon Sep 17 00:00:00 2001
From: yuan <123@>
Date: 星期五, 09 十月 2026 10:58:54 +0800
Subject: [PATCH] feat: 增加科目总账明细表excel导出 和 凭证增加记账凭证work导出

---
 src/utils/docxWriter.js                                     |  317 +++++++++++++
 src/views/financialManagement/voucher/detailLedgerExport.js |  180 +++++++
 src/utils/zipStore.js                                       |  110 ++++
 src/utils/xlsxWriter.js                                     |  417 ++++++++++++++++++
 src/views/financialManagement/voucher/voucherExport.js      |  263 +++++++++++
 src/views/financialManagement/voucher/index.vue             |   18 
 src/views/financialManagement/voucher/detailLedger.vue      |   25 +
 7 files changed, 1,328 insertions(+), 2 deletions(-)

diff --git a/src/utils/docxWriter.js b/src/utils/docxWriter.js
new file mode 100644
index 0000000..96cced5
--- /dev/null
+++ b/src/utils/docxWriter.js
@@ -0,0 +1,317 @@
+/**
+ * 鏋佺畝 DOCX 鐢熸垚鍣紙闆朵緷璧栵級
+ *
+ * 鍙疄鐜板鍑哄崟鎹墍闇�鐨� WordprocessingML 瀛愰泦锛氶〉闈㈠昂瀵�/椤佃竟璺濄�佹钀�
+ * 锛堢缉杩涖�佽璺濄�佸埗琛ㄤ綅銆佸榻愩�佸瓧浣撳瓧鍙峰姞绮楋級銆佽〃鏍硷紙鍥哄畾鍒楀銆佸悎骞跺崟鍏冩牸銆�
+ * 杈规銆佽楂樸�佸崟鍏冩牸鍨傜洿瀵归綈锛夈��
+ *
+ * 鏂囨。妯″瀷锛�
+ *   buildDocxBlob({
+ *     page: { width, height, margin: { top, right, bottom, left } },
+ *     blocks: [
+ *       { type: "paragraph", indentLeft, spacing: { before, line, lineRule },
+ *         align, tabs: [2310], runs: [{ text, size, bold }, { tab: true }] },
+ *       { type: "table", width, indent, grid: [821, ...],
+ *         rows: [{ height, heightRule, cells: [
+ *           { text, width, gridSpan, align, bold, size, vAlign } ] }] },
+ *     ],
+ *   })
+ *
+ * 灏哄鍗曚綅缁熶竴浣跨敤 twips锛�1/20 纾咃級锛屽瓧鍙蜂娇鐢ㄥ崐纾咃紙sz=21 鍗� 10.5 纾咃級銆�
+ */
+import { createZip, utf8 } from "./zipStore";
+
+const DOCX_MIME =
+  "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
+const W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
+const R_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
+
+const escapeXml = (value) =>
+  String(value)
+    .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, "")
+    .replace(/&/g, "&amp;")
+    .replace(/</g, "&lt;")
+    .replace(/>/g, "&gt;")
+    .replace(/"/g, "&quot;")
+    .replace(/'/g, "&apos;");
+
+const ATTR_ORDER = ["val", "color", "sz", "space", "w", "type", "before", "after", "line", "lineRule", "left", "right", "firstLine", "hanging"];
+
+const el = (tag, attrs, children) => {
+  const keys = attrs ? Object.keys(attrs).filter((k) => attrs[k] !== undefined && attrs[k] !== null) : [];
+  keys.sort((a, b) => {
+    const ia = ATTR_ORDER.indexOf(a);
+    const ib = ATTR_ORDER.indexOf(b);
+    return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib);
+  });
+  const attrText = keys.map((k) => ` w:${k}="${escapeXml(attrs[k])}"`).join("");
+  if (children === undefined) return `<w:${tag}${attrText}/>`;
+  return `<w:${tag}${attrText}>${children}</w:${tag}>`;
+};
+
+/** 鐢熸垚 run 鐨勫睘鎬ц妭鐐癸紙椤哄簭蹇呴』绗﹀悎 CT_RPr 鐨� schema 瀹氫箟锛� */
+const buildRunProps = (run) => {
+  const parts = [];
+  if (run.font) {
+    parts.push(
+      el("rFonts", { ascii: run.font, hAnsi: run.font, eastAsia: run.font, cs: run.font })
+    );
+  }
+  if (run.bold) {
+    parts.push(el("b"), el("bCs"));
+  }
+  if (run.color) parts.push(el("color", { val: run.color }));
+  if (run.size) {
+    const sz = String(run.size);
+    parts.push(el("sz", { val: sz }), el("szCs", { val: sz }));
+  }
+  return parts.length ? el("rPr", null, parts.join("")) : "";
+};
+
+const buildRuns = (runs = []) =>
+  runs
+    .map((run) => {
+      const rPr = buildRunProps(run);
+      if (run.tab) return `<w:r>${rPr}<w:tab/></w:r>`;
+      const text = run.text === undefined || run.text === null ? "" : String(run.text);
+      if (!text) return rPr ? `<w:r>${rPr}</w:r>` : "";
+      return `<w:r>${rPr}<w:t xml:space="preserve">${escapeXml(text)}</w:t></w:r>`;
+    })
+    .join("");
+
+/** 鐢熸垚娈佃惤灞炴�ц妭鐐癸紝瀛愬厓绱犻『搴忓繀椤荤鍚� CT_PPr */
+const buildParaProps = (block) => {
+  const parts = [];
+  if (block.tabs && block.tabs.length) {
+    parts.push(
+      el(
+        "tabs",
+        null,
+        block.tabs.map((pos) => el("tab", { val: "left", pos: String(pos) })).join("")
+      )
+    );
+  }
+  if (block.spacing) {
+    parts.push(
+      el("spacing", {
+        before: block.spacing.before,
+        after: block.spacing.after,
+        line: block.spacing.line,
+        lineRule: block.spacing.lineRule,
+      })
+    );
+  }
+  if (block.indentLeft || block.indentRight || block.indentFirstLine) {
+    parts.push(
+      el("ind", {
+        left: block.indentLeft,
+        right: block.indentRight,
+        firstLine: block.indentFirstLine,
+      })
+    );
+  }
+  if (block.align) parts.push(el("jc", { val: block.align }));
+  if (block.runs && block.runs.length) {
+    const rPr = buildRunProps(block.runs[0]);
+    if (rPr) parts.push(rPr);
+  }
+  return parts.length ? el("pPr", null, parts.join("")) : "";
+};
+
+const buildParagraph = (block) => {
+  if (block.type === "empty") return "<w:p/>";
+  return `<w:p>${buildParaProps(block)}${buildRuns(block.runs)}</w:p>`;
+};
+
+const BORDER_SIDES = ["top", "left", "bottom", "right", "insideH", "insideV"];
+
+const buildTableBorders = (border) => {
+  if (!border) return "";
+  const sides = BORDER_SIDES.map((side) => {
+    if (!border[side]) return "";
+    return el(side, {
+      val: border[side].val || "single",
+      color: border[side].color || "000000",
+      sz: String(border[side].sz || 6),
+      space: "0",
+    });
+  }).join("");
+  return sides ? el("tblBorders", null, sides) : "";
+};
+
+const buildCell = (cell) => {
+  const parts = [];
+  if (cell.width) parts.push(el("tcW", { w: String(cell.width), type: "dxa" }));
+  if (cell.gridSpan && cell.gridSpan > 1) {
+    parts.push(el("gridSpan", { val: String(cell.gridSpan) }));
+  }
+  if (cell.vAlign) parts.push(el("vAlign", { val: cell.vAlign }));
+
+  // 绌哄崟鍏冩牸涔熶繚鐣欐钀藉睘鎬э紙瀵归綈/缂╄繘锛夛紝渚夸簬鍚庣画鍦� Word 涓户缁紪杈�
+  const paragraph = buildParagraph({
+    align: cell.align,
+    indentLeft: cell.indentLeft,
+    spacing: cell.spacing,
+    runs: cell.text
+      ? [{ text: cell.text, size: cell.size, bold: cell.bold, font: cell.font }]
+      : [],
+  });
+
+  return `<w:tc>${parts.length ? el("tcPr", null, parts.join("")) : ""}${paragraph}</w:tc>`;
+};
+
+const buildTable = (table) => {
+  const tblPrParts = [];
+  if (table.width) tblPrParts.push(el("tblW", { w: String(table.width), type: "dxa" }));
+  if (table.indent) tblPrParts.push(el("tblInd", { w: String(table.indent), type: "dxa" }));
+  const borders = buildTableBorders(table.border);
+  if (borders) tblPrParts.push(borders);
+  tblPrParts.push(el("tblLayout", { type: "fixed" }));
+  if (table.cellMargin !== undefined && table.cellMargin !== null) {
+    const m = table.cellMargin;
+    tblPrParts.push(
+      el(
+        "tblCellMar",
+        null,
+        ["top", "left", "bottom", "right"]
+          .map((side) => el(side, { w: String(m), type: "dxa" }))
+          .join("")
+      )
+    );
+  }
+
+  const grid = (table.grid || [])
+    .map((w) => el("gridCol", { w: String(w) }))
+    .join("");
+
+  const rows = (table.rows || [])
+    .map((row) => {
+      const trPr = row.height
+        ? el("trPr", null, el("trHeight", { val: String(row.height), hRule: row.heightRule || "atLeast" }))
+        : "";
+      const cells = (row.cells || []).map(buildCell).join("");
+      return `<w:tr>${trPr}${cells}</w:tr>`;
+    })
+    .join("");
+
+  return `<w:tbl>${el("tblPr", null, tblPrParts.join(""))}${el(
+    "tblGrid",
+    null,
+    grid
+  )}${rows}</w:tbl>`;
+};
+
+const buildSectPr = (page) => {
+  const margin = page.margin || {};
+  return el(
+    "sectPr",
+    null,
+    [
+      el("pgSz", { w: String(page.width), h: String(page.height) }),
+      el("pgMar", {
+        top: String(margin.top || 0),
+        right: String(margin.right || 0),
+        bottom: String(margin.bottom || 0),
+        left: String(margin.left || 0),
+        header: "0",
+        footer: "0",
+        gutter: "0",
+      }),
+      el("cols", { space: "720", num: "1" }),
+    ].join("")
+  );
+};
+
+const buildDocumentXml = (model) => {
+  const body = (model.blocks || [])
+    .map((block) => (block.type === "table" ? buildTable(block) : buildParagraph(block)))
+    .join("");
+  return [
+    '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
+    `<w:document xmlns:w="${W_NS}" xmlns:r="${R_NS}">`,
+    `<w:body>${body}${buildSectPr(model.page || {})}</w:body>`,
+    "</w:document>",
+  ].join("");
+};
+
+const buildStylesXml = (defaultFont, defaultSize) => {
+  const fonts = el("rFonts", {
+    ascii: defaultFont,
+    hAnsi: defaultFont,
+    eastAsia: defaultFont,
+    cs: defaultFont,
+  });
+  const sz = String(defaultSize);
+  return [
+    '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
+    `<w:styles xmlns:w="${W_NS}">`,
+    el(
+      "docDefaults",
+      null,
+      el(
+        "rPrDefault",
+        null,
+        el("rPr", null, `${fonts}${el("sz", { val: sz })}${el("szCs", { val: sz })}`)
+      ) +
+        el(
+          "pPrDefault",
+          null,
+          el("pPr", null, el("spacing", { line: "240", lineRule: "auto" }))
+        )
+    ),
+    '<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:qFormat/></w:style>',
+    "</w:styles>",
+  ].join("");
+};
+
+/**
+ * 鐢熸垚 docx 鏂囦欢鐨� Blob銆�
+ * @param {{page?: object, blocks?: Array, defaultFont?: string, defaultSize?: number}} model
+ * @returns {Blob}
+ */
+export function buildDocxBlob(model = {}) {
+  const font = model.defaultFont || "瀹嬩綋";
+  const size = model.defaultSize || 21;
+
+  const contentTypes = [
+    '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
+    '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">',
+    '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>',
+    '<Default Extension="xml" ContentType="application/xml"/>',
+    '<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>',
+    '<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>',
+    "</Types>",
+  ].join("");
+
+  const files = [
+    { name: "[Content_Types].xml", data: utf8(contentTypes) },
+    {
+      name: "_rels/.rels",
+      data: utf8(
+        [
+          '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
+          '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">',
+          `<Relationship Id="rId1" Type="${R_NS}/officeDocument" Target="word/document.xml"/>`,
+          "</Relationships>",
+        ].join("")
+      ),
+    },
+    { name: "word/document.xml", data: utf8(buildDocumentXml(model)) },
+    {
+      name: "word/_rels/document.xml.rels",
+      data: utf8(
+        [
+          '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
+          '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">',
+          `<Relationship Id="rId1" Type="${R_NS}/styles" Target="styles.xml"/>`,
+          "</Relationships>",
+        ].join("")
+      ),
+    },
+    { name: "word/styles.xml", data: utf8(buildStylesXml(font, size)) },
+  ];
+
+  return createZip(files, DOCX_MIME);
+}
+
+export default buildDocxBlob;
diff --git a/src/utils/xlsxWriter.js b/src/utils/xlsxWriter.js
new file mode 100644
index 0000000..551a2b9
--- /dev/null
+++ b/src/utils/xlsxWriter.js
@@ -0,0 +1,417 @@
+/**
+ * 鏋佺畝 XLSX 鐢熸垚鍣紙闆朵緷璧栵級
+ *
+ * 鍙疄鐜板鍑哄満鏅墍闇�鐨� OOXML 瀛愰泦锛氬垪瀹姐�佽楂樸�佸悎骞跺崟鍏冩牸銆佸瓧浣撱�佽竟妗嗐��
+ * 瀵归綈鏂瑰紡銆佹暟瀛楁牸寮忋�佺函鑹插~鍏呫�傛暟鎹竴寰嬪啓涓� inlineStr / number锛�
+ * 涓嶇敓鎴� sharedStrings锛岄伩鍏嶉澶栫殑閮ㄤ欢涓庣储寮曠淮鎶ゃ��
+ *
+ * 鐢ㄦ硶锛�
+ *   const blob = buildXlsxBlob([{
+ *     name: "Sheet1",
+ *     columns: [{ width: 14 }],
+ *     rows: [{ height: 20, cells: [{ value: "鏍囬", style: { ... } }] }],
+ *     merges: [{ r1: 0, c1: 0, r2: 0, c2: 7 }],
+ *   }]);
+ */
+
+import { createZip, utf8 } from "./zipStore";
+
+const escapeXml = (value) =>
+  String(value)
+    // 鍘婚櫎 XML 1.0 涓嶅厑璁哥殑鎺у埗瀛楃
+    .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, "")
+    .replace(/&/g, "&amp;")
+    .replace(/</g, "&lt;")
+    .replace(/>/g, "&gt;")
+    .replace(/"/g, "&quot;")
+    .replace(/'/g, "&apos;");
+
+const columnName = (index) => {
+  let name = "";
+  let n = index + 1;
+  while (n > 0) {
+    const remainder = (n - 1) % 26;
+    name = String.fromCharCode(65 + remainder) + name;
+    n = Math.floor((n - 1) / 26);
+  }
+  return name;
+};
+
+const cellRef = (rowIndex, colIndex) => `${columnName(colIndex)}${rowIndex + 1}`;
+
+const XLSX_MIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
+
+const BORDER_STYLE_BY_CODE = {
+  1: "thin",
+  2: "medium",
+  3: "dashed",
+  4: "dotted",
+  5: "thick",
+  6: "double",
+  7: "hair",
+  8: "mediumDashed",
+  9: "dashDot",
+  10: "mediumDashDot",
+  11: "dashDotDot",
+  12: "mediumDashDotDot",
+  13: "slantDashDot",
+};
+
+const resolveBorderStyle = (value) => {
+  if (!value) return "";
+  if (typeof value === "number") return BORDER_STYLE_BY_CODE[value] || "thin";
+  return value === true ? "thin" : String(value);
+};
+
+/** 鎶婃牱寮忓璞″綊涓�鍖栨垚鍙瘮杈冪殑 key锛屽苟鏀堕泦鍚勫瓙琛� */
+const collectStyles = (styleList) => {
+  const fonts = [];
+  const fills = [];
+  const borders = [];
+  const numFmts = [];
+  const xfs = [];
+
+  const fontIndex = new Map();
+  const fillIndex = new Map();
+  const borderIndex = new Map();
+  const numFmtIndex = new Map();
+
+  // 榛樿瀛椾綋 / 濉厖 / 杈规鍗犱綅锛坒ill 0=none銆�1=gray125 鏄� Excel 鐨勫浐瀹氱害瀹氾級
+  const internFont = (font) => {
+    const key = JSON.stringify(font || {});
+    if (fontIndex.has(key)) return fontIndex.get(key);
+    const index = fonts.length;
+    fonts.push(font || {});
+    fontIndex.set(key, index);
+    return index;
+  };
+  const internFill = (fill) => {
+    const key = JSON.stringify(fill || null);
+    if (fillIndex.has(key)) return fillIndex.get(key);
+    const index = fills.length;
+    fills.push(fill || null);
+    fillIndex.set(key, index);
+    return index;
+  };
+  const internBorder = (border) => {
+    const normalized = border || {};
+    const key = JSON.stringify(normalized);
+    if (borderIndex.has(key)) return borderIndex.get(key);
+    const index = borders.length;
+    borders.push(normalized);
+    borderIndex.set(key, index);
+    return index;
+  };
+  const internNumFmt = (formatCode) => {
+    if (!formatCode) return 0;
+    if (numFmtIndex.has(formatCode)) return numFmtIndex.get(formatCode);
+    const id = 164 + numFmts.length;
+    numFmts.push({ id, code: formatCode });
+    numFmtIndex.set(formatCode, id);
+    return id;
+  };
+
+  // 棰勭暀榛樿鏉$洰
+  internFont({ name: "瀹嬩綋", size: 11 });
+  internFill(null);
+  internFill({ patternType: "gray125" });
+  internBorder({});
+
+  styleList.forEach((style) => {
+    const source = style || {};
+    xfs.push({
+      fontId: internFont(source.font),
+      fillId: internFill(source.fill),
+      borderId: internBorder(source.border),
+      numFmtId: internNumFmt(source.numFmt),
+      align: source.align || null,
+    });
+  });
+
+  return { fonts, fills, borders, numFmts, xfs };
+};
+
+const buildFontXml = (font) => {
+  const parts = ["<font>"];
+  if (font.bold) parts.push("<b/>");
+  if (font.italic) parts.push("<i/>");
+  if (font.strike) parts.push("<strike/>");
+  if (font.underline) {
+    parts.push(font.underline === "double" ? '<u val="double"/>' : "<u/>");
+  }
+  parts.push(`<sz val="${font.size || 11}"/>`);
+  if (font.color) parts.push(`<color rgb="${font.color}"/>`);
+  parts.push(`<name val="${escapeXml(font.name || "瀹嬩綋")}"/>`);
+  if (font.charset) parts.push(`<charset val="${font.charset}"/>`);
+  parts.push("</font>");
+  return parts.join("");
+};
+
+const buildFillXml = (fill) => {
+  if (!fill) return '<fill><patternFill patternType="none"/></fill>';
+  const patternType = fill.patternType || "solid";
+  const fg = fill.color ? `<fgColor rgb="${fill.color}"/>` : "<fgColor indexed=\"64\"/>";
+  return `<fill><patternFill patternType="${patternType}">${fg}<bgColor indexed="64"/></patternFill></fill>`;
+};
+
+const buildBorderXml = (border) => {
+  const side = (name) => {
+    const style = resolveBorderStyle(border[name]);
+    return style ? `<${name} style="${style}"><color indexed="64"/></${name}>` : `<${name}/>`;
+  };
+  return `<border>${side("left")}${side("right")}${side("top")}${side("bottom")}<diagonal/></border>`;
+};
+
+const buildXfXml = (xf) => {
+  const attrs = [
+    `numFmtId="${xf.numFmtId}"`,
+    `fontId="${xf.fontId}"`,
+    `fillId="${xf.fillId}"`,
+    `borderId="${xf.borderId}"`,
+    'xfId="0"',
+  ];
+  if (xf.numFmtId) attrs.push('applyNumberFormat="1"');
+  if (xf.fontId) attrs.push('applyFont="1"');
+  if (xf.fillId > 1) attrs.push('applyFill="1"');
+  if (xf.borderId) attrs.push('applyBorder="1"');
+
+  const align = xf.align || {};
+  const alignAttrs = [];
+  if (align.horizontal) alignAttrs.push(`horizontal="${align.horizontal}"`);
+  if (align.vertical) alignAttrs.push(`vertical="${align.vertical}"`);
+  if (align.wrapText) alignAttrs.push('wrapText="1"');
+  if (align.textRotation) alignAttrs.push(`textRotation="${align.textRotation}"`);
+  if (alignAttrs.length) {
+    attrs.push('applyAlignment="1"');
+    return `<xf ${attrs.join(" ")}><alignment ${alignAttrs.join(" ")}/></xf>`;
+  }
+  return `<xf ${attrs.join(" ")}/>`;
+};
+
+const buildStylesXml = (styleTable) => {
+  const { fonts, fills, borders, numFmts, xfs } = styleTable;
+  const numFmtXml = numFmts.length
+    ? `<numFmts count="${numFmts.length}">${numFmts
+        .map((item) => `<numFmt numFmtId="${item.id}" formatCode="${escapeXml(item.code)}"/>`)
+        .join("")}</numFmts>`
+    : "";
+
+  return [
+    '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
+    '<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">',
+    numFmtXml,
+    `<fonts count="${fonts.length}">${fonts.map(buildFontXml).join("")}</fonts>`,
+    `<fills count="${fills.length}">${fills.map(buildFillXml).join("")}</fills>`,
+    `<borders count="${borders.length}">${borders.map(buildBorderXml).join("")}</borders>`,
+    '<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>',
+    `<cellXfs count="${xfs.length}">${xfs.map(buildXfXml).join("")}</cellXfs>`,
+    '<cellStyles count="1"><cellStyle name="甯歌" xfId="0" builtinId="0"/></cellStyles>',
+    "</styleSheet>",
+  ].join("");
+};
+
+const buildSheetXml = (sheet, styleTable) => {
+  const rows = sheet.rows || [];
+  const columns = sheet.columns || [];
+
+  const colsXml = columns.length
+    ? `<cols>${columns
+        .map((column, index) => {
+          const width = typeof column === "number" ? column : column.width;
+          if (!width) return "";
+          return `<col min="${index + 1}" max="${index + 1}" width="${width}" customWidth="1"/>`;
+        })
+        .join("")}</cols>`
+    : "";
+
+  let maxCol = 0;
+  const rowXml = rows
+    .map((row, rowIndex) => {
+      const cells = row.cells || [];
+      const cellsXml = cells
+        .map((cell, colIndex) => {
+          if (!cell || cell.value === undefined || cell.value === null || cell.value === "") {
+            if (!cell || cell.style === undefined) return "";
+            return `<c r="${cellRef(rowIndex, colIndex)}" s="${cell.style}"/>`;
+          }
+          const ref = cellRef(rowIndex, colIndex);
+          const styleAttr = cell.style === undefined ? "" : ` s="${cell.style}"`;
+          if (typeof cell.value === "number" && Number.isFinite(cell.value)) {
+            return `<c r="${ref}"${styleAttr}><v>${cell.value}</v></c>`;
+          }
+          if (typeof cell.value === "boolean") {
+            return `<c r="${ref}"${styleAttr} t="b"><v>${cell.value ? 1 : 0}</v></c>`;
+          }
+          return `<c r="${ref}"${styleAttr} t="inlineStr"><is><t xml:space="preserve">${escapeXml(
+            cell.value
+          )}</t></is></c>`;
+        })
+        .filter(Boolean)
+        .join("");
+
+      const cellCount = cells.length;
+      if (cellCount > maxCol) maxCol = cellCount;
+      if (!cellsXml) return "";
+      const heightAttr = row.height ? ` ht="${row.height}" customHeight="1"` : "";
+      return `<row r="${rowIndex + 1}"${heightAttr}>${cellsXml}</row>`;
+    })
+    .filter(Boolean)
+    .join("");
+
+  const merges = sheet.merges || [];
+  const mergesXml = merges.length
+    ? `<mergeCells count="${merges.length}">${merges
+        .map(
+          (merge) =>
+            `<mergeCell ref="${cellRef(merge.r1, merge.c1)}:${cellRef(merge.r2, merge.c2)}"/>`
+        )
+        .join("")}</mergeCells>`
+    : "";
+
+  const lastRow = rows.length;
+  const dimension = lastRow && maxCol ? `A1:${cellRef(lastRow - 1, maxCol - 1)}` : "A1";
+
+  return [
+    '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
+    '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">',
+    `<dimension ref="${dimension}"/>`,
+    '<sheetViews><sheetView workbookViewId="0"/></sheetViews>',
+    '<sheetFormatPr defaultRowHeight="14.25"/>',
+    colsXml,
+    `<sheetData>${rowXml}</sheetData>`,
+    mergesXml,
+    "</worksheet>",
+  ]
+    .filter(Boolean)
+    .join("");
+};
+
+/**
+ * 鐢熸垚 xlsx 鏂囦欢鐨� Blob銆�
+ * @param {Array<{name:string, columns?:Array, rows?:Array, merges?:Array}>} sheets
+ * @returns {Blob}
+ */
+export function buildXlsxBlob(sheets) {
+  const sheetList = Array.isArray(sheets) ? sheets : [sheets];
+
+  // 鏍峰紡鍏ㄨ〃鍘婚噸锛氭墍鏈夊伐浣滆〃鐨勬牱寮忓叡鐢ㄤ竴寮犺〃
+  const flatStyles = [];
+  const styleIds = new Map();
+  const internStyle = (style) => {
+    const key = JSON.stringify(style || {});
+    if (styleIds.has(key)) return styleIds.get(key);
+    const id = flatStyles.length;
+    flatStyles.push(style || {});
+    styleIds.set(key, id);
+    return id;
+  };
+
+  // 鍏堟妸姣忎釜鍗曞厓鏍肩殑鏍峰紡瀵硅薄鏇挎崲鎴愬叏灞�鏍峰紡涓嬫爣
+  const preparedSheets = sheetList.map((sheet, sheetIndex) => ({
+    name: (sheet.name || `Sheet${sheetIndex + 1}`).replace(/[\\/*?:[\]]/g, "").slice(0, 31),
+    columns: sheet.columns || [],
+    merges: sheet.merges || [],
+    rows: (sheet.rows || []).map((row) => ({
+      height: row.height,
+      cells: (row.cells || []).map((cell) => {
+        if (!cell) return null;
+        if (cell.style === undefined || cell.style === null) {
+          return { value: cell.value, style: 0 };
+        }
+        if (typeof cell.style === "number") return { value: cell.value, style: cell.style };
+        return { value: cell.value, style: internStyle(cell.style) };
+      }),
+    })),
+  }));
+
+  // 鏍峰紡琛ㄩ椤瑰繀椤绘槸榛樿鏍峰紡锛屼繚璇佺┖鐨� style=0 鍙敤
+  if (flatStyles.length === 0) flatStyles.push({});
+  const styleTable = collectStyles(flatStyles);
+
+  const sheetFiles = preparedSheets.map((sheet, index) => ({
+    name: `xl/worksheets/sheet${index + 1}.xml`,
+    data: utf8(buildSheetXml(sheet, styleTable)),
+  }));
+
+  const workbookSheets = preparedSheets
+    .map(
+      (sheet, index) =>
+        `<sheet name="${escapeXml(sheet.name)}" sheetId="${index + 1}" r:id="rId${index + 1}"/>`
+    )
+    .join("");
+
+  const workbookRels = [
+    ...preparedSheets.map(
+      (sheet, index) =>
+        `<Relationship Id="rId${index + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet${
+          index + 1
+        }.xml"/>`
+    ),
+    `<Relationship Id="rId${preparedSheets.length + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>`,
+  ].join("");
+
+  const contentTypes = [
+    '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
+    '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">',
+    '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>',
+    '<Default Extension="xml" ContentType="application/xml"/>',
+    '<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>',
+    ...preparedSheets.map(
+      (sheet, index) =>
+        `<Override PartName="/xl/worksheets/sheet${
+          index + 1
+        }.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`
+    ),
+    '<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>',
+    "</Types>",
+  ].join("");
+
+  const files = [
+    {
+      name: "[Content_Types].xml",
+      data: utf8(contentTypes),
+    },
+    {
+      name: "_rels/.rels",
+      data: utf8(
+        [
+          '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
+          '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">',
+          '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>',
+          "</Relationships>",
+        ].join("")
+      ),
+    },
+    {
+      name: "xl/workbook.xml",
+      data: utf8(
+        [
+          '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
+          '<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">',
+          `<sheets>${workbookSheets}</sheets>`,
+          "</workbook>",
+        ].join("")
+      ),
+    },
+    {
+      name: "xl/_rels/workbook.xml.rels",
+      data: utf8(
+        [
+          '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
+          '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">',
+          workbookRels,
+          "</Relationships>",
+        ].join("")
+      ),
+    },
+    {
+      name: "xl/styles.xml",
+      data: utf8(buildStylesXml(styleTable)),
+    },
+    ...sheetFiles,
+  ];
+
+  return createZip(files, XLSX_MIME);
+}
+
+export default buildXlsxBlob;
diff --git a/src/utils/zipStore.js b/src/utils/zipStore.js
new file mode 100644
index 0000000..98d38c9
--- /dev/null
+++ b/src/utils/zipStore.js
@@ -0,0 +1,110 @@
+/**
+ * 鏋佺畝 ZIP 鎵撳寘锛坰tore 瀛樺偍锛屼笉鍘嬬缉锛夈��
+ *
+ * OOXML 瀹瑰櫒锛坸lsx / docx锛夋湰璐ㄩ兘鏄� zip 鍖咃紝杩欓噷鍙疄鐜板啓鏂囦欢鎵�闇�鐨勬渶灏忛泦鍚堬細
+ * 鏈湴鏂囦欢澶� + 涓ぎ鐩綍 + 鐩綍缁撴潫璁板綍銆備笉鍘嬬缉鍙互鐪佹帀 deflate 渚濊禆锛�
+ * 浣撶Н瀵瑰鍑哄満鏅彲浠ユ帴鍙楋紝涓旀墍鏈夎В鏋愬櫒閮藉吋瀹广��
+ */
+
+const CRC_TABLE = (() => {
+  const table = new Uint32Array(256);
+  for (let i = 0; i < 256; i += 1) {
+    let c = i;
+    for (let k = 0; k < 8; k += 1) {
+      c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
+    }
+    table[i] = c >>> 0;
+  }
+  return table;
+})();
+
+const crc32 = (bytes) => {
+  let c = 0xffffffff;
+  for (let i = 0; i < bytes.length; i += 1) {
+    c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
+  }
+  return (c ^ 0xffffffff) >>> 0;
+};
+
+const encoder = new TextEncoder();
+
+export const utf8 = (text) => encoder.encode(text);
+
+/**
+ * @param {Array<{name: string, data: Uint8Array}>} files
+ * @param {string} mimeType
+ * @returns {Blob}
+ */
+export function createZip(files, mimeType) {
+  const now = new Date();
+  const dosTime =
+    (now.getHours() << 11) | (now.getMinutes() << 5) | Math.floor(now.getSeconds() / 2);
+  const dosDate =
+    ((now.getFullYear() - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate();
+
+  const localParts = [];
+  const centralParts = [];
+  let offset = 0;
+
+  files.forEach((file) => {
+    const nameBytes = utf8(file.name);
+    const data = file.data;
+    const crc = crc32(data);
+
+    const local = new Uint8Array(30 + nameBytes.length);
+    const lv = new DataView(local.buffer);
+    lv.setUint32(0, 0x04034b50, true);
+    lv.setUint16(4, 20, true);
+    lv.setUint16(6, 0x0800, true);
+    lv.setUint16(8, 0, true);
+    lv.setUint16(10, dosTime, true);
+    lv.setUint16(12, dosDate, true);
+    lv.setUint32(14, crc, true);
+    lv.setUint32(18, data.length, true);
+    lv.setUint32(22, data.length, true);
+    lv.setUint16(26, nameBytes.length, true);
+    lv.setUint16(28, 0, true);
+    local.set(nameBytes, 30);
+
+    const central = new Uint8Array(46 + nameBytes.length);
+    const cv = new DataView(central.buffer);
+    cv.setUint32(0, 0x02014b50, true);
+    cv.setUint16(4, 20, true);
+    cv.setUint16(6, 20, true);
+    cv.setUint16(8, 0x0800, true);
+    cv.setUint16(10, 0, true);
+    cv.setUint16(12, dosTime, true);
+    cv.setUint16(14, dosDate, true);
+    cv.setUint32(16, crc, true);
+    cv.setUint32(20, data.length, true);
+    cv.setUint32(24, data.length, true);
+    cv.setUint16(28, nameBytes.length, true);
+    cv.setUint16(30, 0, true);
+    cv.setUint16(32, 0, true);
+    cv.setUint16(34, 0, true);
+    cv.setUint16(36, 0, true);
+    cv.setUint32(38, 0, true);
+    cv.setUint32(42, offset, true);
+    central.set(nameBytes, 46);
+
+    localParts.push(local, data);
+    centralParts.push(central);
+    offset += local.length + data.length;
+  });
+
+  const centralSize = centralParts.reduce((sum, part) => sum + part.length, 0);
+  const end = new Uint8Array(22);
+  const ev = new DataView(end.buffer);
+  ev.setUint32(0, 0x06054b50, true);
+  ev.setUint16(4, 0, true);
+  ev.setUint16(6, 0, true);
+  ev.setUint16(8, files.length, true);
+  ev.setUint16(10, files.length, true);
+  ev.setUint32(12, centralSize, true);
+  ev.setUint32(16, offset, true);
+  ev.setUint16(20, 0, true);
+
+  return new Blob([...localParts, ...centralParts, end], { type: mimeType });
+}
+
+export default createZip;
diff --git a/src/views/financialManagement/voucher/detailLedger.vue b/src/views/financialManagement/voucher/detailLedger.vue
index c07574c..21575f5 100644
--- a/src/views/financialManagement/voucher/detailLedger.vue
+++ b/src/views/financialManagement/voucher/detailLedger.vue
@@ -76,8 +76,10 @@
 <script setup>
 import { ref, reactive, onMounted, computed, watch, nextTick } from "vue";
 import { ElMessage } from "element-plus";
+import { saveAs } from "file-saver";
 import { listAccountSubject } from "@/api/financialManagement/accountSubject";
 import { getDetailLedger } from "@/api/financialManagement/ledger";
+import { buildDetailLedgerBlob, buildDetailLedgerFileName } from "./detailLedgerExport";
 
 defineOptions({
   name: "绉戠洰鏄庣粏璐�",
@@ -236,8 +238,29 @@
   ElMessage.info("鎵撳嵃鍔熻兘");
 };
 
+// 瀵煎嚭 Excel锛岀増闈笌銆屾棤褰㈣祫浜ф槑缁嗚处.xls銆嶆ā鏉夸繚鎸佷竴鑷�
 const handleOut = () => {
-  ElMessage.success("瀵煎嚭鎴愬姛");
+  if (!currentSubject.value) {
+    ElMessage.warning("璇峰厛閫夋嫨浼氳绉戠洰");
+    return;
+  }
+  if (!dataList.value.length) {
+    ElMessage.warning("鏆傛棤鏄庣粏鏁版嵁锛屾棤娉曞鍑�");
+    return;
+  }
+  const options = {
+    subject: currentSubject.value,
+    startMonth: filters.startMonth,
+    endMonth: filters.endMonth,
+    rows: dataList.value,
+  };
+  try {
+    saveAs(buildDetailLedgerBlob(options), buildDetailLedgerFileName(options));
+    ElMessage.success("瀵煎嚭鎴愬姛");
+  } catch (error) {
+    console.error(error);
+    ElMessage.error("瀵煎嚭澶辫触锛岃绋嶅悗閲嶈瘯");
+  }
 };
 
 onMounted(async () => {
diff --git a/src/views/financialManagement/voucher/detailLedgerExport.js b/src/views/financialManagement/voucher/detailLedgerExport.js
new file mode 100644
index 0000000..b1881d0
--- /dev/null
+++ b/src/views/financialManagement/voucher/detailLedgerExport.js
@@ -0,0 +1,180 @@
+/**
+ * 绉戠洰鏄庣粏璐﹀鍑恒��
+ *
+ * 鐗堥潰銆佸瓧浣撱�佽竟妗嗐�佸垪瀹姐�佽楂樸�佸悎骞跺崟鍏冩牸鍧囩収鎼弬鑰冩ā鏉�
+ * 銆屾棤褰㈣祫浜ф槑缁嗚处.xls銆嶏紝浠呮妸鍐呭鏇挎崲涓哄綋鍓嶆煡璇㈠埌鐨勮处绨挎暟鎹��
+ */
+import { buildXlsxBlob } from "@/utils/xlsxWriter";
+
+/** 妯℃澘锛氬墠涓よ涓庝俊鎭浣跨敤 Arial 12 */
+const TITLE_FONT = { name: "Arial", size: 12 };
+/** 琛ㄥご涓庢暟鎹浣跨敤 瀹嬩綋 11锛屽瓧鑹蹭笌涓嬪垝绾垮潎鐢ㄩ粯璁ゅ�� */
+const DATA_FONT = { name: "瀹嬩綋", size: 11 };
+
+/** 妯℃澘锛氳〃澶村洓杈圭粏绾匡紝鏁版嵁琛屽彧鏈夊乏鍙充笅涓夎竟 */
+const HEADER_BORDER = { left: "thin", right: "thin", top: "thin", bottom: "thin" };
+const DATA_BORDER = { left: "thin", right: "thin", bottom: "thin" };
+
+const STYLE = {
+  title: { font: TITLE_FONT, align: { horizontal: "center", vertical: "bottom" } },
+  infoLabel: {
+    font: TITLE_FONT,
+    align: { horizontal: "right", vertical: "bottom" },
+    border: { bottom: "thin" },
+  },
+  infoValue: {
+    font: TITLE_FONT,
+    align: { horizontal: "left", vertical: "bottom" },
+    border: { bottom: "thin" },
+  },
+  header: {
+    font: DATA_FONT,
+    align: { horizontal: "center", vertical: "justify" },
+    border: HEADER_BORDER,
+  },
+  date: {
+    font: DATA_FONT,
+    numFmt: "yyyy-mm-dd",
+    align: { horizontal: "center", vertical: "justify" },
+    border: DATA_BORDER,
+  },
+  center: {
+    font: DATA_FONT,
+    align: { horizontal: "center", vertical: "justify" },
+    border: DATA_BORDER,
+  },
+  text: {
+    font: DATA_FONT,
+    align: { horizontal: "left", vertical: "justify" },
+    border: DATA_BORDER,
+  },
+  amount: {
+    font: DATA_FONT,
+    numFmt: "#,##0.00",
+    align: { horizontal: "right", vertical: "justify" },
+    border: DATA_BORDER,
+  },
+};
+
+/** 妯℃澘鍒楀锛圗xcel 瀛楃瀹藉害锛� */
+const COLUMN_WIDTHS = [13.42578125, 11.7109375, 48.42578125, 22.5703125, 16.42578125, 17, 16.7109375];
+
+const COLUMN_LABELS = ["鍑瘉鏃ユ湡", "鍑瘉瀛楀彿", "鍑瘉鎽樿", "鍊熸柟閲戦", "璐锋柟閲戦", "浣欓鏂瑰悜", "浣欓"];
+
+const ROW_HEIGHT = { title: 20, info: 15, header: 15.6, data: 19 };
+
+const toNumber = (value) => {
+  const result = Number(value);
+  return Number.isFinite(result) ? result : 0;
+};
+
+/** "2026-09" -> "2026骞�09鏈�" */
+const formatPeriod = (month) => {
+  const matched = /^(\d{4})-(\d{2})$/.exec(String(month || ""));
+  return matched ? `${matched[1]}骞�${matched[2]}鏈坄 : String(month || "");
+};
+
+/**
+ * 鍒ゆ柇璁拌处琛岀被鍨嬨��
+ * 浼樺厛鍙栧悗绔殑 rowType锛岀己澶辨椂鎸夋憳瑕佹枃瀛楀厹搴曘��
+ */
+const resolveRowKind = (row) => {
+  const type = row?.rowType;
+  if (type) return type;
+  const summary = String(row?.summary || "");
+  if (summary.includes("鏈熷垵")) return "opening";
+  if (summary.includes("鏈湀")) return "monthly_total";
+  if (summary.includes("绱") || summary.includes("鍚堣")) return "yearly_total";
+  return "entry";
+};
+
+/**
+ * 鎶婂悗绔褰掍竴鍖栨垚妯℃澘琛岋細
+ * 鏈熷垵浣欓琛屾竻绌烘棩鏈熶笌鍑瘉瀛楀彿锛屾眹鎬昏鏂囨鏀规垚銆屾湰鏈堢疮璁� / 鏈勾绱銆嶃��
+ */
+const toTemplateRows = (list) => {
+  const rows = [];
+
+  (Array.isArray(list) ? list : []).forEach((row) => {
+    const kind = resolveRowKind(row);
+    if (kind === "opening") {
+      rows.push({ ...row, summary: "鏈熷垵浣欓", date: "", voucherNo: "" });
+      return;
+    }
+    if (kind === "monthly_total") {
+      rows.push({ ...row, summary: "鏈湀绱", voucherNo: "" });
+      return;
+    }
+    if (kind === "yearly_total") {
+      rows.push({ ...row, summary: "鏈勾绱", voucherNo: "" });
+      return;
+    }
+    rows.push(row);
+  });
+
+  return rows;
+};
+
+const buildLedgerRow = (row) => ({
+  height: ROW_HEIGHT.data,
+  cells: [
+    { value: row.date || "", style: STYLE.date },
+    { value: row.voucherNo || "", style: STYLE.center },
+    { value: row.summary || "", style: STYLE.text },
+    { value: toNumber(row.debit), style: STYLE.amount },
+    { value: toNumber(row.credit), style: STYLE.amount },
+    { value: row.direction || "", style: STYLE.center },
+    { value: toNumber(row.balance), style: STYLE.amount },
+  ],
+});
+
+/**
+ * 鏋勯�犳槑缁嗚处宸ヤ綔琛ㄦ弿杩般��
+ * @param {{subject?: {code?: string, name?: string}, startMonth?: string, endMonth?: string, rows?: Array}} options
+ */
+export function buildDetailLedgerSheet({ subject, startMonth, endMonth, rows } = {}) {
+  const sheetRows = [
+    { height: ROW_HEIGHT.title, cells: [{ value: "鏄庣粏璐�", style: STYLE.title }] },
+    {
+      height: ROW_HEIGHT.info,
+      cells: [
+        { value: "绉戠洰缂栫爜锛�", style: STYLE.infoLabel },
+        { value: subject?.code || "", style: STYLE.infoValue },
+        { value: "绉戠洰鍚嶇О锛�", style: STYLE.infoLabel },
+        { value: subject?.name || "", style: STYLE.infoValue },
+        { value: "浼氳鏈熼棿锛�", style: STYLE.infoLabel },
+        {
+          value: `${formatPeriod(startMonth)}-${formatPeriod(endMonth)}`,
+          style: STYLE.infoValue,
+        },
+      ],
+    },
+    {
+      height: ROW_HEIGHT.header,
+      cells: COLUMN_LABELS.map((label) => ({ value: label, style: STYLE.header })),
+    },
+  ];
+
+  toTemplateRows(rows).forEach((row) => sheetRows.push(buildLedgerRow(row)));
+
+  return {
+    name: "鏁版嵁琛�1",
+    columns: COLUMN_WIDTHS.map((width) => ({ width })),
+    rows: sheetRows,
+    merges: [{ r1: 0, c1: 0, r2: 0, c2: 7 }],
+  };
+}
+
+/** 鐢熸垚鏄庣粏璐� xlsx 鐨� Blob */
+export function buildDetailLedgerBlob(options) {
+  return buildXlsxBlob([buildDetailLedgerSheet(options)]);
+}
+
+/** 鐢熸垚瀵煎嚭鏂囦欢鍚嶏紝濡傘�屾棤褰㈣祫浜ф槑缁嗚处_2026-09_2026-09.xlsx銆� */
+export function buildDetailLedgerFileName({ subject, startMonth, endMonth } = {}) {
+  const subjectName = String(subject?.name || subject?.code || "绉戠洰").replace(/[\\/:*?"<>|]/g, "");
+  const period = [startMonth, endMonth].filter(Boolean).join("_");
+  return period ? `${subjectName}鏄庣粏璐${period}.xlsx` : `${subjectName}鏄庣粏璐�.xlsx`;
+}
+
+export default buildDetailLedgerBlob;
diff --git a/src/views/financialManagement/voucher/index.vue b/src/views/financialManagement/voucher/index.vue
index fb38a26..4b41202 100644
--- a/src/views/financialManagement/voucher/index.vue
+++ b/src/views/financialManagement/voucher/index.vue
@@ -65,6 +65,7 @@
         </template>
         <template #operation="{ row }">
           <el-button type="primary" link @click="view(row)">鏌ョ湅</el-button>
+          <el-button type="primary" link @click="exportVoucher(row)">瀵煎嚭</el-button>
           <el-button type="primary" link @click="edit(row)" v-if="canEditVoucher(row.status)">缂栬緫</el-button>
           <el-button type="success" link @click="handlePost(row)" v-if="canEditVoucher(row.status)">杩囪处</el-button>
           <el-button type="danger" link @click="handleCancel(row)" v-if="canEditVoucher(row.status)">浣滃簾</el-button>
@@ -267,6 +268,7 @@
 <script setup>
 import { ref, reactive, onMounted, computed, nextTick } from "vue";
 import { ElMessage, ElMessageBox } from "element-plus";
+import { saveAs } from "file-saver";
 import FormDialog from "@/components/Dialog/FormDialog.vue";
 import AttachmentUploadFile from "@/components/AttachmentUpload/file/index.vue";
 import FileList from "@/components/Dialog/FileList.vue";
@@ -283,6 +285,7 @@
   cancelVoucher,
   getVoucherDetail,
 } from "@/api/financialManagement/voucher";
+import { buildVoucherDocxBlob, buildVoucherFileName } from "./voucherExport";
 
 defineOptions({
   name: "鍑瘉绠$悊",
@@ -312,7 +315,7 @@
   { label: "璐锋柟閲戦", prop: "credit", dataType: "slot", slot: "credit" },
   { label: "鍒跺崟浜�", prop: "creator", width: "100" },
   { label: "鐘舵��", prop: "status", dataType: "slot", slot: "status" },
-  { label: "鎿嶄綔", prop: "operation", dataType: "slot", slot: "operation", width: "220", fixed: "right" },
+  { label: "鎿嶄綔", prop: "operation", dataType: "slot", slot: "operation", width: "280", fixed: "right" },
 ];
 
 const dataList = ref([]);
@@ -720,6 +723,19 @@
   ElMessage.info("瀵煎叆鍔熻兘");
 };
 
+// 瀵煎嚭閫変腑鍑瘉鐨勬槑缁嗗垎褰曪紙Word锛岀増闈笌浼氳鍑瘉妯℃澘涓�鑷达級
+const exportVoucher = async (row) => {
+  try {
+    const { data } = await getVoucherDetail(row.id);
+    const detail = data || row;
+    saveAs(buildVoucherDocxBlob(detail), buildVoucherFileName(detail));
+    ElMessage.success("瀵煎嚭鎴愬姛");
+  } catch (error) {
+    console.error(error);
+    ElMessage.error("瀵煎嚭澶辫触锛岃绋嶅悗閲嶈瘯");
+  }
+};
+
 const handleOut = () => {
   ElMessage.success("瀵煎嚭鎴愬姛");
 };
diff --git a/src/views/financialManagement/voucher/voucherExport.js b/src/views/financialManagement/voucher/voucherExport.js
new file mode 100644
index 0000000..e43e842
--- /dev/null
+++ b/src/views/financialManagement/voucher/voucherExport.js
@@ -0,0 +1,263 @@
+/**
+ * 璁拌处鍑瘉瀵煎嚭锛圵ord锛夈��
+ *
+ * 鐗堥潰鐓ф惉鍙傝�冩ā鏉裤�屾棤褰㈣祫浜т細璁″嚟璇�.docx銆嶏細妯悜鍑瘉绾搞�佸叕鍙稿悕 + 銆岃璐﹀嚟璇併��
+ * 澶ф爣棰樺悓琛屻�佸嚟璇佷俊鎭銆�6 鍒楁槑缁嗗垎褰曡〃锛堝簭鍙�/绉戠洰浠g爜/绉戠洰鍚嶇О/鎽樿/鍊熸柟/璐锋柟锛�
+ * 涓庡悎璁¤銆佸簳閮ㄥ埗鍗曚汉銆�
+ *
+ * 灏哄鍗曚綅涓� twips锛�1/20 纾咃級锛屽瓧鍙蜂负鍗婄锛�21 = 10.5 纾咃級銆�
+ */
+import { buildDocxBlob } from "@/utils/docxWriter";
+
+/** 鍑瘉鏍囬 */
+const VOUCHER_TITLE = "璁拌处鍑瘉";
+/** 鍏徃 */
+const COMPANY = "鍏徃鍚嶇О锛�";
+/** 鏈綅甯侊紝妯℃澘鍥哄畾涓轰汉姘戝竵 */
+const CURRENCY = "浜烘皯甯�";
+
+/** 鏍囬琛岀缉杩涳紝娌跨敤妯℃澘閲屽叕鍙稿悕鎵�鍦ㄤ綅缃� */
+const TITLE_INDENT = 1823;
+/** 銆岃璐﹀嚟璇併�嶈惤鐐癸紝鍗虫ā鏉块噷鍏徃鍚嶅姞闂磋窛涔嬪悗鐨勫師濮嬩綅缃� */
+const TITLE_TAB = 6863;
+
+/** 鍑瘉绾革細260mm 脳 125mm 妯悜 */
+const PAGE = {
+  width: 14740,
+  height: 7087,
+  margin: { top: 202, right: 375, bottom: 0, left: 1095 },
+};
+
+/** 琛ㄦ牸鍒楀锛堟ā鏉垮師鍊硷級 */
+const GRID = [821, 1725, 3345, 3704, 1769, 1895];
+const TABLE_WIDTH = 13259;
+const TABLE_INDENT = 5;
+const TABLE_BORDER = {
+  top: { val: "single", color: "000000", sz: 6 },
+  left: { val: "single", color: "000000", sz: 6 },
+  bottom: { val: "single", color: "000000", sz: 6 },
+  right: { val: "single", color: "000000", sz: 6 },
+  insideH: { val: "single", color: "000000", sz: 6 },
+  insideV: { val: "single", color: "000000", sz: 6 },
+};
+
+const ROW_HEIGHT = { header: 385, data: 373, total: 463 };
+
+const FONT_SIZE = {
+  title: 44,
+  info: 21,
+  header: 21,
+  index: 18,
+  cell: 21,
+  totalLabel: 24,
+};
+
+/** 淇℃伅琛屽洓涓瓧娈电殑鍒惰〃浣嶏紙鐩稿宸﹁竟璺濓級锛屼笌妯℃澘涓枃瀛楄惤鐐逛竴鑷� */
+const INFO_TABS = [2645, 6215, 9785];
+/** 鍒跺崟浜鸿惤鐐癸細娌跨敤妯℃澘涓埗鍗曚汉鎵�鍦ㄤ綅缃� */
+const CREATOR_POSITION = 9498;
+/** 宸﹀榻愬崟鍏冩牸鐨勫乏渚х暀鐧� */
+const CELL_INDENT = 110;
+
+const COLUMN_LABELS = ["搴忓彿", "绉戠洰浠g爜", "绉戠洰鍚嶇О", "鎽樿", "鍊熸柟", "璐锋柟"];
+
+/** 閲戦鏍煎紡锛氬甫鍗冨垎浣嶄袱浣嶅皬鏁帮紱鏃犻噾棰濇椂鐣欑┖锛屼笌妯℃澘涓�鑷� */
+const formatAmount = (value) => {
+  const amount = Number(value);
+  if (!Number.isFinite(amount) || amount === 0) return "";
+  return amount.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
+};
+
+/** 鍑瘉瀛椾笌缂栧彿锛�'璁�-2' -> { prefix: '璁�', num: '2' } */
+const splitVoucherNo = (voucherNo) => {
+  const text = String(voucherNo || "").trim();
+  if (!text) return { prefix: "", num: "" };
+  const index = text.indexOf("-");
+  if (index < 0) return { prefix: text, num: "" };
+  return { prefix: text.slice(0, index), num: text.slice(index + 1) };
+};
+
+const normalizeDate = (value) => {
+  const text = String(value || "");
+  return /^\d{4}-\d{2}-\d{2}/.test(text) ? text.slice(0, 10) : text;
+};
+
+const buildHeaderRow = () => ({
+  height: ROW_HEIGHT.header,
+  cells: COLUMN_LABELS.map((label, index) => ({
+    text: label,
+    width: GRID[index],
+    align: "center",
+    bold: true,
+    size: FONT_SIZE.header,
+    vAlign: "center",
+  })),
+});
+
+const buildEntryRow = (entry, index) => ({
+  height: ROW_HEIGHT.data,
+  cells: [
+    {
+      text: String(index + 1),
+      width: GRID[0],
+      align: "left",
+      indentLeft: CELL_INDENT + 5,
+      size: FONT_SIZE.index,
+      vAlign: "center",
+    },
+    {
+      text: entry.subjectCode || "",
+      width: GRID[1],
+      align: "left",
+      indentLeft: CELL_INDENT,
+      size: FONT_SIZE.cell,
+      vAlign: "center",
+    },
+    {
+      text: entry.subjectName || "",
+      width: GRID[2],
+      align: "left",
+      indentLeft: CELL_INDENT,
+      size: FONT_SIZE.cell,
+      vAlign: "center",
+    },
+    {
+      text: entry.summary || "",
+      width: GRID[3],
+      align: "left",
+      indentLeft: CELL_INDENT,
+      size: FONT_SIZE.cell,
+      vAlign: "center",
+    },
+    {
+      text: formatAmount(entry.debit),
+      width: GRID[4],
+      align: "center",
+      size: FONT_SIZE.cell,
+      vAlign: "center",
+    },
+    {
+      text: formatAmount(entry.credit),
+      width: GRID[5],
+      align: "center",
+      size: FONT_SIZE.cell,
+      vAlign: "center",
+    },
+  ],
+});
+
+const buildTotalRow = (totalDebit, totalCredit) => ({
+  height: ROW_HEIGHT.total,
+  cells: [
+    { text: "", width: GRID[0] + GRID[1] + GRID[2], gridSpan: 3, vAlign: "center" },
+    {
+      text: "鍚堣",
+      width: GRID[3],
+      align: "center",
+      size: FONT_SIZE.totalLabel,
+      vAlign: "center",
+    },
+    {
+      text: formatAmount(totalDebit),
+      width: GRID[4],
+      align: "center",
+      size: FONT_SIZE.cell,
+      vAlign: "center",
+    },
+    {
+      text: formatAmount(totalCredit),
+      width: GRID[5],
+      align: "center",
+      size: FONT_SIZE.cell,
+      vAlign: "center",
+    },
+  ],
+});
+
+/**
+ * 鏋勯�犺璐﹀嚟璇佹枃妗fā鍨嬨��
+ * @param {{voucherNo?: string, voucherDate?: string, creator?: string, entries?: Array}} voucher
+ */
+export function buildVoucherDocModel(voucher = {}) {
+  const entries = (Array.isArray(voucher.entries) ? voucher.entries : []).filter(Boolean);
+  const { prefix, num } = splitVoucherNo(voucher.voucherNo);
+  const date = normalizeDate(voucher.voucherDate);
+  const creator = voucher.creator || "";
+
+  const totalDebit = entries.reduce((sum, item) => sum + Number(item.debit || 0), 0);
+  const totalCredit = entries.reduce((sum, item) => sum + Number(item.credit || 0), 0);
+
+  const infoRuns = [
+    { text: `鍑瘉瀛楋細${prefix}`, size: FONT_SIZE.info },
+    { tab: true },
+    { text: `鍑瘉缂栧彿锛�${num}`, size: FONT_SIZE.info },
+    { tab: true },
+    { text: `鍑瘉鏃ユ湡锛�${date}`, size: FONT_SIZE.info },
+    { tab: true },
+    { text: `鏈綅甯侊細${CURRENCY}`, size: FONT_SIZE.info },
+  ];
+
+  const footerRuns = [{ text: `鍒跺崟浜猴細${creator}`, size: FONT_SIZE.info }];
+
+  return {
+    defaultFont: "瀹嬩綋",
+    defaultSize: FONT_SIZE.info,
+    page: PAGE,
+    blocks: [
+      {
+        type: "paragraph",
+        indentLeft: TITLE_INDENT,
+        spacing: { before: 86, line: 220, lineRule: "auto" },
+        tabs: [TITLE_TAB],
+        runs: [
+          { text: COMPANY, size: FONT_SIZE.info },
+          { tab: true },
+          { text: VOUCHER_TITLE, size: FONT_SIZE.title },
+        ],
+      },
+      {
+        type: "paragraph",
+        indentLeft: 335,
+        spacing: { before: 156, line: 219, lineRule: "auto" },
+        tabs: INFO_TABS,
+        runs: infoRuns,
+      },
+      { type: "empty" },
+      {
+        type: "table",
+        width: TABLE_WIDTH,
+        indent: TABLE_INDENT,
+        grid: GRID,
+        border: TABLE_BORDER,
+        cellMargin: 0,
+        rows: [
+          buildHeaderRow(),
+          ...entries.map((entry, index) => buildEntryRow(entry, index)),
+          buildTotalRow(totalDebit, totalCredit),
+        ],
+      },
+      {
+        type: "paragraph",
+        indentLeft: CREATOR_POSITION,
+        spacing: { before: 237, line: 219, lineRule: "auto" },
+        runs: footerRuns,
+      },
+    ],
+  };
+}
+
+/** 鐢熸垚璁拌处鍑瘉 docx 鐨� Blob */
+export function buildVoucherDocxBlob(voucher) {
+  return buildDocxBlob(buildVoucherDocModel(voucher));
+}
+
+/** 鐢熸垚瀵煎嚭鏂囦欢鍚嶏紝濡傘�岃璐﹀嚟璇乢璁�-2_2026-07-31.docx銆� */
+export function buildVoucherFileName(voucher = {}) {
+  const { prefix, num } = splitVoucherNo(voucher.voucherNo);
+  const label = `${prefix}${num}`.replace(/[\\/:*?"<>|]/g, "");
+  const date = normalizeDate(voucher.voucherDate).replace(/[\\/:*?"<>|]/g, "");
+  const parts = ["璁拌处鍑瘉", label, date].filter(Boolean);
+  return `${parts.join("_")}.docx`;
+}
+
+export default buildVoucherDocxBlob;

--
Gitblit v1.9.3