feat(agency): 数据报表添加时间切换和导出功能
时间范围切换: - 本周:按每天显示趋势,对比上周 - 本月:按每周显示趋势,对比上月 - 本季度:按每月显示趋势,对比上季度 - 本年:按每月显示全年趋势,对比去年 导出功能: - 支持 Excel、CSV、PDF 三种格式 - CSV 格式实现实际下载功能 - 导出内容包含核心指标、趋势数据、项目统计 - 导出成功显示完成提示 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
9f15709eed
commit
dbf9de66a9
@ -3,6 +3,7 @@
|
||||
import { useState } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Modal } from '@/components/ui/Modal'
|
||||
import {
|
||||
BarChart3,
|
||||
TrendingUp,
|
||||
@ -14,20 +15,100 @@ import {
|
||||
Users,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock
|
||||
Clock,
|
||||
FileSpreadsheet,
|
||||
File,
|
||||
Check
|
||||
} from 'lucide-react'
|
||||
|
||||
// 模拟数据
|
||||
const mockStats = {
|
||||
totalScripts: 156,
|
||||
totalVideos: 128,
|
||||
passRate: 85,
|
||||
avgReviewTime: 4.2,
|
||||
trend: {
|
||||
scripts: '+12%',
|
||||
videos: '+8%',
|
||||
passRate: '+3%',
|
||||
reviewTime: '-15%',
|
||||
// 时间范围类型
|
||||
type DateRange = 'week' | 'month' | 'quarter' | 'year'
|
||||
|
||||
// 按时间范围的模拟数据
|
||||
const mockDataByRange: Record<DateRange, {
|
||||
stats: {
|
||||
totalScripts: number
|
||||
totalVideos: number
|
||||
passRate: number
|
||||
avgReviewTime: number
|
||||
trend: { scripts: string; videos: string; passRate: string; reviewTime: string }
|
||||
}
|
||||
trendData: { label: string; submitted: number; passed: number; rejected: number }[]
|
||||
compareText: string
|
||||
}> = {
|
||||
week: {
|
||||
stats: {
|
||||
totalScripts: 156,
|
||||
totalVideos: 128,
|
||||
passRate: 85,
|
||||
avgReviewTime: 4.2,
|
||||
trend: { scripts: '+12%', videos: '+8%', passRate: '+3%', reviewTime: '-15%' },
|
||||
},
|
||||
trendData: [
|
||||
{ label: '周一', submitted: 25, passed: 22, rejected: 3 },
|
||||
{ label: '周二', submitted: 32, passed: 28, rejected: 4 },
|
||||
{ label: '周三', submitted: 18, passed: 16, rejected: 2 },
|
||||
{ label: '周四', submitted: 41, passed: 35, rejected: 6 },
|
||||
{ label: '周五', submitted: 35, passed: 30, rejected: 5 },
|
||||
{ label: '周六', submitted: 12, passed: 11, rejected: 1 },
|
||||
{ label: '周日', submitted: 8, passed: 7, rejected: 1 },
|
||||
],
|
||||
compareText: '上周',
|
||||
},
|
||||
month: {
|
||||
stats: {
|
||||
totalScripts: 623,
|
||||
totalVideos: 512,
|
||||
passRate: 83,
|
||||
avgReviewTime: 4.5,
|
||||
trend: { scripts: '+18%', videos: '+15%', passRate: '+2%', reviewTime: '-10%' },
|
||||
},
|
||||
trendData: [
|
||||
{ label: '第1周', submitted: 145, passed: 122, rejected: 23 },
|
||||
{ label: '第2周', submitted: 168, passed: 140, rejected: 28 },
|
||||
{ label: '第3周', submitted: 155, passed: 128, rejected: 27 },
|
||||
{ label: '第4周', submitted: 171, passed: 145, rejected: 26 },
|
||||
],
|
||||
compareText: '上月',
|
||||
},
|
||||
quarter: {
|
||||
stats: {
|
||||
totalScripts: 1856,
|
||||
totalVideos: 1520,
|
||||
passRate: 82,
|
||||
avgReviewTime: 4.8,
|
||||
trend: { scripts: '+25%', videos: '+22%', passRate: '+5%', reviewTime: '-8%' },
|
||||
},
|
||||
trendData: [
|
||||
{ label: '1月', submitted: 580, passed: 478, rejected: 102 },
|
||||
{ label: '2月', submitted: 615, passed: 503, rejected: 112 },
|
||||
{ label: '3月', submitted: 661, passed: 548, rejected: 113 },
|
||||
],
|
||||
compareText: '上季度',
|
||||
},
|
||||
year: {
|
||||
stats: {
|
||||
totalScripts: 7424,
|
||||
totalVideos: 6080,
|
||||
passRate: 81,
|
||||
avgReviewTime: 5.0,
|
||||
trend: { scripts: '+45%', videos: '+40%', passRate: '+8%', reviewTime: '-20%' },
|
||||
},
|
||||
trendData: [
|
||||
{ label: '1月', submitted: 520, passed: 420, rejected: 100 },
|
||||
{ label: '2月', submitted: 485, passed: 392, rejected: 93 },
|
||||
{ label: '3月', submitted: 610, passed: 498, rejected: 112 },
|
||||
{ label: '4月', submitted: 580, passed: 470, rejected: 110 },
|
||||
{ label: '5月', submitted: 625, passed: 513, rejected: 112 },
|
||||
{ label: '6月', submitted: 690, passed: 565, rejected: 125 },
|
||||
{ label: '7月', submitted: 715, passed: 582, rejected: 133 },
|
||||
{ label: '8月', submitted: 680, passed: 550, rejected: 130 },
|
||||
{ label: '9月', submitted: 725, passed: 592, rejected: 133 },
|
||||
{ label: '10月', submitted: 760, passed: 620, rejected: 140 },
|
||||
{ label: '11月', submitted: 780, passed: 640, rejected: 140 },
|
||||
{ label: '12月', submitted: 834, passed: 690, rejected: 144 },
|
||||
],
|
||||
compareText: '去年',
|
||||
},
|
||||
}
|
||||
|
||||
@ -38,16 +119,6 @@ const mockProjectStats = [
|
||||
{ name: 'XX运动品牌', scripts: 51, videos: 37, passRate: 88 },
|
||||
]
|
||||
|
||||
const mockWeeklyData = [
|
||||
{ day: '周一', submitted: 25, passed: 22, rejected: 3 },
|
||||
{ day: '周二', submitted: 32, passed: 28, rejected: 4 },
|
||||
{ day: '周三', submitted: 18, passed: 16, rejected: 2 },
|
||||
{ day: '周四', submitted: 41, passed: 35, rejected: 6 },
|
||||
{ day: '周五', submitted: 35, passed: 30, rejected: 5 },
|
||||
{ day: '周六', submitted: 12, passed: 11, rejected: 1 },
|
||||
{ day: '周日', submitted: 8, passed: 7, rejected: 1 },
|
||||
]
|
||||
|
||||
const mockCreatorRanking = [
|
||||
{ name: '小美护肤', passRate: 95, total: 28 },
|
||||
{ name: '健身教练王', passRate: 92, total: 15 },
|
||||
@ -56,15 +127,24 @@ const mockCreatorRanking = [
|
||||
{ name: '美食探店', passRate: 78, total: 25 },
|
||||
]
|
||||
|
||||
function StatCard({ title, value, unit, trend, icon: Icon, color }: {
|
||||
// 时间范围标签
|
||||
const dateRangeLabels: Record<DateRange, string> = {
|
||||
week: '本周',
|
||||
month: '本月',
|
||||
quarter: '本季度',
|
||||
year: '本年',
|
||||
}
|
||||
|
||||
function StatCard({ title, value, unit, trend, compareText, icon: Icon, color }: {
|
||||
title: string
|
||||
value: number | string
|
||||
unit?: string
|
||||
trend?: string
|
||||
compareText: string
|
||||
icon: React.ElementType
|
||||
color: string
|
||||
}) {
|
||||
const isPositive = trend?.startsWith('+') || trend?.startsWith('-') && title.includes('时间')
|
||||
const isPositive = trend?.startsWith('+') || (trend?.startsWith('-') && title.includes('时长'))
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@ -79,7 +159,7 @@ function StatCard({ title, value, unit, trend, icon: Icon, color }: {
|
||||
{trend && (
|
||||
<div className={`flex items-center gap-1 mt-1 text-xs ${isPositive ? 'text-accent-green' : 'text-accent-coral'}`}>
|
||||
{isPositive ? <TrendingUp size={12} /> : <TrendingDown size={12} />}
|
||||
{trend} vs 上周
|
||||
{trend} vs {compareText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -93,7 +173,75 @@ function StatCard({ title, value, unit, trend, icon: Icon, color }: {
|
||||
}
|
||||
|
||||
export default function AgencyReportsPage() {
|
||||
const [dateRange, setDateRange] = useState('week')
|
||||
const [dateRange, setDateRange] = useState<DateRange>('week')
|
||||
const [showExportModal, setShowExportModal] = useState(false)
|
||||
const [exportFormat, setExportFormat] = useState<'csv' | 'excel' | 'pdf'>('excel')
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
const [exportSuccess, setExportSuccess] = useState(false)
|
||||
|
||||
const currentData = mockDataByRange[dateRange]
|
||||
|
||||
// 导出报表
|
||||
const handleExport = async () => {
|
||||
setIsExporting(true)
|
||||
// 模拟导出过程
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
|
||||
// 生成文件名
|
||||
const dateStr = new Date().toISOString().split('T')[0]
|
||||
const fileName = `审核数据报表_${dateRangeLabels[dateRange]}_${dateStr}`
|
||||
|
||||
// 模拟下载
|
||||
if (exportFormat === 'csv') {
|
||||
// 生成 CSV 内容
|
||||
const csvContent = generateCSV()
|
||||
downloadFile(csvContent, `${fileName}.csv`, 'text/csv')
|
||||
} else if (exportFormat === 'excel') {
|
||||
// 实际项目中会使用 xlsx 库
|
||||
alert(`Excel 文件「${fileName}.xlsx」已开始下载`)
|
||||
} else {
|
||||
alert(`PDF 文件「${fileName}.pdf」已开始下载`)
|
||||
}
|
||||
|
||||
setIsExporting(false)
|
||||
setExportSuccess(true)
|
||||
setTimeout(() => {
|
||||
setShowExportModal(false)
|
||||
setExportSuccess(false)
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
// 生成 CSV 内容
|
||||
const generateCSV = () => {
|
||||
const headers = ['指标', '数值', '趋势']
|
||||
const rows = [
|
||||
['脚本审核量', currentData.stats.totalScripts, currentData.stats.trend.scripts],
|
||||
['视频审核量', currentData.stats.totalVideos, currentData.stats.trend.videos],
|
||||
['通过率', `${currentData.stats.passRate}%`, currentData.stats.trend.passRate],
|
||||
['平均审核时长', `${currentData.stats.avgReviewTime}小时`, currentData.stats.trend.reviewTime],
|
||||
[],
|
||||
['时间段', '提交数', '通过数', '驳回数'],
|
||||
...currentData.trendData.map(d => [d.label, d.submitted, d.passed, d.rejected]),
|
||||
[],
|
||||
['项目名称', '脚本数', '视频数', '通过率'],
|
||||
...mockProjectStats.map(p => [p.name, p.scripts, p.videos, `${p.passRate}%`]),
|
||||
]
|
||||
|
||||
return [headers.join(','), ...rows.map(r => r.join(','))].join('\n')
|
||||
}
|
||||
|
||||
// 下载文件
|
||||
const downloadFile = (content: string, fileName: string, mimeType: string) => {
|
||||
const blob = new Blob(['\ufeff' + content], { type: mimeType + ';charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = fileName
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@ -105,35 +253,20 @@ export default function AgencyReportsPage() {
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1 p-1 bg-bg-elevated rounded-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDateRange('week')}
|
||||
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||
dateRange === 'week' ? 'bg-bg-card text-text-primary shadow-sm' : 'text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
本周
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDateRange('month')}
|
||||
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||
dateRange === 'month' ? 'bg-bg-card text-text-primary shadow-sm' : 'text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
本月
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDateRange('quarter')}
|
||||
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||
dateRange === 'quarter' ? 'bg-bg-card text-text-primary shadow-sm' : 'text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
本季度
|
||||
</button>
|
||||
{(['week', 'month', 'quarter', 'year'] as DateRange[]).map((range) => (
|
||||
<button
|
||||
key={range}
|
||||
type="button"
|
||||
onClick={() => setDateRange(range)}
|
||||
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||
dateRange === range ? 'bg-bg-card text-text-primary shadow-sm' : 'text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
{dateRangeLabels[range]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="secondary">
|
||||
<Button variant="secondary" onClick={() => setShowExportModal(true)}>
|
||||
<Download size={16} />
|
||||
导出报表
|
||||
</Button>
|
||||
@ -144,66 +277,70 @@ export default function AgencyReportsPage() {
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
title="脚本审核量"
|
||||
value={mockStats.totalScripts}
|
||||
trend={mockStats.trend.scripts}
|
||||
value={currentData.stats.totalScripts}
|
||||
trend={currentData.stats.trend.scripts}
|
||||
compareText={currentData.compareText}
|
||||
icon={FileText}
|
||||
color="text-accent-indigo"
|
||||
/>
|
||||
<StatCard
|
||||
title="视频审核量"
|
||||
value={mockStats.totalVideos}
|
||||
trend={mockStats.trend.videos}
|
||||
value={currentData.stats.totalVideos}
|
||||
trend={currentData.stats.trend.videos}
|
||||
compareText={currentData.compareText}
|
||||
icon={Video}
|
||||
color="text-purple-400"
|
||||
/>
|
||||
<StatCard
|
||||
title="通过率"
|
||||
value={mockStats.passRate}
|
||||
value={currentData.stats.passRate}
|
||||
unit="%"
|
||||
trend={mockStats.trend.passRate}
|
||||
trend={currentData.stats.trend.passRate}
|
||||
compareText={currentData.compareText}
|
||||
icon={CheckCircle}
|
||||
color="text-accent-green"
|
||||
/>
|
||||
<StatCard
|
||||
title="平均审核时长"
|
||||
value={mockStats.avgReviewTime}
|
||||
value={currentData.stats.avgReviewTime}
|
||||
unit="小时"
|
||||
trend={mockStats.trend.reviewTime}
|
||||
trend={currentData.stats.trend.reviewTime}
|
||||
compareText={currentData.compareText}
|
||||
icon={Clock}
|
||||
color="text-orange-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* 本周趋势 */}
|
||||
{/* 趋势图 */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<BarChart3 size={18} className="text-blue-500" />
|
||||
审核趋势
|
||||
审核趋势 - {dateRangeLabels[dateRange]}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{mockWeeklyData.map((day) => (
|
||||
<div key={day.day} className="flex items-center gap-4">
|
||||
<div className="w-12 text-sm text-text-secondary font-medium">{day.day}</div>
|
||||
{currentData.trendData.map((item) => (
|
||||
<div key={item.label} className="flex items-center gap-4">
|
||||
<div className="w-14 text-sm text-text-secondary font-medium">{item.label}</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex h-6 rounded-full overflow-hidden bg-bg-elevated">
|
||||
<div
|
||||
className="bg-accent-green transition-all"
|
||||
style={{ width: `${(day.passed / day.submitted) * 100}%` }}
|
||||
style={{ width: `${(item.passed / item.submitted) * 100}%` }}
|
||||
/>
|
||||
<div
|
||||
className="bg-accent-coral transition-all"
|
||||
style={{ width: `${(day.rejected / day.submitted) * 100}%` }}
|
||||
style={{ width: `${(item.rejected / item.submitted) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-24 text-right text-sm">
|
||||
<span className="text-accent-green font-medium">{day.passed}</span>
|
||||
<div className="w-28 text-right text-sm">
|
||||
<span className="text-accent-green font-medium">{item.passed}</span>
|
||||
<span className="text-text-tertiary"> / </span>
|
||||
<span className="text-text-secondary">{day.submitted}</span>
|
||||
<span className="text-text-secondary">{item.submitted}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@ -293,6 +430,80 @@ export default function AgencyReportsPage() {
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 导出弹窗 */}
|
||||
<Modal isOpen={showExportModal} onClose={() => setShowExportModal(false)} title="导出报表">
|
||||
<div className="space-y-4">
|
||||
{exportSuccess ? (
|
||||
<div className="py-8 text-center">
|
||||
<div className="w-16 h-16 mx-auto rounded-full bg-accent-green/20 flex items-center justify-center mb-4">
|
||||
<Check size={32} className="text-accent-green" />
|
||||
</div>
|
||||
<p className="text-text-primary font-medium">导出成功!</p>
|
||||
<p className="text-sm text-text-secondary mt-1">文件已开始下载</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-text-secondary text-sm">
|
||||
导出{dateRangeLabels[dateRange]}的审核数据报表,包含核心指标、趋势数据和项目统计。
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-3">选择导出格式</label>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ value: 'excel', label: 'Excel (.xlsx)', desc: '适合数据分析和图表制作', icon: FileSpreadsheet },
|
||||
{ value: 'csv', label: 'CSV (.csv)', desc: '通用格式,兼容性好', icon: File },
|
||||
{ value: 'pdf', label: 'PDF (.pdf)', desc: '适合打印和分享', icon: FileText },
|
||||
].map((format) => (
|
||||
<label
|
||||
key={format.value}
|
||||
className={`flex items-center gap-4 p-4 rounded-xl border cursor-pointer transition-colors ${
|
||||
exportFormat === format.value
|
||||
? 'border-accent-indigo bg-accent-indigo/10'
|
||||
: 'border-border-subtle hover:border-accent-indigo/50'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="format"
|
||||
value={format.value}
|
||||
checked={exportFormat === format.value}
|
||||
onChange={(e) => setExportFormat(e.target.value as typeof exportFormat)}
|
||||
className="w-4 h-4 text-accent-indigo"
|
||||
/>
|
||||
<format.icon size={24} className="text-text-secondary" />
|
||||
<div className="flex-1">
|
||||
<p className="text-text-primary font-medium">{format.label}</p>
|
||||
<p className="text-xs text-text-tertiary">{format.desc}</p>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end pt-2">
|
||||
<Button variant="ghost" onClick={() => setShowExportModal(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleExport} disabled={isExporting}>
|
||||
{isExporting ? (
|
||||
<>
|
||||
<Clock size={16} className="animate-spin" />
|
||||
导出中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download size={16} />
|
||||
确认导出
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user