271 lines
8.2 KiB
TypeScript
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(` `);
|
|
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';
|
|
}
|
|
}
|