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

662 lines
24 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, useEffect } from 'react'
import { useRouter, useParams, useSearchParams } from 'next/navigation'
import { useToast } from '@/components/ui/Toast'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { SuccessTag, WarningTag, ErrorTag, PendingTag } from '@/components/ui/Tag'
import { ReviewSteps, getReviewSteps } from '@/components/ui/ReviewSteps'
import {
ArrowLeft,
Upload,
FileText,
CheckCircle,
XCircle,
AlertTriangle,
Clock,
Loader2,
RefreshCw,
Eye,
MessageSquare,
Download,
File,
Target,
Ban,
ChevronDown,
ChevronUp
} from 'lucide-react'
import { Modal } from '@/components/ui/Modal'
// 代理商Brief文档达人可查看
type AgencyBriefFile = {
id: string
name: string
size: string
uploadedAt: string
description?: string
}
const mockAgencyBrief = {
// 代理商上传的Brief文档
files: [
{ id: 'af1', name: '达人拍摄指南.pdf', size: '1.5MB', uploadedAt: '2026-02-02', description: '详细的拍摄流程和注意事项' },
{ id: 'af2', name: '产品卖点话术.docx', size: '800KB', uploadedAt: '2026-02-02', description: '推荐使用的话术和表达方式' },
{ id: 'af3', name: '品牌视觉参考.pdf', size: '3.2MB', uploadedAt: '2026-02-02', description: '视觉风格和拍摄参考示例' },
] as AgencyBriefFile[],
// 卖点要求
sellingPoints: [
{ id: 'sp1', content: 'SPF50+ PA++++', required: true },
{ id: 'sp2', content: '轻薄质地,不油腻', required: true },
{ id: 'sp3', content: '延展性好,易推开', required: false },
{ id: 'sp4', content: '适合敏感肌', required: false },
{ id: 'sp5', content: '夏日必备防晒', required: true },
],
// 违禁词
blacklistWords: [
{ id: 'bw1', word: '最好', reason: '绝对化用语' },
{ id: 'bw2', word: '第一', reason: '绝对化用语' },
{ id: 'bw3', word: '神器', reason: '夸大宣传' },
{ id: 'bw4', word: '完美', reason: '绝对化用语' },
],
}
// 模拟任务数据
const mockTask = {
id: 'task-001',
projectName: 'XX品牌618推广',
brandName: 'XX护肤品牌',
deadline: '2026-06-18',
scriptStatus: 'pending_upload', // pending_upload | ai_reviewing | ai_result | agent_reviewing | agent_rejected | brand_reviewing | brand_passed | brand_rejected
scriptFile: null as string | null,
aiResult: null as null | {
score: number
violations: Array<{ type: string; content: string; suggestion: string }>
complianceChecks: Array<{ item: string; passed: boolean; note?: string }>
},
agencyReview: null as null | {
result: 'approved' | 'rejected'
comment: string
reviewer: string
time: string
},
brandReview: null as null | {
result: 'approved' | 'rejected'
comment: string
reviewer: string
time: string
},
}
// 根据状态获取模拟数据
function getTaskByStatus(status: string) {
const task = { ...mockTask, scriptStatus: status }
if (status === 'ai_result' || status === 'agent_reviewing' || status === 'agent_rejected' || status === 'brand_reviewing' || status === 'brand_passed' || status === 'brand_rejected') {
task.scriptFile = '夏日护肤推广脚本.docx'
task.aiResult = {
score: 85,
violations: [
{ type: '违禁词', content: '神器', suggestion: '建议替换为"好物"或"必备品"' },
],
complianceChecks: [
{ item: '品牌名称正确', passed: true },
{ item: 'SPF标注准确', passed: true },
{ item: '无绝对化用语', passed: false, note: '"超级好用"建议修改' },
],
}
}
if (status === 'agent_rejected') {
task.agencyReview = {
result: 'rejected',
comment: '违禁词未修改,请修改后重新提交。',
reviewer: '张经理',
time: '2026-02-06 15:30',
}
}
if (status === 'brand_reviewing' || status === 'brand_passed' || status === 'brand_rejected') {
task.agencyReview = {
result: 'approved',
comment: '脚本符合要求,建议通过。',
reviewer: '张经理',
time: '2026-02-06 15:30',
}
}
if (status === 'brand_passed') {
task.brandReview = {
result: 'approved',
comment: '脚本通过终审,可以开始拍摄视频。',
reviewer: '品牌方审核员',
time: '2026-02-06 18:00',
}
}
if (status === 'brand_rejected') {
task.brandReview = {
result: 'rejected',
comment: '产品卖点覆盖不完整,请补充后重新提交。',
reviewer: '品牌方审核员',
time: '2026-02-06 18:00',
}
}
return task
}
// 代理商Brief文档查看组件
function AgencyBriefSection({ toast }: { toast: ReturnType<typeof useToast> }) {
const [isExpanded, setIsExpanded] = useState(true)
const [previewFile, setPreviewFile] = useState<AgencyBriefFile | null>(null)
const handleDownload = (file: AgencyBriefFile) => {
toast.info(`下载文件: ${file.name}`)
}
const handlePreview = (file: AgencyBriefFile) => {
setPreviewFile(file)
}
const requiredPoints = mockAgencyBrief.sellingPoints.filter(sp => sp.required)
const optionalPoints = mockAgencyBrief.sellingPoints.filter(sp => !sp.required)
return (
<>
<Card className="border-accent-indigo/30">
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span className="flex items-center gap-2">
<File size={18} className="text-accent-indigo" />
Brief
</span>
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="p-1 hover:bg-bg-elevated rounded"
>
{isExpanded ? (
<ChevronUp size={18} className="text-text-tertiary" />
) : (
<ChevronDown size={18} className="text-text-tertiary" />
)}
</button>
</CardTitle>
</CardHeader>
{isExpanded && (
<CardContent className="space-y-4">
{/* Brief文档列表 */}
<div>
<h4 className="text-sm font-medium text-text-primary mb-2 flex items-center gap-2">
<FileText size={14} className="text-accent-indigo" />
</h4>
<div className="space-y-2">
{mockAgencyBrief.files.map((file) => (
<div key={file.id} className="flex items-center justify-between p-3 bg-bg-elevated rounded-lg">
<div className="flex items-center gap-3 min-w-0">
<div className="w-8 h-8 rounded bg-accent-indigo/15 flex items-center justify-center flex-shrink-0">
<FileText size={16} className="text-accent-indigo" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{file.name}</p>
<p className="text-xs text-text-tertiary">{file.size}</p>
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<Button variant="ghost" size="sm" onClick={() => handlePreview(file)}>
<Eye size={14} />
</Button>
<Button variant="ghost" size="sm" onClick={() => handleDownload(file)}>
<Download size={14} />
</Button>
</div>
</div>
))}
</div>
</div>
{/* 卖点要求 */}
<div>
<h4 className="text-sm font-medium text-text-primary mb-2 flex items-center gap-2">
<Target size={14} className="text-accent-green" />
</h4>
<div className="space-y-2">
{requiredPoints.length > 0 && (
<div className="p-3 bg-accent-coral/10 rounded-lg border border-accent-coral/30">
<p className="text-xs text-accent-coral font-medium mb-2"></p>
<div className="flex flex-wrap gap-2">
{requiredPoints.map((sp) => (
<span key={sp.id} className="px-2 py-1 text-xs bg-accent-coral/20 text-accent-coral rounded">
{sp.content}
</span>
))}
</div>
</div>
)}
{optionalPoints.length > 0 && (
<div className="p-3 bg-bg-elevated rounded-lg">
<p className="text-xs text-text-tertiary font-medium mb-2"></p>
<div className="flex flex-wrap gap-2">
{optionalPoints.map((sp) => (
<span key={sp.id} className="px-2 py-1 text-xs bg-bg-page text-text-secondary rounded">
{sp.content}
</span>
))}
</div>
</div>
)}
</div>
</div>
{/* 违禁词 */}
<div>
<h4 className="text-sm font-medium text-text-primary mb-2 flex items-center gap-2">
<Ban size={14} className="text-accent-coral" />
使
</h4>
<div className="flex flex-wrap gap-2">
{mockAgencyBrief.blacklistWords.map((bw) => (
<span key={bw.id} className="px-2 py-1 text-xs bg-accent-coral/15 text-accent-coral rounded border border-accent-coral/30">
{bw.word}
</span>
))}
</div>
</div>
</CardContent>
)}
</Card>
{/* 文件预览弹窗 */}
<Modal
isOpen={!!previewFile}
onClose={() => setPreviewFile(null)}
title={previewFile?.name || '文件预览'}
size="lg"
>
<div className="space-y-4">
<div className="aspect-[4/3] bg-bg-elevated rounded-lg flex items-center justify-center">
<div className="text-center">
<FileText size={48} className="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-end gap-2">
<Button variant="secondary" onClick={() => setPreviewFile(null)}>
</Button>
{previewFile && (
<Button onClick={() => handleDownload(previewFile)}>
<Download size={16} />
</Button>
)}
</div>
</div>
</Modal>
</>
)
}
function UploadSection({ onUpload }: { onUpload: () => void }) {
const [file, setFile] = useState<File | null>(null)
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0]
if (selectedFile) {
setFile(selectedFile)
}
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Upload size={18} className="text-accent-indigo" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="border-2 border-dashed border-border-subtle rounded-lg p-8 text-center hover:border-accent-indigo/50 transition-colors">
{file ? (
<div className="flex items-center justify-center gap-3">
<FileText size={24} className="text-accent-indigo" />
<span className="text-text-primary">{file.name}</span>
<button
type="button"
onClick={() => setFile(null)}
className="p-1 hover:bg-bg-elevated rounded-full"
>
<XCircle size={16} className="text-text-tertiary" />
</button>
</div>
) : (
<label className="cursor-pointer">
<Upload size={32} className="mx-auto text-text-tertiary mb-3" />
<p className="text-text-secondary mb-1"></p>
<p className="text-xs text-text-tertiary"> WordPDFTXT </p>
<input
type="file"
accept=".doc,.docx,.pdf,.txt"
onChange={handleFileChange}
className="hidden"
/>
</label>
)}
</div>
<Button onClick={onUpload} disabled={!file} fullWidth>
</Button>
</CardContent>
</Card>
)
}
function AIReviewingSection() {
const [progress, setProgress] = useState(0)
const [logs, setLogs] = useState<string[]>(['开始解析脚本文件...'])
useEffect(() => {
const timer = setInterval(() => {
setProgress(prev => {
if (prev >= 100) {
clearInterval(timer)
return 100
}
return prev + 10
})
}, 500)
const logTimer = setTimeout(() => {
setLogs(prev => [...prev, '正在提取文本内容...'])
}, 1000)
const logTimer2 = setTimeout(() => {
setLogs(prev => [...prev, '正在进行违禁词检测...'])
}, 2000)
const logTimer3 = setTimeout(() => {
setLogs(prev => [...prev, '正在分析卖点覆盖...'])
}, 3000)
return () => {
clearInterval(timer)
clearTimeout(logTimer)
clearTimeout(logTimer2)
clearTimeout(logTimer3)
}
}, [])
return (
<Card>
<CardContent className="py-8 text-center">
<Loader2 size={48} className="mx-auto text-accent-indigo mb-4 animate-spin" />
<h3 className="text-lg font-medium text-text-primary mb-2">AI </h3>
<p className="text-text-secondary mb-4"> 1-2 </p>
<div className="w-full max-w-md mx-auto">
<div className="h-2 bg-bg-elevated rounded-full overflow-hidden mb-2">
<div className="h-full bg-accent-indigo transition-all" style={{ width: `${progress}%` }} />
</div>
<p className="text-sm text-text-tertiary">{progress}%</p>
</div>
<div className="mt-6 p-4 bg-bg-elevated rounded-lg text-left max-w-md mx-auto">
<p className="text-xs text-text-tertiary mb-2"></p>
{logs.map((log, idx) => (
<p key={idx} className="text-sm text-text-secondary">{log}</p>
))}
</div>
</CardContent>
</Card>
)
}
function AIResultSection({ task }: { task: ReturnType<typeof getTaskByStatus> }) {
if (!task.aiResult) return null
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span className="flex items-center gap-2">
<CheckCircle size={18} className="text-accent-green" />
AI
</span>
<span className={`text-xl font-bold ${task.aiResult.score >= 85 ? 'text-accent-green' : task.aiResult.score >= 70 ? 'text-yellow-400' : 'text-accent-coral'}`}>
{task.aiResult.score}
</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 违规检测 */}
{task.aiResult.violations.length > 0 && (
<div>
<h4 className="text-sm font-medium text-text-primary mb-2 flex items-center gap-2">
<AlertTriangle size={14} className="text-orange-500" />
({task.aiResult.violations.length})
</h4>
{task.aiResult.violations.map((v, idx) => (
<div key={idx} className="p-3 bg-orange-500/10 rounded-lg border border-orange-500/30 mb-2">
<div className="flex items-center gap-2 mb-1">
<WarningTag>{v.type}</WarningTag>
</div>
<p className="text-sm text-text-primary">{v.content}</p>
<p className="text-xs text-accent-indigo mt-1">{v.suggestion}</p>
</div>
))}
</div>
)}
{/* 合规检查 */}
<div>
<h4 className="text-sm font-medium text-text-primary mb-2"></h4>
<div className="space-y-2">
{task.aiResult.complianceChecks.map((check, idx) => (
<div key={idx} className="flex items-start gap-2 p-2 rounded-lg bg-bg-elevated">
{check.passed ? (
<CheckCircle size={16} className="text-accent-green flex-shrink-0 mt-0.5" />
) : (
<XCircle size={16} className="text-accent-coral flex-shrink-0 mt-0.5" />
)}
<div className="flex-1">
<span className="text-sm text-text-primary">{check.item}</span>
{check.note && <p className="text-xs text-text-tertiary mt-0.5">{check.note}</p>}
</div>
</div>
))}
</div>
</div>
</CardContent>
</Card>
)
}
function ReviewFeedbackSection({ review, type }: { review: NonNullable<typeof mockTask.agencyReview>; type: 'agency' | 'brand' }) {
const isApproved = review.result === 'approved'
const title = type === 'agency' ? '代理商审核意见' : '品牌方终审意见'
return (
<Card className={isApproved ? 'border-accent-green/30' : 'border-accent-coral/30'}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{isApproved ? (
<CheckCircle size={18} className="text-accent-green" />
) : (
<XCircle size={18} className="text-accent-coral" />
)}
{title}
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2 mb-2">
<span className="font-medium text-text-primary">{review.reviewer}</span>
{isApproved ? <SuccessTag></SuccessTag> : <ErrorTag></ErrorTag>}
</div>
<p className="text-text-secondary">{review.comment}</p>
<p className="text-xs text-text-tertiary mt-2">{review.time}</p>
</CardContent>
</Card>
)
}
function WaitingSection({ message }: { message: string }) {
return (
<Card>
<CardContent className="py-8 text-center">
<Clock size={48} className="mx-auto text-accent-indigo mb-4" />
<h3 className="text-lg font-medium text-text-primary mb-2">{message}</h3>
<p className="text-text-secondary"></p>
</CardContent>
</Card>
)
}
function SuccessSection({ onContinue }: { onContinue: () => void }) {
return (
<Card className="border-accent-green/30">
<CardContent className="py-8 text-center">
<CheckCircle size={48} className="mx-auto text-accent-green mb-4" />
<h3 className="text-lg font-medium text-text-primary mb-2"></h3>
<p className="text-text-secondary mb-6"></p>
<Button onClick={onContinue}>
</Button>
</CardContent>
</Card>
)
}
export default function CreatorScriptPage() {
const router = useRouter()
const params = useParams()
const searchParams = useSearchParams()
const toast = useToast()
const status = searchParams.get('status') || 'pending_upload'
const [task, setTask] = useState(getTaskByStatus(status))
// 模拟状态切换
const simulateUpload = () => {
setTask(getTaskByStatus('ai_reviewing'))
setTimeout(() => {
setTask(getTaskByStatus('ai_result'))
}, 4000)
}
const handleResubmit = () => {
setTask(getTaskByStatus('pending_upload'))
}
const handleContinueToVideo = () => {
router.push(`/creator/task/${params.id}/video`)
}
const getStatusDisplay = () => {
switch (task.scriptStatus) {
case 'pending_upload': return '待上传脚本'
case 'ai_reviewing': return 'AI 审核中'
case 'ai_result': return 'AI 审核完成'
case 'agent_reviewing': return '代理商审核中'
case 'agent_rejected': return '代理商驳回'
case 'brand_reviewing': return '品牌方终审中'
case 'brand_passed': return '审核通过'
case 'brand_rejected': return '品牌方驳回'
default: return '未知状态'
}
}
return (
<div className="space-y-6 max-w-2xl mx-auto">
{/* 顶部导航 */}
<div className="flex items-center gap-4">
<button type="button" onClick={() => router.back()} className="p-2 hover:bg-bg-elevated rounded-full">
<ArrowLeft size={20} className="text-text-primary" />
</button>
<div className="flex-1">
<h1 className="text-xl font-bold text-text-primary">{task.projectName}</h1>
<p className="text-sm text-text-secondary"> · {getStatusDisplay()}</p>
</div>
</div>
{/* 审核流程进度条 */}
<Card>
<CardContent className="py-4">
<ReviewSteps steps={getReviewSteps(task.scriptStatus)} />
</CardContent>
</Card>
{/* Brief文档与要求始终显示 */}
<AgencyBriefSection toast={toast} />
{/* 根据状态显示不同内容 */}
{task.scriptStatus === 'pending_upload' && (
<UploadSection onUpload={simulateUpload} />
)}
{task.scriptStatus === 'ai_reviewing' && (
<AIReviewingSection />
)}
{task.scriptStatus === 'ai_result' && (
<>
<AIResultSection task={task} />
<WaitingSection message="等待代理商审核" />
</>
)}
{task.scriptStatus === 'agent_reviewing' && (
<>
<AIResultSection task={task} />
<WaitingSection message="等待代理商审核" />
</>
)}
{task.scriptStatus === 'agent_rejected' && task.agencyReview && (
<>
<ReviewFeedbackSection review={task.agencyReview} type="agency" />
<AIResultSection task={task} />
<div className="flex gap-3">
<Button variant="secondary" onClick={handleResubmit} fullWidth>
<RefreshCw size={16} />
</Button>
</div>
</>
)}
{task.scriptStatus === 'brand_reviewing' && task.agencyReview && (
<>
<ReviewFeedbackSection review={task.agencyReview} type="agency" />
<AIResultSection task={task} />
<WaitingSection message="等待品牌方终审" />
</>
)}
{task.scriptStatus === 'brand_passed' && task.agencyReview && task.brandReview && (
<>
<SuccessSection onContinue={handleContinueToVideo} />
<ReviewFeedbackSection review={task.brandReview} type="brand" />
<ReviewFeedbackSection review={task.agencyReview} type="agency" />
<AIResultSection task={task} />
</>
)}
{task.scriptStatus === 'brand_rejected' && task.agencyReview && task.brandReview && (
<>
<ReviewFeedbackSection review={task.brandReview} type="brand" />
<ReviewFeedbackSection review={task.agencyReview} type="agency" />
<AIResultSection task={task} />
<div className="flex gap-3">
<Button variant="secondary" onClick={handleResubmit} fullWidth>
<RefreshCw size={16} />
</Button>
</div>
</>
)}
</div>
)
}