feat: 周报预览运营数据增加无故障天数
参照月报逻辑,在 buildWeeklyMetadata 中计算无故障天数, 排除"其他"子分类工单,按故障影响日期范围统计。
This commit is contained in:
parent
5c94719693
commit
f692546281
|
|
@ -11,7 +11,7 @@ build/
|
|||
.claude/
|
||||
data/
|
||||
uploads/
|
||||
reports/
|
||||
/reports/
|
||||
docs/
|
||||
db-backups/
|
||||
.playwright-mcp/
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue