Files
dukang/server/dukang-api/src/modules/dev-plan/admin-dev-plan-export.util.ts
T
jacy 7e12eb3ca6
CI / verify (pull_request) Waiting to run
v3.5.6版本迭代
2026-08-23 16:41:22 +08:00

271 lines
8.2 KiB
TypeScript

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<Buffer | null> {
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<Buffer> {
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<Buffer> {
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<Buffer> {
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';
}
}