Your Name 0bfedb95c8 feat: 为所有终端添加平台显示功能
- 新增 frontend/lib/platforms.ts 共享平台配置模块
- 支持6个平台: 抖音、小红书、B站、快手、微博、微信视频号
- 品牌方终端: 项目看板、项目详情、终审台列表添加平台显示
- 代理商终端: 工作台概览、审核台、Brief配置、达人管理、
  数据报表、消息中心、申诉处理添加平台显示
- 达人端: 任务列表添加平台显示
- 统一使用彩色头部条样式展示平台信息

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 18:53:51 +08:00

523 lines
20 KiB
TypeScript
Raw 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 } 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,
TrendingDown,
Download,
Calendar,
FileText,
Video,
Users,
CheckCircle,
XCircle,
Clock,
FileSpreadsheet,
File,
Check
} from 'lucide-react'
import { getPlatformInfo } from '@/lib/platforms'
// 时间范围类型
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 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">
{/* 页面标题 */}
<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>
{/* 核心指标 */}
<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 ? (
<>
<Clock size={16} className="animate-spin" />
...
</>
) : (
<>
<Download size={16} />
</>
)}
</Button>
</div>
</>
)}
</div>
</Modal>
</div>
)
}