feat: 报告系统重构 — 异步生成、预览重设计、跨月工单支持
- 报告创建与 DOCX 生成分离,新增 POST /api/reports/[id]/generate 异步路由 - 报告预览/列表页重设计:KPI 5列布局、状态标签颜色、故障分类中性色 - 月报支持跨月进行中工单:图表/第二章/第三章/第四章全覆盖 - OLE Package 嵌入自然周周报到月报附件章节 - 修复月报第一章/第二章未排除 fault_subcategory='其他' 工单 - 修复当日恢复故障被计入离线节点(恢复 date < close_time 判定) - 报告预览无故障天数改为完整日期范围计算 - Button 组件增加 loading 支持、API Key 过期检查修复
This commit is contained in:
parent
a5f19ebeda
commit
5c94719693
14
CHANGELOG.md
14
CHANGELOG.md
|
|
@ -1,5 +1,19 @@
|
|||
# 变更日志
|
||||
|
||||
## 2026-05-07
|
||||
|
||||
- [新增] 月报跨月进行中工单支持:第一章折线图覆盖未结单离线天数,第二章标注"处理中",第三章显示"进行中"/"—",第四章标注"仅计本月部分"
|
||||
- [修复] `monthly-report-docx.ts` 6 个 TypeScript 类型错误:HeadingLevel 值类型、createCell align 推断过窄、children 数组类型改为 FileChild[]
|
||||
- [修复] API Key 过期检查失效:`verifyApiKey` 查询缺 `expires_at` 字段,导致过期判断永不触发
|
||||
- [修复] Button 组件不支持 `loading` 属性:增加 `loading?: boolean` 并在 loading 时禁用 + spinner 动画
|
||||
- [修复] `scripts/import-steps.ts` 参数类型错误:`main()` 增加 `excelPath` 判空守卫
|
||||
|
||||
- [新增] 月报生成时自动生成当月自然周周报,以 OLE Package 嵌入「五、附件」章节,双击图标可在 Word 中打开
|
||||
- [新增] 报告管理页面重新设计:预览页 KPI 5 列布局、故障分类去背景色、报告列表状态标签颜色体系、操作按钮按状态显示
|
||||
- [新增] POST `/api/reports/[id]/generate` 异步生成路由,报告创建与 DOCX 生成分离,状态机 ready→generating→completed/failed
|
||||
- [修复] 月报第二章和第一章图表:排除 `fault_subcategory = '其他'` 工单,当日恢复故障不计入离线节点
|
||||
- [修复] 报告预览无故障天数改为按故障完整日期范围计算,与 DOCX 图表规则区分
|
||||
|
||||
## 2026-05-05
|
||||
|
||||
- [修复] 云服务器月报生成失败:重建 Docker 镜像安装 echarts,Dockerfile 补全 Chromium 系统依赖库(libglib2.0、libnss3 等 18 个)
|
||||
|
|
|
|||
|
|
@ -225,6 +225,7 @@ NEXT_PUBLIC_ASSETS_URL=https://assets.tlyq.ai
|
|||
- **新增 API**:在 `src/app/api/` 下创建路由 → 顶部调用 `initDatabase()` → `getCurrentUser()` 验证 → `hasPermission()` 校验
|
||||
- **新增页面**:在 `src/app/(app)/` 下创建 → 布局由 `(app)/layout.tsx` 提供
|
||||
- **权限格式**:`resource:action`,如 `hasPermission(user, 'tickets:write')`
|
||||
- **日期处理**:禁止使用 `Date.toISOString()` 格式化本地日期。`toISOString()` 返回 UTC 时间,在中国时区(UTC+8)下 `new Date('2026-04-01T00:00:00').toISOString()` 会返回 `"2026-03-31T16:00:00.000Z"`,日期偏移一天。应使用本地时间方法拼接:`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
70
README.md
70
README.md
|
|
@ -28,7 +28,7 @@
|
|||
|
||||
### 概述
|
||||
|
||||
月报按自然月生成,统计当月已结单工单,输出为 DOCX 文档。包含封面、目录及四个章节。
|
||||
月报按自然月生成,统计当月已结单工单及跨月进行中工单,输出为 DOCX 文档。包含封面、目录及四个章节。
|
||||
|
||||
### 报告结构
|
||||
|
||||
|
|
@ -45,21 +45,27 @@
|
|||
|
||||
**设备清单**:从 assets-ai 拉取 `filter_status=腾讯使用` 的设备,按 `filter_device_type=GPU服务器` / `存储服务器` 分类,构建 business_ip → device_type 映射。
|
||||
|
||||
**工单筛选**:按 `close_time` 范围查询(monthly report 统计当月结单),条件:
|
||||
**工单筛选**:按 `close_time` 与 `assign_time` 双条件查询,同时覆盖当月结单工单和跨月进行中工单:
|
||||
|
||||
```sql
|
||||
close_time >= periodStart AND close_time <= periodEnd + ' 23:59:59'
|
||||
AND current_status IN ('resolved', 'closed')
|
||||
AND duration_minutes IS NOT NULL
|
||||
SELECT * FROM tickets WHERE (
|
||||
(close_time >= periodStart AND close_time <= periodEnd + ' 23:59:59')
|
||||
OR
|
||||
(assign_time <= periodEnd + ' 23:59:59' AND (close_time IS NULL OR close_time > periodEnd + ' 23:59:59'))
|
||||
)
|
||||
ORDER BY assign_time
|
||||
```
|
||||
|
||||
- 第一条件:当月内结单的工单(含上月派发本月恢复的跨月工单)
|
||||
- 第二条件:当月结束时仍未结单的工单(含当月派发和上月派发的进行中工单)
|
||||
- 使用 `close_time` 比较而非 `current_status`,保证事后补生成报告时判断不变
|
||||
|
||||
**工单分类**:按 device_ip 在设备清单中查找对应 device_type(gpu / storage / other)。
|
||||
|
||||
### 第一章规则
|
||||
|
||||
- 遍历当月每一天,计算当日在线节点数
|
||||
- **统计范围**:排除 `fault_category = '无故障'` 的工单(跨月工单如 7/31 故障 8/3 恢复正常计入,8/1、8/2 各减 1 台)
|
||||
- **统计范围**:排除 `fault_category = '无故障'` 或 `fault_subcategory = '其他'` 的工单("其他"工单不计入节点在线数;跨月工单如 7/31 故障 8/3 恢复正常计入,8/1、8/2 各减 1 台)
|
||||
- 不在线判断:`assign_time日期 ≤ 当前日期 < close_time日期`
|
||||
- 当日发生故障次日恢复 → 发生日计入不在线,恢复日不计入
|
||||
- 当日发生故障当日恢复 → 不计入不在线
|
||||
|
|
@ -68,13 +74,18 @@ ORDER BY assign_time
|
|||
- **Y 轴动态范围**:根据实际数据波动自动调整 min/max/interval,避免总节点数较大时微小变化无法分辨
|
||||
- 无波动时 Y 轴范围 = total ± 2
|
||||
- 有波动时根据实际最值加 buffer,确保 8~15 个刻度
|
||||
- **报告预览"无故障天数"与 DOCX 图表计算方式不同**:
|
||||
- **预览**:按故障完整日期范围(`assign ~ close`,含 close 当天)计算,当日恢复也计入故障天数
|
||||
- **DOCX 图表**:按 `assign ≤ date < close` 计算,当日恢复不计入离线节点
|
||||
|
||||
### 第二章规则
|
||||
|
||||
- 仅统计 GPU / 存储工单,排除 `fault_category = '无故障'`
|
||||
- 仅统计 GPU / 存储工单,排除 `fault_category = '无故障'` 或 `fault_subcategory = '其他'`
|
||||
- 按 `device_type + assign_time日期` 分组
|
||||
- 每条格式:`X月X日发生1次<故障子类>,故障节点为<IP>,<恢复描述>恢复。`
|
||||
- 恢复描述:`assign_date` 与 `close_date` 天数差,0 → 当日,1 → 次日,≥2 → N日后
|
||||
- 每条格式:`X月X日发生1次<故障子类>,故障节点为<IP>,<恢复描述>。`
|
||||
- 恢复描述:
|
||||
- 已结单:`assign_date` 与 `close_date` 天数差,0 → 当日,1 → 次日,≥2 → N日后
|
||||
- 进行中:固定显示 `处理中。`(不含恢复描述)
|
||||
|
||||
### 第三章规则
|
||||
|
||||
|
|
@ -89,36 +100,39 @@ ORDER BY assign_time
|
|||
|
||||
**GPU/存储故障表**(7 列):
|
||||
|
||||
| 列 | 来源 |
|
||||
|----|------|
|
||||
| 工单编号 | `ticket_id` |
|
||||
| 故障节点 | `device_ip` |
|
||||
| 故障日期 | `assign_time`(完整时间,精确到秒) |
|
||||
| 故障问题 | `fault_subcategory` |
|
||||
| 故障原因 | `parts_name` 有值 → `更换{parts_name}`,否则 → `-` |
|
||||
| 处理时长(分钟) | `duration_minutes` |
|
||||
| 是否计入 SLA | 见下方 SLA 规则 |
|
||||
| 列 | 来源 | 进行中工单 |
|
||||
|----|------|-----------|
|
||||
| 工单编号 | `ticket_id` | 正常显示 |
|
||||
| 故障节点 | `device_ip` | 正常显示 |
|
||||
| 故障日期 | `assign_time`(完整时间,精确到秒) | 正常显示 |
|
||||
| 故障问题 | `fault_subcategory` | 正常显示 |
|
||||
| 故障原因 | `parts_name` 有值 → `更换{parts_name}`,否则 → `-` | 正常显示 |
|
||||
| 处理时长(分钟) | `duration_minutes`(已结单) | 显示 `进行中` |
|
||||
| 是否计入 SLA | 见下方 SLA 规则 | 显示 `—` |
|
||||
|
||||
**其他工单表**(7 列):
|
||||
|
||||
| 列 | 来源 |
|
||||
|----|------|
|
||||
| 工单编号 | `ticket_id` |
|
||||
| 设备 IP 地址 | `device_ip` |
|
||||
| 工单日期 | `assign_time`(完整时间,精确到秒) |
|
||||
| 工单内容 | `content` |
|
||||
| 工单结论 | `conclusion` |
|
||||
| 处理时长(分钟) | `duration_minutes` |
|
||||
| 是否计入 SLA | 见下方 SLA 规则 |
|
||||
| 列 | 来源 | 进行中工单 |
|
||||
|----|------|-----------|
|
||||
| 工单编号 | `ticket_id` | 正常显示 |
|
||||
| 设备 IP 地址 | `device_ip` | 正常显示 |
|
||||
| 工单日期 | `assign_time`(完整时间,精确到秒) | 正常显示 |
|
||||
| 工单内容 | `content` | 正常显示 |
|
||||
| 工单结论 | `conclusion` | 正常显示 |
|
||||
| 处理时长(分钟) | `duration_minutes`(已结单) | 显示 `进行中` |
|
||||
| 是否计入 SLA | 见下方 SLA 规则 | 显示 `—` |
|
||||
|
||||
### 第四章规则
|
||||
|
||||
- 排除 `fault_category = '无故障'` 的工单
|
||||
- 按 device_ip 分组求和 `duration_minutes`
|
||||
- 按 device_ip 分组求和故障时长:
|
||||
- **已结单工单**:使用实际 `duration_minutes`
|
||||
- **进行中工单**:仅计算本月内部分,时长 = `(min(assign_date, 月初) → 月末最后一天) × 24 × 60` 分钟
|
||||
- 公式:`可用性 = (monthDays × 24 × 60 - totalDurationMinutes) / (monthDays × 24 × 60) × 100`
|
||||
- monthDays 为当月实际天数(动态计算)
|
||||
- 百分比 **< 99%** 时,该值以黄底红字加粗标记
|
||||
- 百分比 **≥ 99%** 时,正常样式
|
||||
- 该 IP 存在进行中工单时,公式后追加橙色标注 `(故障处理中,仅计本月部分)`
|
||||
|
||||
### SLA 判定规则
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
"docx": "^9.1.1",
|
||||
"echarts": "^5.5.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react": "^1.8.0",
|
||||
"next": "^15.1.0",
|
||||
"puppeteer": "^23.0.0",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
"docx": "^9.1.1",
|
||||
"echarts": "^5.5.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react": "^1.8.0",
|
||||
"next": "^15.1.0",
|
||||
"puppeteer": "^23.0.0",
|
||||
|
|
|
|||
|
|
@ -199,6 +199,10 @@ function parseFaultSheet(data: (string | number | null)[][]): {
|
|||
// 主逻辑
|
||||
// ---------------------------------------------------------------------------
|
||||
function main() {
|
||||
if (!excelPath) {
|
||||
console.error('用法: npx tsx scripts/import-steps.ts <Excel文件路径> [dbPath]')
|
||||
process.exit(1)
|
||||
}
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
console.error(`数据库文件不存在: ${dbPath}`)
|
||||
process.exit(1)
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
'use client'
|
||||
import { ButtonHTMLAttributes, InputHTMLAttributes, SelectHTMLAttributes, ReactNode } from 'react'
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { variant?: 'primary' | 'secondary' | 'danger' | 'ghost'; size?: 'sm' | 'md' | 'lg' }
|
||||
export function Button({ variant = 'primary', size = 'md', className = '', children, ...props }: ButtonProps) {
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { variant?: 'primary' | 'secondary' | 'danger' | 'ghost'; size?: 'sm' | 'md' | 'lg'; loading?: boolean }
|
||||
export function Button({ variant = 'primary', size = 'md', className = '', children, loading, disabled, ...props }: ButtonProps) {
|
||||
const base = 'inline-flex items-center justify-center font-medium rounded-xl transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500/50 disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
const v = { primary: 'bg-blue-600 text-white hover:bg-blue-700 shadow-sm', secondary: 'bg-slate-100 text-slate-700 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700', danger: 'bg-red-600 text-white hover:bg-red-700', ghost: 'text-slate-600 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800' }
|
||||
const s = { sm: 'px-3 py-1.5 text-sm', md: 'px-4 py-2 text-sm', lg: 'px-6 py-2.5 text-base' }
|
||||
return <button className={`${base} ${v[variant]} ${s[size]} ${className}`} {...props}>{children}</button>
|
||||
return <button className={`${base} ${v[variant]} ${s[size]} ${className}`} disabled={disabled || loading} {...props}>{loading ? <span className="mr-2 inline-block w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" /> : null}{children}</button>
|
||||
}
|
||||
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> { label?: string; error?: string }
|
||||
|
|
|
|||
|
|
@ -48,8 +48,8 @@ export function verifyApiKey(key: string): ApiKeyInfo | null {
|
|||
if (!key.startsWith('ak_')) return null
|
||||
const db = getDb()
|
||||
const keyHash = hashApiKey(key)
|
||||
const row = db.prepare('SELECT id, name, permissions FROM api_keys WHERE key_hash = ? AND is_active = 1')
|
||||
.get(keyHash) as { id: number; name: string; permissions: string } | undefined
|
||||
const row = db.prepare('SELECT id, name, permissions, expires_at FROM api_keys WHERE key_hash = ? AND is_active = 1')
|
||||
.get(keyHash) as { id: number; name: string; permissions: string; expires_at: string | null } | undefined
|
||||
if (!row) return null
|
||||
if (row.expires_at && new Date(row.expires_at) < new Date()) return null
|
||||
db.prepare("UPDATE api_keys SET last_used_at = datetime('now') WHERE id = ?").run(row.id)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import {
|
||||
Document, Packer, Paragraph, TextRun, Table, TableRow, TableCell,
|
||||
WidthType, AlignmentType, HeadingLevel, PageBreak, ImageRun,
|
||||
TableOfContents, VerticalAlign,
|
||||
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'
|
||||
|
|
@ -22,7 +25,7 @@ function createHeaderCell(text: string, width?: number): TableCell {
|
|||
})
|
||||
}
|
||||
|
||||
function createCell(text: string, align = AlignmentType.CENTER): TableCell {
|
||||
function createCell(text: string, align: HAlign = AlignmentType.CENTER): TableCell {
|
||||
return new TableCell({
|
||||
children: [new Paragraph({
|
||||
children: [new TextRun({ text: text || '', size: 18, font: 'SimSun' })],
|
||||
|
|
@ -34,7 +37,7 @@ function createCell(text: string, align = AlignmentType.CENTER): TableCell {
|
|||
}
|
||||
|
||||
// 标题段落
|
||||
function chapterTitle(text: string, heading: HeadingLevel, spaceBefore = 400, spaceAfter = 200): Paragraph {
|
||||
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,
|
||||
|
|
@ -77,8 +80,8 @@ function buildFaultTable(entries: Chapter3FaultEntry[]): Table {
|
|||
createCell(e.faultDate),
|
||||
createCell(e.faultProblem),
|
||||
createCell(e.faultCause),
|
||||
createCell(String(e.durationMinutes)),
|
||||
createCell(e.countedInSla),
|
||||
createCell(e.isOngoing ? '进行中' : String(e.durationMinutes)),
|
||||
createCell(e.isOngoing ? '—' : e.countedInSla),
|
||||
],
|
||||
}))
|
||||
return new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, rows: [headerRow, ...dataRows] })
|
||||
|
|
@ -94,8 +97,8 @@ function buildOtherTable(entries: Chapter3OtherEntry[]): Table {
|
|||
createCell(e.ticketDate),
|
||||
createCell(e.ticketContent, AlignmentType.LEFT),
|
||||
createCell(e.ticketConclusion, AlignmentType.LEFT),
|
||||
createCell(String(e.durationMinutes)),
|
||||
createCell(e.countedInSla),
|
||||
createCell(e.isOngoing ? '进行中' : String(e.durationMinutes)),
|
||||
createCell(e.isOngoing ? '—' : e.countedInSla),
|
||||
],
|
||||
}))
|
||||
return new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, rows: [headerRow, ...dataRows] })
|
||||
|
|
@ -106,8 +109,9 @@ function buildOtherTable(entries: Chapter3OtherEntry[]): Table {
|
|||
export async function buildMonthlyReportDocx(
|
||||
data: MonthlyReportData,
|
||||
charts: { gpuPng: Buffer; storagePng: Buffer },
|
||||
weeklyLabels: string[] = [],
|
||||
): Promise<Buffer> {
|
||||
const children: Paragraph[] = []
|
||||
const children: FileChild[] = []
|
||||
|
||||
// ========== 封面页 ==========
|
||||
// 上方留白
|
||||
|
|
@ -162,7 +166,7 @@ export async function buildMonthlyReportDocx(
|
|||
children.push(new TableOfContents('目录', {
|
||||
hyperlink: true,
|
||||
headingStyleRange: '1-2',
|
||||
}) as any)
|
||||
}))
|
||||
|
||||
// ========== 第一章:总体运营概况 ==========
|
||||
children.push(pageBreak())
|
||||
|
|
@ -206,11 +210,11 @@ export async function buildMonthlyReportDocx(
|
|||
for (const f of entry.faults) {
|
||||
const dateParts = entry.date.split('-')
|
||||
const monthDay = `${parseInt(dateParts[1])}月${parseInt(dateParts[2])}日`
|
||||
const recoveryText = f.recoveryDays === 0 ? '当日'
|
||||
: f.recoveryDays === 1 ? '次日'
|
||||
: `${f.recoveryDays}日后`
|
||||
const statusText = f.isOngoing
|
||||
? '处理中。'
|
||||
: `${f.recoveryDays === 0 ? '当日' : f.recoveryDays === 1 ? '次日' : `${f.recoveryDays}日后`}恢复。`
|
||||
children.push(bodyPara(
|
||||
`${monthDay}发生1次${f.fault_subcategory},故障节点为${f.ip},${recoveryText}恢复。`
|
||||
`${monthDay}发生1次${f.fault_subcategory},故障节点为${f.ip},${statusText}`
|
||||
))
|
||||
}
|
||||
}
|
||||
|
|
@ -225,11 +229,11 @@ export async function buildMonthlyReportDocx(
|
|||
for (const f of entry.faults) {
|
||||
const dateParts = entry.date.split('-')
|
||||
const monthDay = `${parseInt(dateParts[1])}月${parseInt(dateParts[2])}日`
|
||||
const recoveryText = f.recoveryDays === 0 ? '当日'
|
||||
: f.recoveryDays === 1 ? '次日'
|
||||
: `${f.recoveryDays}日后`
|
||||
const statusText = f.isOngoing
|
||||
? '处理中。'
|
||||
: `${f.recoveryDays === 0 ? '当日' : f.recoveryDays === 1 ? '次日' : `${f.recoveryDays}日后`}恢复。`
|
||||
children.push(bodyPara(
|
||||
`${monthDay}发生1次${f.fault_subcategory},故障节点为${f.ip},${recoveryText}恢复。`
|
||||
`${monthDay}发生1次${f.fault_subcategory},故障节点为${f.ip},${statusText}`
|
||||
))
|
||||
}
|
||||
}
|
||||
|
|
@ -269,20 +273,30 @@ export async function buildMonthlyReportDocx(
|
|||
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: [
|
||||
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' }
|
||||
),
|
||||
],
|
||||
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: [{
|
||||
|
|
|
|||
|
|
@ -109,13 +109,13 @@ export async function collectMonthlyReportData(
|
|||
|
||||
// 4. 第一章:每日在线节点数
|
||||
const dates = getDateRange(periodStart, periodEnd)
|
||||
// 排除"无故障"分类的工单(agent上报异常等),跨月工单(上月派发本月恢复)正常计入
|
||||
// 排除"无故障"分类和"其他"子分类(其他工单不计入节点在线数)
|
||||
const monthFaults = tickets.filter(t =>
|
||||
t.fault_category !== '无故障'
|
||||
t.fault_category !== '无故障' && t.fault_subcategory !== '其他'
|
||||
)
|
||||
|
||||
const dailyStats: DailyOnlineStats[] = dates.map(date => {
|
||||
// 当天不在线:assign 日期 ≤ date < close 日期(跨月工单也会正确计入)
|
||||
// 当天不在线:assign 日期 ≤ date < close 日期(当日恢复不计入离线)
|
||||
const gpuOffline = monthFaults.filter(t =>
|
||||
t.device_type === 'gpu' &&
|
||||
t.assign_time.slice(0, 10) <= date &&
|
||||
|
|
@ -136,8 +136,8 @@ export async function collectMonthlyReportData(
|
|||
}
|
||||
})
|
||||
|
||||
// 5. 第二章:运营数据总览(仅 gpu/storage,且排除"无故障"工单)
|
||||
const gpuStorageTickets = tickets.filter(t => t.device_type !== 'other' && t.fault_category !== '无故障')
|
||||
// 5. 第二章:运营数据总览(仅 gpu/storage,排除"无故障"和"其他"工单)
|
||||
const gpuStorageTickets = tickets.filter(t => t.device_type !== 'other' && t.fault_category !== '无故障' && t.fault_subcategory !== '其他')
|
||||
const chapter2Map = new Map<string, Chapter2FaultItem[]>()
|
||||
for (const t of gpuStorageTickets) {
|
||||
const assignDate = t.assign_time.slice(0, 10)
|
||||
|
|
@ -256,7 +256,7 @@ export async function collectMonthlyReportData(
|
|||
/** 从采集数据构建 metadata JSON(供 API 路由和 generate 函数共用) */
|
||||
export function buildMonthlyMetadata(data: MonthlyReportData): string {
|
||||
const gpuStorageTickets = data.tickets.filter(t =>
|
||||
t.device_type !== 'other' && t.fault_category !== '无故障'
|
||||
t.device_type !== 'other' && t.fault_category !== '无故障' && t.fault_subcategory !== '其他'
|
||||
)
|
||||
const resolvedTickets = gpuStorageTickets.filter(t => !t.isOngoing)
|
||||
|
||||
|
|
@ -276,10 +276,20 @@ export function buildMonthlyMetadata(data: MonthlyReportData): string {
|
|||
const affectedDeviceIps = new Set(gpuStorageTickets.map(t => t.device_ip))
|
||||
const affectedDeviceCount = affectedDeviceIps.size
|
||||
|
||||
const daysWithFaults = data.dailyStats.filter(d =>
|
||||
(d.gpuTotal - d.gpuOnline) + (d.storageTotal - d.storageOnline) > 0
|
||||
).length
|
||||
const faultFreeDays = data.monthDays - daysWithFaults
|
||||
// 无故障天数:按故障影响的全部日期范围(assign ~ close,含 close),当日恢复也计入
|
||||
// 预览与 DOCX 图表计算方式不同:图表用 date < close 排除当日恢复,预览用全部日期范围
|
||||
const faultDateSet = new Set<string>()
|
||||
for (const t of gpuStorageTickets) {
|
||||
const start = new Date(t.assign_time.slice(0, 10).replace(/-/g, '/'))
|
||||
const endRaw = t.isOngoing ? data.periodEnd : t.close_time.slice(0, 10)
|
||||
const end = new Date(endRaw.replace(/-/g, '/'))
|
||||
const cur = new Date(start)
|
||||
while (cur <= end) {
|
||||
faultDateSet.add(`${cur.getFullYear()}-${String(cur.getMonth()+1).padStart(2,'0')}-${String(cur.getDate()).padStart(2,'0')}`)
|
||||
cur.setDate(cur.getDate() + 1)
|
||||
}
|
||||
}
|
||||
const faultFreeDays = data.monthDays - faultDateSet.size
|
||||
|
||||
const avgAvailability = data.chapter4.length > 0
|
||||
? Math.round(data.chapter4.reduce((s, e) => s + e.availabilityPercent, 0) / data.chapter4.length * 100) / 100
|
||||
|
|
|
|||
|
|
@ -0,0 +1,283 @@
|
|||
// src/lib/ole-embed.ts
|
||||
import JSZip from 'jszip'
|
||||
import type { WeekRange, WeeklyAttachment } from '@/types/report'
|
||||
|
||||
/**
|
||||
* 获取一个自然月内包含的自然周范围(周一~周日为自然周)。
|
||||
* 首尾周可能不完整(如月份从周三开始、到周四结束)。
|
||||
*/
|
||||
export function getMonthNaturalWeeks(periodStart: string, periodEnd: string): WeekRange[] {
|
||||
const start = new Date(periodStart + 'T00:00:00')
|
||||
const end = new Date(periodEnd + 'T00:00:00')
|
||||
const weeks: WeekRange[] = []
|
||||
let index = 1
|
||||
|
||||
// 辅助:获取某天所在周的周日
|
||||
const getSunday = (d: Date): Date => {
|
||||
const r = new Date(d)
|
||||
const day = r.getDay()
|
||||
const diff = day === 0 ? 0 : 7 - day
|
||||
r.setDate(r.getDate() + diff)
|
||||
return r
|
||||
}
|
||||
|
||||
// 格式化日期(使用本地时区,避免 toISOString 的 UTC 偏移问题)
|
||||
const fmt = (d: Date): string => {
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${day}`
|
||||
}
|
||||
|
||||
// 首周:从 monthStart 到其所在周的周日
|
||||
const firstSunday = getSunday(start)
|
||||
const firstWeekEnd = firstSunday > end ? end : firstSunday
|
||||
weeks.push({
|
||||
index: index++,
|
||||
start: fmt(start),
|
||||
end: fmt(firstWeekEnd),
|
||||
label: label(fmt(start), fmt(firstWeekEnd), index - 1),
|
||||
})
|
||||
|
||||
// 如果首周结束已经 >= 月末,直接返回(整月不足一周)
|
||||
if (firstWeekEnd.getTime() >= end.getTime()) return weeks
|
||||
|
||||
// 中间周:从首周周日次日(周一)开始,每次取完整7天
|
||||
let cursor = new Date(firstWeekEnd)
|
||||
cursor.setDate(cursor.getDate() + 1) // 跳到周一
|
||||
|
||||
while (cursor <= end) {
|
||||
const weekEnd = new Date(cursor)
|
||||
weekEnd.setDate(weekEnd.getDate() + 6) // 周日
|
||||
|
||||
const actualEnd = weekEnd > end ? end : weekEnd
|
||||
weeks.push({
|
||||
index: index++,
|
||||
start: fmt(cursor),
|
||||
end: fmt(actualEnd),
|
||||
label: label(fmt(cursor), fmt(actualEnd), index - 1),
|
||||
})
|
||||
|
||||
if (weekEnd >= end) break
|
||||
cursor.setDate(cursor.getDate() + 7)
|
||||
}
|
||||
|
||||
return weeks
|
||||
}
|
||||
|
||||
function fmtDateDisplay(dateStr: string): string {
|
||||
const [_, m, d] = dateStr.split('-')
|
||||
return `${parseInt(m)}/${parseInt(d)}`
|
||||
}
|
||||
|
||||
function label(start: string, end: string, index: number): string {
|
||||
return `第${index}周(${fmtDateDisplay(start)}-${fmtDateDisplay(end)})`
|
||||
}
|
||||
|
||||
/**
|
||||
* 将周报 DOCX 文件以 OLE Package 方式嵌入月报 DOCX ZIP 包。
|
||||
* 在 </w:sectPr> 之前追加 OLE 对象段落。
|
||||
*/
|
||||
export async function embedWeeklyReports(
|
||||
monthlyDocxBuffer: Buffer,
|
||||
weeklyReports: WeeklyAttachment[],
|
||||
): Promise<Buffer> {
|
||||
const zip = await JSZip.loadAsync(monthlyDocxBuffer)
|
||||
|
||||
// 1. 注入周报文件到 word/embeddings/
|
||||
for (let i = 0; i < weeklyReports.length; i++) {
|
||||
zip.file(`word/embeddings/weekly_${i + 1}.docx`, weeklyReports[i].buffer)
|
||||
}
|
||||
|
||||
// 2. 注入 DOCX 图标(通用 Word 文档图标 EMF)
|
||||
const iconEmf = generateDocxIconEmf()
|
||||
zip.file('word/media/docx-icon.emf', iconEmf)
|
||||
|
||||
// 3. 更新 [Content_Types].xml
|
||||
await updateContentTypes(zip)
|
||||
|
||||
// 4. 更新 word/_rels/document.xml.rels
|
||||
await updateRels(zip, weeklyReports.length)
|
||||
|
||||
// 5. 更新 word/document.xml:在 </w:sectPr> 之前插入 OLE 段落
|
||||
await updateDocumentXml(zip, weeklyReports)
|
||||
|
||||
// 6. 重新打包
|
||||
return Buffer.from(await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }))
|
||||
}
|
||||
|
||||
async function updateContentTypes(zip: JSZip): Promise<void> {
|
||||
const ctFile = zip.file('[Content_Types].xml')
|
||||
if (!ctFile) throw new Error('[Content_Types].xml not found')
|
||||
|
||||
let xml = await ctFile.async('text')
|
||||
|
||||
// 确保 docx 扩展名已注册
|
||||
if (!xml.includes('Extension="docx"')) {
|
||||
xml = xml.replace(
|
||||
'</Types>',
|
||||
' <Default Extension="docx" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document"/>\n</Types>'
|
||||
)
|
||||
}
|
||||
|
||||
// 确保 emf 扩展名已注册
|
||||
if (!xml.includes('Extension="emf"')) {
|
||||
xml = xml.replace(
|
||||
'</Types>',
|
||||
' <Default Extension="emf" ContentType="image/x-emf"/>\n</Types>'
|
||||
)
|
||||
}
|
||||
|
||||
zip.file('[Content_Types].xml', xml)
|
||||
}
|
||||
|
||||
async function updateRels(zip: JSZip, count: number): Promise<void> {
|
||||
const relsFile = zip.file('word/_rels/document.xml.rels')
|
||||
if (!relsFile) throw new Error('word/_rels/document.xml.rels not found')
|
||||
|
||||
let xml = await relsFile.async('text')
|
||||
|
||||
const additions: string[] = []
|
||||
for (let i = 1; i <= count; i++) {
|
||||
additions.push(
|
||||
` <Relationship Id="rId_W${i}_pkg" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/package" Target="embeddings/weekly_${i}.docx"/>`
|
||||
)
|
||||
additions.push(
|
||||
` <Relationship Id="rId_W${i}_icon" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/docx-icon.emf"/>`
|
||||
)
|
||||
}
|
||||
|
||||
xml = xml.replace('</Relationships>', additions.join('\n') + '\n</Relationships>')
|
||||
zip.file('word/_rels/document.xml.rels', xml)
|
||||
}
|
||||
|
||||
async function updateDocumentXml(zip: JSZip, weeklyReports: WeeklyAttachment[]): Promise<void> {
|
||||
const docFile = zip.file('word/document.xml')
|
||||
if (!docFile) throw new Error('word/document.xml not found')
|
||||
|
||||
let xml = await docFile.async('text')
|
||||
|
||||
// 确保 document 元素有 v: 和 o: 命名空间声明
|
||||
if (!xml.includes('xmlns:v="urn:schemas-microsoft-com:vml"')) {
|
||||
xml = xml.replace(
|
||||
'<w:document ',
|
||||
'<w:document xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office" '
|
||||
)
|
||||
}
|
||||
|
||||
// 构建所有 OLE 段落
|
||||
const oleParagraphs = weeklyReports.map((wr, i) => {
|
||||
const n = i + 1
|
||||
return `<w:p><w:pPr><w:spacing w:before="100" w:after="100" w:line="360"/><w:ind w:firstLine="480"/></w:pPr><w:r><w:object w:dxaOrig="2880" w:dyaOrig="2880"><v:shape id="weekly_${n}" o:ole="" style="width:36pt;height:36pt"><v:imagedata r:id="rId_W${n}_icon" o:title=""/></v:shape><o:OLEObject Type="Embed" ProgID="Word.Document.12" ShapeID="weekly_${n}" DrawAspect="Icon" ObjectID="_w${n}" r:id="rId_W${n}_pkg"/></w:object></w:r><w:r><w:rPr><w:rFonts w:ascii="SimSun" w:hAnsi="SimSun"/><w:sz w:val="22"/></w:rPr><w:t>周报(${wr.label})</w:t></w:r></w:p>`
|
||||
})
|
||||
|
||||
// 在 </w:sectPr> 之前插入(文档中只出现一次,在末尾)
|
||||
const insertionPoint = xml.lastIndexOf('</w:sectPr>')
|
||||
if (insertionPoint === -1) throw new Error('</w:sectPr> not found in document.xml')
|
||||
|
||||
xml = xml.slice(0, insertionPoint) + oleParagraphs.join('') + xml.slice(insertionPoint)
|
||||
zip.file('word/document.xml', xml)
|
||||
}
|
||||
|
||||
/** 生成通用 DOCX 文件图标 EMF(32x32 像素,白色文档主体 + 蓝色标题栏) */
|
||||
function generateDocxIconEmf(): Buffer {
|
||||
// EMF 记录尺寸常量(单位:字节)
|
||||
const HDR_SIZE = 108 // EMR_HEADER
|
||||
const BRUSH_SIZE = 24 // EMR_CREATEBRUSHINDIRECT (ihBrush + LogBrushEx)
|
||||
const SELECT_SIZE = 12 // EMR_SELECTOBJECT (ihObject)
|
||||
const RECT_SIZE = 24 // EMR_RECTANGLE (rclBox: 16 bytes, no extra fields)
|
||||
const EOF_SIZE = 20 // EMR_EOF
|
||||
|
||||
// 总字节数和记录数
|
||||
const totalSize = HDR_SIZE
|
||||
+ BRUSH_SIZE + SELECT_SIZE + RECT_SIZE // white brush → body rect
|
||||
+ BRUSH_SIZE + SELECT_SIZE + RECT_SIZE // blue brush → title bar
|
||||
+ EOF_SIZE
|
||||
// 8 records: header + 6 GDI commands + EOF
|
||||
const nRecords = 8
|
||||
|
||||
const buf = Buffer.alloc(totalSize, 0)
|
||||
let o = 0
|
||||
|
||||
// ---- EMR_HEADER (108 bytes) ----
|
||||
buf.writeUInt32LE(1, o); o += 4 // iType = EMR_HEADER
|
||||
buf.writeUInt32LE(HDR_SIZE, o); o += 4 // nSize
|
||||
buf.writeInt32LE(0, o); o += 4 // rclBounds.left
|
||||
buf.writeInt32LE(0, o); o += 4 // rclBounds.top
|
||||
buf.writeInt32LE(32, o); o += 4 // rclBounds.right (device units, = szlDevice)
|
||||
buf.writeInt32LE(32, o); o += 4 // rclBounds.bottom
|
||||
buf.writeInt32LE(0, o); o += 4 // rclFrame.left
|
||||
buf.writeInt32LE(0, o); o += 4 // rclFrame.top
|
||||
buf.writeInt32LE(1100, o); o += 4 // rclFrame.right (0.01mm, = szlMillimeters*100)
|
||||
buf.writeInt32LE(1100, o); o += 4 // rclFrame.bottom
|
||||
buf.writeUInt32LE(0x464D4520, o); o += 4 // dSignature
|
||||
buf.writeUInt32LE(0x00010000, o); o += 4 // nVersion
|
||||
buf.writeUInt32LE(totalSize, o); o += 4 // nBytes
|
||||
buf.writeUInt32LE(nRecords, o); o += 4 // nRecords
|
||||
buf.writeUInt16LE(2, o); o += 2 // nHandles (2 brushes)
|
||||
buf.writeUInt16LE(0, o); o += 2 // sReserved
|
||||
buf.writeUInt32LE(0, o); o += 4 // nDescription
|
||||
buf.writeUInt32LE(0, o); o += 4 // offDescription
|
||||
buf.writeUInt32LE(0, o); o += 4 // nPalEntries
|
||||
buf.writeUInt32LE(32, o); o += 4 // szlDevice.cx
|
||||
buf.writeUInt32LE(32, o); o += 4 // szlDevice.cy
|
||||
buf.writeUInt32LE(11, o); o += 4 // szlMillimeters.cx
|
||||
buf.writeUInt32LE(11, o); o += 4 // szlMillimeters.cy
|
||||
buf.writeUInt32LE(0, o); o += 4 // cbPixelFormat
|
||||
buf.writeUInt32LE(0, o); o += 4 // offPixelFormat
|
||||
buf.writeUInt32LE(0, o); o += 4 // bOpenGL
|
||||
buf.writeUInt32LE(11000, o); o += 4 // szlMicrometers.cx
|
||||
buf.writeUInt32LE(11000, o); o += 4 // szlMicrometers.cy
|
||||
// o == 108
|
||||
|
||||
// ---- Record 1: EMR_CREATEBRUSHINDIRECT (white, ihBrush=1) ----
|
||||
buf.writeUInt32LE(39, o); o += 4 // iType
|
||||
buf.writeUInt32LE(BRUSH_SIZE, o); o += 4 // nSize
|
||||
buf.writeUInt32LE(1, o); o += 4 // ihBrush
|
||||
buf.writeUInt32LE(0, o); o += 4 // lbStyle = BS_SOLID
|
||||
buf.writeUInt32LE(0x00FFFFFF, o); o += 4 // lbColor = white
|
||||
buf.writeUInt32LE(0, o); o += 4 // lbHatch
|
||||
|
||||
// ---- Record 2: EMR_SELECTOBJECT (handle 1) ----
|
||||
buf.writeUInt32LE(37, o); o += 4 // iType
|
||||
buf.writeUInt32LE(SELECT_SIZE, o); o += 4 // nSize
|
||||
buf.writeUInt32LE(1, o); o += 4 // ihObject
|
||||
|
||||
// ---- Record 3: EMR_RECTANGLE (document body: 3,5 ~ 29,29) ----
|
||||
buf.writeUInt32LE(43, o); o += 4 // iType
|
||||
buf.writeUInt32LE(RECT_SIZE, o); o += 4 // nSize = 24
|
||||
buf.writeInt32LE(3, o); o += 4 // left
|
||||
buf.writeInt32LE(5, o); o += 4 // top
|
||||
buf.writeInt32LE(29, o); o += 4 // right
|
||||
buf.writeInt32LE(29, o); o += 4 // bottom
|
||||
|
||||
// ---- Record 4: EMR_CREATEBRUSHINDIRECT (blue #4472C4, ihBrush=2) ----
|
||||
buf.writeUInt32LE(39, o); o += 4
|
||||
buf.writeUInt32LE(BRUSH_SIZE, o); o += 4
|
||||
buf.writeUInt32LE(2, o); o += 4
|
||||
buf.writeUInt32LE(0, o); o += 4 // BS_SOLID
|
||||
buf.writeUInt32LE(0x00C47244, o); o += 4 // BGR: 4472C4
|
||||
buf.writeUInt32LE(0, o); o += 4
|
||||
|
||||
// ---- Record 5: EMR_SELECTOBJECT (handle 2) ----
|
||||
buf.writeUInt32LE(37, o); o += 4
|
||||
buf.writeUInt32LE(SELECT_SIZE, o); o += 4
|
||||
buf.writeUInt32LE(2, o); o += 4
|
||||
|
||||
// ---- Record 6: EMR_RECTANGLE (blue title bar: 3,5 ~ 29,12) ----
|
||||
buf.writeUInt32LE(43, o); o += 4
|
||||
buf.writeUInt32LE(RECT_SIZE, o); o += 4
|
||||
buf.writeInt32LE(3, o); o += 4
|
||||
buf.writeInt32LE(5, o); o += 4
|
||||
buf.writeInt32LE(29, o); o += 4
|
||||
buf.writeInt32LE(12, o); o += 4
|
||||
|
||||
// ---- Record 7: EMR_EOF ----
|
||||
buf.writeUInt32LE(14, o); o += 4 // iType
|
||||
buf.writeUInt32LE(EOF_SIZE, o); o += 4 // nSize = 20
|
||||
buf.writeUInt32LE(0, o); o += 4 // nPalEntries
|
||||
buf.writeUInt32LE(0, o); o += 4 // offLast → 0 (first/only EMR_EOF per MS-EMF 2.3.5.2)
|
||||
buf.writeUInt32LE(EOF_SIZE, o); o += 4 // nSizeLast (MUST equal nSize)
|
||||
|
||||
return buf
|
||||
}
|
||||
|
|
@ -186,7 +186,8 @@ export function buildWeeklyMetadata(data: WeeklyReportData): string {
|
|||
? Math.round(durations.reduce((s, d) => s + d, 0) / durations.length)
|
||||
: 0
|
||||
|
||||
const ongoingCount = data.pendingCount
|
||||
const allTickets = [...data.gpuFaultTickets, ...data.storageFaultTickets, ...data.otherTickets]
|
||||
const ongoingCount = allTickets.filter(t => !t.isResolved).length
|
||||
|
||||
const faultTicketCount = data.totalFaultCount
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue