Your Name 37ac749071 fix: 修复前端代码质量问题
- 创建 Toast 通知组件,替换所有 alert() 调用
- 修复 useReview hook 内存泄漏(setInterval 清理)
- 移除所有 console.error 和 console.log 语句
- 为复制操作失败添加用户友好的 toast 提示

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-09 12:48:22 +08:00

597 lines
22 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 Link from 'next/link'
import { useToast } from '@/components/ui/Toast'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { SuccessTag, PendingTag, WarningTag, ErrorTag } from '@/components/ui/Tag'
import {
FileText,
Video,
Search,
Filter,
Clock,
User,
AlertTriangle,
ChevronRight,
Download,
Eye,
File,
MessageSquareWarning
} from 'lucide-react'
import { Modal } from '@/components/ui/Modal'
import { getPlatformInfo } from '@/lib/platforms'
// 模拟脚本待审列表
const mockScriptTasks = [
{
id: 'script-001',
title: '夏日护肤推广脚本',
fileName: '夏日护肤推广_脚本v2.docx',
fileSize: '245 KB',
creatorName: '小美护肤',
projectName: 'XX品牌618推广',
platform: 'douyin',
aiScore: 88,
riskLevel: 'low' as const,
submittedAt: '2026-02-06 14:30',
hasHighRisk: false,
isAppeal: false, // 是否为申诉
},
{
id: 'script-002',
title: '新品口红试色脚本',
fileName: '口红试色_脚本v1.docx',
fileSize: '312 KB',
creatorName: '美妆Lisa',
projectName: 'XX品牌618推广',
platform: 'xiaohongshu',
aiScore: 72,
riskLevel: 'medium' as const,
submittedAt: '2026-02-06 12:15',
hasHighRisk: true,
isAppeal: true, // 申诉重审
appealReason: '已修改违规用词,请求重新审核',
},
{
id: 'script-003',
title: '健身器材推荐脚本',
fileName: '健身器材_推荐脚本.pdf',
fileSize: '189 KB',
creatorName: '健身教练王',
projectName: 'XX运动品牌',
platform: 'bilibili',
aiScore: 95,
riskLevel: 'low' as const,
submittedAt: '2026-02-06 10:00',
hasHighRisk: false,
isAppeal: false,
},
{
id: 'script-004',
title: '618大促预热脚本',
fileName: '618预热_脚本final.docx',
fileSize: '278 KB',
creatorName: '达人D',
projectName: 'XX品牌618推广',
platform: 'kuaishou',
aiScore: 62,
riskLevel: 'high' as const,
submittedAt: '2026-02-06 09:00',
hasHighRisk: true,
isAppeal: true,
appealReason: '对驳回原因有异议,内容符合要求',
},
]
// 模拟视频待审列表
const mockVideoTasks = [
{
id: 'video-001',
title: '夏日护肤推广',
fileName: '夏日护肤_成片v2.mp4',
fileSize: '128 MB',
creatorName: '小美护肤',
projectName: 'XX品牌618推广',
platform: 'douyin',
aiScore: 85,
riskLevel: 'low' as const,
duration: '02:15',
submittedAt: '2026-02-06 15:00',
hasHighRisk: false,
isAppeal: false,
},
{
id: 'video-002',
title: '新品口红试色',
fileName: '口红试色_终版.mp4',
fileSize: '256 MB',
creatorName: '美妆Lisa',
projectName: 'XX品牌618推广',
platform: 'xiaohongshu',
aiScore: 68,
riskLevel: 'medium' as const,
duration: '03:42',
submittedAt: '2026-02-06 13:45',
hasHighRisk: true,
isAppeal: true,
appealReason: '已按要求重新剪辑,删除了争议片段',
},
{
id: 'video-003',
title: '美妆新品体验',
fileName: '美妆体验_v3.mp4',
fileSize: '198 MB',
creatorName: '达人C',
projectName: 'XX品牌618推广',
platform: 'bilibili',
aiScore: 58,
riskLevel: 'high' as const,
duration: '04:20',
submittedAt: '2026-02-06 11:30',
hasHighRisk: true,
isAppeal: false,
},
{
id: 'video-004',
title: '618大促预热',
fileName: '618预热_final.mp4',
fileSize: '167 MB',
creatorName: '达人D',
projectName: 'XX品牌618推广',
platform: 'wechat',
aiScore: 91,
riskLevel: 'low' as const,
duration: '01:45',
submittedAt: '2026-02-06 10:15',
hasHighRisk: false,
isAppeal: 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>
}
type ScriptTask = typeof mockScriptTasks[0]
type VideoTask = typeof mockVideoTasks[0]
function ScriptTaskCard({ task, onPreview, toast }: { task: ScriptTask; onPreview: (task: ScriptTask) => void; toast: ReturnType<typeof useToast> }) {
const riskConfig = riskLevelConfig[task.riskLevel]
const platform = getPlatformInfo(task.platform)
const handleDownload = (e: React.MouseEvent) => {
e.stopPropagation()
toast.info(`下载文件: ${task.fileName}`)
}
const handlePreview = (e: React.MouseEvent) => {
e.stopPropagation()
onPreview(task)
}
return (
<div className="rounded-xl bg-bg-elevated overflow-hidden">
{/* 平台顶部条 */}
{platform && (
<div className={`px-4 py-1.5 ${platform.bgColor} border-b ${platform.borderColor} flex items-center gap-1.5`}>
<span className="text-sm">{platform.icon}</span>
<span className={`text-xs font-medium ${platform.textColor}`}>{platform.name}</span>
{/* 申诉标识 */}
{task.isAppeal && (
<span className="ml-auto flex items-center gap-1 px-2 py-0.5 text-xs bg-accent-amber/30 text-accent-amber rounded-full font-medium">
<MessageSquareWarning size={12} />
</span>
)}
</div>
)}
<div className="p-4">
{/* 顶部:达人名 · 任务名 + 状态标签 */}
<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>
{/* 申诉理由 */}
{task.isAppeal && task.appealReason && (
<div className="mb-3 p-2.5 rounded-lg bg-accent-amber/10 border border-accent-amber/30">
<p className="text-xs text-accent-amber font-medium mb-1"></p>
<p className="text-sm text-text-secondary">{task.appealReason}</p>
</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-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={handlePreview}
className="p-2 rounded-lg hover:bg-bg-elevated transition-colors"
title="预览文件"
>
<Eye size={18} className="text-text-secondary" />
</button>
<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`}>
{task.isAppeal ? '审核申诉' : '审核'}
</Button>
</Link>
</div>
</div>
</div>
)
}
function VideoTaskCard({ task, onPreview, toast }: { task: VideoTask; onPreview: (task: VideoTask) => void; toast: ReturnType<typeof useToast> }) {
const riskConfig = riskLevelConfig[task.riskLevel]
const platform = getPlatformInfo(task.platform)
const handleDownload = (e: React.MouseEvent) => {
e.stopPropagation()
toast.info(`下载文件: ${task.fileName}`)
}
const handlePreview = (e: React.MouseEvent) => {
e.stopPropagation()
onPreview(task)
}
return (
<div className="rounded-xl bg-bg-elevated overflow-hidden">
{/* 平台顶部条 */}
{platform && (
<div className={`px-4 py-1.5 ${platform.bgColor} border-b ${platform.borderColor} flex items-center gap-1.5`}>
<span className="text-sm">{platform.icon}</span>
<span className={`text-xs font-medium ${platform.textColor}`}>{platform.name}</span>
{/* 申诉标识 */}
{task.isAppeal && (
<span className="ml-auto flex items-center gap-1 px-2 py-0.5 text-xs bg-accent-amber/30 text-accent-amber rounded-full font-medium">
<MessageSquareWarning size={12} />
</span>
)}
</div>
)}
<div className="p-4">
{/* 顶部:达人名 · 任务名 + 状态标签 */}
<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>
{/* 申诉理由 */}
{task.isAppeal && task.appealReason && (
<div className="mb-3 p-2.5 rounded-lg bg-accent-amber/10 border border-accent-amber/30">
<p className="text-xs text-accent-amber font-medium mb-1"></p>
<p className="text-sm text-text-secondary">{task.appealReason}</p>
</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={handlePreview}
className="p-2 rounded-lg hover:bg-bg-elevated transition-colors"
title="预览视频"
>
<Eye size={18} className="text-text-secondary" />
</button>
<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`}>
{task.isAppeal ? '审核申诉' : '审核'}
</Button>
</Link>
</div>
</div>
</div>
)
}
export default function AgencyReviewListPage() {
const [searchQuery, setSearchQuery] = useState('')
const [activeTab, setActiveTab] = useState<'all' | 'script' | 'video'>('all')
const [previewScript, setPreviewScript] = useState<ScriptTask | null>(null)
const [previewVideo, setPreviewVideo] = useState<VideoTask | null>(null)
const toast = useToast()
const filteredScripts = mockScriptTasks.filter(task =>
task.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
task.creatorName.toLowerCase().includes(searchQuery.toLowerCase())
)
const filteredVideos = mockVideoTasks.filter(task =>
task.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
task.creatorName.toLowerCase().includes(searchQuery.toLowerCase())
)
// 计算申诉数量
const appealScriptCount = mockScriptTasks.filter(t => t.isAppeal).length
const appealVideoCount = mockVideoTasks.filter(t => t.isAppeal).length
return (
<div className="space-y-6 min-h-0">
{/* 页面标题 */}
<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="text-text-secondary"></span>
<span className="px-2 py-1 bg-accent-indigo/20 text-accent-indigo rounded font-medium">
{mockScriptTasks.length}
</span>
<span className="px-2 py-1 bg-purple-500/20 text-purple-400 rounded font-medium">
{mockVideoTasks.length}
</span>
{(appealScriptCount + appealVideoCount) > 0 && (
<span className="px-2 py-1 bg-accent-amber/20 text-accent-amber rounded font-medium flex items-center gap-1">
<MessageSquareWarning size={14} />
{appealScriptCount + appealVideoCount}
</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 border border-border-subtle rounded-lg 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">
<button
type="button"
onClick={() => setActiveTab('all')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
activeTab === 'all' ? 'bg-bg-card text-text-primary shadow-sm' : 'text-text-secondary hover:text-text-primary'
}`}
>
</button>
<button
type="button"
onClick={() => setActiveTab('script')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
activeTab === 'script' ? 'bg-bg-card text-text-primary shadow-sm' : 'text-text-secondary hover:text-text-primary'
}`}
>
</button>
<button
type="button"
onClick={() => setActiveTab('video')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
activeTab === 'video' ? 'bg-bg-card text-text-primary shadow-sm' : 'text-text-secondary hover:text-text-primary'
}`}
>
</button>
</div>
</div>
{/* 任务列表 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* 脚本待审列表 */}
{(activeTab === 'all' || activeTab === 'script') && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText size={18} className="text-accent-indigo" />
<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) => (
<ScriptTaskCard key={task.id} task={task} onPreview={setPreviewScript} toast={toast} />
))
) : (
<div className="text-center py-8 text-text-tertiary">
<FileText size={32} className="mx-auto mb-2 opacity-50" />
<p></p>
</div>
)}
</CardContent>
</Card>
)}
{/* 视频待审列表 */}
{(activeTab === 'all' || activeTab === 'video') && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Video size={18} className="text-purple-400" />
<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) => (
<VideoTaskCard key={task.id} task={task} onPreview={setPreviewVideo} toast={toast} />
))
) : (
<div className="text-center py-8 text-text-tertiary">
<Video size={32} className="mx-auto mb-2 opacity-50" />
<p></p>
</div>
)}
</CardContent>
</Card>
)}
</div>
{/* 脚本预览弹窗 */}
<Modal
isOpen={!!previewScript}
onClose={() => setPreviewScript(null)}
title={previewScript?.fileName || '脚本预览'}
size="lg"
>
<div className="space-y-4">
{previewScript?.isAppeal && previewScript?.appealReason && (
<div className="p-3 rounded-lg bg-accent-amber/10 border border-accent-amber/30">
<p className="text-xs text-accent-amber font-medium mb-1 flex items-center gap-1">
<MessageSquareWarning size={12} />
</p>
<p className="text-sm text-text-secondary">{previewScript.appealReason}</p>
</div>
)}
<div className="aspect-[4/3] bg-bg-elevated rounded-lg flex items-center justify-center">
<div className="text-center">
<FileText className="w-12 h-12 mx-auto text-accent-indigo mb-4" />
<p className="text-text-secondary"></p>
<p className="text-xs text-text-tertiary mt-1"></p>
</div>
</div>
<div className="flex justify-between items-center">
<div className="text-sm text-text-secondary">
<span>{previewScript?.fileName}</span>
<span className="mx-2">·</span>
<span>{previewScript?.fileSize}</span>
</div>
<div className="flex gap-2">
<Button variant="secondary" onClick={() => setPreviewScript(null)}>
</Button>
<Button onClick={() => toast.info(`下载文件: ${previewScript?.fileName}`)}>
<Download size={16} />
</Button>
</div>
</div>
</div>
</Modal>
{/* 视频预览弹窗 */}
<Modal
isOpen={!!previewVideo}
onClose={() => setPreviewVideo(null)}
title={previewVideo?.fileName || '视频预览'}
size="lg"
>
<div className="space-y-4">
{previewVideo?.isAppeal && previewVideo?.appealReason && (
<div className="p-3 rounded-lg bg-accent-amber/10 border border-accent-amber/30">
<p className="text-xs text-accent-amber font-medium mb-1 flex items-center gap-1">
<MessageSquareWarning size={12} />
</p>
<p className="text-sm text-text-secondary">{previewVideo.appealReason}</p>
</div>
)}
<div className="aspect-video bg-bg-elevated rounded-lg flex items-center justify-center">
<div className="text-center">
<Video className="w-12 h-12 mx-auto text-purple-400 mb-4" />
<p className="text-text-secondary"></p>
<p className="text-xs text-text-tertiary mt-1"></p>
</div>
</div>
<div className="flex justify-between items-center">
<div className="text-sm text-text-secondary">
<span>{previewVideo?.fileName}</span>
<span className="mx-2">·</span>
<span>{previewVideo?.fileSize}</span>
<span className="mx-2">·</span>
<span>{previewVideo?.duration}</span>
</div>
<div className="flex gap-2">
<Button variant="secondary" onClick={() => setPreviewVideo(null)}>
</Button>
<Button onClick={() => toast.info(`下载文件: ${previewVideo?.fileName}`)}>
<Download size={16} />
</Button>
</div>
</div>
</div>
</Modal>
</div>
)
}