feat: 周报预览运营数据增加无故障天数

参照月报逻辑,在 buildWeeklyMetadata 中计算无故障天数,
排除"其他"子分类工单,按故障影响日期范围统计。
This commit is contained in:
gitadmin 2026-05-08 09:28:39 +08:00
parent 5c94719693
commit f692546281
9 changed files with 1283 additions and 2 deletions

2
.gitignore vendored
View File

@ -11,7 +11,7 @@ build/
.claude/
data/
uploads/
reports/
/reports/
docs/
db-backups/
.playwright-mcp/

View File

@ -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<string, string> = {
weekly: '周报',
monthly: '月报',
}
export default function ReportDetailPage() {
const params = useParams()
const router = useRouter()
const [report, setReport] = useState<Report | null>(null)
const [reportData, setReportData] = useState<any>(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 (
<Button size="sm" onClick={handleGenerate} loading={generating} className="bg-emerald-600 hover:bg-emerald-700 text-white">
<FileText size={16} className="mr-1" />
</Button>
)
case 'generating':
return (
<Button size="sm" disabled className="bg-slate-400 text-white cursor-not-allowed">
<span className="inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-1" />
...
</Button>
)
case 'completed':
return (
<Button size="sm" onClick={() => window.open(`/api/reports/${report.id}/download`, '_blank')} className="bg-blue-600 hover:bg-blue-700 text-white">
<Download size={16} className="mr-1" />
</Button>
)
case 'failed':
return (
<Button size="sm" onClick={handleGenerate} loading={generating} className="bg-amber-500 hover:bg-amber-600 text-white">
<RefreshCw size={16} className="mr-1" />
</Button>
)
default:
return null
}
}
const isNewFormat = reportData?.gpuCount !== undefined
if (loading) return <div className="text-center py-12 text-slate-500 dark:text-slate-400">...</div>
if (!report) return <div className="text-center py-12 text-slate-500 dark:text-slate-400"></div>
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between border-b border-slate-200 dark:border-slate-700 pb-4">
<div className="flex items-center gap-4">
<Link href="/reports">
<Button variant="ghost" size="sm"><ArrowLeft size={18} /></Button>
</Link>
<div>
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100">
{typeLabel[report.report_type] || report.report_type}
</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">
{report.period_start} ~ {report.period_end}
</p>
</div>
{report.status === 'ready' && (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400">
<span className="w-1.5 h-1.5 rounded-full bg-amber-500" />
</span>
)}
{report.status === 'generating' && (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400">
<span className="inline-block w-2.5 h-2.5 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
</span>
)}
{report.status === 'completed' && (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" />
</span>
)}
{report.status === 'failed' && (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-red-100 text-red-700 dark:bg-red-500/10 dark:text-red-400">
<span className="w-1.5 h-1.5 rounded-full bg-red-500" />
</span>
)}
{report.status === 'pending' && (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300">
<span className="w-1.5 h-1.5 rounded-full bg-slate-400" />
</span>
)}
</div>
{renderRightButton()}
</div>
{report.status === 'failed' && report.error_message && (
<Card className="p-4 bg-red-50 dark:bg-red-500/10 border-red-200 dark:border-red-500/20">
<p className="text-sm text-red-600 dark:text-red-400">{report.error_message}</p>
</Card>
)}
{/* ===== 旧格式兼容 ===== */}
{!isNewFormat && reportData?.summary && (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className="p-5 text-center">
<p className="text-sm text-slate-500 dark:text-slate-400"></p>
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100 mt-1">{reportData.summary.total_tickets}</p>
</Card>
<Card className="p-5 text-center">
<p className="text-sm text-slate-500 dark:text-slate-400"></p>
<p className="text-2xl font-bold text-emerald-500 mt-1">{reportData.summary.resolved_tickets}</p>
</Card>
<Card className="p-5 text-center">
<p className="text-sm text-slate-500 dark:text-slate-400"></p>
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100 mt-1">{reportData.summary.avg_duration} </p>
</Card>
<Card className="p-5 text-center">
<p className="text-sm text-slate-500 dark:text-slate-400">SLA </p>
<p className={`text-2xl font-bold mt-1 ${reportData.summary.sla_rate >= 90 ? 'text-emerald-500' : 'text-amber-500'}`}>{reportData.summary.sla_rate}%</p>
</Card>
</div>
{reportData.categories && reportData.categories.length > 0 && (
<Card className="p-5">
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-3"></h3>
<div className="space-y-2">
{reportData.categories.map((c: any, i: number) => (
<div key={i} className="flex items-center justify-between py-2 border-b border-slate-100 dark:border-slate-800 last:border-0">
<span className="text-sm text-slate-700 dark:text-slate-300">{c.fault_category || '未分类'}</span>
<span className="text-sm font-medium text-slate-900 dark:text-slate-100">{c.count} </span>
</div>
))}
</div>
</Card>
)}
</div>
)}
{/* ===== 新格式 ===== */}
{isNewFormat && reportData && (
<div className="space-y-5">
{/* KPI Cards (5列) */}
<div className="grid gap-3" style={{ gridTemplateColumns: 'repeat(5, 1fr)' }}>
<Card className="p-4 text-center flex flex-col justify-center min-h-[96px]">
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1"></p>
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{reportData.totalTickets}</p>
</Card>
<Card className="p-4 text-center flex flex-col justify-center min-h-[96px]">
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1"></p>
<p className="text-2xl font-bold text-emerald-500">{reportData.resolvedCount}</p>
</Card>
<Card className="p-4 text-center flex flex-col justify-center min-h-[96px]">
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1"></p>
<p className={`text-2xl font-bold ${(reportData.avgAvailability ?? 100) >= 99 ? 'text-emerald-500' : 'text-amber-500'}`}>
{reportData.avgAvailability != null ? `${reportData.avgAvailability}%` : '-'}
</p>
<p className="text-xs text-slate-400 mt-1">
{reportData.avgAvailability != null && reportData.avgAvailability < 99 ? '低于99%' : ''}
</p>
</Card>
<Card className="p-4 text-center flex flex-col justify-center min-h-[96px]">
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1"></p>
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{reportData.avgDurationMinutes}</p>
<p className="text-xs text-slate-400 mt-1"></p>
</Card>
<Card className="p-4 text-center flex flex-col justify-center min-h-[96px]">
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1"></p>
<p className="text-2xl font-bold text-sky-600">{reportData.ongoingCount}</p>
</Card>
</div>
{/* Chapter 1: 设备概况 */}
<Card className="overflow-hidden">
<div className="flex items-center gap-3 px-5 py-3 bg-slate-50 dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700">
<span className="inline-flex items-center justify-center w-6 h-6 rounded-md bg-blue-500 text-white text-xs font-bold">1</span>
<span className="text-sm font-semibold text-slate-700 dark:text-slate-300"></span>
</div>
<div className="flex gap-8 px-5 py-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-blue-100 dark:bg-blue-500/10 flex items-center justify-center text-lg">🖥</div>
<div>
<span className="text-xl font-bold text-slate-900 dark:text-slate-100">{reportData.gpuCount}</span>
<span className="text-sm text-slate-500 dark:text-slate-400 ml-1"> GPU </span>
</div>
</div>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-amber-100 dark:bg-amber-500/10 flex items-center justify-center text-lg">🗄</div>
<div>
<span className="text-xl font-bold text-slate-900 dark:text-slate-100">{reportData.storageCount}</span>
<span className="text-sm text-slate-500 dark:text-slate-400 ml-1"> </span>
</div>
</div>
</div>
</Card>
{/* Chapter 2: 运营数据 */}
<Card className="overflow-hidden">
<div className="flex items-center gap-3 px-5 py-3 bg-slate-50 dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700">
<span className="inline-flex items-center justify-center w-6 h-6 rounded-md bg-purple-500 text-white text-xs font-bold">2</span>
<span className="text-sm font-semibold text-slate-700 dark:text-slate-300"></span>
</div>
<div className="flex gap-6 px-5 py-4">
<div className="text-sm text-slate-500 dark:text-slate-400">
<span className="font-bold text-amber-600 text-base ml-1">{reportData.faultTicketCount}</span>
</div>
<div className="text-sm text-slate-500 dark:text-slate-400">
<span className="font-bold text-slate-900 dark:text-slate-100 text-base ml-1">{reportData.affectedDeviceCount}</span>
</div>
{reportData.faultFreeDays != null && (
<div className="text-sm text-slate-500 dark:text-slate-400">
<span className="font-bold text-emerald-600 text-base ml-1">{reportData.faultFreeDays}</span>
</div>
)}
</div>
</Card>
{/* Chapter 3: 故障分类 */}
<Card className="overflow-hidden">
<div className="flex items-center gap-3 px-5 py-3 bg-slate-50 dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700">
<span className="inline-flex items-center justify-center w-6 h-6 rounded-md bg-amber-500 text-white text-xs font-bold">3</span>
<span className="text-sm font-semibold text-slate-700 dark:text-slate-300"></span>
</div>
<div className="flex gap-4 px-5 py-4">
{(() => {
const ch3Total = reportData.gpuFaultCount + reportData.storageFaultCount + reportData.otherTicketCount
return (
<>
<div className="flex-1 p-3 rounded-lg text-center">
<p className="text-lg font-bold text-amber-500">{reportData.gpuFaultCount}</p>
<p className="text-xs text-slate-500 dark:text-slate-400">GPU </p>
<div className="w-full h-1 bg-slate-100 dark:bg-slate-700 rounded mt-2">
<div className="h-full bg-amber-500 rounded" style={{ width: `${ch3Total > 0 ? Math.round(reportData.gpuFaultCount / ch3Total * 100) : 0}%` }} />
</div>
<p className="text-xs text-slate-400 mt-0.5">
{ch3Total > 0 ? `${Math.round(reportData.gpuFaultCount / ch3Total * 100)}%` : '—'}
</p>
</div>
<div className="flex-1 p-3 rounded-lg text-center">
<p className="text-lg font-bold text-amber-500">{reportData.storageFaultCount}</p>
<p className="text-xs text-slate-500 dark:text-slate-400"></p>
<div className="w-full h-1 bg-slate-100 dark:bg-slate-700 rounded mt-2">
<div className="h-full bg-amber-500 rounded" style={{ width: `${ch3Total > 0 ? Math.round(reportData.storageFaultCount / ch3Total * 100) : 0}%` }} />
</div>
<p className="text-xs text-slate-400 mt-0.5">
{ch3Total > 0 ? `${Math.round(reportData.storageFaultCount / ch3Total * 100)}%` : '—'}
</p>
</div>
<div className="flex-1 p-3 rounded-lg text-center">
<p className="text-lg font-bold text-blue-500">{reportData.otherTicketCount}</p>
<p className="text-xs text-slate-500 dark:text-slate-400"></p>
<div className="w-full h-1 bg-slate-100 dark:bg-slate-700 rounded mt-2">
<div className="h-full bg-blue-500 rounded" style={{ width: `${ch3Total > 0 ? Math.round(reportData.otherTicketCount / ch3Total * 100) : 0}%` }} />
</div>
<p className="text-xs text-slate-400 mt-0.5">
{ch3Total > 0 ? `${Math.round(reportData.otherTicketCount / ch3Total * 100)}%` : '—'}
</p>
</div>
</>
)
})()}
</div>
</Card>
{/* Chapter 4: 服务可用性 */}
<Card className="overflow-hidden">
<div className="flex items-center gap-3 px-5 py-3 bg-slate-50 dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700">
<span className="inline-flex items-center justify-center w-6 h-6 rounded-md bg-emerald-500 text-white text-xs font-bold">4</span>
<span className="text-sm font-semibold text-slate-700 dark:text-slate-300"></span>
</div>
<div className="px-5 py-4">
<div className="flex items-center gap-2 mb-4">
<span className="text-sm text-slate-500 dark:text-slate-400"></span>
<span className={`text-xl font-bold ${(reportData.avgAvailability ?? 100) >= 99 ? 'text-emerald-500' : 'text-amber-500'}`}>
{reportData.avgAvailability != null ? `${reportData.avgAvailability}%` : '-'}
</span>
</div>
{reportData.availabilityDetails && reportData.availabilityDetails.length > 0 && (
<>
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b-2 border-slate-200 dark:border-slate-700 text-left">
<th className="py-2 px-3 text-slate-500 dark:text-slate-400 font-medium text-xs">IP </th>
<th className="py-2 px-3 text-slate-500 dark:text-slate-400 font-medium text-xs"></th>
<th className="py-2 px-3 text-slate-500 dark:text-slate-400 font-medium text-xs"></th>
<th className="py-2 px-3 text-slate-500 dark:text-slate-400 font-medium text-xs"></th>
<th className="py-2 px-3 text-slate-500 dark:text-slate-400 font-medium text-xs"></th>
</tr>
</thead>
<tbody>
{reportData.availabilityDetails.map((item: any, i: number) => (
<tr key={i} className="border-b border-slate-100 dark:border-slate-800">
<td className="py-2 px-3 font-medium text-slate-900 dark:text-slate-100 text-xs">{item.ip}</td>
<td className="py-2 px-3 text-slate-600 dark:text-slate-400 text-xs">
{item.deviceType === 'gpu' ? 'GPU' : item.deviceType === 'storage' ? '存储' : '其他'}
</td>
<td className={`py-2 px-3 text-xs font-medium ${item.isOngoing ? 'text-red-500' : 'text-slate-700 dark:text-slate-300'}`}>
{item.durationMinutes.toLocaleString()}
</td>
<td className={`py-2 px-3 text-xs font-bold ${item.availabilityPercent < 95 ? 'text-red-500' : 'text-amber-500'}`}>
{item.availabilityPercent}%
</td>
<td className="py-2 px-3">
{item.isOngoing ? (
<span className="inline-flex px-2 py-0.5 rounded-full text-xs bg-red-100 text-red-600 dark:bg-red-500/10 dark:text-red-400"></span>
) : (
<span className="inline-flex px-2 py-0.5 rounded-full text-xs bg-emerald-100 text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-400"></span>
)}
</td>
</tr>
))}
</tbody>
</table>
<p className="text-xs text-slate-400 mt-3">
{reportData.availabilityDetails.length} IP 100% 100% 线
</p>
</>
)}
{(!reportData.availabilityDetails || reportData.availabilityDetails.length === 0) && (
<p className="text-xs text-slate-400">
{report.report_type === 'weekly'
? '周报不展示 IP 明细,请查看下载的 DOCX 文档获取完整可用性数据。'
: '所有设备保持 100% 在线'}
</p>
)}
</div>
</Card>
</div>
)}
</div>
)
}

View File

@ -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<string, 'info' | 'warning' | 'success' | 'danger'> = {
pending: 'info',
ready: 'warning',
generating: 'info',
completed: 'success',
failed: 'danger',
}
const statusLabel: Record<string, string> = {
pending: '待生成',
ready: '数据已就绪',
generating: '文档生成中',
completed: '已完成',
failed: '生成失败',
}
const typeLabel: Record<string, string> = {
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<Report[]>([])
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<Report | null>(null)
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
const [batchDeleteOpen, setBatchDeleteOpen] = useState(false)
const [generatingIds, setGeneratingIds] = useState<Set<number>>(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 (
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => router.push(`/reports/${r.id}`)}>
<Eye size={14} className="mr-0.5" />
</Button>
<Button variant="ghost" size="sm" onClick={() => handleGenerate(r.id)} loading={isGenerating}>
</Button>
<Button variant="ghost" size="sm" onClick={() => setDeleteTarget(r)}>
<Trash2 size={14} className="text-red-500" />
</Button>
</div>
)
case 'generating':
return (
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => router.push(`/reports/${r.id}`)}>
<Eye size={14} className="mr-0.5" />
</Button>
</div>
)
case 'completed':
return (
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => router.push(`/reports/${r.id}`)}>
<Eye size={14} className="mr-0.5" />
</Button>
<Button variant="ghost" size="sm" onClick={() => window.open(`/api/reports/${r.id}/download`, '_blank')}>
<Download size={14} className="mr-0.5" />
</Button>
<Button variant="ghost" size="sm" onClick={() => setDeleteTarget(r)}>
<Trash2 size={14} className="text-red-500" />
</Button>
</div>
)
case 'failed':
return (
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => router.push(`/reports/${r.id}`)}>
<Eye size={14} className="mr-0.5" />
</Button>
<Button variant="ghost" size="sm" onClick={() => handleGenerate(r.id)} loading={isGenerating}>
<RefreshCw size={14} className="mr-0.5" />
</Button>
<Button variant="ghost" size="sm" onClick={() => setDeleteTarget(r)}>
<Trash2 size={14} className="text-red-500" />
</Button>
</div>
)
default:
return (
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => setDeleteTarget(r)}>
<Trash2 size={14} className="text-red-500" />
</Button>
</div>
)
}
}
const renderStatusBadge = (status: string) => {
if (status === 'generating') {
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400">
<span className="inline-block w-2.5 h-2.5 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
</span>
)
}
if (status === 'ready') {
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400">
<span className="w-1.5 h-1.5 rounded-full bg-amber-500" />
</span>
)
}
return <Badge variant={statusVariant[status] || 'default'}>{statusLabel[status] || status}</Badge>
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100"></h1>
<p className="text-slate-500 dark:text-slate-400 text-sm mt-1"></p>
</div>
<div className="flex items-center gap-2">
{selectedIds.size > 0 && (
<>
<Button size="sm" onClick={handleBatchDownload}>
<Archive size={16} className="mr-1" /> ({selectedIds.size})
</Button>
<Button size="sm" variant="danger" onClick={() => setBatchDeleteOpen(true)}>
<Trash2 size={16} className="mr-1" /> ({selectedIds.size})
</Button>
</>
)}
<Button size="sm" onClick={() => setShowCreate(!showCreate)}>
<Plus size={16} className="mr-1" />
</Button>
</div>
</div>
{showCreate && (
<Card className="p-5">
<h3 className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-3"></h3>
<div className="flex gap-3 mb-4">
<label className={`flex items-center gap-2 px-3 py-2 rounded-lg border cursor-pointer text-sm transition-colors ${reportType === 'monthly' ? 'border-blue-500 bg-blue-50 dark:bg-blue-500/10 text-blue-700 dark:text-blue-400' : 'border-slate-300 dark:border-slate-600 text-slate-600 dark:text-slate-400'}`}>
<input type="radio" name="reportType" value="monthly" checked={reportType === 'monthly'} onChange={() => setReportType('monthly')} className="sr-only" />
</label>
<label className={`flex items-center gap-2 px-3 py-2 rounded-lg border cursor-pointer text-sm transition-colors ${reportType === 'weekly' ? 'border-blue-500 bg-blue-50 dark:bg-blue-500/10 text-blue-700 dark:text-blue-400' : 'border-slate-300 dark:border-slate-600 text-slate-600 dark:text-slate-400'}`}>
<input type="radio" name="reportType" value="weekly" checked={reportType === 'weekly'} onChange={() => { setReportType('weekly'); const { start, end } = getLastWeekDates(); setWeekStart(start); setWeekEnd(end) }} className="sr-only" />
</label>
</div>
{reportType === 'monthly' ? (
<div className="space-y-1">
<label className="text-sm text-slate-500 dark:text-slate-400"></label>
<input type="month" className="w-56 px-3 py-2 rounded-lg border bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 border-slate-300 dark:border-slate-600 text-sm" value={month} onChange={e => setMonth(e.target.value)} />
<p className="text-xs text-slate-400 mt-1">1</p>
</div>
) : (
<div>
<div className="flex gap-4">
<div className="space-y-1">
<label className="text-sm text-slate-500 dark:text-slate-400"></label>
<input type="date" className="w-56 px-3 py-2 rounded-lg border bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 border-slate-300 dark:border-slate-600 text-sm" value={weekStart} onChange={e => setWeekStart(e.target.value)} />
</div>
<div className="space-y-1">
<label className="text-sm text-slate-500 dark:text-slate-400"></label>
<input type="date" className="w-56 px-3 py-2 rounded-lg border bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 border-slate-300 dark:border-slate-600 text-sm" value={weekEnd} onChange={e => setWeekEnd(e.target.value)} />
</div>
</div>
<p className="text-xs text-slate-400 mt-2"></p>
</div>
)}
<div className="flex gap-2 mt-4">
<Button size="sm" onClick={handleCreate}></Button>
<Button size="sm" variant="secondary" onClick={() => setShowCreate(false)}></Button>
</div>
</Card>
)}
{loading ? (
<div className="text-center py-12 text-slate-500 dark:text-slate-400">...</div>
) : reports.length === 0 ? (
<div className="text-center py-12 text-slate-500 dark:text-slate-400"></div>
) : (
<div className="overflow-x-auto rounded-xl border border-slate-200 dark:border-slate-700">
<table className="w-full text-sm">
<thead className="bg-slate-50 dark:bg-slate-800">
<tr>
<th className="px-3 py-3 w-10">
<input
type="checkbox"
checked={
reports.filter(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"
/>
</th>
<th className="px-4 py-3 text-left font-medium text-slate-600 dark:text-slate-400"></th>
<th className="px-4 py-3 text-left font-medium text-slate-600 dark:text-slate-400"></th>
<th className="px-4 py-3 text-left font-medium text-slate-600 dark:text-slate-400"></th>
<th className="px-4 py-3 text-left font-medium text-slate-600 dark:text-slate-400"></th>
<th className="px-4 py-3 text-left font-medium text-slate-600 dark:text-slate-400"></th>
<th className="px-4 py-3 text-left font-medium text-slate-600 dark:text-slate-400"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200 dark:divide-slate-700">
{reports.map(r => (
<tr key={r.id} className="hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
<td className="px-3 py-3">
<input
type="checkbox"
checked={selectedIds.has(r.id)}
onChange={() => toggleSelect(r.id)}
className="w-4 h-4 rounded border-slate-300 dark:border-slate-600 text-blue-600 focus:ring-blue-500"
/>
</td>
<td className="px-4 py-3 max-w-[240px]">
<a
href={`/reports/${r.id}`}
onClick={(e) => { 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)}
</a>
</td>
<td className="px-4 py-3">
{r.report_type === 'monthly' ? (
<span className="inline-flex px-2.5 py-0.5 rounded-lg text-xs font-semibold bg-blue-100 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400"></span>
) : (
<span className="inline-flex px-2.5 py-0.5 rounded-lg text-xs font-semibold bg-purple-100 text-purple-700 dark:bg-purple-500/10 dark:text-purple-400"></span>
)}
</td>
<td className="px-4 py-3 text-slate-600 dark:text-slate-400 text-sm">{r.period_start} ~ {r.period_end}</td>
<td className="px-4 py-3">{renderStatusBadge(r.status)}</td>
<td className="px-4 py-3 text-slate-500 dark:text-slate-400 text-sm">{new Date(r.created_at).toLocaleString('zh-CN')}</td>
<td className="px-4 py-3">{getActionButtons(r)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<Modal open={!!deleteTarget} onClose={() => setDeleteTarget(null)} title="确认删除">
<p className="text-sm text-slate-600 dark:text-slate-400 mb-4">
</p>
<div className="flex gap-2 justify-end">
<Button size="sm" variant="secondary" onClick={() => setDeleteTarget(null)}></Button>
<Button size="sm" variant="danger" onClick={handleDelete}></Button>
</div>
</Modal>
<Modal open={batchDeleteOpen} onClose={() => setBatchDeleteOpen(false)} title="确认批量删除">
<p className="text-sm text-slate-600 dark:text-slate-400 mb-4">
{selectedIds.size}
</p>
<div className="flex gap-2 justify-end">
<Button size="sm" variant="secondary" onClick={() => setBatchDeleteOpen(false)}></Button>
<Button size="sm" variant="danger" onClick={handleBatchDelete}></Button>
</div>
</Modal>
{toast && <Toast message={toast.message} type={toast.type} onClose={() => setToast(null)} />}
</div>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<string>()
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,
})