diff --git a/.gitignore b/.gitignore index a65c919..7a7ccde 100644 --- a/.gitignore +++ b/.gitignore @@ -11,7 +11,7 @@ build/ .claude/ data/ uploads/ -reports/ +/reports/ docs/ db-backups/ .playwright-mcp/ diff --git a/src/app/(app)/reports/[id]/page.tsx b/src/app/(app)/reports/[id]/page.tsx new file mode 100644 index 0000000..9b0d5f1 --- /dev/null +++ b/src/app/(app)/reports/[id]/page.tsx @@ -0,0 +1,408 @@ +'use client' +import { useState, useEffect } from 'react' +import { useParams, useRouter } from 'next/navigation' +import { Card, Button } from '@/components/ui' +import { Download, ArrowLeft, FileText, RefreshCw } from 'lucide-react' +import Link from 'next/link' + +interface Report { + id: number + report_type: string + period_start: string | null + period_end: string | null + format: string + status: string + created_at: string + file_name?: string | null + file_path?: string | null + error_message?: string | null + metadata?: string | null +} + +const typeLabel: Record = { + weekly: '周报', + monthly: '月报', +} + +export default function ReportDetailPage() { + const params = useParams() + const router = useRouter() + const [report, setReport] = useState(null) + const [reportData, setReportData] = useState(null) + const [loading, setLoading] = useState(true) + const [generating, setGenerating] = useState(false) + + const fetchReport = () => { + fetch(`/api/reports/${params.id}`) + .then(r => r.json()) + .then(d => { + if (d.report) setReport(d.report) + if (d.reportData) setReportData(d.reportData) + }) + .catch(() => {}) + .finally(() => setLoading(false)) + } + + useEffect(() => { fetchReport() }, [params.id]) + + const handleGenerate = async () => { + if (!report) return + setGenerating(true) + try { + const res = await fetch(`/api/reports/${report.id}/generate`, { method: 'POST' }) + if (res.ok) { + const data = await res.json() + setReport(data.report) + pollUntilDone() + } else { + const data = await res.json().catch(() => ({ error: '生成失败' })) + alert(data.error || '生成失败') + setGenerating(false) + } + } catch { + alert('生成失败') + setGenerating(false) + } + } + + const pollUntilDone = async () => { + if (!report) return + const start = Date.now() + while (Date.now() - start < 120000) { + await new Promise(r => setTimeout(r, 3000)) + const res = await fetch(`/api/reports/${report.id}`) + if (!res.ok) continue + const data = await res.json() + const rpt = data.report + setReport(rpt) + if (rpt?.status === 'completed' || rpt?.status === 'failed') { + setGenerating(false) + if (data.reportData) setReportData(data.reportData) + return + } + } + setGenerating(false) + } + + const renderRightButton = () => { + if (!report) return null + + switch (report.status) { + case 'ready': + return ( + + ) + case 'generating': + return ( + + ) + case 'completed': + return ( + + ) + case 'failed': + return ( + + ) + default: + return null + } + } + + const isNewFormat = reportData?.gpuCount !== undefined + + if (loading) return
加载中...
+ if (!report) return
报告不存在
+ + return ( +
+ {/* Header */} +
+
+ + + +
+

+ {typeLabel[report.report_type] || report.report_type} +

+

+ {report.period_start} ~ {report.period_end} +

+
+ {report.status === 'ready' && ( + + 数据已就绪 + + )} + {report.status === 'generating' && ( + + 文档生成中 + + )} + {report.status === 'completed' && ( + + 已完成 + + )} + {report.status === 'failed' && ( + + 生成失败 + + )} + {report.status === 'pending' && ( + + 待生成 + + )} +
+ {renderRightButton()} +
+ + {report.status === 'failed' && report.error_message && ( + +

{report.error_message}

+
+ )} + + {/* ===== 旧格式兼容 ===== */} + {!isNewFormat && reportData?.summary && ( +
+
+ +

工单总数

+

{reportData.summary.total_tickets}

+
+ +

已解决

+

{reportData.summary.resolved_tickets}

+
+ +

平均处理时长

+

{reportData.summary.avg_duration} 分钟

+
+ +

SLA 达标率

+

= 90 ? 'text-emerald-500' : 'text-amber-500'}`}>{reportData.summary.sla_rate}%

+
+
+ {reportData.categories && reportData.categories.length > 0 && ( + +

故障分类

+
+ {reportData.categories.map((c: any, i: number) => ( +
+ {c.fault_category || '未分类'} + {c.count} 件 +
+ ))} +
+
+ )} +
+ )} + + {/* ===== 新格式 ===== */} + {isNewFormat && reportData && ( +
+ {/* KPI Cards (5列) */} +
+ +

工单总数

+

{reportData.totalTickets}

+
+ +

故障已解决

+

{reportData.resolvedCount}

+
+ +

整体可用性

+

= 99 ? 'text-emerald-500' : 'text-amber-500'}`}> + {reportData.avgAvailability != null ? `${reportData.avgAvailability}%` : '-'} +

+

+ {reportData.avgAvailability != null && reportData.avgAvailability < 99 ? '低于99%' : ''} +

+
+ +

平均处理时长

+

{reportData.avgDurationMinutes}

+

分钟

+
+ +

进行中

+

{reportData.ongoingCount}

+
+
+ + {/* Chapter 1: 设备概况 */} + +
+ 1 + 设备概况 +
+
+
+
🖥
+
+ {reportData.gpuCount} + 台 GPU 服务器 +
+
+
+
🗄
+
+ {reportData.storageCount} + 台 存储服务器 +
+
+
+
+ + {/* Chapter 2: 运营数据 */} + +
+ 2 + 运营数据 +
+
+
+ 故障工单 {reportData.faultTicketCount} 件 +
+
+ 涉及设备 {reportData.affectedDeviceCount} 台 +
+ {reportData.faultFreeDays != null && ( +
+ 无故障天数 {reportData.faultFreeDays} 天 +
+ )} +
+
+ + {/* Chapter 3: 故障分类 */} + +
+ 3 + 故障分类 +
+
+ {(() => { + const ch3Total = reportData.gpuFaultCount + reportData.storageFaultCount + reportData.otherTicketCount + return ( + <> +
+

{reportData.gpuFaultCount}

+

GPU 故障

+
+
0 ? Math.round(reportData.gpuFaultCount / ch3Total * 100) : 0}%` }} /> +
+

+ {ch3Total > 0 ? `${Math.round(reportData.gpuFaultCount / ch3Total * 100)}%` : '—'} +

+
+
+

{reportData.storageFaultCount}

+

存储故障

+
+
0 ? Math.round(reportData.storageFaultCount / ch3Total * 100) : 0}%` }} /> +
+

+ {ch3Total > 0 ? `${Math.round(reportData.storageFaultCount / ch3Total * 100)}%` : '—'} +

+
+
+

{reportData.otherTicketCount}

+

其他工单

+
+
0 ? Math.round(reportData.otherTicketCount / ch3Total * 100) : 0}%` }} /> +
+

+ {ch3Total > 0 ? `${Math.round(reportData.otherTicketCount / ch3Total * 100)}%` : '—'} +

+
+ + ) + })()} +
+ + + {/* Chapter 4: 服务可用性 */} + +
+ 4 + 服务可用性 +
+
+
+ 整体可用性: + = 99 ? 'text-emerald-500' : 'text-amber-500'}`}> + {reportData.avgAvailability != null ? `${reportData.avgAvailability}%` : '-'} + +
+ + {reportData.availabilityDetails && reportData.availabilityDetails.length > 0 && ( + <> + + + + + + + + + + + + {reportData.availabilityDetails.map((item: any, i: number) => ( + + + + + + + + ))} + +
IP 地址设备类型故障时长可用性状态
{item.ip} + {item.deviceType === 'gpu' ? 'GPU' : item.deviceType === 'storage' ? '存储' : '其他'} + + {item.durationMinutes.toLocaleString()} 分钟 + + {item.availabilityPercent}% + + {item.isOngoing ? ( + 进行中 + ) : ( + 已恢复 + )} +
+

+ 共 {reportData.availabilityDetails.length} 个 IP 可用性低于 100%,其余设备保持 100% 在线 +

+ + )} + + {(!reportData.availabilityDetails || reportData.availabilityDetails.length === 0) && ( +

+ {report.report_type === 'weekly' + ? '周报不展示 IP 明细,请查看下载的 DOCX 文档获取完整可用性数据。' + : '所有设备保持 100% 在线'} +

+ )} +
+
+
+ )} +
+ ) +} diff --git a/src/app/(app)/reports/page.tsx b/src/app/(app)/reports/page.tsx new file mode 100644 index 0000000..dc09073 --- /dev/null +++ b/src/app/(app)/reports/page.tsx @@ -0,0 +1,506 @@ +'use client' +import { useState, useEffect, useCallback } from 'react' +import { useRouter } from 'next/navigation' +import { Card, Button, Badge, Toast, Modal } from '@/components/ui' +import { Plus, Eye, Download, Trash2, Archive, RefreshCw } from 'lucide-react' + +interface Report { + id: number + report_type: string + period_start: string | null + period_end: string | null + format: string + status: string + created_at: string + file_name?: string | null + metadata?: string | null +} + +const statusVariant: Record = { + pending: 'info', + ready: 'warning', + generating: 'info', + completed: 'success', + failed: 'danger', +} + +const statusLabel: Record = { + pending: '待生成', + ready: '数据已就绪', + generating: '文档生成中', + completed: '已完成', + failed: '生成失败', +} + +const typeLabel: Record = { + weekly: '周报', + monthly: '月报', +} + +function getReportName(r: Report): string { + if (r.metadata) { + try { + const meta = JSON.parse(r.metadata) + if (meta.reportLabel) return meta.reportLabel + } catch { /* ignore */ } + } + if (r.file_name) return r.file_name + return `${r.period_start || '?'} ~ ${r.period_end || '?'} ${typeLabel[r.report_type] || r.report_type}` +} + +function lastDayOfMonth(y: number, m: number) { + return new Date(y, m, 0).getDate() +} + +function pad(n: number) { return String(n).padStart(2, '0') } + +function getLastWeekDates() { + const today = new Date() + const dayOfWeek = today.getDay() + const daysSinceMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1 + const thisMonday = new Date(today) + thisMonday.setDate(today.getDate() - daysSinceMonday) + const lastMonday = new Date(thisMonday) + lastMonday.setDate(thisMonday.getDate() - 7) + const lastSunday = new Date(lastMonday) + lastSunday.setDate(lastMonday.getDate() + 6) + return { + start: lastMonday.toISOString().split('T')[0], + end: lastSunday.toISOString().split('T')[0], + } +} + +export default function ReportsPage() { + const router = useRouter() + const [reports, setReports] = useState([]) + const [loading, setLoading] = useState(true) + const [showCreate, setShowCreate] = useState(false) + const [reportType, setReportType] = useState<'monthly' | 'weekly'>('monthly') + const [month, setMonth] = useState('') + const [weekStart, setWeekStart] = useState('') + const [weekEnd, setWeekEnd] = useState('') + const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' | 'info' } | null>(null) + const [deleteTarget, setDeleteTarget] = useState(null) + const [selectedIds, setSelectedIds] = useState>(new Set()) + const [batchDeleteOpen, setBatchDeleteOpen] = useState(false) + const [generatingIds, setGeneratingIds] = useState>(new Set()) + + const toggleSelect = (id: number) => { + setSelectedIds(prev => { + const next = new Set(prev) + if (next.has(id)) { next.delete(id) } else { next.add(id) } + return next + }) + } + + const toggleSelectAll = () => { + const selectable = reports.filter(r => + r.status === 'completed' || r.status === 'failed' || r.status === 'ready' + ) + const allIds = selectable.map(r => r.id) + setSelectedIds(prev => + prev.size === allIds.length && allIds.length > 0 ? new Set() : new Set(allIds) + ) + } + + const fetchReports = useCallback(() => { + fetch('/api/reports') + .then(r => r.json()) + .then(d => { if (d.reports) setReports(d.reports) }) + .catch(() => {}) + .finally(() => setLoading(false)) + }, []) + + useEffect(() => { fetchReports() }, [fetchReports]) + + useEffect(() => { + if (!month) { + const now = new Date() + let lastMonth = now.getMonth() - 1 + let lastMonthYear = now.getFullYear() + if (lastMonth < 0) { lastMonth = 11; lastMonthYear-- } + setMonth(`${lastMonthYear}-${pad(lastMonth + 1)}`) + } + }, []) + + const handleCreate = async () => { + let period_start = '' + let period_end = '' + + if (reportType === 'monthly') { + if (!month) return + const [y, m] = month.split('-').map(Number) + period_start = `${y}-${pad(m)}-01` + period_end = `${y}-${pad(m)}-${pad(lastDayOfMonth(y, m))}` + } else { + if (!weekStart || !weekEnd) return + period_start = weekStart + period_end = weekEnd + } + + const res = await fetch('/api/reports', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ report_type: reportType, period_start, period_end }), + }) + if (res.ok) { + const data = await res.json() + setShowCreate(false) + setToast({ message: '数据采集完成,正在跳转预览页...', type: 'success' }) + router.push(`/reports/${data.report.id}`) + } else { + const data = await res.json().catch(() => ({ error: '创建失败' })) + setToast({ message: data.error || '创建失败', type: 'error' }) + } + } + + const handleDelete = async () => { + if (!deleteTarget) return + const res = await fetch(`/api/reports/${deleteTarget.id}`, { method: 'DELETE' }) + if (res.ok) { + setReports(prev => prev.filter(r => r.id !== deleteTarget.id)) + setToast({ message: '已删除', type: 'success' }) + } else { + const data = await res.json().catch(() => ({ error: '删除失败' })) + setToast({ message: data.error || '删除失败', type: 'error' }) + } + setDeleteTarget(null) + } + + const handleBatchDelete = async () => { + const ids = Array.from(selectedIds) + if (ids.length === 0) return + const res = await fetch('/api/reports', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ids }), + }) + if (res.ok) { + setReports(prev => prev.filter(r => !selectedIds.has(r.id))) + setSelectedIds(new Set()) + setToast({ message: `已删除 ${ids.length} 条报告`, type: 'success' }) + } else { + const data = await res.json().catch(() => ({ error: '批量删除失败' })) + setToast({ message: data.error || '批量删除失败', type: 'error' }) + } + setBatchDeleteOpen(false) + } + + const handleBatchDownload = async () => { + const ids = Array.from(selectedIds) + if (ids.length === 0) return + try { + const res = await fetch('/api/reports/download', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ids }), + }) + if (res.ok) { + const blob = await res.blob() + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `reports_${new Date().toISOString().slice(0, 10)}.zip` + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + setToast({ message: `已下载 ${ids.length} 条报告`, type: 'success' }) + } else { + const data = await res.json().catch(() => ({ error: '下载失败' })) + setToast({ message: data.error || '下载失败', type: 'error' }) + } + } catch { + setToast({ message: '下载失败', type: 'error' }) + } + } + + const handleGenerate = async (reportId: number) => { + setGeneratingIds(prev => new Set(prev).add(reportId)) + try { + const res = await fetch(`/api/reports/${reportId}/generate`, { method: 'POST' }) + if (res.ok) { + setToast({ message: '文档生成中...', type: 'info' }) + pollUntilDone(reportId) + } else { + const data = await res.json().catch(() => ({ error: '生成失败' })) + setToast({ message: data.error || '生成失败', type: 'error' }) + setGeneratingIds(prev => { const n = new Set(prev); n.delete(reportId); return n }) + } + } catch { + setToast({ message: '生成失败', type: 'error' }) + setGeneratingIds(prev => { const n = new Set(prev); n.delete(reportId); return n }) + } + } + + const pollUntilDone = async (reportId: number) => { + const start = Date.now() + while (Date.now() - start < 120000) { + await new Promise(r => setTimeout(r, 3000)) + const res = await fetch(`/api/reports/${reportId}`) + if (!res.ok) continue + const data = await res.json() + const status = data.report?.status + if (status === 'completed') { + setToast({ message: '报告生成完成', type: 'success' }) + fetchReports() + setGeneratingIds(prev => { const n = new Set(prev); n.delete(reportId); return n }) + return + } + if (status === 'failed') { + setToast({ message: data.report?.error_message || '报告生成失败', type: 'error' }) + fetchReports() + setGeneratingIds(prev => { const n = new Set(prev); n.delete(reportId); return n }) + return + } + } + setToast({ message: '报告生成超时,请手动刷新', type: 'error' }) + fetchReports() + setGeneratingIds(prev => { const n = new Set(prev); n.delete(reportId); return n }) + } + + const getActionButtons = (r: Report) => { + const isGenerating = generatingIds.has(r.id) + + switch (r.status) { + case 'ready': + return ( +
+ + + +
+ ) + case 'generating': + return ( +
+ +
+ ) + case 'completed': + return ( +
+ + + +
+ ) + case 'failed': + return ( +
+ + + +
+ ) + default: + return ( +
+ +
+ ) + } + } + + const renderStatusBadge = (status: string) => { + if (status === 'generating') { + return ( + + + 文档生成中 + + ) + } + + if (status === 'ready') { + return ( + + + 数据已就绪 + + ) + } + + return {statusLabel[status] || status} + } + + return ( +
+
+
+

报告管理

+

数据预览与文档生成

+
+
+ {selectedIds.size > 0 && ( + <> + + + + )} + +
+
+ + {showCreate && ( + +

新建报告

+ +
+ + +
+ + {reportType === 'monthly' ? ( +
+ + setMonth(e.target.value)} /> +

月报统计当月工单(以结单时间为准),默认统计该月1日至最后一日。

+
+ ) : ( +
+
+
+ + setWeekStart(e.target.value)} /> +
+
+ + setWeekEnd(e.target.value)} /> +
+
+

周报统计当周工单(周一到周日),含处理中和已结单工单。

+
+ )} + +
+ + +
+
+ )} + + {loading ? ( +
加载中...
+ ) : reports.length === 0 ? ( +
暂无报告
+ ) : ( +
+ + + + + + + + + + + + + + {reports.map(r => ( + + + + + + + + + + ))} + +
+ r.status === 'completed' || r.status === 'failed' || r.status === 'ready').length > 0 && + selectedIds.size === reports.filter(r => r.status === 'completed' || r.status === 'failed' || r.status === 'ready').length + } + onChange={toggleSelectAll} + className="w-4 h-4 rounded border-slate-300 dark:border-slate-600 text-blue-600 focus:ring-blue-500" + /> + 报告名称类型时间段状态创建时间操作
+ toggleSelect(r.id)} + className="w-4 h-4 rounded border-slate-300 dark:border-slate-600 text-blue-600 focus:ring-blue-500" + /> + + { e.preventDefault(); router.push(`/reports/${r.id}`) }} + className="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 font-medium truncate block" + > + {getReportName(r)} + + + {r.report_type === 'monthly' ? ( + 月报 + ) : ( + 周报 + )} + {r.period_start} ~ {r.period_end}{renderStatusBadge(r.status)}{new Date(r.created_at).toLocaleString('zh-CN')}{getActionButtons(r)}
+
+ )} + + setDeleteTarget(null)} title="确认删除"> +

+ 确定删除此报告吗?将同时删除报告文件和数据库记录,此操作不可撤销。 +

+
+ + +
+
+ + setBatchDeleteOpen(false)} title="确认批量删除"> +

+ 确定删除选中的 {selectedIds.size} 条报告吗?将同时删除报告文件和数据库记录,此操作不可撤销。 +

+
+ + +
+
+ + {toast && setToast(null)} />} +
+ ) +} diff --git a/src/app/api/reports/[id]/download/route.ts b/src/app/api/reports/[id]/download/route.ts new file mode 100644 index 0000000..ae0e40b --- /dev/null +++ b/src/app/api/reports/[id]/download/route.ts @@ -0,0 +1,41 @@ +import { NextResponse } from 'next/server' +import { getDb } from '@/lib/db' +import { initDatabase } from '@/lib/db-schema' +import { getCurrentUser } from '@/lib/auth' +import fs from 'fs' + +export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) { + try { + initDatabase() + const user = await getCurrentUser() + if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 }) + + const { id } = await params + const db = getDb() + const report = db.prepare('SELECT * FROM reports WHERE id = ?').get(id) as any + if (!report) return NextResponse.json({ error: '报告不存在' }, { status: 404 }) + + if (report.status !== 'completed' || !report.file_path) { + return NextResponse.json({ error: '报告尚未生成完成' }, { status: 400 }) + } + + if (!fs.existsSync(report.file_path)) { + return NextResponse.json({ error: '报告文件不存在' }, { status: 404 }) + } + + const buffer = fs.readFileSync(report.file_path) + const contentType = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + + const downloadName = report.file_name || `report_${id}.docx` + const encodedName = encodeURIComponent(downloadName) + return new NextResponse(new Uint8Array(buffer), { + headers: { + 'Content-Type': contentType, + 'Content-Disposition': `attachment; filename*=UTF-8''${encodedName}`, + }, + }) + } catch (e) { + const msg = e instanceof Error ? e.message : '下载失败' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} diff --git a/src/app/api/reports/[id]/generate/route.ts b/src/app/api/reports/[id]/generate/route.ts new file mode 100644 index 0000000..068ff90 --- /dev/null +++ b/src/app/api/reports/[id]/generate/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getDb } from '@/lib/db' +import { initDatabase } from '@/lib/db-schema' +import { getCurrentUser } from '@/lib/auth' +import { hasPermission } from '@/lib/permissions' +import { generateMonthlyReport } from '@/lib/monthly-report' +import { generateWeeklyReport } from '@/lib/weekly-report' + +export async function POST( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + initDatabase() + const user = await getCurrentUser() + if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 }) + if (!hasPermission(user, 'reports:write')) { + return NextResponse.json({ error: '权限不足' }, { status: 403 }) + } + + const { id } = await params + const db = getDb() + const report = db.prepare('SELECT * FROM reports WHERE id = ?').get(id) as any + + if (!report) { + return NextResponse.json({ error: '报告不存在' }, { status: 404 }) + } + + if (report.status !== 'ready' && report.status !== 'failed') { + return NextResponse.json( + { error: `当前状态 "${report.status}" 不允许生成文档` }, + { status: 409 } + ) + } + + db.prepare("UPDATE reports SET status = 'generating', error_message = NULL WHERE id = ?") + .run(id) + + if (report.report_type === 'weekly') { + generateWeeklyReport(Number(id)).catch(err => { + console.error(`Weekly report generation failed for report ${id}:`, err) + }) + } else { + generateMonthlyReport(Number(id)).catch(err => { + console.error(`Report generation failed for report ${id}:`, err) + }) + } + + const updated = db.prepare('SELECT * FROM reports WHERE id = ?').get(id) + return NextResponse.json({ report: updated }) + } catch (e) { + const msg = e instanceof Error ? e.message : '生成失败' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} diff --git a/src/app/api/reports/[id]/route.ts b/src/app/api/reports/[id]/route.ts new file mode 100644 index 0000000..1ac6f31 --- /dev/null +++ b/src/app/api/reports/[id]/route.ts @@ -0,0 +1,94 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getDb } from '@/lib/db' +import { initDatabase } from '@/lib/db-schema' +import { getCurrentUser } from '@/lib/auth' +import fs from 'fs' + +export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) { + try { + initDatabase() + const user = await getCurrentUser() + if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 }) + + const { id } = await params + const db = getDb() + const report = db.prepare('SELECT * FROM reports WHERE id = ?').get(id) + if (!report) return NextResponse.json({ error: '报告不存在' }, { status: 404 }) + + const r = report as any + let reportData: any = null + + // 优先解析 metadata JSON + if (r.metadata) { + try { + reportData = JSON.parse(r.metadata) + } catch { /* ignore parse errors */ } + } + + // 如果 metadata 为空(旧报告),回退到简单查询 + if (!reportData && r.period_start && r.period_end) { + const tickets = db.prepare( + 'SELECT * FROM tickets WHERE assign_time >= ? AND assign_time <= ? ORDER BY assign_time' + ).all(r.period_start, r.period_end + ' 23:59:59') + + const total = tickets.length + const resolved = (tickets as any[]).filter( + t => t.current_status === 'resolved' || t.current_status === 'closed' + ).length + const avgDur = db.prepare( + "SELECT AVG(duration_minutes) as avg FROM tickets WHERE assign_time >= ? AND assign_time <= ? AND duration_minutes IS NOT NULL" + ).get(r.period_start, r.period_end + ' 23:59:59') as any + + const slaPass = (tickets as any[]).filter( + t => t.counted_in_sla === 1 && ['resolved', 'closed'].includes(t.current_status) + ).length + + const categories = db.prepare(` + SELECT fault_category, COUNT(*) as count FROM tickets + WHERE assign_time >= ? AND assign_time <= ? AND fault_category IS NOT NULL + GROUP BY fault_category ORDER BY count DESC + `).all(r.period_start, r.period_end + ' 23:59:59') + + reportData = { + summary: { + total_tickets: total, + resolved_tickets: resolved, + avg_duration: Math.round(avgDur?.avg || 0), + sla_rate: resolved > 0 ? Math.round((slaPass / resolved) * 100) : 0, + }, + categories, + } + } + + return NextResponse.json({ report, reportData }) + } catch (e) { + const msg = e instanceof Error ? e.message : '查询失败' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} + +export async function DELETE(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + initDatabase() + const user = await getCurrentUser() + if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 }) + + const { id } = await params + const db = getDb() + const report = db.prepare('SELECT * FROM reports WHERE id = ?').get(id) as any + if (!report) return NextResponse.json({ error: '报告不存在' }, { status: 404 }) + + // 删除磁盘文件 + if (report.file_path && fs.existsSync(report.file_path)) { + try { fs.unlinkSync(report.file_path) } catch { /* 文件删除失败不影响数据库操作 */ } + } + + // 删除数据库记录 + db.prepare('DELETE FROM reports WHERE id = ?').run(id) + + return NextResponse.json({ success: true }) + } catch (e) { + const msg = e instanceof Error ? e.message : '删除失败' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} diff --git a/src/app/api/reports/download/route.ts b/src/app/api/reports/download/route.ts new file mode 100644 index 0000000..d704091 --- /dev/null +++ b/src/app/api/reports/download/route.ts @@ -0,0 +1,57 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getDb } from '@/lib/db' +import { initDatabase } from '@/lib/db-schema' +import { getCurrentUser } from '@/lib/auth' +import JSZip from 'jszip' +import fs from 'fs' + +export async function POST(request: NextRequest) { + try { + initDatabase() + const user = await getCurrentUser() + if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 }) + + const { ids } = await request.json() + if (!Array.isArray(ids) || ids.length === 0) { + return NextResponse.json({ error: '缺少 ids 参数' }, { status: 400 }) + } + + const db = getDb() + const reports = db.prepare( + `SELECT * FROM reports WHERE id IN (${ids.map(() => '?').join(',')})` + ).all(...ids) as any[] + + if (reports.length === 0) { + return NextResponse.json({ error: '未找到选中的报告' }, { status: 404 }) + } + + const zip = new JSZip() + + for (const r of reports) { + if (r.status === 'completed' && r.file_path && fs.existsSync(r.file_path)) { + const buffer = fs.readFileSync(r.file_path) + const fileName = r.file_name || `report_${r.id}.docx` + zip.file(fileName, buffer) + } + } + + if (Object.keys(zip.files).length === 0) { + return NextResponse.json({ error: '没有可下载的报告文件' }, { status: 400 }) + } + + const zipBuffer = await zip.generateAsync({ type: 'nodebuffer' }) + + const downloadName = `reports_${new Date().toISOString().slice(0, 10)}.zip` + const encodedName = encodeURIComponent(downloadName) + + return new NextResponse(new Uint8Array(zipBuffer), { + headers: { + 'Content-Type': 'application/zip', + 'Content-Disposition': `attachment; filename*=UTF-8''${encodedName}`, + }, + }) + } catch (e) { + const msg = e instanceof Error ? e.message : '批量下载失败' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} diff --git a/src/app/api/reports/route.ts b/src/app/api/reports/route.ts new file mode 100644 index 0000000..34b015a --- /dev/null +++ b/src/app/api/reports/route.ts @@ -0,0 +1,101 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getDb } from '@/lib/db' +import { initDatabase } from '@/lib/db-schema' +import { getCurrentUser } from '@/lib/auth' +import { hasPermission } from '@/lib/permissions' +import fs from 'fs' + +export async function GET() { + try { + initDatabase() + const user = await getCurrentUser() + if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 }) + + const db = getDb() + const reports = db.prepare('SELECT * FROM reports ORDER BY created_at DESC').all() + return NextResponse.json({ reports }) + } catch (e) { + const msg = e instanceof Error ? e.message : '查询失败' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} + +export async function POST(request: NextRequest) { + try { + initDatabase() + const user = await getCurrentUser() + if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 }) + if (!hasPermission(user, 'reports:read')) return NextResponse.json({ error: '权限不足' }, { status: 403 }) + + const body = await request.json() + const { report_type, period_start, period_end } = body + + if (!report_type || !period_start || !period_end) { + return NextResponse.json({ error: '缺少必要参数' }, { status: 400 }) + } + + const db = getDb() + + // 1. 插入报告行,状态为 ready(数据已就绪,文档未生成) + const result = db.prepare(` + INSERT INTO reports (report_type, period_start, period_end, format, status, created_by) + VALUES (?, ?, ?, 'docx', 'ready', ?) + `).run(report_type, period_start, period_end, user.id) + + const reportId = result.lastInsertRowid as number + + // 2. 采集数据并写入 metadata(同步,1-2s) + if (report_type === 'weekly') { + const { collectWeeklyReportData, buildWeeklyMetadata } = await import('@/lib/weekly-report') + const data = await collectWeeklyReportData(period_start, period_end) + const metadata = buildWeeklyMetadata(data) + db.prepare('UPDATE reports SET metadata = ? WHERE id = ?').run(metadata, reportId) + } else { + const { collectMonthlyReportData, buildMonthlyMetadata } = await import('@/lib/monthly-report') + const data = await collectMonthlyReportData(period_start, period_end) + const metadata = buildMonthlyMetadata(data) + db.prepare('UPDATE reports SET metadata = ? WHERE id = ?').run(metadata, reportId) + } + + // 3. 返回报告(状态为 ready),前端自动跳转预览页 + const report = db.prepare('SELECT * FROM reports WHERE id = ?').get(reportId) + return NextResponse.json({ report }, { status: 201 }) + } catch (e) { + const msg = e instanceof Error ? e.message : '创建失败' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} + +export async function DELETE(request: NextRequest) { + try { + initDatabase() + const user = await getCurrentUser() + if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 }) + + const { ids } = await request.json() + if (!Array.isArray(ids) || ids.length === 0) { + return NextResponse.json({ error: '缺少 ids 参数' }, { status: 400 }) + } + + const db = getDb() + const reports = db.prepare( + `SELECT * FROM reports WHERE id IN (${ids.map(() => '?').join(',')})` + ).all(...ids) as any[] + + // 删除磁盘文件 + for (const r of reports) { + if (r.file_path && fs.existsSync(r.file_path)) { + try { fs.unlinkSync(r.file_path) } catch { /* ignore */ } + } + } + + // 删除数据库记录 + const placeholders = ids.map(() => '?').join(',') + db.prepare(`DELETE FROM reports WHERE id IN (${placeholders})`).run(...ids) + + return NextResponse.json({ success: true, deleted: ids.length }) + } catch (e) { + const msg = e instanceof Error ? e.message : '批量删除失败' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} diff --git a/src/lib/weekly-report.ts b/src/lib/weekly-report.ts index da3b924..7cc83aa 100644 --- a/src/lib/weekly-report.ts +++ b/src/lib/weekly-report.ts @@ -201,6 +201,25 @@ export function buildWeeklyMetadata(data: WeeklyReportData): string { ? Math.round(availabilities.reduce((s, v) => s + v, 0) / availabilities.length * 10000) / 100 : 100 + // 无故障天数:排除"其他"子分类,按故障影响日期范围计算 + const faultTicketsForFreeDays = faultTickets.filter(t => t.faultSubcategory !== '其他') + const periodStartDate = new Date(data.periodStart.replace(/-/g, '/')) + const periodEndDate = new Date(data.periodEnd.replace(/-/g, '/')) + const periodDays = Math.floor((periodEndDate.getTime() - periodStartDate.getTime()) / (1000 * 60 * 60 * 24)) + 1 + + const faultDateSet = new Set() + for (const t of faultTicketsForFreeDays) { + const start = new Date(t.assignTime.slice(0, 10).replace(/-/g, '/')) + const endRaw = t.closeTime ? t.closeTime.slice(0, 10) : data.periodEnd + 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 = periodDays - faultDateSet.size + const reportLabel = `图灵IT基础设施运营周报(${data.weekLabel})` return JSON.stringify({ @@ -216,7 +235,7 @@ export function buildWeeklyMetadata(data: WeeklyReportData): string { ongoingCount, faultTicketCount, affectedDeviceCount, - faultFreeDays: null, + faultFreeDays, availabilityDetails: null, reportLabel, })