diff --git a/CHANGELOG.md b/CHANGELOG.md index 68e576c..65cda11 100644 --- a/CHANGELOG.md +++ b/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 个) diff --git a/CLAUDE.md b/CLAUDE.md index dcfdb00..54fdd63 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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')}` --- diff --git a/README.md b/README.md index 0576d5a..716b35d 100644 --- a/README.md +++ b/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次<故障子类>,故障节点为,<恢复描述>恢复。` -- 恢复描述:`assign_date` 与 `close_date` 天数差,0 → 当日,1 → 次日,≥2 → N日后 +- 每条格式:`X月X日发生1次<故障子类>,故障节点为,<恢复描述>。` +- 恢复描述: + - 已结单:`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 判定规则 diff --git a/package-lock.json b/package-lock.json index ff53305..6304c15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 8aae1f9..b4e40d9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/import-steps.ts b/scripts/import-steps.ts index dd26811..da04769 100644 --- a/scripts/import-steps.ts +++ b/scripts/import-steps.ts @@ -199,6 +199,10 @@ function parseFaultSheet(data: (string | number | null)[][]): { // 主逻辑 // --------------------------------------------------------------------------- function main() { + if (!excelPath) { + console.error('用法: npx tsx scripts/import-steps.ts [dbPath]') + process.exit(1) + } if (!fs.existsSync(dbPath)) { console.error(`数据库文件不存在: ${dbPath}`) process.exit(1) diff --git a/src/components/ui/index.tsx b/src/components/ui/index.tsx index 70a3db2..331242e 100644 --- a/src/components/ui/index.tsx +++ b/src/components/ui/index.tsx @@ -1,12 +1,12 @@ 'use client' import { ButtonHTMLAttributes, InputHTMLAttributes, SelectHTMLAttributes, ReactNode } from 'react' -interface ButtonProps extends ButtonHTMLAttributes { variant?: 'primary' | 'secondary' | 'danger' | 'ghost'; size?: 'sm' | 'md' | 'lg' } -export function Button({ variant = 'primary', size = 'md', className = '', children, ...props }: ButtonProps) { +interface ButtonProps extends ButtonHTMLAttributes { 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 + return } interface InputProps extends InputHTMLAttributes { label?: string; error?: string } diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 2e230ed..a8b9e8b 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -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) diff --git a/src/lib/monthly-report-docx.ts b/src/lib/monthly-report-docx.ts index 5ca0b18..6a705cc 100644 --- a/src/lib/monthly-report-docx.ts +++ b/src/lib/monthly-report-docx.ts @@ -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 { - 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: [{ diff --git a/src/lib/monthly-report.ts b/src/lib/monthly-report.ts index a143077..ece6b2a 100644 --- a/src/lib/monthly-report.ts +++ b/src/lib/monthly-report.ts @@ -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() 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() + 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 diff --git a/src/lib/ole-embed.ts b/src/lib/ole-embed.ts new file mode 100644 index 0000000..9a8a274 --- /dev/null +++ b/src/lib/ole-embed.ts @@ -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 包。 + * 在 之前追加 OLE 对象段落。 + */ +export async function embedWeeklyReports( + monthlyDocxBuffer: Buffer, + weeklyReports: WeeklyAttachment[], +): Promise { + 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:在 之前插入 OLE 段落 + await updateDocumentXml(zip, weeklyReports) + + // 6. 重新打包 + return Buffer.from(await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' })) +} + +async function updateContentTypes(zip: JSZip): Promise { + 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( + '', + ' \n' + ) + } + + // 确保 emf 扩展名已注册 + if (!xml.includes('Extension="emf"')) { + xml = xml.replace( + '', + ' \n' + ) + } + + zip.file('[Content_Types].xml', xml) +} + +async function updateRels(zip: JSZip, count: number): Promise { + 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( + ` ` + ) + additions.push( + ` ` + ) + } + + xml = xml.replace('', additions.join('\n') + '\n') + zip.file('word/_rels/document.xml.rels', xml) +} + +async function updateDocumentXml(zip: JSZip, weeklyReports: WeeklyAttachment[]): Promise { + 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( + ' { + const n = i + 1 + return `周报(${wr.label})` + }) + + // 在 之前插入(文档中只出现一次,在末尾) + const insertionPoint = xml.lastIndexOf('') + if (insertionPoint === -1) throw new Error(' 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 +} diff --git a/src/lib/weekly-report.ts b/src/lib/weekly-report.ts index 23baad8..da3b924 100644 --- a/src/lib/weekly-report.ts +++ b/src/lib/weekly-report.ts @@ -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