feat(agency): 优化审核台列表页并新增申诉处理功能

审核台列表页优化:
- 每个任务项显示文件名、文件大小
- 添加下载按钮支持文件下载
- 点击"审核"按钮才跳转到详情页
- 按风险等级显示不同颜色的状态标签和按钮

新增申诉处理功能:
- 申诉列表页:展示所有达人申诉,支持搜索和状态筛选
- 申诉详情页:查看申诉内容、附件、原审核问题
- 处理决策面板:通过/驳回申诉并填写处理意见
- 侧边栏添加"申诉处理"菜单入口

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-02-06 15:54:53 +08:00
parent 576c89c8c4
commit 66748d4f19
4 changed files with 815 additions and 44 deletions

View File

@ -0,0 +1,348 @@
'use client'
import { useState } from 'react'
import { useRouter, useParams } from 'next/navigation'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import {
ArrowLeft,
MessageSquare,
Clock,
CheckCircle,
XCircle,
AlertTriangle,
User,
FileText,
Video,
Download,
File,
Send,
Image as ImageIcon
} from 'lucide-react'
// 申诉状态类型
type AppealStatus = 'pending' | 'processing' | 'approved' | 'rejected'
// 模拟申诉详情数据
const mockAppealDetail = {
id: 'appeal-001',
taskId: 'task-001',
taskTitle: '夏日护肤推广脚本',
creatorId: 'creator-001',
creatorName: '小美护肤',
creatorAvatar: '小',
type: 'ai' as const,
contentType: 'script' as const,
reason: 'AI误判',
content: '脚本中提到的"某品牌"是泛指并非特指竞品AI系统可能误解了语境。我在脚本中使用的是泛化表述并没有提及任何具体的竞品名称。请代理商重新审核此处谢谢',
status: 'pending' as AppealStatus,
createdAt: '2026-02-06 10:30',
// 附件
attachments: [
{ id: 'att-001', name: '品牌授权证明.pdf', size: '1.2 MB', type: 'pdf' },
{ id: 'att-002', name: '脚本原文截图.png', size: '345 KB', type: 'image' },
],
// 原审核问题
originalIssue: {
type: 'ai',
title: '疑似竞品提及',
description: '脚本第3段提到"某品牌",可能涉及竞品露出风险。',
location: '脚本第3段第2行',
},
// 相关任务信息
taskInfo: {
projectName: 'XX品牌618推广',
scriptFileName: '夏日护肤推广_脚本v2.docx',
scriptFileSize: '245 KB',
},
}
// 状态配置
const statusConfig: Record<AppealStatus, { label: string; color: string; bgColor: string; icon: React.ElementType }> = {
pending: { label: '待处理', color: 'text-accent-amber', bgColor: 'bg-accent-amber/15', icon: Clock },
processing: { label: '处理中', color: 'text-accent-indigo', bgColor: 'bg-accent-indigo/15', icon: MessageSquare },
approved: { label: '已通过', color: 'text-accent-green', bgColor: 'bg-accent-green/15', icon: CheckCircle },
rejected: { label: '已驳回', color: 'text-accent-coral', bgColor: 'bg-accent-coral/15', icon: XCircle },
}
export default function AgencyAppealDetailPage() {
const router = useRouter()
const params = useParams()
const [appeal] = useState(mockAppealDetail)
const [replyContent, setReplyContent] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const status = statusConfig[appeal.status]
const StatusIcon = status.icon
const handleApprove = async () => {
if (!replyContent.trim()) {
alert('请填写处理意见')
return
}
setIsSubmitting(true)
// 模拟提交
await new Promise(resolve => setTimeout(resolve, 1000))
alert('申诉已通过')
router.push('/agency/appeals')
}
const handleReject = async () => {
if (!replyContent.trim()) {
alert('请填写驳回原因')
return
}
setIsSubmitting(true)
// 模拟提交
await new Promise(resolve => setTimeout(resolve, 1000))
alert('申诉已驳回')
router.push('/agency/appeals')
}
return (
<div className="space-y-6">
{/* 顶部导航 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<button
type="button"
onClick={() => router.back()}
className="p-2 rounded-lg hover:bg-bg-elevated transition-colors"
>
<ArrowLeft size={20} className="text-text-secondary" />
</button>
<div>
<h1 className="text-2xl font-bold text-text-primary"></h1>
<p className="text-sm text-text-secondary mt-0.5">: {appeal.id}</p>
</div>
</div>
<div className={`flex items-center gap-2 px-4 py-2 rounded-xl ${status.bgColor}`}>
<StatusIcon size={18} className={status.color} />
<span className={`font-medium ${status.color}`}>{status.label}</span>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* 左侧:申诉详情 */}
<div className="lg:col-span-2 space-y-6">
{/* 申诉人信息 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User size={18} className="text-accent-indigo" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-accent-indigo to-purple-500 flex items-center justify-center text-white font-bold text-lg">
{appeal.creatorAvatar}
</div>
<div>
<p className="font-medium text-text-primary">{appeal.creatorName}</p>
<p className="text-sm text-text-secondary">ID: {appeal.creatorId}</p>
</div>
</div>
<div className="mt-4 pt-4 border-t border-border-subtle grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-text-tertiary"></span>
<p className="text-text-primary mt-1">{appeal.taskTitle}</p>
</div>
<div>
<span className="text-text-tertiary"></span>
<p className="text-text-primary mt-1">{appeal.taskInfo.projectName}</p>
</div>
<div>
<span className="text-text-tertiary"></span>
<p className="text-text-primary mt-1 flex items-center gap-1">
{appeal.contentType === 'script' ? <FileText size={14} /> : <Video size={14} />}
{appeal.contentType === 'script' ? '脚本' : '视频'}
</p>
</div>
<div>
<span className="text-text-tertiary"></span>
<p className="text-text-primary mt-1">{appeal.createdAt}</p>
</div>
</div>
</CardContent>
</Card>
{/* 原审核问题 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertTriangle size={18} className="text-accent-coral" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="p-4 rounded-xl bg-accent-coral/10 border border-accent-coral/20">
<div className="flex items-center gap-2 mb-2">
<span className="px-2 py-0.5 text-xs rounded bg-accent-coral/20 text-accent-coral">
{appeal.originalIssue.type === 'ai' ? 'AI检测' : '人工审核'}
</span>
<span className="font-medium text-text-primary">{appeal.originalIssue.title}</span>
</div>
<p className="text-sm text-text-secondary">{appeal.originalIssue.description}</p>
<p className="text-xs text-text-tertiary mt-2">: {appeal.originalIssue.location}</p>
</div>
</CardContent>
</Card>
{/* 申诉内容 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MessageSquare size={18} className="text-accent-indigo" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div>
<span className="text-sm text-text-tertiary"></span>
<p className="text-text-primary mt-1 font-medium">{appeal.reason}</p>
</div>
<div>
<span className="text-sm text-text-tertiary"></span>
<p className="text-text-primary mt-1 leading-relaxed">{appeal.content}</p>
</div>
{/* 附件 */}
{appeal.attachments.length > 0 && (
<div>
<span className="text-sm text-text-tertiary"></span>
<div className="mt-2 space-y-2">
{appeal.attachments.map((att) => (
<div
key={att.id}
className="flex items-center gap-3 p-3 rounded-lg bg-bg-elevated"
>
<div className="w-10 h-10 rounded-lg bg-accent-indigo/15 flex items-center justify-center">
{att.type === 'image' ? (
<ImageIcon size={20} className="text-accent-indigo" />
) : (
<File size={20} className="text-accent-indigo" />
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{att.name}</p>
<p className="text-xs text-text-tertiary">{att.size}</p>
</div>
<button
type="button"
className="p-2 rounded-lg hover:bg-bg-page transition-colors"
title="下载"
>
<Download size={16} className="text-text-secondary" />
</button>
</div>
))}
</div>
</div>
)}
</div>
</CardContent>
</Card>
</div>
{/* 右侧:处理面板 */}
<div className="space-y-6">
{/* 相关文件 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText size={18} className="text-accent-indigo" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-3 p-3 rounded-lg bg-bg-elevated">
<div className="w-10 h-10 rounded-lg bg-accent-indigo/15 flex items-center justify-center">
<File size={20} className="text-accent-indigo" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">
{appeal.taskInfo.scriptFileName}
</p>
<p className="text-xs text-text-tertiary">{appeal.taskInfo.scriptFileSize}</p>
</div>
<button
type="button"
className="p-2 rounded-lg hover:bg-bg-page transition-colors"
title="下载"
>
<Download size={16} className="text-text-secondary" />
</button>
</div>
</CardContent>
</Card>
{/* 处理决策 */}
{appeal.status === 'pending' || appeal.status === 'processing' ? (
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<label className="text-sm text-text-secondary mb-2 block"></label>
<textarea
value={replyContent}
onChange={(e) => setReplyContent(e.target.value)}
placeholder="请输入处理意见或驳回原因..."
className="w-full h-32 p-3 rounded-xl bg-bg-elevated border border-border-subtle text-text-primary placeholder-text-tertiary resize-none focus:outline-none focus:ring-2 focus:ring-accent-indigo"
/>
</div>
<div className="flex gap-3">
<Button
variant="primary"
className="flex-1 bg-accent-green hover:bg-accent-green/80"
onClick={handleApprove}
disabled={isSubmitting}
>
<CheckCircle size={16} />
</Button>
<Button
variant="secondary"
className="flex-1 border-accent-coral text-accent-coral hover:bg-accent-coral/10"
onClick={handleReject}
disabled={isSubmitting}
>
<XCircle size={16} />
</Button>
</div>
<p className="text-xs text-text-tertiary text-center">
</p>
</CardContent>
</Card>
) : (
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className={`p-4 rounded-xl ${status.bgColor}`}>
<div className="flex items-center gap-2 mb-2">
<StatusIcon size={18} className={status.color} />
<span className={`font-medium ${status.color}`}>
{appeal.status === 'approved' ? '申诉已通过' : '申诉已驳回'}
</span>
</div>
<p className="text-sm text-text-secondary">
{appeal.status === 'approved'
? '经核实,达人申诉理由成立,已撤销原审核问题。'
: '经核实,原审核问题有效,申诉不成立。'}
</p>
</div>
</CardContent>
</Card>
)}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,280 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import {
MessageSquare,
Clock,
CheckCircle,
XCircle,
AlertTriangle,
Search,
Filter,
ChevronRight,
User,
FileText,
Video
} from 'lucide-react'
// 申诉状态类型
type AppealStatus = 'pending' | 'processing' | 'approved' | 'rejected'
// 申诉类型
type AppealType = 'ai' | 'agency'
// 申诉数据类型
interface Appeal {
id: string
taskId: string
taskTitle: string
creatorId: string
creatorName: string
type: AppealType
contentType: 'script' | 'video'
reason: string
content: string
status: AppealStatus
createdAt: string
updatedAt?: string
}
// 模拟申诉数据
const mockAppeals: Appeal[] = [
{
id: 'appeal-001',
taskId: 'task-001',
taskTitle: '夏日护肤推广脚本',
creatorId: 'creator-001',
creatorName: '小美护肤',
type: 'ai',
contentType: 'script',
reason: 'AI误判',
content: '脚本中提到的"某品牌"是泛指,并非特指竞品,请重新审核。',
status: 'pending',
createdAt: '2026-02-06 10:30',
},
{
id: 'appeal-002',
taskId: 'task-002',
taskTitle: '新品口红试色',
creatorId: 'creator-002',
creatorName: '美妆Lisa',
type: 'agency',
contentType: 'video',
reason: '审核标准不清晰',
content: '视频中的背景音乐已获得授权,附上授权证明。代理商反馈的"版权问题"不成立。',
status: 'processing',
createdAt: '2026-02-05 14:20',
},
{
id: 'appeal-003',
taskId: 'task-003',
taskTitle: '健身器材推荐',
creatorId: 'creator-003',
creatorName: '健身教练王',
type: 'ai',
contentType: 'script',
reason: '违禁词误判',
content: 'AI标记的"最好"是在描述个人体验,并非绝对化用语,符合广告法要求。',
status: 'approved',
createdAt: '2026-02-04 09:15',
updatedAt: '2026-02-04 16:30',
},
{
id: 'appeal-004',
taskId: 'task-004',
taskTitle: '美妆新品测评',
creatorId: 'creator-004',
creatorName: '达人小红',
type: 'agency',
contentType: 'video',
reason: '品牌调性理解差异',
content: '代理商认为风格不符但Brief中未明确禁止该风格请提供更具体的标准。',
status: 'rejected',
createdAt: '2026-02-03 11:00',
updatedAt: '2026-02-03 18:45',
},
]
// 状态配置
const statusConfig: Record<AppealStatus, { label: string; color: string; bgColor: string; icon: React.ElementType }> = {
pending: { label: '待处理', color: 'text-accent-amber', bgColor: 'bg-accent-amber/15', icon: Clock },
processing: { label: '处理中', color: 'text-accent-indigo', bgColor: 'bg-accent-indigo/15', icon: MessageSquare },
approved: { label: '已通过', color: 'text-accent-green', bgColor: 'bg-accent-green/15', icon: CheckCircle },
rejected: { label: '已驳回', color: 'text-accent-coral', bgColor: 'bg-accent-coral/15', icon: XCircle },
}
// 类型配置
const typeConfig: Record<AppealType, { label: string; color: string }> = {
ai: { label: 'AI审核申诉', color: 'text-accent-indigo' },
agency: { label: '代理商审核申诉', color: 'text-purple-400' },
}
function AppealCard({ appeal }: { appeal: Appeal }) {
const status = statusConfig[appeal.status]
const type = typeConfig[appeal.type]
const StatusIcon = status.icon
return (
<Link href={`/agency/appeals/${appeal.id}`}>
<div className="p-4 rounded-xl bg-bg-elevated hover:bg-bg-elevated/80 transition-colors cursor-pointer">
{/* 顶部:状态和类型 */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div className={`w-8 h-8 rounded-lg ${status.bgColor} flex items-center justify-center`}>
<StatusIcon size={16} className={status.color} />
</div>
<div>
<span className="font-medium text-text-primary">{appeal.taskTitle}</span>
<div className="flex items-center gap-2 text-xs text-text-tertiary">
<span className="flex items-center gap-1">
<User size={10} />
{appeal.creatorName}
</span>
<span>·</span>
<span className="flex items-center gap-1">
{appeal.contentType === 'script' ? <FileText size={10} /> : <Video size={10} />}
{appeal.contentType === 'script' ? '脚本' : '视频'}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${status.bgColor} ${status.color}`}>
{status.label}
</span>
<ChevronRight size={16} className="text-text-tertiary" />
</div>
</div>
{/* 申诉信息 */}
<div className="space-y-2 mb-3">
<div className="flex items-center gap-2 text-sm">
<span className="text-text-tertiary">:</span>
<span className={type.color}>{type.label}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<span className="text-text-tertiary">:</span>
<span className="text-text-primary">{appeal.reason}</span>
</div>
<p className="text-sm text-text-secondary line-clamp-2">{appeal.content}</p>
</div>
{/* 底部时间 */}
<div className="flex items-center justify-between text-xs text-text-tertiary pt-3 border-t border-border-subtle">
<span>: {appeal.createdAt}</span>
{appeal.updatedAt && <span>: {appeal.updatedAt}</span>}
</div>
</div>
</Link>
)
}
export default function AgencyAppealsPage() {
const [filter, setFilter] = useState<AppealStatus | 'all'>('all')
const [searchQuery, setSearchQuery] = useState('')
// 统计
const pendingCount = mockAppeals.filter(a => a.status === 'pending').length
const processingCount = mockAppeals.filter(a => a.status === 'processing').length
// 筛选
const filteredAppeals = mockAppeals.filter(appeal => {
const matchesSearch = searchQuery === '' ||
appeal.taskTitle.toLowerCase().includes(searchQuery.toLowerCase()) ||
appeal.creatorName.toLowerCase().includes(searchQuery.toLowerCase()) ||
appeal.reason.toLowerCase().includes(searchQuery.toLowerCase())
const matchesFilter = filter === 'all' || appeal.status === filter
return matchesSearch && matchesFilter
})
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-2 text-sm">
<span className="px-3 py-1.5 bg-accent-amber/20 text-accent-amber rounded-lg font-medium">
{pendingCount}
</span>
<span className="px-3 py-1.5 bg-accent-indigo/20 text-accent-indigo rounded-lg font-medium">
{processingCount}
</span>
</div>
</div>
{/* 搜索和筛选 */}
<div className="flex items-center gap-4">
<div className="relative flex-1 max-w-md">
<Search size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-tertiary" />
<input
type="text"
placeholder="搜索任务名称或达人..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 border border-border-subtle rounded-xl bg-bg-elevated text-text-primary focus:outline-none focus:ring-2 focus:ring-accent-indigo"
/>
</div>
<div className="flex items-center gap-1 p-1 bg-bg-elevated rounded-lg">
{[
{ value: 'all', label: '全部' },
{ value: 'pending', label: '待处理' },
{ value: 'processing', label: '处理中' },
{ value: 'approved', label: '已通过' },
{ value: 'rejected', label: '已驳回' },
].map((tab) => (
<button
key={tab.value}
type="button"
onClick={() => setFilter(tab.value as AppealStatus | 'all')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
filter === tab.value ? 'bg-bg-card text-text-primary shadow-sm' : 'text-text-secondary hover:text-text-primary'
}`}
>
{tab.label}
</button>
))}
</div>
</div>
{/* 申诉列表 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MessageSquare size={18} className="text-accent-indigo" />
<span className="ml-auto text-sm font-normal text-text-secondary">
{filteredAppeals.length}
</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{filteredAppeals.length > 0 ? (
filteredAppeals.map((appeal) => (
<AppealCard key={appeal.id} appeal={appeal} />
))
) : (
<div className="text-center py-12 text-text-tertiary">
<MessageSquare size={48} className="mx-auto mb-4 opacity-50" />
<p>{searchQuery || filter !== 'all' ? '没有找到匹配的申诉' : '暂无申诉记录'}</p>
{(searchQuery || filter !== 'all') && (
<button
type="button"
onClick={() => { setSearchQuery(''); setFilter('all'); }}
className="mt-3 text-sm text-accent-indigo hover:underline"
>
</button>
)}
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@ -13,7 +13,10 @@ import {
Clock,
User,
AlertTriangle,
ChevronRight
ChevronRight,
Download,
Eye,
File
} from 'lucide-react'
// 模拟脚本待审列表
@ -21,30 +24,51 @@ const mockScriptTasks = [
{
id: 'script-001',
title: '夏日护肤推广脚本',
fileName: '夏日护肤推广_脚本v2.docx',
fileSize: '245 KB',
creatorName: '小美护肤',
projectName: 'XX品牌618推广',
aiScore: 88,
riskLevel: 'low' as const,
submittedAt: '2026-02-06 14:30',
hasHighRisk: false,
},
{
id: 'script-002',
title: '新品口红试色脚本',
fileName: '口红试色_脚本v1.docx',
fileSize: '312 KB',
creatorName: '美妆Lisa',
projectName: 'XX品牌618推广',
aiScore: 72,
riskLevel: 'medium' as const,
submittedAt: '2026-02-06 12:15',
hasHighRisk: true,
},
{
id: 'script-003',
title: '健身器材推荐脚本',
fileName: '健身器材_推荐脚本.pdf',
fileSize: '189 KB',
creatorName: '健身教练王',
projectName: 'XX运动品牌',
aiScore: 95,
riskLevel: 'low' as const,
submittedAt: '2026-02-06 10:00',
hasHighRisk: false,
},
{
id: 'script-004',
title: '618大促预热脚本',
fileName: '618预热_脚本final.docx',
fileSize: '278 KB',
creatorName: '达人D',
projectName: 'XX品牌618推广',
aiScore: 62,
riskLevel: 'high' as const,
submittedAt: '2026-02-06 09:00',
hasHighRisk: true,
},
]
// 模拟视频待审列表
@ -52,9 +76,12 @@ const mockVideoTasks = [
{
id: 'video-001',
title: '夏日护肤推广',
fileName: '夏日护肤_成片v2.mp4',
fileSize: '128 MB',
creatorName: '小美护肤',
projectName: 'XX品牌618推广',
aiScore: 85,
riskLevel: 'low' as const,
duration: '02:15',
submittedAt: '2026-02-06 15:00',
hasHighRisk: false,
@ -62,61 +89,175 @@ const mockVideoTasks = [
{
id: 'video-002',
title: '新品口红试色',
fileName: '口红试色_终版.mp4',
fileSize: '256 MB',
creatorName: '美妆Lisa',
projectName: 'XX品牌618推广',
aiScore: 68,
riskLevel: 'medium' as const,
duration: '03:42',
submittedAt: '2026-02-06 13:45',
hasHighRisk: true,
},
{
id: 'video-003',
title: '美妆新品体验',
fileName: '美妆体验_v3.mp4',
fileSize: '198 MB',
creatorName: '达人C',
projectName: 'XX品牌618推广',
aiScore: 58,
riskLevel: 'high' as const,
duration: '04:20',
submittedAt: '2026-02-06 11:30',
hasHighRisk: true,
},
{
id: 'video-004',
title: '618大促预热',
fileName: '618预热_final.mp4',
fileSize: '167 MB',
creatorName: '达人D',
projectName: 'XX品牌618推广',
aiScore: 91,
riskLevel: 'low' as const,
duration: '01:45',
submittedAt: '2026-02-06 10:15',
hasHighRisk: false,
},
]
// 风险等级配置
const riskLevelConfig = {
low: { label: 'AI通过', color: 'bg-accent-green', textColor: 'text-accent-green' },
medium: { label: '风险:中', color: 'bg-accent-amber', textColor: 'text-accent-amber' },
high: { label: '风险:高', color: 'bg-accent-coral', textColor: 'text-accent-coral' },
}
function ScoreTag({ score }: { score: number }) {
if (score >= 85) return <SuccessTag>{score}</SuccessTag>
if (score >= 70) return <WarningTag>{score}</WarningTag>
return <ErrorTag>{score}</ErrorTag>
}
function TaskCard({ task, type }: { task: typeof mockScriptTasks[0] | typeof mockVideoTasks[0]; type: 'script' | 'video' }) {
const href = type === 'script' ? `/agency/review/script/${task.id}` : `/agency/review/video/${task.id}`
type ScriptTask = typeof mockScriptTasks[0]
type VideoTask = typeof mockVideoTasks[0]
function ScriptTaskCard({ task }: { task: ScriptTask }) {
const riskConfig = riskLevelConfig[task.riskLevel]
const handleDownload = (e: React.MouseEvent) => {
e.stopPropagation()
// 模拟下载
console.log('下载脚本:', task.fileName)
}
return (
<Link href={href}>
<div className="p-4 rounded-lg border border-border-subtle hover:border-accent-indigo/50 hover:bg-accent-indigo/5 transition-all cursor-pointer">
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h4 className="font-medium text-text-primary truncate">{task.title}</h4>
{task.hasHighRisk && (
<span className="flex items-center gap-1 px-2 py-0.5 text-xs bg-accent-coral/20 text-accent-coral rounded">
<AlertTriangle size={12} />
</span>
)}
</div>
<div className="flex items-center gap-4 mt-1 text-sm text-text-secondary">
<span className="flex items-center gap-1">
<User size={12} />
{task.creatorName}
</span>
</div>
</div>
<ScoreTag score={task.aiScore} />
<div className="p-4 rounded-xl bg-bg-elevated">
{/* 顶部:达人名 · 任务名 + 状态标签 */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${riskConfig.color}`} />
<span className="font-medium text-text-primary">{task.creatorName} · {task.title}</span>
</div>
<div className="flex items-center justify-between text-xs text-text-tertiary">
<span>{task.projectName}</span>
<span className="flex items-center gap-1">
<Clock size={12} />
{task.submittedAt}
</span>
</div>
{'duration' in task && (
<div className="mt-2 text-xs text-text-tertiary">
: {task.duration}
</div>
)}
<span className={`text-xs ${riskConfig.textColor}`}>{riskConfig.label}</span>
</div>
</Link>
{/* 文件信息 */}
<div className="flex items-center gap-3 p-3 rounded-lg bg-bg-page mb-3">
<div className="w-10 h-10 rounded-lg bg-accent-indigo/15 flex items-center justify-center">
<File size={20} className="text-accent-indigo" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{task.fileName}</p>
<p className="text-xs text-text-tertiary">{task.fileSize}</p>
</div>
<button
type="button"
onClick={handleDownload}
className="p-2 rounded-lg hover:bg-bg-elevated transition-colors"
title="下载文件"
>
<Download size={18} className="text-text-secondary" />
</button>
</div>
{/* 底部:时间 + 审核按钮 */}
<div className="flex items-center justify-between">
<span className="text-xs text-text-tertiary flex items-center gap-1">
<Clock size={12} />
{task.submittedAt}
</span>
<Link href={`/agency/review/script/${task.id}`}>
<Button size="sm" className={`${
task.riskLevel === 'high' ? 'bg-accent-coral hover:bg-accent-coral/80' :
task.riskLevel === 'medium' ? 'bg-accent-amber hover:bg-accent-amber/80' :
'bg-accent-green hover:bg-accent-green/80'
} text-white`}>
</Button>
</Link>
</div>
</div>
)
}
function VideoTaskCard({ task }: { task: VideoTask }) {
const riskConfig = riskLevelConfig[task.riskLevel]
const handleDownload = (e: React.MouseEvent) => {
e.stopPropagation()
// 模拟下载
console.log('下载视频:', task.fileName)
}
return (
<div className="p-4 rounded-xl bg-bg-elevated">
{/* 顶部:达人名 · 任务名 + 状态标签 */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${riskConfig.color}`} />
<span className="font-medium text-text-primary">{task.creatorName} · {task.title}</span>
</div>
<span className={`text-xs ${riskConfig.textColor}`}>{riskConfig.label}</span>
</div>
{/* 文件信息 */}
<div className="flex items-center gap-3 p-3 rounded-lg bg-bg-page mb-3">
<div className="w-10 h-10 rounded-lg bg-purple-500/15 flex items-center justify-center">
<Video size={20} className="text-purple-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{task.fileName}</p>
<p className="text-xs text-text-tertiary">{task.fileSize} · {task.duration}</p>
</div>
<button
type="button"
onClick={handleDownload}
className="p-2 rounded-lg hover:bg-bg-elevated transition-colors"
title="下载文件"
>
<Download size={18} className="text-text-secondary" />
</button>
</div>
{/* 底部:时间 + 审核按钮 */}
<div className="flex items-center justify-between">
<span className="text-xs text-text-tertiary flex items-center gap-1">
<Clock size={12} />
{task.submittedAt}
</span>
<Link href={`/agency/review/video/${task.id}`}>
<Button size="sm" className={`${
task.riskLevel === 'high' ? 'bg-accent-coral hover:bg-accent-coral/80' :
task.riskLevel === 'medium' ? 'bg-accent-amber hover:bg-accent-amber/80' :
'bg-accent-green hover:bg-accent-green/80'
} text-white`}>
</Button>
</Link>
</div>
</div>
)
}
@ -205,15 +346,15 @@ export default function AgencyReviewListPage() {
<CardTitle className="flex items-center gap-2">
<FileText size={18} className="text-accent-indigo" />
<span className="ml-auto text-sm font-normal text-text-secondary">
{filteredScripts.length}
<span className="ml-auto text-sm font-normal text-accent-indigo">
{filteredScripts.length}
</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{filteredScripts.length > 0 ? (
filteredScripts.map((task) => (
<TaskCard key={task.id} task={task} type="script" />
<ScriptTaskCard key={task.id} task={task} />
))
) : (
<div className="text-center py-8 text-text-tertiary">
@ -232,15 +373,15 @@ export default function AgencyReviewListPage() {
<CardTitle className="flex items-center gap-2">
<Video size={18} className="text-purple-400" />
<span className="ml-auto text-sm font-normal text-text-secondary">
{filteredVideos.length}
<span className="ml-auto text-sm font-normal text-accent-indigo">
{filteredVideos.length}
</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{filteredVideos.length > 0 ? (
filteredVideos.map((task) => (
<TaskCard key={task.id} task={task} type="video" />
<VideoTaskCard key={task.id} task={task} />
))
) : (
<div className="text-center py-8 text-text-tertiary">

View File

@ -16,7 +16,8 @@ import {
FolderKanban,
PlusCircle,
ClipboardCheck,
Bot
Bot,
MessageSquare
} from 'lucide-react'
import { cn } from '@/lib/utils'
@ -37,6 +38,7 @@ const creatorNavItems: NavItem[] = [
const agencyNavItems: NavItem[] = [
{ icon: LayoutDashboard, label: '工作台', href: '/agency' },
{ icon: Scan, label: '审核台', href: '/agency/review' },
{ icon: MessageSquare, label: '申诉处理', href: '/agency/appeals' },
{ icon: FileText, label: 'Brief 配置', href: '/agency/briefs' },
{ icon: Users, label: '达人管理', href: '/agency/creators' },
{ icon: BarChart3, label: '数据报表', href: '/agency/reports' },