yuan
4 天以前 3df8b1aa4ac3d5ca030121ab7ee9387e9d106951
feat: 增加科目总账明细表excel导出 和 凭证增加记账凭证work导出
已添加5个文件
已修改2个文件
1330 ■■■■■ 文件已修改
src/utils/docxWriter.js 317 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/xlsxWriter.js 417 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/zipStore.js 110 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/financialManagement/voucher/detailLedger.vue 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/financialManagement/voucher/detailLedgerExport.js 180 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/financialManagement/voucher/index.vue 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/financialManagement/voucher/voucherExport.js 263 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
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, "&")
    .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;
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();
  // é»˜è®¤å­—体 / å¡«å…… / è¾¹æ¡†å ä½ï¼ˆfill 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;
src/utils/zipStore.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,110 @@
/**
 * æžç®€ ZIP æ‰“包(store å­˜å‚¨ï¼Œä¸åŽ‹ç¼©ï¼‰ã€‚
 *
 * OOXML å®¹å™¨ï¼ˆxlsx / 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;
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 () => {
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,
  },
};
/** æ¨¡æ¿åˆ—宽(Excel å­—符宽度) */
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;
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("导出成功");
};
src/views/financialManagement/voucher/voucherExport.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,263 @@
/**
 * è®°è´¦å‡­è¯å¯¼å‡ºï¼ˆWord)。
 *
 * ç‰ˆé¢ç…§æ¬å‚考模板「无形资产会计凭证.docx」:横向凭证纸、公司名 + ã€Œè®°è´¦å‡­è¯ã€
 * å¤§æ ‡é¢˜åŒè¡Œã€å‡­è¯ä¿¡æ¯è¡Œã€6 åˆ—明细分录表(序号/科目代码/科目名称/摘要/借方/贷方)
 * ä¸Žåˆè®¡è¡Œã€åº•部制单人。
 *
 * å°ºå¯¸å•位为 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 = ["序号", "科目代码", "科目名称", "摘要", "借方", "贷方"];
/** é‡‘额格式:带千分位两位小数;无金额时留空,与模板一致 */
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",
    },
  ],
});
/**
 * æž„造记账凭证文档模型。
 * @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;