issue-ai/src/lib/monthly-report-docx.ts

326 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}