- 引入 docx 与 xlsx 依赖,纯前端无损编译 Block AST 交付物 - buildDocxModel: 自动生成两行四列受控签署栏、多级编号标题、Markdown富文本、医疗标准三线表、Cambria Math公式与动态页眉页脚 - buildXlsxWorkbook: 自动提取文档概览与全部数据表格,生成带自适应列宽的 Excel 工作簿并支持直接下载 Co-authored-by: Cursor <cursoragent@cursor.com>
157 lines
5.6 KiB
TypeScript
157 lines
5.6 KiB
TypeScript
import * as XLSX from 'xlsx';
|
|
import { DocumentAST } from '../types/block';
|
|
|
|
/**
|
|
* sanitizeSheetName: 净化 Excel 工作表名称(限制在 31 字符以内,移除 \ / ? * [ ] : 等非法字符)
|
|
*/
|
|
function sanitizeSheetName(name: string, fallback: string): string {
|
|
const cleaned = name.replace(/[\\/?*\[\]:]/g, '_').trim();
|
|
if (!cleaned) return fallback;
|
|
return cleaned.substring(0, 30);
|
|
}
|
|
|
|
/**
|
|
* buildXlsxWorkbook: 将 DocumentAST 结构化提炼为正规的 Excel 工作簿模型
|
|
*/
|
|
export function buildXlsxWorkbook(ast: DocumentAST): XLSX.WorkBook {
|
|
const wb = XLSX.utils.book_new();
|
|
|
|
// 1. 创建 Sheet 1: “文档概览与受控签署”
|
|
const statusMap: Record<string, string> = {
|
|
drafting: '编制中 (草稿)',
|
|
reviewing: '审核中 (流程锁定)',
|
|
approving: '批准中 (流程锁定)',
|
|
approved: '已批准生效 (受控基线)',
|
|
rejected: '已驳回修改',
|
|
};
|
|
|
|
const overviewData: (string | number)[][] = [
|
|
['水木东方医疗器械受控体系交付物数据台账'],
|
|
[],
|
|
['交付物标题', ast.title],
|
|
['文档编号/ID', ast.docId],
|
|
['业务任务分类', ast.taskKey],
|
|
['受控版本', ast.version],
|
|
['受控状态', statusMap[ast.lifecycleState] || ast.lifecycleState],
|
|
['空间分类', ast.category === 'private' ? '个人私密生成' : ast.category === 'shared' ? '项目协作共享' : '流程受控交付物'],
|
|
['编制责任人', ast.author],
|
|
['审核责任人', ast.reviewer],
|
|
['批准责任人', ast.approver],
|
|
['最新归档时间', ast.updatedAt || '2026-09-08'],
|
|
['文档摘要', ast.summary || ''],
|
|
[],
|
|
['导出来源', '水木东方协同智能体工作台 (Browser XLSX Engine)'],
|
|
];
|
|
|
|
const wsOverview = XLSX.utils.aoa_to_sheet(overviewData);
|
|
// 设置概览页列宽
|
|
wsOverview['!cols'] = [{ wch: 18 }, { wch: 60 }];
|
|
XLSX.utils.book_append_sheet(wb, wsOverview, '文档概览与签署');
|
|
|
|
// 2. 遍历 AST 提取所有表格块 (Table Blocks)
|
|
const blocks = ast.rootBlock.children || [];
|
|
const tableBlocks = blocks.filter((b) => b.type === 'table' && b.properties?.tableData?.length);
|
|
|
|
if (tableBlocks.length > 0) {
|
|
const usedSheetNames = new Set<string>(['文档概览与签署']);
|
|
|
|
tableBlocks.forEach((tblBlock, idx) => {
|
|
const rawCaption = tblBlock.properties?.caption || `数据表_${idx + 1}`;
|
|
const prefix = tblBlock.numbering ? `${tblBlock.numbering} ` : '';
|
|
let baseSheetName = sanitizeSheetName(`${prefix}${rawCaption}`, `数据表_${idx + 1}`);
|
|
|
|
// 处理 Sheet 名称防重复
|
|
let finalSheetName = baseSheetName;
|
|
let counter = 1;
|
|
while (usedSheetNames.has(finalSheetName)) {
|
|
finalSheetName = `${baseSheetName.substring(0, 26)}_${counter++}`;
|
|
}
|
|
usedSheetNames.add(finalSheetName);
|
|
|
|
const tableData = tblBlock.properties?.tableData || [];
|
|
const sheetAoa: (string | number)[][] = [];
|
|
|
|
// 表头之前加入表格标题与归属说明
|
|
sheetAoa.push([`${prefix}${rawCaption}`]);
|
|
sheetAoa.push([]);
|
|
|
|
// 压入表格真实数据行
|
|
for (const row of tableData) {
|
|
// 清除单元格中的 Markdown 格式字符(如 **粗体**)使 Excel 数据整洁
|
|
const cleanedRow = row.map((cell) => cell.replace(/\*\*(.*?)\*\*/g, '$1').replace(/\*(.*?)\*/g, '$1').trim());
|
|
sheetAoa.push(cleanedRow);
|
|
}
|
|
|
|
const ws = XLSX.utils.aoa_to_sheet(sheetAoa);
|
|
|
|
// 计算自适应列宽
|
|
if (tableData.length > 0) {
|
|
const colCount = Math.max(...tableData.map((r) => r.length));
|
|
const colWidths: { wch: number }[] = [];
|
|
for (let c = 0; c < colCount; c++) {
|
|
let maxLen = 10;
|
|
for (const row of tableData) {
|
|
const cellStr = row[c] || '';
|
|
const len = cellStr.length * 1.5; // 中文字符加权
|
|
if (len > maxLen) maxLen = len;
|
|
}
|
|
colWidths.push({ wch: Math.min(Math.max(Math.round(maxLen), 12), 50) });
|
|
}
|
|
ws['!cols'] = colWidths;
|
|
}
|
|
|
|
XLSX.utils.book_append_sheet(wb, ws, finalSheetName);
|
|
});
|
|
} else {
|
|
// 若文档无独立表格,提取列表及段落构建结构化清单 Sheet
|
|
const listData: string[][] = [['章节/类型', '序号', '条目内容']];
|
|
let curChapter = '正文';
|
|
|
|
for (const b of blocks) {
|
|
if (b.type === 'heading') {
|
|
curChapter = `${b.numbering || ''} ${b.content}`.trim();
|
|
} else if (b.type === 'list') {
|
|
const items =
|
|
b.properties?.listItems ||
|
|
b.content
|
|
.split('\n')
|
|
.map((line) => line.replace(/^\s*(\d+\.|[-*+])\s+/, '').trim())
|
|
.filter(Boolean);
|
|
items.forEach((item, i) => {
|
|
listData.push([curChapter, String(i + 1), item]);
|
|
});
|
|
}
|
|
}
|
|
|
|
if (listData.length > 1) {
|
|
const wsList = XLSX.utils.aoa_to_sheet(listData);
|
|
wsList['!cols'] = [{ wch: 25 }, { wch: 8 }, { wch: 60 }];
|
|
XLSX.utils.book_append_sheet(wb, wsList, '结构化条目清单');
|
|
}
|
|
}
|
|
|
|
return wb;
|
|
}
|
|
|
|
/**
|
|
* exportDocumentToXlsx: 编译并触发浏览器下载 XLSX 二进制文件
|
|
*/
|
|
export async function exportDocumentToXlsx(ast: DocumentAST): Promise<Blob> {
|
|
const wb = buildXlsxWorkbook(ast);
|
|
const wbout = XLSX.write(wb, { bookType: 'xlsx', type: 'array' });
|
|
const blob = new Blob([wbout], {
|
|
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
});
|
|
|
|
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `${ast.title}_${ast.version}_数据清单.xlsx`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
return blob;
|
|
}
|