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, "&")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+
+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, "&")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+
+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