Your Name a8be7bbca9 feat: 前端剩余页面全面对接后端 API(Phase 2 完成)
为品牌方端(8页)、代理商端(10页)、达人端(6页)共24个页面添加真实API调用:
- 每页新增 USE_MOCK 条件分支,开发环境使用 mock 数据,生产环境调用真实 API
- 添加 loading 骨架屏、error toast 提示、submitting 状态管理
- 数据映射:TaskResponse → 页面视图模型,处理类型差异
- 审核操作(通过/驳回/强制通过)对接 api.reviewScript/reviewVideo
- Brief/规则/AI配置对接 api.getBrief/updateBrief/listForbiddenWords 等
- 申诉/历史/额度管理对接 api.listTasks + 状态过滤映射

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 16:29:43 +08:00

601 lines
24 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { useState, useEffect, useCallback } from 'react'
import { useToast } from '@/components/ui/Toast'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { Modal } from '@/components/ui/Modal'
import {
BarChart3,
TrendingUp,
TrendingDown,
Download,
Calendar,
FileText,
Video,
Users,
CheckCircle,
XCircle,
Clock,
FileSpreadsheet,
File,
Check,
Loader2
} from 'lucide-react'
import { getPlatformInfo } from '@/lib/platforms'
import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
import type { AgencyDashboard } from '@/types/dashboard'
// 时间范围类型
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: '去年',
},
}
const mockProjectStats = [
{ name: 'XX品牌618推广', platform: 'douyin', scripts: 45, videos: 38, passRate: 92 },
{ name: '新品口红系列', platform: 'xiaohongshu', scripts: 32, videos: 28, passRate: 85 },
{ name: '护肤品秋季活动', platform: 'bilibili', scripts: 28, videos: 25, passRate: 78 },
{ name: 'XX运动品牌', platform: 'kuaishou', scripts: 51, videos: 37, passRate: 88 },
]
const mockCreatorRanking = [
{ name: '小美护肤', passRate: 95, total: 28 },
{ name: '健身教练王', passRate: 92, total: 15 },
{ name: '美妆Lisa', passRate: 88, total: 22 },
{ name: '时尚达人', passRate: 82, total: 18 },
{ name: '美食探店', passRate: 78, total: 25 },
]
// 时间范围标签
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('时长'))
return (
<Card>
<CardContent className="py-4">
<div className="flex items-start justify-between">
<div>
<p className="text-sm text-text-secondary">{title}</p>
<div className="flex items-baseline gap-1 mt-1">
<span className={`text-2xl font-bold ${color}`}>{value}</span>
{unit && <span className="text-text-secondary">{unit}</span>}
</div>
{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 {compareText}
</div>
)}
</div>
<div className={`w-10 h-10 rounded-lg ${color.replace('text-', 'bg-')}/20 flex items-center justify-center`}>
<Icon size={20} className={color} />
</div>
</div>
</CardContent>
</Card>
)
}
export default function AgencyReportsPage() {
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 [loading, setLoading] = useState(true)
const [dashboardData, setDashboardData] = useState<AgencyDashboard | null>(null)
const toast = useToast()
const fetchData = useCallback(async () => {
if (USE_MOCK) {
setLoading(false)
return
}
try {
setLoading(true)
const data = await api.getAgencyDashboard()
setDashboardData(data)
} catch (err) {
console.error('Failed to fetch agency dashboard:', err)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData()
}, [fetchData])
// In API mode, derive stats from dashboard data where possible
// For fields the backend doesn't provide (trend data, project stats, creator ranking),
// we still use mock data as placeholders since there's no dedicated reports API yet.
const currentData = USE_MOCK ? mockDataByRange[dateRange] : (() => {
const base = mockDataByRange[dateRange]
if (dashboardData) {
return {
...base,
stats: {
...base.stats,
totalScripts: dashboardData.pending_review.script + dashboardData.today_passed.script + dashboardData.in_progress.script,
totalVideos: dashboardData.pending_review.video + dashboardData.today_passed.video + dashboardData.in_progress.video,
},
}
}
return base
})()
// 导出报表
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 库
toast.info(`Excel 文件「${fileName}.xlsx」已开始下载`)
} else {
toast.info(`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)
}
if (loading) {
return (
<div className="flex flex-col items-center justify-center py-24 text-text-tertiary">
<Loader2 size={32} className="animate-spin mb-4" />
<p>...</p>
</div>
)
}
return (
<div className="space-y-6">
{/* 页面标题 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary"></h1>
<p className="text-sm text-text-secondary mt-1"></p>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-1 p-1 bg-bg-elevated rounded-lg">
{(['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 onClick={() => setShowExportModal(true)}>
<Download size={16} />
</Button>
</div>
</div>
{/* Dashboard summary banner (API mode only) */}
{!USE_MOCK && dashboardData && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="p-3 rounded-xl bg-accent-amber/10 border border-accent-amber/20">
<p className="text-xs text-text-tertiary"> (/)</p>
<p className="text-lg font-bold text-accent-amber mt-1">
{dashboardData.pending_review.script} / {dashboardData.pending_review.video}
</p>
</div>
<div className="p-3 rounded-xl bg-accent-coral/10 border border-accent-coral/20">
<p className="text-xs text-text-tertiary"></p>
<p className="text-lg font-bold text-accent-coral mt-1">{dashboardData.pending_appeal}</p>
</div>
<div className="p-3 rounded-xl bg-accent-indigo/10 border border-accent-indigo/20">
<p className="text-xs text-text-tertiary"></p>
<p className="text-lg font-bold text-accent-indigo mt-1">{dashboardData.total_creators}</p>
</div>
<div className="p-3 rounded-xl bg-accent-green/10 border border-accent-green/20">
<p className="text-xs text-text-tertiary"></p>
<p className="text-lg font-bold text-accent-green mt-1">{dashboardData.total_tasks}</p>
</div>
</div>
)}
{/* 核心指标 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<StatCard
title="脚本审核量"
value={currentData.stats.totalScripts}
trend={currentData.stats.trend.scripts}
compareText={currentData.compareText}
icon={FileText}
color="text-accent-indigo"
/>
<StatCard
title="视频审核量"
value={currentData.stats.totalVideos}
trend={currentData.stats.trend.videos}
compareText={currentData.compareText}
icon={Video}
color="text-purple-400"
/>
<StatCard
title="通过率"
value={currentData.stats.passRate}
unit="%"
trend={currentData.stats.trend.passRate}
compareText={currentData.compareText}
icon={CheckCircle}
color="text-accent-green"
/>
<StatCard
title="平均审核时长"
value={currentData.stats.avgReviewTime}
unit="小时"
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">
{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: `${(item.passed / item.submitted) * 100}%` }}
/>
<div
className="bg-accent-coral transition-all"
style={{ width: `${(item.rejected / item.submitted) * 100}%` }}
/>
</div>
</div>
<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">{item.submitted}</span>
</div>
</div>
))}
</div>
<div className="flex gap-6 mt-4 text-sm">
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-accent-green rounded" />
<span className="text-text-secondary"></span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-accent-coral rounded" />
<span className="text-text-secondary"></span>
</div>
</div>
</CardContent>
</Card>
{/* 达人排名 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users size={18} className="text-accent-indigo" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{mockCreatorRanking.map((creator, index) => (
<div key={creator.name} className="flex items-center gap-3 p-3 rounded-lg bg-bg-elevated">
<div className={`w-7 h-7 rounded-full flex items-center justify-center text-sm font-bold ${
index === 0 ? 'bg-yellow-500/20 text-yellow-400' :
index === 1 ? 'bg-gray-500/20 text-gray-400' :
index === 2 ? 'bg-orange-500/20 text-orange-400' : 'bg-bg-page text-text-tertiary'
}`}>
{index + 1}
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-text-primary truncate">{creator.name}</div>
<div className="text-xs text-text-tertiary">{creator.total} </div>
</div>
<div className={`font-bold ${creator.passRate >= 90 ? 'text-accent-green' : creator.passRate >= 80 ? 'text-accent-indigo' : 'text-orange-400'}`}>
{creator.passRate}%
</div>
</div>
))}
</CardContent>
</Card>
</div>
{/* 项目统计 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<table className="w-full">
<thead>
<tr className="border-b border-border-subtle text-left text-sm text-text-secondary">
<th className="pb-3 font-medium"></th>
<th className="pb-3 font-medium"></th>
<th className="pb-3 font-medium text-center"></th>
<th className="pb-3 font-medium text-center"></th>
<th className="pb-3 font-medium text-center"></th>
<th className="pb-3 font-medium"></th>
</tr>
</thead>
<tbody>
{mockProjectStats.map((project) => {
const platform = getPlatformInfo(project.platform)
return (
<tr key={project.name} className="border-b border-border-subtle last:border-0">
<td className="py-4 font-medium text-text-primary">{project.name}</td>
<td className="py-4">
{platform && (
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium ${platform.bgColor} ${platform.textColor} border ${platform.borderColor}`}>
<span>{platform.icon}</span>
{platform.name}
</span>
)}
</td>
<td className="py-4 text-center text-text-secondary">{project.scripts}</td>
<td className="py-4 text-center text-text-secondary">{project.videos}</td>
<td className="py-4 text-center">
<span className={`font-medium ${project.passRate >= 90 ? 'text-accent-green' : project.passRate >= 80 ? 'text-accent-indigo' : 'text-orange-400'}`}>
{project.passRate}%
</span>
</td>
<td className="py-4">
<div className="w-full h-2 bg-bg-elevated rounded-full overflow-hidden">
<div
className={`h-full ${project.passRate >= 90 ? 'bg-accent-green' : project.passRate >= 80 ? 'bg-accent-indigo' : 'bg-orange-400'}`}
style={{ width: `${project.passRate}%` }}
/>
</div>
</td>
</tr>
)
})}
</tbody>
</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 ? (
<>
<Loader2 size={16} className="animate-spin" />
...
</>
) : (
<>
<Download size={16} />
</>
)}
</Button>
</div>
</>
)}
</div>
</Modal>
</div>
)
}