import { DEV_PLAN_TASK_STATUS_LABELS, DEV_PLAN_TASK_TYPE_LABELS, type DevPlanTaskStatusDto, type DevPlanTaskTypeDto, } from '@dukang/shared-types'; import ExcelJS from 'exceljs'; import * as fs from 'fs'; import * as path from 'path'; import PDFDocument from 'pdfkit'; import { Document, HeadingLevel, ImageRun, Packer, Paragraph, Table, TableCell, TableRow, TextRun, WidthType, } from 'docx'; const EXPORT_HEADERS = [ '任务编号', '类型', '状态', '内容', '关联工单', '创建人', '创建时间', '完成时间', '附件', ] as const; export type DevPlanTaskExportRow = { taskNo: string; type: string; status: string; content: string; supportTicketNo: string; creatorName: string; createdAt: string; completedAt: string; attachmentUrls: string[]; }; export type DevPlanExportFormat = 'markdown' | 'docx' | 'xlsx' | 'pdf'; export function mapTaskToExportRow(task: { taskNo: string; type: DevPlanTaskTypeDto; status: DevPlanTaskStatusDto; content: string; supportTicketNo?: string | null; creatorName?: string | null; createdAt: string; completedAt?: string | null; attachmentUrls?: string[] | null; }): DevPlanTaskExportRow { return { taskNo: task.taskNo, type: DEV_PLAN_TASK_TYPE_LABELS[task.type] ?? task.type, status: DEV_PLAN_TASK_STATUS_LABELS[task.status] ?? task.status, content: task.content, supportTicketNo: task.supportTicketNo ?? '', creatorName: task.creatorName ?? '', createdAt: task.createdAt.slice(0, 19).replace('T', ' '), completedAt: task.completedAt ? task.completedAt.slice(0, 19).replace('T', ' ') : '', attachmentUrls: task.attachmentUrls ?? [], }; } function rowToCells(row: DevPlanTaskExportRow): string[] { return [ row.taskNo, row.type, row.status, row.content, row.supportTicketNo, row.creatorName, row.createdAt, row.completedAt, row.attachmentUrls.join(' '), ]; } function resolvePdfFontPath(): string { const candidates = [ process.env.EXPORT_PDF_FONT_PATH, path.join(process.cwd(), 'assets', 'fonts', 'NotoSansSC-Regular.otf'), path.join(process.cwd(), 'assets', 'fonts', 'simhei.ttf'), path.join(process.cwd(), 'assets', 'fonts', 'msyh.ttc'), path.join(process.cwd(), 'dist', 'assets', 'fonts', 'simhei.ttf'), '/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc', 'C:\\Windows\\Fonts\\simhei.ttf', 'C:\\Windows\\Fonts\\msyh.ttc', ].filter(Boolean) as string[]; for (const candidate of candidates) { if (fs.existsSync(candidate)) return candidate; } throw new Error('未找到可用于 PDF 的中文字体,请将字体文件放到 server/dukang-api/assets/fonts/'); } export function buildDevPlanMarkdown(rows: DevPlanTaskExportRow[]): Buffer { const lines: string[] = ['# 开发计划任务导出', '']; rows.forEach((row, index) => { lines.push(`## ${index + 1}. ${row.taskNo} [${row.type}] ${row.status}`); lines.push(''); lines.push(row.content); lines.push(''); if (row.supportTicketNo) lines.push(`- 关联工单:${row.supportTicketNo}`); if (row.creatorName) lines.push(`- 创建人:${row.creatorName}`); lines.push(`- 创建时间:${row.createdAt}`); if (row.completedAt) lines.push(`- 完成时间:${row.completedAt}`); if (row.attachmentUrls.length) { lines.push('- 附件:'); for (const url of row.attachmentUrls) { lines.push(` ![附件](${url})`); lines.push(` ${url}`); } } lines.push(''); }); return Buffer.from(lines.join('\n'), 'utf8'); } async function fetchImageBuffer(url: string): Promise { try { const res = await fetch(url, { signal: AbortSignal.timeout(8000) }); if (!res.ok) return null; const buf = Buffer.from(await res.arrayBuffer()); return buf.length > 0 && buf.length < 5 * 1024 * 1024 ? buf : null; } catch { return null; } } export async function buildDevPlanDocx(rows: DevPlanTaskExportRow[]): Promise { const children: (Paragraph | Table)[] = [ new Paragraph({ text: '开发计划任务导出', heading: HeadingLevel.HEADING_1 }), ]; for (const row of rows) { children.push( new Paragraph({ text: `${row.taskNo} [${row.type}] ${row.status}`, heading: HeadingLevel.HEADING_2, }), new Paragraph({ children: [new TextRun(row.content)] }), new Paragraph({ children: [ new TextRun(`创建人:${row.creatorName || '—'} 创建:${row.createdAt}`), ], }), ); if (row.supportTicketNo) { children.push(new Paragraph({ children: [new TextRun(`关联工单:${row.supportTicketNo}`)] })); } if (row.completedAt) { children.push(new Paragraph({ children: [new TextRun(`完成时间:${row.completedAt}`)] })); } for (const url of row.attachmentUrls) { const img = await fetchImageBuffer(url); if (img) { children.push( new Paragraph({ children: [ new ImageRun({ data: img, transformation: { width: 320, height: 240 }, type: 'png', }), ], }), ); } else { children.push(new Paragraph({ children: [new TextRun(url)] })); } } children.push(new Paragraph({ text: '' })); } const doc = new Document({ sections: [{ children }], }); return Packer.toBuffer(doc); } export async function buildDevPlanXlsx(rows: DevPlanTaskExportRow[]): Promise { const workbook = new ExcelJS.Workbook(); const sheet = workbook.addWorksheet('开发任务'); sheet.addRow([...EXPORT_HEADERS]); for (const row of rows) { sheet.addRow(rowToCells(row)); } sheet.columns.forEach((col, i) => { col.width = i === 3 || i === 8 ? 40 : 16; }); const buffer = await workbook.xlsx.writeBuffer(); return Buffer.from(buffer); } export async function buildDevPlanPdf(rows: DevPlanTaskExportRow[]): Promise { const fontPath = resolvePdfFontPath(); const chunks: Buffer[] = []; return new Promise((resolve, reject) => { const doc = new PDFDocument({ size: 'A4', layout: 'landscape', margin: 24, bufferPages: true }); doc.on('data', (chunk) => chunks.push(chunk as Buffer)); doc.on('end', () => resolve(Buffer.concat(chunks))); doc.on('error', reject); doc.registerFont('zh', fontPath); doc.font('zh'); const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right; const colWidths = [72, 48, 48, 180, 72, 56, 78, 78, 120]; const scale = pageWidth / colWidths.reduce((s, w) => s + w, 0); const widths = colWidths.map((w) => w * scale); const rowHeight = 36; let y = doc.page.margins.top; const drawRow = (cells: string[], isHeader = false) => { let x = doc.page.margins.left; const height = isHeader ? 24 : rowHeight; if (y + height > doc.page.height - doc.page.margins.bottom) { doc.addPage({ size: 'A4', layout: 'landscape', margin: 24 }); y = doc.page.margins.top; } doc.fontSize(isHeader ? 8 : 7); cells.forEach((cell, index) => { doc.rect(x, y, widths[index], height).stroke('#dddddd'); doc.text(cell || '', x + 2, y + 4, { width: widths[index] - 4, height: height - 6, lineBreak: true, }); x += widths[index]; }); y += height; }; drawRow([...EXPORT_HEADERS], true); for (const row of rows) { drawRow(rowToCells(row)); } doc.end(); }); } export function buildDevPlanExportFilename(format: DevPlanExportFormat, count: number): string { const stamp = new Date().toISOString().slice(0, 10); const ext = format === 'markdown' ? 'md' : format === 'docx' ? 'docx' : format; return `开发任务导出_${stamp}_${count}条.${ext}`; } export function devPlanExportMimeType(format: DevPlanExportFormat): string { switch (format) { case 'markdown': return 'text/markdown;charset=utf-8'; case 'docx': return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; case 'xlsx': return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; case 'pdf': return 'application/pdf'; default: return 'application/octet-stream'; } }