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:
gitadmin 2026-05-07 21:45:14 +08:00
parent a5f19ebeda
commit 5c94719693
12 changed files with 411 additions and 68 deletions

View File

@ -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 镜像安装 echartsDockerfile 补全 Chromium 系统依赖库libglib2.0、libnss3 等 18 个)

View File

@ -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')}`
---

View File

@ -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_typegpu / 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 判定规则

1
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

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

View File

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

View File

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

View File

@ -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: [{

View File

@ -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

283
src/lib/ole-embed.ts Normal file
View File

@ -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 文件图标 EMF32x32 像素,白色文档主体 + 蓝色标题栏) */
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
}

View File

@ -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