326 lines
12 KiB
TypeScript
326 lines
12 KiB
TypeScript
import {
|
||
Document, Packer, Paragraph, TextRun, Table, TableRow, TableCell,
|
||
WidthType, AlignmentType, HeadingLevel, PageBreak, ImageRun,
|
||
TableOfContents, VerticalAlign, FileChild,
|
||
} from 'docx'
|
||
|
||
type Heading = (typeof HeadingLevel)[keyof typeof HeadingLevel]
|
||
type HAlign = (typeof AlignmentType)[keyof typeof AlignmentType]
|
||
import type {
|
||
MonthlyReportData, Chapter3FaultEntry, Chapter3OtherEntry,
|
||
} from '@/types/report'
|
||
|
||
// ---- helpers ----
|
||
|
||
function createHeaderCell(text: string, width?: number): TableCell {
|
||
return new TableCell({
|
||
children: [new Paragraph({
|
||
children: [new TextRun({ text, bold: true, size: 20, font: 'SimSun', color: 'FFFFFF' })],
|
||
alignment: AlignmentType.CENTER,
|
||
spacing: { line: 360 },
|
||
})],
|
||
shading: { fill: '1F4E79' },
|
||
width: width ? { size: width, type: WidthType.PERCENTAGE } : undefined,
|
||
verticalAlign: VerticalAlign.CENTER,
|
||
})
|
||
}
|
||
|
||
function createCell(text: string, align: HAlign = AlignmentType.CENTER): TableCell {
|
||
return new TableCell({
|
||
children: [new Paragraph({
|
||
children: [new TextRun({ text: text || '', size: 18, font: 'SimSun' })],
|
||
alignment: align,
|
||
spacing: { line: 276 },
|
||
})],
|
||
verticalAlign: VerticalAlign.CENTER,
|
||
})
|
||
}
|
||
|
||
// 标题段落
|
||
function chapterTitle(text: string, heading: Heading, spaceBefore = 400, spaceAfter = 200): Paragraph {
|
||
return new Paragraph({
|
||
children: [new TextRun({ text, bold: true, size: 28, font: 'SimSun' })],
|
||
heading,
|
||
spacing: { before: spaceBefore, after: spaceAfter, line: 360 },
|
||
})
|
||
}
|
||
|
||
// 小节标题
|
||
function sectionTitle(text: string): Paragraph {
|
||
return new Paragraph({
|
||
children: [new TextRun({ text, bold: true, size: 24, font: 'SimSun' })],
|
||
heading: HeadingLevel.HEADING_2,
|
||
spacing: { before: 200, after: 100, line: 360 },
|
||
})
|
||
}
|
||
|
||
// 正文段落(首行缩进2字符)
|
||
function bodyPara(text: string): Paragraph {
|
||
return new Paragraph({
|
||
children: [new TextRun({ text, size: 22, font: 'SimSun' })],
|
||
spacing: { after: 80, line: 360 },
|
||
indent: { firstLine: 480 },
|
||
})
|
||
}
|
||
|
||
// 页面分隔
|
||
function pageBreak(): Paragraph {
|
||
return new Paragraph({ children: [new PageBreak()] })
|
||
}
|
||
|
||
// ---- 表格构建 ----
|
||
|
||
function buildFaultTable(entries: Chapter3FaultEntry[]): Table {
|
||
const headerTexts = ['工单编号', '故障节点', '故障日期', '故障问题', '故障原因', '处理时长(分钟)', '是否计入SLA']
|
||
const headerRow = new TableRow({ children: headerTexts.map(t => createHeaderCell(t)) })
|
||
const dataRows = entries.map(e => new TableRow({
|
||
children: [
|
||
createCell(String(e.ticketId)),
|
||
createCell(e.nodeIp),
|
||
createCell(e.faultDate),
|
||
createCell(e.faultProblem),
|
||
createCell(e.faultCause),
|
||
createCell(e.isOngoing ? '进行中' : String(e.durationMinutes)),
|
||
createCell(e.isOngoing ? '—' : e.countedInSla),
|
||
],
|
||
}))
|
||
return new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, rows: [headerRow, ...dataRows] })
|
||
}
|
||
|
||
function buildOtherTable(entries: Chapter3OtherEntry[]): Table {
|
||
const headerTexts = ['工单编号', '设备IP地址', '工单日期', '工单内容', '工单结论', '处理时长(分钟)', '是否计入SLA']
|
||
const headerRow = new TableRow({ children: headerTexts.map(t => createHeaderCell(t)) })
|
||
const dataRows = entries.map(e => new TableRow({
|
||
children: [
|
||
createCell(String(e.ticketId)),
|
||
createCell(e.deviceIp),
|
||
createCell(e.ticketDate),
|
||
createCell(e.ticketContent, AlignmentType.LEFT),
|
||
createCell(e.ticketConclusion, AlignmentType.LEFT),
|
||
createCell(e.isOngoing ? '进行中' : String(e.durationMinutes)),
|
||
createCell(e.isOngoing ? '—' : e.countedInSla),
|
||
],
|
||
}))
|
||
return new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, rows: [headerRow, ...dataRows] })
|
||
}
|
||
|
||
// ---- 主入口 ----
|
||
|
||
export async function buildMonthlyReportDocx(
|
||
data: MonthlyReportData,
|
||
charts: { gpuPng: Buffer; storagePng: Buffer },
|
||
weeklyLabels: string[] = [],
|
||
): Promise<Buffer> {
|
||
const children: FileChild[] = []
|
||
|
||
// ========== 封面页 ==========
|
||
// 上方留白
|
||
children.push(new Paragraph({ children: [], spacing: { before: 2400 } }))
|
||
|
||
children.push(new Paragraph({
|
||
children: [new TextRun({
|
||
text: '图灵引擎&腾讯公司算力服务框架IT基础设施运营月报',
|
||
bold: true, size: 44, font: 'SimSun',
|
||
})],
|
||
alignment: AlignmentType.CENTER,
|
||
spacing: { after: 200, line: 360 },
|
||
}))
|
||
|
||
children.push(new Paragraph({
|
||
children: [new TextRun({
|
||
text: `(${data.monthLabel.replace('年', '年').replace('月', '月')}1日-${new Date(data.periodEnd).getDate()}日)`,
|
||
bold: true, size: 52, font: 'SimHei',
|
||
})],
|
||
alignment: AlignmentType.CENTER,
|
||
spacing: { after: 800, line: 360 },
|
||
}))
|
||
|
||
// 公司 + 生成月份(页面下方,标题下方留 8 空行)
|
||
children.push(new Paragraph({ children: [], spacing: { before: 600, line: 360 } }))
|
||
children.push(new Paragraph({ children: [], spacing: { before: 200, line: 360 } }))
|
||
children.push(new Paragraph({ children: [], spacing: { before: 200, line: 360 } }))
|
||
children.push(new Paragraph({ children: [], spacing: { before: 200, line: 360 } }))
|
||
children.push(new Paragraph({ children: [], spacing: { before: 200, line: 360 } }))
|
||
children.push(new Paragraph({ children: [], spacing: { before: 200, line: 360 } }))
|
||
children.push(new Paragraph({ children: [], spacing: { before: 200, line: 360 } }))
|
||
children.push(new Paragraph({ children: [], spacing: { before: 200, line: 360 } }))
|
||
children.push(new Paragraph({ children: [], spacing: { before: 200 } }))
|
||
children.push(new Paragraph({
|
||
children: [new TextRun({ text: '杭州图灵引擎科技有限公司', size: 32, font: 'SimSun' })],
|
||
alignment: AlignmentType.CENTER,
|
||
spacing: { line: 360 },
|
||
}))
|
||
children.push(new Paragraph({
|
||
children: [new TextRun({ text: `${data.generationLabel}`, size: 32, font: 'SimSun' })],
|
||
alignment: AlignmentType.CENTER,
|
||
spacing: { line: 360 },
|
||
}))
|
||
|
||
// ========== 目录页 ==========
|
||
children.push(pageBreak())
|
||
children.push(new Paragraph({
|
||
children: [new TextRun({ text: '目录', bold: true, size: 36, font: 'SimSun' })],
|
||
alignment: AlignmentType.CENTER,
|
||
spacing: { after: 400, line: 360 },
|
||
}))
|
||
children.push(new TableOfContents('目录', {
|
||
hyperlink: true,
|
||
headingStyleRange: '1-2',
|
||
}))
|
||
|
||
// ========== 第一章:总体运营概况 ==========
|
||
children.push(pageBreak())
|
||
children.push(chapterTitle('一、总体运营概况', HeadingLevel.HEADING_1))
|
||
|
||
// GPU 图表
|
||
children.push(new Paragraph({
|
||
children: [new ImageRun({
|
||
data: charts.gpuPng,
|
||
transformation: { width: 550, height: 275 },
|
||
type: 'png',
|
||
})],
|
||
alignment: AlignmentType.CENTER,
|
||
spacing: { before: 200, after: 300 },
|
||
}))
|
||
|
||
// 存储图表
|
||
children.push(new Paragraph({
|
||
children: [new ImageRun({
|
||
data: charts.storagePng,
|
||
transformation: { width: 550, height: 275 },
|
||
type: 'png',
|
||
})],
|
||
alignment: AlignmentType.CENTER,
|
||
spacing: { before: 200, after: 200 },
|
||
}))
|
||
|
||
// ========== 第二章:运营数据总览 ==========
|
||
children.push(chapterTitle('二、运营数据总览', HeadingLevel.HEADING_1))
|
||
|
||
// 按 device_type 分组
|
||
const gpuChapter2 = data.chapter2.filter(e => e.device_type === 'gpu')
|
||
const storageChapter2 = data.chapter2.filter(e => e.device_type === 'storage')
|
||
|
||
// 2.1 GPU
|
||
children.push(sectionTitle('2.1 GPU服务器运行状态'))
|
||
if (gpuChapter2.length === 0) {
|
||
children.push(bodyPara('故障记录:无。'))
|
||
} else {
|
||
for (const entry of gpuChapter2) {
|
||
for (const f of entry.faults) {
|
||
const dateParts = entry.date.split('-')
|
||
const monthDay = `${parseInt(dateParts[1])}月${parseInt(dateParts[2])}日`
|
||
const statusText = f.isOngoing
|
||
? '处理中。'
|
||
: `${f.recoveryDays === 0 ? '当日' : f.recoveryDays === 1 ? '次日' : `${f.recoveryDays}日后`}恢复。`
|
||
children.push(bodyPara(
|
||
`${monthDay}发生1次${f.fault_subcategory},故障节点为${f.ip},${statusText}`
|
||
))
|
||
}
|
||
}
|
||
}
|
||
|
||
// 2.2 存储
|
||
children.push(sectionTitle('2.2 存储服务器运行状态'))
|
||
if (storageChapter2.length === 0) {
|
||
children.push(bodyPara('故障记录:无。'))
|
||
} else {
|
||
for (const entry of storageChapter2) {
|
||
for (const f of entry.faults) {
|
||
const dateParts = entry.date.split('-')
|
||
const monthDay = `${parseInt(dateParts[1])}月${parseInt(dateParts[2])}日`
|
||
const statusText = f.isOngoing
|
||
? '处理中。'
|
||
: `${f.recoveryDays === 0 ? '当日' : f.recoveryDays === 1 ? '次日' : `${f.recoveryDays}日后`}恢复。`
|
||
children.push(bodyPara(
|
||
`${monthDay}发生1次${f.fault_subcategory},故障节点为${f.ip},${statusText}`
|
||
))
|
||
}
|
||
}
|
||
}
|
||
|
||
// ========== 第三章:运营故障概览 ==========
|
||
children.push(chapterTitle('三、运营故障概览', HeadingLevel.HEADING_1))
|
||
|
||
const { gpuFaults, storageFaults, otherTickets } = data.chapter3
|
||
|
||
// 3.1 GPU
|
||
children.push(sectionTitle(
|
||
gpuFaults.length > 0 ? `3.1 GPU服务器故障(${gpuFaults.length}个)` : '3.1 GPU服务器故障(无)'
|
||
))
|
||
if (gpuFaults.length > 0) children.push(buildFaultTable(gpuFaults))
|
||
|
||
// 3.2 存储
|
||
children.push(sectionTitle(
|
||
storageFaults.length > 0 ? `3.2 存储服务器故障(${storageFaults.length}个)` : '3.2 存储服务器故障(无)'
|
||
))
|
||
if (storageFaults.length > 0) children.push(buildFaultTable(storageFaults))
|
||
|
||
// 3.3 其他工单
|
||
children.push(sectionTitle(
|
||
otherTickets.length > 0 ? `3.3 其他工单(${otherTickets.length}个)` : '3.3 其他工单(无)'
|
||
))
|
||
if (otherTickets.length > 0) children.push(buildOtherTable(otherTickets))
|
||
|
||
// ========== 第四章:服务可用性说明 ==========
|
||
children.push(chapterTitle('四、服务可用性说明', HeadingLevel.HEADING_1))
|
||
|
||
if (data.chapter4.length === 0) {
|
||
children.push(bodyPara('无。'))
|
||
} else {
|
||
for (const entry of data.chapter4) {
|
||
const totalMinutes = entry.monthDays * 24 * 60
|
||
const formula = `${entry.ip}服务可用性=(${entry.monthDays}*24*60-${entry.totalDurationMinutes})/(${entry.monthDays}*24*60)*100%=`
|
||
const percent = `${entry.availabilityPercent.toFixed(2)}%`
|
||
const below99 = entry.availabilityPercent < 99
|
||
const children_: TextRun[] = [
|
||
new TextRun({ text: formula, size: 22, font: 'SimSun' }),
|
||
new TextRun(below99
|
||
? { text: percent, size: 22, font: 'SimSun', bold: true, color: 'FF0000', highlight: 'yellow' }
|
||
: { text: percent, size: 22, font: 'SimSun' }
|
||
),
|
||
]
|
||
if (entry.hasOngoing) {
|
||
children_.push(new TextRun({ text: '(故障处理中,仅计本月部分)', size: 22, font: 'SimSun', color: 'FF6600' }))
|
||
}
|
||
children.push(new Paragraph({
|
||
children: children_,
|
||
spacing: { after: 80, line: 360 },
|
||
indent: { firstLine: 480 },
|
||
}))
|
||
}
|
||
}
|
||
|
||
// ========== 第五章:附件 ==========
|
||
if (weeklyLabels.length > 0) {
|
||
children.push(chapterTitle('五、附件', HeadingLevel.HEADING_1))
|
||
children.push(bodyPara(`本月共 ${weeklyLabels.length} 份周报,双击图标可打开查看:`))
|
||
}
|
||
|
||
// ========== 组装文档 ==========
|
||
const doc = new Document({
|
||
sections: [{
|
||
children,
|
||
properties: {
|
||
page: {
|
||
margin: {
|
||
top: 1440, // ~1 inch in twips
|
||
bottom: 1440,
|
||
left: 1440,
|
||
right: 1440,
|
||
},
|
||
},
|
||
},
|
||
}],
|
||
styles: {
|
||
default: {
|
||
document: { run: { font: 'SimSun', size: 22 } },
|
||
heading1: { run: { font: 'SimSun', size: 28, bold: true }, paragraph: { spacing: { line: 360 } } },
|
||
heading2: { run: { font: 'SimSun', size: 24, bold: true }, paragraph: { spacing: { line: 360 } } },
|
||
},
|
||
},
|
||
})
|
||
|
||
return Packer.toBuffer(doc)
|
||
}
|